"use strict"; // ENTRY RENDERING ===================================================================================================== /* * // EXAMPLE USAGE // * * const entryRenderer = new Renderer(); * * const topLevelEntry = mydata[0]; * // prepare an array to hold the string we collect while recursing * const textStack = []; * * // recurse through the entry tree * entryRenderer.renderEntries(topLevelEntry, textStack); * * // render the final product by joining together all the collected strings * $("#myElement").html(toDisplay.join("")); */ globalThis.Renderer = function () { this.wrapperTag = "div"; this.baseUrl = ""; this.baseMediaUrls = {}; if (globalThis.DEPLOYED_IMG_ROOT) { this.baseMediaUrls["img"] = globalThis.DEPLOYED_IMG_ROOT; } this._lazyImages = false; this._subVariant = false; this._firstSection = true; this._isAddHandlers = true; this._headerIndex = 1; this._tagExportDict = null; this._roll20Ids = null; this._trackTitles = {enabled: false, titles: {}}; this._enumerateTitlesRel = {enabled: false, titles: {}}; this._isHeaderIndexIncludeTableCaptions = false; this._isHeaderIndexIncludeImageTitles = false; this._plugins = {}; this._fnPostProcess = null; this._extraSourceClasses = null; this._depthTracker = null; this._depthTrackerAdditionalProps = []; this._depthTrackerAdditionalPropsInherited = []; this._lastDepthTrackerInheritedProps = {}; this._isInternalLinksDisabled = false; this._isPartPageExpandCollapseDisabled = false; this._fnsGetStyleClasses = {}; /** * Enables/disables lazy-load image rendering. * @param bool true to enable, false to disable. */ this.setLazyImages = function (bool) { // hard-disable lazy loading if the Intersection API is unavailable (e.g. under iOS 12) if (typeof IntersectionObserver === "undefined") this._lazyImages = false; else this._lazyImages = !!bool; return this; }; /** * Set the tag used to group rendered elements * @param tag to use */ this.setWrapperTag = function (tag) { this.wrapperTag = tag; return this; }; /** * Set the base url for rendered links. * Usage: `renderer.setBaseUrl("https://www.example.com/")` (note the "http" prefix and "/" suffix) * @param url to use */ this.setBaseUrl = function (url) { this.baseUrl = url; return this; }; this.setBaseMediaUrl = function (mediaDir, url) { this.baseMediaUrls[mediaDir] = url; return this; }; this.getMediaUrl = function (mediaDir, path) { if (Renderer.get().baseMediaUrls[mediaDir]) return `${Renderer.get().baseMediaUrls[mediaDir]}${path}`; return `${Renderer.get().baseUrl}${mediaDir}/${path}`; }; /** * Other sections should be prefixed with a vertical divider * @param bool */ this.setFirstSection = function (bool) { this._firstSection = bool; return this; }; /** * Disable adding JS event handlers on elements. * @param bool */ this.setAddHandlers = function (bool) { this._isAddHandlers = bool; return this; }; /** * Add a post-processing function which acts on the final rendered strings from a root call. * @param fn */ this.setFnPostProcess = function (fn) { this._fnPostProcess = fn; return this; }; /** * Specify a list of extra classes to be added to those rendered on entries with sources. * @param arr */ this.setExtraSourceClasses = function (arr) { this._extraSourceClasses = arr; return this; }; // region Header index /** * Headers are ID'd using the attribute `data-title-index` using an incrementing int. This resets it to 1. */ this.resetHeaderIndex = function () { this._headerIndex = 1; this._trackTitles.titles = {}; this._enumerateTitlesRel.titles = {}; return this; }; this.getHeaderIndex = function () { return this._headerIndex; }; this.setHeaderIndexTableCaptions = function (bool) { this._isHeaderIndexIncludeTableCaptions = bool; return this; }; this.setHeaderIndexImageTitles = function (bool) { this._isHeaderIndexIncludeImageTitles = bool; return this; }; // endregion /** * Pass an object to have the renderer export lists of found @-tagged content during renders * * @param toObj the object to fill with exported data. Example results: * { * commoner_mm: {page: "bestiary.html", source: "MM", hash: "commoner_mm"}, * storm%20giant_mm: {page: "bestiary.html", source: "MM", hash: "storm%20giant_mm"}, * detect%20magic_phb: {page: "spells.html", source: "PHB", hash: "detect%20magic_phb"} * } * These results intentionally match those used for hover windows, so can use the same cache/loading paths */ this.doExportTags = function (toObj) { this._tagExportDict = toObj; return this; }; /** * Reset/disable tag export */ this.resetExportTags = function () { this._tagExportDict = null; return this; }; this.setRoll20Ids = function (roll20Ids) { this._roll20Ids = roll20Ids; return this; }; this.resetRoll20Ids = function () { this._roll20Ids = null; return this; }; /** Used by Foundry config. */ this.setInternalLinksDisabled = function (val) { this._isInternalLinksDisabled = !!val; return this; }; this.isInternalLinksDisabled = function () { return !!this._isInternalLinksDisabled; }; this.setPartPageExpandCollapseDisabled = function (val) { this._isPartPageExpandCollapseDisabled = !!val; return this; }; /** Bind function which apply exta CSS classes to entry/list renders. */ this.setFnGetStyleClasses = function (identifier, fn) { if (fn == null) { delete this._fnsGetStyleClasses[identifier]; return this; } this._fnsGetStyleClasses[identifier] = fn; return this; }; /** * If enabled, titles with the same name will be given numerical identifiers. * This identifier is stored in `data-title-relative-index` */ this.setEnumerateTitlesRel = function (bool) { this._enumerateTitlesRel.enabled = bool; return this; }; this._getEnumeratedTitleRel = function (name) { if (this._enumerateTitlesRel.enabled && name) { const clean = name.toLowerCase(); this._enumerateTitlesRel.titles[clean] = this._enumerateTitlesRel.titles[clean] || 0; return `data-title-relative-index="${this._enumerateTitlesRel.titles[clean]++}"`; } else return ""; }; this.setTrackTitles = function (bool) { this._trackTitles.enabled = bool; return this; }; this.getTrackedTitles = function () { return MiscUtil.copyFast(this._trackTitles.titles); }; this.getTrackedTitlesInverted = function ({isStripTags = false} = {}) { // `this._trackTitles.titles` is a map of `{[data-title-index]: ""}` // Invert it such that we have a map of `{"": ["data-title-index-0", ..., "data-title-index-n"]}` const trackedTitlesInverse = {}; Object.entries(this._trackTitles.titles || {}).forEach(([titleIx, titleName]) => { if (isStripTags) titleName = Renderer.stripTags(titleName); titleName = titleName.toLowerCase().trim(); (trackedTitlesInverse[titleName] = trackedTitlesInverse[titleName] || []).push(titleIx); }); return trackedTitlesInverse; }; this._handleTrackTitles = function (name, {isTable = false, isImage = false} = {}) { if (!this._trackTitles.enabled) return; if (isTable && !this._isHeaderIndexIncludeTableCaptions) return; if (isImage && !this._isHeaderIndexIncludeImageTitles) return; this._trackTitles.titles[this._headerIndex] = name; }; this._handleTrackDepth = function (entry, depth) { if (!entry.name || !this._depthTracker) return; this._lastDepthTrackerInheritedProps = MiscUtil.copyFast(this._lastDepthTrackerInheritedProps); if (entry.source) this._lastDepthTrackerInheritedProps.source = entry.source; if (this._depthTrackerAdditionalPropsInherited?.length) { this._depthTrackerAdditionalPropsInherited.forEach(prop => this._lastDepthTrackerInheritedProps[prop] = entry[prop] || this._lastDepthTrackerInheritedProps[prop]); } const additionalData = this._depthTrackerAdditionalProps.length ? this._depthTrackerAdditionalProps.mergeMap(it => ({[it]: entry[it]})) : {}; this._depthTracker.push({ ...this._lastDepthTrackerInheritedProps, ...additionalData, depth, name: entry.name, type: entry.type, ixHeader: this._headerIndex, source: this._lastDepthTrackerInheritedProps.source, data: entry.data, page: entry.page, alias: entry.alias, entry, }); }; // region Plugins this.addPlugin = function (pluginType, fnPlugin) { MiscUtil.getOrSet(this._plugins, pluginType, []).push(fnPlugin); }; this.removePlugin = function (pluginType, fnPlugin) { if (!fnPlugin) return; const ix = (MiscUtil.get(this._plugins, pluginType) || []).indexOf(fnPlugin); if (~ix) this._plugins[pluginType].splice(ix, 1); }; this.removePlugins = function (pluginType) { MiscUtil.delete(this._plugins, pluginType); }; this._getPlugins = function (pluginType) { return this._plugins[pluginType] || []; }; /** Run a function with the given plugin active. */ this.withPlugin = function ({pluginTypes, fnPlugin, fn}) { for (const pt of pluginTypes) this.addPlugin(pt, fnPlugin); try { return fn(this); } finally { for (const pt of pluginTypes) this.removePlugin(pt, fnPlugin); } }; /** Run an async function with the given plugin active. */ this.pWithPlugin = async function ({pluginTypes, fnPlugin, pFn}) { for (const pt of pluginTypes) this.addPlugin(pt, fnPlugin); try { const out = await pFn(this); return out; } finally { for (const pt of pluginTypes) this.removePlugin(pt, fnPlugin); } }; // endregion /** * Specify an array where the renderer will record rendered header depths. * Items added to the array are of the form: `{name: "Header Name", depth: 1, type: "entries", source: "PHB"}` * @param arr * @param additionalProps Additional data props which should be tracked per-entry. * @param additionalPropsInherited As per additionalProps, but if a parent entry has the prop, it should be passed * to its children. */ this.setDepthTracker = function (arr, {additionalProps, additionalPropsInherited} = {}) { this._depthTracker = arr; this._depthTrackerAdditionalProps = additionalProps || []; this._depthTrackerAdditionalPropsInherited = additionalPropsInherited || []; return this; }; this.getLineBreak = function () { return "
"; }; /** * Recursively walk down a tree of "entry" JSON items, adding to a stack of strings to be finally rendered to the * page. Note that this function does _not_ actually do the rendering, see the example code above for how to display * the result. * * @param entry An "entry" usually defined in JSON. A schema is available in tests/schema * @param textStack A reference to an array, which will hold all our strings as we recurse * @param [meta] Meta state. * @param [meta.depth] The current recursion depth. Optional; default 0, or -1 for type "section" entries. * @param [options] Render options. * @param [options.prefix] String to prefix rendered lines with. * @param [options.suffix] String to suffix rendered lines with. */ this.recursiveRender = function (entry, textStack, meta, options) { if (entry instanceof Array) { entry.forEach(nxt => this.recursiveRender(nxt, textStack, meta, options)); setTimeout(() => { throw new Error(`Array passed to renderer! The renderer only guarantees support for primitives and basic objects.`); }); return this; } // respect the API of the original, but set up for using string concatenations if (textStack.length === 0) textStack[0] = ""; else textStack.reverse(); // initialise meta meta = meta || {}; meta._typeStack = []; meta.depth = meta.depth == null ? 0 : meta.depth; this._recursiveRender(entry, textStack, meta, options); if (this._fnPostProcess) textStack[0] = this._fnPostProcess(textStack[0]); textStack.reverse(); return this; }; /** * Inner rendering code. Uses string concatenation instead of an array stack, for ~2x the speed. * @param entry As above. * @param textStack As above. * @param meta As above, with the addition of... * @param options * .prefix The (optional) prefix to be added to the textStack before whatever is added by the current call * .suffix The (optional) suffix to be added to the textStack after whatever is added by the current call * @private */ this._recursiveRender = function (entry, textStack, meta, options) { if (entry == null) return; // Avoid dying on nully entries if (!textStack) throw new Error("Missing stack!"); if (!meta) throw new Error("Missing metadata!"); if (entry.type === "section") meta.depth = -1; options = options || {}; meta._didRenderPrefix = false; meta._didRenderSuffix = false; if (typeof entry === "object") { // the root entry (e.g. "Rage" in barbarian "classFeatures") is assumed to be of type "entries" const type = entry.type == null || entry.type === "section" ? "entries" : entry.type; // For wrapped entries, simply recurse if (type === "wrapper") return this._recursiveRender(entry.wrapped, textStack, meta, options); meta._typeStack.push(type); switch (type) { // recursive case "entries": this._renderEntries(entry, textStack, meta, options); break; case "options": this._renderOptions(entry, textStack, meta, options); break; case "list": this._renderList(entry, textStack, meta, options); break; case "table": this._renderTable(entry, textStack, meta, options); break; case "tableGroup": this._renderTableGroup(entry, textStack, meta, options); break; case "inset": this._renderInset(entry, textStack, meta, options); break; case "insetReadaloud": this._renderInsetReadaloud(entry, textStack, meta, options); break; case "variant": this._renderVariant(entry, textStack, meta, options); break; case "variantInner": this._renderVariantInner(entry, textStack, meta, options); break; case "variantSub": this._renderVariantSub(entry, textStack, meta, options); break; case "spellcasting": this._renderSpellcasting(entry, textStack, meta, options); break; case "quote": this._renderQuote(entry, textStack, meta, options); break; case "optfeature": this._renderOptfeature(entry, textStack, meta, options); break; case "patron": this._renderPatron(entry, textStack, meta, options); break; // block case "abilityDc": this._renderAbilityDc(entry, textStack, meta, options); break; case "abilityAttackMod": this._renderAbilityAttackMod(entry, textStack, meta, options); break; case "abilityGeneric": this._renderAbilityGeneric(entry, textStack, meta, options); break; // inline case "inline": this._renderInline(entry, textStack, meta, options); break; case "inlineBlock": this._renderInlineBlock(entry, textStack, meta, options); break; case "bonus": this._renderBonus(entry, textStack, meta, options); break; case "bonusSpeed": this._renderBonusSpeed(entry, textStack, meta, options); break; case "dice": this._renderDice(entry, textStack, meta, options); break; case "link": this._renderLink(entry, textStack, meta, options); break; case "actions": this._renderActions(entry, textStack, meta, options); break; case "attack": this._renderAttack(entry, textStack, meta, options); break; case "ingredient": this._renderIngredient(entry, textStack, meta, options); break; // list items case "item": this._renderItem(entry, textStack, meta, options); break; case "itemSub": this._renderItemSub(entry, textStack, meta, options); break; case "itemSpell": this._renderItemSpell(entry, textStack, meta, options); break; // embedded entities case "statblockInline": this._renderStatblockInline(entry, textStack, meta, options); break; case "statblock": this._renderStatblock(entry, textStack, meta, options); break; // images case "image": this._renderImage(entry, textStack, meta, options); break; case "gallery": this._renderGallery(entry, textStack, meta, options); break; // flowchart case "flowchart": this._renderFlowchart(entry, textStack, meta, options); break; case "flowBlock": this._renderFlowBlock(entry, textStack, meta, options); break; // homebrew changes case "homebrew": this._renderHomebrew(entry, textStack, meta, options); break; // misc case "code": this._renderCode(entry, textStack, meta, options); break; case "hr": this._renderHr(entry, textStack, meta, options); break; } meta._typeStack.pop(); } else if (typeof entry === "string") { // block this._renderPrefix(entry, textStack, meta, options); this._renderString(entry, textStack, meta, options); this._renderSuffix(entry, textStack, meta, options); } else { // block // for ints or any other types which do not require specific rendering this._renderPrefix(entry, textStack, meta, options); this._renderPrimitive(entry, textStack, meta, options); this._renderSuffix(entry, textStack, meta, options); } }; this._RE_TEXT_CENTER = /\btext-center\b/; this._getMutatedStyleString = function (str) { if (!str) return str; return str.replace(this._RE_TEXT_CENTER, "ve-text-center"); }; this._adjustDepth = function (meta, dDepth) { const cachedDepth = meta.depth; meta.depth += dDepth; meta.depth = Math.min(Math.max(-1, meta.depth), 2); // cap depth between -1 and 2 for general use return cachedDepth; }; this._renderPrefix = function (entry, textStack, meta, options) { if (meta._didRenderPrefix) return; if (options.prefix != null) { textStack[0] += options.prefix; meta._didRenderPrefix = true; } }; this._renderSuffix = function (entry, textStack, meta, options) { if (meta._didRenderSuffix) return; if (options.suffix != null) { textStack[0] += options.suffix; meta._didRenderSuffix = true; } }; this._renderImage = function (entry, textStack, meta, options) { if (entry.title) this._handleTrackTitles(entry.title, {isImage: true}); textStack[0] += `
`; if (entry.imageType === "map" || entry.imageType === "mapPlayer") textStack[0] += `
`; textStack[0] += `
`; const href = this._renderImage_getUrl(entry); const svg = this._lazyImages && entry.width != null && entry.height != null ? `data:image/svg+xml,${encodeURIComponent(``)}` : null; const ptTitleCreditTooltip = this._renderImage_getTitleCreditTooltipText(entry); const ptTitle = ptTitleCreditTooltip ? `title="${ptTitleCreditTooltip}"` : ""; const pluginDataIsNoLink = this._getPlugins("image_isNoLink").map(plugin => plugin(entry, textStack, meta, options)).some(Boolean); textStack[0] += `
${pluginDataIsNoLink ? "" : ``} ${pluginDataIsNoLink ? "" : ``}
`; if (!this._renderImage_isComicStyling(entry) && (entry.title || entry.credit || entry.mapRegions)) { const ptAdventureBookMeta = entry.mapRegions && meta.adventureBookPage && meta.adventureBookSource && meta.adventureBookHash ? `data-rd-adventure-book-map-page="${meta.adventureBookPage.qq()}" data-rd-adventure-book-map-source="${meta.adventureBookSource.qq()}" data-rd-adventure-book-map-hash="${meta.adventureBookHash.qq()}"` : ""; textStack[0] += `
`; if (entry.title && !entry.mapRegions) textStack[0] += `
${this.render(entry.title)}
`; if (entry.mapRegions && !IS_VTT) { textStack[0] += ``; } if (entry.credit) textStack[0] += `
${this.render(entry.credit)}
`; textStack[0] += `
`; } if (entry._galleryTitlePad) textStack[0] += `
 
`; if (entry._galleryCreditPad) textStack[0] += `
 
`; textStack[0] += `
`; if (entry.imageType === "map" || entry.imageType === "mapPlayer") textStack[0] += `
`; }; this._renderImage_getTitleCreditTooltipText = function (entry) { if (!entry.title && !entry.credit) return null; return Renderer.stripTags( [entry.title, entry.credit ? `Art credit: ${entry.credit}` : null] .filter(Boolean) .join(". "), ).qq(); }; this._renderImage_getStylePart = function (entry) { const styles = [ // N.b. this width/height should be reflected in the renderer image CSS // Clamp the max width at 100%, as per the renderer styling entry.maxWidth ? `max-width: min(100%, ${entry.maxWidth}${entry.maxWidthUnits || "px"})` : "", // Clamp the max height at 60vh, as per the renderer styling entry.maxHeight ? `max-height: min(60vh, ${entry.maxHeight}${entry.maxHeightUnits || "px"})` : "", ].filter(Boolean).join("; "); return styles ? `style="${styles}"` : ""; }; this._renderImage_getMapRegionData = function (entry) { return JSON.stringify(this.getMapRegionData(entry)).escapeQuotes(); }; this.getMapRegionData = function (entry) { return { regions: entry.mapRegions, width: entry.width, height: entry.height, href: this._renderImage_getUrl(entry), hrefThumbnail: this._renderImage_getUrlThumbnail(entry), page: entry.page, source: entry.source, hash: entry.hash, }; }; this._renderImage_isComicStyling = function (entry) { if (!entry.style) return false; return ["comic-speaker-left", "comic-speaker-right"].includes(entry.style); }; this._renderImage_getWrapperClasses = function (entry) { const out = ["rd__wrp-image", "relative"]; if (entry.style) { switch (entry.style) { case "comic-speaker-left": out.push("rd__comic-img-speaker", "rd__comic-img-speaker--left"); break; case "comic-speaker-right": out.push("rd__comic-img-speaker", "rd__comic-img-speaker--right"); break; } } return out.join(" "); }; this._renderImage_getImageClasses = function (entry) { const out = ["rd__image"]; if (entry.style) { switch (entry.style) { case "deity-symbol": out.push("rd__img-small"); break; } } return out.join(" "); }; this._renderImage_getUrl = function (entry) { let url = Renderer.utils.getEntryMediaUrl(entry, "href", "img"); for (const plugin of this._getPlugins(`image_urlPostProcess`)) { url = plugin(entry, url) || url; } return url; }; this._renderImage_getUrlThumbnail = function (entry) { let url = Renderer.utils.getEntryMediaUrl(entry, "hrefThumbnail", "img"); for (const plugin of this._getPlugins(`image_urlThumbnailPostProcess`)) { url = plugin(entry, url) || url; } return url; }; this._renderList_getListCssClasses = function (entry, textStack, meta, options) { const out = [`rd__list`]; if (entry.style || entry.columns) { if (entry.style) out.push(...entry.style.split(" ").map(it => `rd__${it}`)); if (entry.columns) out.push(`columns-${entry.columns}`); } return out.join(" "); }; this._renderTableGroup = function (entry, textStack, meta, options) { const len = entry.tables.length; for (let i = 0; i < len; ++i) this._recursiveRender(entry.tables[i], textStack, meta); }; this._renderTable = function (entry, textStack, meta, options) { // TODO add handling for rowLabel property if (entry.intro) { const len = entry.intro.length; for (let i = 0; i < len; ++i) { this._recursiveRender(entry.intro[i], textStack, meta, {prefix: "

", suffix: "

"}); } } textStack[0] += ``; const headerRowMetas = Renderer.table.getHeaderRowMetas(entry); const autoRollMode = Renderer.table.getAutoConvertedRollMode(entry, {headerRowMetas}); const toRenderLabel = autoRollMode ? RollerUtil.getFullRollCol(headerRowMetas.last()[0]) : null; const isInfiniteResults = autoRollMode === RollerUtil.ROLL_COL_VARIABLE; // caption if (entry.caption != null) { this._handleTrackTitles(entry.caption, {isTable: true}); textStack[0] += ``; } // body -- temporarily build this to own string; append after headers const rollCols = []; let bodyStack = [""]; bodyStack[0] += ""; const lenRows = entry.rows.length; for (let ixRow = 0; ixRow < lenRows; ++ixRow) { bodyStack[0] += ""; const r = entry.rows[ixRow]; let roRender = r.type === "row" ? r.row : r; const len = roRender.length; for (let ixCell = 0; ixCell < len; ++ixCell) { rollCols[ixCell] = rollCols[ixCell] || false; // pre-convert rollables if (autoRollMode && ixCell === 0) { roRender = Renderer.getRollableRow( roRender, { isForceInfiniteResults: isInfiniteResults, isFirstRow: ixRow === 0, isLastRow: ixRow === lenRows - 1, }, ); rollCols[ixCell] = true; } let toRenderCell; if (roRender[ixCell].type === "cell") { if (roRender[ixCell].roll) { rollCols[ixCell] = true; if (roRender[ixCell].entry) { toRenderCell = roRender[ixCell].entry; } else if (roRender[ixCell].roll.exact != null) { toRenderCell = roRender[ixCell].roll.pad ? StrUtil.padNumber(roRender[ixCell].roll.exact, 2, "0") : roRender[ixCell].roll.exact; } else { // TODO(Future) render "negative infinite" minimum nicely (or based on an example from a book, if one ever occurs) // "Selling a Magic Item" from DMG p129 almost meets this, but it has its own display const dispMin = roRender[ixCell].roll.displayMin != null ? roRender[ixCell].roll.displayMin : roRender[ixCell].roll.min; const dispMax = roRender[ixCell].roll.displayMax != null ? roRender[ixCell].roll.displayMax : roRender[ixCell].roll.max; if (dispMax === Renderer.dice.POS_INFINITE) { toRenderCell = roRender[ixCell].roll.pad ? `${StrUtil.padNumber(dispMin, 2, "0")}+` : `${dispMin}+`; } else { toRenderCell = roRender[ixCell].roll.pad ? `${StrUtil.padNumber(dispMin, 2, "0")}-${StrUtil.padNumber(dispMax, 2, "0")}` : `${dispMin}-${dispMax}`; } } } else if (roRender[ixCell].entry) { toRenderCell = roRender[ixCell].entry; } } else { toRenderCell = roRender[ixCell]; } bodyStack[0] += `"; } bodyStack[0] += ""; } bodyStack[0] += ""; // header if (headerRowMetas) { textStack[0] += ""; for (let ixRow = 0, lenRows = headerRowMetas.length; ixRow < lenRows; ++ixRow) { textStack[0] += ""; const headerRowMeta = headerRowMetas[ixRow]; for (let ixCell = 0, lenCells = headerRowMeta.length; ixCell < lenCells; ++ixCell) { const lbl = headerRowMeta[ixCell]; textStack[0] += ``; } textStack[0] += ""; } textStack[0] += ""; } textStack[0] += bodyStack[0]; // footer if (entry.footnotes != null) { textStack[0] += ""; const len = entry.footnotes.length; for (let i = 0; i < len; ++i) { textStack[0] += `"; } textStack[0] += ""; } textStack[0] += "
${entry.caption}
`; if (r.style === "row-indent-first" && ixCell === 0) bodyStack[0] += `
`; const cacheDepth = this._adjustDepth(meta, 1); this._recursiveRender(toRenderCell, bodyStack, meta); meta.depth = cacheDepth; bodyStack[0] += "
`; this._recursiveRender(autoRollMode && ixCell === 0 ? RollerUtil.getFullRollCol(lbl) : lbl, textStack, meta); textStack[0] += `
`; const cacheDepth = this._adjustDepth(meta, 1); this._recursiveRender(entry.footnotes[i], textStack, meta); meta.depth = cacheDepth; textStack[0] += "
"; if (entry.outro) { const len = entry.outro.length; for (let i = 0; i < len; ++i) { this._recursiveRender(entry.outro[i], textStack, meta, {prefix: "

", suffix: "

"}); } } }; this._renderTable_getCellDataStr = function (ent) { function convertZeros (num) { if (num === 0) return 100; return num; } if (ent.roll) { return `data-roll-min="${convertZeros(ent.roll.exact != null ? ent.roll.exact : ent.roll.min)}" data-roll-max="${convertZeros(ent.roll.exact != null ? ent.roll.exact : ent.roll.max)}"`; } return ""; }; this._renderTable_getTableThClassText = function (entry, i) { return entry.colStyles == null || i >= entry.colStyles.length ? "" : `class="${this._getMutatedStyleString(entry.colStyles[i])}"`; }; this._renderTable_makeTableTdClassText = function (entry, i) { if (entry.rowStyles != null) return i >= entry.rowStyles.length ? "" : `class="${this._getMutatedStyleString(entry.rowStyles[i])}"`; else return this._renderTable_getTableThClassText(entry, i); }; this._renderEntries = function (entry, textStack, meta, options) { this._renderEntriesSubtypes(entry, textStack, meta, options, true); }; this._getPagePart = function (entry, isInset) { if (!Renderer.utils.isDisplayPage(entry.page)) return ""; return ` ${entry.source ? `${Parser.sourceJsonToAbv(entry.source)} ` : ""}p${entry.page}`; }; this._renderEntriesSubtypes = function (entry, textStack, meta, options, incDepth) { const type = entry.type || "entries"; const isInlineTitle = meta.depth >= 2; const isAddPeriod = isInlineTitle && entry.name && !Renderer._INLINE_HEADER_TERMINATORS.has(entry.name[entry.name.length - 1]); const pagePart = !this._isPartPageExpandCollapseDisabled && !isInlineTitle ? this._getPagePart(entry) : ""; const partExpandCollapse = !this._isPartPageExpandCollapseDisabled && !isInlineTitle ? `[\u2013]` : ""; const partPageExpandCollapse = !this._isPartPageExpandCollapseDisabled && (pagePart || partExpandCollapse) ? `${[pagePart, partExpandCollapse].filter(Boolean).join("")}` : ""; const nextDepth = incDepth && meta.depth < 2 ? meta.depth + 1 : meta.depth; const styleString = this._renderEntriesSubtypes_getStyleString(entry, meta, isInlineTitle); const dataString = this._renderEntriesSubtypes_getDataString(entry); if (entry.name != null && Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP[entry.type]) this._handleTrackTitles(entry.name); const headerTag = isInlineTitle ? "span" : `h${Math.min(Math.max(meta.depth + 2, 1), 6)}`; const headerClass = `rd__h--${meta.depth + 1}`; // adjust as the CSS is 0..4 rather than -1..3 const cachedLastDepthTrackerProps = MiscUtil.copyFast(this._lastDepthTrackerInheritedProps); this._handleTrackDepth(entry, meta.depth); const pluginDataNamePrefix = this._getPlugins(`${type}_namePrefix`).map(plugin => plugin(entry, textStack, meta, options)).filter(Boolean); const headerSpan = entry.name ? `<${headerTag} class="rd__h ${headerClass}" data-title-index="${this._headerIndex++}" ${this._getEnumeratedTitleRel(entry.name)}> ${pluginDataNamePrefix.join("")}${this.render({type: "inline", entries: [entry.name]})}${isAddPeriod ? "." : ""}${partPageExpandCollapse} ` : ""; if (meta.depth === -1) { if (!this._firstSection) textStack[0] += `
`; this._firstSection = false; } if (entry.entries || entry.name) { textStack[0] += `<${this.wrapperTag} ${dataString} ${styleString}>${headerSpan}`; this._renderEntriesSubtypes_renderPreReqText(entry, textStack, meta); if (entry.entries) { const cacheDepth = meta.depth; const len = entry.entries.length; for (let i = 0; i < len; ++i) { meta.depth = nextDepth; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: "

", suffix: "

"}); // Add a spacer for style sets that have vertical whitespace instead of indents if (i === 0 && cacheDepth >= 2) textStack[0] += `
`; } meta.depth = cacheDepth; } textStack[0] += ``; } this._lastDepthTrackerInheritedProps = cachedLastDepthTrackerProps; }; this._renderEntriesSubtypes_getDataString = function (entry) { let dataString = ""; if (entry.source) dataString += `data-source="${entry.source}"`; if (entry.data) { for (const k in entry.data) { if (!k.startsWith("rd-")) continue; dataString += ` data-${k}="${`${entry.data[k]}`.escapeQuotes()}"`; } } return dataString; }; this._renderEntriesSubtypes_renderPreReqText = function (entry, textStack, meta) { if (entry.prerequisite) { textStack[0] += `Prerequisite: `; this._recursiveRender({type: "inline", entries: [entry.prerequisite]}, textStack, meta); textStack[0] += ``; } }; this._renderEntriesSubtypes_getStyleString = function (entry, meta, isInlineTitle) { const styleClasses = ["rd__b"]; styleClasses.push(this._getStyleClass(entry.type || "entries", entry)); if (isInlineTitle) { if (this._subVariant) styleClasses.push(Renderer.HEAD_2_SUB_VARIANT); else styleClasses.push(Renderer.HEAD_2); } else styleClasses.push(meta.depth === -1 ? Renderer.HEAD_NEG_1 : meta.depth === 0 ? Renderer.HEAD_0 : Renderer.HEAD_1); return styleClasses.length > 0 ? `class="${styleClasses.join(" ")}"` : ""; }; this._renderOptions = function (entry, textStack, meta, options) { if (!entry.entries) return; entry.entries = entry.entries.sort((a, b) => a.name && b.name ? SortUtil.ascSort(a.name, b.name) : a.name ? -1 : b.name ? 1 : 0); if (entry.style && entry.style === "list-hang-notitle") { const fauxEntry = { type: "list", style: "list-hang-notitle", items: entry.entries.map(ent => { if (typeof ent === "string") return ent; if (ent.type === "item") return ent; const out = {...ent, type: "item"}; if (ent.name) out.name = Renderer._INLINE_HEADER_TERMINATORS.has(ent.name[ent.name.length - 1]) ? out.name : `${out.name}.`; return out; }), }; this._renderList(fauxEntry, textStack, meta, options); } else this._renderEntriesSubtypes(entry, textStack, meta, options, false); }; this._renderList = function (entry, textStack, meta, options) { if (entry.items) { const tag = entry.start ? "ol" : "ul"; const cssClasses = this._renderList_getListCssClasses(entry, textStack, meta, options); textStack[0] += `<${tag} ${cssClasses ? `class="${cssClasses}"` : ""} ${entry.start ? `start="${entry.start}"` : ""}>`; if (entry.name) textStack[0] += `
  • ${entry.name}
  • `; const isListHang = entry.style && entry.style.split(" ").includes("list-hang"); const len = entry.items.length; for (let i = 0; i < len; ++i) { const item = entry.items[i]; // Special case for child lists -- avoid wrapping in LI tags to avoid double-bullet if (item.type !== "list") { const className = `${this._getStyleClass(entry.type, item)}${item.type === "itemSpell" ? " rd__li-spell" : ""}`; textStack[0] += `
  • `; } // If it's a raw string in a hanging list, wrap it in a div to allow for the correct styling if (isListHang && typeof item === "string") textStack[0] += "
    "; this._recursiveRender(item, textStack, meta); if (isListHang && typeof item === "string") textStack[0] += "
    "; if (item.type !== "list") textStack[0] += "
  • "; } textStack[0] += ``; } }; this._getPtExpandCollapseSpecial = function () { return `[\u2013]`; }; this._renderInset = function (entry, textStack, meta, options) { const dataString = this._renderEntriesSubtypes_getDataString(entry); textStack[0] += `<${this.wrapperTag} class="rd__b-special rd__b-inset ${this._getMutatedStyleString(entry.style || "")}" ${dataString}>`; const cachedLastDepthTrackerProps = MiscUtil.copyFast(this._lastDepthTrackerInheritedProps); this._handleTrackDepth(entry, 1); const pagePart = this._getPagePart(entry, true); const partExpandCollapse = this._getPtExpandCollapseSpecial(); const partPageExpandCollapse = `${[pagePart, partExpandCollapse].filter(Boolean).join("")}`; if (entry.name != null) { if (Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP[entry.type]) this._handleTrackTitles(entry.name); textStack[0] += `

    ${entry.name}

    ${partPageExpandCollapse}
    `; } else { textStack[0] += `${partPageExpandCollapse}`; } if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: "

    ", suffix: "

    "}); meta.depth = cacheDepth; } } textStack[0] += `
    `; textStack[0] += ``; this._lastDepthTrackerInheritedProps = cachedLastDepthTrackerProps; }; this._renderInsetReadaloud = function (entry, textStack, meta, options) { const dataString = this._renderEntriesSubtypes_getDataString(entry); textStack[0] += `<${this.wrapperTag} class="rd__b-special rd__b-inset rd__b-inset--readaloud ${this._getMutatedStyleString(entry.style || "")}" ${dataString}>`; const cachedLastDepthTrackerProps = MiscUtil.copyFast(this._lastDepthTrackerInheritedProps); this._handleTrackDepth(entry, 1); const pagePart = this._getPagePart(entry, true); const partExpandCollapse = this._getPtExpandCollapseSpecial(); const partPageExpandCollapse = `${[pagePart, partExpandCollapse].filter(Boolean).join("")}`; if (entry.name != null) { if (Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP[entry.type]) this._handleTrackTitles(entry.name); textStack[0] += `

    ${entry.name}

    ${this._getPagePart(entry, true)}
    `; } else { textStack[0] += `${partPageExpandCollapse}`; } const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: "

    ", suffix: "

    "}); meta.depth = cacheDepth; } textStack[0] += `
    `; textStack[0] += ``; this._lastDepthTrackerInheritedProps = cachedLastDepthTrackerProps; }; this._renderVariant = function (entry, textStack, meta, options) { const dataString = this._renderEntriesSubtypes_getDataString(entry); if (entry.name != null && Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP[entry.type]) this._handleTrackTitles(entry.name); const cachedLastDepthTrackerProps = MiscUtil.copyFast(this._lastDepthTrackerInheritedProps); this._handleTrackDepth(entry, 1); const pagePart = this._getPagePart(entry, true); const partExpandCollapse = this._getPtExpandCollapseSpecial(); const partPageExpandCollapse = `${[pagePart, partExpandCollapse].filter(Boolean).join("")}`; textStack[0] += `<${this.wrapperTag} class="rd__b-special rd__b-inset" ${dataString}>`; textStack[0] += `

    Variant: ${entry.name}

    ${partPageExpandCollapse}
    `; const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: "

    ", suffix: "

    "}); meta.depth = cacheDepth; } if (entry.source) textStack[0] += Renderer.utils.getSourceAndPageTrHtml({source: entry.source, page: entry.page}); textStack[0] += ``; this._lastDepthTrackerInheritedProps = cachedLastDepthTrackerProps; }; this._renderVariantInner = function (entry, textStack, meta, options) { const dataString = this._renderEntriesSubtypes_getDataString(entry); if (entry.name != null && Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP[entry.type]) this._handleTrackTitles(entry.name); const cachedLastDepthTrackerProps = MiscUtil.copyFast(this._lastDepthTrackerInheritedProps); this._handleTrackDepth(entry, 1); textStack[0] += `<${this.wrapperTag} class="rd__b-inset-inner" ${dataString}>`; textStack[0] += `

    ${entry.name}

    `; const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: "

    ", suffix: "

    "}); meta.depth = cacheDepth; } if (entry.source) textStack[0] += Renderer.utils.getSourceAndPageTrHtml({source: entry.source, page: entry.page}); textStack[0] += ``; this._lastDepthTrackerInheritedProps = cachedLastDepthTrackerProps; }; this._renderVariantSub = function (entry, textStack, meta, options) { // pretend this is an inline-header'd entry, but set a flag so we know not to add bold this._subVariant = true; const fauxEntry = entry; fauxEntry.type = "entries"; const cacheDepth = meta.depth; meta.depth = 3; this._recursiveRender(fauxEntry, textStack, meta, {prefix: "

    ", suffix: "

    "}); meta.depth = cacheDepth; this._subVariant = false; }; this._renderSpellcasting_getEntries = function (entry) { const hidden = new Set(entry.hidden || []); const toRender = [{type: "entries", name: entry.name, entries: entry.headerEntries ? MiscUtil.copyFast(entry.headerEntries) : []}]; if (entry.constant || entry.will || entry.recharge || entry.charges || entry.rest || entry.daily || entry.weekly || entry.yearly || entry.ritual) { const tempList = {type: "list", style: "list-hang-notitle", items: [], data: {isSpellList: true}}; if (entry.constant && !hidden.has("constant")) tempList.items.push({type: "itemSpell", name: `Constant:`, entry: this._renderSpellcasting_getRenderableList(entry.constant).join(", ")}); if (entry.will && !hidden.has("will")) tempList.items.push({type: "itemSpell", name: `At will:`, entry: this._renderSpellcasting_getRenderableList(entry.will).join(", ")}); this._renderSpellcasting_getEntries_procPerDuration({entry, tempList, hidden, prop: "recharge", fnGetDurationText: num => `{@recharge ${num}|m}`, isSkipPrefix: true}); this._renderSpellcasting_getEntries_procPerDuration({entry, tempList, hidden, prop: "charges", fnGetDurationText: num => ` charge${num === 1 ? "" : "s"}`}); this._renderSpellcasting_getEntries_procPerDuration({entry, tempList, hidden, prop: "rest", durationText: "/rest"}); this._renderSpellcasting_getEntries_procPerDuration({entry, tempList, hidden, prop: "daily", durationText: "/day"}); this._renderSpellcasting_getEntries_procPerDuration({entry, tempList, hidden, prop: "weekly", durationText: "/week"}); this._renderSpellcasting_getEntries_procPerDuration({entry, tempList, hidden, prop: "yearly", durationText: "/year"}); if (entry.ritual && !hidden.has("ritual")) tempList.items.push({type: "itemSpell", name: `Rituals:`, entry: this._renderSpellcasting_getRenderableList(entry.ritual).join(", ")}); tempList.items = tempList.items.filter(it => it.entry !== ""); if (tempList.items.length) toRender[0].entries.push(tempList); } if (entry.spells && !hidden.has("spells")) { const tempList = {type: "list", style: "list-hang-notitle", items: [], data: {isSpellList: true}}; const lvls = Object.keys(entry.spells) .map(lvl => Number(lvl)) .sort(SortUtil.ascSort); for (const lvl of lvls) { const spells = entry.spells[lvl]; if (spells) { let levelCantrip = `${Parser.spLevelToFull(lvl)}${(lvl === 0 ? "s" : " level")}`; let slotsAtWill = ` (at will)`; const slots = spells.slots; if (slots >= 0) slotsAtWill = slots > 0 ? ` (${slots} slot${slots > 1 ? "s" : ""})` : ``; if (spells.lower && spells.lower !== lvl) { levelCantrip = `${Parser.spLevelToFull(spells.lower)}-${levelCantrip}`; if (slots >= 0) slotsAtWill = slots > 0 ? ` (${slots} ${Parser.spLevelToFull(lvl)}-level slot${slots > 1 ? "s" : ""})` : ``; } tempList.items.push({type: "itemSpell", name: `${levelCantrip}${slotsAtWill}:`, entry: this._renderSpellcasting_getRenderableList(spells.spells).join(", ") || "\u2014"}); } } toRender[0].entries.push(tempList); } if (entry.footerEntries) toRender.push({type: "entries", entries: entry.footerEntries}); return toRender; }; this._renderSpellcasting_getEntries_procPerDuration = function ({entry, hidden, tempList, prop, durationText, fnGetDurationText, isSkipPrefix}) { if (!entry[prop] || hidden.has(prop)) return; for (let lvl = 9; lvl > 0; lvl--) { const perDur = entry[prop]; if (perDur[lvl]) { tempList.items.push({ type: "itemSpell", name: `${isSkipPrefix ? "" : lvl}${fnGetDurationText ? fnGetDurationText(lvl) : durationText}:`, entry: this._renderSpellcasting_getRenderableList(perDur[lvl]).join(", "), }); } const lvlEach = `${lvl}e`; if (perDur[lvlEach]) { const isHideEach = !perDur[lvl] && perDur[lvlEach].length === 1; tempList.items.push({ type: "itemSpell", name: `${isSkipPrefix ? "" : lvl}${fnGetDurationText ? fnGetDurationText(lvl) : durationText}${isHideEach ? "" : ` each`}:`, entry: this._renderSpellcasting_getRenderableList(perDur[lvlEach]).join(", "), }); } } }; this._renderSpellcasting_getRenderableList = function (spellList) { return spellList.filter(it => !it.hidden).map(it => it.entry || it); }; this._renderSpellcasting = function (entry, textStack, meta, options) { const toRender = this._renderSpellcasting_getEntries(entry); if (!toRender?.[0].entries?.length) return; this._recursiveRender({type: "entries", entries: toRender}, textStack, meta); }; this._renderQuote = function (entry, textStack, meta, options) { textStack[0] += `
    `; const len = entry.entries.length; for (let i = 0; i < len; ++i) { textStack[0] += `

    ${i === 0 && !entry.skipMarks ? "“" : ""}`; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: entry.skipItalics ? "" : "", suffix: entry.skipItalics ? "" : ""}); textStack[0] += `${i === len - 1 && !entry.skipMarks ? "”" : ""}

    `; } if (entry.by || entry.from) { textStack[0] += `

    `; const tempStack = [""]; const byArr = this._renderQuote_getBy(entry); if (byArr) { for (let i = 0, len = byArr.length; i < len; ++i) { const by = byArr[i]; this._recursiveRender(by, tempStack, meta); if (i < len - 1) tempStack[0] += "
    "; } } textStack[0] += `\u2014 ${byArr ? tempStack.join("") : ""}${byArr && entry.from ? `, ` : ""}${entry.from ? `${entry.from}` : ""}`; textStack[0] += `

    `; } textStack[0] += `
    `; }; this._renderList_getQuoteCssClasses = function (entry, textStack, meta, options) { const out = [`rd__quote`]; if (entry.style) { if (entry.style) out.push(...entry.style.split(" ").map(it => `rd__${it}`)); } return out.join(" "); }; this._renderQuote_getBy = function (entry) { if (!entry.by?.length) return null; return entry.by instanceof Array ? entry.by : [entry.by]; }; this._renderOptfeature = function (entry, textStack, meta, options) { this._renderEntriesSubtypes(entry, textStack, meta, options, true); }; this._renderPatron = function (entry, textStack, meta, options) { this._renderEntriesSubtypes(entry, textStack, meta, options, false); }; this._renderAbilityDc = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `
    `; this._recursiveRender(entry.name, textStack, meta); textStack[0] += ` save DC = 8 + your proficiency bonus + your ${Parser.attrChooseToFull(entry.attributes)}
    `; this._renderSuffix(entry, textStack, meta, options); }; this._renderAbilityAttackMod = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `
    `; this._recursiveRender(entry.name, textStack, meta); textStack[0] += ` attack modifier = your proficiency bonus + your ${Parser.attrChooseToFull(entry.attributes)}
    `; this._renderSuffix(entry, textStack, meta, options); }; this._renderAbilityGeneric = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `
    `; if (entry.name) this._recursiveRender(entry.name, textStack, meta, {prefix: "", suffix: " = "}); textStack[0] += `${entry.text}${entry.attributes ? ` ${Parser.attrChooseToFull(entry.attributes)}` : ""}
    `; this._renderSuffix(entry, textStack, meta, options); }; this._renderInline = function (entry, textStack, meta, options) { if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) this._recursiveRender(entry.entries[i], textStack, meta); } }; this._renderInlineBlock = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) this._recursiveRender(entry.entries[i], textStack, meta); } this._renderSuffix(entry, textStack, meta, options); }; this._renderBonus = function (entry, textStack, meta, options) { textStack[0] += (entry.value < 0 ? "" : "+") + entry.value; }; this._renderBonusSpeed = function (entry, textStack, meta, options) { textStack[0] += entry.value === 0 ? "\u2014" : `${entry.value < 0 ? "" : "+"}${entry.value} ft.`; }; this._renderDice = function (entry, textStack, meta, options) { const pluginResults = this._getPlugins("dice").map(plugin => plugin(entry, textStack, meta, options)).filter(Boolean); textStack[0] += Renderer.getEntryDice(entry, entry.name, {isAddHandlers: this._isAddHandlers, pluginResults}); }; this._renderActions = function (entry, textStack, meta, options) { const dataString = this._renderEntriesSubtypes_getDataString(entry); if (entry.name != null && Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP[entry.type]) this._handleTrackTitles(entry.name); const cachedLastDepthTrackerProps = MiscUtil.copyFast(this._lastDepthTrackerInheritedProps); this._handleTrackDepth(entry, 2); textStack[0] += `<${this.wrapperTag} class="${Renderer.HEAD_2}" ${dataString}>${entry.name}. `; const len = entry.entries.length; for (let i = 0; i < len; ++i) this._recursiveRender(entry.entries[i], textStack, meta, {prefix: "

    ", suffix: "

    "}); textStack[0] += ``; this._lastDepthTrackerInheritedProps = cachedLastDepthTrackerProps; }; this._renderAttack = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `${Parser.attackTypeToFull(entry.attackType)}: `; const len = entry.attackEntries.length; for (let i = 0; i < len; ++i) this._recursiveRender(entry.attackEntries[i], textStack, meta); textStack[0] += ` Hit: `; const len2 = entry.hitEntries.length; for (let i = 0; i < len2; ++i) this._recursiveRender(entry.hitEntries[i], textStack, meta); this._renderSuffix(entry, textStack, meta, options); }; this._renderIngredient = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); this._recursiveRender(entry.entry, textStack, meta); this._renderSuffix(entry, textStack, meta, options); }; this._renderItem = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `

    ${this.render(entry.name)}${this._renderItem_isAddPeriod(entry) ? "." : ""} `; if (entry.entry) this._recursiveRender(entry.entry, textStack, meta); else if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) this._recursiveRender(entry.entries[i], textStack, meta, {prefix: i > 0 ? `` : "", suffix: i > 0 ? "" : ""}); } textStack[0] += "

    "; this._renderSuffix(entry, textStack, meta, options); }; this._renderItem_isAddPeriod = function (entry) { return entry.name && entry.nameDot !== false && !Renderer._INLINE_HEADER_TERMINATORS.has(entry.name[entry.name.length - 1]); }; this._renderItemSub = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); const isAddPeriod = entry.name && entry.nameDot !== false && !Renderer._INLINE_HEADER_TERMINATORS.has(entry.name[entry.name.length - 1]); this._recursiveRender(entry.entry, textStack, meta, {prefix: `

    ${entry.name}${isAddPeriod ? "." : ""} `, suffix: "

    "}); this._renderSuffix(entry, textStack, meta, options); }; this._renderItemSpell = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); const tempStack = [""]; this._recursiveRender(entry.name || "", tempStack, meta); this._recursiveRender(entry.entry, textStack, meta, {prefix: `

    ${tempStack.join("")} `, suffix: "

    "}); this._renderSuffix(entry, textStack, meta, options); }; this._InlineStatblockStrategy = function ( { pFnPreProcess, }, ) { this.pFnPreProcess = pFnPreProcess; }; this._INLINE_STATBLOCK_STRATEGIES = { "item": new this._InlineStatblockStrategy({ pFnPreProcess: async (ent) => { await Renderer.item.pPopulatePropertyAndTypeReference(); Renderer.item.enhanceItem(ent); return ent; }, }), }; this._renderStatblockInline = function (entry, textStack, meta, options) { const fnGetRenderCompact = Renderer.hover.getFnRenderCompact(entry.dataType); const headerName = entry.displayName || entry.data?.name; const headerStyle = entry.style; if (!fnGetRenderCompact) { this._renderPrefix(entry, textStack, meta, options); this._renderDataHeader(textStack, headerName, headerStyle); textStack[0] += ` Cannot render "${entry.type}"—unknown data type "${entry.dataType}"! `; this._renderDataFooter(textStack); this._renderSuffix(entry, textStack, meta, options); return; } const strategy = this._INLINE_STATBLOCK_STRATEGIES[entry.dataType]; if (!strategy?.pFnPreProcess && !entry.data?._copy) { this._renderPrefix(entry, textStack, meta, options); this._renderDataHeader(textStack, headerName, headerStyle, {isCollapsed: entry.collapsed}); textStack[0] += fnGetRenderCompact(entry.data, {isEmbeddedEntity: true}); this._renderDataFooter(textStack); this._renderSuffix(entry, textStack, meta, options); return; } this._renderPrefix(entry, textStack, meta, options); this._renderDataHeader(textStack, headerName, headerStyle, {isCollapsed: entry.collapsed}); const id = CryptUtil.uid(); Renderer._cache.inlineStatblock[id] = { pFn: async (ele) => { const entLoaded = entry.data?._copy ? (await DataUtil.pDoMetaMergeSingle( entry.dataType, {dependencies: {[entry.dataType]: entry.dependencies}}, entry.data, )) : entry.data; const ent = strategy?.pFnPreProcess ? await strategy.pFnPreProcess(entLoaded) : entLoaded; const tbl = ele.closest("table"); const nxt = e_({ outer: Renderer.utils.getEmbeddedDataHeader(headerName, headerStyle, {isCollapsed: entry.collapsed}) + fnGetRenderCompact(ent, {isEmbeddedEntity: true}) + Renderer.utils.getEmbeddedDataFooter(), }); tbl.parentNode.replaceChild( nxt, tbl, ); }, }; textStack[0] += ``; this._renderDataFooter(textStack); this._renderSuffix(entry, textStack, meta, options); }; this._renderDataHeader = function (textStack, name, style, {isCollapsed = false} = {}) { textStack[0] += Renderer.utils.getEmbeddedDataHeader(name, style, {isCollapsed}); }; this._renderDataFooter = function (textStack) { textStack[0] += Renderer.utils.getEmbeddedDataFooter(); }; this._renderStatblock = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); const page = entry.prop || Renderer.tag.getPage(entry.tag); const source = Parser.getTagSource(entry.tag, entry.source); const hash = entry.hash || (UrlUtil.URL_TO_HASH_BUILDER[page] ? UrlUtil.URL_TO_HASH_BUILDER[page]({...entry, name: entry.name, source}) : null); const asTag = `{@${entry.tag} ${entry.name}|${source}${entry.displayName ? `|${entry.displayName}` : ""}}`; if (!page || !source || !hash) { this._renderDataHeader(textStack, entry.name, entry.style); textStack[0] += ` Cannot load ${entry.tag ? `"${asTag}"` : entry.displayName || entry.name}! An unknown tag/prop, source, or hash was provided. `; this._renderDataFooter(textStack); this._renderSuffix(entry, textStack, meta, options); return; } this._renderDataHeader(textStack, entry.displayName || entry.name, entry.style, {isCollapsed: entry.collapsed}); textStack[0] += ` Loading ${entry.tag ? `${Renderer.get().render(asTag)}` : entry.displayName || entry.name}... `; this._renderDataFooter(textStack); this._renderSuffix(entry, textStack, meta, options); }; this._renderGallery = function (entry, textStack, meta, options) { if (entry.name) textStack[0] += ``; textStack[0] += ``; }; this._renderFlowchart = function (entry, textStack, meta, options) { textStack[0] += `
    `; const len = entry.blocks.length; for (let i = 0; i < len; ++i) { this._recursiveRender(entry.blocks[i], textStack, meta, options); if (i !== len - 1) { textStack[0] += `
    `; } } textStack[0] += `
    `; }; this._renderFlowBlock = function (entry, textStack, meta, options) { const dataString = this._renderEntriesSubtypes_getDataString(entry); textStack[0] += `<${this.wrapperTag} class="rd__b-special rd__b-flow ve-text-center" ${dataString}>`; const cachedLastDepthTrackerProps = MiscUtil.copyFast(this._lastDepthTrackerInheritedProps); this._handleTrackDepth(entry, 1); if (entry.name != null) { if (Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP[entry.type]) this._handleTrackTitles(entry.name); textStack[0] += `

    ${this.render({type: "inline", entries: [entry.name]})}

    `; } if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: "

    ", suffix: "

    "}); meta.depth = cacheDepth; } } textStack[0] += `
    `; textStack[0] += ``; this._lastDepthTrackerInheritedProps = cachedLastDepthTrackerProps; }; this._renderHomebrew = function (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `
    `; if (entry.oldEntries) { const hoverMeta = Renderer.hover.getInlineHover({type: "entries", name: "Homebrew", entries: entry.oldEntries}); let markerText; if (entry.movedTo) { markerText = "(See moved content)"; } else if (entry.entries) { markerText = "(See replaced content)"; } else { markerText = "(See removed content)"; } textStack[0] += `${markerText}`; } textStack[0] += `
    `; if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) this._recursiveRender(entry.entries[i], textStack, meta, {prefix: "

    ", suffix: "

    "}); } else if (entry.movedTo) { textStack[0] += `This content has been moved to ${entry.movedTo}.`; } else { textStack[0] += "This content has been deleted."; } textStack[0] += `
    `; this._renderSuffix(entry, textStack, meta, options); }; this._renderCode = function (entry, textStack, meta, options) { const isWrapped = !!StorageUtil.syncGet("rendererCodeWrap"); textStack[0] += `
    ${entry.preformatted}
    `; }; this._renderHr = function (entry, textStack, meta, options) { textStack[0] += `
    `; }; this._getStyleClass = function (entryType, entry) { const outList = []; const pluginResults = this._getPlugins(`${entryType}_styleClass_fromSource`) .map(plugin => plugin(entryType, entry)).filter(Boolean); if (!pluginResults.some(it => it.isSkip)) { if ( SourceUtil.isNonstandardSource(entry.source) || (typeof PrereleaseUtil !== "undefined" && PrereleaseUtil.hasSourceJson(entry.source)) ) outList.push("spicy-sauce"); if (typeof BrewUtil2 !== "undefined" && BrewUtil2.hasSourceJson(entry.source)) outList.push("refreshing-brew"); } if (this._extraSourceClasses) outList.push(...this._extraSourceClasses); for (const k in this._fnsGetStyleClasses) { const fromFn = this._fnsGetStyleClasses[k](entry); if (fromFn) outList.push(...fromFn); } if (entry.style) outList.push(this._getMutatedStyleString(entry.style)); return outList.join(" "); }; this._renderString = function (entry, textStack, meta, options) { const tagSplit = Renderer.splitByTags(entry); const len = tagSplit.length; for (let i = 0; i < len; ++i) { const s = tagSplit[i]; if (!s) continue; if (s.startsWith("{@")) { const [tag, text] = Renderer.splitFirstSpace(s.slice(1, -1)); this._renderString_renderTag(textStack, meta, options, tag, text); } else textStack[0] += s; } }; this._renderString_renderTag = function (textStack, meta, options, tag, text) { // region Plugins // Generic for (const plugin of this._getPlugins("string_tag")) { const out = plugin(tag, text, textStack, meta, options); if (out) return void (textStack[0] += out); } // Tag-specific for (const plugin of this._getPlugins(`string_${tag}`)) { const out = plugin(tag, text, textStack, meta, options); if (out) return void (textStack[0] += out); } // endregion switch (tag) { // BASIC STYLES/TEXT /////////////////////////////////////////////////////////////////////////////// case "@b": case "@bold": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@i": case "@italic": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@s": case "@strike": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@u": case "@underline": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@sup": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@sub": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@kbd": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@code": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@style": { const [displayText, styles] = Renderer.splitTagByPipe(text); const classNames = (styles || "").split(";").map(it => Renderer._STYLE_TAG_ID_TO_STYLE[it.trim()]).filter(Boolean).join(" "); textStack[0] += ``; this._recursiveRender(displayText, textStack, meta); textStack[0] += ``; break; } case "@font": { const [displayText, fontFamily] = Renderer.splitTagByPipe(text); textStack[0] += ``; this._recursiveRender(displayText, textStack, meta); textStack[0] += ``; break; } case "@note": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@tip": { const [displayText, titielText] = Renderer.splitTagByPipe(text); textStack[0] += ``; this._recursiveRender(displayText, textStack, meta); textStack[0] += ``; break; } case "@atk": textStack[0] += `${Renderer.attackTagToFull(text)}`; break; case "@h": textStack[0] += `Hit: `; break; case "@m": textStack[0] += `Miss: `; break; case "@color": { const [toDisplay, color] = Renderer.splitTagByPipe(text); const ptColor = this._renderString_renderTag_getBrewColorPart(color); textStack[0] += ``; this._recursiveRender(toDisplay, textStack, meta); textStack[0] += ``; break; } case "@highlight": { const [toDisplay, color] = Renderer.splitTagByPipe(text); const ptColor = this._renderString_renderTag_getBrewColorPart(color); textStack[0] += ptColor ? `` : ``; textStack[0] += toDisplay; textStack[0] += ``; break; } case "@help": { const [toDisplay, title = ""] = Renderer.splitTagByPipe(text); textStack[0] += ``; this._recursiveRender(toDisplay, textStack, meta); textStack[0] += ``; break; } // Misc utilities ////////////////////////////////////////////////////////////////////////////////// case "@unit": { const [amount, unitSingle, unitPlural] = Renderer.splitTagByPipe(text); textStack[0] += isNaN(amount) ? unitSingle : Number(amount) > 1 ? (unitPlural || unitSingle.toPlural()) : unitSingle; break; } // Comic styles //////////////////////////////////////////////////////////////////////////////////// case "@comic": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@comicH1": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@comicH2": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@comicH3": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@comicH4": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; case "@comicNote": textStack[0] += ``; this._recursiveRender(text, textStack, meta); textStack[0] += ``; break; // DCs ///////////////////////////////////////////////////////////////////////////////////////////// case "@dc": { const [dcText, displayText] = Renderer.splitTagByPipe(text); textStack[0] += `DC ${displayText || dcText}`; break; } case "@dcYourSpellSave": { const [displayText] = Renderer.splitTagByPipe(text); textStack[0] += displayText || "your spell save DC"; break; } // DICE //////////////////////////////////////////////////////////////////////////////////////////// case "@dice": case "@autodice": case "@damage": case "@hit": case "@d20": case "@chance": case "@coinflip": case "@recharge": case "@ability": case "@savingThrow": case "@skillCheck": { const fauxEntry = Renderer.utils.getTagEntry(tag, text); if (tag === "@recharge") { const [, flagsRaw] = Renderer.splitTagByPipe(text); const flags = flagsRaw ? flagsRaw.split("") : null; textStack[0] += `${flags && flags.includes("m") ? "" : "("}Recharge `; this._recursiveRender(fauxEntry, textStack, meta); textStack[0] += `${flags && flags.includes("m") ? "" : ")"}`; } else { this._recursiveRender(fauxEntry, textStack, meta); } break; } case "@hitYourSpellAttack": this._renderString_renderTag_hitYourSpellAttack(textStack, meta, options, tag, text); break; // SCALE DICE ////////////////////////////////////////////////////////////////////////////////////// case "@scaledice": case "@scaledamage": { const fauxEntry = Renderer.parseScaleDice(tag, text); this._recursiveRender(fauxEntry, textStack, meta); break; } // LINKS /////////////////////////////////////////////////////////////////////////////////////////// case "@filter": { // format: {@filter Warlock Spells|spells|level=1;2|class=Warlock} const [displayText, page, ...filters] = Renderer.splitTagByPipe(text); const filterSubhashMeta = Renderer.getFilterSubhashes(filters); const fauxEntry = { type: "link", text: displayText, href: { type: "internal", path: `${page}.html`, hash: HASH_BLANK, hashPreEncoded: true, subhashes: filterSubhashMeta.subhashes, }, }; if (filterSubhashMeta.customHash) fauxEntry.href.hash = filterSubhashMeta.customHash; this._recursiveRender(fauxEntry, textStack, meta); break; } case "@link": { const [displayText, url] = Renderer.splitTagByPipe(text); let outUrl = url == null ? displayText : url; if (!outUrl.startsWith("http")) outUrl = `http://${outUrl}`; // avoid HTTPS, as the D&D homepage doesn't support it const fauxEntry = { type: "link", href: { type: "external", url: outUrl, }, text: displayText, }; this._recursiveRender(fauxEntry, textStack, meta); break; } case "@5etools": { const [displayText, page, hash] = Renderer.splitTagByPipe(text); const fauxEntry = { type: "link", href: { type: "internal", path: page, }, text: displayText, }; if (hash) { fauxEntry.hash = hash; fauxEntry.hashPreEncoded = true; } this._recursiveRender(fauxEntry, textStack, meta); break; } // OTHER HOVERABLES //////////////////////////////////////////////////////////////////////////////// case "@footnote": { const [displayText, footnoteText, optTitle] = Renderer.splitTagByPipe(text); const hoverMeta = Renderer.hover.getInlineHover({ type: "entries", name: optTitle ? optTitle.toTitleCase() : "Footnote", entries: [footnoteText, optTitle ? `{@note ${optTitle}}` : ""].filter(Boolean), }); textStack[0] += ``; this._recursiveRender(displayText, textStack, meta); textStack[0] += ``; break; } case "@homebrew": { const [newText, oldText] = Renderer.splitTagByPipe(text); const tooltipEntries = []; if (newText && oldText) { tooltipEntries.push("{@b This is a homebrew addition, replacing the following:}"); } else if (newText) { tooltipEntries.push("{@b This is a homebrew addition.}"); } else if (oldText) { tooltipEntries.push("{@b The following text has been removed with this homebrew:}"); } if (oldText) { tooltipEntries.push(oldText); } const hoverMeta = Renderer.hover.getInlineHover({ type: "entries", name: "Homebrew Modifications", entries: tooltipEntries, }); textStack[0] += ``; this._recursiveRender(newText || "[...]", textStack, meta); textStack[0] += ``; break; } case "@area": { const {areaId, displayText} = Renderer.tag.TAG_LOOKUP.area.getMeta(tag, text); if (typeof BookUtil === "undefined") { // for the roll20 script textStack[0] += displayText; } else { const area = BookUtil.curRender.headerMap[areaId] || {entry: {name: ""}}; // default to prevent rendering crash on bad tag const hoverMeta = Renderer.hover.getInlineHover(area.entry, {isLargeBookContent: true, depth: area.depth}); textStack[0] += `${displayText}`; } break; } // HOMEBREW LOADING //////////////////////////////////////////////////////////////////////////////// case "@loader": { const {name, path, mode} = this._renderString_getLoaderTagMeta(text); const brewUtilName = mode === "homebrew" ? "BrewUtil2" : mode === "prerelease" ? "PrereleaseUtil" : null; const brewUtil = globalThis[brewUtilName]; if (!brewUtil) { textStack[0] += `${name}`; break; } textStack[0] += `${name}`; break; } // CONTENT TAGS //////////////////////////////////////////////////////////////////////////////////// case "@book": case "@adventure": { // format: {@tag Display Text|DMG< |chapter< |section >< |number > >} const page = tag === "@book" ? "book.html" : "adventure.html"; const [displayText, book, chapter, section, rawNumber] = Renderer.splitTagByPipe(text); const number = rawNumber || 0; const hash = `${book}${chapter ? `${HASH_PART_SEP}${chapter}${section ? `${HASH_PART_SEP}${UrlUtil.encodeForHash(section)}${number != null ? `${HASH_PART_SEP}${UrlUtil.encodeForHash(number)}` : ""}` : ""}` : ""}`; const fauxEntry = { type: "link", href: { type: "internal", path: page, hash, hashPreEncoded: true, }, text: displayText, }; this._recursiveRender(fauxEntry, textStack, meta); break; } default: { const {name, source, displayText, others, page, hash, hashPreEncoded, pageHover, hashHover, hashPreEncodedHover, preloadId, linkText, subhashes, subhashesHover, isFauxPage} = Renderer.utils.getTagMeta(tag, text); const fauxEntry = { type: "link", href: { type: "internal", path: page, hash, hover: { page, isFauxPage, source, }, }, text: (displayText || name), }; if (hashPreEncoded != null) fauxEntry.href.hashPreEncoded = hashPreEncoded; if (pageHover != null) fauxEntry.href.hover.page = pageHover; if (hashHover != null) fauxEntry.href.hover.hash = hashHover; if (hashPreEncodedHover != null) fauxEntry.href.hover.hashPreEncoded = hashPreEncodedHover; if (preloadId != null) fauxEntry.href.hover.preloadId = preloadId; if (linkText) fauxEntry.text = linkText; if (subhashes) fauxEntry.href.subhashes = subhashes; if (subhashesHover) fauxEntry.href.hover.subhashes = subhashesHover; this._recursiveRender(fauxEntry, textStack, meta); break; } } }; this._renderString_renderTag_getBrewColorPart = function (color) { if (!color) return ""; const scrubbedColor = BrewUtilShared.getValidColor(color, {isExtended: true}); return scrubbedColor.startsWith("--") ? `var(${scrubbedColor})` : `#${scrubbedColor}`; }; this._renderString_renderTag_hitYourSpellAttack = function (textStack, meta, options, tag, text) { const [displayText] = Renderer.splitTagByPipe(text); const fauxEntry = { type: "dice", rollable: true, subType: "d20", displayText: displayText || "your spell attack modifier", toRoll: `1d20 + #$prompt_number:title=Enter your Spell Attack Modifier$#`, }; return this._recursiveRender(fauxEntry, textStack, meta); }; this._renderString_getLoaderTagMeta = function (text, {isDefaultUrl = false} = {}) { const [name, file, mode = "homebrew"] = Renderer.splitTagByPipe(text); if (!isDefaultUrl) return {name, path: file, mode}; const path = /^.*?:\/\//.test(file) ? file : `${VeCt.URL_ROOT_BREW}${file}`; return {name, path, mode}; }; this._renderPrimitive = function (entry, textStack, meta, options) { textStack[0] += entry; }; this._renderLink = function (entry, textStack, meta, options) { let href = this._renderLink_getHref(entry); // overwrite href if there's an available Roll20 handout/character if (entry.href.hover && this._roll20Ids) { const procHash = UrlUtil.encodeForHash(entry.href.hash); const id = this._roll20Ids[procHash]; if (id) { href = `http://journal.roll20.net/${id.type}/${id.roll20Id}`; } } const pluginData = this._getPlugins("link").map(plugin => plugin(entry, textStack, meta, options)).filter(Boolean); const isDisableEvents = pluginData.some(it => it.isDisableEvents); const additionalAttributes = pluginData.map(it => it.attributes).filter(Boolean); if (this._isInternalLinksDisabled && entry.href.type === "internal") { textStack[0] += `${this.render(entry.text)}`; } else if (entry.href.hover?.isFauxPage) { textStack[0] += `${this.render(entry.text)}`; } else { textStack[0] += `${this.render(entry.text)}`; } }; this._renderLink_getHref = function (entry) { let href; if (entry.href.type === "internal") { // baseURL is blank by default href = `${this.baseUrl}${entry.href.path}#`; if (entry.href.hash != null) { href += entry.href.hashPreEncoded ? entry.href.hash : UrlUtil.encodeForHash(entry.href.hash); } if (entry.href.subhashes != null) { href += Renderer.utils.getLinkSubhashString(entry.href.subhashes); } } else if (entry.href.type === "external") { href = entry.href.url; } return href; }; this._renderLink_getHoverString = function (entry) { if (!entry.href.hover || !this._isAddHandlers) return ""; let procHash = entry.href.hover.hash ? entry.href.hover.hashPreEncoded ? entry.href.hover.hash : UrlUtil.encodeForHash(entry.href.hover.hash) : entry.href.hashPreEncoded ? entry.href.hash : UrlUtil.encodeForHash(entry.href.hash); if (this._tagExportDict) { this._tagExportDict[procHash] = { page: entry.href.hover.page, source: entry.href.hover.source, hash: procHash, }; } if (entry.href.hover.subhashes) { procHash += Renderer.utils.getLinkSubhashString(entry.href.hover.subhashes); } const pluginData = this._getPlugins("link_attributesHover") .map(plugin => plugin(entry, procHash)) .filter(Boolean); const replacementAttributes = pluginData.map(it => it.attributesHoverReplace).filter(Boolean); if (replacementAttributes.length) return replacementAttributes.join(" "); return `onmouseover="Renderer.hover.pHandleLinkMouseOver(event, this)" onmouseleave="Renderer.hover.handleLinkMouseLeave(event, this)" onmousemove="Renderer.hover.handleLinkMouseMove(event, this)" data-vet-page="${entry.href.hover.page.qq()}" data-vet-source="${entry.href.hover.source.qq()}" data-vet-hash="${procHash.qq()}" ${entry.href.hover.preloadId != null ? `data-vet-preload-id="${`${entry.href.hover.preloadId}`.qq()}"` : ""} ${entry.href.hover.isFauxPage ? `data-vet-is-faux-page="true"` : ""} ${Renderer.hover.getPreventTouchString()}`; }; /** * Helper function to render an entity using this renderer * @param entry * @param depth * @returns {string} */ this.render = function (entry, depth = 0) { const tempStack = []; this.recursiveRender(entry, tempStack, {depth}); return tempStack.join(""); }; }; // Unless otherwise specified, these use `"name"` as their name title prop Renderer.ENTRIES_WITH_ENUMERATED_TITLES = [ {type: "section", key: "entries", depth: -1}, {type: "entries", key: "entries", depthIncrement: 1}, {type: "options", key: "entries"}, {type: "inset", key: "entries", depth: 2}, {type: "insetReadaloud", key: "entries", depth: 2}, {type: "variant", key: "entries", depth: 2}, {type: "variantInner", key: "entries", depth: 2}, {type: "actions", key: "entries", depth: 2}, {type: "flowBlock", key: "entries", depth: 2}, {type: "optfeature", key: "entries", depthIncrement: 1}, {type: "patron", key: "entries"}, ]; Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP = Renderer.ENTRIES_WITH_ENUMERATED_TITLES.mergeMap(it => ({[it.type]: it})); Renderer.ENTRIES_WITH_CHILDREN = [ ...Renderer.ENTRIES_WITH_ENUMERATED_TITLES, {type: "list", key: "items"}, {type: "table", key: "rows"}, ]; Renderer._INLINE_HEADER_TERMINATORS = new Set([".", ",", "!", "?", ";", ":", `"`]); Renderer._STYLE_TAG_ID_TO_STYLE = { "small-caps": "small-caps", "small": "ve-small", "capitalize": "capitalize", "dnd-font": "dnd-font", }; Renderer.get = () => { if (!Renderer.defaultRenderer) Renderer.defaultRenderer = new Renderer(); return Renderer.defaultRenderer; }; Renderer.applyProperties = function (entry, object) { const propSplit = Renderer.splitByPropertyInjectors(entry); const len = propSplit.length; if (len === 1) return entry; let textStack = ""; for (let i = 0; i < len; ++i) { const s = propSplit[i]; if (!s) continue; if (!s.startsWith("{=")) { textStack += s; continue; } if (s.startsWith("{=")) { const [path, modifiers] = s.slice(2, -1).split("/"); let fromProp = object[path]; if (!modifiers) { textStack += fromProp; continue; } if (fromProp == null) throw new Error(`Could not apply property in "${s}"; "${path}" value was null!`); modifiers .split("") .sort((a, b) => Renderer.applyProperties._OP_ORDER.indexOf(a) - Renderer.applyProperties._OP_ORDER.indexOf(b)); for (const modifier of modifiers) { switch (modifier) { case "a": // render "a"/"an" depending on prop value fromProp = Renderer.applyProperties._LEADING_AN.has(fromProp[0].toLowerCase()) ? "an" : "a"; break; case "l": fromProp = fromProp.toLowerCase(); break; // convert text to lower case case "t": fromProp = fromProp.toTitleCase(); break; // title-case text case "u": fromProp = fromProp.toUpperCase(); break; // uppercase text case "v": fromProp = Parser.numberToVulgar(fromProp); break; // vulgarize number case "x": fromProp = Parser.numberToText(fromProp); break; // convert number to text case "r": fromProp = Math.round(fromProp); break; // round number case "f": fromProp = Math.floor(fromProp); break; // floor number case "c": fromProp = Math.ceil(fromProp); break; // ceiling number default: throw new Error(`Unhandled property modifier "${modifier}"`); } } textStack += fromProp; } } return textStack; }; Renderer.applyProperties._LEADING_AN = new Set(["a", "e", "i", "o", "u"]); Renderer.applyProperties._OP_ORDER = [ "r", "f", "c", // operate on value first "v", "x", // cast to desired type "l", "t", "u", "a", // operate on value representation ]; Renderer.applyAllProperties = function (entries, object = null) { let lastObj = null; const handlers = { object: (obj) => { lastObj = obj; return obj; }, string: (str) => Renderer.applyProperties(str, object || lastObj), }; return MiscUtil.getWalker().walk(entries, handlers); }; Renderer.attackTagToFull = function (tagStr) { function renderTag (tags) { return `${tags.includes("m") ? "Melee " : tags.includes("r") ? "Ranged " : tags.includes("g") ? "Magical " : tags.includes("a") ? "Area " : ""}${tags.includes("w") ? "Weapon " : tags.includes("s") ? "Spell " : tags.includes("p") ? "Power " : ""}`; } const tagGroups = tagStr.toLowerCase().split(",").map(it => it.trim()).filter(it => it).map(it => it.split("")); if (tagGroups.length > 1) { const seen = new Set(tagGroups.last()); for (let i = tagGroups.length - 2; i >= 0; --i) { tagGroups[i] = tagGroups[i].filter(it => { const out = !seen.has(it); seen.add(it); return out; }); } } return `${tagGroups.map(it => renderTag(it)).join(" or ")}Attack:`; }; Renderer.splitFirstSpace = function (string) { const firstIndex = string.indexOf(" "); return firstIndex === -1 ? [string, ""] : [string.substr(0, firstIndex), string.substr(firstIndex + 1)]; }; Renderer._splitByTagsBase = function (leadingCharacter) { return function (string) { let tagDepth = 0; let char, char2; const out = []; let curStr = ""; let isLastOpen = false; const len = string.length; for (let i = 0; i < len; ++i) { char = string[i]; char2 = string[i + 1]; switch (char) { case "{": isLastOpen = true; if (char2 === leadingCharacter) { if (tagDepth++ > 0) { curStr += "{"; } else { out.push(curStr.replace(//g, leadingCharacter)); curStr = `{${leadingCharacter}`; ++i; } } else curStr += "{"; break; case "}": isLastOpen = false; curStr += "}"; if (tagDepth !== 0 && --tagDepth === 0) { out.push(curStr.replace(//g, leadingCharacter)); curStr = ""; } break; case leadingCharacter: { if (!isLastOpen) curStr += ""; else curStr += leadingCharacter; break; } default: isLastOpen = false; curStr += char; break; } } if (curStr) out.push(curStr.replace(//g, leadingCharacter)); return out; }; }; Renderer.splitByTags = Renderer._splitByTagsBase("@"); Renderer.splitByPropertyInjectors = Renderer._splitByTagsBase("="); Renderer._splitByPipeBase = function (leadingCharacter) { return function (string) { let tagDepth = 0; let char, char2; const out = []; let curStr = ""; const len = string.length; for (let i = 0; i < len; ++i) { char = string[i]; char2 = string[i + 1]; switch (char) { case "{": if (char2 === leadingCharacter) tagDepth++; curStr += "{"; break; case "}": if (tagDepth) tagDepth--; curStr += "}"; break; case "|": { if (tagDepth) curStr += "|"; else { out.push(curStr); curStr = ""; } break; } default: { curStr += char; break; } } } if (curStr) out.push(curStr); return out; }; }; Renderer.splitTagByPipe = Renderer._splitByPipeBase("@"); Renderer.getEntryDice = function (entry, name, opts = {}) { const toDisplay = Renderer.getEntryDiceDisplayText(entry); if (entry.rollable === true) return Renderer.getRollableEntryDice(entry, name, toDisplay, opts); else return toDisplay; }; Renderer.getRollableEntryDice = function ( entry, name, toDisplay, { isAddHandlers = true, pluginResults = null, } = {}, ) { const toPack = MiscUtil.copyFast(entry); if (typeof toPack.toRoll !== "string") { // handle legacy format toPack.toRoll = Renderer.legacyDiceToString(toPack.toRoll); } const handlerPart = isAddHandlers ? `onmousedown="event.preventDefault()" data-packed-dice='${JSON.stringify(toPack).qq()}'` : ""; const rollableTitlePart = isAddHandlers ? Renderer.getEntryDiceTitle(toPack.subType) : null; const titlePart = isAddHandlers ? `title="${[name, rollableTitlePart].filter(Boolean).join(". ").qq()}" ${name ? `data-roll-name="${name}"` : ""}` : name ? `title="${name.qq()}" data-roll-name="${name.qq()}"` : ""; const additionalDataPart = (pluginResults || []) .filter(it => it.additionalData) .map(it => { return Object.entries(it.additionalData) .map(([dataKey, val]) => `${dataKey}='${typeof val === "object" ? JSON.stringify(val).qq() : `${val}`.qq()}'`) .join(" "); }) .join(" "); toDisplay = (pluginResults || []).filter(it => it.toDisplay)[0]?.toDisplay ?? toDisplay; const ptRoll = Renderer.getRollableEntryDice._getPtRoll(toPack); return `${toDisplay}${ptRoll}`; }; Renderer.getRollableEntryDice._getPtRoll = (toPack) => { if (!toPack.autoRoll) return ""; const r = Renderer.dice.parseRandomise2(toPack.toRoll); return ` (${r})`; }; Renderer.getEntryDiceTitle = function (subType) { return `Click to roll. ${subType === "damage" ? "SHIFT to roll a critical hit, CTRL to half damage (rounding down)." : subType === "d20" ? "SHIFT to roll with advantage, CTRL to roll with disadvantage." : "SHIFT/CTRL to roll twice."}`; }; Renderer.legacyDiceToString = function (array) { let stack = ""; array.forEach(r => { stack += `${r.neg ? "-" : stack === "" ? "" : "+"}${r.number || 1}d${r.faces}${r.mod ? r.mod > 0 ? `+${r.mod}` : r.mod : ""}`; }); return stack; }; Renderer.getEntryDiceDisplayText = function (entry) { if (entry.displayText) return entry.displayText; return Renderer._getEntryDiceDisplayText_getDiceAsStr(entry); }; Renderer._getEntryDiceDisplayText_getDiceAsStr = function (entry) { if (entry.successThresh != null) return `${entry.successThresh} percent`; if (typeof entry.toRoll === "string") return entry.toRoll; // handle legacy format return Renderer.legacyDiceToString(entry.toRoll); }; Renderer.parseScaleDice = function (tag, text) { // format: {@scaledice 2d6;3d6|2-8,9|1d6|psi|display text} (or @scaledamage) const [baseRoll, progression, addPerProgress, renderMode, displayText] = Renderer.splitTagByPipe(text); const progressionParse = MiscUtil.parseNumberRange(progression, 1, 9); const baseLevel = Math.min(...progressionParse); const options = {}; const isMultableDice = /^(\d+)d(\d+)$/i.exec(addPerProgress); const getSpacing = () => { let diff = null; const sorted = [...progressionParse].sort(SortUtil.ascSort); for (let i = 1; i < sorted.length; ++i) { const prev = sorted[i - 1]; const curr = sorted[i]; if (diff == null) diff = curr - prev; else if (curr - prev !== diff) return null; } return diff; }; const spacing = getSpacing(); progressionParse.forEach(k => { const offset = k - baseLevel; if (isMultableDice && spacing != null) { options[k] = offset ? `${Number(isMultableDice[1]) * (offset / spacing)}d${isMultableDice[2]}` : ""; } else { options[k] = offset ? [...new Array(Math.floor(offset / spacing))].map(_ => addPerProgress).join("+") : ""; } }); const out = { type: "dice", rollable: true, toRoll: baseRoll, displayText: displayText || addPerProgress, prompt: { entry: renderMode === "psi" ? "Spend Psi Points..." : "Cast at...", mode: renderMode, options, }, }; if (tag === "@scaledamage") out.subType = "damage"; return out; }; Renderer.getAbilityData = function (abArr, {isOnlyShort, isCurrentLineage} = {}) { if (isOnlyShort && isCurrentLineage) return new Renderer._AbilityData({asTextShort: "Lineage (choose)"}); const outerStack = (abArr || [null]).map(it => Renderer.getAbilityData._doRenderOuter(it)); if (outerStack.length <= 1) return outerStack[0]; return new Renderer._AbilityData({ asText: `Choose one of: ${outerStack.map((it, i) => `(${Parser.ALPHABET[i].toLowerCase()}) ${it.asText}`).join(" ")}`, asTextShort: `${outerStack.map((it, i) => `(${Parser.ALPHABET[i].toLowerCase()}) ${it.asTextShort}`).join(" ")}`, asCollection: [...new Set(outerStack.map(it => it.asCollection).flat())], areNegative: [...new Set(outerStack.map(it => it.areNegative).flat())], }); }; Renderer.getAbilityData._doRenderOuter = function (abObj) { const mainAbs = []; const asCollection = []; const areNegative = []; const toConvertToText = []; const toConvertToShortText = []; if (abObj != null) { handleAllAbilities(abObj); handleAbilitiesChoose(); return new Renderer._AbilityData({ asText: toConvertToText.join("; "), asTextShort: toConvertToShortText.join("; "), asCollection: asCollection, areNegative: areNegative, }); } return new Renderer._AbilityData(); function handleAllAbilities (abObj, targetList) { MiscUtil.copyFast(Parser.ABIL_ABVS) .sort((a, b) => SortUtil.ascSort(abObj[b] || 0, abObj[a] || 0)) .forEach(shortLabel => handleAbility(abObj, shortLabel, targetList)); } function handleAbility (abObj, shortLabel, optToConvertToTextStorage) { if (abObj[shortLabel] != null) { const isNegMod = abObj[shortLabel] < 0; const toAdd = `${shortLabel.uppercaseFirst()} ${(isNegMod ? "" : "+")}${abObj[shortLabel]}`; if (optToConvertToTextStorage) { optToConvertToTextStorage.push(toAdd); } else { toConvertToText.push(toAdd); toConvertToShortText.push(toAdd); } mainAbs.push(shortLabel.uppercaseFirst()); asCollection.push(shortLabel); if (isNegMod) areNegative.push(shortLabel); } } function handleAbilitiesChoose () { if (abObj.choose != null) { const ch = abObj.choose; let outStack = ""; if (ch.weighted) { const w = ch.weighted; const froms = w.from.map(it => it.uppercaseFirst()); const isAny = froms.length === 6; const isAllEqual = w.weights.unique().length === 1; let cntProcessed = 0; const weightsIncrease = w.weights.filter(it => it >= 0).sort(SortUtil.ascSort).reverse(); const weightsReduce = w.weights.filter(it => it < 0).map(it => -it).sort(SortUtil.ascSort); const areIncreaseShort = []; const areIncrease = isAny && isAllEqual && w.weights.length > 1 && w.weights[0] >= 0 ? (() => { weightsIncrease.forEach(it => areIncreaseShort.push(`+${it}`)); return [`${cntProcessed ? "choose " : ""}${Parser.numberToText(w.weights.length)} different +${weightsIncrease[0]}`]; })() : weightsIncrease.map(it => { areIncreaseShort.push(`+${it}`); if (isAny) return `${cntProcessed ? "choose " : ""}any ${cntProcessed++ ? `other ` : ""}+${it}`; return `one ${cntProcessed++ ? `other ` : ""}ability to increase by ${it}`; }); const areReduceShort = []; const areReduce = isAny && isAllEqual && w.weights.length > 1 && w.weights[0] < 0 ? (() => { weightsReduce.forEach(it => areReduceShort.push(`-${it}`)); return [`${cntProcessed ? "choose " : ""}${Parser.numberToText(w.weights.length)} different -${weightsReduce[0]}`]; })() : weightsReduce.map(it => { areReduceShort.push(`-${it}`); if (isAny) return `${cntProcessed ? "choose " : ""}any ${cntProcessed++ ? `other ` : ""}-${it}`; return `one ${cntProcessed++ ? `other ` : ""}ability to decrease by ${it}`; }); const startText = isAny ? `Choose ` : `From ${froms.joinConjunct(", ", " and ")} choose `; const ptAreaIncrease = isAny ? areIncrease.concat(areReduce).join("; ") : areIncrease.concat(areReduce).joinConjunct(", ", isAny ? "; " : " and "); toConvertToText.push(`${startText}${ptAreaIncrease}`); toConvertToShortText.push(`${isAny ? "Any combination " : ""}${areIncreaseShort.concat(areReduceShort).join("/")}${isAny ? "" : ` from ${froms.join("/")}`}`); } else { const allAbilities = ch.from.length === 6; const allAbilitiesWithParent = isAllAbilitiesWithParent(ch); let amount = ch.amount === undefined ? 1 : ch.amount; amount = (amount < 0 ? "" : "+") + amount; if (allAbilities) { outStack += "any "; } else if (allAbilitiesWithParent) { outStack += "any other "; } if (ch.count != null && ch.count > 1) { outStack += `${Parser.numberToText(ch.count)} `; } if (allAbilities || allAbilitiesWithParent) { outStack += `${ch.count > 1 ? "unique " : ""}${amount}`; } else { for (let j = 0; j < ch.from.length; ++j) { let suffix = ""; if (ch.from.length > 1) { if (j === ch.from.length - 2) { suffix = " or "; } else if (j < ch.from.length - 2) { suffix = ", "; } } let thsAmount = ` ${amount}`; if (ch.from.length > 1) { if (j !== ch.from.length - 1) { thsAmount = ""; } } outStack += ch.from[j].uppercaseFirst() + thsAmount + suffix; } } } if (outStack.trim()) { toConvertToText.push(`Choose ${outStack}`); toConvertToShortText.push(outStack.uppercaseFirst()); } } } function isAllAbilitiesWithParent (chooseAbs) { const tempAbilities = []; for (let i = 0; i < mainAbs.length; ++i) { tempAbilities.push(mainAbs[i].toLowerCase()); } for (let i = 0; i < chooseAbs.from.length; ++i) { const ab = chooseAbs.from[i].toLowerCase(); if (!tempAbilities.includes(ab)) tempAbilities.push(ab); if (!asCollection.includes(ab.toLowerCase)) asCollection.push(ab.toLowerCase()); } return tempAbilities.length === 6; } }; Renderer._AbilityData = function ({asText, asTextShort, asCollection, areNegative} = {}) { this.asText = asText || ""; this.asTextShort = asTextShort || ""; this.asCollection = asCollection || []; this.areNegative = areNegative || []; }; /** * @param filters String of the form `"level=1;2|class=Warlock"` * @param namespace Filter namespace to use */ Renderer.getFilterSubhashes = function (filters, namespace = null) { let customHash = null; const subhashes = filters.map(f => { const [fName, fVals, fMeta, fOpts] = f.split("=").map(s => s.trim()); const isBoxData = fName.startsWith("fb"); const key = isBoxData ? `${fName}${namespace ? `.${namespace}` : ""}` : `flst${namespace ? `.${namespace}` : ""}${UrlUtil.encodeForHash(fName)}`; let value; // special cases for "search" and "hash" keywords if (isBoxData) { return { key, value: fVals, preEncoded: true, }; } else if (fName === "search") { // "search" as a filter name is hackily converted to a box meta option return { key: VeCt.FILTER_BOX_SUB_HASH_SEARCH_PREFIX, value: UrlUtil.encodeForHash(fVals), preEncoded: true, }; } else if (fName === "hash") { customHash = fVals; return null; } else if (fVals.startsWith("[") && fVals.endsWith("]")) { // range const [min, max] = fVals.substring(1, fVals.length - 1).split(";").map(it => it.trim()); if (max == null) { // shorthand version, with only one value, becomes min _and_ max value = [ `min=${min}`, `max=${min}`, ].join(HASH_SUB_LIST_SEP); } else { value = [ min ? `min=${min}` : "", max ? `max=${max}` : "", ].filter(Boolean).join(HASH_SUB_LIST_SEP); } } else if (fVals.startsWith("::") && fVals.endsWith("::")) { // options value = fVals.substring(2, fVals.length - 2).split(";") .map(it => it.trim()) .map(it => { if (it.startsWith("!")) return `${UrlUtil.encodeForHash(it.slice(1))}=${UrlUtil.mini.compress(false)}`; return `${UrlUtil.encodeForHash(it)}=${UrlUtil.mini.compress(true)}`; }) .join(HASH_SUB_LIST_SEP); } else { value = fVals.split(";") .map(s => s.trim()) .filter(Boolean) .map(s => { if (s.startsWith("!")) return `${UrlUtil.encodeForHash(s.slice(1))}=2`; return `${UrlUtil.encodeForHash(s)}=1`; }) .join(HASH_SUB_LIST_SEP); } const out = [{ key, value, preEncoded: true, }]; if (fMeta) { out.push({ key: `flmt${UrlUtil.encodeForHash(fName)}`, value: fMeta, preEncoded: true, }); } if (fOpts) { out.push({ key: `flop${UrlUtil.encodeForHash(fName)}`, value: fOpts, preEncoded: true, }); } return out; }).flat().filter(Boolean); return { customHash, subhashes, }; }; Renderer._cache = { inlineStatblock: {}, async pRunFromEle (ele) { const cached = Renderer._cache[ele.dataset.rdCache][ele.dataset.rdCacheId]; await cached.pFn(ele); }, }; Renderer.utils = class { static getBorderTr (optText = null) { return `${optText || ""}`; } static getDividerTr () { return `
    `; } static getSourceSubText (it) { return it.sourceSub ? ` \u2014 ${it.sourceSub}` : ""; } /** * @param it Entity to render the name row for. * @param [opts] Options object. * @param [opts.prefix] Prefix to display before the name. * @param [opts.suffix] Suffix to display after the name. * @param [opts.controlRhs] Additional control(s) to display after the name. * @param [opts.extraThClasses] Additional TH classes to include. * @param [opts.page] The hover page for this entity. * @param [opts.asJquery] If the element should be returned as a jQuery object. * @param [opts.extensionData] Additional data to pass to listening extensions when the send button is clicked. * @param [opts.isEmbeddedEntity] True if this is an embedded entity, i.e. one from a `"dataX"` entry. */ static getNameTr (it, opts) { opts = opts || {}; let dataPart = ""; let pageLinkPart; if (opts.page) { const hash = UrlUtil.URL_TO_HASH_BUILDER[opts.page](it); dataPart = `data-page="${opts.page}" data-source="${it.source.escapeQuotes()}" data-hash="${hash.escapeQuotes()}" ${opts.extensionData != null ? `data-extension='${JSON.stringify(opts.extensionData).escapeQuotes()}` : ""}'`; pageLinkPart = SourceUtil.getAdventureBookSourceHref(it.source, it.page); // Enable Rivet import for entities embedded in entries if (opts.isEmbeddedEntity) ExtensionUtil.addEmbeddedToCache(opts.page, it.source, hash, it); } const tagPartSourceStart = `<${pageLinkPart ? `a href="${Renderer.get().baseUrl}${pageLinkPart}"` : "span"}`; const tagPartSourceEnd = ``; const ptBrewSourceLink = Renderer.utils._getNameTr_getPtPrereleaseBrewSourceLink({ent: it, brewUtil: PrereleaseUtil}) || Renderer.utils._getNameTr_getPtPrereleaseBrewSourceLink({ent: it, brewUtil: BrewUtil2}); // Add data-page/source/hash attributes for external script use (e.g. Rivet) const $ele = $$`

    ${opts.prefix || ""}${it._displayName || it.name}${opts.suffix || ""}

    ${opts.controlRhs || ""} ${!IS_VTT && ExtensionUtil.ACTIVE && opts.page ? Renderer.utils.getBtnSendToFoundryHtml() : ""}
    ${tagPartSourceStart} class="help-subtle stats-source-abbreviation ${it.source ? `${Parser.sourceJsonToColor(it.source)}" title="${Parser.sourceJsonToFull(it.source)}${Renderer.utils.getSourceSubText(it)}` : ""}" ${Parser.sourceJsonToStyle(it.source)}>${it.source ? Parser.sourceJsonToAbv(it.source) : ""}${tagPartSourceEnd} ${Renderer.utils.isDisplayPage(it.page) ? ` ${tagPartSourceStart} class="rd__stats-name-page ml-1" title="Page ${it.page}">p${it.page}${tagPartSourceEnd}` : ""} ${ptBrewSourceLink}
    `; if (opts.asJquery) return $ele; else return $ele[0].outerHTML; } static _getNameTr_getPtPrereleaseBrewSourceLink ({ent, brewUtil}) { if (!brewUtil.hasSourceJson(ent.source) || !brewUtil.sourceJsonToSource(ent.source)?.url) return ""; return ``; } static getBtnSendToFoundryHtml ({isMb = true} = {}) { return ``; } static isDisplayPage (page) { return page != null && ((!isNaN(page) && page > 0) || isNaN(page)); } static getExcludedTr ({entity, dataProp, page, isExcluded}) { const excludedHtml = Renderer.utils.getExcludedHtml({entity, dataProp, page, isExcluded}); if (!excludedHtml) return ""; return `${excludedHtml}`; } static getExcludedHtml ({entity, dataProp, page, isExcluded}) { if (isExcluded != null && !isExcluded) return ""; if (isExcluded == null) { if (!ExcludeUtil.isInitialised) return ""; if (page && !UrlUtil.URL_TO_HASH_BUILDER[page]) return ""; const hash = page ? UrlUtil.URL_TO_HASH_BUILDER[page](entity) : UrlUtil.autoEncodeHash(entity); isExcluded = isExcluded || dataProp === "item" ? Renderer.item.isExcluded(entity, {hash}) : ExcludeUtil.isExcluded(hash, dataProp, entity.source); } return isExcluded ? `
    Warning: This content has been blocklisted.
    ` : ""; } static getSourceAndPageTrHtml (it, {tag, fnUnpackUid} = {}) { const html = Renderer.utils.getSourceAndPageHtml(it, {tag, fnUnpackUid}); return html ? `Source: ${html}` : ""; } static _getAltSourceHtmlOrText (it, prop, introText, isText) { if (!it[prop] || !it[prop].length) return ""; return `${introText} ${it[prop].map(as => { if (as.entry) return (isText ? Renderer.stripTags : Renderer.get().render)(as.entry); return `${isText ? "" : ``}${Parser.sourceJsonToAbv(as.source)}${isText ? "" : ``}${Renderer.utils.isDisplayPage(as.page) ? `, page ${as.page}` : ""}`; }).join("; ")}`; } static _getReprintedAsHtmlOrText (ent, {isText, tag, fnUnpackUid} = {}) { if (!ent.reprintedAs) return ""; if (!tag || !fnUnpackUid) return ""; const ptReprinted = ent.reprintedAs .map(it => { const uid = it.uid ?? it; const tag_ = it.tag ?? tag; const {name, source, displayText} = fnUnpackUid(uid); if (isText) { return `${Renderer.stripTags(displayText || name)} in ${Parser.sourceJsonToAbv(source)}`; } const asTag = `{@${tag_} ${name}|${source}${displayText ? `|${displayText}` : ""}}`; return `${Renderer.get().render(asTag)} in ${Parser.sourceJsonToAbv(source)}`; }) .join("; "); return `Reprinted as ${ptReprinted}`; } static getSourceAndPageHtml (it, {tag, fnUnpackUid} = {}) { return this._getSourceAndPageHtmlOrText(it, {tag, fnUnpackUid}); } static getSourceAndPageText (it, {tag, fnUnpackUid} = {}) { return this._getSourceAndPageHtmlOrText(it, {isText: true, tag, fnUnpackUid}); } static _getSourceAndPageHtmlOrText (it, {isText, tag, fnUnpackUid} = {}) { const sourceSub = Renderer.utils.getSourceSubText(it); const baseText = `${isText ? `` : ``}${Parser.sourceJsonToAbv(it.source)}${sourceSub}${isText ? "" : ``}${Renderer.utils.isDisplayPage(it.page) ? `, page ${it.page}` : ""}`; const reprintedAsText = Renderer.utils._getReprintedAsHtmlOrText(it, {isText, tag, fnUnpackUid}); const addSourceText = Renderer.utils._getAltSourceHtmlOrText(it, "additionalSources", "Additional information from", isText); const otherSourceText = Renderer.utils._getAltSourceHtmlOrText(it, "otherSources", "Also found in", isText); const externalSourceText = Renderer.utils._getAltSourceHtmlOrText(it, "externalSources", "External sources:", isText); const srdText = it.srd ? `${isText ? "" : `the `}SRD${isText ? "" : ``}${typeof it.srd === "string" ? ` (as "${it.srd}")` : ""}` : ""; const basicRulesText = it.basicRules ? `the Basic Rules${typeof it.basicRules === "string" ? ` (as "${it.basicRules}")` : ""}` : ""; const srdAndBasicRulesText = (srdText || basicRulesText) ? `Available in ${[srdText, basicRulesText].filter(it => it).join(" and ")}` : ""; return `${[baseText, addSourceText, reprintedAsText, otherSourceText, srdAndBasicRulesText, externalSourceText].filter(it => it).join(". ")}${baseText && (addSourceText || otherSourceText || srdAndBasicRulesText || externalSourceText) ? "." : ""}`; } static async _pHandleNameClick (ele) { await MiscUtil.pCopyTextToClipboard($(ele).text()); JqueryUtil.showCopiedEffect($(ele)); } static getPageTr (it, {tag, fnUnpackUid} = {}) { return `${Renderer.utils.getSourceAndPageTrHtml(it, {tag, fnUnpackUid})}`; } static getAbilityRollerEntry (statblock, ability) { if (statblock[ability] == null) return "\u2014"; return `{@ability ${ability} ${statblock[ability]}}`; } static getAbilityRoller (statblock, ability) { return Renderer.get().render(Renderer.utils.getAbilityRollerEntry(statblock, ability)); } static getEmbeddedDataHeader (name, style, {isCollapsed = false} = {}) { return ``; } static getEmbeddedDataFooter () { return `
    ${name}[${isCollapsed ? "+" : "\u2013"}]
    `; } static TabButton = function ({label, fnChange, fnPopulate, isVisible}) { this.label = label; this.fnChange = fnChange; this.fnPopulate = fnPopulate; this.isVisible = isVisible; }; static _tabs = {}; static _curTab = null; static _tabsPreferredLabel = null; static bindTabButtons ({tabButtons, tabLabelReference, $wrpTabs, $pgContent}) { Renderer.utils._tabs = {}; Renderer.utils._curTab = null; $wrpTabs.find(`.stat-tab-gen`).remove(); tabButtons.forEach((tb, i) => { tb.ix = i; tb.$t = $(``) .click(() => tb.fnActivateTab({isUserInput: true})); tb.fnActivateTab = ({isUserInput = false} = {}) => { const curTab = Renderer.utils._curTab; const tabs = Renderer.utils._tabs; if (!curTab || curTab.label !== tb.label) { if (curTab) curTab.$t.removeClass(`ui-tab__btn-tab-head--active`); Renderer.utils._curTab = tb; tb.$t.addClass(`ui-tab__btn-tab-head--active`); if (curTab) tabs[curTab.label].$content = $pgContent.children().detach(); tabs[tb.label] = tb; if (!tabs[tb.label].$content && tb.fnPopulate) tb.fnPopulate(); else $pgContent.append(tabs[tb.label].$content); if (tb.fnChange) tb.fnChange(); } // If the user clicked a tab, save it as their chosen tab if (isUserInput) Renderer.utils._tabsPreferredLabel = tb.label; }; }); // Avoid displaying a tab button for single tabs if (tabButtons.length !== 1) tabButtons.slice().reverse().forEach(tb => $wrpTabs.prepend(tb.$t)); // If there was no previous selection, select the first tab if (!Renderer.utils._tabsPreferredLabel) return tabButtons[0].fnActivateTab(); // If the exact tab exist, select it const tabButton = tabButtons.find(tb => tb.label === Renderer.utils._tabsPreferredLabel); if (tabButton) return tabButton.fnActivateTab(); // If the user's preferred tab is not present, find the closest tab, and activate it instead. // Always prefer later tabs. const ixDesired = tabLabelReference.indexOf(Renderer.utils._tabsPreferredLabel); if (!~ixDesired) return tabButtons[0].fnActivateTab(); // Should never occur const ixsAvailableMetas = tabButtons .map(tb => { const ixMapped = tabLabelReference.indexOf(tb.label); if (!~ixMapped) return null; return { ixMapped, label: tb.label, }; }) .filter(Boolean); if (!ixsAvailableMetas.length) return tabButtons[0].fnActivateTab(); // Should never occur // Find a later tab and activate it, if possible const ixMetaHigher = ixsAvailableMetas.find(({ixMapped}) => ixMapped > ixDesired); if (ixMetaHigher != null) return (tabButtons.find(it => it.label === ixMetaHigher.label) || tabButtons[0]).fnActivateTab(); // Otherwise, click the highest tab const ixMetaMax = ixsAvailableMetas.last(); (tabButtons.find(it => it.label === ixMetaMax.label) || tabButtons[0]).fnActivateTab(); } static _pronounceButtonsBound = false; static bindPronounceButtons () { if (Renderer.utils._pronounceButtonsBound) return; Renderer.utils._pronounceButtonsBound = true; $(`body`).on("click", ".btn-name-pronounce", function () { const audio = $(this).find(`.name-pronounce`)[0]; audio.currentTime = 0; audio.play(); }); } static async pHasFluffText (entity, prop) { return entity.hasFluff || ((await Renderer.utils.pGetPredefinedFluff(entity, prop))?.entries?.length || 0) > 0; } static async pHasFluffImages (entity, prop) { return entity.hasFluffImages || (((await Renderer.utils.pGetPredefinedFluff(entity, prop))?.images?.length || 0) > 0); } /** * @param entry Data entry to search for fluff on, e.g. a monster * @param prop The fluff index reference prop, e.g. `"monsterFluff"` */ static async pGetPredefinedFluff (entry, prop) { if (!entry.fluff) return null; const mappedProp = `_${prop}`; const mappedPropAppend = `_append${prop.uppercaseFirst()}`; const fluff = {}; const assignPropsIfExist = (fromObj, ...props) => { props.forEach(prop => { if (fromObj[prop]) fluff[prop] = fromObj[prop]; }); }; assignPropsIfExist(entry.fluff, "name", "type", "entries", "images"); if (entry.fluff[mappedProp]) { const fromList = [ ...((await PrereleaseUtil.pGetBrewProcessed())[prop] || []), ...((await BrewUtil2.pGetBrewProcessed())[prop] || []), ] .find(it => it.name === entry.fluff[mappedProp].name && it.source === entry.fluff[mappedProp].source, ); if (fromList) { assignPropsIfExist(fromList, "name", "type", "entries", "images"); } } if (entry.fluff[mappedPropAppend]) { const fromList = [ ...((await PrereleaseUtil.pGetBrewProcessed())[prop] || []), ...((await BrewUtil2.pGetBrewProcessed())[prop] || []), ] .find(it => it.name === entry.fluff[mappedPropAppend].name && it.source === entry.fluff[mappedPropAppend].source, ); if (fromList) { if (fromList.entries) { fluff.entries = MiscUtil.copyFast(fluff.entries || []); fluff.entries.push(...MiscUtil.copyFast(fromList.entries)); } if (fromList.images) { fluff.images = MiscUtil.copyFast(fluff.images || []); fluff.images.push(...MiscUtil.copyFast(fromList.images)); } } } return fluff; } static async pGetFluff ({entity, pFnPostProcess, fnGetFluffData, fluffUrl, fluffBaseUrl, fluffProp} = {}) { let predefinedFluff = await Renderer.utils.pGetPredefinedFluff(entity, fluffProp); if (predefinedFluff) { if (pFnPostProcess) predefinedFluff = await pFnPostProcess(predefinedFluff); return predefinedFluff; } if (!fnGetFluffData && !fluffBaseUrl && !fluffUrl) return null; const fluffIndex = fluffBaseUrl ? await DataUtil.loadJSON(`${Renderer.get().baseUrl}${fluffBaseUrl}fluff-index.json`) : null; if (fluffIndex && !fluffIndex[entity.source]) return null; const data = fnGetFluffData ? await fnGetFluffData() : fluffIndex && fluffIndex[entity.source] ? await DataUtil.loadJSON(`${Renderer.get().baseUrl}${fluffBaseUrl}${fluffIndex[entity.source]}`) : await DataUtil.loadJSON(`${Renderer.get().baseUrl}${fluffUrl}`); if (!data) return null; let fluff = (data[fluffProp] || []).find(it => it.name === entity.name && it.source === entity.source); if (!fluff && entity._versionBase_name && entity._versionBase_source) fluff = (data[fluffProp] || []).find(it => it.name === entity._versionBase_name && it.source === entity._versionBase_source); if (!fluff) return null; // Avoid modifying the original object if (pFnPostProcess) fluff = await pFnPostProcess(fluff); return fluff; } static _TITLE_SKIP_TYPES = new Set(["entries", "section"]); /** * @param isImageTab True if this is the "Images" tab, false otherwise * @param $content The statblock wrapper * @param entity Entity to build tab for (e.g. a monster; an item) * @param pFnGetFluff Function which gets the entity's fluff. * @param $headerControls */ static async pBuildFluffTab ({isImageTab, $content, entity, $headerControls, pFnGetFluff} = {}) { $content.append(Renderer.utils.getBorderTr()); $content.append(Renderer.utils.getNameTr(entity, {controlRhs: $headerControls, asJquery: true})); const $td = $(``); $$`${$td}`.appendTo($content); $content.append(Renderer.utils.getBorderTr()); const fluff = MiscUtil.copyFast((await pFnGetFluff(entity)) || {}); fluff.entries = fluff.entries || [Renderer.utils.HTML_NO_INFO]; fluff.images = fluff.images || [Renderer.utils.HTML_NO_IMAGES]; $td.fastSetHtml(Renderer.utils.getFluffTabContent({entity, fluff, isImageTab})); } static getFluffTabContent ({entity, fluff, isImageTab = false}) { Renderer.get().setFirstSection(true); return (fluff[isImageTab ? "images" : "entries"] || []).map((ent, i) => { if (isImageTab) return Renderer.get().render(ent); // If the first entry has a name, and it matches the name of the statblock, remove it to avoid having two // of the same title stacked on top of each other. if (i === 0 && ent.name && entity.name && (Renderer.utils._TITLE_SKIP_TYPES).has(ent.type)) { const entryLowName = ent.name.toLowerCase().trim(); const entityLowName = entity.name.toLowerCase().trim(); if (entryLowName.includes(entityLowName) || entityLowName.includes(entryLowName)) { const cpy = MiscUtil.copyFast(ent); delete cpy.name; return Renderer.get().render(cpy); } else return Renderer.get().render(ent); } else { if (typeof ent === "string") return `

    ${Renderer.get().render(ent)}

    `; else return Renderer.get().render(ent); } }).join(""); } static HTML_NO_INFO = "No information available."; static HTML_NO_IMAGES = "No images available."; static prerequisite = class { static _WEIGHTS = [ "level", "pact", "patron", "spell", "race", "alignment", "ability", "proficiency", "spellcasting", "spellcasting2020", "spellcastingFeature", "spellcastingPrepared", "psionics", "feature", "feat", "background", "item", "itemType", "itemProperty", "campaign", "group", "other", "otherSummary", undefined, ] .mergeMap((k, i) => ({[k]: i})); static _getShortClassName (className) { // remove all the vowels except the first const ixFirstVowel = /[aeiou]/.exec(className).index; const start = className.slice(0, ixFirstVowel + 1); let end = className.slice(ixFirstVowel + 1); end = end.replace(/[aeiou]/g, ""); return `${start}${end}`.toTitleCase(); } static getHtml (prerequisites, {isListMode = false, blocklistKeys = new Set(), isTextOnly = false, isSkipPrefix = false} = {}) { if (!prerequisites?.length) return isListMode ? "\u2014" : ""; const prereqsShared = prerequisites.length === 1 ? {} : Object.entries( prerequisites .slice(1) .reduce((a, b) => CollectionUtil.objectIntersect(a, b), prerequisites[0]), ) .filter(([k, v]) => prerequisites.every(pre => CollectionUtil.deepEquals(pre[k], v))) .mergeMap(([k, v]) => ({[k]: v})); const shared = Object.keys(prereqsShared).length ? this.getHtml([prereqsShared], {isListMode, blocklistKeys, isTextOnly, isSkipPrefix: true}) : null; let cntPrerequisites = 0; let hasNote = false; const listOfChoices = prerequisites .map(pr => { // Never include notes in list mode const ptNote = !isListMode && pr.note ? Renderer.get().render(pr.note) : null; if (ptNote) { hasNote = true; } const prereqsToJoin = Object.entries(pr) .filter(([k]) => !prereqsShared[k]) .sort(([kA], [kB]) => this._WEIGHTS[kA] - this._WEIGHTS[kB]) .map(([k, v]) => { if (k === "note" || blocklistKeys.has(k)) return false; cntPrerequisites += 1; switch (k) { case "level": return this._getHtml_level({v, isListMode, isTextOnly}); case "pact": return this._getHtml_pact({v, isListMode, isTextOnly}); case "patron": return this._getHtml_patron({v, isListMode, isTextOnly}); case "spell": return this._getHtml_spell({v, isListMode, isTextOnly}); case "feat": return this._getHtml_feat({v, isListMode, isTextOnly}); case "feature": return this._getHtml_feature({v, isListMode, isTextOnly}); case "item": return this._getHtml_item({v, isListMode, isTextOnly}); case "itemType": return this._getHtml_itemType({v, isListMode, isTextOnly}); case "itemProperty": return this._getHtml_itemProperty({v, isListMode, isTextOnly}); case "otherSummary": return this._getHtml_otherSummary({v, isListMode, isTextOnly}); case "other": return this._getHtml_other({v, isListMode, isTextOnly}); case "race": return this._getHtml_race({v, isListMode, isTextOnly}); case "background": return this._getHtml_background({v, isListMode, isTextOnly}); case "ability": return this._getHtml_ability({v, isListMode, isTextOnly}); case "proficiency": return this._getHtml_proficiency({v, isListMode, isTextOnly}); case "spellcasting": return this._getHtml_spellcasting({v, isListMode, isTextOnly}); case "spellcasting2020": return this._getHtml_spellcasting2020({v, isListMode, isTextOnly}); case "spellcastingFeature": return this._getHtml_spellcastingFeature({v, isListMode, isTextOnly}); case "spellcastingPrepared": return this._getHtml_spellcastingPrepared({v, isListMode, isTextOnly}); case "psionics": return this._getHtml_psionics({v, isListMode, isTextOnly}); case "alignment": return this._getHtml_alignment({v, isListMode, isTextOnly}); case "campaign": return this._getHtml_campaign({v, isListMode, isTextOnly}); case "group": return this._getHtml_group({v, isListMode, isTextOnly}); default: throw new Error(`Unhandled key: ${k}`); } }) .filter(Boolean); const ptPrereqs = prereqsToJoin .join(prereqsToJoin.some(it => / or /.test(it)) ? "; " : ", "); return [ptPrereqs, ptNote] .filter(Boolean) .join(". "); }) .filter(Boolean); if (!listOfChoices.length && !shared) return isListMode ? "\u2014" : ""; if (isListMode) return [shared, listOfChoices.join("/")].filter(Boolean).join(" + "); const sharedSuffix = MiscUtil.findCommonSuffix(listOfChoices, {isRespectWordBoundaries: true}); const listOfChoicesTrimmed = sharedSuffix ? listOfChoices.map(it => it.slice(0, -sharedSuffix.length)) : listOfChoices; const joinedChoices = ( hasNote ? listOfChoicesTrimmed.join(" Or, ") : listOfChoicesTrimmed.joinConjunct(listOfChoicesTrimmed.some(it => / or /.test(it)) ? "; " : ", ", " or ") ) + sharedSuffix; return `${isSkipPrefix ? "" : `Prerequisite${cntPrerequisites === 1 ? "" : "s"}: `}${[shared, joinedChoices].filter(Boolean).join(", plus ")}`; } static _getHtml_level ({v, isListMode}) { // a generic level requirement if (typeof v === "number") { if (isListMode) return `Lvl ${v}`; else return `${Parser.getOrdinalForm(v)} level`; } else if (!v.class && !v.subclass) { if (isListMode) return `Lvl ${v.level}`; else return `${Parser.getOrdinalForm(v.level)} level`; } const isLevelVisible = v.level !== 1; // Hide the "implicit" 1st level. const isSubclassVisible = v.subclass && v.subclass.visible; const isClassVisible = v.class && (v.class.visible || isSubclassVisible); // force the class name to be displayed if there's a subclass being displayed if (isListMode) { const shortNameRaw = isClassVisible ? this._getShortClassName(v.class.name) : null; return `${isClassVisible ? `${shortNameRaw.slice(0, 4)}${isSubclassVisible ? "*" : "."}` : ""}${isLevelVisible ? ` Lvl ${v.level}` : ""}`; } else { let classPart = ""; if (isClassVisible && isSubclassVisible) classPart = ` ${v.class.name} (${v.subclass.name})`; else if (isClassVisible) classPart = ` ${v.class.name}`; else if (isSubclassVisible) classPart = ` <remember to insert class name here> (${v.subclass.name})`; // :^) return `${isLevelVisible ? `${Parser.getOrdinalForm(v.level)} level` : ""}${isClassVisible ? ` ${classPart}` : ""}`; } } static _getHtml_pact ({v, isListMode}) { return Parser.prereqPactToFull(v); } static _getHtml_patron ({v, isListMode}) { return isListMode ? `${Parser.prereqPatronToShort(v)} patron` : `${v} patron`; } static _getHtml_spell ({v, isListMode, isTextOnly}) { return isListMode ? v.map(sp => { if (typeof sp === "string") return sp.split("#")[0].split("|")[0].toTitleCase(); return sp.entrySummary || sp.entry; }) .join("/") : v.map(sp => { if (typeof sp === "string") return Parser.prereqSpellToFull(sp, {isTextOnly}); return isTextOnly ? Renderer.stripTags(sp.entry) : Renderer.get().render(`{@filter ${sp.entry}|spells|${sp.choose}}`); }) .joinConjunct(", ", " or "); } static _getHtml_feat ({v, isListMode, isTextOnly}) { return isListMode ? v.map(x => x.split("|")[0].toTitleCase()).join("/") : v.map(it => (isTextOnly ? Renderer.stripTags.bind(Renderer) : Renderer.get().render.bind(Renderer.get()))(`{@feat ${it}} feat`)).joinConjunct(", ", " or "); } static _getHtml_feature ({v, isListMode, isTextOnly}) { return isListMode ? v.map(x => Renderer.stripTags(x).toTitleCase()).join("/") : v.map(it => isTextOnly ? Renderer.stripTags(it) : Renderer.get().render(it)).joinConjunct(", ", " or "); } static _getHtml_item ({v, isListMode}) { return isListMode ? v.map(x => x.toTitleCase()).join("/") : v.joinConjunct(", ", " or "); } static _getHtml_itemType ({v, isListMode}) { return isListMode ? v .map(it => Renderer.item.getType(it)) .map(it => it?.abbreviation) .join("+") : v .map(it => Renderer.item.getType(it)) .map(it => it?.name?.toTitleCase()) .joinConjunct(", ", " and "); } static _getHtml_itemProperty ({v, isListMode}) { if (v == null) return isListMode ? "No Prop." : "No Other Properties"; return isListMode ? v .map(it => Renderer.item.getProperty(it)) .map(it => it?.abbreviation) .join("+") : ( `${v .map(it => Renderer.item.getProperty(it)) .map(it => it?.name?.toTitleCase()) .joinConjunct(", ", " and ") } Property` ); } static _getHtml_otherSummary ({v, isListMode, isTextOnly}) { return isListMode ? (v.entrySummary || Renderer.stripTags(v.entry)) : (isTextOnly ? Renderer.stripTags(v.entry) : Renderer.get().render(v.entry)); } static _getHtml_other ({v, isListMode, isTextOnly}) { return isListMode ? "Special" : (isTextOnly ? Renderer.stripTags(v) : Renderer.get().render(v)); } static _getHtml_race ({v, isListMode, isTextOnly}) { const parts = v.map((it, i) => { if (isListMode) { return `${it.name.toTitleCase()}${it.subrace != null ? ` (${it.subrace})` : ""}`; } else { const raceName = it.displayEntry ? (isTextOnly ? Renderer.stripTags(it.displayEntry) : Renderer.get().render(it.displayEntry)) : i === 0 ? it.name.toTitleCase() : it.name; return `${raceName}${it.subrace != null ? ` (${it.subrace})` : ""}`; } }); return isListMode ? parts.join("/") : parts.joinConjunct(", ", " or "); } static _getHtml_background ({v, isListMode, isTextOnly}) { const parts = v.map((it, i) => { if (isListMode) { return `${it.name.toTitleCase()}`; } else { return it.displayEntry ? (isTextOnly ? Renderer.stripTags(it.displayEntry) : Renderer.get().render(it.displayEntry)) : i === 0 ? it.name.toTitleCase() : it.name; } }); return isListMode ? parts.join("/") : parts.joinConjunct(", ", " or "); } static _getHtml_ability ({v, isListMode, isTextOnly}) { // `v` is an array or objects with str/dex/... properties; array is "OR"'d togther, object is "AND"'d together let hadMultipleInner = false; let hadMultiMultipleInner = false; let allValuesEqual = null; outer: for (const abMeta of v) { for (const req of Object.values(abMeta)) { if (allValuesEqual == null) allValuesEqual = req; else { if (req !== allValuesEqual) { allValuesEqual = null; break outer; } } } } const abilityOptions = v.map(abMeta => { if (allValuesEqual) { const abList = Object.keys(abMeta); hadMultipleInner = hadMultipleInner || abList.length > 1; return isListMode ? abList.map(ab => ab.uppercaseFirst()).join(", ") : abList.map(ab => Parser.attAbvToFull(ab)).joinConjunct(", ", " and "); } else { const groups = {}; Object.entries(abMeta).forEach(([ab, req]) => { (groups[req] = groups[req] || []).push(ab); }); let isMulti = false; const byScore = Object.entries(groups) .sort(([reqA], [reqB]) => SortUtil.ascSort(Number(reqB), Number(reqA))) .map(([req, abs]) => { hadMultipleInner = hadMultipleInner || abs.length > 1; if (abs.length > 1) hadMultiMultipleInner = isMulti = true; abs = abs.sort(SortUtil.ascSortAtts); return isListMode ? `${abs.map(ab => ab.uppercaseFirst()).join(", ")} ${req}+` : `${abs.map(ab => Parser.attAbvToFull(ab)).joinConjunct(", ", " and ")} ${req} or higher`; }); return isListMode ? `${isMulti || byScore.length > 1 ? "(" : ""}${byScore.join(" & ")}${isMulti || byScore.length > 1 ? ")" : ""}` : isMulti ? byScore.joinConjunct("; ", " and ") : byScore.joinConjunct(", ", " and "); } }); // if all values were equal, add the "X+" text at the end, as the options render doesn't include it if (isListMode) { return `${abilityOptions.join("/")}${allValuesEqual != null ? ` ${allValuesEqual}+` : ""}`; } else { const isComplex = hadMultiMultipleInner || hadMultipleInner || allValuesEqual == null; const joined = abilityOptions.joinConjunct( hadMultiMultipleInner ? " - " : hadMultipleInner ? "; " : ", ", isComplex ? (isTextOnly ? ` /or/ ` : ` or `) : " or ", ); return `${joined}${allValuesEqual != null ? ` ${allValuesEqual} or higher` : ""}`; } } static _getHtml_proficiency ({v, isListMode}) { const parts = v.map(obj => { return Object.entries(obj).map(([profType, prof]) => { switch (profType) { case "armor": { return isListMode ? `Prof ${Parser.armorFullToAbv(prof)} armor` : `Proficiency with ${prof} armor`; } case "weapon": { return isListMode ? `Prof ${Parser.weaponFullToAbv(prof)} weapon` : `Proficiency with a ${prof} weapon`; } case "weaponGroup": { return isListMode ? `Prof ${Parser.weaponFullToAbv(prof)} weapons` : `${prof.toTitleCase()} Proficiency`; } default: throw new Error(`Unhandled proficiency type: "${profType}"`); } }); }); return isListMode ? parts.join("/") : parts.joinConjunct(", ", " or "); } static _getHtml_spellcasting ({v, isListMode}) { return isListMode ? "Spellcasting" : "The ability to cast at least one spell"; } static _getHtml_spellcasting2020 ({v, isListMode}) { return isListMode ? "Spellcasting" : "Spellcasting or Pact Magic feature"; } static _getHtml_spellcastingFeature ({v, isListMode}) { return isListMode ? "Spellcasting" : "Spellcasting Feature"; } static _getHtml_spellcastingPrepared ({v, isListMode}) { return isListMode ? "Spellcasting" : "Spellcasting feature from a class that prepares spells"; } static _getHtml_psionics ({v, isListMode, isTextOnly}) { return isListMode ? "Psionics" : (isTextOnly ? Renderer.stripTags : Renderer.get().render.bind(Renderer.get()))("Psionic Talent feature or Wild Talent feat"); } static _getHtml_alignment ({v, isListMode}) { return isListMode ? Parser.alignmentListToFull(v) .replace(/\bany\b/gi, "").trim() .replace(/\balignment\b/gi, "align").trim() .toTitleCase() : Parser.alignmentListToFull(v); } static _getHtml_campaign ({v, isListMode}) { return isListMode ? v.join("/") : `${v.joinConjunct(", ", " or ")} Campaign`; } static _getHtml_group ({v, isListMode}) { return isListMode ? v.map(it => it.toTitleCase()).join("/") : `${v.map(it => it.toTitleCase()).joinConjunct(", ", " or ")} Group`; } }; static getRepeatableEntry (ent) { if (!ent.repeatable) return null; return `{@b Repeatable:} ${ent.repeatableNote || (ent.repeatable ? "Yes" : "No")}`; } static getRepeatableHtml (ent, {isListMode = false} = {}) { const entryRepeatable = Renderer.utils.getRepeatableEntry(ent); if (entryRepeatable == null) return isListMode ? "\u2014" : ""; return Renderer.get().render(entryRepeatable); } static getRenderedSize (size) { return [...(size ? [size].flat() : [])] .sort(SortUtil.ascSortSize) .map(sz => Parser.sizeAbvToFull(sz)) .joinConjunct(", ", " or "); } static _FN_TAG_SENSES = null; static _SENSE_TAG_METAS = null; static getSensesEntry (senses) { if (typeof senses === "string") senses = [senses]; // handle legacy format if (!Renderer.utils._FN_TAG_SENSES) { Renderer.utils._SENSE_TAG_METAS = [ ...MiscUtil.copyFast(Parser.SENSES), ...(PrereleaseUtil.getBrewProcessedFromCache("sense") || []), ...(BrewUtil2.getBrewProcessedFromCache("sense") || []), ]; const seenNames = new Set(); Renderer.utils._SENSE_TAG_METAS .filter(it => { if (seenNames.has(it.name.toLowerCase())) return false; seenNames.add(it.name.toLowerCase()); return true; }) .forEach(it => it._re = new RegExp(`\\b(?${it.name.escapeRegexp()})\\b`, "gi")); Renderer.utils._FN_TAG_SENSES = str => { Renderer.utils._SENSE_TAG_METAS .forEach(({name, source, _re}) => str = str.replace(_re, (...m) => `{@sense ${m.last().sense}|${source}}`)); return str; }; } return senses .map(str => { const tagSplit = Renderer.splitByTags(str); str = ""; const len = tagSplit.length; for (let i = 0; i < len; ++i) { const s = tagSplit[i]; if (!s) continue; if (s.startsWith("{@")) { str += s; continue; } str += Renderer.utils._FN_TAG_SENSES(s); } return str; }) .join(", ") .replace(/(^| |\()(blind|blinded)(\)| |$)/gi, (...m) => `${m[1]}{@condition blinded||${m[2]}}${m[3]}`); } static getRenderedSenses (senses, isPlainText) { const sensesEntry = Renderer.utils.getSensesEntry(senses); if (isPlainText) return Renderer.stripTags(sensesEntry); return Renderer.get().render(sensesEntry); } static getEntryMediaUrl (entry, prop, mediaDir) { if (!entry[prop]) return ""; let href = ""; if (entry[prop].type === "internal") { href = UrlUtil.link(Renderer.get().getMediaUrl(mediaDir, entry[prop].path)); } else if (entry[prop].type === "external") { href = entry[prop].url; } return href; } static getTagEntry (tag, text) { switch (tag) { case "@dice": case "@autodice": case "@damage": case "@hit": case "@d20": case "@chance": case "@recharge": { const fauxEntry = { type: "dice", rollable: true, }; const [rollText, displayText, name, ...others] = Renderer.splitTagByPipe(text); if (displayText) fauxEntry.displayText = displayText; if ((!fauxEntry.displayText && (rollText || "").includes("summonSpellLevel")) || (fauxEntry.displayText && fauxEntry.displayText.includes("summonSpellLevel"))) fauxEntry.displayText = (fauxEntry.displayText || rollText || "").replace(/summonSpellLevel/g, "the spell's level"); if ((!fauxEntry.displayText && (rollText || "").includes("summonClassLevel")) || (fauxEntry.displayText && fauxEntry.displayText.includes("summonClassLevel"))) fauxEntry.displayText = (fauxEntry.displayText || rollText || "").replace(/summonClassLevel/g, "your class level"); if (name) fauxEntry.name = name; switch (tag) { case "@dice": case "@autodice": case "@damage": { // format: {@dice 1d2 + 3 + 4d5 - 6} fauxEntry.toRoll = rollText; if (!fauxEntry.displayText && (rollText || "").includes(";")) fauxEntry.displayText = rollText.replace(/;/g, "/"); if ((!fauxEntry.displayText && (rollText || "").includes("#$")) || (fauxEntry.displayText && fauxEntry.displayText.includes("#$"))) fauxEntry.displayText = (fauxEntry.displayText || rollText).replace(/#\$prompt_number[^$]*\$#/g, "(n)"); fauxEntry.displayText = fauxEntry.displayText || fauxEntry.toRoll; if (tag === "@damage") fauxEntry.subType = "damage"; if (tag === "@autodice") fauxEntry.autoRoll = true; return fauxEntry; } case "@d20": case "@hit": { // format: {@hit +1} or {@hit -2} let mod; if (!isNaN(rollText)) { const n = Number(rollText); mod = `${n >= 0 ? "+" : ""}${n}`; } else mod = /^\s+[-+]/.test(rollText) ? rollText : `+${rollText}`; fauxEntry.displayText = fauxEntry.displayText || mod; fauxEntry.toRoll = `1d20${mod}`; fauxEntry.subType = "d20"; fauxEntry.d20mod = mod; if (tag === "@hit") fauxEntry.context = {type: "hit"}; return fauxEntry; } case "@chance": { // format: {@chance 25|display text|rollbox rollee name|success text|failure text} const [textSuccess, textFailure] = others; fauxEntry.toRoll = `1d100`; fauxEntry.successThresh = Number(rollText); fauxEntry.chanceSuccessText = textSuccess; fauxEntry.chanceFailureText = textFailure; return fauxEntry; } case "@recharge": { // format: {@recharge 4|flags} const flags = displayText ? displayText.split("") : null; // "m" for "minimal" = no brackets fauxEntry.toRoll = "1d6"; const asNum = Number(rollText || 6); fauxEntry.successThresh = 7 - asNum; fauxEntry.successMax = 6; fauxEntry.displayText = `${asNum}${asNum < 6 ? `\u20136` : ""}`; fauxEntry.chanceSuccessText = "Recharged!"; fauxEntry.chanceFailureText = "Did not recharge"; fauxEntry.isColorSuccessFail = true; return fauxEntry; } } return fauxEntry; } case "@ability": // format: {@ability str 20} or {@ability str 20|Display Text} or {@ability str 20|Display Text|Roll Name Text} case "@savingThrow": { // format: {@savingThrow str 5} or {@savingThrow str 5|Display Text} or {@savingThrow str 5|Display Text|Roll Name Text} const fauxEntry = { type: "dice", rollable: true, subType: "d20", context: {type: tag === "@ability" ? "abilityCheck" : "savingThrow"}, }; const [abilAndScoreOrScore, displayText, name, ...others] = Renderer.splitTagByPipe(text); let [abil, ...rawScoreOrModParts] = abilAndScoreOrScore.split(" ").map(it => it.trim()).filter(Boolean); abil = abil.toLowerCase(); fauxEntry.context.ability = abil; if (name) fauxEntry.name = name; else { if (tag === "@ability") fauxEntry.name = Parser.attAbvToFull(abil); else if (tag === "@savingThrow") fauxEntry.name = `${Parser.attAbvToFull(abil)} save`; } const rawScoreOrMod = rawScoreOrModParts.join(" "); // Saving throws can have e.g. `+ PB` if (isNaN(rawScoreOrMod) && tag === "@savingThrow") { if (displayText) fauxEntry.displayText = displayText; else fauxEntry.displayText = rawScoreOrMod; fauxEntry.toRoll = `1d20${rawScoreOrMod}`; fauxEntry.d20mod = rawScoreOrMod; } else { const scoreOrMod = Number(rawScoreOrMod) || 0; const mod = (tag === "@ability" ? Parser.getAbilityModifier : UiUtil.intToBonus)(scoreOrMod); if (displayText) fauxEntry.displayText = displayText; else { if (tag === "@ability") fauxEntry.displayText = `${scoreOrMod} (${mod})`; else fauxEntry.displayText = mod; } fauxEntry.toRoll = `1d20${mod}`; fauxEntry.d20mod = mod; } return fauxEntry; } // format: {@skillCheck animal_handling 5} or {@skillCheck animal_handling 5|Display Text} // or {@skillCheck animal_handling 5|Display Text|Roll Name Text} case "@skillCheck": { const fauxEntry = { type: "dice", rollable: true, subType: "d20", context: {type: "skillCheck"}, }; const [skillAndMod, displayText, name, ...others] = Renderer.splitTagByPipe(text); const parts = skillAndMod.split(" ").map(it => it.trim()).filter(Boolean); const namePart = parts.shift(); const bonusPart = parts.join(" "); const skill = namePart.replace(/_/g, " "); let mod = bonusPart; if (!isNaN(bonusPart)) mod = UiUtil.intToBonus(Number(bonusPart) || 0); else if (bonusPart.startsWith("#$")) mod = `+${bonusPart}`; fauxEntry.context.skill = skill; fauxEntry.displayText = displayText || mod; if (name) fauxEntry.name = name; else fauxEntry.name = skill.toTitleCase(); fauxEntry.toRoll = `1d20${mod}`; fauxEntry.d20mod = mod; return fauxEntry; } // format: {@coinflip} or {@coinflip display text|rollbox rollee name|success text|failure text} case "@coinflip": { const [displayText, name, textSuccess, textFailure] = Renderer.splitTagByPipe(text); const fauxEntry = { type: "dice", toRoll: "1d2", successThresh: 1, successMax: 2, displayText: displayText || "flip a coin", chanceSuccessText: textSuccess || `Heads`, chanceFailureText: textFailure || `Tails`, isColorSuccessFail: !textSuccess && !textFailure, rollable: true, }; return fauxEntry; } default: throw new Error(`Unhandled tag "${tag}"`); } } static getTagMeta (tag, text) { switch (tag) { case "@deity": { let [name, pantheon, source, displayText, ...others] = Renderer.splitTagByPipe(text); pantheon = pantheon || "forgotten realms"; source = source || Parser.getTagSource(tag, source); const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_DEITIES]({name, pantheon, source}); return { name, displayText, others, page: UrlUtil.PG_DEITIES, source, hash, hashPreEncoded: true, }; } case "@card": { const unpacked = DataUtil.deck.unpackUidCard(text); const {name, set, source, displayText} = unpacked; const hash = UrlUtil.URL_TO_HASH_BUILDER["card"]({name, set, source}); return { name, displayText, isFauxPage: true, page: "card", source, hash, hashPreEncoded: true, }; } case "@classFeature": { const unpacked = DataUtil.class.unpackUidClassFeature(text); const classPageHash = `${UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES]({name: unpacked.className, source: unpacked.classSource})}${HASH_PART_SEP}${UrlUtil.getClassesPageStatePart({feature: {ixLevel: unpacked.level - 1, ixFeature: 0}})}`; return { name: unpacked.name, displayText: unpacked.displayText, page: UrlUtil.PG_CLASSES, source: unpacked.source, hash: classPageHash, hashPreEncoded: true, pageHover: "classfeature", hashHover: UrlUtil.URL_TO_HASH_BUILDER["classFeature"](unpacked), hashPreEncodedHover: true, }; } case "@subclassFeature": { const unpacked = DataUtil.class.unpackUidSubclassFeature(text); const classPageHash = `${UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES]({name: unpacked.className, source: unpacked.classSource})}${HASH_PART_SEP}${UrlUtil.getClassesPageStatePart({feature: {ixLevel: unpacked.level - 1, ixFeature: 0}})}`; return { name: unpacked.name, displayText: unpacked.displayText, page: UrlUtil.PG_CLASSES, source: unpacked.source, hash: classPageHash, hashPreEncoded: true, pageHover: "subclassfeature", hashHover: UrlUtil.URL_TO_HASH_BUILDER["subclassFeature"](unpacked), hashPreEncodedHover: true, }; } case "@quickref": { const unpacked = DataUtil.quickreference.unpackUid(text); const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_QUICKREF](unpacked); return { name: unpacked.name, displayText: unpacked.displayText, page: UrlUtil.PG_QUICKREF, source: unpacked.source, hash, hashPreEncoded: true, }; } default: return Renderer.utils._getTagMeta_generic(tag, text); } } static _getTagMeta_generic (tag, text) { const {name, source, displayText, others} = DataUtil.generic.unpackUid(text, tag); const hash = UrlUtil.encodeForHash([name, source]); const out = { name, displayText, others, page: null, source, hash, preloadId: null, subhashes: null, linkText: null, hashPreEncoded: true, }; switch (tag) { case "@spell": out.page = UrlUtil.PG_SPELLS; break; case "@item": out.page = UrlUtil.PG_ITEMS; break; case "@condition": case "@disease": case "@status": out.page = UrlUtil.PG_CONDITIONS_DISEASES; break; case "@background": out.page = UrlUtil.PG_BACKGROUNDS; break; case "@race": out.page = UrlUtil.PG_RACES; break; case "@optfeature": out.page = UrlUtil.PG_OPT_FEATURES; break; case "@reward": out.page = UrlUtil.PG_REWARDS; break; case "@feat": out.page = UrlUtil.PG_FEATS; break; case "@psionic": out.page = UrlUtil.PG_PSIONICS; break; case "@object": out.page = UrlUtil.PG_OBJECTS; break; case "@boon": case "@cult": out.page = UrlUtil.PG_CULTS_BOONS; break; case "@trap": case "@hazard": out.page = UrlUtil.PG_TRAPS_HAZARDS; break; case "@variantrule": out.page = UrlUtil.PG_VARIANTRULES; break; case "@table": out.page = UrlUtil.PG_TABLES; break; case "@vehicle": case "@vehupgrade": out.page = UrlUtil.PG_VEHICLES; break; case "@action": out.page = UrlUtil.PG_ACTIONS; break; case "@language": out.page = UrlUtil.PG_LANGUAGES; break; case "@charoption": out.page = UrlUtil.PG_CHAR_CREATION_OPTIONS; break; case "@recipe": out.page = UrlUtil.PG_RECIPES; break; case "@deck": out.page = UrlUtil.PG_DECKS; break; case "@legroup": { out.page = "legendaryGroup"; out.isFauxPage = true; break; } case "@creature": { out.page = UrlUtil.PG_BESTIARY; // "...|scaled=scaledCr}" or "...|scaledsummon=scaledSummonLevel}" if (others.length) { const [type, value] = others[0].split("=").map(it => it.trim().toLowerCase()).filter(Boolean); if (type && value) { switch (type) { case VeCt.HASH_SCALED: { const targetCrNum = Parser.crToNumber(value); out.preloadId = Renderer.monster.getCustomHashId({name, source, _isScaledCr: true, _scaledCr: targetCrNum}); out.subhashes = [ {key: VeCt.HASH_SCALED, value: targetCrNum}, ]; out.linkText = displayText || `${name} (CR ${value})`; break; } case VeCt.HASH_SCALED_SPELL_SUMMON: { const scaledSpellNum = Number(value); out.preloadId = Renderer.monster.getCustomHashId({name, source, _isScaledSpellSummon: true, _scaledSpellSummonLevel: scaledSpellNum}); out.subhashes = [ {key: VeCt.HASH_SCALED_SPELL_SUMMON, value: scaledSpellNum}, ]; out.linkText = displayText || `${name} (Spell Level ${value})`; break; } case VeCt.HASH_SCALED_CLASS_SUMMON: { const scaledClassNum = Number(value); out.preloadId = Renderer.monster.getCustomHashId({name, source, _isScaledClassSummon: true, _scaledClassSummonLevel: scaledClassNum}); out.subhashes = [ {key: VeCt.HASH_SCALED_CLASS_SUMMON, value: scaledClassNum}, ]; out.linkText = displayText || `${name} (Class Level ${value})`; break; } } } } break; } case "@class": { out.page = UrlUtil.PG_CLASSES; if (others.length) { const [subclassShortName, subclassSource, featurePart] = others; if (subclassSource) out.source = subclassSource; const classStateOpts = { subclass: { shortName: subclassShortName.trim(), source: subclassSource ? subclassSource.trim() : Parser.SRC_PHB, }, }; // Don't include the feature part for hovers, as it is unsupported const hoverSubhashObj = UrlUtil.unpackSubHash(UrlUtil.getClassesPageStatePart(classStateOpts)); out.subhashesHover = [{key: "state", value: hoverSubhashObj.state, preEncoded: true}]; if (featurePart) { const featureParts = featurePart.trim().split("-"); classStateOpts.feature = { ixLevel: featureParts[0] || "0", ixFeature: featureParts[1] || "0", }; } const subhashObj = UrlUtil.unpackSubHash(UrlUtil.getClassesPageStatePart(classStateOpts)); out.subhashes = [ {key: "state", value: subhashObj.state.join(HASH_SUB_LIST_SEP), preEncoded: true}, {key: "fltsource", value: "clear"}, {key: "flstmiscellaneous", value: "clear"}, ]; } break; } case "@skill": { out.isFauxPage = true; out.page = "skill"; break; } case "@sense": { out.isFauxPage = true; out.page = "sense"; break; } case "@itemMastery": { out.isFauxPage = true; out.page = "itemMastery"; break; } case "@cite": { out.isFauxPage = true; out.page = "citation"; break; } default: throw new Error(`Unhandled tag "${tag}"`); } return out; } // region Templating static applyTemplate (ent, templateString, {fnPreApply, mapCustom} = {}) { return templateString.replace(/{{([^}]+)}}/g, (fullMatch, strArgs) => { if (fnPreApply) fnPreApply(fullMatch, strArgs); // Special case for damage dice -- need to add @damage tags if (strArgs === "item.dmg1") { return Renderer.item._getTaggedDamage(ent.dmg1); } else if (strArgs === "item.dmg2") { return Renderer.item._getTaggedDamage(ent.dmg2); } if (mapCustom && mapCustom[strArgs]) return mapCustom[strArgs]; const args = strArgs.split(" ").map(arg => arg.trim()).filter(Boolean); // Args can either be a static property, or a function and a static property if (args.length === 1) { return Renderer.utils._applyTemplate_getValue(ent, args[0]); } else if (args.length === 2) { const val = Renderer.utils._applyTemplate_getValue(ent, args[1]); switch (args[0]) { case "getFullImmRes": return Parser.getFullImmRes(val); default: throw new Error(`Unknown template function "${args[0]}"`); } } else throw new Error(`Unhandled number of arguments ${args.length}`); }); } static _applyTemplate_getValue (ent, prop) { const spl = prop.split("."); switch (spl[0]) { case "item": { const path = spl.slice(1); if (!path.length) return `{@i missing key path}`; return MiscUtil.get(ent, ...path); } default: return `{@i unknown template root: "${spl[0]}"}`; } } // endregion /** * Convert a nested entry structure into a flat list of entry metadata with depth info. **/ static getFlatEntries (entry) { const out = []; const depthStack = []; const recurse = ({obj}) => { let isPopDepth = false; Renderer.ENTRIES_WITH_ENUMERATED_TITLES .forEach(meta => { if (obj.type !== meta.type) return; const kName = "name"; // Note: allow this to be specified on the `meta` if needed in future if (obj[kName] == null) return; isPopDepth = true; const curDepth = depthStack.length ? depthStack.last() : 0; const nxtDepth = meta.depth ? meta.depth : meta.depthIncrement ? curDepth + meta.depthIncrement : curDepth; depthStack.push( Math.min( nxtDepth, 2, ), ); const cpyObj = MiscUtil.copyFast(obj); out.push({ depth: curDepth, entry: cpyObj, key: meta.key, ix: out.length, name: cpyObj.name, }); cpyObj[meta.key] = cpyObj[meta.key].map(child => { if (!child.type) return child; const childMeta = Renderer.ENTRIES_WITH_ENUMERATED_TITLES_LOOKUP[child.type]; if (!childMeta) return child; const kNameChild = "name"; // Note: allow this to be specified on the `meta` if needed in future if (child[kName] == null) return child; // Predict what index the child will have in the output array const ixNextRef = out.length; // Allow the child to add its entries to the output array recurse({obj: child}); // Return a reference pointing forwards to the child's flat data return {IX_FLAT_REF: ixNextRef}; }); }); if (isPopDepth) depthStack.pop(); }; recurse({obj: entry}); return out; } static getLinkSubhashString (subhashes) { let out = ""; const len = subhashes.length; for (let i = 0; i < len; ++i) { const subHash = subhashes[i]; if (subHash.preEncoded) out += `${HASH_PART_SEP}${subHash.key}${HASH_SUB_KV_SEP}`; else out += `${HASH_PART_SEP}${UrlUtil.encodeForHash(subHash.key)}${HASH_SUB_KV_SEP}`; if (subHash.value != null) { if (subHash.preEncoded) out += subHash.value; else out += UrlUtil.encodeForHash(subHash.value); } else { // TODO allow list of values out += subHash.values.map(v => UrlUtil.encodeForHash(v)).join(HASH_SUB_LIST_SEP); } } return out; } static initFullEntries_ (ent, {propEntries = "entries", propFullEntries = "_fullEntries"} = {}) { ent[propFullEntries] = ent[propFullEntries] || (ent[propEntries] ? MiscUtil.copyFast(ent[propEntries]) : []); } static lazy = { _getIntersectionConfig () { return { rootMargin: "150px 0px", // if the element gets within 150px of the viewport threshold: 0.01, }; }, _OBSERVERS: {}, getCreateObserver ({observerId, fnOnObserve}) { if (!Renderer.utils.lazy._OBSERVERS[observerId]) { const observer = Renderer.utils.lazy._OBSERVERS[observerId] = new IntersectionObserver( Renderer.utils.lazy.getFnOnIntersect({ observerId, fnOnObserve, }), Renderer.utils.lazy._getIntersectionConfig(), ); observer._TRACKED = new Set(); observer.track = it => { observer._TRACKED.add(it); return observer.observe(it); }; observer.untrack = it => { observer._TRACKED.delete(it); return observer.unobserve(it); }; // If we try to print a page with e.g. un-loaded images, attempt to load them all first observer._printListener = evt => { if (!observer._TRACKED.size) return; // region Sadly we cannot cancel or delay the print event, so, show a blocking alert [...observer._TRACKED].forEach(it => { observer.untrack(it); fnOnObserve({ observer, entry: { target: it, }, }); }); alert(`All content must be loaded prior to printing. Please cancel the print and wait a few moments for loading to complete!`); // endregion }; window.addEventListener("beforeprint", observer._printListener); } return Renderer.utils.lazy._OBSERVERS[observerId]; }, destroyObserver ({observerId}) { const observer = Renderer.utils.lazy._OBSERVERS[observerId]; if (!observer) return; observer.disconnect(); window.removeEventListener("beforeprint", observer._printListener); }, getFnOnIntersect ({observerId, fnOnObserve}) { return obsEntries => { const observer = Renderer.utils.lazy._OBSERVERS[observerId]; obsEntries.forEach(entry => { // filter observed entries for those that intersect if (entry.intersectionRatio <= 0) return; observer.untrack(entry.target); fnOnObserve({ observer, entry, }); }); }; }, }; }; Renderer.tag = class { static _TagBase = class { tagName; defaultSource = null; page = null; get tag () { return `@${this.tagName}`; } getStripped (tag, text) { text = text.replace(/<\$([^$]+)\$>/gi, ""); // remove any variable tags return this._getStripped(tag, text); } /** @abstract */ _getStripped (tag, text) { throw new Error("Unimplemented!"); } getMeta (tag, text) { return this._getMeta(tag, text); } _getMeta (tag, text) { throw new Error("Unimplemented!"); } }; static _TagBaseAt = class extends this._TagBase { get tag () { return `@${this.tagName}`; } }; static _TagBaseHash = class extends this._TagBase { get tag () { return `#${this.tagName}`; } }; static _TagTextStyle = class extends this._TagBaseAt { _getStripped (tag, text) { return text; } }; static TagBoldShort = class extends this._TagTextStyle { tagName = "b"; }; static TagBoldLong = class extends this._TagTextStyle { tagName = "bold"; }; static TagItalicShort = class extends this._TagTextStyle { tagName = "i"; }; static TagItalicLong = class extends this._TagTextStyle { tagName = "italic"; }; static TagStrikethroughShort = class extends this._TagTextStyle { tagName = "s"; }; static TagStrikethroughLong = class extends this._TagTextStyle { tagName = "strike"; }; static TagUnderlineShort = class extends this._TagTextStyle { tagName = "u"; }; static TagUnderlineLong = class extends this._TagTextStyle { tagName = "underline"; }; static TagSup = class extends this._TagTextStyle { tagName = "sup"; }; static TagSub = class extends this._TagTextStyle { tagName = "sub"; }; static TagKbd = class extends this._TagTextStyle { tagName = "kbd"; }; static TagCode = class extends this._TagTextStyle { tagName = "code"; }; static TagStyle = class extends this._TagTextStyle { tagName = "style"; }; static TagFont = class extends this._TagTextStyle { tagName = "font"; }; static TagComic = class extends this._TagTextStyle { tagName = "comic"; }; static TagComicH1 = class extends this._TagTextStyle { tagName = "comicH1"; }; static TagComicH2 = class extends this._TagTextStyle { tagName = "comicH2"; }; static TagComicH3 = class extends this._TagTextStyle { tagName = "comicH3"; }; static TagComicH4 = class extends this._TagTextStyle { tagName = "comicH4"; }; static TagComicNote = class extends this._TagTextStyle { tagName = "comicNote"; }; static TagNote = class extends this._TagTextStyle { tagName = "note"; }; static TagTip = class extends this._TagTextStyle { tagName = "tip"; }; static TagUnit = class extends this._TagBaseAt { tagName = "unit"; _getStripped (tag, text) { const [amount, unitSingle, unitPlural] = Renderer.splitTagByPipe(text); return isNaN(amount) ? unitSingle : Number(amount) > 1 ? (unitPlural || unitSingle.toPlural()) : unitSingle; } }; static TagHit = class extends this._TagBaseAt { tagName = "h"; _getStripped (tag, text) { return "Hit: "; } }; static TagMiss = class extends this._TagBaseAt { tagName = "m"; _getStripped (tag, text) { return "Miss: "; } }; static TagAtk = class extends this._TagBaseAt { tagName = "atk"; _getStripped (tag, text) { return Renderer.attackTagToFull(text); } }; static TagHitYourSpellAttack = class extends this._TagBaseAt { tagName = "hitYourSpellAttack"; _getStripped (tag, text) { const [displayText] = Renderer.splitTagByPipe(text); return displayText || "your spell attack modifier"; } }; static TagDc = class extends this._TagBaseAt { tagName = "dc"; _getStripped (tag, text) { const [dcText, displayText] = Renderer.splitTagByPipe(text); return `DC ${displayText || dcText}`; } }; static TagDcYourSpellSave = class extends this._TagBaseAt { tagName = "dcYourSpellSave"; _getStripped (tag, text) { const [displayText] = Renderer.splitTagByPipe(text); return displayText || "your spell save DC"; } }; static _TagDiceFlavor = class extends this._TagBaseAt { _getStripped (tag, text) { const [rollText, displayText] = Renderer.splitTagByPipe(text); switch (tag) { case "@damage": case "@dice": case "@autodice": { return displayText || rollText.replace(/;/g, "/"); } case "@d20": case "@hit": { return displayText || (() => { const n = Number(rollText); if (!isNaN(n)) return `${n >= 0 ? "+" : ""}${n}`; return rollText; })(); } case "@recharge": { const asNum = Number(rollText || 6); if (isNaN(asNum)) { throw new Error(`Could not parse "${rollText}" as a number!`); } return `(Recharge ${asNum}${asNum < 6 ? `\u20136` : ""})`; } case "@chance": { return displayText || `${rollText} percent`; } case "@ability": { const [, rawScore] = rollText.split(" ").map(it => it.trim().toLowerCase()).filter(Boolean); const score = Number(rawScore) || 0; return displayText || `${score} (${Parser.getAbilityModifier(score)})`; } case "@savingThrow": case "@skillCheck": { return displayText || rollText; } } throw new Error(`Unhandled tag: ${tag}`); } }; static TaChance = class extends this._TagDiceFlavor { tagName = "chance"; }; static TaD20 = class extends this._TagDiceFlavor { tagName = "d20"; }; static TaDamage = class extends this._TagDiceFlavor { tagName = "damage"; }; static TaDice = class extends this._TagDiceFlavor { tagName = "dice"; }; static TaAutodice = class extends this._TagDiceFlavor { tagName = "autodice"; }; static TaHit = class extends this._TagDiceFlavor { tagName = "hit"; }; static TaRecharge = class extends this._TagDiceFlavor { tagName = "recharge"; }; static TaAbility = class extends this._TagDiceFlavor { tagName = "ability"; }; static TaSavingThrow = class extends this._TagDiceFlavor { tagName = "savingThrow"; }; static TaSkillCheck = class extends this._TagDiceFlavor { tagName = "skillCheck"; }; static _TagDiceFlavorScaling = class extends this._TagBaseAt { _getStripped (tag, text) { const [, , addPerProgress, , displayText] = Renderer.splitTagByPipe(text); return displayText || addPerProgress; } }; static TagScaledice = class extends this._TagDiceFlavorScaling { tagName = "scaledice"; }; static TagScaledamage = class extends this._TagDiceFlavorScaling { tagName = "scaledamage"; }; static TagCoinflip = class extends this._TagBaseAt { tagName = "coinflip"; _getStripped (tag, text) { const [displayText] = Renderer.splitTagByPipe(text); return displayText || "flip a coin"; } }; static _TagPipedNoDisplayText = class extends this._TagBaseAt { _getStripped (tag, text) { const parts = Renderer.splitTagByPipe(text); return parts[0]; } }; static Tag5etools = class extends this._TagPipedNoDisplayText { tagName = "5etools"; }; static TagAdventure = class extends this._TagPipedNoDisplayText { tagName = "adventure"; }; static TagBook = class extends this._TagPipedNoDisplayText { tagName = "book"; }; static TagFilter = class extends this._TagPipedNoDisplayText { tagName = "filter"; }; static TagFootnote = class extends this._TagPipedNoDisplayText { tagName = "footnote"; }; static TagLink = class extends this._TagPipedNoDisplayText { tagName = "link"; }; static TagLoader = class extends this._TagPipedNoDisplayText { tagName = "loader"; }; static TagColor = class extends this._TagPipedNoDisplayText { tagName = "color"; }; static TagHighlight = class extends this._TagPipedNoDisplayText { tagName = "highlight"; }; static TagHelp = class extends this._TagPipedNoDisplayText { tagName = "help"; }; static _TagPipedDisplayTextThird = class extends this._TagBaseAt { _getStripped (tag, text) { const parts = Renderer.splitTagByPipe(text); return parts.length >= 3 ? parts[2] : parts[0]; } }; static TagAction = class extends this._TagPipedDisplayTextThird { tagName = "action"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_ACTIONS; }; static TagBackground = class extends this._TagPipedDisplayTextThird { tagName = "background"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_BACKGROUNDS; }; static TagBoon = class extends this._TagPipedDisplayTextThird { tagName = "boon"; defaultSource = Parser.SRC_MTF; page = UrlUtil.PG_CULTS_BOONS; }; static TagCharoption = class extends this._TagPipedDisplayTextThird { tagName = "charoption"; defaultSource = Parser.SRC_MOT; page = UrlUtil.PG_CHAR_CREATION_OPTIONS; }; static TagClass = class extends this._TagPipedDisplayTextThird { tagName = "class"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_CLASSES; }; static TagCondition = class extends this._TagPipedDisplayTextThird { tagName = "condition"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_CONDITIONS_DISEASES; }; static TagCreature = class extends this._TagPipedDisplayTextThird { tagName = "creature"; defaultSource = Parser.SRC_MM; page = UrlUtil.PG_BESTIARY; }; static TagCult = class extends this._TagPipedDisplayTextThird { tagName = "cult"; defaultSource = Parser.SRC_MTF; page = UrlUtil.PG_CULTS_BOONS; }; static TagDeck = class extends this._TagPipedDisplayTextThird { tagName = "deck"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_DECKS; }; static TagDisease = class extends this._TagPipedDisplayTextThird { tagName = "disease"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_CONDITIONS_DISEASES; }; static TagFeat = class extends this._TagPipedDisplayTextThird { tagName = "feat"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_FEATS; }; static TagHazard = class extends this._TagPipedDisplayTextThird { tagName = "hazard"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_TRAPS_HAZARDS; }; static TagItem = class extends this._TagPipedDisplayTextThird { tagName = "item"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_ITEMS; }; static TagItemMastery = class extends this._TagPipedDisplayTextThird { tagName = "itemMastery"; defaultSource = VeCt.STR_GENERIC; // TODO(Future) adjust as/when these are published page = "itemMastery"; }; static TagLanguage = class extends this._TagPipedDisplayTextThird { tagName = "language"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_LANGUAGES; }; static TagLegroup = class extends this._TagPipedDisplayTextThird { tagName = "legroup"; defaultSource = Parser.SRC_MM; page = "legendaryGroup"; }; static TagObject = class extends this._TagPipedDisplayTextThird { tagName = "object"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_OBJECTS; }; static TagOptfeature = class extends this._TagPipedDisplayTextThird { tagName = "optfeature"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_OPT_FEATURES; }; static TagPsionic = class extends this._TagPipedDisplayTextThird { tagName = "psionic"; defaultSource = Parser.SRC_UATMC; page = UrlUtil.PG_PSIONICS; }; static TagRace = class extends this._TagPipedDisplayTextThird { tagName = "race"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_RACES; }; static TagRecipe = class extends this._TagPipedDisplayTextThird { tagName = "recipe"; defaultSource = Parser.SRC_HF; page = UrlUtil.PG_RECIPES; }; static TagReward = class extends this._TagPipedDisplayTextThird { tagName = "reward"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_REWARDS; }; static TagVehicle = class extends this._TagPipedDisplayTextThird { tagName = "vehicle"; defaultSource = Parser.SRC_GoS; page = UrlUtil.PG_VEHICLES; }; static TagVehupgrade = class extends this._TagPipedDisplayTextThird { tagName = "vehupgrade"; defaultSource = Parser.SRC_GoS; page = UrlUtil.PG_VEHICLES; }; static TagSense = class extends this._TagPipedDisplayTextThird { tagName = "sense"; defaultSource = Parser.SRC_PHB; page = "sense"; }; static TagSkill = class extends this._TagPipedDisplayTextThird { tagName = "skill"; defaultSource = Parser.SRC_PHB; page = "skill"; }; static TagSpell = class extends this._TagPipedDisplayTextThird { tagName = "spell"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_SPELLS; }; static TagStatus = class extends this._TagPipedDisplayTextThird { tagName = "status"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_CONDITIONS_DISEASES; }; static TagTable = class extends this._TagPipedDisplayTextThird { tagName = "table"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_TABLES; }; static TagTrap = class extends this._TagPipedDisplayTextThird { tagName = "trap"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_TRAPS_HAZARDS; }; static TagVariantrule = class extends this._TagPipedDisplayTextThird { tagName = "variantrule"; defaultSource = Parser.SRC_DMG; page = UrlUtil.PG_VARIANTRULES; }; static TagCite = class extends this._TagPipedDisplayTextThird { tagName = "cite"; defaultSource = Parser.SRC_PHB; page = "citation"; }; static _TagPipedDisplayTextFourth = class extends this._TagBaseAt { _getStripped (tag, text) { const parts = Renderer.splitTagByPipe(text); return parts.length >= 4 ? parts[3] : parts[0]; } }; static TagCard = class extends this._TagPipedDisplayTextFourth { tagName = "card"; defaultSource = Parser.SRC_DMG; page = "card"; }; static TagDeity = class extends this._TagPipedDisplayTextFourth { tagName = "deity"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_DEITIES; }; static _TagPipedDisplayTextSixth = class extends this._TagBaseAt { _getStripped (tag, text) { const parts = Renderer.splitTagByPipe(text); return parts.length >= 6 ? parts[5] : parts[0]; } }; static TagClassFeature = class extends this._TagPipedDisplayTextSixth { tagName = "classFeature"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_CLASSES; }; static _TagPipedDisplayTextEight = class extends this._TagBaseAt { _getStripped (tag, text) { const parts = Renderer.splitTagByPipe(text); return parts.length >= 8 ? parts[7] : parts[0]; } }; static TagSubclassFeature = class extends this._TagPipedDisplayTextEight { tagName = "subclassFeature"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_CLASSES; }; static TagQuickref = class extends this._TagBaseAt { tagName = "quickref"; defaultSource = Parser.SRC_PHB; page = UrlUtil.PG_QUICKREF; _getStripped (tag, text) { const {name, displayText} = DataUtil.quickreference.unpackUid(text); return displayText || name; } }; static TagArea = class extends this._TagBaseAt { tagName = "area"; _getStripped (tag, text) { const [compactText, , flags] = Renderer.splitTagByPipe(text); return flags && flags.includes("x") ? compactText : `${flags && flags.includes("u") ? "A" : "a"}rea ${compactText}`; } _getMeta (tag, text) { const [compactText, areaId, flags] = Renderer.splitTagByPipe(text); const displayText = flags && flags.includes("x") ? compactText : `${flags && flags.includes("u") ? "A" : "a"}rea ${compactText}`; return { areaId, displayText, }; } }; static TagHomebrew = class extends this._TagBaseAt { tagName = "homebrew"; _getStripped (tag, text) { const [newText, oldText] = Renderer.splitTagByPipe(text); if (newText && oldText) { return `${newText} [this is a homebrew addition, replacing the following: "${oldText}"]`; } else if (newText) { return `${newText} [this is a homebrew addition]`; } else if (oldText) { return `[the following text has been removed due to homebrew: ${oldText}]`; } else throw new Error(`Homebrew tag had neither old nor new text!`); } }; static TagItemEntry = class extends this._TagBaseHash { tagName = "itemEntry"; defaultSource = Parser.SRC_DMG; }; /* -------------------------------------------- */ static TAGS = [ new this.TagBoldShort(), new this.TagBoldLong(), new this.TagItalicShort(), new this.TagItalicLong(), new this.TagStrikethroughShort(), new this.TagStrikethroughLong(), new this.TagUnderlineShort(), new this.TagUnderlineLong(), new this.TagSup(), new this.TagSub(), new this.TagKbd(), new this.TagCode(), new this.TagStyle(), new this.TagFont(), new this.TagComic(), new this.TagComicH1(), new this.TagComicH2(), new this.TagComicH3(), new this.TagComicH4(), new this.TagComicNote(), new this.TagNote(), new this.TagTip(), new this.TagUnit(), new this.TagHit(), new this.TagMiss(), new this.TagAtk(), new this.TagHitYourSpellAttack(), new this.TagDc(), new this.TagDcYourSpellSave(), new this.TaChance(), new this.TaD20(), new this.TaDamage(), new this.TaDice(), new this.TaAutodice(), new this.TaHit(), new this.TaRecharge(), new this.TaAbility(), new this.TaSavingThrow(), new this.TaSkillCheck(), new this.TagScaledice(), new this.TagScaledamage(), new this.TagCoinflip(), new this.Tag5etools(), new this.TagAdventure(), new this.TagBook(), new this.TagFilter(), new this.TagFootnote(), new this.TagLink(), new this.TagLoader(), new this.TagColor(), new this.TagHighlight(), new this.TagHelp(), new this.TagQuickref(), new this.TagArea(), new this.TagAction(), new this.TagBackground(), new this.TagBoon(), new this.TagCharoption(), new this.TagClass(), new this.TagCondition(), new this.TagCreature(), new this.TagCult(), new this.TagDeck(), new this.TagDisease(), new this.TagFeat(), new this.TagHazard(), new this.TagItem(), new this.TagItemMastery(), new this.TagLanguage(), new this.TagLegroup(), new this.TagObject(), new this.TagOptfeature(), new this.TagPsionic(), new this.TagRace(), new this.TagRecipe(), new this.TagReward(), new this.TagVehicle(), new this.TagVehupgrade(), new this.TagSense(), new this.TagSkill(), new this.TagSpell(), new this.TagStatus(), new this.TagTable(), new this.TagTrap(), new this.TagVariantrule(), new this.TagCite(), new this.TagCard(), new this.TagDeity(), new this.TagClassFeature({tagName: "classFeature"}), new this.TagSubclassFeature({tagName: "subclassFeature"}), new this.TagHomebrew(), /* ----------------------------------------- */ new this.TagItemEntry(), ]; static TAG_LOOKUP = {}; static _init () { this.TAGS.forEach(tag => { this.TAG_LOOKUP[tag.tag] = tag; this.TAG_LOOKUP[tag.tagName] = tag; }); return null; } static _ = this._init(); /* ----------------------------------------- */ static getPage (tag) { const tagInfo = this.TAG_LOOKUP[tag]; return tagInfo?.page; } }; Renderer.events = class { static handleClick_copyCode (evt, ele) { const $e = $(ele).parent().next("pre"); MiscUtil.pCopyTextToClipboard($e.text()); JqueryUtil.showCopiedEffect($e); } static handleClick_toggleCodeWrap (evt, ele) { const nxt = !StorageUtil.syncGet("rendererCodeWrap"); StorageUtil.syncSet("rendererCodeWrap", nxt); const $btn = $(ele).toggleClass("active", nxt); const $e = $btn.parent().next("pre"); $e.toggleClass("rd__pre-wrap", nxt); } static bindGeneric ({element = document.body} = {}) { const $ele = $(element) .on("click", `[data-rd-data-embed-header]`, evt => { Renderer.events.handleClick_dataEmbedHeader(evt, evt.currentTarget); }); Renderer.events._HEADER_TOGGLE_CLICK_SELECTORS .forEach(selector => { $ele .on("click", selector, evt => { Renderer.events.handleClick_headerToggleButton(evt, evt.currentTarget, {selector}); }); }) ; } static handleClick_dataEmbedHeader (evt, ele) { evt.stopPropagation(); evt.preventDefault(); const $ele = $(ele); $ele.find(".rd__data-embed-name").toggleVe(); $ele.find(".rd__data-embed-toggle").text($ele.text().includes("+") ? "[\u2013]" : "[+]"); $ele.closest("table").find("tbody").toggleVe(); } static _HEADER_TOGGLE_CLICK_SELECTORS = [ `[data-rd-h-toggle-button]`, `[data-rd-h-special-toggle-button]`, ]; static handleClick_headerToggleButton (evt, ele, {selector = false} = {}) { evt.stopPropagation(); evt.preventDefault(); const isShow = this._handleClick_headerToggleButton_doToggleEle(ele, {selector}); if (!EventUtil.isCtrlMetaKey(evt)) return; Renderer.events._HEADER_TOGGLE_CLICK_SELECTORS .forEach(selector => { [...document.querySelectorAll(selector)] .filter(eleOther => eleOther !== ele) .forEach(eleOther => { Renderer.events._handleClick_headerToggleButton_doToggleEle(eleOther, {selector, force: isShow}); }); }) ; } static _handleClick_headerToggleButton_doToggleEle (ele, {selector = false, force = null} = {}) { const isShow = force != null ? force : ele.innerHTML.includes("+"); let eleNxt = ele.closest(".rd__h").nextElementSibling; while (eleNxt) { // Never hide float-fixing elements if (eleNxt.classList.contains("float-clear")) { eleNxt = eleNxt.nextElementSibling; continue; } // For special sections, always collapse the whole thing. if (selector !== `[data-rd-h-special-toggle-button]`) { const eleToCheck = Renderer.events._handleClick_headerToggleButton_getEleToCheck(eleNxt); if ( eleToCheck.classList.contains("rd__b-special") || (eleToCheck.classList.contains("rd__h") && !eleToCheck.classList.contains("rd__h--3")) || (eleToCheck.classList.contains("rd__b") && !eleToCheck.classList.contains("rd__b--3")) ) break; } eleNxt.classList.toggle("rd__ele-toggled-hidden", !isShow); eleNxt = eleNxt.nextElementSibling; } ele.innerHTML = isShow ? "[\u2013]" : "[+]"; return isShow; } static _handleClick_headerToggleButton_getEleToCheck (eleNxt) { if (eleNxt.type === 3) return eleNxt; // Text nodes // If the element is a block with only one child which is itself a block, treat it as a "wrapper" block, and dig if (!eleNxt.classList.contains("rd__b") || eleNxt.classList.contains("rd__b--3")) return eleNxt; const childNodes = [...eleNxt.childNodes].filter(it => (it.type === 3 && (it.textContent || "").trim()) || it.type !== 3); if (childNodes.length !== 1) return eleNxt; if (childNodes[0].classList.contains("rd__b")) return Renderer.events._handleClick_headerToggleButton_getEleToCheck(childNodes[0]); return eleNxt; } static handleLoad_inlineStatblock (ele) { const observer = Renderer.utils.lazy.getCreateObserver({ observerId: "inlineStatblock", fnOnObserve: Renderer.events._handleLoad_inlineStatblock_fnOnObserve.bind(Renderer.events), }); observer.track(ele.parentNode); } static _handleLoad_inlineStatblock_fnOnObserve ({entry}) { const ele = entry.target; const tag = ele.dataset.rdTag.uq(); const page = ele.dataset.rdPage.uq(); const source = ele.dataset.rdSource.uq(); const name = ele.dataset.rdName.uq(); const displayName = ele.dataset.rdDisplayName.uq(); const hash = ele.dataset.rdHash.uq(); const style = ele.dataset.rdStyle.uq(); DataLoader.pCacheAndGet(page, Parser.getTagSource(tag, source), hash) .then(toRender => { const tr = ele.closest("tr"); if (!toRender) { tr.innerHTML = `Failed to load ${tag ? Renderer.get().render(`{@${tag} ${name}|${source}${displayName ? `|${displayName}` : ""}}`) : displayName || name}!`; throw new Error(`Could not find tag: "${tag}" (page/prop: "${page}") hash: "${hash}"`); } const headerName = displayName || (name ?? toRender.name ?? (toRender.entries?.length ? toRender.entries?.[0]?.name : "(Unknown)")); const fnRender = Renderer.hover.getFnRenderCompact(page); const tbl = tr.closest("table"); const nxt = e_({ outer: Renderer.utils.getEmbeddedDataHeader(headerName, style) + fnRender(toRender, {isEmbeddedEntity: true}) + Renderer.utils.getEmbeddedDataFooter(), }); tbl.parentNode.replaceChild( nxt, tbl, ); const nxtTgt = nxt.querySelector(`[data-rd-embedded-data-render-target="true"]`); const fnBind = Renderer.hover.getFnBindListenersCompact(page); if (fnBind) fnBind(toRender, nxtTgt); }); } }; Renderer.feat = class { static _mergeAbilityIncrease_getListItemText (abilityObj) { return Renderer.feat._mergeAbilityIncrease_getText(abilityObj); } static _mergeAbilityIncrease_getListItemItem (abilityObj) { return { type: "item", name: "Ability Score Increase.", entry: Renderer.feat._mergeAbilityIncrease_getText(abilityObj), }; } static _mergeAbilityIncrease_getText (abilityObj) { const maxScore = abilityObj.max ?? 20; if (!abilityObj.choose) { return Object.keys(abilityObj) .filter(k => k !== "max") .map(ab => `Increase your ${Parser.attAbvToFull(ab)} score by ${abilityObj[ab]}, to a maximum of ${maxScore}.`) .join(" "); } if (abilityObj.choose.from.length === 6) { return abilityObj.choose.entry ? Renderer.get().render(abilityObj.choose.entry) // only used in "Resilient" : `Increase one ability score of your choice by ${abilityObj.choose.amount ?? 1}, to a maximum of ${maxScore}.`; } const abbChoicesText = abilityObj.choose.from.map(it => Parser.attAbvToFull(it)).joinConjunct(", ", " or "); return `Increase your ${abbChoicesText} by ${abilityObj.choose.amount ?? 1}, to a maximum of ${maxScore}.`; } static initFullEntries (feat) { if (!feat.ability || feat._fullEntries || !feat.ability.length) return; const abilsToDisplay = feat.ability.filter(it => !it.hidden); if (!abilsToDisplay.length) return; Renderer.utils.initFullEntries_(feat); const targetList = feat._fullEntries.find(e => e.type === "list"); // FTD+ style if (targetList && targetList.items.every(it => it.type === "item")) { abilsToDisplay.forEach(abilObj => targetList.items.unshift(Renderer.feat._mergeAbilityIncrease_getListItemItem(abilObj))); return; } if (targetList) { abilsToDisplay.forEach(abilObj => targetList.items.unshift(Renderer.feat._mergeAbilityIncrease_getListItemText(abilObj))); return; } // this should never happen, but display sane output anyway, and throw an out-of-order exception abilsToDisplay.forEach(abilObj => feat._fullEntries.unshift(Renderer.feat._mergeAbilityIncrease_getListItemText(abilObj))); setTimeout(() => { throw new Error(`Could not find object of type "list" in "entries" for feat "${feat.name}" from source "${feat.source}" when merging ability scores! Reformat the feat to include a "list"-type entry.`); }, 1); } static getFeatRendereableEntriesMeta (ent) { Renderer.feat.initFullEntries(ent); return { entryMain: {entries: ent._fullEntries || ent.entries}, }; } static getJoinedCategoryPrerequisites (category, rdPrereqs) { const ptCategory = category ? `${category.toTitleCase()} Feat` : ""; return ptCategory && rdPrereqs ? `${ptCategory} (${rdPrereqs})` : (ptCategory || rdPrereqs); } /** * @param feat * @param [opts] * @param [opts.isSkipNameRow] */ static getCompactRenderedString (feat, opts) { opts = opts || {}; const renderer = Renderer.get().setFirstSection(true); const renderStack = []; const ptCategoryPrerequisite = Renderer.feat.getJoinedCategoryPrerequisites( feat.category, Renderer.utils.prerequisite.getHtml(feat.prerequisite), ); const ptRepeatable = Renderer.utils.getRepeatableHtml(feat); renderStack.push(` ${Renderer.utils.getExcludedTr({entity: feat, dataProp: "feat", page: UrlUtil.PG_FEATS})} ${opts.isSkipNameRow ? "" : Renderer.utils.getNameTr(feat, {page: UrlUtil.PG_FEATS})} ${ptCategoryPrerequisite ? `

    ${ptCategoryPrerequisite}

    ` : ""} ${ptRepeatable ? `

    ${ptRepeatable}

    ` : ""} `); renderer.recursiveRender(Renderer.feat.getFeatRendereableEntriesMeta(feat)?.entryMain, renderStack, {depth: 2}); renderStack.push(``); return renderStack.join(""); } static pGetFluff (feat) { return Renderer.utils.pGetFluff({ entity: feat, fnGetFluffData: DataUtil.featFluff.loadJSON.bind(DataUtil.featFluff), fluffProp: "featFluff", }); } }; Renderer.class = class { static getCompactRenderedString (cls) { if (cls.__prop === "subclass") return Renderer.subclass.getCompactRenderedString(cls); const clsEntry = { type: "section", name: cls.name, source: cls.source, page: cls.page, entries: MiscUtil.copyFast((cls.classFeatures || []).flat()), }; return Renderer.hover.getGenericCompactRenderedString(clsEntry); } static getHitDiceEntry (clsHd) { return clsHd ? {toRoll: `${clsHd.number}d${clsHd.faces}`, rollable: true} : null; } static getHitPointsAtFirstLevel (clsHd) { return clsHd ? `${clsHd.number * clsHd.faces} + your Constitution modifier` : null; } static getHitPointsAtHigherLevels (className, clsHd, hdEntry) { return className && clsHd && hdEntry ? `${Renderer.getEntryDice(hdEntry, "Hit die")} (or ${((clsHd.number * clsHd.faces) / 2 + 1)}) + your Constitution modifier per ${className} level after 1st` : null; } static getRenderedArmorProfs (armorProfs) { return armorProfs.map(a => Renderer.get().render(a.full ? a.full : a === "light" || a === "medium" || a === "heavy" ? `{@filter ${a} armor|items|type=${a} armor}` : a)).join(", "); } static getRenderedWeaponProfs (weaponProfs) { return weaponProfs.map(w => Renderer.get().render(w === "simple" || w === "martial" ? `{@filter ${w} weapons|items|type=${w} weapon}` : w.optional ? `${w.proficiency}` : w)).join(", "); } static getRenderedToolProfs (toolProfs) { return toolProfs.map(it => Renderer.get().render(it)).join(", "); } static getRenderedSkillProfs (skills) { return `${Parser.skillProficienciesToFull(skills).uppercaseFirst()}.`; } static getWalkerFilterDereferencedFeatures () { return MiscUtil.getWalker({ keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, isAllowDeleteObjects: true, isDepthFirst: true, }); } static mutFilterDereferencedClassFeatures ( { walker, cpyCls, pageFilter, filterValues, isUseSubclassSources = false, }, ) { walker = walker || Renderer.class.getWalkerFilterDereferencedFeatures(); cpyCls.classFeatures = cpyCls.classFeatures.map((lvlFeatures, ixLvl) => { return walker.walk( lvlFeatures, { object: (obj) => { if (!obj.source) return obj; const fText = obj.isClassFeatureVariant ? {isClassFeatureVariant: true} : null; const isDisplay = [obj.source, ...(obj.otherSources || []) .map(it => it.source)] .some(src => pageFilter.filterBox.toDisplayByFilters( filterValues, ...[ { filter: pageFilter.sourceFilter, value: isUseSubclassSources && src === cpyCls.source ? pageFilter.getActiveSource(filterValues) : src, }, pageFilter.levelFilter ? { filter: pageFilter.levelFilter, value: ixLvl + 1, } : null, { filter: pageFilter.optionsFilter, value: fText, }, ].filter(Boolean), )); return isDisplay ? obj : null; }, array: (arr) => { return arr.filter(it => it != null); }, }, ); }); } static mutFilterDereferencedSubclassFeatures ( { walker, cpySc, pageFilter, filterValues, }, ) { walker = walker || Renderer.class.getWalkerFilterDereferencedFeatures(); cpySc.subclassFeatures = cpySc.subclassFeatures.map(lvlFeatures => { const level = CollectionUtil.bfs(lvlFeatures, {prop: "level"}); return walker.walk( lvlFeatures, { object: (obj) => { if (obj.entries && !obj.entries.length) return null; if (!obj.source) return obj; const fText = obj.isClassFeatureVariant ? {isClassFeatureVariant: true} : null; const isDisplay = [obj.source, ...(obj.otherSources || []) .map(it => it.source)] .some(src => pageFilter.filterBox.toDisplayByFilters( filterValues, ...[ { filter: pageFilter.sourceFilter, value: src, }, pageFilter.levelFilter ? { filter: pageFilter.levelFilter, value: level, } : null, { filter: pageFilter.optionsFilter, value: fText, }, ].filter(Boolean), )); return isDisplay ? obj : null; }, array: (arr) => { return arr.filter(it => it != null); }, }, ); }); } }; Renderer.subclass = class { static getCompactRenderedString (sc) { const scEntry = { type: "section", name: sc.name, source: sc.source, page: sc.page, entries: MiscUtil.copyFast((sc.subclassFeatures || []).flat()), }; return Renderer.hover.getGenericCompactRenderedString(scEntry); } }; Renderer.spell = class { static getCompactRenderedString (spell, opts) { opts = opts || {}; const renderer = Renderer.get(); const renderStack = []; renderStack.push(` ${Renderer.utils.getExcludedTr({entity: spell, dataProp: "spell", page: UrlUtil.PG_SPELLS})} ${Renderer.utils.getNameTr(spell, {page: UrlUtil.PG_SPELLS, isEmbeddedEntity: opts.isEmbeddedEntity})}
    Level School Casting Time Range
    ${Parser.spLevelToFull(spell.level)}${Parser.spMetaToFull(spell.meta)} ${Parser.spSchoolAndSubschoolsAbvsToFull(spell.school, spell.subschools)} ${Parser.spTimeListToFull(spell.time)} ${Parser.spRangeToFull(spell.range)}
    Components Duration
    ${Parser.spComponentsToFull(spell.components, spell.level)} ${Parser.spDurationToFull(spell.duration)}
    `); renderStack.push(``); const entryList = {type: "entries", entries: spell.entries}; renderer.recursiveRender(entryList, renderStack, {depth: 1}); if (spell.entriesHigherLevel) { const higherLevelsEntryList = {type: "entries", entries: spell.entriesHigherLevel}; renderer.recursiveRender(higherLevelsEntryList, renderStack, {depth: 2}); } const fromClassList = Renderer.spell.getCombinedClasses(spell, "fromClassList"); if (fromClassList.length) { const [current] = Parser.spClassesToCurrentAndLegacy(fromClassList); renderStack.push(`
    Classes: ${Parser.spMainClassesToFull(current)}
    `); } renderStack.push(``); return renderStack.join(""); } static _SpellSourceManager = class { _cache = null; populate ({brew, isForce = false}) { if (this._cache && !isForce) return; this._cache = { classes: {}, groups: {}, // region Unused races: {}, backgrounds: {}, feats: {}, optionalfeatures: {}, // endregion }; // region Load homebrew class spell list addons // Two formats are available: a string UID, or "class" object (object with a `className`, etc.). (brew.class || []) .forEach(c => { c.source = c.source || Parser.SRC_PHB; (c.classSpells || []) .forEach(itm => { this._populate_fromClass_classSubclass({ itm, className: c.name, classSource: c.source, }); this._populate_fromClass_group({ itm, className: c.name, classSource: c.source, }); }); }); (brew.subclass || []) .forEach(sc => { sc.classSource = sc.classSource || Parser.SRC_PHB; sc.shortName = sc.shortName || sc.name; sc.source = sc.source || sc.classSource; (sc.subclassSpells || []) .forEach(itm => { this._populate_fromClass_classSubclass({ itm, className: sc.className, classSource: sc.classSource, subclassShortName: sc.shortName, subclassName: sc.name, subclassSource: sc.source, }); this._populate_fromClass_group({ itm, className: sc.className, classSource: sc.classSource, subclassShortName: sc.shortName, subclassName: sc.name, subclassSource: sc.source, }); }); Object.entries(sc.subSubclassSpells || {}) .forEach(([subSubclassName, arr]) => { arr .forEach(itm => { this._populate_fromClass_classSubclass({ itm, className: sc.className, classSource: sc.classSource, subclassShortName: sc.shortName, subclassName: sc.name, subclassSource: sc.source, subSubclassName, }); this._populate_fromClass_group({ itm, className: sc.className, classSource: sc.classSource, subclassShortName: sc.shortName, subclassName: sc.name, subclassSource: sc.source, subSubclassName, }); }); }); }); // endregion (brew.spellList || []) .forEach(spellList => this._populate_fromGroup_group({spellList})); } _populate_fromClass_classSubclass ( { itm, className, classSource, subclassShortName, subclassName, subclassSource, subSubclassName, }, ) { if (itm.groupName) return; // region Duplicate the spell list of another class/subclass/sub-subclass if (itm.className) { return this._populate_fromClass_doAdd({ tgt: MiscUtil.getOrSet( this._cache.classes, "class", (itm.classSource || Parser.SRC_PHB).toLowerCase(), itm.className.toLowerCase(), {}, ), className, classSource, subclassShortName, subclassName, subclassSource, subSubclassName, }); } // endregion // region Individual spell let [name, source] = `${itm}`.toLowerCase().split("|"); source = source || Parser.SRC_PHB.toLowerCase(); this._populate_fromClass_doAdd({ tgt: MiscUtil.getOrSet( this._cache.classes, "spell", source, name, {fromClassList: [], fromSubclass: []}, ), className, classSource, subclassShortName, subclassName, subclassSource, subSubclassName, }); // endregion } _populate_fromClass_doAdd ( { tgt, className, classSource, subclassShortName, subclassName, subclassSource, subSubclassName, schools, }, ) { if (subclassShortName) { const toAdd = { class: {name: className, source: classSource}, subclass: {name: subclassName || subclassShortName, shortName: subclassShortName, source: subclassSource}, }; if (subSubclassName) toAdd.subclass.subSubclass = subSubclassName; if (schools) toAdd.schools = schools; tgt.fromSubclass = tgt.fromSubclass || []; tgt.fromSubclass.push(toAdd); return; } const toAdd = {name: className, source: classSource}; if (schools) toAdd.schools = schools; tgt.fromClassList = tgt.fromClassList || []; tgt.fromClassList.push(toAdd); } _populate_fromClass_group ( { itm, className, classSource, subclassShortName, subclassName, subclassSource, subSubclassName, }, ) { if (!itm.groupName) return; return this._populate_fromClass_doAdd({ tgt: MiscUtil.getOrSet( this._cache.classes, "group", (itm.groupSource || Parser.SRC_PHB).toLowerCase(), itm.groupName.toLowerCase(), {}, ), className, classSource, subclassShortName, subclassName, subclassSource, subSubclassName, schools: itm.spellSchools, }); } _populate_fromGroup_group ( { spellList, }, ) { const spellListSourceLower = (spellList.source || "").toLowerCase(); const spellListNameLower = (spellList.name || "").toLowerCase(); spellList.spells .forEach(spell => { if (typeof spell === "string") { const {name, source} = DataUtil.proxy.unpackUid("spell", spell, "spell", {isLower: true}); return MiscUtil.set(this._cache.groups, "spell", source, name, spellListSourceLower, spellListNameLower, {name: spellList.name, source: spellList.source}); } // TODO(Future) implement "copy existing list" throw new Error(`Grouping spells based on other spell lists is not yet supported!`); }); } /* -------------------------------------------- */ mutateSpell ({spell: sp, lowName, lowSource}) { lowName = lowName || sp.name.toLowerCase(); lowSource = lowSource || sp.source.toLowerCase(); this._mutateSpell_brewGeneric({sp, lowName, lowSource, propSpell: "races", prop: "race"}); this._mutateSpell_brewGeneric({sp, lowName, lowSource, propSpell: "backgrounds", prop: "background"}); this._mutateSpell_brewGeneric({sp, lowName, lowSource, propSpell: "feats", prop: "feat"}); this._mutateSpell_brewGeneric({sp, lowName, lowSource, propSpell: "optionalfeatures", prop: "optionalfeature"}); this._mutateSpell_brewGroup({sp, lowName, lowSource}); this._mutateSpell_brewClassesSubclasses({sp, lowName, lowSource}); } _mutateSpell_brewClassesSubclasses ({sp, lowName, lowSource}) { if (!this._cache?.classes) return; if (this._cache.classes.spell?.[lowSource]?.[lowName]?.fromClassList?.length) { sp._tmpClasses.fromClassList = sp._tmpClasses.fromClassList || []; sp._tmpClasses.fromClassList.push(...this._cache.classes.spell[lowSource][lowName].fromClassList); } if (this._cache.classes.spell?.[lowSource]?.[lowName]?.fromSubclass?.length) { sp._tmpClasses.fromSubclass = sp._tmpClasses.fromSubclass || []; sp._tmpClasses.fromSubclass.push(...this._cache.classes.spell[lowSource][lowName].fromSubclass); } if (this._cache.classes.class && sp.classes?.fromClassList) { (sp._tmpClasses = sp._tmpClasses || {}).fromClassList = sp._tmpClasses.fromClassList || []; // speed over safety outer: for (const srcLower in this._cache.classes.class) { const searchForClasses = this._cache.classes.class[srcLower]; for (const clsLowName in searchForClasses) { const spellHasClass = sp.classes?.fromClassList?.some(cls => (cls.source || "").toLowerCase() === srcLower && cls.name.toLowerCase() === clsLowName); if (!spellHasClass) continue; const fromDetails = searchForClasses[clsLowName]; if (fromDetails.fromClassList) { sp._tmpClasses.fromClassList.push(...this._mutateSpell_getListFilteredBySchool({sp, arr: fromDetails.fromClassList})); } if (fromDetails.fromSubclass) { sp._tmpClasses.fromSubclass = sp._tmpClasses.fromSubclass || []; sp._tmpClasses.fromSubclass.push(...this._mutateSpell_getListFilteredBySchool({sp, arr: fromDetails.fromSubclass})); } // Only add it once regardless of how many classes match break outer; } } } if (this._cache.classes.group && (sp.groups?.length || sp._tmpGroups?.length)) { const groups = Renderer.spell.getCombinedGeneric(sp, {propSpell: "groups"}); (sp._tmpClasses = sp._tmpClasses || {}).fromClassList = sp._tmpClasses.fromClassList || []; // speed over safety outer: for (const srcLower in this._cache.classes.group) { const searchForGroups = this._cache.classes.group[srcLower]; for (const groupLowName in searchForGroups) { const spellHasGroup = groups?.some(grp => (grp.source || "").toLowerCase() === srcLower && grp.name.toLowerCase() === groupLowName); if (!spellHasGroup) continue; const fromDetails = searchForGroups[groupLowName]; if (fromDetails.fromClassList) { sp._tmpClasses.fromClassList.push(...this._mutateSpell_getListFilteredBySchool({sp, arr: fromDetails.fromClassList})); } if (fromDetails.fromSubclass) { sp._tmpClasses.fromSubclass = sp._tmpClasses.fromSubclass || []; sp._tmpClasses.fromSubclass.push(...this._mutateSpell_getListFilteredBySchool({sp, arr: fromDetails.fromSubclass})); } // Only add it once regardless of how many classes match break outer; } } } } _mutateSpell_getListFilteredBySchool ({arr, sp}) { return arr .filter(it => { if (!it.schools) return true; return it.schools.includes(sp.school); }) .map(it => { if (!it.schools) return it; const out = MiscUtil.copyFast(it); delete it.schools; return it; }); } _mutateSpell_brewGeneric ({sp, lowName, lowSource, propSpell, prop}) { if (!this._cache?.[propSpell]) return; const propTmp = `_tmp${propSpell.uppercaseFirst()}`; // If a precise spell has been specified if (this._cache[propSpell]?.spell?.[lowSource]?.[lowName]?.length) { (sp[propTmp] = sp[propTmp] || []) .push(...this._cache[propSpell].spell[lowSource][lowName]); } // If we have a copy of an existing entity's spells if (this._cache?.[propSpell]?.[prop] && sp[propSpell]) { sp[propTmp] = sp[propTmp] || []; // speed over safety outer: for (const srcLower in this._cache[propSpell][prop]) { const searchForExisting = this._cache[propSpell][prop][srcLower]; for (const lowName in searchForExisting) { const spellHasEnt = sp[propSpell].some(it => (it.source || "").toLowerCase() === srcLower && it.name.toLowerCase() === lowName); if (!spellHasEnt) continue; const fromDetails = searchForExisting[lowName]; sp[propTmp].push(...fromDetails); // Only add it once regardless of how many entities match break outer; } } } } _mutateSpell_brewGroup ({sp, lowName, lowSource}) { if (!this._cache?.groups) return; if (this._cache.groups.spell?.[lowSource]?.[lowName]) { Object.values(this._cache.groups.spell[lowSource][lowName]) .forEach(bySource => { Object.values(bySource) .forEach(byName => { sp._tmpGroups.push(byName); }); }); } // TODO(Future) implement "copy existing list" } }; static populatePrereleaseLookup (brew, {isForce = false} = {}) { Renderer.spell._spellSourceManagerPrerelease.populate({brew, isForce}); } static populateBrewLookup (brew, {isForce = false} = {}) { Renderer.spell._spellSourceManagerBrew.populate({brew, isForce}); } static prePopulateHover (data) { (data.spell || []).forEach(sp => Renderer.spell.initBrewSources(sp)); } static prePopulateHoverPrerelease (data) { Renderer.spell.populatePrereleaseLookup(data); } static prePopulateHoverBrew (data) { Renderer.spell.populateBrewLookup(data); } /* -------------------------------------------- */ static _BREW_SOURCES_TMP_PROPS = [ "_tmpSourcesInit", "_tmpClasses", "_tmpRaces", "_tmpBackgrounds", "_tmpFeats", "_tmpOptionalfeatures", "_tmpGroups", ]; static uninitBrewSources (sp) { Renderer.spell._BREW_SOURCES_TMP_PROPS.forEach(prop => delete sp[prop]); } static initBrewSources (sp) { if (sp._tmpSourcesInit) return; sp._tmpSourcesInit = true; sp._tmpClasses = {}; sp._tmpRaces = []; sp._tmpBackgrounds = []; sp._tmpFeats = []; sp._tmpOptionalfeatures = []; sp._tmpGroups = []; const lowName = sp.name.toLowerCase(); const lowSource = sp.source.toLowerCase(); for (const manager of [Renderer.spell._spellSourceManagerPrerelease, Renderer.spell._spellSourceManagerBrew]) { manager.mutateSpell({spell: sp, lowName, lowSource}); } } static getCombinedClasses (sp, prop) { return [ ...((sp.classes || {})[prop] || []), ...((sp._tmpClasses || {})[prop] || []), ] .filter(it => { if (!ExcludeUtil.isInitialised) return true; switch (prop) { case "fromClassList": case "fromClassListVariant": { const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](it); if (ExcludeUtil.isExcluded(hash, "class", it.source, {isNoCount: true})) return false; if (prop !== "fromClassListVariant") return true; if (it.definedInSource) return !ExcludeUtil.isExcluded("*", "classFeature", it.definedInSource, {isNoCount: true}); return true; } case "fromSubclass": case "fromSubclassVariant": { const hash = UrlUtil.URL_TO_HASH_BUILDER["subclass"]({ name: it.subclass.name, shortName: it.subclass.shortName, source: it.subclass.source, className: it.class.name, classSource: it.class.source, }); if (prop !== "fromSubclassVariant") return !ExcludeUtil.isExcluded(hash, "subclass", it.subclass.source, {isNoCount: true}); if (it.class.definedInSource) return !Renderer.spell.isExcludedSubclassVariantSource({classDefinedInSource: it.class.definedInSource}); return true; } default: throw new Error(`Unhandled prop "${prop}"`); } }); } static isExcludedSubclassVariantSource ({classDefinedInSource, subclassDefinedInSource}) { return (classDefinedInSource != null && ExcludeUtil.isExcluded("*", "classFeature", classDefinedInSource, {isNoCount: true})) || (subclassDefinedInSource != null && ExcludeUtil.isExcluded("*", "subclassFeature", subclassDefinedInSource, {isNoCount: true})); } static getCombinedGeneric (sp, {propSpell, prop}) { const propSpellTmp = `_tmp${propSpell.uppercaseFirst()}`; return [ ...(sp[propSpell] || []), ...(sp[propSpellTmp] || []), ] .filter(it => { if (!ExcludeUtil.isInitialised || !prop) return true; const hash = UrlUtil.URL_TO_HASH_BUILDER[prop](it); return !ExcludeUtil.isExcluded(hash, prop, it.source, {isNoCount: true}); }) .sort(SortUtil.ascSortGenericEntity.bind(SortUtil)); } /* -------------------------------------------- */ static pGetFluff (sp) { return Renderer.utils.pGetFluff({ entity: sp, fluffBaseUrl: `data/spells/`, fluffProp: "spellFluff", }); } }; Renderer.spell._spellSourceManagerPrerelease = new Renderer.spell._SpellSourceManager(); Renderer.spell._spellSourceManagerBrew = new Renderer.spell._SpellSourceManager(); Renderer.condition = class { static getCompactRenderedString (cond) { const renderer = Renderer.get(); const renderStack = []; renderStack.push(` ${Renderer.utils.getExcludedTr({entity: cond, dataProp: cond.__prop || cond._type, page: UrlUtil.PG_CONDITIONS_DISEASES})} ${Renderer.utils.getNameTr(cond, {page: UrlUtil.PG_CONDITIONS_DISEASES})} `); renderer.recursiveRender({entries: cond.entries}, renderStack); renderStack.push(``); return renderStack.join(""); } static pGetFluff (it) { return Renderer.utils.pGetFluff({ entity: it, fnGetFluffData: it.__prop === "condition" ? DataUtil.conditionFluff.loadJSON.bind(DataUtil.conditionFluff) : null, fluffProp: it.__prop === "condition" ? "conditionFluff" : "diseaseFluff", }); } }; Renderer.background = class { static getCompactRenderedString (bg) { return Renderer.generic.getCompactRenderedString( bg, { dataProp: "background", page: UrlUtil.PG_BACKGROUNDS, }, ); } static pGetFluff (bg) { return Renderer.utils.pGetFluff({ entity: bg, fnGetFluffData: DataUtil.backgroundFluff.loadJSON.bind(DataUtil.backgroundFluff), fluffProp: "backgroundFluff", }); } }; Renderer.backgroundFeature = class { static getCompactRenderedString (ent) { return Renderer.generic.getCompactRenderedString(ent); } }; Renderer.optionalfeature = class { static getListPrerequisiteLevelText (prerequisites) { if (!prerequisites || !prerequisites.some(it => it.level)) return "\u2014"; const levelPart = prerequisites.find(it => it.level).level; return levelPart.level || levelPart; } /* -------------------------------------------- */ static getPreviouslyPrintedEntry (ent) { if (!ent.previousVersion) return null; return `{@i An earlier version of this ${ent.featureType.map(t => Parser.optFeatureTypeToFull(t)).join("/")} is available in }${Parser.sourceJsonToFull(ent.previousVersion.source)} {@i as {@optfeature ${ent.previousVersion.name}|${ent.previousVersion.source}}.}`; } static getTypeEntry (ent) { return `{@note Type: ${Renderer.optionalfeature.getTypeText(ent)}}`; } static getCostEntry (ent) { if (!ent.consumes?.name) return null; const ptPrefix = "Cost: "; const tksUnit = ent.consumes.name .split(" ") .map(it => it.trim()) .filter(Boolean); tksUnit.last(tksUnit.last()[ent.consumes.amount != null && ent.consumes.amount !== 1 ? "toPlural" : "toString"]()); const ptUnit = ` ${tksUnit.join(" ")}`; if (ent.consumes?.amountMin != null && ent.consumes?.amountMax != null) return `{@i ${ptPrefix}${ent.consumes.amountMin}\u2013${ent.consumes.amountMax}${ptUnit}}`; return `{@i ${ptPrefix}${ent.consumes.amount ?? 1}${ptUnit}}`; } /* -------------------------------------------- */ static getPreviouslyPrintedText (ent) { const entry = Renderer.optionalfeature.getPreviouslyPrintedEntry(ent); if (!entry) return ""; return `

    ${Renderer.get().render(entry)}

    `; } static getTypeText (ent) { const commonPrefix = ent.featureType.length > 1 ? MiscUtil.findCommonPrefix(ent.featureType.map(fs => Parser.optFeatureTypeToFull(fs)), {isRespectWordBoundaries: true}) : ""; return [ commonPrefix.trim() || null, ent.featureType.map(ft => Parser.optFeatureTypeToFull(ft).substring(commonPrefix.length)).join("/"), ] .filter(Boolean).join(" "); } static getCostHtml (ent) { const entry = Renderer.optionalfeature.getCostEntry(ent); if (!entry) return ""; return Renderer.get().render(entry); } static getCompactRenderedString (ent) { const ptCost = Renderer.optionalfeature.getCostHtml(ent); return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: "optionalfeature", page: UrlUtil.PG_OPT_FEATURES})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_OPT_FEATURES})} ${ent.prerequisite ? `

    ${Renderer.utils.prerequisite.getHtml(ent.prerequisite)}

    ` : ""} ${ptCost ? `

    ${ptCost}

    ` : ""} ${Renderer.get().render({entries: ent.entries}, 1)} ${Renderer.optionalfeature.getPreviouslyPrintedText(ent)}

    ${Renderer.get().render(Renderer.optionalfeature.getTypeEntry(ent))}

    `; } }; Renderer.reward = class { static getRewardRenderableEntriesMeta (ent) { const ptSubtitle = [ (ent.type || "").toTitleCase(), ent.rarity ? ent.rarity.toTitleCase() : "", ] .filter(Boolean) .join(", "); return { entriesContent: [ ptSubtitle ? `{@i ${ptSubtitle}}` : "", ...ent.entries, ] .filter(Boolean), }; } static getRenderedString (ent) { const entriesMeta = Renderer.reward.getRewardRenderableEntriesMeta(ent); return `${Renderer.get().setFirstSection(true).render({entries: entriesMeta.entriesContent}, 1)}`; } static getCompactRenderedString (ent) { return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: "reward", page: UrlUtil.PG_REWARDS})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_REWARDS})} ${Renderer.reward.getRenderedString(ent)} `; } static pGetFluff (ent) { return Renderer.utils.pGetFluff({ entity: ent, fnGetFluffData: DataUtil.rewardFluff.loadJSON.bind(DataUtil.rewardFluff), fluffProp: "rewardFluff", }); } }; Renderer.race = class { static getRaceRenderableEntriesMeta (race) { return { entryMain: race._isBaseRace ? {type: "entries", entries: race._baseRaceEntries} : {type: "entries", entries: race.entries}, }; } static getCompactRenderedString (race, {isStatic = false} = {}) { const renderer = Renderer.get(); const renderStack = []; renderStack.push(` ${Renderer.utils.getExcludedTr({entity: race, dataProp: "race", page: UrlUtil.PG_RACES})} ${Renderer.utils.getNameTr(race, {page: UrlUtil.PG_RACES})}
    Ability Scores Size Speed
    ${Renderer.getAbilityData(race.ability).asText} ${(race.size || [Parser.SZ_VARIES]).map(sz => Parser.sizeAbvToFull(sz)).join("/")} ${Parser.getSpeedString(race)}
    `); renderer.recursiveRender(Renderer.race.getRaceRenderableEntriesMeta(race).entryMain, renderStack, {depth: 1}); renderStack.push(""); const ptHeightWeight = Renderer.race.getHeightAndWeightPart(race, {isStatic}); if (ptHeightWeight) renderStack.push(`
    ${ptHeightWeight}`); return renderStack.join(""); } static getRenderedSize (race) { return (race.size || [Parser.SZ_VARIES]).map(sz => Parser.sizeAbvToFull(sz)).join("/"); } static getHeightAndWeightPart (race, {isStatic = false} = {}) { if (!race.heightAndWeight) return null; if (race._isBaseRace) return null; return Renderer.get().render({entries: Renderer.race.getHeightAndWeightEntries(race, {isStatic})}); } static getHeightAndWeightEntries (race, {isStatic = false} = {}) { const colLabels = ["Base Height", "Base Weight", "Height Modifier", "Weight Modifier"]; const colStyles = ["col-2-3 ve-text-center", "col-2-3 ve-text-center", "col-2-3 ve-text-center", "col-2 ve-text-center"]; const cellHeightMod = !isStatic ? `+${race.heightAndWeight.heightMod}` : `+${race.heightAndWeight.heightMod}`; const cellWeightMod = !isStatic ? `× ${race.heightAndWeight.weightMod || "1"} lb.` : `× ${race.heightAndWeight.weightMod || "1"} lb.`; const row = [ Renderer.race.getRenderedHeight(race.heightAndWeight.baseHeight), `${race.heightAndWeight.baseWeight} lb.`, cellHeightMod, cellWeightMod, ]; if (!isStatic) { colLabels.push(""); colStyles.push("col-3-1 ve-text-center"); row.push(`
    =
    ;
    lb.
    `); } return [ "You may roll for your character's height and weight on the Random Height and Weight table. The roll in the Height Modifier column adds a number (in inches) to the character's base height. To get a weight, multiply the number you rolled for height by the roll in the Weight Modifier column and add the result (in pounds) to the base weight.", { type: "table", caption: "Random Height and Weight", colLabels, colStyles, rows: [row], }, ]; } static getRenderedHeight (height) { const heightFeet = Number(Math.floor(height / 12).toFixed(3)); const heightInches = Number((height % 12).toFixed(3)); return `${heightFeet ? `${heightFeet}'` : ""}${heightInches ? `${heightInches}"` : ""}`; } /** * @param races * @param [opts] Options object. * @param [opts.isAddBaseRaces] If an entity should be created for each base race. */ static mergeSubraces (races, opts) { opts = opts || {}; const out = []; races.forEach(r => { // FIXME(Deprecated) Backwards compatibility for old race data; remove at some point if (r.size && typeof r.size === "string") r.size = [r.size]; // Ignore `"lineage": true`, as it is only used for filters if (r.lineage && r.lineage !== true) { r = MiscUtil.copyFast(r); if (r.lineage === "VRGR") { r.ability = r.ability || [ { choose: { weighted: { from: [...Parser.ABIL_ABVS], weights: [2, 1], }, }, }, { choose: { weighted: { from: [...Parser.ABIL_ABVS], weights: [1, 1, 1], }, }, }, ]; } else if (r.lineage === "UA1") { r.ability = r.ability || [ { choose: { weighted: { from: [...Parser.ABIL_ABVS], weights: [2, 1], }, }, }, ]; } r.entries = r.entries || []; r.entries.push({ type: "entries", name: "Languages", entries: ["You can speak, read, and write Common and one other language that you and your DM agree is appropriate for your character."], }); r.languageProficiencies = r.languageProficiencies || [{"common": true, "anyStandard": 1}]; } if (r.subraces && !r.subraces.length) delete r.subraces; if (r.subraces) { r.subraces.forEach(sr => { sr.source = sr.source || r.source; sr._isSubRace = true; }); r.subraces.sort((a, b) => SortUtil.ascSortLower(a.name || "_", b.name || "_") || SortUtil.ascSortLower(Parser.sourceJsonToAbv(a.source), Parser.sourceJsonToAbv(b.source))); } if (opts.isAddBaseRaces && r.subraces) { const baseRace = MiscUtil.copyFast(r); baseRace._isBaseRace = true; const isAnyNoName = r.subraces.some(it => !it.name); if (isAnyNoName) { baseRace._rawName = baseRace.name; baseRace.name = `${baseRace.name} (Base)`; } const nameCounts = {}; r.subraces.filter(sr => sr.name).forEach(sr => nameCounts[sr.name.toLowerCase()] = (nameCounts[sr.name.toLowerCase()] || 0) + 1); nameCounts._ = r.subraces.filter(sr => !sr.name).length; const lst = { type: "list", items: r.subraces.map(sr => { const count = nameCounts[(sr.name || "_").toLowerCase()]; const idName = Renderer.race.getSubraceName(r.name, sr.name); return `{@race ${idName}|${sr.source}${count > 1 ? `|${idName} (${Parser.sourceJsonToAbv(sr.source)})` : ""}}`; }), }; Renderer.race._mutBaseRaceEntries(baseRace, lst); baseRace._subraces = r.subraces.map(sr => ({name: Renderer.race.getSubraceName(r.name, sr.name), source: sr.source})); delete baseRace.subraces; out.push(baseRace); } out.push(...Renderer.race._mergeSubraces(r)); }); return out; } static _mutMakeBaseRace (baseRace) { if (baseRace._isBaseRace) return; baseRace._isBaseRace = true; Renderer.race._mutBaseRaceEntries(baseRace, {type: "list", items: []}); } static _mutBaseRaceEntries (baseRace, lst) { baseRace._baseRaceEntries = [ { type: "section", entries: [ "This race has multiple subraces, as listed below:", lst, ], }, { type: "section", entries: [ { type: "entries", entries: [ { type: "entries", name: "Traits", entries: [ ...MiscUtil.copyFast(baseRace.entries), ], }, ], }, ], }, ]; } static getSubraceName (raceName, subraceName) { if (!subraceName) return raceName; const mBrackets = /^(.*?)(\(.*?\))$/i.exec(raceName || ""); if (!mBrackets) return `${raceName} (${subraceName})`; const bracketPart = mBrackets[2].substring(1, mBrackets[2].length - 1); return `${mBrackets[1]}(${[bracketPart, subraceName].join("; ")})`; } static _mergeSubraces (race) { if (!race.subraces) return [race]; return MiscUtil.copyFast(race.subraces).map(s => Renderer.race._getMergedSubrace(race, s)); } static _getMergedSubrace (race, cpySr) { const cpy = MiscUtil.copyFast(race); cpy._baseName = cpy.name; cpy._baseSource = cpy.source; cpy._baseSrd = cpy.srd; cpy._baseBasicRules = cpy.basicRules; delete cpy.subraces; delete cpy.srd; delete cpy.basicRules; delete cpy._versions; delete cpy.hasFluff; delete cpy.hasFluffImages; delete cpySr.__prop; // merge names, abilities, entries, tags if (cpySr.name) { cpy._subraceName = cpySr.name; if (cpySr.alias) { cpy.alias = cpySr.alias.map(it => Renderer.race.getSubraceName(cpy.name, it)); delete cpySr.alias; } cpy.name = Renderer.race.getSubraceName(cpy.name, cpySr.name); delete cpySr.name; } if (cpySr.ability) { // If the base race doesn't have any ability scores, make a set of empty records if ((cpySr.overwrite && cpySr.overwrite.ability) || !cpy.ability) cpy.ability = cpySr.ability.map(() => ({})); if (cpy.ability.length !== cpySr.ability.length) throw new Error(`Race and subrace ability array lengths did not match!`); cpySr.ability.forEach((obj, i) => Object.assign(cpy.ability[i], obj)); delete cpySr.ability; } if (cpySr.entries) { cpySr.entries.forEach(ent => { if (!ent.data?.overwrite) return cpy.entries.push(ent); const toOverwrite = cpy.entries.findIndex(it => it.name?.toLowerCase()?.trim() === ent.data.overwrite.toLowerCase().trim()); if (~toOverwrite) cpy.entries[toOverwrite] = ent; else cpy.entries.push(ent); }); delete cpySr.entries; } if (cpySr.traitTags) { if (cpySr.overwrite && cpySr.overwrite.traitTags) cpy.traitTags = cpySr.traitTags; else cpy.traitTags = (cpy.traitTags || []).concat(cpySr.traitTags); delete cpySr.traitTags; } if (cpySr.languageProficiencies) { if (cpySr.overwrite && cpySr.overwrite.languageProficiencies) cpy.languageProficiencies = cpySr.languageProficiencies; else cpy.languageProficiencies = cpy.languageProficiencies = (cpy.languageProficiencies || []).concat(cpySr.languageProficiencies); delete cpySr.languageProficiencies; } // TODO make a generalised merge system? Probably have one of those lying around somewhere [bestiary schema?] if (cpySr.skillProficiencies) { // Overwrite if possible if (!cpy.skillProficiencies || (cpySr.overwrite && cpySr.overwrite["skillProficiencies"])) cpy.skillProficiencies = cpySr.skillProficiencies; else { if (!cpySr.skillProficiencies.length || !cpy.skillProficiencies.length) throw new Error(`No items!`); if (cpySr.skillProficiencies.length > 1 || cpy.skillProficiencies.length > 1) throw new Error(`Subrace merging does not handle choices!`); // Implement if required // Otherwise, merge if (cpySr.skillProficiencies.choose) { if (cpy.skillProficiencies.choose) throw new Error(`Subrace choose merging is not supported!!`); // Implement if required cpy.skillProficiencies.choose = cpySr.skillProficiencies.choose; delete cpySr.skillProficiencies.choose; } Object.assign(cpy.skillProficiencies[0], cpySr.skillProficiencies[0]); } delete cpySr.skillProficiencies; } // overwrite everything else Object.assign(cpy, cpySr); // For any null'd out fields on the subrace, delete the field Object.entries(cpy) .forEach(([k, v]) => { if (v != null) return; delete cpy[k]; }); return cpy; } static adoptSubraces (allRaces, subraces) { const nxtData = []; subraces.forEach(sr => { if (!sr.raceName || !sr.raceSource) throw new Error(`Subrace was missing parent "raceName" and/or "raceSource"!`); const _baseRace = allRaces.find(r => r.name === sr.raceName && r.source === sr.raceSource); if (!_baseRace) throw new Error(`Could not find parent race for subrace "${sr.name}" (${sr.source})!`); // Avoid adding duplicates, by tracking already-seen subraces if ((_baseRace._seenSubraces || []).some(it => it.name === sr.name && it.source === sr.source)) return; (_baseRace._seenSubraces = _baseRace._seenSubraces || []).push({name: sr.name, source: sr.source}); // If this is a prerelease/homebrew "base race" which is not marked as such, upgrade it to a base race if ( !_baseRace._isBaseRace && (PrereleaseUtil.hasSourceJson(_baseRace.source) || BrewUtil2.hasSourceJson(_baseRace.source)) ) { Renderer.race._mutMakeBaseRace(_baseRace); } // If the base race is a _real_ base race, add our new subrace to its list of subraces if (_baseRace._isBaseRace) { const subraceListEntry = ((_baseRace._baseRaceEntries[0] || {}).entries || []).find(it => it.type === "list"); subraceListEntry.items.push(`{@race ${_baseRace._rawName || _baseRace.name} (${sr.name})|${sr.source || _baseRace.source}}`); } // Attempt to graft multiple subraces from the same data set onto the same base race copy let baseRace = nxtData.find(r => r.name === sr.raceName && r.source === sr.raceSource); if (!baseRace) { // copy and remove base-race-specific data baseRace = MiscUtil.copyFast(_baseRace); if (baseRace._rawName) { baseRace.name = baseRace._rawName; delete baseRace._rawName; } delete baseRace._isBaseRace; delete baseRace._baseRaceEntries; nxtData.push(baseRace); } baseRace.subraces = baseRace.subraces || []; baseRace.subraces.push(sr); }); return nxtData; } static bindListenersHeightAndWeight (race, ele) { if (!race.heightAndWeight) return; if (race._isBaseRace) return; const $render = $(ele); const $dispResult = $render.find(`.race__disp-result-height-weight`); const $dispHeight = $render.find(`.race__disp-result-height`); const $dispWeight = $render.find(`.race__disp-result-weight`); const lock = new VeLock(); let hasRolled = false; let resultHeight; let resultWeightMod; const $btnRollHeight = $render .find(`[data-race-heightmod="true"]`) .html(race.heightAndWeight.heightMod) .addClass("roller") .mousedown(evt => evt.preventDefault()) .click(async () => { try { await lock.pLock(); if (!hasRolled) return pDoFullRoll(true); await pRollHeight(); updateDisplay(); } finally { lock.unlock(); } }); const isWeightRoller = race.heightAndWeight.weightMod && isNaN(race.heightAndWeight.weightMod); const $btnRollWeight = $render .find(`[data-race-weightmod="true"]`) .html(isWeightRoller ? `(${race.heightAndWeight.weightMod})` : race.heightAndWeight.weightMod || "1") .click(async () => { try { await lock.pLock(); if (!hasRolled) return pDoFullRoll(true); await pRollWeight(); updateDisplay(); } finally { lock.unlock(); } }); if (isWeightRoller) $btnRollWeight.mousedown(evt => evt.preventDefault()); const $btnRoll = $render .find(`button.race__btn-roll-height-weight`) .click(async () => pDoFullRoll()); const pRollHeight = async () => { const mResultHeight = await Renderer.dice.pRoll2(race.heightAndWeight.heightMod, { isUser: false, label: "Height Modifier", name: race.name, }); if (mResultHeight == null) return; resultHeight = mResultHeight; }; const pRollWeight = async () => { const weightModRaw = race.heightAndWeight.weightMod || "1"; const mResultWeightMod = isNaN(weightModRaw) ? await Renderer.dice.pRoll2(weightModRaw, { isUser: false, label: "Weight Modifier", name: race.name, }) : Number(weightModRaw); if (mResultWeightMod == null) return; resultWeightMod = mResultWeightMod; }; const updateDisplay = () => { const renderedHeight = Renderer.race.getRenderedHeight(race.heightAndWeight.baseHeight + resultHeight); const totalWeight = race.heightAndWeight.baseWeight + (resultWeightMod * resultHeight); $dispHeight.text(renderedHeight); $dispWeight.text(Number(totalWeight.toFixed(3))); }; const pDoFullRoll = async isPreLocked => { try { if (!isPreLocked) await lock.pLock(); $btnRoll.parent().removeClass(`ve-flex-vh-center`).addClass(`split-v-center`); await pRollHeight(); await pRollWeight(); $dispResult.removeClass(`ve-hidden`); updateDisplay(); hasRolled = true; } finally { if (!isPreLocked) lock.unlock(); } }; } static bindListenersCompact (race, ele) { Renderer.race.bindListenersHeightAndWeight(race, ele); } static pGetFluff (race) { return Renderer.utils.pGetFluff({ entity: race, fnGetFluffData: DataUtil.raceFluff.loadJSON.bind(DataUtil.raceFluff), fluffProp: "raceFluff", }); } }; Renderer.raceFeature = class { static getCompactRenderedString (ent) { return Renderer.generic.getCompactRenderedString(ent); } }; Renderer.deity = class { static _BASE_PART_TRANSLATORS = { "alignment": { name: "Alignment", displayFn: (it) => it.map(a => Parser.alignmentAbvToFull(a)).join(" ").toTitleCase(), }, "pantheon": { name: "Pantheon", }, "category": { name: "Category", displayFn: it => typeof it === "string" ? it : it.join(", "), }, "domains": { name: "Domains", displayFn: (it) => it.join(", "), }, "province": { name: "Province", }, "altNames": { name: "Alternate Names", displayFn: (it) => it.join(", "), }, "symbol": { name: "Symbol", }, }; static getDeityRenderableEntriesMeta (ent) { return { entriesAttributes: [ ...Object.entries(Renderer.deity._BASE_PART_TRANSLATORS) .map(([prop, {name, displayFn}]) => { if (ent[prop] == null) return null; const displayVal = displayFn ? displayFn(ent[prop]) : ent[prop]; return { name, entry: `{@b ${name}:} ${displayVal}`, }; }) .filter(Boolean), ...Object.entries(ent.customProperties || {}) .map(([name, val]) => ({ name, entry: `{@b ${name}:} ${val}`, })), ] .sort(({name: nameA}, {name: nameB}) => SortUtil.ascSortLower(nameA, nameB)) .map(({entry}) => entry), }; } static getCompactRenderedString (ent) { const renderer = Renderer.get(); const entriesMeta = Renderer.deity.getDeityRenderableEntriesMeta(ent); return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: "deity", page: UrlUtil.PG_DEITIES})} ${Renderer.utils.getNameTr(ent, {suffix: ent.title ? `, ${ent.title.toTitleCase()}` : "", page: UrlUtil.PG_DEITIES})} ${entriesMeta.entriesAttributes.map(entry => `
    ${Renderer.get().render(entry)}
    `).join("")} ${ent.entries ? `
    ${renderer.render({entries: ent.entries}, 1)}` : ""} `; } }; Renderer.object = class { static CHILD_PROPS = ["actionEntries"]; /* -------------------------------------------- */ static RENDERABLE_ENTRIES_PROP_ORDER__ATTRIBUTES = [ "entryCreatureCapacity", "entryCargoCapacity", "entryArmorClass", "entryHitPoints", "entrySpeed", "entryAbilityScores", "entryDamageImmunities", "entryDamageResistances", "entryDamageVulnerabilities", "entryConditionImmunities", "entrySenses", ]; static getObjectRenderableEntriesMeta (ent) { return { entrySize: `{@i ${ent.objectType !== "GEN" ? `${Renderer.utils.getRenderedSize(ent.size)} ${ent.creatureType ? Parser.monTypeToFullObj(ent.creatureType).asText : "object"}` : `Variable size object`}}`, entryCreatureCapacity: ent.capCrew != null || ent.capPassenger != null ? `{@b Creature Capacity:} ${Renderer.vehicle.getShipCreatureCapacity(ent)}` : null, entryCargoCapacity: ent.capCargo != null ? `{@b Cargo Capacity:} ${Renderer.vehicle.getShipCargoCapacity(ent)}` : null, entryArmorClass: ent.ac != null ? `{@b Armor Class:} ${ent.ac.special ?? ent.ac}` : null, entryHitPoints: ent.hp != null ? `{@b Hit Points:} ${ent.hp.special ?? ent.hp}` : null, entrySpeed: ent.speed != null ? `{@b Speed:} ${Parser.getSpeedString(ent)}` : null, entryAbilityScores: Parser.ABIL_ABVS.some(ab => ent[ab] != null) ? `{@b Ability Scores:} ${Parser.ABIL_ABVS.filter(ab => ent[ab] != null).map(ab => `${ab.toUpperCase()} ${Renderer.utils.getAbilityRollerEntry(ent, ab)}`).join(", ")}` : null, entryDamageImmunities: ent.immune != null ? `{@b Damage Immunities:} ${Parser.getFullImmRes(ent.immune)}` : null, entryDamageResistances: ent.resist ? `{@b Damage Resistances:} ${Parser.getFullImmRes(ent.resist)}` : null, entryDamageVulnerabilities: ent.vulnerable ? `{@b Damage Vulnerabilities:} ${Parser.getFullImmRes(ent.vulnerable)}` : null, entryConditionImmunities: ent.conditionImmune ? `{@b Condition Immunities:} ${Parser.getFullCondImm(ent.conditionImmune, {isEntry: true})}` : null, entrySenses: ent.senses ? `{@b Senses:} ${Renderer.utils.getSensesEntry(ent.senses)}` : null, }; } /* -------------------------------------------- */ static getCompactRenderedString (obj, opts) { return Renderer.object.getRenderedString(obj, {...opts, isCompact: true}); } static getRenderedString (ent, opts) { opts = opts || {}; const renderer = Renderer.get().setFirstSection(true); const hasToken = ent.tokenUrl || ent.hasToken; const extraThClasses = !opts.isCompact && hasToken ? ["objs__name--token"] : null; const entriesMeta = Renderer.object.getObjectRenderableEntriesMeta(ent); const ptAttribs = Renderer.object.RENDERABLE_ENTRIES_PROP_ORDER__ATTRIBUTES .filter(prop => entriesMeta[prop]) .map(prop => `${Renderer.get().render(entriesMeta[prop])}
    `) .join(""); return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: "object", page: opts.page || UrlUtil.PG_OBJECTS})} ${Renderer.utils.getNameTr(ent, {page: opts.page || UrlUtil.PG_OBJECTS, extraThClasses, isEmbeddedEntity: opts.isEmbeddedEntity})} ${Renderer.get().render(entriesMeta.entrySize)} ${ptAttribs} ${ent.entries ? renderer.render({entries: ent.entries}, 2) : ""} ${ent.actionEntries ? renderer.render({entries: ent.actionEntries}, 2) : ""} `; } static getTokenUrl (obj) { if (obj.tokenUrl) return obj.tokenUrl; return Renderer.get().getMediaUrl("img", `objects/tokens/${Parser.sourceJsonToAbv(obj.source)}/${Parser.nameToTokenName(obj.name)}.webp`); } static pGetFluff (obj) { return Renderer.utils.pGetFluff({ entity: obj, fnGetFluffData: DataUtil.objectFluff.loadJSON.bind(DataUtil.objectFluff), fluffProp: "objectFluff", }); } }; Renderer.trap = class { static CHILD_PROPS = ["trigger", "effect", "eActive", "eDynamic", "eConstant", "countermeasures"]; static getTrapRenderableEntriesMeta (ent) { return { entriesAttributes: [ // region Shared between simple/complex ent.trigger ? { type: "entries", name: "Trigger", entries: ent.trigger, } : null, // endregion // region Simple traps ent.effect ? { type: "entries", name: "Effect", entries: ent.effect, } : null, // endregion // region Complex traps ent.initiative ? { type: "entries", name: "Initiative", entries: Renderer.trap.getTrapInitiativeEntries(ent), } : null, ent.eActive ? { type: "entries", name: "Active Elements", entries: ent.eActive, } : null, ent.eDynamic ? { type: "entries", name: "Dynamic Elements", entries: ent.eDynamic, } : null, ent.eConstant ? { type: "entries", name: "Constant Elements", entries: ent.eConstant, } : null, // endregion // region Shared between simple/complex ent.countermeasures ? { type: "entries", name: "Countermeasures", entries: ent.countermeasures, } : null, // endregion ] .filter(Boolean), }; } static getTrapInitiativeEntries (ent) { return [`The trap acts on ${Parser.trapInitToFull(ent.initiative)}${ent.initiativeNote ? ` (${ent.initiativeNote})` : ""}.`]; } static getRenderedTrapPart (renderer, ent) { const entriesMeta = Renderer.trap.getTrapRenderableEntriesMeta(ent); if (!entriesMeta.entriesAttributes.length) return ""; return renderer.render({entries: entriesMeta.entriesAttributes}, 1); } static getCompactRenderedString (ent, opts) { return Renderer.traphazard.getCompactRenderedString(ent, opts); } static pGetFluff (ent) { return Renderer.traphazard.pGetFluff(ent); } }; Renderer.hazard = class { static getCompactRenderedString (ent, opts) { return Renderer.traphazard.getCompactRenderedString(ent, opts); } static pGetFluff (ent) { return Renderer.traphazard.pGetFluff(ent); } }; Renderer.traphazard = class { static getSubtitle (ent) { const type = ent.trapHazType || "HAZ"; if (type === "GEN") return null; const ptThreat = ent.threat ? ent.threat.toTitleCase() : null; const ptTypeThreat = [ Parser.trapHazTypeToFull(type), ent.threat ? ent.threat.toTitleCase() : null, ] .filter(Boolean) .join(", "); const parenPart = [ ent.tier ? Parser.tierToFullLevel(ent.tier) : null, Renderer.traphazard.getTrapLevelPart(ent), ] .filter(Boolean) .join(", "); return parenPart ? `${ptTypeThreat} (${parenPart})` : ptTypeThreat; } static getTrapLevelPart (ent) { return ent.level?.min != null && ent.level?.max != null ? `level ${ent.level.min}${ent.level.min !== ent.level.max ? `\u2013${ent.level.max}` : ""}` : null; } static getCompactRenderedString (ent, opts) { opts = opts || {}; const renderer = Renderer.get(); const subtitle = Renderer.traphazard.getSubtitle(ent); return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: ent.__prop, page: UrlUtil.PG_TRAPS_HAZARDS})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_TRAPS_HAZARDS, isEmbeddedEntity: opts.isEmbeddedEntity})} ${subtitle ? `${subtitle}` : ""} ${renderer.render({entries: ent.entries}, 2)} ${Renderer.trap.getRenderedTrapPart(renderer, ent)} `; } static pGetFluff (ent) { return Renderer.utils.pGetFluff({ entity: ent, fnGetFluffData: ent.__prop === "trap" ? DataUtil.trapFluff.loadJSON.bind(DataUtil.trapFluff) : DataUtil.hazardFluff.loadJSON.bind(DataUtil.hazardFluff), fluffProp: ent.__prop === "trap" ? "trapFluff" : "hazardFluff", }); } }; Renderer.cultboon = class { static getCultRenderableEntriesMeta (ent) { if (!ent.goal && !ent.cultists && !ent.signaturespells) return null; const fauxList = { type: "list", style: "list-hang-notitle", items: [], }; if (ent.goal) { fauxList.items.push({ type: "item", name: "Goals:", entry: ent.goal.entry, }); } if (ent.cultists) { fauxList.items.push({ type: "item", name: "Typical Cultists:", entry: ent.cultists.entry, }); } if (ent.signaturespells) { fauxList.items.push({ type: "item", name: "Signature Spells:", entry: ent.signaturespells.entry, }); } return {listGoalsCultistsSpells: fauxList}; } static doRenderCultParts (ent, renderer, renderStack) { const cultEntriesMeta = Renderer.cultboon.getCultRenderableEntriesMeta(ent); if (!cultEntriesMeta) return; renderer.recursiveRender(cultEntriesMeta.listGoalsCultistsSpells, renderStack, {depth: 2}); } /* -------------------------------------------- */ static getBoonRenderableEntriesMeta (ent) { if (!ent.ability && !ent.signaturespells) return null; const benefits = {type: "list", style: "list-hang-notitle", items: []}; if (ent.ability) { benefits.items.push({ type: "item", name: "Ability Score Adjustment:", entry: ent.ability ? ent.ability.entry : "None", }); } if (ent.signaturespells) { benefits.items.push({ type: "item", name: "Signature Spells:", entry: ent.signaturespells ? ent.signaturespells.entry : "None", }); } return {listBenefits: benefits}; } static doRenderBoonParts (ent, renderer, renderStack) { const boonEntriesMeta = Renderer.cultboon.getBoonRenderableEntriesMeta(ent); if (!boonEntriesMeta) return; renderer.recursiveRender(boonEntriesMeta.listBenefits, renderStack, {depth: 1}); } /* -------------------------------------------- */ static _getCompactRenderedString_cult ({ent, renderer}) { const renderStack = []; Renderer.cultboon.doRenderCultParts(ent, renderer, renderStack); renderer.recursiveRender({entries: ent.entries}, renderStack, {depth: 2}); return `${Renderer.utils.getExcludedTr({entity: ent, dataProp: "cult", page: UrlUtil.PG_CULTS_BOONS})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_CULTS_BOONS})}
    ${renderStack.join("")}`; } static _getCompactRenderedString_boon ({ent, renderer}) { const renderStack = []; Renderer.cultboon.doRenderBoonParts(ent, renderer, renderStack); renderer.recursiveRender({entries: ent.entries}, renderStack, {depth: 1}); ent._displayName = ent._displayName || ent.name; return `${Renderer.utils.getExcludedTr({entity: ent, dataProp: "boon", page: UrlUtil.PG_CULTS_BOONS})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_CULTS_BOONS})} ${renderStack.join("")}`; } static getCompactRenderedString (ent) { const renderer = Renderer.get(); switch (ent.__prop) { case "cult": return Renderer.cultboon._getCompactRenderedString_cult({ent, renderer}); case "boon": return Renderer.cultboon._getCompactRenderedString_boon({ent, renderer}); default: throw new Error(`Unhandled prop "${ent.__prop}"`); } } }; Renderer.monster = class { static CHILD_PROPS = ["action", "bonus", "reaction", "trait", "legendary", "mythic", "variant", "spellcasting"]; static getShortName (mon, {isTitleCase = false, isSentenceCase = false, isUseDisplayName = false} = {}) { const name = isUseDisplayName ? (mon._displayName ?? mon.name) : mon.name; const shortName = isUseDisplayName ? (mon._displayShortName ?? mon.shortName) : mon.shortName; const prefix = mon.isNamedCreature ? "" : isTitleCase || isSentenceCase ? "The " : "the "; if (shortName === true) return `${prefix}${name}`; else if (shortName) return `${prefix}${!prefix && isTitleCase ? shortName.toTitleCase() : shortName.toLowerCase()}`; const out = Renderer.monster.getShortNameFromName(name, {isNamedCreature: mon.isNamedCreature}); return `${prefix}${out}`; } static getShortNameFromName (name, {isNamedCreature = false} = {}) { const base = name.split(",")[0]; let out = base .replace(/(?:adult|ancient|young) \w+ (dragon|dracolich)/gi, "$1"); out = isNamedCreature ? out.split(" ")[0] : out.toLowerCase(); return out; } static getLegendaryActionIntro (mon, {renderer = Renderer.get(), isUseDisplayName = false} = {}) { return renderer.render(Renderer.monster.getLegendaryActionIntroEntry(mon, {isUseDisplayName})); } static getLegendaryActionIntroEntry (mon, {isUseDisplayName = false} = {}) { if (mon.legendaryHeader) { return {entries: mon.legendaryHeader}; } const legendaryActions = mon.legendaryActions || 3; const legendaryNameTitle = Renderer.monster.getShortName(mon, {isTitleCase: true, isUseDisplayName}); return { entries: [ `${legendaryNameTitle} can take ${legendaryActions} legendary action${legendaryActions > 1 ? "s" : ""}, choosing from the options below. Only one legendary action can be used at a time and only at the end of another creature's turn. ${legendaryNameTitle} regains spent legendary actions at the start of its turn.`, ], }; } static getSectionIntro (mon, {renderer = Renderer.get(), prop}) { const headerProp = `${prop}Header`; if (mon[headerProp]) return renderer.render({entries: mon[headerProp]}); return ""; } static getSave (renderer, attr, mod) { if (attr === "special") return renderer.render(mod); return renderer.render(`${attr.uppercaseFirst()} {@savingThrow ${attr} ${mod}}`); } static dragonCasterVariant = class { // Community-created (legacy) static _LVL_TO_COLOR_TO_SPELLS__UNOFFICIAL = { 2: { black: ["darkness", "Melf's acid arrow", "fog cloud", "scorching ray"], green: ["ray of sickness", "charm person", "detect thoughts", "invisibility", "suggestion"], white: ["ice knife|XGE", "Snilloc's snowball swarm|XGE"], brass: ["see invisibility", "magic mouth", "blindness/deafness", "sleep", "detect thoughts"], bronze: ["gust of wind", "misty step", "locate object", "blur", "witch bolt", "thunderwave", "shield"], copper: ["knock", "sleep", "detect thoughts", "blindness/deafness", "tasha's hideous laughter"], }, 3: { blue: ["wall of sand|XGE", "thunder step|XGE", "lightning bolt", "blink", "magic missile", "slow"], red: ["fireball", "scorching ray", "haste", "erupting earth|XGE", "Aganazzar's scorcher|XGE"], gold: ["slow", "fireball", "dispel magic", "counterspell", "Aganazzar's scorcher|XGE", "shield"], silver: ["sleet storm", "protection from energy", "catnap|XGE", "locate object", "identify", "Leomund's tiny hut"], }, 4: { black: ["vitriolic sphere|XGE", "sickening radiance|XGE", "Evard's black tentacles", "blight", "hunger of Hadar"], white: ["fire shield", "ice storm", "sleet storm"], brass: ["charm monster|XGE", "sending", "wall of sand|XGE", "hypnotic pattern", "tongues"], copper: ["polymorph", "greater invisibility", "confusion", "stinking cloud", "major image", "charm monster|XGE"], }, 5: { blue: ["telekinesis", "hold monster", "dimension door", "wall of stone", "wall of force"], green: ["cloudkill", "charm monster|XGE", "modify memory", "mislead", "hallucinatory terrain", "dimension door"], bronze: ["steel wind strike|XGE", "control winds|XGE", "watery sphere|XGE", "storm sphere|XGE", "tidal wave|XGE"], gold: ["hold monster", "immolation|XGE", "wall of fire", "greater invisibility", "dimension door"], silver: ["cone of cold", "ice storm", "teleportation circle", "skill empowerment|XGE", "creation", "Mordenkainen's private sanctum"], }, 6: { white: ["cone of cold", "wall of ice"], brass: ["scrying", "Rary's telepathic bond", "Otto's irresistible dance", "legend lore", "hold monster", "dream"], }, 7: { black: ["power word pain|XGE", "finger of death", "disintegrate", "hold monster"], blue: ["chain lightning", "forcecage", "teleport", "etherealness"], green: ["project image", "mirage arcane", "prismatic spray", "teleport"], bronze: ["whirlwind|XGE", "chain lightning", "scatter|XGE", "teleport", "disintegrate", "lightning bolt"], copper: ["symbol", "simulacrum", "reverse gravity", "project image", "Bigby's hand", "mental prison|XGE", "seeming"], silver: ["Otiluke's freezing sphere", "prismatic spray", "wall of ice", "contingency", "arcane gate"], }, 8: { gold: ["sunburst", "delayed blast fireball", "antimagic field", "teleport", "globe of invulnerability", "maze"], }, }; // From Fizban's Treasury of Dragons static _LVL_TO_COLOR_TO_SPELLS__FTD = { 1: { deep: ["command", "dissonant whispers", "faerie fire"], }, 2: { black: ["blindness/deafness", "create or destroy water"], green: ["invisibility", "speak with animals"], white: ["gust of wind"], brass: ["create or destroy water", "speak with animals"], bronze: ["beast sense", "detect thoughts", "speak with animals"], copper: ["lesser restoration", "phantasmal force"], }, 3: { blue: ["create or destroy water", "major image"], red: ["bane", "heat metal", "hypnotic pattern", "suggestion"], gold: ["bless", "cure wounds", "slow", "suggestion", "zone of truth"], silver: ["beacon of hope", "calm emotions", "hold person", "zone of truth"], deep: ["command", "dissonant whispers", "faerie fire", "water breathing"], }, 4: { black: ["blindness/deafness", "create or destroy water", "plant growth"], white: ["gust of wind"], brass: ["create or destroy water", "speak with animals", "suggestion"], copper: ["lesser restoration", "phantasmal force", "stone shape"], }, 5: { blue: ["arcane eye", "create or destroy water", "major image"], red: ["bane", "dominate person", "heat metal", "hypnotic pattern", "suggestion"], green: ["invisibility", "plant growth", "speak with animals"], bronze: ["beast sense", "control water", "detect thoughts", "speak with animals"], gold: ["bless", "commune", "cure wounds", "geas", "slow", "suggestion", "zone of truth"], silver: ["beacon of hope", "calm emotions", "hold person", "polymorph", "zone of truth"], }, 6: { white: ["gust of wind", "ice storm"], brass: ["create or destroy water", "locate creature", "speak with animals", "suggestion"], deep: ["command", "dissonant whispers", "faerie fire", "passwall", "water breathing"], }, 7: { black: ["blindness/deafness", "create or destroy water", "insect plague", "plant growth"], blue: ["arcane eye", "create or destroy water", "major image", "project image"], red: ["bane", "dominate person", "heat metal", "hypnotic pattern", "power word stun", "suggestion"], green: ["invisibility", "mass suggestion", "plant growth", "speak with animals"], bronze: ["beast sense", "control water", "detect thoughts", "heroes' feast", "speak with animals"], copper: ["lesser restoration", "move earth", "phantasmal force", "stone shape"], silver: ["beacon of hope", "calm emotions", "hold person", "polymorph", "teleport", "zone of truth"], }, 8: { gold: ["bless", "commune", "cure wounds", "geas", "plane shift", "slow", "suggestion", "word of recall", "zone of truth"], }, }; static getAvailableColors () { const out = new Set(); const add = (lookup) => Object.values(lookup).forEach(obj => Object.keys(obj).forEach(k => out.add(k))); add(Renderer.monster.dragonCasterVariant._LVL_TO_COLOR_TO_SPELLS__UNOFFICIAL); add(Renderer.monster.dragonCasterVariant._LVL_TO_COLOR_TO_SPELLS__FTD); return [...out].sort(SortUtil.ascSortLower); } static hasCastingColorVariant (dragon) { // if the dragon already has a spellcasting trait specified, don't add a note about adding a spellcasting trait return dragon.dragonCastingColor && !dragon.spellcasting; } static getMeta (dragon) { const chaMod = Parser.getAbilityModNumber(dragon.cha); const pb = Parser.crToPb(dragon.cr); const maxSpellLevel = Math.floor(Parser.crToNumber(dragon.cr) / 3); return { chaMod, pb, maxSpellLevel, spellSaveDc: pb + chaMod + 8, spellToHit: pb + chaMod, exampleSpellsUnofficial: Renderer.monster.dragonCasterVariant._getMeta_getExampleSpells({ dragon, maxSpellLevel, spellLookup: Renderer.monster.dragonCasterVariant._LVL_TO_COLOR_TO_SPELLS__UNOFFICIAL, }), exampleSpellsFtd: Renderer.monster.dragonCasterVariant._getMeta_getExampleSpells({ dragon, maxSpellLevel, spellLookup: Renderer.monster.dragonCasterVariant._LVL_TO_COLOR_TO_SPELLS__FTD, }), }; } static _getMeta_getExampleSpells ({dragon, maxSpellLevel, spellLookup}) { if (spellLookup[maxSpellLevel]?.[dragon.dragonCastingColor]) return spellLookup[maxSpellLevel][dragon.dragonCastingColor]; // If there's no exact match, try to find the next lowest const flatKeys = Object.entries(spellLookup) .map(([lvl, group]) => { return Object.keys(group) .map(color => `${lvl}${color}`); }) .flat() .mergeMap(it => ({[it]: true})); while (--maxSpellLevel > -1) { const lookupKey = `${maxSpellLevel}${dragon.dragonCastingColor}`; if (flatKeys[lookupKey]) return spellLookup[maxSpellLevel][dragon.dragonCastingColor]; } return []; } static getSpellcasterDetailsPart ({chaMod, maxSpellLevel, spellSaveDc, spellToHit, isSeeSpellsPageNote = false}) { const levelString = maxSpellLevel === 0 ? `${chaMod === 1 ? "This" : "These"} spells are Cantrips.` : `${chaMod === 1 ? "The" : "Each"} spell's level can be no higher than ${Parser.spLevelToFull(maxSpellLevel)}.`; return `This dragon can innately cast ${Parser.numberToText(chaMod)} spell${chaMod === 1 ? "" : "s"}, once per day${chaMod === 1 ? "" : " each"}, requiring no material components. ${levelString} The dragon's spell save DC is {@dc ${spellSaveDc}}, and it has {@hit ${spellToHit}} to hit with spell attacks.${isSeeSpellsPageNote ? ` See the {@filter spell page|spells|level=${[...new Array(maxSpellLevel + 1)].map((it, i) => i).join(";")}} for a list of spells the dragon is capable of casting.` : ""}`; } static getVariantEntries (dragon) { if (!Renderer.monster.dragonCasterVariant.hasCastingColorVariant(dragon)) return []; const meta = Renderer.monster.dragonCasterVariant.getMeta(dragon); const {exampleSpellsUnofficial, exampleSpellsFtd} = meta; const vFtd = exampleSpellsFtd?.length ? { type: "variant", name: "Dragons as Innate Spellcasters", source: Parser.SRC_FTD, entries: [ `${Renderer.monster.dragonCasterVariant.getSpellcasterDetailsPart(meta)}`, `A suggested spell list is shown below, but you can also choose spells to reflect the dragon's character. A dragon who innately casts {@filter druid|spells|class=druid} spells feels different from one who casts {@filter warlock|spells|class=warlock} spells. You can also give a dragon spells of a higher level than this rule allows, but such a tweak might increase the dragon's challenge rating\u2014especially if those spells deal damage or impose conditions on targets.`, { type: "list", items: exampleSpellsFtd.map(it => `{@spell ${it}}`), }, ], } : null; const vBasic = { type: "variant", name: "Dragons as Innate Spellcasters", entries: [ "Dragons are innately magical creatures that can master a few spells as they age, using this variant.", `A young or older dragon can innately cast a number of spells equal to its Charisma modifier. Each spell can be cast once per day, requiring no material components, and the spell's level can be no higher than one-third the dragon's challenge rating (rounded down). The dragon's bonus to hit with spell attacks is equal to its proficiency bonus + its Charisma bonus. The dragon's spell save DC equals 8 + its proficiency bonus + its Charisma modifier.`, `{@note ${Renderer.monster.dragonCasterVariant.getSpellcasterDetailsPart({...meta, isSeeSpellsPageNote: true})}${exampleSpellsUnofficial?.length ? ` A selection of examples are shown below:` : ""}}`, ], }; if (dragon.source !== Parser.SRC_MM) { vBasic.source = Parser.SRC_MM; vBasic.page = 86; } if (exampleSpellsUnofficial) { const ls = { type: "list", style: "list-italic", items: exampleSpellsUnofficial.map(it => `{@spell ${it}}`), }; vBasic.entries.push(ls); } return [vFtd, vBasic].filter(Boolean); } static getHtml (dragon, {renderer = null} = {}) { const variantEntrues = Renderer.monster.dragonCasterVariant.getVariantEntries(dragon); if (!variantEntrues.length) return null; return variantEntrues.map(it => renderer.render(it)).join(""); } }; static getCrScaleTarget ( { win, $btnScale, initialCr, cbRender, isCompact, }, ) { const evtName = "click.cr-scaler"; let slider; const $body = $(win.document.body); function cleanSliders () { $body.find(`.mon__cr_slider_wrp`).remove(); $btnScale.off(evtName); if (slider) slider.destroy(); } cleanSliders(); const $wrp = $(`
    `); const cur = Parser.CRS.indexOf(initialCr); if (cur === -1) throw new Error(`Initial CR ${initialCr} was not valid!`); const comp = BaseComponent.fromObject({ min: 0, max: Parser.CRS.length - 1, cur, }); slider = new ComponentUiUtil.RangeSlider({ comp, propMin: "min", propMax: "max", propCurMin: "cur", fnDisplay: ix => Parser.CRS[ix], }); slider.$get().appendTo($wrp); $btnScale.off(evtName).on(evtName, (evt) => evt.stopPropagation()); $wrp.on(evtName, (evt) => evt.stopPropagation()); $body.off(evtName).on(evtName, cleanSliders); comp._addHookBase("cur", () => { cbRender(Parser.crToNumber(Parser.CRS[comp._state.cur])); $body.off(evtName); cleanSliders(); }); $btnScale.after($wrp); } static getSelSummonSpellLevel (mon) { if (mon.summonedBySpellLevel == null) return; return e_({ tag: "select", clazz: "input-xs form-control form-control--minimal w-initial inline-block ve-popwindow__hidden", name: "mon__sel-summon-spell-level", children: [ e_({tag: "option", val: "-1", text: "\u2014"}), ...[...new Array(VeCt.SPELL_LEVEL_MAX + 1 - mon.summonedBySpellLevel)].map((_, i) => e_({ tag: "option", val: i + mon.summonedBySpellLevel, text: i + mon.summonedBySpellLevel, })), ], }); } static getSelSummonClassLevel (mon) { if (mon.summonedByClass == null) return; return e_({ tag: "select", clazz: "input-xs form-control form-control--minimal w-initial inline-block ve-popwindow__hidden", name: "mon__sel-summon-class-level", children: [ e_({tag: "option", val: "-1", text: "\u2014"}), ...[...new Array(VeCt.LEVEL_MAX)].map((_, i) => e_({ tag: "option", val: i + 1, text: i + 1, })), ], }); } static getCompactRenderedStringSection (mon, renderer, title, key, depth) { if (!mon[key]) return ""; const noteKey = `${key}Note`; const toRender = key === "lairActions" || key === "regionalEffects" ? [{type: "entries", entries: mon[key]}] : mon[key]; const ptHeader = mon[key] ? Renderer.monster.getSectionIntro(mon, {prop: key}) : ""; return `

    ${title}${mon[noteKey] ? ` (${mon[noteKey]})` : ""}

    ${key === "legendary" && mon.legendary ? `

    ${Renderer.monster.getLegendaryActionIntro(mon)}

    ` : ""} ${ptHeader ? `

    ${ptHeader}

    ` : ""} ${toRender.map(it => it.rendered || renderer.render(it, depth)).join("")} `; } static getTypeAlignmentPart (mon) { const typeObj = Parser.monTypeToFullObj(mon.type); return `${mon.level ? `${Parser.getOrdinalForm(mon.level)}-level ` : ""}${typeObj.asTextSidekick ? `${typeObj.asTextSidekick}; ` : ""}${Renderer.utils.getRenderedSize(mon.size)}${mon.sizeNote ? ` ${mon.sizeNote}` : ""} ${typeObj.asText}${mon.alignment ? `, ${mon.alignmentPrefix ? Renderer.get().render(mon.alignmentPrefix) : ""}${Parser.alignmentListToFull(mon.alignment).toTitleCase()}` : ""}`; } static getSavesPart (mon) { return `${Object.keys(mon.save || {}).sort(SortUtil.ascSortAtts).map(s => Renderer.monster.getSave(Renderer.get(), s, mon.save[s])).join(", ")}`; } static getSensesPart (mon) { return `${mon.senses ? `${Renderer.utils.getRenderedSenses(mon.senses)}, ` : ""}passive Perception ${mon.passive || "\u2014"}`; } static getRenderWithPlugins ({renderer, mon, fn}) { return renderer.withPlugin({ pluginTypes: [ "dice", ], fnPlugin: () => { if (mon.summonedBySpellLevel == null && mon._summonedByClass_level == null) return null; if (mon._summonedByClass_level) { return { additionalData: { "data-summoned-by-class-level": mon._summonedByClass_level, }, }; } return { additionalData: { "data-summoned-by-spell-level": mon._summonedBySpell_level ?? mon.summonedBySpellLevel, }, }; }, fn, }); } /** * @param mon * @param [opts] * @param [opts.isCompact] * @param [opts.isEmbeddedEntity] * @param [opts.isShowScalers] * @param [opts.isScaledCr] * @param [opts.isScaledSpellSummon] * @param [opts.isScaledClassSummon] */ static getCompactRenderedString (mon, opts) { const renderer = Renderer.get(); return Renderer.monster.getRenderWithPlugins({ renderer, mon, fn: () => Renderer.monster._getCompactRenderedString(mon, renderer, opts), }); } static _getCompactRenderedString (mon, renderer, opts) { opts = opts || {}; if (opts.isCompact === undefined) opts.isCompact = true; const renderStack = []; const legGroup = DataUtil.monster.getMetaGroup(mon); const hasToken = mon.tokenUrl || mon.hasToken; const extraThClasses = !opts.isCompact && hasToken ? ["mon__name--token"] : null; const isShowCrScaler = ScaleCreature.isCrInScaleRange(mon); const isShowSpellLevelScaler = opts.isShowScalers && !isShowCrScaler && mon.summonedBySpellLevel != null; const isShowClassLevelScaler = opts.isShowScalers && !isShowSpellLevelScaler && mon.summonedByClass != null; const fnGetSpellTraits = Renderer.monster.getSpellcastingRenderedTraits.bind(Renderer.monster, renderer); const allTraits = Renderer.monster.getOrderedTraits(mon, {fnGetSpellTraits}); const allActions = Renderer.monster.getOrderedActions(mon, {fnGetSpellTraits}); const allBonusActions = Renderer.monster.getOrderedBonusActions(mon, {fnGetSpellTraits}); const allReactions = Renderer.monster.getOrderedReactions(mon, {fnGetSpellTraits}); let ptCrSpellLevel = `\u2014`; if (isShowSpellLevelScaler || isShowClassLevelScaler) { // Note that `outerHTML` ignores the value of the select, so we cannot e.g. select the correct option // here and expect to return it in the HTML. const selHtml = isShowSpellLevelScaler ? Renderer.monster.getSelSummonSpellLevel(mon)?.outerHTML : Renderer.monster.getSelSummonClassLevel(mon)?.outerHTML; ptCrSpellLevel = `${selHtml || ""}`; } else if (isShowCrScaler) { ptCrSpellLevel = ` ${Parser.monCrToFull(mon.cr, {isMythic: !!mon.mythic})} ${opts.isShowScalers && !opts.isScaledCr && Parser.isValidCr(mon.cr ? (mon.cr.cr || mon.cr) : null) ? ` ` : ""} ${opts.isScaledCr ? ` ` : ""} `; } renderStack.push(` ${Renderer.utils.getExcludedTr({entity: mon, dataProp: "monster", page: opts.page || UrlUtil.PG_BESTIARY})} ${Renderer.utils.getNameTr(mon, {page: opts.page || UrlUtil.PG_BESTIARY, extensionData: {_scaledCr: mon._scaledCr, _scaledSpellSummonLevel: mon._scaledSpellSummonLevel, _scaledClassSummonLevel: mon._scaledClassSummonLevel}, extraThClasses, isEmbeddedEntity: opts.isEmbeddedEntity})} ${Renderer.monster.getTypeAlignmentPart(mon)}
    ${mon.pbNote || Parser.crToNumber(mon.cr) < VeCt.CR_CUSTOM ? `` : ""} ${hasToken && !opts.isCompact ? `` : ""} ${ptCrSpellLevel} ${mon.pbNote || Parser.crToNumber(mon.cr) < VeCt.CR_CUSTOM ? `` : ""} ${hasToken && !opts.isCompact ? `` : ""}
    Armor Class Hit Points Speed ${isShowSpellLevelScaler ? "Spell Level" : isShowClassLevelScaler ? "Class Level" : "Challenge"}PB
    ${Parser.acToFull(mon.ac)} ${Renderer.monster.getRenderedHp(mon.hp)} ${Parser.getSpeedString(mon)}${mon.pbNote ?? UiUtil.intToBonus(Parser.crToPb(mon.cr), {isPretty: true})}
    ${Renderer.monster.getRenderedAbilityScores(mon)}
    ${mon.resource ? mon.resource.map(res => `

    ${res.name} ${Renderer.monster.getRenderedResource(res)}

    `).join("") : ""} ${mon.save ? `

    Saving Throws ${Renderer.monster.getSavesPart(mon)}

    ` : ""} ${mon.skill ? `

    Skills ${Renderer.monster.getSkillsString(renderer, mon)}

    ` : ""} ${mon.vulnerable ? `

    Damage Vuln. ${Parser.getFullImmRes(mon.vulnerable)}

    ` : ""} ${mon.resist ? `

    Damage Res. ${Parser.getFullImmRes(mon.resist)}

    ` : ""} ${mon.immune ? `

    Damage Imm. ${Parser.getFullImmRes(mon.immune)}

    ` : ""} ${mon.conditionImmune ? `

    Condition Imm. ${Parser.getFullCondImm(mon.conditionImmune)}

    ` : ""} ${opts.isHideSenses ? "" : `

    Senses ${Renderer.monster.getSensesPart(mon)}

    `} ${opts.isHideLanguages ? "" : `

    Languages ${Renderer.monster.getRenderedLanguages(mon.languages)}

    `}
    ${allTraits ? `
    ${allTraits.map(it => it.rendered || renderer.render(it, 2)).join("")} ` : ""} ${Renderer.monster.getCompactRenderedStringSection({...mon, action: allActions}, renderer, "Actions", "action", 2)} ${Renderer.monster.getCompactRenderedStringSection({...mon, bonus: allBonusActions}, renderer, "Bonus Actions", "bonus", 2)} ${Renderer.monster.getCompactRenderedStringSection({...mon, reaction: allReactions}, renderer, "Reactions", "reaction", 2)} ${Renderer.monster.getCompactRenderedStringSection(mon, renderer, "Legendary Actions", "legendary", 2)} ${Renderer.monster.getCompactRenderedStringSection(mon, renderer, "Mythic Actions", "mythic", 2)} ${legGroup && legGroup.lairActions ? Renderer.monster.getCompactRenderedStringSection(legGroup, renderer, "Lair Actions", "lairActions", 1) : ""} ${legGroup && legGroup.regionalEffects ? Renderer.monster.getCompactRenderedStringSection(legGroup, renderer, "Regional Effects", "regionalEffects", 1) : ""} ${mon.variant || (mon.dragonCastingColor && !mon.spellcasting) || mon.summonedBySpell ? ` ${mon.variant ? mon.variant.map(it => it.rendered || renderer.render(it)).join("") : ""} ${mon.dragonCastingColor ? Renderer.monster.dragonCasterVariant.getHtml(mon, {renderer}) : ""} ${mon.footer ? renderer.render({entries: mon.footer}) : ""} ${mon.summonedBySpell ? `
    Summoned By: ${renderer.render(`{@spell ${mon.summonedBySpell}}`)}
    ` : ""} ` : ""} `); return renderStack.join(""); } static _getFormulaMax (formula) { return Renderer.dice.parseRandomise2(`dmax(${formula})`); } static getRenderedHp (hp, isPlainText) { if (hp.special != null) return isPlainText ? Renderer.stripTags(hp.special) : Renderer.get().render(hp.special); if (/^\d+d1$/.exec(hp.formula)) { return hp.average; } if (isPlainText) return `${hp.average} (${hp.formula})`; const maxVal = Renderer.monster._getFormulaMax(hp.formula); const maxStr = maxVal ? `Maximum: ${maxVal}` : ""; return `${maxStr ? `` : ""}${hp.average}${maxStr ? "" : ""} ${Renderer.get().render(`({@dice ${hp.formula}|${hp.formula}|Hit Points})`)}`; } static getRenderedResource (res, isPlainText) { if (!res.formula) return `${res.value}`; if (isPlainText) return `${res.value} (${res.formula})`; const maxVal = Renderer.monster._getFormulaMax(res.formula); const maxStr = maxVal ? `Maximum: ${maxVal}` : ""; return `${maxStr ? `` : ""}${res.value}${maxStr ? "" : ""} ${Renderer.get().render(`({@dice ${res.formula}|${res.formula}|${res.name}})`)}`; } static getSafeAbilityScore (mon, abil, {isDefaultTen = false} = {}) { if (!mon || abil == null) return isDefaultTen ? 10 : 0; if (mon[abil] == null) return isDefaultTen ? 10 : 0; return typeof mon[abil] === "number" ? mon[abil] : (isDefaultTen ? 10 : 0); } static getRenderedAbilityScores (mon) { const byAbil = {}; const byValue = {}; Parser.ABIL_ABVS .forEach(ab => { if (mon[ab] == null || typeof mon[ab] === "number") return; const meta = {abil: ab, value: mon[ab].special}; byAbil[meta.abil] = meta; meta.family = (byValue[meta.value] = byValue[meta.value] || []); meta.family.push(meta); }); const seenAbs = new Set(); const ptSpecial = Parser.ABIL_ABVS .map(ab => { const meta = byAbil[ab]; if (!meta) return null; if (seenAbs.has(meta.abil)) return null; meta.family.forEach(meta => seenAbs.add(meta.abil)); return `${meta.family.map(meta => meta.abil.toUpperCase()).join(", ")} ${meta.value}`; }) .filter(Boolean) .map(r => `${r}`).join(""); if (Parser.ABIL_ABVS.every(ab => mon[ab] != null && typeof mon[ab] !== "number")) return ptSpecial; const absRemaining = Parser.ABIL_ABVS.filter(ab => !seenAbs.has(ab)); return ` ${absRemaining.map(ab => `${ab.toUpperCase()}`).join("")} ${absRemaining.map(ab => `${Renderer.utils.getAbilityRoller(mon, ab)}`).join("")} `; } static getSpellcastingRenderedTraits (renderer, mon, displayAsProp = "trait") { const out = []; (mon.spellcasting || []).filter(it => (it.displayAs || "trait") === displayAsProp).forEach(entry => { entry.type = entry.type || "spellcasting"; const renderStack = []; renderer.recursiveRender(entry, renderStack, {depth: 2}); const rendered = renderStack.join(""); if (!rendered.length) return; out.push({name: entry.name, rendered}); }); return out; } static getOrderedTraits (mon, {fnGetSpellTraits} = {}) { let traits = mon.trait ? MiscUtil.copyFast(mon.trait) : null; if (fnGetSpellTraits) { const spellTraits = fnGetSpellTraits(mon, "trait"); if (spellTraits.length) traits = traits ? traits.concat(spellTraits) : spellTraits; } if (traits?.length) return traits.sort((a, b) => SortUtil.monTraitSort(a, b)); return null; } static getOrderedActions (mon, {fnGetSpellTraits} = {}) { return Renderer.monster._getOrderedActionsBonusActions({mon, fnGetSpellTraits, prop: "action"}); } static getOrderedBonusActions (mon, {fnGetSpellTraits} = {}) { return Renderer.monster._getOrderedActionsBonusActions({mon, fnGetSpellTraits, prop: "bonus"}); } static getOrderedReactions (mon, {fnGetSpellTraits} = {}) { return Renderer.monster._getOrderedActionsBonusActions({mon, fnGetSpellTraits, prop: "reaction"}); } static _getOrderedActionsBonusActions ({mon, fnGetSpellTraits, prop} = {}) { let actions = mon[prop] ? MiscUtil.copyFast(mon[prop]) : null; let spellActions; if (fnGetSpellTraits) { spellActions = fnGetSpellTraits(mon, prop); } if (!spellActions?.length && !actions?.length) return null; if (!actions?.length) return spellActions; if (!spellActions?.length) return actions; // Actions are generally ordered as: // - "Multiattack" // - Attack actions // - Other actions (alphabetical) // Insert our spellcasting section into the "Other actions" part, in an alphabetically-appropriate place. const ixLastAttack = actions.findLastIndex(it => it.entries && it.entries.length && typeof it.entries[0] === "string" && it.entries[0].includes(`{@atk `)); const ixNext = actions.findIndex((act, ix) => ix > ixLastAttack && act.name && SortUtil.ascSortLower(act.name, "Spellcasting") >= 0); if (~ixNext) actions.splice(ixNext, 0, ...spellActions); else actions.push(...spellActions); return actions; } static getSkillsString (renderer, mon) { if (!mon.skill) return ""; function doSortMapJoinSkillKeys (obj, keys, joinWithOr) { const toJoin = keys.sort(SortUtil.ascSort).map(s => `${renderer.render(`{@skill ${s.toTitleCase()}}`)} ${Renderer.get().render(`{@skillCheck ${s.replace(/ /g, "_")} ${obj[s]}}`)}`); return joinWithOr ? toJoin.joinConjunct(", ", " or ") : toJoin.join(", "); } const skills = doSortMapJoinSkillKeys(mon.skill, Object.keys(mon.skill).filter(k => k !== "other" && k !== "special")); if (mon.skill.other || mon.skill.special) { const others = mon.skill.other && mon.skill.other.map(it => { if (it.oneOf) { return `plus one of the following: ${doSortMapJoinSkillKeys(it.oneOf, Object.keys(it.oneOf), true)}`; } throw new Error(`Unhandled monster "other" skill properties!`); }); const special = mon.skill.special && Renderer.get().render(mon.skill.special); return [skills, others, special].filter(Boolean).join(", "); } return skills; } static getTokenUrl (mon) { if (mon.tokenUrl) return mon.tokenUrl; return Renderer.get().getMediaUrl("img", `bestiary/tokens/${Parser.sourceJsonToAbv(mon.source)}/${Parser.nameToTokenName(mon.name)}.webp`); } static postProcessFluff (mon, fluff) { const cpy = MiscUtil.copyFast(fluff); // TODO is this good enough? Should additionally check for lair blocks which are not the last, and tag them with // "data": {"lairRegionals": true}, and insert the lair/regional text there if available (do the current "append" otherwise) const thisGroup = DataUtil.monster.getMetaGroup(mon); const handleGroupProp = (prop, name) => { if (thisGroup && thisGroup[prop]) { cpy.entries = cpy.entries || []; cpy.entries.push({ type: "entries", entries: [ { type: "entries", name, entries: MiscUtil.copyFast(thisGroup[prop]), }, ], }); } }; handleGroupProp("lairActions", "Lair Actions"); handleGroupProp("regionalEffects", "Regional Effects"); handleGroupProp("mythicEncounter", `${mon.name} as a Mythic Encounter`); return cpy; } static getRenderedLanguages (languages) { if (typeof languages === "string") languages = [languages]; // handle legacy format return languages ? languages.map(it => Renderer.get().render(it)).join(", ") : "\u2014"; } static initParsed (mon) { mon._pTypes = mon._pTypes || Parser.monTypeToFullObj(mon.type); // store the parsed type if (!mon._pCr) { if (Parser.crToNumber(mon.cr) === VeCt.CR_CUSTOM) mon._pCr = "Special"; else if (Parser.crToNumber(mon.cr) === VeCt.CR_UNKNOWN) mon._pCr = "Unknown"; else mon._pCr = mon.cr == null ? "\u2014" : (mon.cr.cr || mon.cr); } if (!mon._fCr) { mon._fCr = [mon._pCr]; if (mon.cr) { if (mon.cr.lair) mon._fCr.push(mon.cr.lair); if (mon.cr.coven) mon._fCr.push(mon.cr.coven); } } } static updateParsed (mon) { delete mon._pTypes; delete mon._pCr; delete mon._fCr; Renderer.monster.initParsed(mon); } static getRenderedVariants (mon, {renderer = null} = {}) { renderer = renderer || Renderer.get(); const dragonVariant = Renderer.monster.dragonCasterVariant.getHtml(mon, {renderer}); const variants = mon.variant; if (!variants && !dragonVariant) return null; const rStack = []; (variants || []).forEach(v => renderer.recursiveRender(v, rStack)); if (dragonVariant) rStack.push(dragonVariant); return rStack.join(""); } static getRenderedEnvironment (envs) { return (envs || []).sort(SortUtil.ascSortLower).map(it => it.toTitleCase()).join(", "); } static getRenderedAltArtEntry (meta, {isPlainText = false} = {}) { return `${isPlainText ? "" : `
    `}${meta.displayName || meta.name}; ${isPlainText ? "" : ``}${Parser.sourceJsonToAbv(meta.source)}${Renderer.utils.isDisplayPage(meta.page) ? ` p${meta.page}` : ""}${isPlainText ? "" : `
    `}`; } static pGetFluff (mon) { return Renderer.utils.pGetFluff({ entity: mon, pFnPostProcess: Renderer.monster.postProcessFluff.bind(null, mon), fluffBaseUrl: `data/bestiary/`, fluffProp: "monsterFluff", }); } // region Custom hash ID packing/unpacking static getCustomHashId (mon) { if (!mon._isScaledCr && !mon._isScaledSpellSummon && !mon._scaledClassSummonLevel) return null; const { name, source, _scaledCr: scaledCr, _scaledSpellSummonLevel: scaledSpellSummonLevel, _scaledClassSummonLevel: scaledClassSummonLevel, } = mon; return [ name, source, scaledCr ?? "", scaledSpellSummonLevel ?? "", scaledClassSummonLevel ?? "", ].join("__").toLowerCase(); } static getUnpackedCustomHashId (customHashId) { if (!customHashId) return null; const [, , scaledCr, scaledSpellSummonLevel, scaledClassSummonLevel] = customHashId.split("__").map(it => it.trim()); if (!scaledCr && !scaledSpellSummonLevel && !scaledClassSummonLevel) return null; return { _scaledCr: scaledCr ? Number(scaledCr) : null, _scaledSpellSummonLevel: scaledSpellSummonLevel ? Number(scaledSpellSummonLevel) : null, _scaledClassSummonLevel: scaledClassSummonLevel ? Number(scaledClassSummonLevel) : null, customHashId, }; } // endregion static async pGetModifiedCreature (monRaw, customHashId) { if (!customHashId) return monRaw; const {_scaledCr, _scaledSpellSummonLevel, _scaledClassSummonLevel} = Renderer.monster.getUnpackedCustomHashId(customHashId); if (_scaledCr) return ScaleCreature.scale(monRaw, _scaledCr); if (_scaledSpellSummonLevel) return ScaleSpellSummonedCreature.scale(monRaw, _scaledSpellSummonLevel); if (_scaledClassSummonLevel) return ScaleClassSummonedCreature.scale(monRaw, _scaledClassSummonLevel); throw new Error(`Unhandled custom hash ID "${customHashId}"`); } static _bindListenersScale (mon, ele) { const page = UrlUtil.PG_BESTIARY; const source = mon.source; const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon); const fnRender = Renderer.hover.getFnRenderCompact(page); const $content = $(ele); $content .find(".mon__btn-scale-cr") .click(evt => { evt.stopPropagation(); const win = (evt.view || {}).window; const $btn = $(evt.target).closest("button"); const initialCr = mon._originalCr != null ? mon._originalCr : mon.cr.cr || mon.cr; const lastCr = mon.cr.cr || mon.cr; Renderer.monster.getCrScaleTarget({ win, $btnScale: $btn, initialCr: lastCr, isCompact: true, cbRender: async (targetCr) => { const original = await DataLoader.pCacheAndGet(page, source, hash); const toRender = Parser.numberToCr(targetCr) === initialCr ? original : await ScaleCreature.scale(original, targetCr); $content.empty().append(fnRender(toRender)); Renderer.monster._bindListenersScale(toRender, ele); }, }); }); $content .find(".mon__btn-reset-cr") .click(async () => { const toRender = await DataLoader.pCacheAndGet(page, source, hash); $content.empty().append(fnRender(toRender)); Renderer.monster._bindListenersScale(toRender, ele); }); const $selSummonSpellLevel = $content .find(`[name="mon__sel-summon-spell-level"]`) .change(async () => { const original = await DataLoader.pCacheAndGet(page, source, hash); const spellLevel = Number($selSummonSpellLevel.val()); const toRender = ~spellLevel ? await ScaleSpellSummonedCreature.scale(original, spellLevel) : original; $content.empty().append(fnRender(toRender)); Renderer.monster._bindListenersScale(toRender, ele); }) .val(mon._summonedBySpell_level != null ? `${mon._summonedBySpell_level}` : "-1"); const $selSummonClassLevel = $content .find(`[name="mon__sel-summon-class-level"]`) .change(async () => { const original = await DataLoader.pCacheAndGet(page, source, hash); const classLevel = Number($selSummonClassLevel.val()); const toRender = ~classLevel ? await ScaleClassSummonedCreature.scale(original, classLevel) : original; $content.empty().append(fnRender(toRender)); Renderer.monster._bindListenersScale(toRender, ele); }) .val(mon._summonedByClass_level != null ? `${mon._summonedByClass_level}` : "-1"); } static bindListenersCompact (mon, ele) { Renderer.monster._bindListenersScale(mon, ele); } static hover = class { static bindFluffImageMouseover ({mon, $ele}) { $ele .on("mouseover", evt => this._pOnFluffImageMouseover({evt, mon, $ele})); } static async _pOnFluffImageMouseover ({evt, mon, $ele}) { // We'll rebuild the mouseover handler with whatever we load $ele.off("mouseover"); const fluff = mon ? await Renderer.monster.pGetFluff(mon) : null; if (fluff?.images?.length) return this._pOnFluffImageMouseover_hasImage({mon, $ele, fluff}); return this._pOnFluffImageMouseover_noImage({mon, $ele}); } static _pOnFluffImageMouseover_noImage ({mon, $ele}) { const hoverMeta = this.getMakePredefinedFluffImageHoverNoImage({name: mon?.name}); $ele .on("mouseover", evt => hoverMeta.mouseOver(evt, $ele[0])) .on("mousemove", evt => hoverMeta.mouseMove(evt, $ele[0])) .on("mouseleave", evt => hoverMeta.mouseLeave(evt, $ele[0])) .trigger("mouseover"); } static _pOnFluffImageMouseover_hasImage ({mon, $ele, fluff}) { const hoverMeta = this.getMakePredefinedFluffImageHoverHasImage({imageHref: fluff.images[0].href, name: mon.name}); $ele .on("mouseover", evt => hoverMeta.mouseOver(evt, $ele[0])) .on("mousemove", evt => hoverMeta.mouseMove(evt, $ele[0])) .on("mouseleave", evt => hoverMeta.mouseLeave(evt, $ele[0])) .trigger("mouseover"); } static getMakePredefinedFluffImageHoverNoImage ({name}) { return Renderer.hover.getMakePredefinedHover( { type: "entries", entries: [ Renderer.utils.HTML_NO_IMAGES, ], data: { hoverTitle: name ? `Image \u2014 ${name}` : "Image", }, }, {isBookContent: true}, ); } static getMakePredefinedFluffImageHoverHasImage ({imageHref, name}) { return Renderer.hover.getMakePredefinedHover( { type: "image", href: imageHref, data: { hoverTitle: name ? `Image \u2014 ${name}` : "Image", }, }, {isBookContent: true}, ); } }; }; Renderer.monster.CHILD_PROPS_EXTENDED = [...Renderer.monster.CHILD_PROPS, "lairActions", "regionalEffects"]; Renderer.monster.CHILD_PROPS_EXTENDED.forEach(prop => { const propFull = `monster${prop.uppercaseFirst()}`; Renderer[propFull] = { getCompactRenderedString (ent) { return Renderer.generic.getCompactRenderedString(ent); }, }; }); Renderer.monsterAction.getWeaponLookupName = act => { return (act.name || "") .replace(/\(.*\)$/, "") // remove parenthetical text (e.g. "(Humanoid or Hybrid Form Only)" off the end .trim() .toLowerCase() ; }; Renderer.legendaryGroup = class { static getCompactRenderedString (legGroup, opts) { opts = opts || {}; const ent = Renderer.legendaryGroup.getSummaryEntry(legGroup); if (!ent) return ""; return ` ${Renderer.utils.getNameTr(legGroup, {isEmbeddedEntity: opts.isEmbeddedEntity})} ${Renderer.get().setFirstSection(true).render(ent)} ${Renderer.utils.getPageTr(legGroup)}`; } static getSummaryEntry (legGroup) { if (!legGroup || (!legGroup.lairActions && !legGroup.regionalEffects && !legGroup.mythicEncounter)) return null; return { type: "section", entries: [ legGroup.lairActions ? {name: "Lair Actions", type: "entries", entries: legGroup.lairActions} : null, legGroup.regionalEffects ? {name: "Regional Effects", type: "entries", entries: legGroup.regionalEffects} : null, legGroup.mythicEncounter ? {name: "As a Mythic Encounter", type: "entries", entries: legGroup.mythicEncounter} : null, ].filter(Boolean), }; } }; Renderer.item = class { static _sortProperties (a, b) { return SortUtil.ascSort(Renderer.item.getProperty(a, {isIgnoreMissing: true})?.name || "", Renderer.item.getProperty(b, {isIgnoreMissing: true})?.name || ""); } static _getPropertiesText (item, {renderer = null} = {}) { renderer = renderer || Renderer.get(); if (!item.property) { const parts = []; if (item.dmg2) parts.push(`alt. ${Renderer.item._renderDamage(item.dmg2, {renderer})}`); if (item.range) parts.push(`range ${item.range} ft.`); return `${item.dmg1 && parts.length ? " - " : ""}${parts.join(", ")}`; } let renderedDmg2 = false; const renderedProperties = item.property .sort(Renderer.item._sortProperties) .map(p => { const pFull = Renderer.item.getProperty(p); if (pFull.template) { const toRender = Renderer.utils.applyTemplate( item, pFull.template, { fnPreApply: (fullMatch, variablePath) => { if (variablePath === "item.dmg2") renderedDmg2 = true; }, mapCustom: {"prop_name": pFull.name}, }, ); return renderer.render(toRender); } else return pFull.name; }); if (!renderedDmg2 && item.dmg2) renderedProperties.unshift(`alt. ${Renderer.item._renderDamage(item.dmg2, {renderer})}`); return `${item.dmg1 && renderedProperties.length ? " - " : ""}${renderedProperties.join(", ")}`; } static _getTaggedDamage (dmg, {renderer = null} = {}) { if (!dmg) return ""; renderer = renderer || Renderer.get(); Renderer.stripTags(dmg.trim()); return renderer.render(`{@damage ${dmg}}`); } static _renderDamage (dmg, {renderer = null} = {}) { renderer = renderer || Renderer.get(); return renderer.render(Renderer.item._getTaggedDamage(dmg, {renderer})); } static getDamageAndPropertiesText (item, {renderer = null} = {}) { renderer = renderer || Renderer.get(); const damagePartsPre = []; const damageParts = []; if (item.mastery) damagePartsPre.push(`Mastery: ${item.mastery.map(it => renderer.render(`{@itemMastery ${it}}`)).join(", ")}`); // armor if (item.ac != null) { const prefix = item.type === "S" ? "+" : ""; const suffix = (item.type === "LA" || item.bardingType === "LA") || ((item.type === "MA" || item.bardingType === "MA") && item.dexterityMax === null) ? " + Dex" : (item.type === "MA" || item.bardingType === "MA") ? ` + Dex (max ${item.dexterityMax ?? 2})` : ""; damageParts.push(`AC ${prefix}${item.ac}${suffix}`); } if (item.acSpecial != null) damageParts.push(item.ac != null ? item.acSpecial : `AC ${item.acSpecial}`); // damage if (item.dmg1) damageParts.push(Renderer.item._renderDamage(item.dmg1, {renderer})); // mounts if (item.speed != null) damageParts.push(`Speed: ${item.speed}`); if (item.carryingCapacity) damageParts.push(`Carrying Capacity: ${item.carryingCapacity} lb.`); // vehicles if (item.vehSpeed || item.capCargo || item.capPassenger || item.crew || item.crewMin || item.crewMax || item.vehAc || item.vehHp || item.vehDmgThresh || item.travelCost || item.shippingCost) { const vehPartUpper = item.vehSpeed ? `Speed: ${Parser.numberToVulgar(item.vehSpeed)} mph` : null; const vehPartMiddle = item.capCargo || item.capPassenger ? `Carrying Capacity: ${[item.capCargo ? `${Parser.numberToFractional(item.capCargo)} ton${item.capCargo === 0 || item.capCargo > 1 ? "s" : ""} cargo` : null, item.capPassenger ? `${item.capPassenger} passenger${item.capPassenger === 1 ? "" : "s"}` : null].filter(Boolean).join(", ")}` : null; const {travelCostFull, shippingCostFull} = Parser.itemVehicleCostsToFull(item); // These may not be present in homebrew const vehPartLower = [ item.crew ? `Crew ${item.crew}` : null, item.crewMin && item.crewMax ? `Crew ${item.crewMin}-${item.crewMax}` : null, item.vehAc ? `AC ${item.vehAc}` : null, item.vehHp ? `HP ${item.vehHp}${item.vehDmgThresh ? `, Damage Threshold ${item.vehDmgThresh}` : ""}` : null, ].filter(Boolean).join(", "); damageParts.push([ vehPartUpper, vehPartMiddle, // region ~~Dammit Mercer~~ Additional fields present in EGW travelCostFull ? `Personal Travel Cost: ${travelCostFull} per mile per passenger` : null, shippingCostFull ? `Shipping Cost: ${shippingCostFull} per 100 pounds per mile` : null, // endregion vehPartLower, ].filter(Boolean).join(renderer.getLineBreak())); } const damage = [ damagePartsPre.join(", "), damageParts.join(", "), ] .filter(Boolean) .join(renderer.getLineBreak()); const damageType = item.dmgType ? Parser.dmgTypeToFull(item.dmgType) : ""; const propertiesTxt = Renderer.item._getPropertiesText(item, {renderer}); return [damage, damageType, propertiesTxt]; } static getTypeRarityAndAttunementText (item) { const typeRarity = [ item._typeHtml === "other" ? "" : item._typeHtml, (item.rarity && Renderer.item.doRenderRarity(item.rarity) ? item.rarity : ""), ].filter(Boolean).join(", "); return [ item.reqAttune ? `${typeRarity} ${item._attunement}` : typeRarity, item._subTypeHtml || "", item.tier ? `${item.tier} tier` : "", ]; } static getAttunementAndAttunementCatText (item, prop = "reqAttune") { let attunement = null; let attunementCat = VeCt.STR_NO_ATTUNEMENT; if (item[prop] != null && item[prop] !== false) { if (item[prop] === true) { attunementCat = "Requires Attunement"; attunement = "(requires attunement)"; } else if (item[prop] === "optional") { attunementCat = "Attunement Optional"; attunement = "(attunement optional)"; } else if (item[prop].toLowerCase().startsWith("by")) { attunementCat = "Requires Attunement By..."; attunement = `(requires attunement ${Renderer.get().render(item[prop])})`; } else { attunementCat = "Requires Attunement"; // throw any weird ones in the "Yes" category (e.g. "outdoors at night") attunement = `(requires attunement ${Renderer.get().render(item[prop])})`; } } return [attunement, attunementCat]; } static getHtmlAndTextTypes (item) { const typeHtml = []; const typeListText = []; const subTypeHtml = []; let showingBase = false; if (item.wondrous) { typeHtml.push(`wondrous item${item.tattoo ? ` (tattoo)` : ""}`); typeListText.push("wondrous item"); } if (item.tattoo) { typeListText.push("tattoo"); } if (item.staff) { typeHtml.push("staff"); typeListText.push("staff"); } if (item.ammo) { typeHtml.push(`ammunition`); typeListText.push("ammunition"); } if (item.firearm) { subTypeHtml.push("firearm"); typeListText.push("firearm"); } if (item.age) { subTypeHtml.push(item.age); typeListText.push(item.age); } if (item.weaponCategory) { typeHtml.push(`weapon${item.baseItem ? ` (${Renderer.get().render(`{@item ${item.baseItem}}`)})` : ""}`); subTypeHtml.push(`${item.weaponCategory} weapon`); typeListText.push(`${item.weaponCategory} weapon`); showingBase = true; } if (item.staff && (item.type !== "M" && item.typeAlt !== "M")) { // DMG p140: "Unless a staff's description says otherwise, a staff can be used as a quarterstaff." subTypeHtml.push("melee weapon"); typeListText.push("melee weapon"); } if (item.type) Renderer.item._getHtmlAndTextTypes_type({type: item.type, typeHtml, typeListText, subTypeHtml, showingBase, item}); if (item.typeAlt) Renderer.item._getHtmlAndTextTypes_type({type: item.typeAlt, typeHtml, typeListText, subTypeHtml, showingBase, item}); if (item.poison) { typeHtml.push(`poison${item.poisonTypes ? ` (${item.poisonTypes.joinConjunct(", ", " or ")})` : ""}`); typeListText.push("poison"); } return [typeListText, typeHtml.join(", "), subTypeHtml.join(", ")]; } static _getHtmlAndTextTypes_type ({type, typeHtml, typeListText, subTypeHtml, showingBase, item}) { const fullType = Renderer.item.getItemTypeName(type); const isSub = (typeListText.some(it => it.includes("weapon")) && fullType.includes("weapon")) || (typeListText.some(it => it.includes("armor")) && fullType.includes("armor")); if (!showingBase && !!item.baseItem) (isSub ? subTypeHtml : typeHtml).push(`${fullType} (${Renderer.get().render(`{@item ${item.baseItem}}`)})`); else if (type === "S") (isSub ? subTypeHtml : typeHtml).push(Renderer.get().render(`armor ({@item shield|phb})`)); else (isSub ? subTypeHtml : typeHtml).push(fullType); typeListText.push(fullType); } static _GET_RENDERED_ENTRIES_WALKER = null; /** * @param item * @param isCompact * @param wrappedTypeAllowlist An optional set of: `"note", "type", "property", "variant"` */ static getRenderedEntries (item, {isCompact = false, wrappedTypeAllowlist = null} = {}) { const renderer = Renderer.get(); Renderer.item._GET_RENDERED_ENTRIES_WALKER = Renderer.item._GET_RENDERED_ENTRIES_WALKER || MiscUtil.getWalker({ keyBlocklist: new Set([ ...MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, "data", ]), }); const handlersName = { string: (str) => Renderer.item._getRenderedEntries_handlerConvertNamesToItalics.bind(Renderer.item, item, item.name)(str), }; const handlersVariantName = item._variantName == null ? null : { string: (str) => Renderer.item._getRenderedEntries_handlerConvertNamesToItalics.bind(Renderer.item, item, item._variantName)(str), }; const renderStack = []; if (item._fullEntries || item.entries?.length) { const entry = MiscUtil.copyFast({type: "entries", entries: item._fullEntries || item.entries}); let procEntry = Renderer.item._GET_RENDERED_ENTRIES_WALKER.walk(entry, handlersName); if (handlersVariantName) procEntry = Renderer.item._GET_RENDERED_ENTRIES_WALKER.walk(entry, handlersVariantName); if (wrappedTypeAllowlist) procEntry.entries = procEntry.entries.filter(it => !it?.data?.[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG] || wrappedTypeAllowlist.has(it?.data?.[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG])); renderer.recursiveRender(procEntry, renderStack, {depth: 1}); } if (item._fullAdditionalEntries || item.additionalEntries) { const additionEntries = MiscUtil.copyFast({type: "entries", entries: item._fullAdditionalEntries || item.additionalEntries}); let procAdditionEntries = Renderer.item._GET_RENDERED_ENTRIES_WALKER.walk(additionEntries, handlersName); if (handlersVariantName) procAdditionEntries = Renderer.item._GET_RENDERED_ENTRIES_WALKER.walk(additionEntries, handlersVariantName); if (wrappedTypeAllowlist) procAdditionEntries.entries = procAdditionEntries.entries.filter(it => !it?.data?.[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG] || wrappedTypeAllowlist.has(it?.data?.[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG])); renderer.recursiveRender(procAdditionEntries, renderStack, {depth: 1}); } if (!isCompact && item.lootTables) { renderStack.push(`
    Found On: ${item.lootTables.sort(SortUtil.ascSortLower).map(tbl => renderer.render(`{@table ${tbl}}`)).join(", ")}
    `); } return renderStack.join("").trim(); } static _getRenderedEntries_handlerConvertNamesToItalics (item, baseName, str) { if (Renderer.item.isMundane(item)) return str; const stack = []; let depth = 0; const tgtLen = baseName.length; // Only accept title-case names for sentient items (e.g. Wave) const tgtName = item.sentient ? baseName : baseName.toLowerCase(); const tgtNamePlural = tgtName.toPlural(); const tgtLenPlural = tgtNamePlural.length; // e.g. "Orb of Shielding (Fernian Basalt)" -> "Orb of Shielding" const tgtNameNoBraces = tgtName.replace(/ \(.*$/, ""); const tgtLenNoBraces = tgtNameNoBraces.length; const len = str.length; for (let i = 0; i < len; ++i) { const c = str[i]; switch (c) { case "{": { if (str[i + 1] === "@") depth++; stack.push(c); break; } case "}": { if (depth) depth--; stack.push(c); break; } default: stack.push(c); break; } if (depth) continue; if (stack.slice(-tgtLen).join("")[item.sentient ? "toString" : "toLowerCase"]() === tgtName) { stack.splice(stack.length - tgtLen, tgtLen, `{@i ${stack.slice(-tgtLen).join("")}}`); } else if (stack.slice(-tgtLenPlural).join("")[item.sentient ? "toString" : "toLowerCase"]() === tgtNamePlural) { stack.splice(stack.length - tgtLenPlural, tgtLenPlural, `{@i ${stack.slice(-tgtLenPlural).join("")}}`); } else if (stack.slice(-tgtLenNoBraces).join("")[item.sentient ? "toString" : "toLowerCase"]() === tgtNameNoBraces) { stack.splice(stack.length - tgtLenNoBraces, tgtLenNoBraces, `{@i ${stack.slice(-tgtLenNoBraces).join("")}}`); } } return stack.join(""); } static getCompactRenderedString (item, opts) { opts = opts || {}; const [damage, damageType, propertiesTxt] = Renderer.item.getDamageAndPropertiesText(item); const [typeRarityText, subTypeText, tierText] = Renderer.item.getTypeRarityAndAttunementText(item); return ` ${Renderer.utils.getExcludedTr({entity: item, dataProp: "item", page: UrlUtil.PG_ITEMS})} ${Renderer.utils.getNameTr(item, {page: UrlUtil.PG_ITEMS, isEmbeddedEntity: opts.isEmbeddedEntity})} ${Renderer.item.getTypeRarityAndAttunementHtml(typeRarityText, subTypeText, tierText)} ${[Parser.itemValueToFullMultiCurrency(item), Parser.itemWeightToFull(item)].filter(Boolean).join(", ").uppercaseFirst()} ${damage} ${damageType} ${propertiesTxt} ${Renderer.item.hasEntries(item) ? `${Renderer.utils.getDividerTr()}${Renderer.item.getRenderedEntries(item, {isCompact: true})}` : ""}`; } static hasEntries (item) { return item._fullAdditionalEntries?.length || item._fullEntries?.length || item.entries?.length; } static getTypeRarityAndAttunementHtml (typeRarityText, subTypeText, tierText) { return `
    ${typeRarityText || tierText ? `
    ${(typeRarityText || "").uppercaseFirst()}
    ${(tierText || "").uppercaseFirst()}
    ` : ""} ${subTypeText ? `
    ${subTypeText.uppercaseFirst()}
    ` : ""}
    `; } static _hiddenRarity = new Set(["none", "unknown", "unknown (magic)", "varies"]); static doRenderRarity (rarity) { return !Renderer.item._hiddenRarity.has(rarity); } // --- static _propertyMap = {}; static _addProperty (prt) { if (Renderer.item._propertyMap[prt.abbreviation]) return; const cpy = MiscUtil.copyFast(prt); Renderer.item._propertyMap[prt.abbreviation] = prt.name ? cpy : { ...cpy, name: (prt.entries || prt.entriesTemplate)[0].name.toLowerCase(), }; } static getProperty (abbv, {isIgnoreMissing = false} = {}) { if (!isIgnoreMissing && !Renderer.item._propertyMap[abbv]) throw new Error(`Item property ${abbv} not found. You probably meant to load the property reference first.`); return Renderer.item._propertyMap[abbv]; } // --- static _typeMap = {}; static _addType (typ) { if (Renderer.item._typeMap[typ.abbreviation]?.entries || Renderer.item._typeMap[typ.abbreviation]?.entriesTemplate) return; const cpy = MiscUtil.copyFast(typ); // Merge in data from existing version, if it exists Object.entries(Renderer.item._typeMap[typ.abbreviation] || {}) .forEach(([k, v]) => { if (cpy[k]) return; cpy[k] = v; }); cpy.name = cpy.name || (cpy.entries || cpy.entriesTemplate)[0].name.toLowerCase(); Renderer.item._typeMap[typ.abbreviation] = cpy; } static getType (abbv) { if (!Renderer.item._typeMap[abbv]) throw new Error(`Item type ${abbv} not found. You probably meant to load the type reference first.`); return Renderer.item._typeMap[abbv]; } // --- static entryMap = {}; static _addEntry (ent) { if (Renderer.item.entryMap[ent.source]?.[ent.name]) return; MiscUtil.set(Renderer.item.entryMap, ent.source, ent.name, ent); } // --- static _additionalEntriesMap = {}; static _addAdditionalEntries (ent) { if (Renderer.item._additionalEntriesMap[ent.appliesTo]) return; Renderer.item._additionalEntriesMap[ent.appliesTo] = MiscUtil.copyFast(ent.entries); } // --- static _masteryMap = {}; static _addMastery (ent) { const lookupSource = ent.source.toLowerCase(); const lookupName = ent.name.toLowerCase(); if (Renderer.item._masteryMap[lookupSource]?.[lookupName]) return; MiscUtil.set(Renderer.item._masteryMap, lookupSource, lookupName, ent); } static _getMastery (uid) { const {name, source} = DataUtil.proxy.unpackUid("itemMastery", uid, "itemMastery", {isLower: true}); const out = MiscUtil.get(Renderer.item._masteryMap, source, name); if (!out) throw new Error(`Item mastry ${uid} not found. You probably meant to load the mastery reference first.`); return out; } // --- static async _pAddPrereleaseBrewPropertiesAndTypes () { if (typeof PrereleaseUtil !== "undefined") Renderer.item.addPrereleaseBrewPropertiesAndTypesFrom({data: await PrereleaseUtil.pGetBrewProcessed()}); if (typeof BrewUtil2 !== "undefined") Renderer.item.addPrereleaseBrewPropertiesAndTypesFrom({data: await BrewUtil2.pGetBrewProcessed()}); } static addPrereleaseBrewPropertiesAndTypesFrom ({data}) { (data.itemProperty || []) .forEach(it => Renderer.item._addProperty(it)); (data.itemType || []) .forEach(it => Renderer.item._addType(it)); (data.itemEntry || []) .forEach(it => Renderer.item._addEntry(it)); (data.itemTypeAdditionalEntries || []) .forEach(it => Renderer.item._addAdditionalEntries(it)); (data.itemMastery || []) .forEach(it => Renderer.item._addMastery(it)); } static _addBasePropertiesAndTypes (baseItemData) { Object.entries(Parser.ITEM_TYPE_JSON_TO_ABV).forEach(([abv, name]) => Renderer.item._addType({abbreviation: abv, name})); // Convert the property and type list JSONs into look-ups, i.e. use the abbreviation as a JSON property name (baseItemData.itemProperty || []).forEach(it => Renderer.item._addProperty(it)); (baseItemData.itemType || []).forEach(it => Renderer.item._addType(it)); (baseItemData.itemEntry || []).forEach(it => Renderer.item._addEntry(it)); (baseItemData.itemTypeAdditionalEntries || []).forEach(it => Renderer.item._addAdditionalEntries(it)); (baseItemData.itemMastery || []).forEach(it => Renderer.item._addMastery(it)); baseItemData.baseitem.forEach(it => it._isBaseItem = true); } static async _pGetSiteUnresolvedRefItems_pLoadItems () { const itemData = await DataUtil.loadJSON(`${Renderer.get().baseUrl}data/items.json`); const items = itemData.item; itemData.itemGroup.forEach(it => it._isItemGroup = true); return [...items, ...itemData.itemGroup]; } static async pGetSiteUnresolvedRefItems () { const itemList = await Renderer.item._pGetSiteUnresolvedRefItems_pLoadItems(); const baseItemsJson = await DataUtil.loadJSON(`${Renderer.get().baseUrl}data/items-base.json`); const baseItems = await Renderer.item._pGetAndProcBaseItems(baseItemsJson); const {genericVariants, linkedLootTables} = await Renderer.item._pGetCacheSiteGenericVariants(); const specificVariants = Renderer.item._createSpecificVariants(baseItems, genericVariants, {linkedLootTables}); const allItems = [...itemList, ...baseItems, ...genericVariants, ...specificVariants]; Renderer.item._enhanceItems(allItems); return { item: allItems, itemEntry: baseItemsJson.itemEntry, }; } static _pGettingSiteGenericVariants = null; static async _pGetCacheSiteGenericVariants () { Renderer.item._pGettingSiteGenericVariants = Renderer.item._pGettingSiteGenericVariants || (async () => { const [genericVariants, linkedLootTables] = Renderer.item._getAndProcGenericVariants(await DataUtil.loadJSON(`${Renderer.get().baseUrl}data/magicvariants.json`)); return {genericVariants, linkedLootTables}; })(); return Renderer.item._pGettingSiteGenericVariants; } static async pBuildList () { return DataLoader.pCacheAndGetAllSite(UrlUtil.PG_ITEMS); } static async _pGetAndProcBaseItems (baseItemData) { Renderer.item._addBasePropertiesAndTypes(baseItemData); await Renderer.item._pAddPrereleaseBrewPropertiesAndTypes(); return baseItemData.baseitem; } static _getAndProcGenericVariants (variantData) { variantData.magicvariant.forEach(Renderer.item._genericVariants_addInheritedPropertiesToSelf); return [variantData.magicvariant, variantData.linkedLootTables]; } static _initFullEntries (item) { Renderer.utils.initFullEntries_(item); } static _initFullAdditionalEntries (item) { Renderer.utils.initFullEntries_(item, {propEntries: "additionalEntries", propFullEntries: "_fullAdditionalEntries"}); } /** * @param baseItems * @param genericVariants * @param [opts] * @param [opts.linkedLootTables] */ static _createSpecificVariants (baseItems, genericVariants, opts) { opts = opts || {}; const genericAndSpecificVariants = []; baseItems.forEach((curBaseItem) => { curBaseItem._category = "Basic"; if (curBaseItem.entries == null) curBaseItem.entries = []; if (curBaseItem.packContents) return; // e.g. "Arrows (20)" genericVariants.forEach((curGenericVariant) => { if (!Renderer.item._createSpecificVariants_hasRequiredProperty(curBaseItem, curGenericVariant)) return; if (Renderer.item._createSpecificVariants_hasExcludedProperty(curBaseItem, curGenericVariant)) return; genericAndSpecificVariants.push(Renderer.item._createSpecificVariants_createSpecificVariant(curBaseItem, curGenericVariant, opts)); }); }); return genericAndSpecificVariants; } static _createSpecificVariants_hasRequiredProperty (baseItem, genericVariant) { return genericVariant.requires.some(req => Renderer.item._createSpecificVariants_isRequiresExcludesMatch(baseItem, req, "every")); } static _createSpecificVariants_hasExcludedProperty (baseItem, genericVariant) { const curExcludes = genericVariant.excludes || {}; return Renderer.item._createSpecificVariants_isRequiresExcludesMatch(baseItem, genericVariant.excludes, "some"); } static _createSpecificVariants_isRequiresExcludesMatch (candidate, requirements, method) { if (candidate == null || requirements == null) return false; return Object.entries(requirements)[method](([reqKey, reqVal]) => { if (reqVal instanceof Array) { return candidate[reqKey] instanceof Array ? candidate[reqKey].some(it => reqVal.includes(it)) : reqVal.includes(candidate[reqKey]); } // Recurse for e.g. `"customProperties": { ... }` if (reqVal != null && typeof reqVal === "object") { return Renderer.item._createSpecificVariants_isRequiresExcludesMatch(candidate[reqKey], reqVal, method); } return candidate[reqKey] instanceof Array ? candidate[reqKey].some(it => reqVal === it) : reqVal === candidate[reqKey]; }); } /** * @param baseItem * @param genericVariant * @param [opts] * @param [opts.linkedLootTables] */ static _createSpecificVariants_createSpecificVariant (baseItem, genericVariant, opts) { const inherits = genericVariant.inherits; const specificVariant = MiscUtil.copyFast(baseItem); // Update prop specificVariant.__prop = "item"; // Remove "base item" flag delete specificVariant._isBaseItem; // Reset enhancements/entry cache specificVariant._isEnhanced = false; delete specificVariant._fullEntries; specificVariant._baseName = baseItem.name; specificVariant._baseSrd = baseItem.srd; specificVariant._baseBasicRules = baseItem.basicRules; if (baseItem.source !== inherits.source) specificVariant._baseSource = baseItem.source; specificVariant._variantName = genericVariant.name; // Magic items do not inherit the value of the non-magical item delete specificVariant.value; // Magic variants apply their own SRD info; page info delete specificVariant.srd; delete specificVariant.basicRules; delete specificVariant.page; // Remove fluff specifiers delete specificVariant.hasFluff; delete specificVariant.hasFluffImages; specificVariant._category = "Specific Variant"; Object.entries(inherits) .forEach(([inheritedProperty, val]) => { switch (inheritedProperty) { case "namePrefix": specificVariant.name = `${val}${specificVariant.name}`; break; case "nameSuffix": specificVariant.name = `${specificVariant.name}${val}`; break; case "entries": { Renderer.item._initFullEntries(specificVariant); const appliedPropertyEntries = Renderer.applyAllProperties(val, Renderer.item._getInjectableProps(baseItem, inherits)); appliedPropertyEntries.forEach((ent, i) => specificVariant._fullEntries.splice(i, 0, ent)); break; } case "vulnerable": case "resist": case "immune": { // Handled below break; } case "conditionImmune": { specificVariant[inheritedProperty] = [...specificVariant[inheritedProperty] || [], ...val].unique(); break; } case "nameRemove": { specificVariant.name = specificVariant.name.replace(new RegExp(val.escapeRegexp(), "g"), ""); break; } case "weightExpression": case "valueExpression": { const exp = Renderer.item._createSpecificVariants_evaluateExpression(baseItem, specificVariant, inherits, inheritedProperty); const result = Renderer.dice.parseRandomise2(exp); if (result != null) { switch (inheritedProperty) { case "weightExpression": specificVariant.weight = result; break; case "valueExpression": specificVariant.value = result; break; } } break; } case "barding": { specificVariant.bardingType = baseItem.type; break; } case "propertyAdd": { specificVariant.property = [ ...(specificVariant.property || []), ...val.filter(it => !specificVariant.property || !specificVariant.property.includes(it)), ]; break; } case "propertyRemove": { if (specificVariant.property) { specificVariant.property = specificVariant.property.filter(it => !val.includes(it)); if (!specificVariant.property.length) delete specificVariant.property; } break; } default: specificVariant[inheritedProperty] = val; } }); Renderer.item._createSpecificVariants_mergeVulnerableResistImmune({specificVariant, inherits}); // track the specific variant on the parent generic, to later render as part of the stats genericVariant.variants = genericVariant.variants || []; if (!genericVariant.variants.some(it => it.base?.name === baseItem.name && it.base?.source === baseItem.source)) genericVariant.variants.push({base: baseItem, specificVariant}); // add reverse link to get generic from specific--primarily used for indexing specificVariant.genericVariant = { name: genericVariant.name, source: genericVariant.source, }; // add linked loot tables if (opts.linkedLootTables && opts.linkedLootTables[specificVariant.source] && opts.linkedLootTables[specificVariant.source][specificVariant.name]) { (specificVariant.lootTables = specificVariant.lootTables || []).push(...opts.linkedLootTables[specificVariant.source][specificVariant.name]); } if (baseItem.source !== Parser.SRC_PHB && baseItem.source !== Parser.SRC_DMG) { Renderer.item._initFullEntries(specificVariant); specificVariant._fullEntries.unshift({ type: "wrapper", wrapped: `{@note The {@item ${baseItem.name}|${baseItem.source}|base item} can be found in ${Parser.sourceJsonToFull(baseItem.source)}${baseItem.page ? `, page ${baseItem.page}` : ""}.}`, data: { [VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "note", }, }); } return specificVariant; } static _createSpecificVariants_evaluateExpression (baseItem, specificVariant, inherits, inheritedProperty) { return inherits[inheritedProperty].replace(/\[\[([^\]]+)]]/g, (...m) => { const propPath = m[1].split("."); return propPath[0] === "item" ? MiscUtil.get(specificVariant, ...propPath.slice(1)) : propPath[0] === "baseItem" ? MiscUtil.get(baseItem, ...propPath.slice(1)) : MiscUtil.get(specificVariant, ...propPath); }); } static _PROPS_VULN_RES_IMMUNE = [ "vulnerable", "resist", "immune", ]; static _createSpecificVariants_mergeVulnerableResistImmune ({specificVariant, inherits}) { const fromBase = {}; Renderer.item._PROPS_VULN_RES_IMMUNE .filter(prop => specificVariant[prop]) .forEach(prop => fromBase[prop] = [...specificVariant[prop]]); // For each `inherits` prop, remove matching values from non-matching props in base item (i.e., a value should be // unique across all three arrays). Renderer.item._PROPS_VULN_RES_IMMUNE .forEach(prop => { const val = inherits[prop]; // Retain existing from base item if (val === undefined) return; // Delete from base item if (val == null) return delete fromBase[prop]; const valSet = new Set(); val.forEach(it => { if (typeof it === "string") valSet.add(it); if (!it?.[prop]?.length) return; it?.[prop].forEach(itSub => { if (typeof itSub === "string") valSet.add(itSub); }); }); Renderer.item._PROPS_VULN_RES_IMMUNE .filter(it => it !== prop) .forEach(propOther => { if (!fromBase[propOther]) return; fromBase[propOther] = fromBase[propOther] .filter(it => { if (typeof it === "string") return !valSet.has(it); if (it?.[propOther]?.length) { it[propOther] = it[propOther].filter(itSub => { if (typeof itSub === "string") return !valSet.has(itSub); return true; }); } return true; }); if (!fromBase[propOther].length) delete fromBase[propOther]; }); }); Renderer.item._PROPS_VULN_RES_IMMUNE .forEach(prop => { if (fromBase[prop] || inherits[prop]) specificVariant[prop] = [...(fromBase[prop] || []), ...(inherits[prop] || [])].unique(); else delete specificVariant[prop]; }); } static _enhanceItems (allItems) { allItems.forEach((item) => Renderer.item.enhanceItem(item)); return allItems; } /** * @param genericVariants * @param opts * @param [opts.additionalBaseItems] * @param [opts.baseItems] * @param [opts.isSpecificVariantsOnly] */ static async pGetGenericAndSpecificVariants (genericVariants, opts) { opts = opts || {}; let baseItems; if (opts.baseItems) { baseItems = opts.baseItems; } else { const baseItemData = await DataUtil.loadJSON(`${Renderer.get().baseUrl}data/items-base.json`); Renderer.item._addBasePropertiesAndTypes(baseItemData); baseItems = [...baseItemData.baseitem, ...(opts.additionalBaseItems || [])]; } await Renderer.item._pAddPrereleaseBrewPropertiesAndTypes(); genericVariants.forEach(Renderer.item._genericVariants_addInheritedPropertiesToSelf); const specificVariants = Renderer.item._createSpecificVariants(baseItems, genericVariants); const outSpecificVariants = Renderer.item._enhanceItems(specificVariants); if (opts.isSpecificVariantsOnly) return outSpecificVariants; const outGenericVariants = Renderer.item._enhanceItems(genericVariants); return [...outGenericVariants, ...outSpecificVariants]; } static _getInjectableProps (baseItem, inherits) { return { baseName: baseItem.name, dmgType: baseItem.dmgType ? Parser.dmgTypeToFull(baseItem.dmgType) : null, bonusAc: inherits.bonusAc, bonusWeapon: inherits.bonusWeapon, bonusWeaponAttack: inherits.bonusWeaponAttack, bonusWeaponDamage: inherits.bonusWeaponDamage, bonusWeaponCritDamage: inherits.bonusWeaponCritDamage, bonusSpellAttack: inherits.bonusSpellAttack, bonusSpellSaveDc: inherits.bonusSpellSaveDc, bonusSavingThrow: inherits.bonusSavingThrow, }; } static _INHERITED_PROPS_BLOCKLIST = new Set([ // region Specific merge strategy "entries", "rarity", // endregion // region Meaningless on merged item "namePrefix", "nameSuffix", // endregion ]); static _genericVariants_addInheritedPropertiesToSelf (genericVariant) { if (genericVariant._isInherited) return; genericVariant._isInherited = true; for (const prop in genericVariant.inherits) { if (Renderer.item._INHERITED_PROPS_BLOCKLIST.has(prop)) continue; const val = genericVariant.inherits[prop]; if (val == null) delete genericVariant[prop]; else if (genericVariant[prop]) { if (genericVariant[prop] instanceof Array && val instanceof Array) genericVariant[prop] = MiscUtil.copyFast(genericVariant[prop]).concat(val); else genericVariant[prop] = val; } else genericVariant[prop] = genericVariant.inherits[prop]; } if (!genericVariant.entries && genericVariant.inherits.entries) { genericVariant.entries = MiscUtil.copyFast(Renderer.applyAllProperties(genericVariant.inherits.entries, genericVariant.inherits)); } if (genericVariant.inherits.rarity == null) delete genericVariant.rarity; else if (genericVariant.inherits.rarity === "varies") { /* No-op, i.e., use current rarity */ } else genericVariant.rarity = genericVariant.inherits.rarity; if (genericVariant.requires.armor) genericVariant.armor = genericVariant.requires.armor; } static getItemTypeName (t) { return Renderer.item.getType(t).name?.toLowerCase() || t; } static enhanceItem (item) { if (item._isEnhanced) return; item._isEnhanced = true; if (item.noDisplay) return; if (item.type === "GV") item._category = "Generic Variant"; if (item._category == null) item._category = "Other"; if (item.entries == null) item.entries = []; if (item.type && (Renderer.item.getType(item.type)?.entries || Renderer.item.getType(item.type)?.entriesTemplate)) { Renderer.item._initFullEntries(item); const propetyEntries = Renderer.item._enhanceItem_getItemPropertyTypeEntries({item, ent: Renderer.item.getType(item.type)}); propetyEntries.forEach(e => item._fullEntries.push({type: "wrapper", wrapped: e, data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type"}})); } if (item.property) { item.property.forEach(p => { const entProperty = Renderer.item.getProperty(p); if (!entProperty.entries && !entProperty.entriesTemplate) return; Renderer.item._initFullEntries(item); const propetyEntries = Renderer.item._enhanceItem_getItemPropertyTypeEntries({item, ent: entProperty}); propetyEntries.forEach(e => item._fullEntries.push({type: "wrapper", wrapped: e, data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "property"}})); }); } // The following could be encoded in JSON, but they depend on more than one JSON property; maybe fix if really bored later if (item.type === "LA" || item.type === "MA" || item.type === "HA") { if (item.stealth) { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: "The wearer has disadvantage on Dexterity ({@skill Stealth}) checks.", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type"}}); } if (item.type === "HA" && item.strength) { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: `If the wearer has a Strength score lower than ${item.strength}, their speed is reduced by 10 feet.`, data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type"}}); } } if (item.type === "SCF") { if (item._isItemGroup) { if (item.scfType === "arcane" && item.source !== Parser.SRC_ERLW) { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: "An arcane focus is a special item\u2014an orb, a crystal, a rod, a specially constructed staff, a wand-like length of wood, or some similar item\u2014designed to channel the power of arcane spells. A sorcerer, warlock, or wizard can use such an item as a spellcasting focus.", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type.SCF"}}); } if (item.scfType === "druid") { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: "A druidic focus might be a sprig of mistletoe or holly, a wand or scepter made of yew or another special wood, a staff drawn whole out of a living tree, or a totem object incorporating feathers, fur, bones, and teeth from sacred animals. A druid can use such an object as a spellcasting focus.", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type.SCF"}}); } if (item.scfType === "holy") { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: "A holy symbol is a representation of a god or pantheon. It might be an amulet depicting a symbol representing a deity, the same symbol carefully engraved or inlaid as an emblem on a shield, or a tiny box holding a fragment of a sacred relic. A cleric or paladin can use a holy symbol as a spellcasting focus. To use the symbol in this way, the caster must hold it in hand, wear it visibly, or bear it on a shield.", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type.SCF"}}); } } else { if (item.scfType === "arcane") { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: "An arcane focus is a special item designed to channel the power of arcane spells. A sorcerer, warlock, or wizard can use such an item as a spellcasting focus.", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type.SCF"}}); } if (item.scfType === "druid") { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: "A druid can use this object as a spellcasting focus.", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type.SCF"}}); } if (item.scfType === "holy") { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: "A holy symbol is a representation of a god or pantheon.", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type.SCF"}}); item._fullEntries.push({type: "wrapper", wrapped: "A cleric or paladin can use a holy symbol as a spellcasting focus. To use the symbol in this way, the caster must hold it in hand, wear it visibly, or bear it on a shield.", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type.SCF"}}); } } } (item.mastery || []) .forEach(uid => { const mastery = Renderer.item._getMastery(uid); if (!mastery) throw new Error(`Item mastery ${uid} not found. You probably meant to load the property/type reference first; see \`Renderer.item.pPopulatePropertyAndTypeReference()\`.`); if (!mastery.entries && !mastery.entriesTemplate) return; Renderer.item._initFullEntries(item); item._fullEntries.push({ type: "wrapper", wrapped: { type: "entries", name: `Mastery: ${mastery.name}`, source: mastery.source, page: mastery.page, entries: Renderer.item._enhanceItem_getItemPropertyTypeEntries({item, ent: mastery}), }, data: { [VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "mastery", }, }); }); // add additional entries based on type (e.g. XGE variants) if (item.type === "T" || item.type === "AT" || item.type === "INS" || item.type === "GS") { // tools, artisan's tools, instruments, gaming sets Renderer.item._initFullAdditionalEntries(item); item._fullAdditionalEntries.push({type: "wrapper", wrapped: {type: "hr"}, data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type"}}); item._fullAdditionalEntries.push({type: "wrapper", wrapped: `{@note See the {@variantrule Tool Proficiencies|XGE} entry for more information.}`, data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type"}}); } // Add additional sources for all instruments and gaming sets if (item.type === "INS" || item.type === "GS") item.additionalSources = item.additionalSources || []; if (item.type === "INS") { if (!item.additionalSources.find(it => it.source === "XGE" && it.page === 83)) item.additionalSources.push({"source": "XGE", "page": 83}); } else if (item.type === "GS") { if (!item.additionalSources.find(it => it.source === "XGE" && it.page === 81)) item.additionalSources.push({"source": "XGE", "page": 81}); } if (item.type && Renderer.item._additionalEntriesMap[item.type]) { Renderer.item._initFullAdditionalEntries(item); const additional = Renderer.item._additionalEntriesMap[item.type]; item._fullAdditionalEntries.push({type: "wrapper", wrapped: {type: "entries", entries: additional}, data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "type"}}); } // bake in types const [typeListText, typeHtml, subTypeHtml] = Renderer.item.getHtmlAndTextTypes(item); item._typeListText = typeListText; item._typeHtml = typeHtml; item._subTypeHtml = subTypeHtml; // bake in attunement const [attune, attuneCat] = Renderer.item.getAttunementAndAttunementCatText(item); item._attunement = attune; item._attunementCategory = attuneCat; if (item.reqAttuneAlt) { const [attuneAlt, attuneCatAlt] = Renderer.item.getAttunementAndAttunementCatText(item, "reqAttuneAlt"); item._attunementCategory = [attuneCat, attuneCatAlt]; } // handle item groups if (item._isItemGroup) { Renderer.item._initFullEntries(item); item._fullEntries.push({type: "wrapper", wrapped: "Multiple variations of this item exist, as listed below:", data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "magicvariant"}}); item._fullEntries.push({ type: "wrapper", wrapped: { type: "list", items: item.items.map(it => typeof it === "string" ? `{@item ${it}}` : `{@item ${it.name}|${it.source}}`), }, data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "magicvariant"}, }); } // region Add base items list // item.variants was added during generic variant creation if (item.variants && item.variants.length) { item.variants.sort((a, b) => SortUtil.ascSortLower(a.base.name, b.base.name) || SortUtil.ascSortLower(a.base.source, b.base.source)); Renderer.item._initFullEntries(item); item._fullEntries.push({ type: "wrapper", wrapped: { type: "entries", name: "Base items", entries: [ "This item variant can be applied to the following base items:", { type: "list", items: item.variants.map(({base, specificVariant}) => { return `{@item ${base.name}|${base.source}} ({@item ${specificVariant.name}|${specificVariant.source}})`; }), }, ], }, data: {[VeCt.ENTDATA_ITEM_MERGED_ENTRY_TAG]: "magicvariant"}, }); } // endregion } static _enhanceItem_getItemPropertyTypeEntries ({item, ent}) { if (!ent.entriesTemplate) return MiscUtil.copyFast(ent.entries); return MiscUtil .getWalker({ keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, }) .walk( MiscUtil.copyFast(ent.entriesTemplate), { string: (str) => { return Renderer.utils.applyTemplate( item, str, ); }, }, ); } static unenhanceItem (item) { if (!item._isEnhanced) return; delete item._isEnhanced; delete item._fullEntries; } static async pGetSiteUnresolvedRefItemsFromPrereleaseBrew ({brewUtil, brew = null}) { if (brewUtil == null && brew == null) return []; brew = brew || await brewUtil.pGetBrewProcessed(); (brew.itemProperty || []).forEach(p => Renderer.item._addProperty(p)); (brew.itemType || []).forEach(t => Renderer.item._addType(t)); (brew.itemEntry || []).forEach(it => Renderer.item._addEntry(it)); (brew.itemTypeAdditionalEntries || []).forEach(it => Renderer.item._addAdditionalEntries(it)); let items = [...(brew.baseitem || []), ...(brew.item || [])]; if (brew.itemGroup) { const itemGroups = MiscUtil.copyFast(brew.itemGroup); itemGroups.forEach(it => it._isItemGroup = true); items = [...items, ...itemGroups]; } Renderer.item._enhanceItems(items); let isReEnhanceVariants = false; // Get specific variants for brew base items, using official generic variants if (brew.baseitem && brew.baseitem.length) { isReEnhanceVariants = true; const {genericVariants} = await Renderer.item._pGetCacheSiteGenericVariants(); const variants = await Renderer.item.pGetGenericAndSpecificVariants( genericVariants, {baseItems: brew.baseitem || [], isSpecificVariantsOnly: true}, ); items = [...items, ...variants]; } // Get specific and generic variants for official and brew base items, using brew generic variants if (brew.magicvariant && brew.magicvariant.length) { isReEnhanceVariants = true; const variants = await Renderer.item.pGetGenericAndSpecificVariants( brew.magicvariant, {additionalBaseItems: brew.baseitem || []}, ); items = [...items, ...variants]; } // Regenerate the full entries for the generic variants, as there may be more specific variants to add to their // specific variant lists. if (isReEnhanceVariants) { const {genericVariants} = await Renderer.item._pGetCacheSiteGenericVariants(); genericVariants.forEach(item => { Renderer.item.unenhanceItem(item); Renderer.item.enhanceItem(item); }); } return items; } static async pGetItemsFromPrerelease () { return DataLoader.pCacheAndGetAllPrerelease(UrlUtil.PG_ITEMS); } static async pGetItemsFromBrew () { return DataLoader.pCacheAndGetAllBrew(UrlUtil.PG_ITEMS); } static _pPopulatePropertyAndTypeReference = null; static pPopulatePropertyAndTypeReference () { return Renderer.item._pPopulatePropertyAndTypeReference || (async () => { const data = await DataUtil.loadJSON(`${Renderer.get().baseUrl}data/items-base.json`); Object.entries(Parser.ITEM_TYPE_JSON_TO_ABV).forEach(([abv, name]) => Renderer.item._addType({abbreviation: abv, name})); data.itemProperty.forEach(p => Renderer.item._addProperty(p)); data.itemType.forEach(t => Renderer.item._addType(t)); data.itemEntry.forEach(it => Renderer.item._addEntry(it)); data.itemTypeAdditionalEntries.forEach(e => Renderer.item._addAdditionalEntries(e)); await Renderer.item._pAddPrereleaseBrewPropertiesAndTypes(); })(); } // fetch every possible indexable item from official data static async getAllIndexableItems (rawVariants, rawBaseItems) { const basicItems = await Renderer.item._pGetAndProcBaseItems(rawBaseItems); const [genericVariants, linkedLootTables] = await Renderer.item._getAndProcGenericVariants(rawVariants); const specificVariants = Renderer.item._createSpecificVariants(basicItems, genericVariants, {linkedLootTables}); [...genericVariants, ...specificVariants].forEach(item => { if (item.variants) delete item.variants; // prevent circular references }); return specificVariants; } static isMundane (item) { return item.rarity === "none" || item.rarity === "unknown" || item._category === "Basic"; } static isExcluded (item, {hash = null} = {}) { const name = item.name; const source = item.source || item.inherits?.source; hash = hash || UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_ITEMS]({name, source}); if (ExcludeUtil.isExcluded(hash, "item", source)) return true; if (item._isBaseItem) return ExcludeUtil.isExcluded(hash, "baseitem", source); if (item._isItemGroup) return ExcludeUtil.isExcluded(hash, "itemGroup", source); if (item._variantName) { if (ExcludeUtil.isExcluded(hash, "_specificVariant", source)) return true; const baseHash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_ITEMS]({name: item._baseName, source: item._baseSource || source}); if (ExcludeUtil.isExcluded(baseHash, "baseitem", item._baseSource || source)) return true; const variantHash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_ITEMS]({name: item._variantName, source: source}); return ExcludeUtil.isExcluded(variantHash, "magicvariant", source); } if (item.type === "GV") return ExcludeUtil.isExcluded(hash, "magicvariant", source); return false; } static pGetFluff (item) { return Renderer.utils.pGetFluff({ entity: item, fnGetFluffData: DataUtil.itemFluff.loadJSON.bind(DataUtil.itemFluff), fluffProp: "itemFluff", }); } }; Renderer.psionic = class { static enhanceMode (mode) { if (mode._isEnhanced) return; mode.name = [mode.name, Renderer.psionic._enhanceMode_getModeTitleBracketPart({mode: mode})].filter(Boolean).join(" "); if (mode.submodes) { mode.submodes.forEach(sm => { sm.name = [sm.name, Renderer.psionic._enhanceMode_getModeTitleBracketPart({mode: sm})].filter(Boolean).join(" "); }); } mode._isEnhanced = true; } static _enhanceMode_getModeTitleBracketPart ({mode}) { const modeTitleBracketArray = []; if (mode.cost) modeTitleBracketArray.push(Renderer.psionic._enhanceMode_getModeTitleCost({mode})); if (mode.concentration) modeTitleBracketArray.push(Renderer.psionic._enhanceMode_getModeTitleConcentration({mode})); if (modeTitleBracketArray.length === 0) return null; return `(${modeTitleBracketArray.join("; ")})`; } static _enhanceMode_getModeTitleCost ({mode}) { const costMin = mode.cost.min; const costMax = mode.cost.max; const costString = costMin === costMax ? costMin : `${costMin}-${costMax}`; return `${costString} psi`; } static _enhanceMode_getModeTitleConcentration ({mode}) { return `conc., ${mode.concentration.duration} ${mode.concentration.unit}.`; } /* -------------------------------------------- */ static getPsionicRenderableEntriesMeta (ent) { const entriesContent = []; return { entryTypeOrder: `{@i ${Renderer.psionic.getTypeOrderString(ent)}}`, entryContent: ent.entries ? {entries: ent.entries, type: "entries"} : null, entryFocus: ent.focus ? `{@b {@i Psychic Focus.}} ${ent.focus}` : null, entriesModes: ent.modes ? ent.modes .flatMap(mode => Renderer.psionic._getModeEntries(mode)) : null, }; } static _getModeEntries (mode, renderer) { Renderer.psionic.enhanceMode(mode); return [ { type: mode.type || "entries", name: mode.name, entries: mode.entries, }, mode.submodes ? Renderer.psionic._getSubModesEntry(mode.submodes) : null, ] .filter(Boolean); } static _getSubModesEntry (subModes) { return { type: "list", style: "list-hang-notitle", items: subModes .map(sm => ({ type: "item", name: sm.name, entries: sm.entries, })), }; } static getTypeOrderString (psi) { const typeMeta = Parser.psiTypeToMeta(psi.type); // if "isAltDisplay" is true, render as e.g. "Greater Discipline (Awakened)" rather than "Awakened Greater Discipline" return typeMeta.hasOrder ? typeMeta.isAltDisplay ? `${typeMeta.full} (${psi.order})` : `${psi.order} ${typeMeta.full}` : typeMeta.full; } static getBodyHtml (ent, {renderer = null, entriesMeta = null} = {}) { renderer ||= Renderer.get().setFirstSection(true); entriesMeta ||= Renderer.psionic.getPsionicRenderableEntriesMeta(ent); return `${entriesMeta.entryContent ? renderer.render(entriesMeta.entryContent) : ""} ${entriesMeta.entryFocus ? `

    ${renderer.render(entriesMeta.entryFocus)}

    ` : ""} ${entriesMeta.entriesModes ? entriesMeta.entriesModes.map(entry => renderer.render(entry, 2)).join("") : ""}`; } static getCompactRenderedString (ent) { const renderer = Renderer.get().setFirstSection(true); const entriesMeta = Renderer.psionic.getPsionicRenderableEntriesMeta(ent); return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: "psionic", page: UrlUtil.PG_PSIONICS})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_PSIONICS})}

    ${renderer.render(entriesMeta.entryTypeOrder)}

    ${Renderer.psionic.getBodyHtml(ent, {renderer, entriesMeta})} `; } }; Renderer.rule = class { static getCompactRenderedString (rule) { return ` ${Renderer.get().setFirstSection(true).render(rule)} `; } }; Renderer.variantrule = class { static getCompactRenderedString (rule) { const cpy = MiscUtil.copyFast(rule); delete cpy.name; return ` ${Renderer.utils.getExcludedTr({entity: rule, dataProp: "variantrule", page: UrlUtil.PG_VARIANTRULES})} ${Renderer.utils.getNameTr(rule, {page: UrlUtil.PG_VARIANTRULES})} ${Renderer.get().setFirstSection(true).render(cpy)} `; } }; Renderer.table = class { static getCompactRenderedString (it) { it.type = it.type || "table"; const cpy = MiscUtil.copyFast(it); delete cpy.name; return ` ${Renderer.utils.getExcludedTr({entity: it, dataProp: "table", page: UrlUtil.PG_TABLES})} ${Renderer.utils.getNameTr(it, {page: UrlUtil.PG_TABLES})} ${Renderer.get().setFirstSection(true).render(it)} `; } static getConvertedEncounterOrNamesTable ({group, tableRaw, fnGetNameCaption, colLabel1}) { const getPadded = (number) => { if (tableRaw.diceExpression === "d100") return String(number).padStart(2, "0"); return String(number); }; const nameCaption = fnGetNameCaption(group, tableRaw); return { name: nameCaption, type: "table", source: group?.source, page: group?.page, caption: nameCaption, colLabels: [ `{@dice ${tableRaw.diceExpression}}`, colLabel1, tableRaw.rollAttitude ? `Attitude` : null, ].filter(Boolean), colStyles: [ "col-2 text-center", tableRaw.rollAttitude ? "col-8" : "col-10", tableRaw.rollAttitude ? `col-2 text-center` : null, ].filter(Boolean), rows: tableRaw.table.map(it => [ `${getPadded(it.min)}${it.max != null && it.max !== it.min ? `-${getPadded(it.max)}` : ""}`, it.result, tableRaw.rollAttitude ? it.resultAttitude || "\u2014" : null, ].filter(Boolean)), footnotes: tableRaw.footnotes, }; } static getConvertedEncounterTableName (group, tableRaw) { return `${group.name}${tableRaw.caption ? ` ${tableRaw.caption}` : ""}${/\bencounters?\b/i.test(group.name) ? "" : " Encounters"}${tableRaw.minlvl && tableRaw.maxlvl ? ` (Levels ${tableRaw.minlvl}\u2014${tableRaw.maxlvl})` : ""}`; } static getConvertedNameTableName (group, tableRaw) { return `${group.name} Names \u2013 ${tableRaw.option}`; } static getHeaderRowMetas (ent) { if (!ent.colLabels?.length && !ent.colLabelGroups?.length) return null; if (ent.colLabels?.length) return [ent.colLabels]; const maxHeight = Math.max(...ent.colLabelGroups.map(clg => clg.colLabels?.length || 0)); const padded = ent.colLabelGroups .map(clg => { const out = [...(clg.colLabels || [])]; while (out.length < maxHeight) out.unshift(""); return out; }); return [...new Array(maxHeight)] .map((_, i) => padded.map(lbls => lbls[i])); } static _RE_TABLE_ROW_DASHED_NUMBERS = /^\d+([-\u2012\u2013]\d+)?/; static getAutoConvertedRollMode (table, {headerRowMetas} = {}) { if (headerRowMetas === undefined) headerRowMetas = Renderer.table.getHeaderRowMetas(table); if (!headerRowMetas || headerRowMetas.last().length < 2) return RollerUtil.ROLL_COL_NONE; const rollColMode = RollerUtil.getColRollType(headerRowMetas.last()[0]); if (!rollColMode) return RollerUtil.ROLL_COL_NONE; if (!Renderer.table.isEveryRowRollable(table.rows)) return RollerUtil.ROLL_COL_NONE; return rollColMode; } static isEveryRowRollable (rows) { // scan the first column to ensure all rollable return rows .every(row => { if (!row) return false; const [cell] = row; return Renderer.table.isRollableCell(cell); }); } static isRollableCell (cell) { if (cell == null) return false; if (cell?.roll) return true; if (typeof cell === "number") return Number.isInteger(cell); // u2012 = figure dash; u2013 = en-dash return typeof cell === "string" && Renderer.table._RE_TABLE_ROW_DASHED_NUMBERS.test(cell); } }; Renderer.vehicle = class { static CHILD_PROPS = ["movement", "weapon", "other", "action", "trait", "reaction", "control", "actionStation"]; static getVehicleRenderableEntriesMeta (ent) { return { entryDamageImmunities: ent.immune ? `{@b Damage Immunities} ${Parser.getFullImmRes(ent.immune)}` : null, entryConditionImmunities: ent.conditionImmune ? `{@b Condition Immunities} ${Parser.getFullCondImm(ent.conditionImmune, {isEntry: true})}` : null, }; } static getCompactRenderedString (veh, opts) { return Renderer.vehicle.getRenderedString(veh, {...opts, isCompact: true}); } static getRenderedString (ent, opts) { opts = opts || {}; if (ent.upgradeType) return Renderer.vehicleUpgrade.getCompactRenderedString(ent, opts); ent.vehicleType ||= "SHIP"; switch (ent.vehicleType) { case "SHIP": return Renderer.vehicle._getRenderedString_ship(ent, opts); case "SPELLJAMMER": return Renderer.vehicle._getRenderedString_spelljammer(ent, opts); case "INFWAR": return Renderer.vehicle._getRenderedString_infwar(ent, opts); case "CREATURE": return Renderer.monster.getCompactRenderedString(ent, {...opts, isHideLanguages: true, isHideSenses: true, isCompact: opts.isCompact ?? false, page: UrlUtil.PG_VEHICLES}); case "OBJECT": return Renderer.object.getCompactRenderedString(ent, {...opts, isCompact: opts.isCompact ?? false, page: UrlUtil.PG_VEHICLES}); default: throw new Error(`Unhandled vehicle type "${ent.vehicleType}"`); } } static ship = class { static PROPS_RENDERABLE_ENTRIES_ATTRIBUTES = [ "entryCreatureCapacity", "entryCargoCapacity", "entryTravelPace", "entryTravelPaceNote", ]; static getVehicleShipRenderableEntriesMeta (ent) { // Render UA ship actions at the top, to match later printed layout const entriesOtherActions = (ent.other || []).filter(it => it.name === "Actions"); const entriesOtherOthers = (ent.other || []).filter(it => it.name !== "Actions"); return { entrySizeDimensions: `{@i ${Parser.sizeAbvToFull(ent.size)} vehicle${ent.dimensions ? ` (${ent.dimensions.join(" by ")})` : ""}}`, entryCreatureCapacity: ent.capCrew != null || ent.capPassenger != null ? `{@b Creature Capacity} ${Renderer.vehicle.getShipCreatureCapacity(ent)}` : null, entryCargoCapacity: ent.capCargo != null ? `{@b Cargo Capacity} ${Renderer.vehicle.getShipCargoCapacity(ent)}` : null, entryTravelPace: ent.pace != null ? `{@b Travel Pace} ${ent.pace} miles per hour (${ent.pace * 24} miles per day)` : null, entryTravelPaceNote: ent.pace != null ? `[{@b Speed} ${ent.pace * 10} ft.]` : null, entryTravelPaceNoteTitle: ent.pace != null ? `Based on "Special Travel Pace," DMG p242` : null, entriesOtherActions: entriesOtherActions.length ? entriesOtherActions : null, entriesOtherOthers: entriesOtherOthers.length ? entriesOtherOthers : null, }; } static getLocomotionEntries (loc) { return { type: "list", style: "list-hang-notitle", items: [ { type: "item", name: `Locomotion (${loc.mode})`, entries: loc.entries, }, ], }; } static getSpeedEntries (spd) { return { type: "list", style: "list-hang-notitle", items: [ { type: "item", name: `Speed (${spd.mode})`, entries: spd.entries, }, ], }; } static getActionPart_ (renderer, veh) { return renderer.render({entries: veh.action}); } static getSectionTitle_ (title) { return `

    ${title}

    `; } static getSectionHpEntriesMeta_ ({entry, isEach = false}) { return { entryArmorClass: entry.ac ? `{@b Armor Class} ${entry.ac}` : null, entryHitPoints: entry.hp ? `{@b Hit Points} ${entry.hp}${isEach ? ` each` : ""}${entry.dt ? ` (damage threshold ${entry.dt})` : ""}${entry.hpNote ? `; ${entry.hpNote}` : ""}` : null, }; } static getSectionHpPart_ (renderer, entry, isEach) { const entriesMetaSection = Renderer.vehicle.ship.getSectionHpEntriesMeta_({entry, isEach}); const props = [ "entryArmorClass", "entryHitPoints", ]; if (!props.some(prop => entriesMetaSection[prop])) return ""; return props .map(prop => `
    ${renderer.render(entriesMetaSection[prop])}
    `) .join(""); } static getControlSection_ (renderer, control) { if (!control) return ""; return `

    Control: ${control.name}

    ${Renderer.vehicle.ship.getSectionHpPart_(renderer, control)}
    ${renderer.render({entries: control.entries})}
    `; } static _getMovementSection_getLocomotionSection ({renderer, entry}) { const asList = Renderer.vehicle.ship.getLocomotionEntries(entry); return `
    ${renderer.render(asList)}
    `; } static _getMovementSection_getSpeedSection ({renderer, entry}) { const asList = Renderer.vehicle.ship.getSpeedEntries(entry); return `
    ${renderer.render(asList)}
    `; } static getMovementSection_ (renderer, move) { if (!move) return ""; return `

    ${move.isControl ? `Control and ` : ""}Movement: ${move.name}

    ${Renderer.vehicle.ship.getSectionHpPart_(renderer, move)} ${(move.locomotion || []).map(entry => Renderer.vehicle.ship._getMovementSection_getLocomotionSection({renderer, entry})).join("")} ${(move.speed || []).map(entry => Renderer.vehicle.ship._getMovementSection_getSpeedSection({renderer, entry})).join("")} `; } static getWeaponSection_ (renderer, weap) { return `

    Weapons: ${weap.name}${weap.count ? ` (${weap.count})` : ""}

    ${Renderer.vehicle.ship.getSectionHpPart_(renderer, weap, !!weap.count)} ${renderer.render({entries: weap.entries})} `; } static getOtherSection_ (renderer, oth) { return `

    ${oth.name}

    ${Renderer.vehicle.ship.getSectionHpPart_(renderer, oth)} ${renderer.render({entries: oth.entries})} `; } static getCrewCargoPaceSection_ (ent, {entriesMetaShip = null} = {}) { entriesMetaShip ||= Renderer.vehicle.ship.getVehicleShipRenderableEntriesMeta(ent); if (!Renderer.vehicle.ship.PROPS_RENDERABLE_ENTRIES_ATTRIBUTES.some(prop => entriesMetaShip[prop])) return ""; return ` ${entriesMetaShip.entryCreatureCapacity ? `
    ${Renderer.get().render(entriesMetaShip.entryCreatureCapacity)}
    ` : ""} ${entriesMetaShip.entryCargoCapacity ? `
    ${Renderer.get().render(entriesMetaShip.entryCargoCapacity)}
    ` : ""} ${entriesMetaShip.entryTravelPace ? `
    ${Renderer.get().render(entriesMetaShip.entryTravelPace)}
    ` : ""} ${entriesMetaShip.entryTravelPaceNote ? `
    ${Renderer.get().render(entriesMetaShip.entryTravelPaceNote)}
    ` : ""} `; } }; static spelljammer = class { static getVehicleSpelljammerRenderableEntriesMeta (ent) { const ptAc = ent.hull?.ac ? `${ent.hull.ac}${ent.hull.acFrom ? ` (${ent.hull.acFrom.join(", ")})` : ""}` : "\u2014"; const ptSpeed = ent.speed != null ? Parser.getSpeedString(ent, {isSkipZeroWalk: true}) : ""; const ptPace = Renderer.vehicle.spelljammer._getVehicleSpelljammerRenderableEntriesMeta_getPtPace({ent}); const ptSpeedPace = [ptSpeed, ptPace].filter(Boolean).join(" "); return { entryTableSummary: { type: "table", style: "summary", colStyles: ["col-6", "col-6"], rows: [ [ `{@b Armor Class:} ${ptAc}`, `{@b Cargo:} ${ent.capCargo ? `${ent.capCargo} ton${ent.capCargo === 1 ? "" : "s"}` : "\u2014"}`, ], [ `{@b Hit Points:} ${ent.hull?.hp ?? "\u2014"}`, `{@b Crew:} ${ent.capCrew ?? "\u2014"}${ent.capCrewNote ? ` ${ent.capCrewNote}` : ""}`, ], [ `{@b Damage Threshold:} ${ent.hull?.dt ?? "\u2014"}`, `{@b Keel/Beam:} ${(ent.dimensions || ["\u2014"]).join("/")}`, ], [ `{@b Speed:} ${ptSpeedPace}`, `{@b Cost:} ${ent.cost != null ? Parser.vehicleCostToFull(ent) : "\u2014"}`, ], ], }, }; } static _getVehicleSpelljammerRenderableEntriesMeta_getPtPace (ent) { if (!ent.pace) return ""; const isMulti = Object.keys(ent.pace).length > 1; const out = Parser.SPEED_MODES .map(mode => { const pace = ent.pace[mode]; if (!pace) return null; const asNum = Parser.vulgarToNumber(pace); return `{@tip ${isMulti && mode !== "walk" ? `${mode} ` : ""}${pace} mph|${asNum * 24} miles per day}`; }) .filter(Boolean) .join(", "); return `(${out})`; } static getSummarySection_ (renderer, ent) { const entriesMetaSpelljammer = Renderer.vehicle.spelljammer.getVehicleSpelljammerRenderableEntriesMeta(ent); return `${renderer.render(entriesMetaSpelljammer.entryTableSummary)}`; } static getSectionWeaponEntriesMeta (entry) { const isMultiple = entry.count != null && entry.count > 1; return { entryName: `${isMultiple ? `${entry.count} ` : ""}${entry.name}${entry.crew ? ` (Crew: ${entry.crew}${isMultiple ? " each" : ""})` : ""}`, }; } static getWeaponSection_ (renderer, entry) { const entriesMetaSectionWeapon = Renderer.vehicle.spelljammer.getSectionWeaponEntriesMeta(entry); const ptAction = entry.action?.length ? entry.action.map(act => `
    ${renderer.render(act, 2)}
    `).join("") : ""; return `

    ${entriesMetaSectionWeapon.entryName}

    ${Renderer.vehicle.spelljammer.getSectionHpCostPart_(renderer, entry)} ${entry.entries?.length ? `
    ${renderer.render({entries: entry.entries})}
    ` : ""} ${ptAction} `; } static getSectionHpCostEntriesMeta (entry) { const ptCosts = entry.costs?.length ? entry.costs.map(cost => { return `${Parser.vehicleCostToFull(cost) || "\u2014"}${cost.note ? ` (${cost.note})` : ""}`; }).join(", ") : "\u2014"; return { entryArmorClass: `{@b Armor Class:} ${entry.ac == null ? "\u2014" : entry.ac}`, entryHitPoints: `{@b Hit Points:} ${entry.hp == null ? "\u2014" : entry.hp}`, entryCost: `{@b Cost:} ${ptCosts}`, }; } static getSectionHpCostPart_ (renderer, entry) { const entriesMetaSectionHpCost = Renderer.vehicle.spelljammer.getSectionHpCostEntriesMeta(entry); return `
    ${renderer.render(entriesMetaSectionHpCost.entryArmorClass)}
    ${renderer.render(entriesMetaSectionHpCost.entryHitPoints)}
    ${renderer.render(entriesMetaSectionHpCost.entryCost)}
    `; } }; static _getAbilitySection (veh) { return Parser.ABIL_ABVS.some(it => veh[it] != null) ? `
    STR DEX CON INT WIS CHA
    ${Renderer.utils.getAbilityRoller(veh, "str")} ${Renderer.utils.getAbilityRoller(veh, "dex")} ${Renderer.utils.getAbilityRoller(veh, "con")} ${Renderer.utils.getAbilityRoller(veh, "int")} ${Renderer.utils.getAbilityRoller(veh, "wis")} ${Renderer.utils.getAbilityRoller(veh, "cha")}
    ` : ""; } static _getResImmVulnSection (ent, {entriesMeta = null} = {}) { entriesMeta ||= Renderer.vehicle.getVehicleRenderableEntriesMeta(ent); const props = [ "entryDamageImmunities", "entryConditionImmunities", ]; if (!props.some(prop => entriesMeta[prop])) return ""; return ` ${props.filter(prop => entriesMeta[prop]).map(prop => `
    ${Renderer.get().render(entriesMeta[prop])}
    `).join("")} `; } static _getTraitSection (renderer, veh) { return veh.trait ? `

    Traits

    ${Renderer.monster.getOrderedTraits(veh, renderer).map(it => it.rendered || renderer.render(it, 2)).join("")} ` : ""; } static _getRenderedString_ship (ent, opts) { const renderer = Renderer.get(); const entriesMeta = Renderer.vehicle.getVehicleRenderableEntriesMeta(ent); const entriesMetaShip = Renderer.vehicle.ship.getVehicleShipRenderableEntriesMeta(ent); const hasToken = ent.tokenUrl || ent.hasToken; const extraThClasses = !opts.isCompact && hasToken ? ["veh__name--token"] : null; return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: "vehicle", page: UrlUtil.PG_VEHICLES})} ${Renderer.utils.getNameTr(ent, {extraThClasses, page: UrlUtil.PG_VEHICLES})} ${Renderer.get().render(entriesMetaShip.entrySizeDimensions)} ${Renderer.vehicle.ship.getCrewCargoPaceSection_(ent, {entriesMetaShip})} ${Renderer.vehicle._getAbilitySection(ent)} ${Renderer.vehicle._getResImmVulnSection(ent, {entriesMeta})} ${ent.action ? Renderer.vehicle.ship.getSectionTitle_("Actions") : ""} ${ent.action ? `${Renderer.vehicle.ship.getActionPart_(renderer, ent)}` : ""} ${(entriesMetaShip.entriesOtherActions || []).map(Renderer.vehicle.ship.getOtherSection_.bind(this, renderer)).join("")} ${ent.hull ? `${Renderer.vehicle.ship.getSectionTitle_("Hull")} ${Renderer.vehicle.ship.getSectionHpPart_(renderer, ent.hull)} ` : ""} ${Renderer.vehicle._getTraitSection(renderer, ent)} ${(ent.control || []).map(Renderer.vehicle.ship.getControlSection_.bind(this, renderer)).join("")} ${(ent.movement || []).map(Renderer.vehicle.ship.getMovementSection_.bind(this, renderer)).join("")} ${(ent.weapon || []).map(Renderer.vehicle.ship.getWeaponSection_.bind(this, renderer)).join("")} ${(entriesMetaShip.entriesOtherOthers || []).map(Renderer.vehicle.ship.getOtherSection_.bind(this, renderer)).join("")} `; } static getShipCreatureCapacity (veh) { return [ veh.capCrew ? `${veh.capCrew} crew` : null, veh.capPassenger ? `${veh.capPassenger} passenger${veh.capPassenger === 1 ? "" : "s"}` : null, ].filter(Boolean).join(", "); } static getShipCargoCapacity (veh) { return typeof veh.capCargo === "string" ? veh.capCargo : `${veh.capCargo} ton${veh.capCargo === 1 ? "" : "s"}`; } static _getRenderedString_spelljammer (veh, opts) { const renderer = Renderer.get(); const hasToken = veh.tokenUrl || veh.hasToken; const extraThClasses = !opts.isCompact && hasToken ? ["veh__name--token"] : null; return ` ${Renderer.utils.getExcludedTr({entity: veh, dataProp: "vehicle", page: UrlUtil.PG_VEHICLES})} ${Renderer.utils.getNameTr(veh, {extraThClasses, page: UrlUtil.PG_VEHICLES})} ${Renderer.vehicle.spelljammer.getSummarySection_(renderer, veh)} ${(veh.weapon || []).map(Renderer.vehicle.spelljammer.getWeaponSection_.bind(this, renderer)).join("")} `; } static infwar = class { static PROPS_RENDERABLE_ENTRIES_ATTRIBUTES = [ "entryCreatureCapacity", "entryCargoCapacity", "entryArmorClass", "entryHitPoints", "entrySpeed", ]; static getVehicleInfwarRenderableEntriesMeta (ent) { const dexMod = Parser.getAbilityModNumber(ent.dex); return { entrySizeWeight: `{@i ${Parser.sizeAbvToFull(ent.size)} vehicle (${ent.weight.toLocaleString()} lb.)}`, entryCreatureCapacity: `{@b Creature Capacity} ${Renderer.vehicle.getInfwarCreatureCapacity(ent)}`, entryCargoCapacity: `{@b Cargo Capacity} ${Parser.weightToFull(ent.capCargo)}`, entryArmorClass: `{@b Armor Class} ${dexMod === 0 ? `19` : `${19 + dexMod} (19 while motionless)`}`, entryHitPoints: `{@b Hit Points} ${ent.hp.hp} (damage threshold ${ent.hp.dt}, mishap threshold ${ent.hp.mt})`, entrySpeed: `{@b Speed} ${ent.speed} ft.`, entrySpeedNote: `[{@b Travel Pace} ${Math.floor(ent.speed / 10)} miles per hour (${Math.floor(ent.speed * 24 / 10)} miles per day)]`, entrySpeedNoteTitle: `Based on "Special Travel Pace," DMG p242`, }; } }; static _getRenderedString_infwar (ent, opts) { const renderer = Renderer.get(); const entriesMeta = Renderer.vehicle.getVehicleRenderableEntriesMeta(ent); const entriesMetaInfwar = Renderer.vehicle.infwar.getVehicleInfwarRenderableEntriesMeta(ent); const hasToken = ent.tokenUrl || ent.hasToken; const extraThClasses = !opts.isCompact && hasToken ? ["veh__name--token"] : null; return ` ${Renderer.utils.getExcludedTr({entity: ent, datProp: "vehicle", page: UrlUtil.PG_VEHICLES})} ${Renderer.utils.getNameTr(ent, {extraThClasses, page: UrlUtil.PG_VEHICLES})} ${renderer.render(entriesMetaInfwar.entrySizeWeight)} ${Renderer.vehicle.infwar.PROPS_RENDERABLE_ENTRIES_ATTRIBUTES.map(prop => `
    ${renderer.render(entriesMetaInfwar[prop])}
    `).join("")}
    ${renderer.render(entriesMetaInfwar.entrySpeedNote)}
    ${Renderer.vehicle._getAbilitySection(ent)} ${Renderer.vehicle._getResImmVulnSection(ent, {entriesMeta})} ${Renderer.vehicle._getTraitSection(renderer, ent)} ${Renderer.monster.getCompactRenderedStringSection(ent, renderer, "Action Stations", "actionStation", 2)} ${Renderer.monster.getCompactRenderedStringSection(ent, renderer, "Reactions", "reaction", 2)} `; } static getInfwarCreatureCapacity (veh) { return `${veh.capCreature} Medium creatures`; } static pGetFluff (veh) { return Renderer.utils.pGetFluff({ entity: veh, fnGetFluffData: DataUtil.vehicleFluff.loadJSON.bind(DataUtil.vehicleFluff), fluffProp: "vehicleFluff", }); } static getTokenUrl (veh) { if (veh.tokenUrl) return veh.tokenUrl; return Renderer.get().getMediaUrl("img", `vehicles/tokens/${Parser.sourceJsonToAbv(veh.source)}/${Parser.nameToTokenName(veh.name)}.webp`); } }; Renderer.vehicleUpgrade = class { static getUpgradeSummary (ent) { return [ ent.upgradeType ? ent.upgradeType.map(t => Parser.vehicleTypeToFull(t)) : null, ent.prerequisite ? Renderer.utils.prerequisite.getHtml(ent.prerequisite) : null, ] .filter(Boolean) .join(", "); } static getCompactRenderedString (ent, opts) { return `${Renderer.utils.getExcludedTr({entity: ent, dataProp: "vehicleUpgrade", page: UrlUtil.PG_VEHICLES})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_VEHICLES})} ${Renderer.vehicleUpgrade.getUpgradeSummary(ent)}
    ${Renderer.get().render({entries: ent.entries}, 1)}`; } }; Renderer.action = class { static getCompactRenderedString (it) { const cpy = MiscUtil.copyFast(it); delete cpy.name; return `${Renderer.utils.getExcludedTr({entity: it, dataProp: "action", page: UrlUtil.PG_ACTIONS})} ${Renderer.utils.getNameTr(it, {page: UrlUtil.PG_ACTIONS})} ${Renderer.get().setFirstSection(true).render(cpy)}`; } }; Renderer.language = class { static getLanguageRenderableEntriesMeta (ent) { const hasMeta = ent.typicalSpeakers || ent.script; const entriesContent = []; if (ent.entries) entriesContent.push(...ent.entries); if (ent.dialects) { entriesContent.push(`This language is a family which includes the following dialects: ${ent.dialects.sort(SortUtil.ascSortLower).join(", ")}. Creatures that speak different dialects of the same language can communicate with one another.`); } if (!entriesContent.length && !hasMeta) entriesContent.push("{@i No information available.}"); return { entryType: ent.type ? `{@i ${ent.type.toTitleCase()} language}` : null, entryTypicalSpeakers: ent.typicalSpeakers ? `{@b Typical Speakers:} ${ent.typicalSpeakers.join(", ")}` : null, entryScript: ent.script ? `{@b Script:} ${ent.script}` : null, entriesContent: entriesContent.length ? entriesContent : null, }; } static getCompactRenderedString (ent) { return Renderer.language.getRenderedString(ent); } static getRenderedString (ent, {isSkipNameRow = false} = {}) { const entriesMeta = Renderer.language.getLanguageRenderableEntriesMeta(ent); return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: "language", page: UrlUtil.PG_LANGUAGES})} ${isSkipNameRow ? "" : Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_LANGUAGES})} ${entriesMeta.entryType ? `${Renderer.get().render(entriesMeta.entryType)}` : ""} ${entriesMeta.entryTypicalSpeakers || entriesMeta.entryScript ? ` ${[entriesMeta.entryTypicalSpeakers, entriesMeta.entryScript].filter(Boolean).map(entry => `
    ${Renderer.get().render(entry)}
    `).join("")} ` : ""} ${entriesMeta.entriesContent ? ` ${Renderer.get().setFirstSection(true).render({entries: entriesMeta.entriesContent})} ` : ""}`; } static pGetFluff (it) { return Renderer.utils.pGetFluff({ entity: it, fnGetFluffData: DataUtil.languageFluff.loadJSON.bind(DataUtil.languageFluff), fluffProp: "languageFluff", }); } }; Renderer.adventureBook = class { static getEntryIdLookup (bookData, doThrowError = true) { const out = {}; const titlesRel = {}; const titlesRelChapter = {}; let chapIx; const depthStack = []; const handlers = { object: (obj) => { Renderer.ENTRIES_WITH_ENUMERATED_TITLES .forEach(meta => { if (obj.type !== meta.type) return; const curDepth = depthStack.length ? depthStack.last() : 0; const nxtDepth = meta.depth ? meta.depth : meta.depthIncrement ? curDepth + meta.depthIncrement : curDepth; depthStack.push( Math.min( nxtDepth, 2, ), ); }); if (!obj.id) return obj; if (out[obj.id]) { (out.__BAD = out.__BAD || []).push(obj.id); return obj; } out[obj.id] = { chapter: chapIx, entry: obj, depth: depthStack.last(), }; if (obj.name) { out[obj.id].name = obj.name; const cleanName = obj.name.toLowerCase(); out[obj.id].nameClean = cleanName; // Relative title index for full-book mode titlesRel[cleanName] = titlesRel[cleanName] || 0; out[obj.id].ixTitleRel = titlesRel[cleanName]++; // Relative title index per-chapter MiscUtil.getOrSet(titlesRelChapter, chapIx, cleanName, -1); out[obj.id].ixTitleRelChapter = ++titlesRelChapter[chapIx][cleanName]; } return obj; }, postObject: (obj) => { Renderer.ENTRIES_WITH_ENUMERATED_TITLES .forEach(meta => { if (obj.type !== meta.type) return; depthStack.pop(); }); }, }; bookData.forEach((chap, _chapIx) => { chapIx = _chapIx; MiscUtil.getWalker({ isNoModification: true, keyBlocklist: new Set(["mapParent"]), }) .walk(chap, handlers); }); if (doThrowError) if (out.__BAD) throw new Error(`IDs were already in storage: ${out.__BAD.map(it => `"${it}"`).join(", ")}`); return out; } static _isAltMissingCoverUsed = false; static getCoverUrl (contents) { if (contents.coverUrl) { // FIXME(future) migrate to e.g. a `coverImage` "image"-type entry to avoid this hack if (/^https?:\/\//.test(contents.coverUrl)) return contents.coverUrl; return UrlUtil.link(Renderer.get().getMediaUrl("img", contents.coverUrl.replace(/^img\//, ""))); } return UrlUtil.link(Renderer.get().getMediaUrl("img", `covers/blank${Math.random() <= 0.05 && !Renderer.adventureBook._isAltMissingCoverUsed && (Renderer.adventureBook._isAltMissingCoverUsed = true) ? "-alt" : ""}.webp`)); } }; Renderer.charoption = class { static getCompactRenderedString (ent) { const prerequisite = Renderer.utils.prerequisite.getHtml(ent.prerequisite); const preText = Renderer.charoption.getOptionTypePreText(ent); return ` ${Renderer.utils.getExcludedTr({entity: ent, dataProp: "charoption", page: UrlUtil.PG_CHAR_CREATION_OPTIONS})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_CHAR_CREATION_OPTIONS})} ${prerequisite ? `

    ${prerequisite}

    ` : ""} ${preText || ""}${Renderer.get().setFirstSection(true).render({type: "entries", entries: ent.entries})} `; } /* -------------------------------------------- */ static getCharoptionRenderableEntriesMeta (ent) { const optsMapped = ent.optionType .map(it => Renderer.charoption._OPTION_TYPE_ENTRIES[it]) .filter(Boolean); if (!optsMapped.length) return null; return { entryOptionType: {type: "entries", entries: optsMapped}, }; } static _OPTION_TYPE_ENTRIES = { "RF:B": `{@note You may replace the standard feature of your background with this feature.}`, "CS": `{@note See the {@adventure Character Secrets|IDRotF|0|character secrets} section for more information.}`, }; static getOptionTypePreText (ent) { const meta = Renderer.charoption.getCharoptionRenderableEntriesMeta(ent); if (!meta) return ""; return Renderer.get().render(meta.entryOptionType); } /* -------------------------------------------- */ static pGetFluff (it) { return Renderer.utils.pGetFluff({ entity: it, fnGetFluffData: DataUtil.charoptionFluff.loadJSON.bind(DataUtil.charoptionFluff), fluffProp: "charoptionFluff", }); } }; Renderer.recipe = class { static _getEntryMetasTime (ent) { if (!Object.keys(ent.time || {}).length) return null; return [ "total", "preparation", "cooking", ...Object.keys(ent.time), ] .unique() .filter(prop => ent.time[prop]) .map((prop, i, arr) => { const val = ent.time[prop]; const ptsTime = ( val.min != null && val.max != null ? [ Parser.getMinutesToFull(val.min), Parser.getMinutesToFull(val.max), ] : [Parser.getMinutesToFull(val)] ); const suffix = MiscUtil.findCommonSuffix(ptsTime, {isRespectWordBoundaries: true}); const ptTime = ptsTime .map(it => !suffix.length ? it : it.slice(0, -suffix.length)) .join(" to "); return { entryName: `{@b {@style ${prop.toTitleCase()} Time:|small-caps}}`, entryContent: `${ptTime}${suffix}`, }; }); } static getRecipeRenderableEntriesMeta (ent) { return { entryMakes: ent.makes ? `{@b {@style Makes|small-caps}} ${ent._scaleFactor ? `${ent._scaleFactor}× ` : ""}${ent.makes}` : null, entryServes: ent.serves ? `{@b {@style Serves|small-caps}} ${ent.serves.min ?? ent.serves.exact}${ent.serves.min != null ? " to " : ""}${ent.serves.max ?? ""}` : null, entryMetasTime: Renderer.recipe._getEntryMetasTime(ent), entryIngredients: {entries: ent._fullIngredients}, entryEquipment: ent._fullEquipment?.length ? {entries: ent._fullEquipment} : null, entryCooksNotes: ent.noteCook ? {entries: ent.noteCook} : null, entryInstructions: {entries: ent.instructions}, }; } static getCompactRenderedString (ent) { return `${Renderer.utils.getExcludedTr({entity: ent, dataProp: "recipe", page: UrlUtil.PG_RECIPES})} ${Renderer.utils.getNameTr(ent, {page: UrlUtil.PG_RECIPES})} ${Renderer.recipe.getBodyHtml(ent)} `; } static getBodyHtml (ent) { const entriesMeta = Renderer.recipe.getRecipeRenderableEntriesMeta(ent); const ptTime = Renderer.recipe.getTimeHtml(ent, {entriesMeta}); const {ptMakes, ptServes} = Renderer.recipe.getMakesServesHtml(ent, {entriesMeta}); return `
    ${ptTime || ""} ${ptMakes || ""} ${ptServes || ""}
    ${Renderer.get().render(entriesMeta.entryIngredients, 0)}
    ${entriesMeta.entryEquipment ? `
    Equipment
    ${Renderer.get().render(entriesMeta.entryEquipment)}
    ` : ""} ${entriesMeta.entryCooksNotes ? `
    Cook's Notes
    ${Renderer.get().render(entriesMeta.entryCooksNotes)}
    ` : ""}
    ${Renderer.get().setFirstSection(true).render(entriesMeta.entryInstructions, 2)}
    `; } static getMakesServesHtml (ent, {entriesMeta = null} = {}) { entriesMeta ||= Renderer.recipe.getRecipeRenderableEntriesMeta(ent); const ptMakes = entriesMeta.entryMakes ? `
    ${Renderer.get().render(entriesMeta.entryMakes)}
    ` : null; const ptServes = entriesMeta.entryServes ? `
    ${Renderer.get().render(entriesMeta.entryServes)}
    ` : null; return {ptMakes, ptServes}; } static getTimeHtml (ent, {entriesMeta = null} = {}) { entriesMeta ||= Renderer.recipe.getRecipeRenderableEntriesMeta(ent); if (!entriesMeta.entryMetasTime) return ""; return entriesMeta.entryMetasTime .map(({entryName, entryContent}, i, arr) => { return `
    ${Renderer.get().render(entryName)} ${Renderer.get().render(entryContent)}
    `; }) .join(""); } static pGetFluff (it) { return Renderer.utils.pGetFluff({ entity: it, fnGetFluffData: DataUtil.recipeFluff.loadJSON.bind(DataUtil.recipeFluff), fluffProp: "recipeFluff", }); } static populateFullIngredients (r) { r._fullIngredients = Renderer.applyAllProperties(MiscUtil.copyFast(r.ingredients)); if (r.equipment) r._fullEquipment = Renderer.applyAllProperties(MiscUtil.copyFast(r.equipment)); } static _RE_AMOUNT = /(?{=amount\d+(?:\/[^}]+)?})/g; static _SCALED_PRECISION_LIMIT = 10 ** 6; static getScaledRecipe (r, scaleFactor) { const cpyR = MiscUtil.copyFast(r); ["ingredients", "equipment"] .forEach(prop => { if (!cpyR[prop]) return; MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST}).walk( cpyR[prop], { object: (obj) => { if (obj.type !== "ingredient") return obj; const objOriginal = MiscUtil.copyFast(obj); Object.keys(obj) .filter(k => /^amount\d+/.test(k)) .forEach(k => { let base = obj[k]; if (Math.round(base) !== base && base < 20) { const divOneSixth = obj[k] / 0.166; if (Math.abs(divOneSixth - Math.round(divOneSixth)) < 0.05) base = (1 / 6) * Math.round(divOneSixth); } let scaled = base * scaleFactor; obj[k] = Math.round(base * scaleFactor * Renderer.recipe._SCALED_PRECISION_LIMIT) / Renderer.recipe._SCALED_PRECISION_LIMIT; }); // region Attempt to singleize/pluralize units const amountsOriginal = Object.keys(objOriginal).filter(k => /^amount\d+$/.test(k)).map(k => objOriginal[k]); const amountsScaled = Object.keys(obj).filter(k => /^amount\d+$/.test(k)).map(k => obj[k]); const entryParts = obj.entry.split(Renderer.recipe._RE_AMOUNT).filter(Boolean); const entryPartsOut = entryParts.slice(0, entryParts.findIndex(it => Renderer.recipe._RE_AMOUNT.test(it)) + 1); let ixAmount = 0; for (let i = entryPartsOut.length; i < entryParts.length; ++i) { let pt = entryParts[i]; if (Renderer.recipe._RE_AMOUNT.test(pt)) { ixAmount++; entryPartsOut.push(pt); continue; } if (amountsOriginal[ixAmount] == null || amountsScaled[ixAmount] == null) { entryPartsOut.push(pt); continue; } const isSingleToPlural = amountsOriginal[ixAmount] <= 1 && amountsScaled[ixAmount] > 1; const isPluralToSingle = amountsOriginal[ixAmount] > 1 && amountsScaled[ixAmount] <= 1; if (!isSingleToPlural && !isPluralToSingle) { entryPartsOut.push(pt); continue; } if (isSingleToPlural) pt = Renderer.recipe._getPluralizedUnits(pt); else if (isPluralToSingle) pt = Renderer.recipe._getSingleizedUnits(pt); entryPartsOut.push(pt); } obj.entry = entryPartsOut.join(""); // endregion Renderer.recipe._mutWrapOriginalAmounts({obj, objOriginal}); return obj; }, }, ); }); Renderer.recipe.populateFullIngredients(cpyR); if (cpyR.serves) { if (cpyR.serves.min) cpyR.serves.min *= scaleFactor; if (cpyR.serves.max) cpyR.serves.max *= scaleFactor; if (cpyR.serves.exact) cpyR.serves.exact *= scaleFactor; } cpyR._displayName = `${cpyR.name} (×${scaleFactor})`; cpyR._scaleFactor = scaleFactor; return cpyR; } static _UNITS_SINGLE_TO_PLURAL_S = [ "bundle", "cup", "handful", "ounce", "packet", "piece", "pound", "slice", "sprig", "square", "strip", "tablespoon", "teaspoon", "wedge", ]; static _UNITS_SINGLE_TO_PLURAL_ES = [ "dash", "inch", ]; static _FNS_SINGLE_TO_PLURAL = []; static _FNS_PLURAL_TO_SINGLE = []; static _getSingleizedUnits (str) { if (!Renderer.recipe._FNS_PLURAL_TO_SINGLE.length) { Renderer.recipe._FNS_PLURAL_TO_SINGLE = [ ...Renderer.recipe._UNITS_SINGLE_TO_PLURAL_S.map(word => str => str.replace(new RegExp(`\\b${word.escapeRegexp()}s\\b`, "gi"), (...m) => m[0].slice(0, -1))), ...Renderer.recipe._UNITS_SINGLE_TO_PLURAL_ES.map(word => str => str.replace(new RegExp(`\\b${word.escapeRegexp()}es\\b`, "gi"), (...m) => m[0].slice(0, -2))), ]; } Renderer.recipe._FNS_PLURAL_TO_SINGLE.forEach(fn => str = fn(str)); return str; } static _getPluralizedUnits (str) { if (!Renderer.recipe._FNS_SINGLE_TO_PLURAL.length) { Renderer.recipe._FNS_SINGLE_TO_PLURAL = [ ...Renderer.recipe._UNITS_SINGLE_TO_PLURAL_S.map(word => str => str.replace(new RegExp(`\\b${word.escapeRegexp()}\\b`, "gi"), (...m) => `${m[0]}s`)), ...Renderer.recipe._UNITS_SINGLE_TO_PLURAL_ES.map(word => str => str.replace(new RegExp(`\\b${word.escapeRegexp()}\\b`, "gi"), (...m) => `${m[0]}es`)), ]; } Renderer.recipe._FNS_SINGLE_TO_PLURAL.forEach(fn => str = fn(str)); return str; } /** Only apply the `@help` note to standalone amounts, i.e. those not in other tags. */ static _mutWrapOriginalAmounts ({obj, objOriginal}) { const parts = []; let stack = ""; let depth = 0; for (let i = 0; i < obj.entry.length; ++i) { const c = obj.entry[i]; switch (c) { case "{": { if (!depth && stack) { parts.push(stack); stack = ""; } depth++; stack += c; break; } case "}": { depth--; stack += c; if (!depth && stack) { parts.push(stack); stack = ""; } break; } default: stack += c; } } if (stack) parts.push(stack); obj.entry = parts .map(pt => pt.replace(Renderer.recipe._RE_AMOUNT, (...m) => { const ixStart = m.slice(-3, -2)[0]; if (ixStart !== 0 || m[0].length !== pt.length) return m[0]; const originalValue = Renderer.applyProperties(m.last().tagAmount, objOriginal); return `{@help ${m.last().tagAmount}|In the original recipe: ${originalValue}}`; })) .join(""); } // region Custom hash ID packing/unpacking static getCustomHashId (it) { if (!it._scaleFactor) return null; const { name, source, _scaleFactor: scaleFactor, } = it; return [ name, source, scaleFactor ?? "", ].join("__").toLowerCase(); } static getUnpackedCustomHashId (customHashId) { if (!customHashId) return null; const [, , scaleFactor] = customHashId.split("__").map(it => it.trim()); if (!scaleFactor) return null; return { _scaleFactor: scaleFactor ? Number(scaleFactor) : null, customHashId, }; } // endregion static async pGetModifiedRecipe (ent, customHashId) { if (!customHashId) return ent; const {_scaleFactor} = Renderer.recipe.getUnpackedCustomHashId(customHashId); if (_scaleFactor == null) return ent; return Renderer.recipe.getScaledRecipe(ent, _scaleFactor); } }; Renderer.card = class { static getFullEntries (ent) { const entries = [...ent.entries || []]; if (ent.suit && (ent.valueName || ent.value)) { const suitAndValue = `${((ent.valueName || "") || Parser.numberToText(ent.value)).toTitleCase()} of ${ent.suit.toTitleCase()}`; if (suitAndValue.toLowerCase() !== ent.name.toLowerCase()) entries.unshift(`{@i ${suitAndValue}}`); } return entries; } static getCompactRenderedString (ent) { const fullEntries = Renderer.card.getFullEntries(ent); return ` ${Renderer.utils.getNameTr(ent)} ${Renderer.get().setFirstSection(true).render({...ent.face, maxHeight: 40, maxHeightUnits: "vh"})} ${fullEntries?.length ? `
    ${Renderer.get().setFirstSection(true).render({type: "entries", entries: fullEntries}, 1)}` : ""} `; } }; Renderer.deck = class { static getCompactRenderedString (ent) { const lstCards = { name: "Cards", entries: [ { type: "list", columns: 3, items: ent.cards.map(card => `{@card ${card.name}|${card.set}|${card.source}}`), }, ], }; return ` ${Renderer.utils.getNameTr(ent)} ${Renderer.get().setFirstSection(true).render({type: "entries", entries: ent.entries}, 1)}
    ${Renderer.get().setFirstSection(true).render(lstCards, 1)} `; } }; Renderer.skill = class { static getCompactRenderedString (ent) { return Renderer.generic.getCompactRenderedString(ent); } }; Renderer.sense = class { static getCompactRenderedString (ent) { return Renderer.generic.getCompactRenderedString(ent); } }; Renderer.itemMastery = class { static getCompactRenderedString (ent) { return Renderer.generic.getCompactRenderedString(ent); } }; Renderer.generic = class { /** * @param ent * @param [opts] * @param [opts.isSkipNameRow] * @param [opts.isSkipPageRow] * @param [opts.dataProp] * @param [opts.page] */ static getCompactRenderedString (ent, opts) { opts = opts || {}; const prerequisite = Renderer.utils.prerequisite.getHtml(ent.prerequisite); return ` ${opts.dataProp && opts.page ? Renderer.utils.getExcludedTr({entity: ent, dataProp: opts.dataProp, page: opts.page}) : ""} ${opts.isSkipNameRow ? "" : Renderer.utils.getNameTr(ent, {page: opts.page})} ${prerequisite ? `

    ${prerequisite}

    ` : ""} ${Renderer.get().setFirstSection(true).render({entries: ent.entries})} ${opts.isSkipPageRow ? "" : Renderer.utils.getPageTr(ent)}`; } /* -------------------------------------------- */ // region Mirror the schema static FEATURE__SKILLS_ALL = Object.keys(Parser.SKILL_TO_ATB_ABV).sort(SortUtil.ascSortLower); static FEATURE__TOOLS_ARTISANS = [ "alchemist's supplies", "brewer's supplies", "calligrapher's supplies", "carpenter's tools", "cartographer's tools", "cobbler's tools", "cook's utensils", "glassblower's tools", "jeweler's tools", "leatherworker's tools", "mason's tools", "painter's supplies", "potter's tools", "smith's tools", "tinker's tools", "weaver's tools", "woodcarver's tools", ]; static FEATURE__TOOLS_MUSICAL_INSTRUMENTS = [ "bagpipes", "drum", "dulcimer", "flute", "horn", "lute", "lyre", "pan flute", "shawm", "viol", ]; static FEATURE__TOOLS_ALL = [ "artisan's tools", ...this.FEATURE__TOOLS_ARTISANS, ...this.FEATURE__TOOLS_MUSICAL_INSTRUMENTS, "disguise kit", "forgery kit", "gaming set", "herbalism kit", "musical instrument", "navigator's tools", "thieves' tools", "poisoner's kit", "vehicles (land)", "vehicles (water)", "vehicles (air)", "vehicles (space)", ]; static FEATURE__LANGUAGES_ALL = Parser.LANGUAGES_ALL.map(it => it.toLowerCase()); static FEATURE__LANGUAGES_STANDARD__CHOICE_OBJ = { from: [ ...Parser.LANGUAGES_STANDARD .map(it => ({ name: it.toLowerCase(), prop: "languageProficiencies", group: "languagesStandard", })), ...Parser.LANGUAGES_EXOTIC .map(it => ({ name: it.toLowerCase(), prop: "languageProficiencies", group: "languagesExotic", })), ...Parser.LANGUAGES_SECRET .map(it => ({ name: it.toLowerCase(), prop: "languageProficiencies", group: "languagesSecret", })), ], groups: { languagesStandard: { name: "Standard Languages", }, languagesExotic: { name: "Exotic Languages", hint: "With your DM's permission, you can choose an exotic language.", }, languagesSecret: { name: "Secret Languages", hint: "With your DM's permission, you can choose a secret language.", }, }, }; static FEATURE__SAVING_THROWS_ALL = [...Parser.ABIL_ABVS]; // endregion /* -------------------------------------------- */ // region Should mirror the schema static _SKILL_TOOL_LANGUAGE_KEYS__SKILL_ANY = new Set(["anySkill"]); static _SKILL_TOOL_LANGUAGE_KEYS__TOOL_ANY = new Set(["anyTool", "anyArtisansTool"]); static _SKILL_TOOL_LANGUAGE_KEYS__LANGAUGE_ANY = new Set(["anyLanguage", "anyStandardLanguage", "anyExoticLanguage"]); // endregion static getSkillSummary ({skillProfs, skillToolLanguageProfs, isShort = false}) { return this._summariseProfs({ profGroupArr: skillProfs, skillToolLanguageProfs, setValid: new Set(this.FEATURE__SKILLS_ALL), setValidAny: this._SKILL_TOOL_LANGUAGE_KEYS__SKILL_ANY, isShort, hoverTag: "skill", }); } static getToolSummary ({toolProfs, skillToolLanguageProfs, isShort = false}) { return this._summariseProfs({ profGroupArr: toolProfs, skillToolLanguageProfs, setValid: new Set(this.FEATURE__TOOLS_ALL), setValidAny: this._SKILL_TOOL_LANGUAGE_KEYS__TOOL_ANY, isShort, }); } static getLanguageSummary ({languageProfs, skillToolLanguageProfs, isShort = false}) { return this._summariseProfs({ profGroupArr: languageProfs, skillToolLanguageProfs, setValid: new Set(this.FEATURE__LANGUAGES_ALL), setValidAny: this._SKILL_TOOL_LANGUAGE_KEYS__LANGAUGE_ANY, isShort, }); } static _summariseProfs ({profGroupArr, skillToolLanguageProfs, setValid, setValidAny, isShort, hoverTag}) { if (!profGroupArr?.length && !skillToolLanguageProfs?.length) return {summary: "", collection: []}; const collectionSet = new Set(); const handleProfGroup = (profGroup, {isValidate = true} = {}) => { let sep = ", "; const toJoin = Object.entries(profGroup) .sort(([kA], [kB]) => this._summariseProfs_sortKeys(kA, kB)) .filter(([k, v]) => v && (!isValidate || setValid.has(k) || setValidAny.has(k))) .map(([k, v], i) => { const vMapped = this.getMappedAnyProficiency({keyAny: k, countRaw: v}) ?? v; if (k === "choose") { sep = "; "; const chooseProfs = vMapped.from .filter(s => !isValidate || setValid.has(s)) .map(s => { collectionSet.add(s); return this._summariseProfs_getEntry({str: s, isShort, hoverTag}); }); return `${isShort ? `${i === 0 ? "C" : "c"}hoose ` : ""}${v.count || 1} ${isShort ? `of` : `from`} ${chooseProfs.joinConjunct(", ", " or ")}`; } collectionSet.add(k); return this._summariseProfs_getEntry({str: k, isShort, hoverTag}); }); return toJoin.join(sep); }; const summary = [ ...(profGroupArr || []) // Skip validation (i.e. allow homebrew/etc.) for the specific proficiency array .map(profGroup => handleProfGroup(profGroup, {isValidate: false})), ...(skillToolLanguageProfs || []) .map(profGroup => handleProfGroup(profGroup)), ] .filter(Boolean) .join(` or `); return {summary, collection: [...collectionSet].sort(SortUtil.ascSortLower)}; } static _summariseProfs_sortKeys (a, b, {setValidAny = null} = {}) { if (a === b) return 0; if (a === "choose") return 2; if (b === "choose") return -2; if (setValidAny) { if (setValidAny.has(a)) return 1; if (setValidAny.has(b)) return -1; } return SortUtil.ascSort(a, b); } static _summariseProfs_getEntry ({str, isShort, hoverTag}) { return isShort ? str.toTitleCase() : hoverTag ? `{@${hoverTag} ${str.toTitleCase()}}` : str.toTitleCase(); } /* -------------------------------------------- */ static getMappedAnyProficiency ({keyAny, countRaw}) { const mappedCount = !isNaN(countRaw) ? Number(countRaw) : 1; if (mappedCount <= 0) return null; switch (keyAny) { case "anySkill": return { name: mappedCount === 1 ? `Any Skill` : `Any ${mappedCount} Skills`, from: this.FEATURE__SKILLS_ALL .map(it => ({name: it, prop: "skillProficiencies"})), count: mappedCount, }; case "anyTool": return { name: mappedCount === 1 ? `Any Tool` : `Any ${mappedCount} Tools`, from: this.FEATURE__TOOLS_ALL .map(it => ({name: it, prop: "toolProficiencies"})), count: mappedCount, }; case "anyArtisansTool": return { name: mappedCount === 1 ? `Any Artisan's Tool` : `Any ${mappedCount} Artisan's Tools`, from: this.FEATURE__TOOLS_ARTISANS .map(it => ({name: it, prop: "toolProficiencies"})), count: mappedCount, }; case "anyMusicalInstrument": return { name: mappedCount === 1 ? `Any Musical Instrument` : `Any ${mappedCount} Musical Instruments`, from: this.FEATURE__TOOLS_MUSICAL_INSTRUMENTS .map(it => ({name: it, prop: "toolProficiencies"})), count: mappedCount, }; case "anyLanguage": return { name: mappedCount === 1 ? `Any Language` : `Any ${mappedCount} Languages`, from: this.FEATURE__LANGUAGES_ALL .map(it => ({name: it, prop: "languageProficiencies"})), count: mappedCount, }; case "anyStandardLanguage": return { name: mappedCount === 1 ? `Any Standard Language` : `Any ${mappedCount} Standard Languages`, ...MiscUtil.copyFast(this.FEATURE__LANGUAGES_STANDARD__CHOICE_OBJ), // Use a generic choice object, as rules state DM can allow choosing any count: mappedCount, }; case "anyExoticLanguage": return { name: mappedCount === 1 ? `Any Exotic Language` : `Any ${mappedCount} Exotic Languages`, ...MiscUtil.copyFast(this.FEATURE__LANGUAGES_STANDARD__CHOICE_OBJ), // Use a generic choice object, as rules state DM can allow choosing any count: mappedCount, }; case "anySavingThrow": return { name: mappedCount === 1 ? `Any Saving Throw` : `Any ${mappedCount} Saving Throws`, from: this.FEATURE__SAVING_THROWS_ALL .map(it => ({name: it, prop: "savingThrowProficiencies"})), count: mappedCount, }; case "anyWeapon": throw new Error(`Property handling for "anyWeapon" is unimplemented!`); case "anyArmor": throw new Error(`Property handling for "anyArmor" is unimplemented!`); default: return null; } } }; Renderer.hover = { LinkMeta: function () { this.isHovered = false; this.isLoading = false; this.isPermanent = false; this.windowMeta = null; }, _BAR_HEIGHT: 16, _linkCache: {}, _eleCache: new Map(), _entryCache: {}, _isInit: false, _dmScreen: null, _lastId: 0, _contextMenu: null, _contextMenuLastClicked: null, bindDmScreen (screen) { this._dmScreen = screen; }, _getNextId () { return ++Renderer.hover._lastId; }, _doInit () { if (!Renderer.hover._isInit) { Renderer.hover._isInit = true; $(document.body).on("click", () => Renderer.hover.cleanTempWindows()); Renderer.hover._contextMenu = ContextUtil.getMenu([ new ContextUtil.Action( "Maximize All", () => { const $permWindows = $(`.hoverborder[data-perm="true"]`); $permWindows.attr("data-display-title", "false"); }, ), new ContextUtil.Action( "Minimize All", () => { const $permWindows = $(`.hoverborder[data-perm="true"]`); $permWindows.attr("data-display-title", "true"); }, ), null, new ContextUtil.Action( "Close Others", () => { const hoverId = Renderer.hover._contextMenuLastClicked?.hoverId; Renderer.hover._doCloseAllWindows({hoverIdBlocklist: new Set([hoverId])}); }, ), new ContextUtil.Action( "Close All", () => Renderer.hover._doCloseAllWindows(), ), ]); } }, cleanTempWindows () { for (const [key, meta] of Renderer.hover._eleCache.entries()) { // If this is an element-less "permanent" show which has been closed if (!meta.isPermanent && meta.windowMeta && typeof key === "number") { meta.windowMeta.doClose(); Renderer.hover._eleCache.delete(key); return; } if (!meta.isPermanent && meta.windowMeta && !document.body.contains(key)) { meta.windowMeta.doClose(); return; } if (!meta.isPermanent && meta.isHovered && meta.windowMeta) { // Check if any elements have failed to clear their hovering status on mouse move const bounds = key.getBoundingClientRect(); if (EventUtil._mouseX < bounds.x || EventUtil._mouseY < bounds.y || EventUtil._mouseX > bounds.x + bounds.width || EventUtil._mouseY > bounds.y + bounds.height) { meta.windowMeta.doClose(); } } } }, _doCloseAllWindows ({hoverIdBlocklist = null} = {}) { Object.entries(Renderer.hover._WINDOW_METAS) .filter(([hoverId, meta]) => hoverIdBlocklist == null || !hoverIdBlocklist.has(Number(hoverId))) .forEach(([, meta]) => meta.doClose()); }, _getSetMeta (ele) { if (!Renderer.hover._eleCache.has(ele)) Renderer.hover._eleCache.set(ele, new Renderer.hover.LinkMeta()); return Renderer.hover._eleCache.get(ele); }, _handleGenericMouseOverStart ({evt, ele}) { // Don't open on small screens unless forced if (Renderer.hover.isSmallScreen(evt) && !evt.shiftKey) return; Renderer.hover.cleanTempWindows(); const meta = Renderer.hover._getSetMeta(ele); if (meta.isHovered || meta.isLoading) return; // Another hover is already in progress // Set the cursor to a waiting spinner ele.style.cursor = "progress"; meta.isHovered = true; meta.isLoading = true; meta.isPermanent = evt.shiftKey; return meta; }, _doPredefinedShowStart ({entryId}) { Renderer.hover.cleanTempWindows(); const meta = Renderer.hover._getSetMeta(entryId); meta.isPermanent = true; return meta; }, // (Baked into render strings) async pHandleLinkMouseOver (evt, ele, opts) { Renderer.hover._doInit(); let page, source, hash, preloadId, customHashId, isFauxPage; if (opts) { page = opts.page; source = opts.source; hash = opts.hash; preloadId = opts.preloadId; customHashId = opts.customHashId; isFauxPage = !!opts.isFauxPage; } else { page = ele.dataset.vetPage; source = ele.dataset.vetSource; hash = ele.dataset.vetHash; preloadId = ele.dataset.vetPreloadId; isFauxPage = ele.dataset.vetIsFauxPage; } let meta = Renderer.hover._handleGenericMouseOverStart({evt, ele}); if (meta == null) return; if ((EventUtil.isCtrlMetaKey(evt)) && Renderer.hover._pageToFluffFn(page)) meta.isFluff = true; let toRender; if (preloadId != null) { // FIXME(Future) remove in favor of `customHashId` switch (page) { case UrlUtil.PG_BESTIARY: { const {_scaledCr: scaledCr, _scaledSpellSummonLevel: scaledSpellSummonLevel, _scaledClassSummonLevel: scaledClassSummonLevel} = Renderer.monster.getUnpackedCustomHashId(preloadId); const baseMon = await DataLoader.pCacheAndGet(page, source, hash); if (scaledCr != null) { toRender = await ScaleCreature.scale(baseMon, scaledCr); } else if (scaledSpellSummonLevel != null) { toRender = await ScaleSpellSummonedCreature.scale(baseMon, scaledSpellSummonLevel); } else if (scaledClassSummonLevel != null) { toRender = await ScaleClassSummonedCreature.scale(baseMon, scaledClassSummonLevel); } break; } } } else if (customHashId) { toRender = await DataLoader.pCacheAndGet(page, source, hash); toRender = await Renderer.hover.pApplyCustomHashId(page, toRender, customHashId); } else { if (meta.isFluff) toRender = await Renderer.hover.pGetHoverableFluff(page, source, hash); else toRender = await DataLoader.pCacheAndGet(page, source, hash); } meta.isLoading = false; if (opts?.isDelay) { meta.isDelayed = true; ele.style.cursor = "help"; await MiscUtil.pDelay(1100); meta.isDelayed = false; } // Reset cursor ele.style.cursor = ""; // Check if we're still hovering the entity if (!meta || (!meta.isHovered && !meta.isPermanent)) return; const tmpEvt = meta._tmpEvt; delete meta._tmpEvt; // TODO(Future) avoid rendering e.g. creature scaling controls if `win?._IS_POPOUT` const win = (evt.view || {}).window; const $content = meta.isFluff ? Renderer.hover.$getHoverContent_fluff(page, toRender) : Renderer.hover.$getHoverContent_stats(page, toRender); // FIXME(Future) replace this with something maintainable const compactReferenceData = { page, source, hash, }; if (meta.windowMeta && !meta.isPermanent) { meta.windowMeta.doClose(); meta.windowMeta = null; } meta.windowMeta = Renderer.hover.getShowWindow( $content, Renderer.hover.getWindowPositionFromEvent(tmpEvt || evt, {isPreventFlicker: !meta.isPermanent}), { title: toRender ? toRender.name : "", isPermanent: meta.isPermanent, pageUrl: isFauxPage ? null : `${Renderer.get().baseUrl}${page}#${hash}`, cbClose: () => meta.isHovered = meta.isPermanent = meta.isLoading = meta.isFluff = false, isBookContent: page === UrlUtil.PG_RECIPES, compactReferenceData, sourceData: toRender, }, ); if (!meta.isFluff && !win?._IS_POPOUT) { const fnBind = Renderer.hover.getFnBindListenersCompact(page); if (fnBind) fnBind(toRender, $content); } }, // (Baked into render strings) handleInlineMouseOver (evt, ele, entry, opts) { Renderer.hover._doInit(); entry = entry || JSON.parse(ele.dataset.vetEntry); let meta = Renderer.hover._handleGenericMouseOverStart({evt, ele}); if (meta == null) return; meta.isLoading = false; // Reset cursor ele.style.cursor = ""; // Check if we're still hovering the entity if (!meta || (!meta.isHovered && !meta.isPermanent)) return; const tmpEvt = meta._tmpEvt; delete meta._tmpEvt; const win = (evt.view || {}).window; const $content = Renderer.hover.$getHoverContent_generic(entry, opts); if (meta.windowMeta && !meta.isPermanent) { meta.windowMeta.doClose(); meta.windowMeta = null; } meta.windowMeta = Renderer.hover.getShowWindow( $content, Renderer.hover.getWindowPositionFromEvent(tmpEvt || evt, {isPreventFlicker: !meta.isPermanent}), { title: entry?.name || "", isPermanent: meta.isPermanent, pageUrl: null, cbClose: () => meta.isHovered = meta.isPermanent = meta.isLoading = false, isBookContent: true, sourceData: entry, }, ); }, async pGetHoverableFluff (page, source, hash, opts) { // Try to fetch the fluff directly let toRender = await DataLoader.pCacheAndGet(`${page}Fluff`, source, hash, opts); if (!toRender) { // Fall back on fluff attached to the object itself const entity = await DataLoader.pCacheAndGet(page, source, hash, opts); const pFnGetFluff = Renderer.hover._pageToFluffFn(page); if (!pFnGetFluff && opts?.isSilent) return null; toRender = await pFnGetFluff(entity); } if (!toRender) return toRender; // For inline homebrew fluff, populate the name/source if (toRender && (!toRender.name || !toRender.source)) { const toRenderParent = await DataLoader.pCacheAndGet(page, source, hash, opts); toRender = MiscUtil.copyFast(toRender); toRender.name = toRenderParent.name; toRender.source = toRenderParent.source; } return toRender; }, // (Baked into render strings) handleLinkMouseLeave (evt, ele) { const meta = Renderer.hover._eleCache.get(ele); ele.style.cursor = ""; if (!meta || meta.isPermanent) return; if (evt.shiftKey) { meta.isPermanent = true; meta.windowMeta.setIsPermanent(true); return; } meta.isHovered = false; if (meta.windowMeta) { meta.windowMeta.doClose(); meta.windowMeta = null; } }, // (Baked into render strings) handleLinkMouseMove (evt, ele) { const meta = Renderer.hover._eleCache.get(ele); if (!meta || meta.isPermanent) return; // If loading has finished, but we're not displaying the element yet (e.g. because it has been delayed) if (meta.isDelayed) { meta._tmpEvt = evt; return; } if (!meta.windowMeta) return; meta.windowMeta.setPosition(Renderer.hover.getWindowPositionFromEvent(evt, {isPreventFlicker: !evt.shiftKey && !meta.isPermanent})); if (evt.shiftKey && !meta.isPermanent) { meta.isPermanent = true; meta.windowMeta.setIsPermanent(true); } }, /** * (Baked into render strings) * @param evt * @param ele * @param entryId * @param [opts] * @param [opts.isBookContent] * @param [opts.isLargeBookContent] */ handlePredefinedMouseOver (evt, ele, entryId, opts) { opts = opts || {}; const meta = Renderer.hover._handleGenericMouseOverStart({evt, ele}); if (meta == null) return; Renderer.hover.cleanTempWindows(); const toRender = Renderer.hover._entryCache[entryId]; meta.isLoading = false; // Check if we're still hovering the entity if (!meta.isHovered && !meta.isPermanent) return; const $content = Renderer.hover.$getHoverContent_generic(toRender, opts); meta.windowMeta = Renderer.hover.getShowWindow( $content, Renderer.hover.getWindowPositionFromEvent(evt, {isPreventFlicker: !meta.isPermanent}), { title: toRender.data && toRender.data.hoverTitle != null ? toRender.data.hoverTitle : toRender.name, isPermanent: meta.isPermanent, cbClose: () => meta.isHovered = meta.isPermanent = meta.isLoading = false, sourceData: toRender, }, ); // Reset cursor ele.style.cursor = ""; }, doPredefinedShow (entryId, opts) { opts = opts || {}; const meta = Renderer.hover._doPredefinedShowStart({entryId}); if (meta == null) return; Renderer.hover.cleanTempWindows(); const toRender = Renderer.hover._entryCache[entryId]; const $content = Renderer.hover.$getHoverContent_generic(toRender, opts); meta.windowMeta = Renderer.hover.getShowWindow( $content, Renderer.hover.getWindowPositionExact((window.innerWidth / 2) - (Renderer.hover._DEFAULT_WIDTH_PX / 2), 100), { title: toRender.data && toRender.data.hoverTitle != null ? toRender.data.hoverTitle : toRender.name, isPermanent: meta.isPermanent, cbClose: () => meta.isHovered = meta.isPermanent = meta.isLoading = false, sourceData: toRender, }, ); }, // (Baked into render strings) handlePredefinedMouseLeave (evt, ele) { return Renderer.hover.handleLinkMouseLeave(evt, ele); }, // (Baked into render strings) handlePredefinedMouseMove (evt, ele) { return Renderer.hover.handleLinkMouseMove(evt, ele); }, _WINDOW_POSITION_PROPS_FROM_EVENT: [ "isFromBottom", "isFromRight", "clientX", "window", "isPreventFlicker", "bcr", ], getWindowPositionFromEvent (evt, {isPreventFlicker = false} = {}) { const ele = evt.target; const win = evt?.view?.window || window; const bcr = ele.getBoundingClientRect().toJSON(); const isFromBottom = bcr.top > win.innerHeight / 2; const isFromRight = bcr.left > win.innerWidth / 2; return { mode: "autoFromElement", isFromBottom, isFromRight, clientX: EventUtil.getClientX(evt), window: win, isPreventFlicker, bcr, }; }, getWindowPositionExact (x, y, evt = null) { return { window: evt?.view?.window || window, mode: "exact", x, y, }; }, getWindowPositionExactVisibleBottom (x, y, evt = null) { return { ...Renderer.hover.getWindowPositionExact(x, y, evt), mode: "exactVisibleBottom", }; }, _WINDOW_METAS: {}, MIN_Z_INDEX: 200, _MAX_Z_INDEX: 300, _DEFAULT_WIDTH_PX: 600, _BODY_SCROLLER_WIDTH_PX: 15, _getZIndex () { const zIndices = Object.values(Renderer.hover._WINDOW_METAS).map(it => it.zIndex); if (!zIndices.length) return Renderer.hover.MIN_Z_INDEX; return Math.max(...zIndices); }, _getNextZIndex (hoverId) { const cur = Renderer.hover._getZIndex(); // If we're already the highest index, continue to use this index if (hoverId != null && Renderer.hover._WINDOW_METAS[hoverId].zIndex === cur) return cur; // otherwise, go one higher const out = cur + 1; // If we've broken through the max z-index, try to free up some z-indices if (out > Renderer.hover._MAX_Z_INDEX) { const sortedWindowMetas = Object.entries(Renderer.hover._WINDOW_METAS) .sort(([kA, vA], [kB, vB]) => SortUtil.ascSort(vA.zIndex, vB.zIndex)); if (sortedWindowMetas.length >= (Renderer.hover._MAX_Z_INDEX - Renderer.hover.MIN_Z_INDEX)) { // If we have too many window open, collapse them into one z-index sortedWindowMetas.forEach(([k, v]) => { v.setZIndex(Renderer.hover.MIN_Z_INDEX); }); } else { // Otherwise, ensure one consistent run from min to max z-index sortedWindowMetas.forEach(([k, v], i) => { v.setZIndex(Renderer.hover.MIN_Z_INDEX + i); }); } return Renderer.hover._getNextZIndex(hoverId); } else return out; }, _isIntersectRect (r1, r2) { return r1.left <= r2.right && r2.left <= r1.right && r1.top <= r2.bottom && r2.top <= r1.bottom; }, /** * @param $content Content to append to the window. * @param position The position of the window. Can be specified in various formats. * @param [opts] Options object. * @param [opts.isPermanent] If the window should have the expanded toolbar of a "permanent" window. * @param [opts.title] The window title. * @param [opts.isBookContent] If the hover window contains book content. Affects the styling of borders. * @param [opts.pageUrl] A page URL which is navigable via a button in the window header * @param [opts.cbClose] Callback to run on window close. * @param [opts.width] An initial width for the window. * @param [opts.height] An initial height fot the window. * @param [opts.$pFnGetPopoutContent] A function which loads content for this window when it is popped out. * @param [opts.fnGetPopoutSize] A function which gets a `{width: ..., height: ...}` object with dimensions for a * popout window. * @param [opts.isPopout] If the window should be immediately popped out. * @param [opts.compactReferenceData] Reference (e.g. page/source/hash/others) which can be used to load the contents into the DM screen. * @param [opts.sourceData] Source JSON (as raw as possible) used to construct this popout. */ getShowWindow ($content, position, opts) { opts = opts || {}; Renderer.hover._doInit(); const initialWidth = opts.width == null ? Renderer.hover._DEFAULT_WIDTH_PX : opts.width; const initialZIndex = Renderer.hover._getNextZIndex(); const $body = $(position.window.document.body); const $hov = $(`
    `) .css({ "right": -initialWidth, "width": initialWidth, "zIndex": initialZIndex, }); const $wrpContent = $(`
    `); if (opts.height != null) $wrpContent.css("height", opts.height); const $hovTitle = $(`${opts.title || ""}`); const hoverWindow = {}; const hoverId = Renderer.hover._getNextId(); Renderer.hover._WINDOW_METAS[hoverId] = hoverWindow; const mouseUpId = `mouseup.${hoverId} touchend.${hoverId}`; const mouseMoveId = `mousemove.${hoverId} touchmove.${hoverId}`; const resizeId = `resize.${hoverId}`; const drag = {}; const $brdrTopRightResize = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 1})); const $brdrRightResize = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 2})); const $brdrBottomRightResize = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 3})); const $brdrBtm = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 4})); const $brdrBtmLeftResize = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 5})); const $brdrLeftResize = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 6})); const $brdrTopLeftResize = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 7})); const $brdrTopResize = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 8})); const $brdrTop = $(`
    `) .on("mousedown touchstart", (evt) => Renderer.hover._getShowWindow_handleDragMousedown({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type: 9})) .on("contextmenu", (evt) => { Renderer.hover._contextMenuLastClicked = { hoverId, }; ContextUtil.pOpenMenu(evt, Renderer.hover._contextMenu); }); $(position.window.document) .on(mouseUpId, (evt) => { if (drag.type) { if (drag.type < 9) { $wrpContent.css("max-height", ""); $hov.css("max-width", ""); } Renderer.hover._getShowWindow_adjustPosition({$hov, $wrpContent, position}); if (drag.type === 9) { // handle mobile button touches if (EventUtil.isUsingTouch() && evt.target.classList.contains("hwin__top-border-icon")) { evt.preventDefault(); drag.type = 0; $(evt.target).click(); return; } // handle DM screen integration if (this._dmScreen && opts.compactReferenceData) { const panel = this._dmScreen.getPanelPx(EventUtil.getClientX(evt), EventUtil.getClientY(evt)); if (!panel) return; this._dmScreen.setHoveringPanel(panel); const target = panel.getAddButtonPos(); if (Renderer.hover._getShowWindow_isOverHoverTarget({evt, target})) { panel.doPopulate_Stats(opts.compactReferenceData.page, opts.compactReferenceData.source, opts.compactReferenceData.hash); Renderer.hover._getShowWindow_doClose({$hov, position, mouseUpId, mouseMoveId, resizeId, hoverId, opts, hoverWindow}); } this._dmScreen.resetHoveringButton(); } } drag.type = 0; } }) .on(mouseMoveId, (evt) => { const args = {$wrpContent, $hov, drag, evt}; switch (drag.type) { case 1: Renderer.hover._getShowWindow_handleNorthDrag(args); Renderer.hover._getShowWindow_handleEastDrag(args); break; case 2: Renderer.hover._getShowWindow_handleEastDrag(args); break; case 3: Renderer.hover._getShowWindow_handleSouthDrag(args); Renderer.hover._getShowWindow_handleEastDrag(args); break; case 4: Renderer.hover._getShowWindow_handleSouthDrag(args); break; case 5: Renderer.hover._getShowWindow_handleSouthDrag(args); Renderer.hover._getShowWindow_handleWestDrag(args); break; case 6: Renderer.hover._getShowWindow_handleWestDrag(args); break; case 7: Renderer.hover._getShowWindow_handleNorthDrag(args); Renderer.hover._getShowWindow_handleWestDrag(args); break; case 8: Renderer.hover._getShowWindow_handleNorthDrag(args); break; case 9: { const diffX = drag.startX - EventUtil.getClientX(evt); const diffY = drag.startY - EventUtil.getClientY(evt); $hov.css("left", drag.baseLeft - diffX) .css("top", drag.baseTop - diffY); drag.startX = EventUtil.getClientX(evt); drag.startY = EventUtil.getClientY(evt); drag.baseTop = parseFloat($hov.css("top")); drag.baseLeft = parseFloat($hov.css("left")); // handle DM screen integration if (this._dmScreen) { const panel = this._dmScreen.getPanelPx(EventUtil.getClientX(evt), EventUtil.getClientY(evt)); if (!panel) return; this._dmScreen.setHoveringPanel(panel); const target = panel.getAddButtonPos(); if (Renderer.hover._getShowWindow_isOverHoverTarget({evt, target})) this._dmScreen.setHoveringButton(panel); else this._dmScreen.resetHoveringButton(); } break; } } }); $(position.window).on(resizeId, () => Renderer.hover._getShowWindow_adjustPosition({$hov, $wrpContent, position})); $brdrTop.attr("data-display-title", false); $brdrTop.on("dblclick", () => Renderer.hover._getShowWindow_doToggleMinimizedMaximized({$brdrTop, $hov})); $brdrTop.append($hovTitle); const $brdTopRhs = $(`
    `).appendTo($brdrTop); if (opts.pageUrl && !position.window._IS_POPOUT && !Renderer.get().isInternalLinksDisabled()) { const $btnGotoPage = $(``) .appendTo($brdTopRhs); } if (!position.window._IS_POPOUT && !opts.isPopout) { const $btnPopout = $(``) .on("click", evt => { evt.stopPropagation(); return Renderer.hover._getShowWindow_pDoPopout({$hov, position, mouseUpId, mouseMoveId, resizeId, hoverId, opts, hoverWindow, $content}, {evt}); }) .appendTo($brdTopRhs); } if (opts.sourceData) { const btnPopout = e_({ tag: "span", clazz: `hwin__top-border-icon hwin__top-border-icon--text`, title: "Show Source Data", text: "{}", click: evt => { evt.stopPropagation(); evt.preventDefault(); const $content = Renderer.hover.$getHoverContent_statsCode(opts.sourceData); Renderer.hover.getShowWindow( $content, Renderer.hover.getWindowPositionFromEvent(evt), { title: [opts.sourceData._displayName || opts.sourceData.name, "Source Data"].filter(Boolean).join(" \u2014 "), isPermanent: true, isBookContent: true, }, ); }, }); $brdTopRhs.append(btnPopout); } const $btnClose = $(``) .on("click", (evt) => { evt.stopPropagation(); if (EventUtil.isCtrlMetaKey(evt)) { Renderer.hover._doCloseAllWindows(); return; } Renderer.hover._getShowWindow_doClose({$hov, position, mouseUpId, mouseMoveId, resizeId, hoverId, opts, hoverWindow}); }).appendTo($brdTopRhs); $wrpContent.append($content); $hov.append($brdrTopResize).append($brdrTopRightResize).append($brdrRightResize).append($brdrBottomRightResize) .append($brdrBtmLeftResize).append($brdrLeftResize).append($brdrTopLeftResize) .append($brdrTop) .append($wrpContent) .append($brdrBtm); $body.append($hov); Renderer.hover._getShowWindow_setPosition({$hov, $wrpContent, position}, position); hoverWindow.$windowTitle = $hovTitle; hoverWindow.zIndex = initialZIndex; hoverWindow.setZIndex = Renderer.hover._getNextZIndex.bind(this, {$hov, hoverWindow}); hoverWindow.setPosition = Renderer.hover._getShowWindow_setPosition.bind(this, {$hov, $wrpContent, position}); hoverWindow.setIsPermanent = Renderer.hover._getShowWindow_setIsPermanent.bind(this, {opts, $brdrTop}); hoverWindow.doClose = Renderer.hover._getShowWindow_doClose.bind(this, {$hov, position, mouseUpId, mouseMoveId, resizeId, hoverId, opts, hoverWindow}); hoverWindow.doMaximize = Renderer.hover._getShowWindow_doMaximize.bind(this, {$brdrTop, $hov}); hoverWindow.doZIndexToFront = Renderer.hover._getShowWindow_doZIndexToFront.bind(this, {$hov, hoverWindow, hoverId}); if (opts.isPopout) Renderer.hover._getShowWindow_pDoPopout({$hov, position, mouseUpId, mouseMoveId, resizeId, hoverId, opts, hoverWindow, $content}); return hoverWindow; }, _getShowWindow_doClose ({$hov, position, mouseUpId, mouseMoveId, resizeId, hoverId, opts, hoverWindow}) { $hov.remove(); $(position.window.document).off(mouseUpId); $(position.window.document).off(mouseMoveId); $(position.window).off(resizeId); delete Renderer.hover._WINDOW_METAS[hoverId]; if (opts.cbClose) opts.cbClose(hoverWindow); }, _getShowWindow_handleDragMousedown ({hoverWindow, hoverId, $hov, drag, $wrpContent}, {evt, type}) { if (evt.which === 0 || evt.which === 1) evt.preventDefault(); hoverWindow.zIndex = Renderer.hover._getNextZIndex(hoverId); $hov.css({ "z-index": hoverWindow.zIndex, "animation": "initial", }); drag.type = type; drag.startX = EventUtil.getClientX(evt); drag.startY = EventUtil.getClientY(evt); drag.baseTop = parseFloat($hov.css("top")); drag.baseLeft = parseFloat($hov.css("left")); drag.baseHeight = $wrpContent.height(); drag.baseWidth = parseFloat($hov.css("width")); if (type < 9) { $wrpContent.css({ "height": drag.baseHeight, "max-height": "initial", }); $hov.css("max-width", "initial"); } }, _getShowWindow_isOverHoverTarget ({evt, target}) { return EventUtil.getClientX(evt) >= target.left && EventUtil.getClientX(evt) <= target.left + target.width && EventUtil.getClientY(evt) >= target.top && EventUtil.getClientY(evt) <= target.top + target.height; }, _getShowWindow_handleNorthDrag ({$wrpContent, $hov, drag, evt}) { const diffY = Math.max(drag.startY - EventUtil.getClientY(evt), 80 - drag.baseHeight); // prevent <80 height, as this will cause the box to move downwards $wrpContent.css("height", drag.baseHeight + diffY); $hov.css("top", drag.baseTop - diffY); drag.startY = EventUtil.getClientY(evt); drag.baseHeight = $wrpContent.height(); drag.baseTop = parseFloat($hov.css("top")); }, _getShowWindow_handleEastDrag ({$wrpContent, $hov, drag, evt}) { const diffX = drag.startX - EventUtil.getClientX(evt); $hov.css("width", drag.baseWidth - diffX); drag.startX = EventUtil.getClientX(evt); drag.baseWidth = parseFloat($hov.css("width")); }, _getShowWindow_handleSouthDrag ({$wrpContent, $hov, drag, evt}) { const diffY = drag.startY - EventUtil.getClientY(evt); $wrpContent.css("height", drag.baseHeight - diffY); drag.startY = EventUtil.getClientY(evt); drag.baseHeight = $wrpContent.height(); }, _getShowWindow_handleWestDrag ({$wrpContent, $hov, drag, evt}) { const diffX = Math.max(drag.startX - EventUtil.getClientX(evt), 150 - drag.baseWidth); $hov.css("width", drag.baseWidth + diffX) .css("left", drag.baseLeft - diffX); drag.startX = EventUtil.getClientX(evt); drag.baseWidth = parseFloat($hov.css("width")); drag.baseLeft = parseFloat($hov.css("left")); }, _getShowWindow_doToggleMinimizedMaximized ({$brdrTop, $hov}) { const curState = $brdrTop.attr("data-display-title"); const isNextMinified = curState === "false"; $brdrTop.attr("data-display-title", isNextMinified); $brdrTop.attr("data-perm", true); $hov.toggleClass("hwin--minified", isNextMinified); }, _getShowWindow_doMaximize ({$brdrTop, $hov}) { $brdrTop.attr("data-display-title", false); $hov.toggleClass("hwin--minified", false); }, async _getShowWindow_pDoPopout ({$hov, position, mouseUpId, mouseMoveId, resizeId, hoverId, opts, hoverWindow, $content}, {evt} = {}) { const dimensions = opts.fnGetPopoutSize ? opts.fnGetPopoutSize() : {width: 600, height: $content.height()}; const win = window.open( "", opts.title || "", `width=${dimensions.width},height=${dimensions.height}location=0,menubar=0,status=0,titlebar=0,toolbar=0`, ); // If this is a new window, bootstrap general page elements/variables. // Otherwise, we can skip straight to using the window. if (!win._IS_POPOUT) { win._IS_POPOUT = true; win.document.write(` ${opts.title} ${$(`link[rel="stylesheet"][href]`).map((i, e) => e.outerHTML).get().join("\n")}
    `); win.Renderer = Renderer; let ticks = 50; while (!win.document.body && ticks-- > 0) await MiscUtil.pDelay(5); win.$wrpHoverContent = $(win.document).find(`.hoverbox--popout`); } let $cpyContent; if (opts.$pFnGetPopoutContent) { $cpyContent = await opts.$pFnGetPopoutContent(); } else { $cpyContent = $content.clone(true, true); } $cpyContent.appendTo(win.$wrpHoverContent.empty()); Renderer.hover._getShowWindow_doClose({$hov, position, mouseUpId, mouseMoveId, resizeId, hoverId, opts, hoverWindow}); }, _getShowWindow_setPosition ({$hov, $wrpContent, position}, positionNxt) { switch (positionNxt.mode) { case "autoFromElement": { const bcr = $hov[0].getBoundingClientRect(); if (positionNxt.isFromBottom) $hov.css("top", positionNxt.bcr.top - (bcr.height + 10)); else $hov.css("top", positionNxt.bcr.top + positionNxt.bcr.height + 10); if (positionNxt.isFromRight) $hov.css("left", (positionNxt.clientX || positionNxt.bcr.left) - (bcr.width + 10)); else $hov.css("left", (positionNxt.clientX || (positionNxt.bcr.left + positionNxt.bcr.width)) + 10); // region Sync position info when updating if (position !== positionNxt) { Renderer.hover._WINDOW_POSITION_PROPS_FROM_EVENT .forEach(prop => { position[prop] = positionNxt[prop]; }); } // endregion break; } case "exact": { $hov.css({ "left": positionNxt.x, "top": positionNxt.y, }); break; } case "exactVisibleBottom": { $hov.css({ "left": positionNxt.x, "top": positionNxt.y, "animation": "initial", // Briefly remove the animation so we can calculate the height }); let yPos = positionNxt.y; const {bottom: posBottom, height: winHeight} = $hov[0].getBoundingClientRect(); const height = position.window.innerHeight; if (posBottom > height) { yPos = position.window.innerHeight - winHeight; $hov.css({ "top": yPos, "animation": "", }); } break; } default: throw new Error(`Positiong mode unimplemented: "${positionNxt.mode}"`); } Renderer.hover._getShowWindow_adjustPosition({$hov, $wrpContent, position}); }, _getShowWindow_adjustPosition ({$hov, $wrpContent, position}) { const eleHov = $hov[0]; const wrpContent = $wrpContent[0]; const bcr = eleHov.getBoundingClientRect().toJSON(); const screenHeight = position.window.innerHeight; const screenWidth = position.window.innerWidth; // readjust position... // ...if vertically clipping off screen if (bcr.top < 0) { bcr.top = 0; bcr.bottom = bcr.top + bcr.height; eleHov.style.top = `${bcr.top}px`; } else if (bcr.top >= screenHeight - Renderer.hover._BAR_HEIGHT) { bcr.top = screenHeight - Renderer.hover._BAR_HEIGHT; bcr.bottom = bcr.top + bcr.height; eleHov.style.top = `${bcr.top}px`; } // ...if horizontally clipping off screen if (bcr.left < 0) { bcr.left = 0; bcr.right = bcr.left + bcr.width; eleHov.style.left = `${bcr.left}px`; } else if (bcr.left + bcr.width + Renderer.hover._BODY_SCROLLER_WIDTH_PX > screenWidth) { bcr.left = Math.max(screenWidth - bcr.width - Renderer.hover._BODY_SCROLLER_WIDTH_PX, 0); bcr.right = bcr.left + bcr.width; eleHov.style.left = `${bcr.left}px`; } // Prevent window "flickering" when hovering a link if ( position.isPreventFlicker && Renderer.hover._isIntersectRect(bcr, position.bcr) ) { if (position.isFromBottom) { bcr.height = position.bcr.top - 5; wrpContent.style.height = `${bcr.height}px`; } else { bcr.height = screenHeight - position.bcr.bottom - 5; wrpContent.style.height = `${bcr.height}px`; } } }, _getShowWindow_setIsPermanent ({opts, $brdrTop}, isPermanent) { opts.isPermanent = isPermanent; $brdrTop.attr("data-perm", isPermanent); }, _getShowWindow_setZIndex ({$hov, hoverWindow}, zIndex) { $hov.css("z-index", zIndex); hoverWindow.zIndex = zIndex; }, _getShowWindow_doZIndexToFront ({$hov, hoverWindow, hoverId}) { const nxtZIndex = Renderer.hover._getNextZIndex(hoverId); Renderer.hover._getNextZIndex({$hov, hoverWindow}, nxtZIndex); }, /** * @param entry * @param [opts] * @param [opts.isBookContent] * @param [opts.isLargeBookContent] * @param [opts.depth] * @param [opts.id] */ getMakePredefinedHover (entry, opts) { opts = opts || {}; const id = opts.id ?? Renderer.hover._getNextId(); Renderer.hover._entryCache[id] = entry; return { id, html: `onmouseover="Renderer.hover.handlePredefinedMouseOver(event, this, ${id}, ${JSON.stringify(opts).escapeQuotes()})" onmousemove="Renderer.hover.handlePredefinedMouseMove(event, this)" onmouseleave="Renderer.hover.handlePredefinedMouseLeave(event, this)" ${Renderer.hover.getPreventTouchString()}`, mouseOver: (evt, ele) => Renderer.hover.handlePredefinedMouseOver(evt, ele, id, opts), mouseMove: (evt, ele) => Renderer.hover.handlePredefinedMouseMove(evt, ele), mouseLeave: (evt, ele) => Renderer.hover.handlePredefinedMouseLeave(evt, ele), touchStart: (evt, ele) => Renderer.hover.handleTouchStart(evt, ele), show: () => Renderer.hover.doPredefinedShow(id, opts), }; }, updatePredefinedHover (id, entry) { Renderer.hover._entryCache[id] = entry; }, getInlineHover (entry, opts) { return { // Re-use link handlers, as the inline version is a simplified version html: `onmouseover="Renderer.hover.handleInlineMouseOver(event, this)" onmouseleave="Renderer.hover.handleLinkMouseLeave(event, this)" onmousemove="Renderer.hover.handleLinkMouseMove(event, this)" data-vet-entry="${JSON.stringify(entry).qq()}" ${opts ? `data-vet-opts="${JSON.stringify(opts).qq()}"` : ""} ${Renderer.hover.getPreventTouchString()}`, }; }, getPreventTouchString () { return `ontouchstart="Renderer.hover.handleTouchStart(event, this)"`; }, handleTouchStart (evt, ele) { // on large touchscreen devices only (e.g. iPads) if (!Renderer.hover.isSmallScreen(evt)) { // cache the link location and redirect it to void $(ele).data("href", $(ele).data("href") || $(ele).attr("href")); $(ele).attr("href", "javascript:void(0)"); // restore the location after 100ms; if the user long-presses the link will be restored by the time they // e.g. attempt to open a new tab setTimeout(() => { const data = $(ele).data("href"); if (data) { $(ele).attr("href", data); $(ele).data("href", null); } }, 100); } }, // region entry fetching getEntityLink ( ent, { displayText = null, prop = null, isLowerCase = false, isTitleCase = false, } = {}, ) { if (isLowerCase && isTitleCase) throw new Error(`"isLowerCase" and "isTitleCase" are mutually exclusive!`); const name = isLowerCase ? ent.name.toLowerCase() : isTitleCase ? ent.name.toTitleCase() : ent.name; let parts = [ name, ent.source, displayText || "", ]; switch (prop || ent.__prop) { case "monster": { if (ent._isScaledCr) { parts.push(`${VeCt.HASH_SCALED}=${Parser.numberToCr(ent._scaledCr)}`); } if (ent._isScaledSpellSummon) { parts.push(`${VeCt.HASH_SCALED_SPELL_SUMMON}=${ent._scaledSpellSummonLevel}`); } if (ent._isScaledClassSummon) { parts.push(`${VeCt.HASH_SCALED_CLASS_SUMMON}=${ent._scaledClassSummonLevel}`); } break; } // TODO recipe? case "deity": { parts.splice(1, 0, ent.pantheon); break; } } while (parts.length && !parts.last()?.length) parts.pop(); return Renderer.get().render(`{@${Parser.getPropTag(prop || ent.__prop)} ${parts.join("|")}}`); }, getRefMetaFromTag (str) { // convert e.g. `"{#itemEntry Ring of Resistance|DMG}"` // to `{type: "refItemEntry", "itemEntry": "Ring of Resistance|DMG"}` str = str.slice(2, -1); const [tag, ...refParts] = str.split(" "); const ref = refParts.join(" "); const type = `ref${tag.uppercaseFirst()}`; return {type, [tag]: ref}; }, // endregion // region Apply custom hash IDs async pApplyCustomHashId (page, ent, customHashId) { switch (page) { case UrlUtil.PG_BESTIARY: { const out = await Renderer.monster.pGetModifiedCreature(ent, customHashId); Renderer.monster.updateParsed(out); return out; } case UrlUtil.PG_RECIPES: return Renderer.recipe.pGetModifiedRecipe(ent, customHashId); default: return ent; } }, // endregion getGenericCompactRenderedString (entry, depth = 0) { return ` ${Renderer.get().setFirstSection(true).render(entry, depth)} `; }, getFnRenderCompact (page, {isStatic = false} = {}) { switch (page) { case "generic": case "hover": return Renderer.hover.getGenericCompactRenderedString; case UrlUtil.PG_QUICKREF: return Renderer.hover.getGenericCompactRenderedString; case UrlUtil.PG_CLASSES: return Renderer.class.getCompactRenderedString; case UrlUtil.PG_SPELLS: return Renderer.spell.getCompactRenderedString; case UrlUtil.PG_ITEMS: return Renderer.item.getCompactRenderedString; case UrlUtil.PG_BESTIARY: return it => Renderer.monster.getCompactRenderedString(it, {isShowScalers: !isStatic, isScaledCr: it._originalCr != null, isScaledSpellSummon: it._isScaledSpellSummon, isScaledClassSummon: it._isScaledClassSummon}); case UrlUtil.PG_CONDITIONS_DISEASES: return Renderer.condition.getCompactRenderedString; case UrlUtil.PG_BACKGROUNDS: return Renderer.background.getCompactRenderedString; case UrlUtil.PG_FEATS: return Renderer.feat.getCompactRenderedString; case UrlUtil.PG_OPT_FEATURES: return Renderer.optionalfeature.getCompactRenderedString; case UrlUtil.PG_PSIONICS: return Renderer.psionic.getCompactRenderedString; case UrlUtil.PG_REWARDS: return Renderer.reward.getCompactRenderedString; case UrlUtil.PG_RACES: return it => Renderer.race.getCompactRenderedString(it, {isStatic}); case UrlUtil.PG_DEITIES: return Renderer.deity.getCompactRenderedString; case UrlUtil.PG_OBJECTS: return Renderer.object.getCompactRenderedString; case UrlUtil.PG_TRAPS_HAZARDS: return Renderer.traphazard.getCompactRenderedString; case UrlUtil.PG_VARIANTRULES: return Renderer.variantrule.getCompactRenderedString; case UrlUtil.PG_CULTS_BOONS: return Renderer.cultboon.getCompactRenderedString; case UrlUtil.PG_TABLES: return Renderer.table.getCompactRenderedString; case UrlUtil.PG_VEHICLES: return Renderer.vehicle.getCompactRenderedString; case UrlUtil.PG_ACTIONS: return Renderer.action.getCompactRenderedString; case UrlUtil.PG_LANGUAGES: return Renderer.language.getCompactRenderedString; case UrlUtil.PG_CHAR_CREATION_OPTIONS: return Renderer.charoption.getCompactRenderedString; case UrlUtil.PG_RECIPES: return Renderer.recipe.getCompactRenderedString; case UrlUtil.PG_CLASS_SUBCLASS_FEATURES: return Renderer.hover.getGenericCompactRenderedString; case UrlUtil.PG_CREATURE_FEATURES: return Renderer.hover.getGenericCompactRenderedString; case UrlUtil.PG_DECKS: return Renderer.deck.getCompactRenderedString; // region props case "classfeature": case "classFeature": return Renderer.hover.getGenericCompactRenderedString; case "subclassfeature": case "subclassFeature": return Renderer.hover.getGenericCompactRenderedString; case "citation": return Renderer.hover.getGenericCompactRenderedString; // endregion default: if (Renderer[page]?.getCompactRenderedString) return Renderer[page].getCompactRenderedString; return null; } }, getFnBindListenersCompact (page) { switch (page) { case UrlUtil.PG_BESTIARY: return Renderer.monster.bindListenersCompact; case UrlUtil.PG_RACES: return Renderer.race.bindListenersCompact; default: return null; } }, _pageToFluffFn (page) { switch (page) { case UrlUtil.PG_BESTIARY: return Renderer.monster.pGetFluff; case UrlUtil.PG_ITEMS: return Renderer.item.pGetFluff; case UrlUtil.PG_CONDITIONS_DISEASES: return Renderer.condition.pGetFluff; case UrlUtil.PG_SPELLS: return Renderer.spell.pGetFluff; case UrlUtil.PG_RACES: return Renderer.race.pGetFluff; case UrlUtil.PG_BACKGROUNDS: return Renderer.background.pGetFluff; case UrlUtil.PG_FEATS: return Renderer.feat.pGetFluff; case UrlUtil.PG_LANGUAGES: return Renderer.language.pGetFluff; case UrlUtil.PG_VEHICLES: return Renderer.vehicle.pGetFluff; case UrlUtil.PG_CHAR_CREATION_OPTIONS: return Renderer.charoption.pGetFluff; case UrlUtil.PG_RECIPES: return Renderer.recipe.pGetFluff; default: return null; } }, isSmallScreen (evt) { if (typeof window === "undefined") return false; evt = evt || {}; const win = (evt.view || {}).window || window; return win.innerWidth <= 768; }, /** * @param page * @param toRender * @param [opts] * @param [opts.isBookContent] * @param [opts.isStatic] If this content is to be "static," i.e. display only, containing minimal interactive UI. * @param [opts.fnRender] * @param [renderFnOpts] */ $getHoverContent_stats (page, toRender, opts, renderFnOpts) { opts = opts || {}; if (page === UrlUtil.PG_RECIPES) opts = {...MiscUtil.copyFast(opts), isBookContent: true}; const fnRender = opts.fnRender || Renderer.hover.getFnRenderCompact(page, {isStatic: opts.isStatic}); const $out = $$`${fnRender(toRender, renderFnOpts)}
    `; if (!opts.isStatic) { const fnBind = Renderer.hover.getFnBindListenersCompact(page); if (fnBind) fnBind(toRender, $out[0]); } return $out; }, /** * @param page * @param toRender * @param [opts] * @param [opts.isBookContent] * @param [renderFnOpts] */ $getHoverContent_fluff (page, toRender, opts, renderFnOpts) { opts = opts || {}; if (page === UrlUtil.PG_RECIPES) opts = {...MiscUtil.copyFast(opts), isBookContent: true}; if (!toRender) { return $$`
    ${Renderer.utils.HTML_NO_INFO}
    `; } toRender = MiscUtil.copyFast(toRender); if (toRender.images && toRender.images.length) { const cachedImages = MiscUtil.copyFast(toRender.images); delete toRender.images; toRender.entries = toRender.entries || []; const hasText = toRender.entries.length > 0; // Add the first image at the top if (hasText) toRender.entries.unshift({type: "hr"}); cachedImages[0].maxHeight = 33; cachedImages[0].maxHeightUnits = "vh"; toRender.entries.unshift(cachedImages[0]); // Add any other images at the bottom if (cachedImages.length > 1) { if (hasText) toRender.entries.push({type: "hr"}); toRender.entries.push(...cachedImages.slice(1)); } } return $$`${Renderer.generic.getCompactRenderedString(toRender, renderFnOpts)}
    `; }, $getHoverContent_statsCode (toRender, {isSkipClean = false, title = null} = {}) { const cleanCopy = isSkipClean ? toRender : DataUtil.cleanJson(MiscUtil.copyFast(toRender)); return Renderer.hover.$getHoverContent_miscCode( title || [cleanCopy.name, "Source Data"].filter(Boolean).join(" \u2014 "), JSON.stringify(cleanCopy, null, "\t"), ); }, $getHoverContent_miscCode (name, code) { const toRenderCode = { type: "code", name, preformatted: code, }; return $$`${Renderer.get().render(toRenderCode)}
    `; }, /** * @param toRender * @param [opts] * @param [opts.isBookContent] * @param [opts.isLargeBookContent] * @param [opts.depth] */ $getHoverContent_generic (toRender, opts) { opts = opts || {}; return $$`${Renderer.hover.getGenericCompactRenderedString(toRender, opts.depth || 0)}
    `; }, /** * @param evt * @param entity */ doPopoutCurPage (evt, entity) { const page = UrlUtil.getCurrentPage(); const $content = Renderer.hover.$getHoverContent_stats(page, entity); Renderer.hover.getShowWindow( $content, Renderer.hover.getWindowPositionFromEvent(evt), { pageUrl: `#${UrlUtil.autoEncodeHash(entity)}`, title: entity._displayName || entity.name, isPermanent: true, isBookContent: page === UrlUtil.PG_RECIPES, sourceData: entity, }, ); }, }; /** * Recursively find all the names of entries, useful for indexing * @param nameStack an array to append the names to * @param entry the base entry * @param [opts] Options object. * @param [opts.maxDepth] Maximum depth to search for * @param [opts.depth] Start depth (used internally when recursing) * @param [opts.typeBlocklist] A set of entry types to avoid. */ Renderer.getNames = function (nameStack, entry, opts) { opts = opts || {}; if (opts.maxDepth == null) opts.maxDepth = false; if (opts.depth == null) opts.depth = 0; if (opts.typeBlocklist && entry.type && opts.typeBlocklist.has(entry.type)) return; if (opts.maxDepth !== false && opts.depth > opts.maxDepth) return; if (entry.name) nameStack.push(Renderer.stripTags(entry.name)); if (entry.entries) { let nextDepth = entry.type === "section" ? -1 : entry.type === "entries" ? opts.depth + 1 : opts.depth; for (const eX of entry.entries) { const nxtOpts = {...opts}; nxtOpts.depth = nextDepth; Renderer.getNames(nameStack, eX, nxtOpts); } } else if (entry.items) { for (const eX of entry.items) { Renderer.getNames(nameStack, eX, opts); } } }; Renderer.getNumberedNames = function (entry) { const renderer = new Renderer().setTrackTitles(true); renderer.render(entry); const titles = renderer.getTrackedTitles(); const out = {}; Object.entries(titles).forEach(([k, v]) => { v = Renderer.stripTags(v); out[v] = Number(k); }); return out; }; // dig down until we find a name, as feature names can be nested Renderer.findName = function (entry) { return CollectionUtil.dfs(entry, {prop: "name"}); }; Renderer.findSource = function (entry) { return CollectionUtil.dfs(entry, {prop: "source"}); }; Renderer.findEntry = function (entry) { return CollectionUtil.dfs(entry, {fnMatch: obj => obj.name && obj?.entries?.length}); }; Renderer.stripTags = function (str) { if (!str) return str; let nxtStr = Renderer._stripTagLayer(str); while (nxtStr.length !== str.length) { str = nxtStr; nxtStr = Renderer._stripTagLayer(str); } return nxtStr; }; Renderer._stripTagLayer = function (str) { if (str.includes("{@")) { const tagSplit = Renderer.splitByTags(str); return tagSplit.filter(it => it).map(it => { if (it.startsWith("{@")) { let [tag, text] = Renderer.splitFirstSpace(it.slice(1, -1)); const tagInfo = Renderer.tag.TAG_LOOKUP[tag]; if (!tagInfo) throw new Error(`Unhandled tag: "${tag}"`); return tagInfo.getStripped(tag, text); } else return it; }).join(""); } return str; }; /** * This assumes validation has been done in advance. * @param row * @param [opts] * @param [opts.cbErr] * @param [opts.isForceInfiniteResults] * @param [opts.isFirstRow] Used it `isForceInfiniteResults` is specified. * @param [opts.isLastRow] Used it `isForceInfiniteResults` is specified. */ Renderer.getRollableRow = function (row, opts) { opts = opts || {}; if ( row[0]?.type === "cell" && ( row[0]?.roll?.exact != null || (row[0]?.roll?.min != null && row[0]?.roll?.max != null) ) ) return row; row = MiscUtil.copyFast(row); try { const cleanRow = String(row[0]).trim(); // format: "20 or lower"; "99 or higher" const mLowHigh = /^(\d+) or (lower|higher)$/i.exec(cleanRow); if (mLowHigh) { row[0] = {type: "cell", entry: cleanRow}; // Preseve the original text if (mLowHigh[2].toLowerCase() === "lower") { row[0].roll = { min: -Renderer.dice.POS_INFINITE, max: Number(mLowHigh[1]), }; } else { row[0].roll = { min: Number(mLowHigh[1]), max: Renderer.dice.POS_INFINITE, }; } return row; } // format: "95-00" or "12" // u2012 = figure dash; u2013 = en-dash const m = /^(\d+)([-\u2012\u2013](\d+))?$/.exec(cleanRow); if (m) { if (m[1] && !m[2]) { row[0] = { type: "cell", roll: { exact: Number(m[1]), }, }; if (m[1][0] === "0") row[0].roll.pad = true; Renderer.getRollableRow._handleInfiniteOpts(row, opts); } else { row[0] = { type: "cell", roll: { min: Number(m[1]), max: Number(m[3]), }, }; if (m[1][0] === "0" || m[3][0] === "0") row[0].roll.pad = true; Renderer.getRollableRow._handleInfiniteOpts(row, opts); } } else { // format: "12+" const m = /^(\d+)\+$/.exec(row[0]); row[0] = { type: "cell", roll: { min: Number(m[1]), max: Renderer.dice.POS_INFINITE, }, }; } } catch (e) { if (opts.cbErr) opts.cbErr(row[0], e); } return row; }; Renderer.getRollableRow._handleInfiniteOpts = function (row, opts) { if (!opts.isForceInfiniteResults) return; const isExact = row[0].roll.exact != null; if (opts.isFirstRow) { if (!isExact) row[0].roll.displayMin = row[0].roll.min; row[0].roll.min = -Renderer.dice.POS_INFINITE; } if (opts.isLastRow) { if (!isExact) row[0].roll.displayMax = row[0].roll.max; row[0].roll.max = Renderer.dice.POS_INFINITE; } }; Renderer.initLazyImageLoaders = function () { const images = document.querySelectorAll(`img[data-src]`); Renderer.utils.lazy.destroyObserver({observerId: "images"}); const observer = Renderer.utils.lazy.getCreateObserver({ observerId: "images", fnOnObserve: ({entry}) => { const $img = $(entry.target); $img.attr("src", $img.attr("data-src")).removeAttr("data-src"); }, }); images.forEach(img => observer.track(img)); }; Renderer.HEAD_NEG_1 = "rd__b--0"; Renderer.HEAD_0 = "rd__b--1"; Renderer.HEAD_1 = "rd__b--2"; Renderer.HEAD_2 = "rd__b--3"; Renderer.HEAD_2_SUB_VARIANT = "rd__b--4"; Renderer.DATA_NONE = "data-none";