This commit is contained in:
TheGiddyLimit
2024-01-01 19:34:49 +00:00
parent 332769043f
commit 8117ebddc5
1748 changed files with 2544409 additions and 1 deletions

112
js/actions.js Normal file
View File

@@ -0,0 +1,112 @@
"use strict";
class ActionsSublistManager extends SublistManager {
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Name",
css: "bold col-8 pl-0",
colStyle: "",
}),
new SublistCellTemplate({
name: "Time",
css: "ve-text-center col-4 pr-0",
colStyle: "text-center",
}),
];
}
pGetSublistItem (it, hash) {
const time = it.time ? it.time.map(tm => PageFilterActions.getTimeText(tm)).join("/") : "\u2014";
const cellsText = [it.name, time];
const $ele = $(`<div class="lst__row lst__row--sublist ve-flex-col">
<a href="#${hash}" class="lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText})}
</a>
</div>`)
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._listSub.doSelect(listItem, evt));
const listItem = new ListItem(
hash,
$ele,
it.name,
{
hash,
time,
},
{
entity: it,
mdRow: [...cellsText],
},
);
return listItem;
}
}
class ActionsPage extends ListPage {
constructor () {
const pageFilter = new PageFilterActions();
super({
dataSource: "data/actions.json",
pageFilter,
dataProps: ["action"],
isMarkdownPopout: true,
isPreviewable: true,
});
}
getListItem (it, anI, isExcluded) {
this._pageFilter.mutateAndAddToFilters(it, isExcluded);
const eleLi = document.createElement("div");
eleLi.className = `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`;
const source = Parser.sourceJsonToAbv(it.source);
const hash = UrlUtil.autoEncodeHash(it);
const time = it.time ? it.time.map(tm => PageFilterActions.getTimeText(tm)).join("/") : "\u2014";
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
<span class="col-0-3 px-0 ve-flex-vh-center lst__btn-toggle-expand ve-self-flex-stretch">[+]</span>
<span class="col-5-7 px-1 bold">${it.name}</span>
<span class="col-4 ve-text-center">${time}</span>
<span class="col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
</a>
<div class="ve-flex ve-hidden relative lst__wrp-preview">
<div class="vr-0 absolute lst__vr-preview"></div>
<div class="ve-flex-col py-3 ml-4 lst__wrp-preview-inner"></div>
</div>`;
const listItem = new ListItem(
anI,
eleLi,
it.name,
{
hash,
source,
time,
},
{
isExcluded,
},
);
eleLi.addEventListener("click", (evt) => this._list.doSelect(listItem, evt));
eleLi.addEventListener("contextmenu", (evt) => this._openContextMenu(evt, this._list, listItem));
return listItem;
}
_renderStats_doBuildStatsTab ({ent}) {
this._$pgContent.empty().append(RenderActions.$getRenderedAction(ent));
}
}
const actionsPage = new ActionsPage();
actionsPage.sublistManager = new ActionsSublistManager();
window.addEventListener("load", () => actionsPage.pOnLoad());

36
js/adventure.js Normal file
View File

@@ -0,0 +1,36 @@
"use strict";
const CONTENTS_URL = "data/adventures.json";
window.addEventListener("load", async () => {
BookUtil.$dispBook = $(`#pagecontent`);
await Promise.all([
PrereleaseUtil.pInit(),
BrewUtil2.pInit(),
]);
ExcludeUtil.pInitialise().then(null); // don't await, as this is only used for search
DataUtil.loadJSON(CONTENTS_URL).then(onJsonLoad);
});
async function onJsonLoad (data) {
BookUtil.baseDataUrl = "data/adventure/adventure-";
BookUtil.allPageUrl = "adventures.html";
BookUtil.propHomebrewData = "adventureData";
BookUtil.typeTitle = "Adventure";
BookUtil.initLinkGrabbers();
BookUtil.initScrollTopFloat();
BookUtil.contentType = "adventure";
BookUtil.bookIndex = data?.adventure || [];
$(`.book-head-message`).text(`Select an adventure from the list on the left`);
$(`.book-loading-message`).text(`Select an adventure to begin`);
BookUtil.bookIndexPrerelease = (await PrereleaseUtil.pGetBrewProcessed())?.adventure || [];
BookUtil.bookIndexBrew = (await BrewUtil2.pGetBrewProcessed())?.adventure || [];
window.onhashchange = BookUtil.booksHashChange.bind(BookUtil);
await BookUtil.booksHashChange();
window.dispatchEvent(new Event("toolsLoaded"));
}

41
js/adventures.js Normal file
View File

@@ -0,0 +1,41 @@
"use strict";
class AdventuresList extends AdventuresBooksList {
static _getLevelsStr (adv) {
if (adv.level.custom) return adv.level.custom;
return `${adv.level.start}\u2013${adv.level.end}`;
}
constructor () {
super({
contentsUrl: "data/adventures.json",
fnSort: AdventuresBooksList._sortAdventuresBooks.bind(AdventuresBooksList),
sortByInitial: "group",
sortDirInitial: "asc",
dataProp: "adventure",
enhanceRowDataFn: (adv) => {
adv._startLevel = adv.level.start || 20;
adv._pubDate = new Date(adv.published);
},
rootPage: "adventure.html",
rowBuilderFn: (adv) => {
return `
<span class="col-1-3 ve-text-center mobile__text-clip-ellipsis">${AdventuresBooksList._getGroupStr(adv)}</span>
<span class="col-5-5 bold mobile__text-clip-ellipsis">${adv.name}</span>
<span class="col-2-5 mobile__text-clip-ellipsis">${adv.storyline || "\u2014"}</span>
<span class="col-1 ve-text-center mobile__text-clip-ellipsis">${AdventuresList._getLevelsStr(adv)}</span>
<span class="col-1-7 ve-text-center mobile__text-clip-ellipsis code">${AdventuresBooksList._getDateStr(adv)}</span>
`;
},
});
}
}
const adventuresList = new AdventuresList();
window.addEventListener("load", () => adventuresList.pOnPageLoad());
function handleBrew (homebrew) {
adventuresList.addData(homebrew);
return Promise.resolve();
}

115
js/backgrounds.js Normal file
View File

@@ -0,0 +1,115 @@
"use strict";
class BackgroundSublistManager extends SublistManager {
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Name",
css: "bold col-4 pl-0",
colStyle: "",
}),
new SublistCellTemplate({
name: "Skills",
css: "col-8 pr-0",
colStyle: "",
}),
];
}
pGetSublistItem (it, hash) {
const name = it.name.replace("Variant ", "");
const {summary: skills} = Renderer.generic.getSkillSummary({skillProfs: it.skillProficiencies || [], isShort: true});
const cellsText = [name, skills];
const $ele = $$`<div class="lst__row lst__row--sublist ve-flex-col">
<a href="#${hash}" class="lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText})}
</a>
</div>`
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._listSub.doSelect(listItem, evt));
const listItem = new ListItem(
hash,
$ele,
name,
{
hash,
source: Parser.sourceJsonToAbv(it.source),
skills,
},
{
entity: it,
mdRow: [...cellsText],
},
);
return listItem;
}
}
class BackgroundPage extends ListPage {
constructor () {
const pageFilter = new PageFilterBackgrounds();
super({
dataSource: DataUtil.background.loadJSON.bind(DataUtil.background),
dataSourceFluff: DataUtil.backgroundFluff.loadJSON.bind(DataUtil.backgroundFluff),
pFnGetFluff: Renderer.background.pGetFluff.bind(Renderer.background),
pageFilter,
bookViewOptions: {
namePlural: "backgrounds",
pageTitle: "Backgrounds Book View",
},
dataProps: ["background"],
isMarkdownPopout: true,
});
}
getListItem (bg, bgI, isExcluded) {
this._pageFilter.mutateAndAddToFilters(bg, isExcluded);
const eleLi = document.createElement("div");
eleLi.className = `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`;
const name = bg.name.replace("Variant ", "");
const hash = UrlUtil.autoEncodeHash(bg);
const source = Parser.sourceJsonToAbv(bg.source);
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
<span class="bold col-4 pl-0">${name}</span>
<span class="col-6">${bg._skillDisplay}</span>
<span class="col-2 ve-text-center ${Parser.sourceJsonToColor(bg.source)} pr-0" title="${Parser.sourceJsonToFull(bg.source)}" ${Parser.sourceJsonToStyle(bg.source)}>${source}</span>
</a>`;
const listItem = new ListItem(
bgI,
eleLi,
name,
{
hash,
source,
skills: bg._skillDisplay,
},
{
isExcluded,
},
);
eleLi.addEventListener("click", (evt) => this._list.doSelect(listItem, evt));
eleLi.addEventListener("contextmenu", (evt) => this._openContextMenu(evt, this._list, listItem));
return listItem;
}
_renderStats_doBuildStatsTab ({ent}) {
this._$pgContent.empty().append(RenderBackgrounds.$getRenderedBackground(ent));
}
}
const backgroundsPage = new BackgroundPage();
backgroundsPage.sublistManager = new BackgroundSublistManager();
window.addEventListener("load", () => backgroundsPage.pOnLoad());

985
js/bestiary.js Normal file
View File

@@ -0,0 +1,985 @@
import {EncounterBuilderCacheBestiaryPage} from "./bestiary/bestiary-encounterbuilder-cache.js";
import {EncounterBuilderComponentBestiary} from "./bestiary/bestiary-encounterbuilder-component.js";
import {EncounterBuilderUiBestiary} from "./bestiary/bestiary-encounterbuilder-ui.js";
import {EncounterBuilderSublistPlugin} from "./bestiary/bestiary-encounterbuilder-sublistplugin.js";
class _BestiaryConsts {
static PROF_MODE_BONUS = "bonus";
static PROF_MODE_DICE = "dice";
}
class _BestiaryUtil {
static getUrlSubhashes (mon, {isAddLeadingSep = true} = {}) {
const subhashesRaw = [
mon._isScaledCr ? `${UrlUtil.HASH_START_CREATURE_SCALED}${mon._scaledCr}` : null,
mon._summonedBySpell_level ? `${UrlUtil.HASH_START_CREATURE_SCALED_SPELL_SUMMON}${mon._summonedBySpell_level}` : null,
mon._summonedByClass_level ? `${UrlUtil.HASH_START_CREATURE_SCALED_CLASS_SUMMON}${mon._summonedByClass_level}` : null,
].filter(Boolean);
if (!subhashesRaw.length) return "";
return `${isAddLeadingSep ? HASH_PART_SEP : ""}${subhashesRaw.join(HASH_PART_SEP)}`;
}
static getListDisplayType (mon) {
let type = mon._pTypes.asTextShort.uppercaseFirst();
if (mon._pTypes.asTextSidekick) type += `, ${mon._pTypes.asTextSidekick}`;
return type;
}
}
class BestiarySublistManager extends SublistManager {
constructor () {
super({
sublistListOptions: {
fnSort: PageFilterBestiary.sortMonsters,
},
shiftCountAddSubtract: 5,
isSublistItemsCountable: true,
});
this._$dispCrTotal = null;
this._encounterBuilder = null;
}
set encounterBuilder (val) { this._encounterBuilder = val; }
_getCustomHashId ({entity}) {
return Renderer.monster.getCustomHashId(entity);
}
_getSerializedPinnedItemData (listItem) {
return {l: listItem.data.isLocked ? listItem.data.isLocked : undefined};
}
_getDeserializedPinnedItemData (serialData) {
return {isLocked: !!serialData.l};
}
_onSublistChange () {
this._$dispCrTotal = this._$dispCrTotal || $(`#totalcr`);
this._encounterBuilder.onSublistChange({$dispCrTotal: this._$dispCrTotal});
}
_getSublistFullHash ({entity}) {
return `${super._getSublistFullHash({entity})}${_BestiaryUtil.getUrlSubhashes(entity)}`;
}
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Name",
css: "bold col-5 pl-0",
colStyle: "",
}),
new SublistCellTemplate({
name: "Type",
css: "col-3-8",
colStyle: "",
}),
new SublistCellTemplate({
name: "CR",
css: "col-1-2 ve-text-center",
colStyle: "text-center",
}),
new SublistCellTemplate({
name: "Number",
css: "col-2 ve-text-center",
colStyle: "text-center",
}),
];
}
async pGetSublistItem (mon, hash, {count = 1, customHashId = null, initialData} = {}) {
const name = mon._displayName || mon.name;
const type = _BestiaryUtil.getListDisplayType(mon);
const cr = mon._pCr;
const hashBase = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon);
const isLocked = !!initialData?.isLocked; // If e.g. reloading from a save
const cellsText = [name, type, cr];
const $hovStatblock = $(`<span class="col-1-4 help help--hover best-ecgen__visible">Stat Block</span>`)
.mouseover(evt => this._encounterBuilder.doStatblockMouseOver({
evt,
ele: $hovStatblock[0],
source: mon.source,
hash: hashBase,
customHashId: this._getCustomHashId({entity: mon}),
}))
.mousemove(evt => Renderer.hover.handleLinkMouseMove(evt, $hovStatblock[0]))
.mouseleave(evt => Renderer.hover.handleLinkMouseLeave(evt, $hovStatblock[0]));
const hovTokenMeta = EncounterBuilderUiBestiary.getTokenHoverMeta(mon);
const $hovToken = !hovTokenMeta ? $(`<span class="col-1-2 best-ecgen__visible"></span>`) : $(`<span class="col-1-2 best-ecgen__visible help help--hover">Token</span>`)
.mouseover(evt => hovTokenMeta.mouseOver(evt, $hovToken[0]))
.mousemove(evt => hovTokenMeta.mouseMove(evt, $hovToken[0]))
.mouseleave(evt => hovTokenMeta.mouseLeave(evt, $hovToken[0]));
const $hovImage = $(`<span class="col-1-2 best-ecgen__visible help help--hover">Image</span>`);
Renderer.monster.hover.bindFluffImageMouseover({mon, $ele: $hovImage});
const $ptCr = (() => {
if (!ScaleCreature.isCrInScaleRange(mon)) return $(`<span class="col-1-2 ve-text-center">${cr}</span>`);
const $iptCr = $(`<input value="${cr}" class="w-100 ve-text-center form-control form-control--minimal input-xs">`)
.click(() => $iptCr.select())
.change(() => this._encounterBuilder.pDoCrChange($iptCr, mon, mon._scaledCr));
return $$`<span class="col-1-2 ve-text-center">${$iptCr}</span>`;
})();
const $eleCount1 = $(`<span class="col-2 ve-text-center">${count}</span>`);
const $eleCount2 = $(`<span class="col-2 pr-0 ve-text-center">${count}</span>`);
const listItem = new ListItem(
hash,
null,
name,
{
hash,
source: Parser.sourceJsonToAbv(mon.source),
type,
cr,
page: mon.page,
},
{
count,
customHashId,
isLocked,
$elesCount: [$eleCount1, $eleCount2],
fnsUpdate: [],
entity: mon,
entityBase: await DataLoader.pCacheAndGetHash(
UrlUtil.PG_BESTIARY,
hashBase,
),
mdRow: [...cellsText, ({listItem}) => listItem.data.count],
},
);
const sublistButtonsMeta = this._encounterBuilder.getSublistButtonsMeta(listItem);
listItem.data.fnsUpdate.push(sublistButtonsMeta.fnUpdate);
listItem.ele = $$`<div class="lst__row lst__row--sublist ve-flex-col lst__row--bestiary-sublist">
<a href="#${hash}" draggable="false" class="best-ecgen__hidden lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText, templates: this.constructor._ROW_TEMPLATE.slice(0, 3)})}
${$eleCount1}
</a>
<div class="lst__wrp-cells best-ecgen__visible--flex lst--border lst__row-inner">
${sublistButtonsMeta.$wrp}
<span class="best-ecgen__name--sub col-3-5">${name}</span>
${$hovStatblock}
${$hovToken}
${$hovImage}
${$ptCr}
${$eleCount2}
</div>
</div>`
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._handleBestiaryLinkClickSub(evt, listItem));
return listItem;
}
_handleBestiaryLinkClickSub (evt, listItem) {
if (this._encounterBuilder.isActive()) evt.preventDefault();
else this._listSub.doSelect(listItem, evt);
}
}
class BestiaryPageBookView extends ListPageBookView {
constructor (opts) {
super({
namePlural: "creatures",
pageTitle: "Bestiary Printer View",
...opts,
});
}
async _$pGetWrpControls ({$wrpContent}) {
const out = await super._$pGetWrpControls({$wrpContent});
const {$wrpPrint} = out;
// region Markdown
// TODO refactor this and spell markdown section
const pGetAsMarkdown = async () => {
const toRender = this._bookViewToShow.length ? this._bookViewToShow : [this._fnGetEntLastLoaded()];
return RendererMarkdown.monster.pGetMarkdownDoc(toRender);
};
const $btnDownloadMarkdown = $(`<button class="btn btn-default btn-sm">Download as Markdown</button>`)
.click(async () => DataUtil.userDownloadText("bestiary.md", await pGetAsMarkdown()));
const $btnCopyMarkdown = $(`<button class="btn btn-default btn-sm px-2" title="Copy Markdown to Clipboard"><span class="glyphicon glyphicon-copy"/></button>`)
.click(async () => {
await MiscUtil.pCopyTextToClipboard(await pGetAsMarkdown());
JqueryUtil.showCopiedEffect($btnCopyMarkdown);
});
const $btnDownloadMarkdownSettings = $(`<button class="btn btn-default btn-sm px-2" title="Markdown Settings"><span class="glyphicon glyphicon-cog"/></button>`)
.click(async () => RendererMarkdown.pShowSettingsModal());
$$`<div class="ve-flex-v-center btn-group ml-2">
${$btnDownloadMarkdown}
${$btnCopyMarkdown}
${$btnDownloadMarkdownSettings}
</div>`.appendTo($wrpPrint);
// endregion
return out;
}
async _pGetRenderContentMeta ({$wrpContent}) {
this._bookViewToShow = this._sublistManager.getPinnedEntities()
.sort(this._getSorted.bind(this));
let cntSelectedEnts = 0;
let isAnyEntityRendered = false;
const stack = [];
const renderCreature = (mon) => {
isAnyEntityRendered = true;
stack.push(`<div class="bkmv__wrp-item ve-inline-block print__ve-block print__my-2"><table class="w-100 stats stats--book stats--bkmv"><tbody>`);
stack.push(Renderer.monster.getCompactRenderedString(mon));
stack.push(`</tbody></table></div>`);
};
this._bookViewToShow.forEach(mon => renderCreature(mon));
if (!this._bookViewToShow.length && Hist.lastLoadedId != null) {
renderCreature(this._fnGetEntLastLoaded());
}
cntSelectedEnts += this._bookViewToShow.length;
$wrpContent.append(stack.join(""));
return {cntSelectedEnts, isAnyEntityRendered};
}
_getSorted (a, b) {
return SortUtil.ascSort(a._displayName || a.name, b._displayName || b.name);
}
}
class BestiaryPage extends ListPageMultiSource {
static async _prereleaseBrewDataSource ({brewUtil}) {
const brew = await brewUtil.pGetBrewProcessed();
DataUtil.monster.populateMetaReference(brew);
return brew;
}
static _tableView_getEntryPropTransform ({mon, fnGet}) {
const fnGetSpellTraits = Renderer.monster.getSpellcastingRenderedTraits.bind(Renderer.monster, Renderer.get());
const allEntries = fnGet(mon, {fnGetSpellTraits});
return (allEntries || []).map(it => it.rendered || Renderer.get().render(it, 2)).join("");
}
constructor () {
const pFnGetFluff = Renderer.monster.pGetFluff.bind(Renderer.monster);
super({
pageFilter: new PageFilterBestiary(),
listOptions: {
fnSort: PageFilterBestiary.sortMonsters,
},
dataProps: ["monster"],
prereleaseDataSource: () => BestiaryPage._prereleaseBrewDataSource({brewUtil: PrereleaseUtil}),
brewDataSource: () => BestiaryPage._prereleaseBrewDataSource({brewUtil: BrewUtil2}),
pFnGetFluff,
hasAudio: true,
bookViewOptions: {
ClsBookView: BestiaryPageBookView,
},
tableViewOptions: {
title: "Bestiary",
colTransforms: {
name: UtilsTableview.COL_TRANSFORM_NAME,
source: UtilsTableview.COL_TRANSFORM_SOURCE,
size: {name: "Size", transform: size => Renderer.utils.getRenderedSize(size)},
type: {name: "Type", transform: type => Parser.monTypeToFullObj(type).asText},
alignment: {name: "Alignment", transform: align => Parser.alignmentListToFull(align)},
ac: {name: "AC", transform: ac => Parser.acToFull(ac)},
hp: {name: "HP", transform: hp => Renderer.monster.getRenderedHp(hp)},
_speed: {name: "Speed", transform: mon => Parser.getSpeedString(mon)},
...Parser.ABIL_ABVS.mergeMap(ab => ({[ab]: {name: Parser.attAbvToFull(ab)}})),
_save: {name: "Saving Throws", transform: mon => Renderer.monster.getSavesPart(mon)},
_skill: {name: "Skills", transform: mon => Renderer.monster.getSkillsString(Renderer.get(), mon)},
vulnerable: {name: "Damage Vulnerabilities", transform: it => Parser.getFullImmRes(it)},
resist: {name: "Damage Resistances", transform: it => Parser.getFullImmRes(it)},
immune: {name: "Damage Immunities", transform: it => Parser.getFullImmRes(it)},
conditionImmune: {name: "Condition Immunities", transform: it => Parser.getFullCondImm(it)},
_senses: {name: "Senses", transform: mon => Renderer.monster.getSensesPart(mon)},
languages: {name: "Languages", transform: it => Renderer.monster.getRenderedLanguages(it)},
_cr: {name: "CR", transform: mon => Parser.monCrToFull(mon.cr, {isMythic: !!mon.mythic})},
_trait: {
name: "Traits",
transform: mon => BestiaryPage._tableView_getEntryPropTransform({mon, fnGet: Renderer.monster.getOrderedTraits}),
flex: 3,
},
_action: {
name: "Actions",
transform: mon => BestiaryPage._tableView_getEntryPropTransform({mon, fnGet: Renderer.monster.getOrderedActions}),
flex: 3,
},
_bonus: {
name: "Bonus Actions",
transform: mon => BestiaryPage._tableView_getEntryPropTransform({mon, fnGet: Renderer.monster.getOrderedBonusActions}),
flex: 3,
},
_reaction: {
name: "Reactions",
transform: mon => BestiaryPage._tableView_getEntryPropTransform({mon, fnGet: Renderer.monster.getOrderedReactions}),
flex: 3,
},
legendary: {name: "Legendary Actions", transform: it => (it || []).map(x => Renderer.get().render(x, 2)).join(""), flex: 3},
mythic: {name: "Mythic Actions", transform: it => (it || []).map(x => Renderer.get().render(x, 2)).join(""), flex: 3},
_lairActions: {
name: "Lair Actions",
transform: mon => {
const legGroup = DataUtil.monster.getMetaGroup(mon);
if (!legGroup?.lairActions?.length) return "";
return Renderer.get().render({entries: legGroup.lairActions});
},
flex: 3,
},
_regionalEffects: {
name: "Regional Effects",
transform: mon => {
const legGroup = DataUtil.monster.getMetaGroup(mon);
if (!legGroup?.regionalEffects?.length) return "";
return Renderer.get().render({entries: legGroup.regionalEffects});
},
flex: 3,
},
environment: {name: "Environment", transform: it => Renderer.monster.getRenderedEnvironment(it)},
},
},
isMarkdownPopout: true,
propEntryData: "monster",
propLoader: "monster",
listSyntax: new ListSyntaxBestiary({fnGetDataList: () => this._dataList, pFnGetFluff}),
});
this._$wrpBtnProf = null;
this._$btnProf = null;
this._profDicMode = _BestiaryConsts.PROF_MODE_BONUS;
this._encounterBuilder = null;
this._$dispToken = null;
}
get _bindOtherButtonsOptions () {
return {
upload: {
pFnPreLoad: (...args) => this._pPreloadSublistSources(...args),
},
sendToBrew: {
mode: "creatureBuilder",
fnGetMeta: () => ({
page: UrlUtil.getCurrentPage(),
source: Hist.getHashSource(),
hash: `${UrlUtil.autoEncodeHash(this._lastRender.entity)}${_BestiaryUtil.getUrlSubhashes(this._lastRender.entity)}`,
}),
},
other: [
this._bindOtherButtonsOptions_openAsSinglePage({slugPage: "bestiary", fnGetHash: () => UrlUtil.autoEncodeHash(this._lastRender.entity)}),
].filter(Boolean),
};
}
set encounterBuilder (val) { this._encounterBuilder = val; }
get list_ () { return this._list; }
getListItem (mon, mI) {
const hash = UrlUtil.autoEncodeHash(mon);
if (this._seenHashes.has(hash)) return null;
this._seenHashes.add(hash);
Renderer.monster.updateParsed(mon);
const isExcluded = ExcludeUtil.isExcluded(hash, "monster", mon.source);
this._pageFilter.mutateAndAddToFilters(mon, isExcluded);
const source = Parser.sourceJsonToAbv(mon.source);
const type = _BestiaryUtil.getListDisplayType(mon);
const cr = mon._pCr;
const eleLi = e_({
tag: "div",
clazz: `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`,
click: (evt) => this._handleBestiaryLiClick(evt, listItem),
contextmenu: (evt) => this._handleBestiaryLiContext(evt, listItem),
children: [
e_({
tag: "a",
href: `#${hash}`,
clazz: "lst--border lst__row-inner",
click: evt => this._handleBestiaryLinkClick(evt),
children: [
this._encounterBuilder.getButtons(mI),
e_({tag: "span", clazz: `best-ecgen__name bold col-4-2 pl-0`, text: mon.name}),
e_({tag: "span", clazz: `col-4-1`, text: type}),
e_({tag: "span", clazz: `col-1-7 ve-text-center`, text: cr}),
e_({
tag: "span",
clazz: `col-2 ve-text-center ${Parser.sourceJsonToColor(mon.source)} pr-0`,
style: Parser.sourceJsonToStylePart(mon.source),
title: `${Parser.sourceJsonToFull(mon.source)}${Renderer.utils.getSourceSubText(mon)}`,
text: source,
}),
],
}),
],
});
const listItem = new ListItem(
mI,
eleLi,
mon.name,
{
hash,
source,
type,
cr,
group: mon.group ? [mon.group].flat().join(",") : "",
alias: (mon.alias || []).map(it => `"${it}"`).join(","),
page: mon.page,
},
{
isExcluded,
},
);
return listItem;
}
handleFilterChange () {
super.handleFilterChange();
this._encounterBuilder.resetCache();
}
async _pDoLoadHash ({id, lockToken}) {
const mon = this._dataList[id];
this._renderStatblock(mon);
await this._pDoLoadSubHash({sub: [], lockToken});
this._updateSelected();
}
async _pDoLoadSubHash ({sub, lockToken}) {
sub = await super._pDoLoadSubHash({sub, lockToken});
const scaledHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED));
const scaledSpellSummonHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED_SPELL_SUMMON));
const scaledClassSummonHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED_CLASS_SUMMON));
const mon = this._dataList[Hist.lastLoadedId];
if (scaledHash) {
const scaleTo = Number(UrlUtil.unpackSubHash(scaledHash)[VeCt.HASH_SCALED][0]);
const scaleToStr = Parser.numberToCr(scaleTo);
if (Parser.isValidCr(scaleToStr) && scaleTo !== Parser.crToNumber(this._lastRender.entity.cr)) {
ScaleCreature.scale(mon, scaleTo)
.then(monScaled => this._renderStatblock(monScaled, {isScaledCr: true}));
}
} else if (scaledSpellSummonHash) {
const scaleTo = Number(UrlUtil.unpackSubHash(scaledSpellSummonHash)[VeCt.HASH_SCALED_SPELL_SUMMON][0]);
if (mon.summonedBySpellLevel != null && scaleTo >= mon.summonedBySpellLevel && scaleTo !== this._lastRender.entity._summonedBySpell_level) {
ScaleSpellSummonedCreature.scale(mon, scaleTo)
.then(monScaled => this._renderStatblock(monScaled, {isScaledSpellSummon: true}));
}
} else if (scaledClassSummonHash) {
const scaleTo = Number(UrlUtil.unpackSubHash(scaledClassSummonHash)[VeCt.HASH_SCALED_CLASS_SUMMON][0]);
if (mon.summonedByClass != null && scaleTo > 0 && scaleTo !== this._lastRender.entity._summonedByClass_level) {
ScaleClassSummonedCreature.scale(mon, scaleTo)
.then(monScaled => this._renderStatblock(monScaled, {isScaledClassSummon: true}));
}
}
this._encounterBuilder.handleSubhash(sub);
}
async _pOnLoad_pPreDataLoad () {
this._encounterBuilder.initUi();
await DataUtil.monster.pPreloadMeta();
this._bindProfDiceHandlers();
}
async _pOnLoad_pPreDataAdd () {
this._pPageInit_profBonusDiceToggle();
}
_pOnLoad_pPostLoad () {
this._encounterBuilder.render();
}
_pPageInit_profBonusDiceToggle () {
const $btnProfBonusDice = $("button#profbonusdice");
$btnProfBonusDice.click(() => {
if (this._profDicMode === _BestiaryConsts.PROF_MODE_DICE) {
this._profDicMode = _BestiaryConsts.PROF_MODE_BONUS;
$btnProfBonusDice.html("Use Proficiency Dice");
this._$pgContent.attr("data-proficiency-dice-mode", this._profDicMode);
} else {
this._profDicMode = _BestiaryConsts.PROF_MODE_DICE;
$btnProfBonusDice.html("Use Proficiency Bonus");
this._$pgContent.attr("data-proficiency-dice-mode", this._profDicMode);
}
});
}
_handleBestiaryLiClick (evt, listItem) {
if (this._encounterBuilder.isActive()) Renderer.hover.doPopoutCurPage(evt, this._dataList[listItem.ix]);
else this._list.doSelect(listItem, evt);
}
_handleBestiaryLiContext (evt, listItem) {
this._openContextMenu(evt, this._list, listItem);
}
_handleBestiaryLinkClick (evt) {
if (this._encounterBuilder.isActive()) evt.preventDefault();
}
_bindProfDiceHandlers () {
this._$pgContent.attr("data-proficiency-dice-mode", this._profDicMode);
this._$pgContent
.on(`mousedown`, `[data-roll-prof-type]`, evt => {
if (this._profDicMode !== _BestiaryConsts.PROF_MODE_BONUS) evt.preventDefault();
})
.on(`click`, `[data-roll-prof-type]`, evt => {
const parent = evt.currentTarget.closest(`[data-roll-prof-type]`);
const type = parent?.dataset?.rollProfType;
if (!type) return;
switch (type) {
case "d20": {
if (this._profDicMode === _BestiaryConsts.PROF_MODE_BONUS) return;
evt.stopPropagation();
evt.preventDefault();
const cpyOriginalEntry = JSON.parse(parent.dataset.packedDice);
cpyOriginalEntry.toRoll = `d20${parent.dataset.rollProfDice}`;
cpyOriginalEntry.d20mod = parent.dataset.rollProfDice;
Renderer.dice.pRollerClick(evt, parent, JSON.stringify(cpyOriginalEntry));
break;
}
case "dc": {
if (this._profDicMode === _BestiaryConsts.PROF_MODE_BONUS) {
evt.stopPropagation();
evt.preventDefault();
return;
}
const fauxEntry = Renderer.utils.getTagEntry(`@d20`, parent.dataset.rollProfDice);
Renderer.dice.pRollerClick(evt, parent, JSON.stringify(fauxEntry));
break;
}
default: throw new Error(`Unhandled roller type "${type}"`);
}
});
}
_renderStatblock (mon, {isScaledCr = false, isScaledSpellSummon = false, isScaledClassSummon = false} = {}) {
this._lastRender.entity = mon;
this._lastRender.isScaledCr = isScaledCr;
this._lastRender.isScaledSpellSummon = isScaledSpellSummon;
this._lastRender.isScaledClassSummon = isScaledClassSummon;
this._$wrpBtnProf = this._$wrpBtnProf || $(`#wrp-profbonusdice`);
this._$dispToken = this._$dispToken || $(`#float-token`);
this._$pgContent.empty();
if (this._$btnProf != null) {
this._$wrpBtnProf.append(this._$btnProf);
this._$btnProf = null;
}
const tabMetaStats = new Renderer.utils.TabButton({
label: "Stat Block",
fnChange: () => {
this._$wrpBtnProf.append(this._$btnProf);
this._$dispToken.showVe();
},
fnPopulate: () => this._renderStatblock_doBuildStatsTab({mon, isScaledCr, isScaledSpellSummon, isScaledClassSummon}),
isVisible: true,
});
Renderer.utils.bindTabButtons({
tabButtons: [tabMetaStats],
tabLabelReference: [tabMetaStats].map(it => it.label),
$wrpTabs: this._$wrpTabs,
$pgContent: this._$pgContent,
});
Promise.all([
Renderer.utils.pHasFluffText(mon, "monsterFluff"),
Renderer.utils.pHasFluffImages(mon, "monsterFluff"),
])
.then(([hasFluffText, hasFluffImages]) => {
if (!hasFluffText && !hasFluffImages) return;
if (this._lastRender.entity !== mon) return;
const tabMetas = [
tabMetaStats,
new Renderer.utils.TabButton({
label: "Info",
fnChange: () => {
this._$btnProf = this._$wrpBtnProf.children().length ? this._$wrpBtnProf.children().detach() : this._$btnProf;
this._$dispToken.hideVe();
},
fnPopulate: () => this._renderStats_doBuildFluffTab({ent: mon}),
isVisible: hasFluffText,
}),
new Renderer.utils.TabButton({
label: "Images",
fnChange: () => {
this._$btnProf = this._$wrpBtnProf.children().length ? this._$wrpBtnProf.children().detach() : this._$btnProf;
this._$dispToken.hideVe();
},
fnPopulate: () => this._renderStats_doBuildFluffTab({ent: mon, isImageTab: true}),
isVisible: hasFluffImages,
}),
];
Renderer.utils.bindTabButtons({
tabButtons: tabMetas.filter(it => it.isVisible),
tabLabelReference: tabMetas.map(it => it.label),
$wrpTabs: this._$wrpTabs,
$pgContent: this._$pgContent,
});
});
}
_renderStatblock_doBuildStatsTab (
{
mon,
isScaledCr,
isScaledSpellSummon,
isScaledClassSummon,
},
) {
Renderer.get().setFirstSection(true);
const $btnScaleCr = !ScaleCreature.isCrInScaleRange(mon) ? null : $(`<button id="btn-scale-cr" title="Scale Creature By CR (Highly Experimental)" class="mon__btn-scale-cr btn btn-xs btn-default ve-popwindow__hidden"><span class="glyphicon glyphicon-signal"></span></button>`)
.click((evt) => {
evt.stopPropagation();
const win = (evt.view || {}).window;
const mon = this._dataList[Hist.lastLoadedId];
const lastCr = this._lastRender.entity ? this._lastRender.entity.cr.cr || this._lastRender.entity.cr : mon.cr.cr || mon.cr;
Renderer.monster.getCrScaleTarget({
win,
$btnScale: $btnScaleCr,
initialCr: lastCr,
cbRender: (targetCr) => {
if (targetCr === Parser.crToNumber(mon.cr)) this._renderStatblock(mon);
else Hist.setSubhash(VeCt.HASH_SCALED, targetCr);
},
});
});
const $btnResetScaleCr = !ScaleCreature.isCrInScaleRange(mon) ? null : $(`<button id="btn-reset-cr" title="Reset CR Scaling" class="mon__btn-reset-cr btn btn-xs btn-default ve-popwindow__hidden"><span class="glyphicon glyphicon-refresh"></span></button>`)
.click(() => Hist.setSubhash(VeCt.HASH_SCALED, null))
.toggle(isScaledCr);
const selSummonSpellLevel = Renderer.monster.getSelSummonSpellLevel(mon);
if (selSummonSpellLevel) {
selSummonSpellLevel
.onChange(evt => {
evt.stopPropagation();
const scaleTo = Number(selSummonSpellLevel.val());
if (!~scaleTo) Hist.setSubhash(VeCt.HASH_SCALED_SPELL_SUMMON, null);
else Hist.setSubhash(VeCt.HASH_SCALED_SPELL_SUMMON, scaleTo);
});
}
if (isScaledSpellSummon) selSummonSpellLevel.val(`${mon._summonedBySpell_level}`);
const selSummonClassLevel = Renderer.monster.getSelSummonClassLevel(mon);
if (selSummonClassLevel) {
selSummonClassLevel
.onChange(evt => {
evt.stopPropagation();
const scaleTo = Number(selSummonClassLevel.val());
if (!~scaleTo) Hist.setSubhash(VeCt.HASH_SCALED_CLASS_SUMMON, null);
else Hist.setSubhash(VeCt.HASH_SCALED_CLASS_SUMMON, scaleTo);
});
}
if (isScaledClassSummon) selSummonClassLevel.val(`${mon._summonedByClass_level}`);
// region dice rollers
const expectedPB = Parser.crToPb(mon.cr);
const pluginDc = (tag, text) => {
if (isNaN(text) || expectedPB <= 0) return null;
const withoutPB = Number(text) - expectedPB;
const profDiceString = BestiaryPage._addSpacesToDiceExp(`+1d${(expectedPB * 2)}${withoutPB >= 0 ? "+" : ""}${withoutPB}`);
return `DC <span class="rd__dc rd__dc--rollable" data-roll-prof-type="dc" data-roll-prof-dice="${profDiceString.qq()}"><span class="rd__dc--rollable-text">${text}</span><span class="rd__dc--rollable-dice">${profDiceString}</span></span>`;
};
const pluginDice = (entry, textStack, meta, options) => {
if (expectedPB <= 0 || entry.subType !== "d20" || entry.context?.type == null) return null;
const text = Renderer.getEntryDiceDisplayText(entry);
let profDiceString;
let expert = 1;
let pB = expectedPB;
const bonus = Number(entry.d20mod);
switch (entry.context?.type) {
case "savingThrow": {
const ability = entry.context.ability;
const fromAbility = Parser.getAbilityModNumber(mon[ability]);
pB = bonus - fromAbility;
expert = (pB === expectedPB * 2) ? 2 : 1;
break;
}
case "skillCheck": {
const ability = Parser.skillToAbilityAbv(entry.context.skill.toLowerCase().trim());
const fromAbility = Parser.getAbilityModNumber(mon[ability]);
pB = bonus - fromAbility;
expert = (pB === expectedPB * 2) ? 2 : 1;
break;
}
// add proficiency dice stuff for attack rolls, since those _generally_ have proficiency
// this is not 100% accurate; for example, ghouls don't get their prof bonus on bite attacks
// fixing this would require additional context, which is not (yet) available in the renderer
case "hit": break;
case "abilityCheck": return null;
default: throw new Error(`Unhandled roll context "${entry.context.type}"`);
}
const withoutPB = bonus - pB;
profDiceString = BestiaryPage._addSpacesToDiceExp(`+${expert}d${pB * (3 - expert)}${withoutPB >= 0 ? "+" : ""}${withoutPB}`);
return {
toDisplay: `<span class="rd__roller--roll-prof-bonus">${text}</span><span class="rd__roller--roll-prof-dice">${profDiceString}</span>`,
additionalData: {
"data-roll-prof-type": "d20",
"data-roll-prof-dice": profDiceString,
},
};
};
try {
Renderer.get().addPlugin("string_@dc", pluginDc);
Renderer.get().addPlugin("dice", pluginDice);
this._$pgContent.empty().append(RenderBestiary.$getRenderedCreature(mon, {$btnScaleCr, $btnResetScaleCr, selSummonSpellLevel, selSummonClassLevel}));
} finally {
Renderer.get().removePlugin("dice", pluginDice);
Renderer.get().removePlugin("string_@dc", pluginDc);
}
// endregion
// tokens
this._renderStatblock_doBuildStatsTab_token(mon);
}
_renderStatblock_doBuildStatsTab_token (mon) {
const $tokenImages = [];
// statblock scrolling handler
$(`#wrp-pagecontent`).off("scroll").on("scroll", function () {
$tokenImages.forEach($img => {
$img
.toggle(this.scrollTop < 32)
.css({
opacity: (32 - this.scrollTop) / 32,
top: -this.scrollTop,
});
});
});
const $floatToken = this._$dispToken.empty();
const hasToken = mon.tokenUrl || mon.hasToken;
if (!hasToken) return;
const imgLink = Renderer.monster.getTokenUrl(mon);
const $img = $(`<img src="${imgLink}" class="mon__token" alt="Token Image: ${(mon.name || "").qq()}" ${mon.tokenCredit ? `title="Credit: ${mon.tokenCredit.qq()}"` : ""} loading="lazy">`);
$tokenImages.push($img);
const $lnkToken = $$`<a href="${imgLink}" class="mon__wrp-token" target="_blank" rel="noopener noreferrer">${$img}</a>`
.appendTo($floatToken);
const altArtMeta = [];
if (mon.altArt) altArtMeta.push(...MiscUtil.copy(mon.altArt));
if (mon.variant) {
const variantTokens = mon.variant.filter(it => it.token).map(it => it.token);
if (variantTokens.length) altArtMeta.push(...MiscUtil.copy(variantTokens).map(it => ({...it, displayName: `Variant; ${it.name}`})));
}
if (altArtMeta.length) {
// make a fake entry for the original token
altArtMeta.unshift({$ele: $lnkToken});
const buildEle = (meta) => {
if (!meta.$ele) {
const imgLink = Renderer.monster.getTokenUrl({name: meta.name, source: meta.source, tokenUrl: meta.tokenUrl});
const $img = $(`<img src="${imgLink}" class="mon__token" alt="Token Image: ${(meta.displayName || meta.name || "").qq()}" ${meta.tokenCredit ? `title="Credit: ${meta.tokenCredit.qq()}"` : ""} loading="lazy">`)
.on("error", () => {
$img.attr(
"src",
`data:image/svg+xml,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
<circle cx="200" cy="200" r="175" fill="#b00"/>
<rect x="190" y="40" height="320" width="20" fill="#ddd" transform="rotate(45 200 200)"/>
<rect x="190" y="40" height="320" width="20" fill="#ddd" transform="rotate(135 200 200)"/>
</svg>`,
)}`,
);
});
$tokenImages.push($img);
meta.$ele = $$`<a href="${imgLink}" class="mon__wrp-token" target="_blank" rel="noopener noreferrer">${$img}</a>`
.hide()
.css("max-width", "100%") // hack to ensure the token gets shown at max width on first look
.appendTo($floatToken);
}
};
altArtMeta.forEach(buildEle);
let ix = 0;
const handleClick = (evt, direction) => {
evt.stopPropagation();
evt.preventDefault();
// avoid going off the edge of the list
if (ix === 0 && !~direction) return;
if (ix === altArtMeta.length - 1 && ~direction) return;
ix += direction;
if (!~direction) { // left
if (ix === 0) {
$btnLeft.hide();
$wrpFooter.hide();
}
$btnRight.show();
} else {
$btnLeft.show();
$wrpFooter.show();
if (ix === altArtMeta.length - 1) {
$btnRight.hide();
}
}
altArtMeta.filter(it => it.$ele).forEach(it => it.$ele.hide());
const meta = altArtMeta[ix];
meta.$ele.show();
setTimeout(() => meta.$ele.css("max-width", ""), 10); // hack to clear the earlier 100% width
if (meta.name && meta.source) $footer.html(Renderer.monster.getRenderedAltArtEntry(meta));
else $footer.html("");
$wrpFooter.detach().appendTo(meta.$ele);
$btnLeft.detach().appendTo(meta.$ele);
$btnRight.detach().appendTo(meta.$ele);
};
// append footer first to be behind buttons
const $footer = $(`<div class="mon__token-footer"/>`);
const $wrpFooter = $$`<div class="mon__wrp-token-footer">${$footer}</div>`.hide().appendTo($lnkToken);
const $btnLeft = $$`<div class="mon__btn-token-cycle mon__btn-token-cycle--left"><span class="glyphicon glyphicon-chevron-left"/></div>`
.click(evt => handleClick(evt, -1)).appendTo($lnkToken)
.hide();
const $btnRight = $$`<div class="mon__btn-token-cycle mon__btn-token-cycle--right"><span class="glyphicon glyphicon-chevron-right"/></div>`
.click(evt => handleClick(evt, 1)).appendTo($lnkToken);
}
}
static _addSpacesToDiceExp (exp) {
return exp.replace(/([^0-9d])/gi, " $1 ").replace(/\s+/g, " ").trim().replace(/^([-+])\s*/, "$1");
}
async _pPreloadSublistSources (json) {
if (json.l && json.l.items && json.l.sources) { // if it's an encounter file
json.items = json.l.items;
json.sources = json.l.sources;
}
const loaded = Object.keys(this._loadedSources)
.filter(it => this._loadedSources[it].loaded);
const lowerSources = json.sources.map(it => it.toLowerCase());
const toLoad = Object.keys(this._loadedSources)
.filter(it => !loaded.includes(it))
.filter(it => lowerSources.includes(it.toLowerCase()));
const loadTotal = toLoad.length;
if (loadTotal) {
await Promise.all(toLoad.map(src => this._pLoadSource(src, "yes")));
}
}
async pHandleUnknownHash (link, sub) {
const src = Object.keys(this._loadedSources)
.find(src => src.toLowerCase() === (UrlUtil.decodeHash(link)[1] || "").toLowerCase());
if (src) {
await this._pLoadSource(src, "yes");
Hist.hashChange();
}
}
_pOnLoad_initVisibleItemsDisplay (...args) {
super._pOnLoad_initVisibleItemsDisplay(...arguments);
this._list.on("updated", () => {
this._encounterBuilder.resetCache();
});
}
}
const bestiaryPage = new BestiaryPage();
window.bestiaryPage = bestiaryPage;
const sublistManager = new BestiarySublistManager();
const encounterBuilderCache = new EncounterBuilderCacheBestiaryPage({bestiaryPage});
const encounterBuilderComp = new EncounterBuilderComponentBestiary();
const encounterBuilder = new EncounterBuilderUiBestiary({
cache: encounterBuilderCache,
comp: encounterBuilderComp,
bestiaryPage,
sublistManager,
});
const sublistPlugin = new EncounterBuilderSublistPlugin({
sublistManager,
encounterBuilder,
encounterBuilderComp,
});
sublistManager.addPlugin(sublistPlugin);
bestiaryPage.encounterBuilder = encounterBuilder;
bestiaryPage.sublistManager = sublistManager;
encounterBuilder.bestiaryPage = bestiaryPage;
encounterBuilder.sublistManager = sublistManager;
sublistManager.encounterBuilder = encounterBuilder;
window.addEventListener("load", () => bestiaryPage.pOnLoad());

View File

@@ -0,0 +1,39 @@
import {EncounterBuilderCacheBase} from "../encounterbuilder/encounterbuilder-cache.js";
export class EncounterBuilderCacheBestiaryPage extends EncounterBuilderCacheBase {
_cache = null;
constructor ({bestiaryPage}) {
super();
this._bestiaryPage = bestiaryPage;
}
_build () {
if (this._cache != null) return;
// create a map of {XP: [monster list]}
this._cache = this._getBuiltCache();
}
_getBuiltCache () {
const out = {};
this._bestiaryPage.list_.visibleItems
.map(li => this._bestiaryPage.dataList_[li.ix])
.filter(mon => !this._isUnwantedCreature(mon))
.forEach(mon => {
(out[Parser.crToXpNumber(mon.cr)] ||= []).push(mon);
});
return out;
}
reset () { this._cache = null; }
getCreaturesByXp (xp) {
this._build();
return this._cache[xp] || [];
}
getXpKeys () {
this._build();
return Object.keys(this._cache).map(it => Number(it));
}
}

View File

@@ -0,0 +1,37 @@
import {EncounterBuilderComponent} from "../encounterbuilder/encounterbuilder-component.js";
export class EncounterBuilderComponentBestiary extends EncounterBuilderComponent {
getSublistPluginState () {
return {
// region Special handling for `creatureMetas`
items: this._state.creatureMetas
.map(creatureMeta => ({
h: creatureMeta.getHash(),
c: creatureMeta.count,
customHashId: creatureMeta.customHashId || undefined,
l: creatureMeta.isLocked,
})),
sources: this._state.creatureMetas
.map(creatureMeta => creatureMeta.creature.source)
.unique(),
// endregion
...Object.fromEntries(
Object.entries(this._state)
.filter(([k]) => k !== "creatureMetas")
.map(([k, v]) => [k, MiscUtil.copyFast(v)]),
),
};
}
/** Get a generic representation of the encounter, which can be used elsewhere. */
static getStateFromExportedSublist ({exportedSublist}) {
exportedSublist = MiscUtil.copyFast(exportedSublist);
const out = this._getDefaultState();
Object.keys(out)
.filter(k => exportedSublist[k] != null)
.forEach(k => out[k] = exportedSublist[k]);
return out;
}
}

View File

@@ -0,0 +1,305 @@
import {EncounterBuilderHelpers} from "../utils-list-bestiary.js";
import {EncounterBuilderCreatureMeta} from "../encounterbuilder/encounterbuilder-models.js";
import {EncounterBuilderComponentBestiary} from "./bestiary-encounterbuilder-component.js";
/**
* Serialize/deserialize state from the encounter builder.
*/
export class EncounterBuilderSublistPlugin extends SublistPlugin {
constructor ({sublistManager, encounterBuilder, encounterBuilderComp}) {
super();
this._sublistManager = sublistManager;
this._encounterBuilder = encounterBuilder;
this._encounterBuilderComp = encounterBuilderComp;
}
/* -------------------------------------------- */
async pLoadData ({exportedSublist, isMemoryOnly}) {
const nxt = {};
// Allow URLified versions of keys
const keyLookup = this._encounterBuilderComp.getDefaultStateKeys()
.mergeMap(k => ({[k]: k, [k.toUrlified()]: k}));
if (exportedSublist) {
Object.entries(exportedSublist)
.filter(([, v]) => v != null)
.map(([k, v]) => {
// Only add specific keys, as we do not want to track e.g. sublist state
k = keyLookup[k];
if (!k) return null;
return [k, v];
})
.filter(Boolean)
// Always process `colsExtraAdvanced` first (if available), as used in `playersAdvanced`
.sort(([kA], [kB]) => kA === "colsExtraAdvanced" ? -1 : kB === "colsExtraAdvanced" ? 1 : 0)
.forEach(([k, v]) => {
if (isMemoryOnly) return nxt[k] = MiscUtil.copyFast(v);
// When loading from non-memory sources, expand the data
switch (k) {
case "playersSimple": return nxt[k] = v.map(it => EncounterBuilderComponentBestiary.getDefaultPlayerRow_simple(it));
case "colsExtraAdvanced": return nxt[k] = v.map(it => EncounterBuilderComponentBestiary.getDefaultColExtraAdvanced(it));
case "playersAdvanced": return nxt[k] = v.map(it => EncounterBuilderComponentBestiary.getDefaultPlayerRow_advanced({
...it,
extras: it.extras.map(x => EncounterBuilderComponentBestiary.getDefaultPlayerAdvancedExtra(x)),
colsExtraAdvanced: nxt.colsExtraAdvanced || this._encounterBuilderComp.colsExtraAdvanced,
}));
default: return nxt[k] = v;
}
});
if (nxt.playersSimple) {
nxt.playersSimple
.forEach(wrapped => {
wrapped.entity.count = wrapped.entity.count || 1;
wrapped.entity.level = wrapped.entity.level || 1;
});
}
if (nxt.playersAdvanced) {
nxt.playersAdvanced
.forEach(wrapped => {
wrapped.entity.name = wrapped.entity.name || "";
wrapped.entity.level = wrapped.entity.level || 1;
wrapped.entity.extraCols = wrapped.entity.extraCols
|| (nxt.colsExtraAdvanced || this._encounterBuilderComp.colsExtraAdvanced.map(() => ""));
});
}
}
// Note that we do not set `creatureMetas` here, as `onSublistUpdate` handles this
this._encounterBuilderComp.setStateFromLoaded(nxt);
}
async _pLoadData_getCreatureMetas ({exportedSublist}) {
if (!exportedSublist.items?.length) return [];
return exportedSublist.items
.pSerialAwaitMap(async serialItem => {
const {
entity,
entityBase,
count,
isLocked,
customHashId,
} = await SublistManager.pDeserializeExportedSublistItem(serialItem);
return new EncounterBuilderCreatureMeta({
creature: entity,
count,
isLocked,
customHashId: customHashId,
baseCreature: entityBase,
});
});
}
static async pMutLegacyData ({exportedSublist, isMemoryOnly}) {
if (!exportedSublist) return;
// region Legacy Bestiary Encounter Builder format
if (exportedSublist.p) {
exportedSublist.playersSimple = exportedSublist.p.map(it => EncounterBuilderComponentBestiary.getDefaultPlayerRow_simple(it));
if (!isMemoryOnly) this._mutExternalize({obj: exportedSublist, k: "playersSimple"});
delete exportedSublist.p;
}
if (exportedSublist.l) {
Object.assign(exportedSublist, exportedSublist.l);
delete exportedSublist.l;
}
if (exportedSublist.a != null) {
exportedSublist.isAdvanced = !!exportedSublist.a;
delete exportedSublist.a;
}
if (exportedSublist.c) {
exportedSublist.colsExtraAdvanced = exportedSublist.c.map(name => EncounterBuilderComponentBestiary.getDefaultColExtraAdvanced({name}));
if (!isMemoryOnly) this._mutExternalize({obj: exportedSublist, k: "colsExtraAdvanced"});
delete exportedSublist.c;
}
if (exportedSublist.d) {
exportedSublist.playersAdvanced = exportedSublist.d.map(({n, l, x}) => EncounterBuilderComponentBestiary.getDefaultPlayerRow_advanced({
name: n,
level: l,
extras: x.map(value => EncounterBuilderComponentBestiary.getDefaultPlayerAdvancedExtra({value})),
colsExtraAdvanced: exportedSublist.colsExtraAdvanced,
}));
if (!isMemoryOnly) this._mutExternalize({obj: exportedSublist, k: "playersAdvanced"});
delete exportedSublist.d;
}
// endregion
// region Legacy "reference" format
// These are general save manager properties, but we set them here, as encounter data was the only thing to make
// use of this system.
if (exportedSublist.bestiaryId) {
exportedSublist.saveId = exportedSublist.bestiaryId;
delete exportedSublist.bestiaryId;
}
if (exportedSublist.isRef) {
exportedSublist.managerClient_isReferencable = true;
exportedSublist.managerClient_isLoadAsCopy = false;
}
delete exportedSublist.isRef;
// endregion
}
async pMutLegacyData ({exportedSublist, isMemoryOnly}) {
await this.constructor.pMutLegacyData({exportedSublist, isMemoryOnly});
}
/* -------------------------------------------- */
async pMutSaveableData ({exportedSublist, isForce = false, isMemoryOnly = false}) {
if (!isForce && !this._encounterBuilder.isActive()) return;
[
"playersSimple",
"isAdvanced",
"colsExtraAdvanced",
"playersAdvanced",
].forEach(k => {
exportedSublist[k] = MiscUtil.copyFast(this._encounterBuilderComp[k]);
if (isMemoryOnly) return;
this.constructor._mutExternalize({obj: exportedSublist, k});
});
}
static _WALKER_EXTERNALIZE = null;
static _HANDLERS_EXTERNALIZE = {
array: (arr) => {
if (arr.some(it => !it.id || !it.entity)) return arr;
return arr.map(({entity}) => entity);
},
};
static _mutExternalize ({obj, k}) {
this._WALKER_EXTERNALIZE = this._WALKER_EXTERNALIZE || MiscUtil.getWalker();
obj[k] = this._WALKER_EXTERNALIZE.walk(
obj[k],
this._HANDLERS_EXTERNALIZE,
);
}
/* -------------------------------------------- */
async pDoInitNewState ({prevExportableSublist, evt}) {
// If SHIFT pressed, reset players
const nxt = {
playersSimple: evt.shiftKey ? [] : MiscUtil.copyFast(prevExportableSublist.playersSimple),
playersAdvanced: evt.shiftKey ? [] : MiscUtil.copyFast(prevExportableSublist.playersAdvanced),
};
this._encounterBuilderComp.setPartialStateFromLoaded(nxt);
}
/* -------------------------------------------- */
getDownloadName () {
if (!this._encounterBuilder.isActive()) return null;
return "encounter";
}
getDownloadFileType () {
if (!this._encounterBuilder.isActive()) return null;
return "encounter";
}
getUploadFileTypes ({downloadFileTypeBase}) {
if (!this._encounterBuilder.isActive()) return null;
return [this.getDownloadFileType(), downloadFileTypeBase];
}
/* -------------------------------------------- */
onSublistUpdate () {
this._encounterBuilder.withSublistSyncSuppressed(() => {
// Note that we only update `creatureMetas` here, as this only triggers on direct updates to the underlying sublist.
// For everything else, the `pLoadData` path is used.
this._encounterBuilderComp.creatureMetas = this._sublistManager.sublistItems
.map(sublistItem => EncounterBuilderHelpers.getSublistedCreatureMeta({sublistItem}));
});
}
}
class EncounterBuilderLegacyStorageMigration {
static _VERSION = 2;
static _STORAGE_KEY_LEGACY_SAVED_ENCOUNTERS = "ENCOUNTER_SAVED_STORAGE";
static _STORAGE_KEY_LEGACY_ENCOUNTER = "ENCOUNTER_STORAGE";
static _STORAGE_KEY_LEGACY_ENCOUNTER_MIGRATION_VERSION = "ENCOUNTER_STORAGE_MIGRATION_VERSION";
static _STORAGE_KEY_LEGACY_SAVED_ENCOUNTER_MIGRATION_VERSION = "ENCOUNTER_SAVED_STORAGE_MIGRATION_VERSION";
static register () {
SublistPersistor._LEGACY_MIGRATOR.registerLegacyMigration(this._pMigrateSublist.bind(this));
SaveManager._LEGACY_MIGRATOR.registerLegacyMigration(this._pMigrateSaves.bind(this));
}
static async _pMigrateSublist (stored) {
let version = await StorageUtil.pGet(this._STORAGE_KEY_LEGACY_ENCOUNTER_MIGRATION_VERSION);
if (version && version >= 2) return false;
if (!version) version = 1;
const encounter = await StorageUtil.pGet(this._STORAGE_KEY_LEGACY_ENCOUNTER);
if (!encounter) return false;
Object.entries(encounter)
.forEach(([k, v]) => {
if (stored[k] != null) return;
stored[k] = v;
});
await EncounterBuilderSublistPlugin.pMutLegacyData({exportedSublist: stored});
await StorageUtil.pSet(this._STORAGE_KEY_LEGACY_ENCOUNTER_MIGRATION_VERSION, this._VERSION);
JqueryUtil.doToast(`Migrated active Bestiary encounter from version ${version} to version ${this._VERSION}!`);
return true;
}
static async _pMigrateSaves (stored) {
let version = await StorageUtil.pGet(this._STORAGE_KEY_LEGACY_SAVED_ENCOUNTER_MIGRATION_VERSION);
if (version && version >= 2) return false;
if (!version) version = 1;
const encounters = await StorageUtil.pGet(this._STORAGE_KEY_LEGACY_SAVED_ENCOUNTERS);
if (!encounters) return false;
await Object.entries(encounters.savedEncounters || {})
.pSerialAwaitMap(async ([id, enc]) => {
const legacyData = MiscUtil.copyFast(enc.data || {});
legacyData.name = enc.name || "(Unnamed encounter)";
legacyData.saveId = id;
legacyData.manager_isSaved = true;
await EncounterBuilderSublistPlugin.pMutLegacyData({exportedSublist: legacyData});
const tgt = MiscUtil.getOrSet(stored, "state", "saves", []);
tgt.push({
id: CryptUtil.uid(),
entity: legacyData,
});
});
await StorageUtil.pSet(this._STORAGE_KEY_LEGACY_SAVED_ENCOUNTER_MIGRATION_VERSION, this._VERSION);
JqueryUtil.doToast(`Migrated saved Bestiary encounters from version ${version} to version ${this._VERSION}!`);
return true;
}
}
EncounterBuilderLegacyStorageMigration.register();

View File

@@ -0,0 +1,363 @@
import {EncounterBuilderUi} from "../encounterbuilder/encounterbuilder-ui.js";
import {EncounterBuilderCreatureMeta} from "../encounterbuilder/encounterbuilder-models.js";
import {EncounterBuilderHelpers} from "../utils-list-bestiary.js";
export class EncounterBuilderUiBestiary extends EncounterBuilderUi {
static _HASH_KEY = "encounterbuilder";
_isSuspendSyncToSublist = false;
constructor ({cache, comp, bestiaryPage, sublistManager}) {
super({cache, comp});
this._bestiaryPage = bestiaryPage;
this._sublistManager = sublistManager;
this._lock = new VeLock();
this._cachedTitle = null;
}
initUi () {
document.getElementById("stat-tabs").classList.add("best-ecgen__hidden");
document.getElementById("float-token").classList.add("best-ecgen__hidden");
document.getElementById("wrp-pagecontent").classList.add("best-ecgen__hidden");
$(`#btn-encounterbuild`).click(() => Hist.setSubhash(this.constructor._HASH_KEY, true));
}
render () {
super.render({
$parentRandomAndAdjust: $("#wrp-encounterbuild-random-and-adjust"),
$parentGroupAndDifficulty: $("#wrp-encounterbuild-group-and-difficulty"),
});
this._render_saveLoad();
}
_render_saveLoad () {
const $btnSave = $(`<button class="btn btn-default btn-xs">Save Encounter</button>`)
.click(evt => this._sublistManager.pHandleClick_save(evt));
const $btnLoad = $(`<button class="btn btn-default btn-xs">Load Encounter</button>`)
.click(evt => this._sublistManager.pHandleClick_load(evt));
$$(document.getElementById("best-ecgen__wrp-save-controls"))`<div class="ve-flex-col">
<div class="ve-flex-h-right btn-group">
${$btnSave}
${$btnLoad}
</div>
</div>`;
}
_handleClickCopyAsText (evt) {
let xpTotal = 0;
const ptsCreature = this._sublistManager.sublistItems
.sort((a, b) => SortUtil.ascSortLower(a.name, b.name))
.map(it => {
xpTotal += Parser.crToXpNumber(it.values.cr) * it.data.count;
return `${it.data.count}× ${it.name}`;
});
const ptXp = `${xpTotal.toLocaleString()} XP`;
if (evt.shiftKey) {
MiscUtil.pCopyTextToClipboard([...ptsCreature, ptXp].join("\n")).then(null);
} else {
MiscUtil.pCopyTextToClipboard(`${ptsCreature.join(", ")} (${ptXp})`).then(null);
}
JqueryUtil.showCopiedEffect(evt.currentTarget);
}
_handleClickBackToStatblocks () {
Hist.setSubhash(this.constructor._HASH_KEY, null);
}
_render_groupAndDifficulty ({rdState, $parentGroupAndDifficulty}) {
super._render_groupAndDifficulty({rdState, $parentGroupAndDifficulty});
const $btnSaveToUrl = $(`<button class="btn btn-default btn-xs mr-2">Save to URL</button>`)
.click(() => this._sublistManager.pHandleClick_download({isUrl: true, $eleCopyEffect: $btnSaveToUrl}));
const $btnSaveToFile = $(`<button class="btn btn-default btn-xs">Save to File</button>`)
.click(() => this._sublistManager.pHandleClick_download());
const $btnLoadFromFile = $(`<button class="btn btn-default btn-xs">Load from File</button>`)
.click(evt => this._sublistManager.pHandleClick_upload({isAdditive: evt.shiftKey}));
const $btnCopyAsText = $(`<button class="btn btn-default btn-xs mr-2" title="SHIFT for Multi-Line Format">Copy as Text</button>`).click((evt) => this._handleClickCopyAsText(evt));
const $btnReset = $(`<button class="btn btn-danger btn-xs" title="SHIFT to Reset Players">Reset</button>`)
.click((evt) => this._sublistManager.pHandleClick_new(evt));
const $btnBackToStatblocks = $(`<button class="btn btn-success btn-xs">Back to Stat Blocks</button>`).click((evt) => this._handleClickBackToStatblocks(evt));
$$`<div class="ve-flex-col w-100">
<hr class="hr-1">
<div class="ve-flex-v-center mb-2">
${$btnSaveToUrl}
<div class="btn-group ve-flex-v-center mr-2">
${$btnSaveToFile}
${$btnLoadFromFile}
</div>
${$btnCopyAsText}
${$btnReset}
</div>
<div class="ve-flex">
${$btnBackToStatblocks}
</div>
</div>`
.appendTo($parentGroupAndDifficulty);
}
/* -------------------------------------------- */
withSublistSyncSuppressed (fn) {
try {
this._isSuspendSyncToSublist = true;
fn();
} finally {
this._isSuspendSyncToSublist = false;
}
}
/**
* On encounter builder state change, save to the sublist
*/
_render_hk_doUpdateExternalStates () {
if (this._isSuspendSyncToSublist) return;
this._render_hk_pDoUpdateExternalStates().then(null);
}
async _render_hk_pDoUpdateExternalStates () {
try {
await this._lock.pLock();
await this._render_hk_pDoUpdateExternalStates_();
} finally {
this._lock.unlock();
}
}
async _render_hk_pDoUpdateExternalStates_ () {
const nxtState = await this._sublistManager.pGetExportableSublist({isMemoryOnly: true});
Object.assign(nxtState, this._comp.getSublistPluginState());
await this._sublistManager.pDoLoadExportedSublist(nxtState, {isMemoryOnly: true});
}
/* -------------------------------------------- */
onSublistChange ({$dispCrTotal}) {
const encounterXpInfo = EncounterBuilderCreatureMeta.getEncounterXpInfo(this._comp.creatureMetas, this._getPartyMeta());
const monCount = this._sublistManager.sublistItems.map(it => it.data.count).sum();
$dispCrTotal.html(`${monCount} creature${monCount === 1 ? "" : "s"}; ${encounterXpInfo.baseXp.toLocaleString()} XP (<span class="help" title="Adjusted Encounter XP">Enc</span>: ${(encounterXpInfo.adjustedXp).toLocaleString()} XP)`);
}
/* -------------------------------------------- */
resetCache () { this._cache.reset(); }
isActive () {
return Hist.getSubHash(this.constructor._HASH_KEY) === "true";
}
_showBuilder () {
this._cachedTitle = this._cachedTitle || document.title;
document.title = "Encounter Builder - 5etools";
$(document.body).addClass("best__ecgen-active");
this._bestiaryPage.doDeselectAll();
this._sublistManager.doSublistDeselectAll();
}
_hideBuilder () {
if (this._cachedTitle) {
document.title = this._cachedTitle;
this._cachedTitle = null;
}
$(document.body).removeClass("best__ecgen-active");
}
_handleClick ({evt, mode, entity}) {
if (mode === "add") {
return this._sublistManager.pDoSublistAdd({entity, doFinalize: true, addCount: evt.shiftKey ? 5 : 1});
}
return this._sublistManager.pDoSublistSubtract({entity, subtractCount: evt.shiftKey ? 5 : 1});
}
async _pHandleShuffleClick ({evt, sublistItem}) {
const creatureMeta = EncounterBuilderHelpers.getSublistedCreatureMeta({sublistItem});
this._doShuffle({creatureMeta});
}
handleSubhash () {
if (Hist.getSubHash(this.constructor._HASH_KEY) === "true") this._showBuilder();
else this._hideBuilder();
}
async doStatblockMouseOver ({evt, ele, source, hash, customHashId}) {
return Renderer.hover.pHandleLinkMouseOver(
evt,
ele,
{
page: UrlUtil.PG_BESTIARY,
source,
hash,
customHashId,
},
);
}
static getTokenHoverMeta (mon) {
const hasToken = mon.tokenUrl || mon.hasToken;
if (!hasToken) return null;
return Renderer.hover.getMakePredefinedHover(
{
type: "image",
href: {
type: "external",
url: Renderer.monster.getTokenUrl(mon),
},
data: {
hoverTitle: `Token \u2014 ${mon.name}`,
},
},
{isBookContent: true},
);
}
static _getFauxMon (name, source, scaledTo) {
return {name, source, _isScaledCr: scaledTo != null, _scaledCr: scaledTo};
}
async pDoCrChange ($iptCr, monScaled, scaledTo) {
if (!$iptCr) return; // Should never occur, but if the creature has a non-adjustable CR, this field will not exist
try {
await this._lock.pLock();
await this._pDoCrChange({$iptCr, monScaled, scaledTo});
} finally {
this._lock.unlock();
}
}
async _pDoCrChange ({$iptCr, monScaled, scaledTo}) {
// Fetch original
const mon = await DataLoader.pCacheAndGetHash(
UrlUtil.PG_BESTIARY,
UrlUtil.autoEncodeHash(monScaled),
);
const baseCr = mon.cr.cr || mon.cr;
if (baseCr == null) return;
const baseCrNum = Parser.crToNumber(baseCr);
const targetCr = $iptCr.val();
if (!Parser.isValidCr(targetCr)) {
JqueryUtil.doToast({
content: `"${$iptCr.val()}" is not a valid Challenge Rating! Please enter a valid CR (0-30). For fractions, "1/X" should be used.`,
type: "danger",
});
$iptCr.val(Parser.numberToCr(scaledTo || baseCr));
return;
}
const targetCrNum = Parser.crToNumber(targetCr);
if (targetCrNum === scaledTo) return;
const state = await this._sublistManager.pGetExportableSublist({isForceIncludePlugins: true, isMemoryOnly: true});
const toFindHash = UrlUtil.autoEncodeHash(mon);
const toFindUid = !(scaledTo == null || baseCrNum === scaledTo) ? Renderer.monster.getCustomHashId(this.constructor._getFauxMon(mon.name, mon.source, scaledTo)) : null;
const ixCurrItem = state.items.findIndex(it => {
if (scaledTo == null || scaledTo === baseCrNum) return !it.customHashId && it.h === toFindHash;
else return it.customHashId === toFindUid;
});
if (!~ixCurrItem) throw new Error(`Could not find previously sublisted item!`);
const toFindNxtUid = baseCrNum !== targetCrNum ? Renderer.monster.getCustomHashId(this.constructor._getFauxMon(mon.name, mon.source, targetCrNum)) : null;
const nextItem = state.items.find(it => {
if (targetCrNum === baseCrNum) return !it.customHashId && it.h === toFindHash;
else return it.customHashId === toFindNxtUid;
});
// if there's an existing item with a matching UID (or lack of), merge into it
if (nextItem) {
const curr = state.items[ixCurrItem];
nextItem.c = `${Number(nextItem.c || 1) + Number(curr.c || 1)}`;
state.items.splice(ixCurrItem, 1);
} else {
// if we're returning to the original CR, wipe the existing UID. Otherwise, adjust it
if (targetCrNum === baseCrNum) delete state.items[ixCurrItem].customHashId;
else state.items[ixCurrItem].customHashId = Renderer.monster.getCustomHashId(this.constructor._getFauxMon(mon.name, mon.source, targetCrNum));
}
await this._sublistManager.pDoLoadExportedSublist(state, {isMemoryOnly: true});
}
getButtons (monId) {
return e_({
tag: "span",
clazz: `best-ecgen__visible col-1 no-wrap pl-0 btn-group`,
click: evt => {
evt.preventDefault();
evt.stopPropagation();
},
children: [
e_({
tag: "button",
title: `Add (SHIFT for 5)`,
clazz: `btn btn-success btn-xs best-ecgen__btn-list`,
click: evt => this._handleClick({evt, entity: this._bestiaryPage.dataList_[monId], mode: "add"}),
children: [
e_({
tag: "span",
clazz: `glyphicon glyphicon-plus`,
}),
],
}),
e_({
tag: "button",
title: `Subtract (SHIFT for 5)`,
clazz: `btn btn-danger btn-xs best-ecgen__btn-list`,
click: evt => this._handleClick({evt, entity: this._bestiaryPage.dataList_[monId], mode: "subtract"}),
children: [
e_({
tag: "span",
clazz: `glyphicon glyphicon-minus`,
}),
],
}),
],
});
}
getSublistButtonsMeta (sublistItem) {
const $btnAdd = $(`<button title="Add (SHIFT for 5)" class="btn btn-success btn-xs best-ecgen__btn-list"><span class="glyphicon glyphicon-plus"></span></button>`)
.click(evt => this._handleClick({evt, entity: sublistItem.data.entity, mode: "add"}));
const $btnSub = $(`<button title="Subtract (SHIFT for 5)" class="btn btn-danger btn-xs best-ecgen__btn-list"><span class="glyphicon glyphicon-minus"></span></button>`)
.click(evt => this._handleClick({evt, entity: sublistItem.data.entity, mode: "subtract"}));
const $btnRandomize = $(`<button title="Randomize Monster" class="btn btn-default btn-xs best-ecgen__btn-list"><span class="glyphicon glyphicon-random"></span></button>`)
.click(evt => this._pHandleShuffleClick({evt, sublistItem}));
const $btnLock = $(`<button title="Lock Monster against Randomizing/Adjusting" class="btn btn-default btn-xs best-ecgen__btn-list"><span class="glyphicon glyphicon-lock"></span></button>`)
.click(() => this._sublistManager.pSetDataEntry({sublistItem, key: "isLocked", value: !sublistItem.data.isLocked}))
.toggleClass("active", sublistItem.data.isLocked);
const $wrp = $$`<span class="best-ecgen__visible col-1-5 no-wrap pl-0 btn-group">
${$btnAdd}
${$btnSub}
${$btnRandomize}
${$btnLock}
</span>`
.click(evt => {
evt.preventDefault();
evt.stopPropagation();
});
return {
$wrp,
fnUpdate: () => $btnLock.toggleClass("active", sublistItem.data.isLocked),
};
}
}

699
js/blocklist-ui.js Normal file
View File

@@ -0,0 +1,699 @@
"use strict";
class BlocklistUtil {
static _IGNORED_CATEGORIES = new Set([
"_meta",
"linkedLootTables",
// `items-base.json`
"itemProperty",
"itemType",
"itemEntry",
"itemTypeAdditionalEntries",
]);
static _BASIC_FILES = [
"actions.json",
"adventures.json",
"backgrounds.json",
"books.json",
"cultsboons.json",
"charcreationoptions.json",
"conditionsdiseases.json",
"deities.json",
"feats.json",
"items-base.json",
"magicvariants.json",
"items.json",
"objects.json",
"optionalfeatures.json",
"psionics.json",
"recipes.json",
"rewards.json",
"trapshazards.json",
"variantrules.json",
"vehicles.json",
"decks.json",
];
static async pLoadData () {
const out = {};
this._addData(out, {monster: MiscUtil.copy(await DataUtil.monster.pLoadAll())});
this._addData(out, {spell: MiscUtil.copy(await DataUtil.spell.pLoadAll())});
this._addData(out, MiscUtil.copy(await DataUtil.class.loadRawJSON()));
this._addData(out, MiscUtil.copy(await DataUtil.race.loadJSON({isAddBaseRaces: true})));
const jsons = await Promise.all(this._BASIC_FILES.map(url => DataUtil.loadJSON(`${Renderer.get().baseUrl}data/${url}`)));
for (let json of jsons) {
if (json.magicvariant) {
json = MiscUtil.copy(json);
json.magicvariant.forEach(it => it.source = SourceUtil.getEntitySource(it));
}
this._addData(out, json);
}
return out;
}
static _addData (out, json) {
Object.keys(json)
.filter(it => !this._IGNORED_CATEGORIES.has(it))
.forEach(k => out[k] ? out[k] = out[k].concat(json[k]) : out[k] = json[k]);
}
}
globalThis.BlocklistUtil = BlocklistUtil;
class BlocklistUi {
constructor (
{
$wrpContent,
data,
isCompactUi = false,
isAutoSave = true,
},
) {
this._$wrpContent = $wrpContent;
this._data = data;
this._isCompactUi = !!isCompactUi;
this._isAutoSave = !!isAutoSave;
this._excludes = ExcludeUtil.getList();
this._subBlocklistEntries = {};
this._allSources = null;
this._allCategories = null;
this._$wrpControls = null;
this._comp = null;
this._$wrpSelName = null;
this._metaSelName = null;
}
_addExclude (displayName, hash, category, source) {
if (!this._excludes.find(row => row.source === source && row.category === category && row.hash === hash)) {
this._excludes.push({displayName, hash, category, source});
if (this._isAutoSave) ExcludeUtil.pSetList(MiscUtil.copy(this._excludes)).then(null);
return true;
}
return false;
}
_removeExclude (hash, category, source) {
const ix = this._excludes.findIndex(row => row.source === source && row.category === category && row.hash === hash);
if (~ix) {
this._excludes.splice(ix, 1);
if (this._isAutoSave) ExcludeUtil.pSetList(MiscUtil.copy(this._excludes)).then(null);
}
}
_resetExcludes () {
this._excludes = [];
if (this._isAutoSave) ExcludeUtil.pSetList(MiscUtil.copy(this._excludes)).then(null);
}
async _pInitSubBlocklistEntries () {
for (const c of (this._data.class || [])) {
const classHash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](c);
const subBlocklist = this._data.classFeature
.filter(it => it.className === c.name && it.classSource === c.source)
.map(it => {
const hash = UrlUtil.URL_TO_HASH_BUILDER["classFeature"](it);
const displayName = `${this._getDisplayNamePrefix_classFeature(it)}${it.name}`;
return {displayName, hash, category: "classFeature", source: it.source};
});
MiscUtil.set(this._subBlocklistEntries, "class", classHash, subBlocklist);
}
for (const sc of (this._data.subclass || [])) {
const subclassHash = UrlUtil.URL_TO_HASH_BUILDER["subclass"](sc);
const subBlocklist = this._data.subclassFeature
.filter(it => it.className === sc.className && it.classSource === sc.classSource && it.subclassShortName === sc.shortName && it.subclassSource === sc.source)
.map(it => {
const hash = UrlUtil.URL_TO_HASH_BUILDER["subclassFeature"](it);
const displayName = `${this._getDisplayNamePrefix_subclassFeature(it)}${it.name}`;
return {displayName, hash, category: "subclassFeature", source: it.source};
});
MiscUtil.set(this._subBlocklistEntries, "subclass", subclassHash, subBlocklist);
}
for (const it of (this._data.itemGroup || [])) {
const itemGroupHash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_ITEMS](it);
const subBlocklist = (await it.items.pSerialAwaitMap(async uid => {
let [name, source] = uid.split("|");
source = Parser.getTagSource("item", source);
const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_ITEMS]({name, source});
const item = await DataLoader.pCacheAndGet(UrlUtil.PG_ITEMS, source, hash);
if (!item) return null;
return {displayName: item.name, hash, category: "item", source: item.source};
})).filter(Boolean);
MiscUtil.set(this._subBlocklistEntries, "itemGroup", itemGroupHash, subBlocklist);
}
for (const it of (this._data.race || []).filter(it => it._isBaseRace || it._versions?.length)) {
const baseRaceHash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_RACES](it);
const subBlocklist = [];
if (it._isBaseRace) {
subBlocklist.push(
...it._subraces.map(sr => {
const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_RACES](sr);
return {displayName: sr.name, hash, category: "race", source: sr.source};
}),
);
}
if (it._versions?.length) {
subBlocklist.push(
...DataUtil.proxy.getVersions(it.__prop, it).map(ver => {
const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_RACES](ver);
return {displayName: ver.name, hash, category: "race", source: ver.source};
}),
);
}
MiscUtil.set(this._subBlocklistEntries, "race", baseRaceHash, subBlocklist);
}
}
_getDisplayValues (category, source) {
const displaySource = source === "*" ? source : Parser.sourceJsonToFullCompactPrefix(source);
const displayCategory = category === "*" ? category : Parser.getPropDisplayName(category);
return {displaySource, displayCategory};
}
_renderList () {
this._excludes
.sort((a, b) => SortUtil.ascSort(a.source, b.source) || SortUtil.ascSort(a.category, b.category) || SortUtil.ascSort(a.displayName, b.displayName))
.forEach(({displayName, hash, category, source}) => this._addListItem(displayName, hash, category, source));
this._list.init();
this._list.update();
}
_getDisplayNamePrefix_classFeature (it) { return `${it.className} ${it.level}: `; }
_getDisplayNamePrefix_subclassFeature (it) { return `${it.className} (${it.subclassShortName}) ${it.level}: `; }
async pInit () {
await this._pInitSubBlocklistEntries();
this._pInit_initUi();
this._pInit_render();
this._renderList();
}
_pInit_initUi () {
this._$wrpControls = $(`<div ${this._isCompactUi ? "" : `class="bg-solid py-5 px-3 shadow-big b-1p"`}></div>`);
const $iptSearch = $(`<input type="search" class="search form-control lst__search lst__search--no-border-h h-100">`).disableSpellcheck();
const $btnReset = $(`<button class="btn btn-default">Reset Search</button>`)
.click(() => {
$iptSearch.val("");
this._list.reset();
});
const $wrpFilterTools = $$`<div class="input-group input-group--bottom ve-flex no-shrink">
<button class="col-4 sort btn btn-default btn-xs ve-grow" data-sort="source">Source</button>
<button class="col-2 sort btn btn-default btn-xs" data-sort="category">Category</button>
<button class="col-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
<button class="col-1 sort btn btn-default btn-xs" disabled>&nbsp;</button>
</div>`;
const $wrpList = $(`<div class="list-display-only smooth-scroll overflow-y-auto h-100 min-h-0"></div>`);
$$(this._$wrpContent.empty())`
${this._$wrpControls}
<hr class="${this._isCompactUi ? "hr-2" : "hr-5"}">
<h4 class="my-0">Blocklist</h4>
<div class="text-muted ${this._isCompactUi ? "mb-2" : "mb-3"}"><i>Rows marked with an asterisk (*) in a field match everything in that field.</i></div>
<div class="ve-flex-col min-h-0">
<div class="ve-flex-v-stretch input-group input-group--top no-shrink">
<div class="w-100 relative">
${$iptSearch}
<div class="lst__wrp-search-glass no-events ve-flex-vh-center"><span class="glyphicon glyphicon-search"></span></div>
<div class="lst__wrp-search-visible no-events ve-flex-vh-center"></div>
</div>
${$btnReset}
</div>
${$wrpFilterTools}
${$wrpList}
</div>`;
this._list = new List({
$iptSearch,
$wrpList,
isUseJquery: true,
});
this._listId = 1;
SortUtil.initBtnSortHandlers($wrpFilterTools, this._list);
}
_pInit_render () {
// region Helper controls
const $btnExcludeAllUa = $(this._getBtnHtml_addToBlocklist())
.click(() => this._addAllUa());
const $btnIncludeAllUa = $(this._getBtnHtml_removeFromBlocklist())
.click(() => this._removeAllUa());
const $btnExcludeAllSources = $(this._getBtnHtml_addToBlocklist())
.click(() => this._addAllSources());
const $btnIncludeAllSources = $(this._getBtnHtml_removeFromBlocklist())
.click(() => this._removeAllSources());
const $btnExcludeAllComedySources = $(this._getBtnHtml_addToBlocklist())
.click(() => this._addAllComedySources());
const $btnIncludeAllComedySources = $(this._getBtnHtml_removeFromBlocklist())
.click(() => this._removeAllComedySources());
const $btnExcludeAllNonForgottenRealmsSources = $(this._getBtnHtml_addToBlocklist())
.click(() => this._addAllNonForgottenRealms());
const $btnIncludeAllNonForgottenRealmsSources = $(this._getBtnHtml_removeFromBlocklist())
.click(() => this._removeAllNonForgottenRealms());
// endregion
// region Primary controls
const sourceSet = new Set();
const propSet = new Set();
Object.keys(this._data).forEach(prop => {
propSet.add(prop);
const arr = this._data[prop];
arr.forEach(it => sourceSet.has(it.source) || sourceSet.add(it.source));
});
this._allSources = [...sourceSet]
.sort((a, b) => SortUtil.ascSort(Parser.sourceJsonToFull(a), Parser.sourceJsonToFull(b)));
this._allCategories = [...propSet]
.sort((a, b) => SortUtil.ascSort(Parser.getPropDisplayName(a), Parser.getPropDisplayName(b)));
this._comp = new BlocklistUi.Component();
const $selSource = ComponentUiUtil.$getSelSearchable(
this._comp,
"source",
{
values: ["*", ...this._allSources],
fnDisplay: val => val === "*" ? val : Parser.sourceJsonToFull(val),
},
);
this._comp.addHook("source", () => this._doHandleSourceCategorySelChange());
const $selCategory = ComponentUiUtil.$getSelSearchable(
this._comp,
"category",
{
values: ["*", ...this._allCategories],
fnDisplay: val => val === "*" ? val : Parser.getPropDisplayName(val),
},
);
this._comp.addHook("category", () => this._doHandleSourceCategorySelChange());
this._$wrpSelName = $(`<div class="w-100 ve-flex"></div>`);
this._doHandleSourceCategorySelChange();
const $btnAddExclusion = $(`<button class="btn btn-default btn-xs">Add to Blocklist</button>`)
.click(() => this._pAdd());
// endregion
// Utility controls
const $btnSendToFoundry = !IS_VTT && ExtensionUtil.ACTIVE
? $(`<button title="Send to Foundry" class="btn btn-xs btn-default mr-2"><span class="glyphicon glyphicon-send"></span></button>`)
.click(evt => this._pDoSendToFoundry({isTemp: !!evt.shiftKey}))
: null;
const $btnExport = $(`<button class="btn btn-default btn-xs">Export List</button>`)
.click(() => this._export());
const $btnImport = $(`<button class="btn btn-default btn-xs" title="SHIFT for Add Only">Import List</button>`)
.click(evt => this._pImport(evt));
const $btnReset = $(`<button class="btn btn-danger btn-xs">Reset List</button>`)
.click(async () => {
if (!await InputUiUtil.pGetUserBoolean({title: "Reset Blocklist", htmlDescription: "Are you sure?", textYes: "Yes", textNo: "Cancel"})) return;
this._reset();
});
// endregion
$$`<div class="${this._isCompactUi ? "mb-2" : "mb-5"} ve-flex-v-center">
<div class="ve-flex-vh-center mr-4">
<div class="mr-2">UA/Etc. Sources</div>
<div class="ve-flex-v-center btn-group">
${$btnExcludeAllUa}
${$btnIncludeAllUa}
</div>
</div>
<div class="ve-flex-vh-center mr-3">
<div class="mr-2">Comedy Sources</div>
<div class="ve-flex-v-center btn-group">
${$btnExcludeAllComedySources}
${$btnIncludeAllComedySources}
</div>
</div>
<div class="ve-flex-vh-center mr-3">
<div class="mr-2">Non-<i>Forgotten Realms</i></div>
<div class="ve-flex-v-center btn-group">
${$btnExcludeAllNonForgottenRealmsSources}
${$btnIncludeAllNonForgottenRealmsSources}
</div>
</div>
<div class="ve-flex-vh-center mr-3">
<div class="mr-2">All Sources</div>
<div class="ve-flex-v-center btn-group">
${$btnExcludeAllSources}
${$btnIncludeAllSources}
</div>
</div>
</div>
<div class="ve-flex-v-end ${this._isCompactUi ? "mb-2" : "mb-5"}">
<div class="ve-flex-col w-25 pr-2">
<label class="mb-1">Source</label>
${$selSource}
</div>
<div class="ve-flex-col w-25 px-2">
<label class="mb-1">Category</label>
${$selCategory}
</div>
<div class="ve-flex-col w-25 px-2">
<label class="mb-1">Name</label>
${this._$wrpSelName}
</div>
<div class="ve-flex-col w-25 pl-2">
<div class="mt-auto">
${$btnAddExclusion}
</div>
</div>
</div>
<div class="w-100 ve-flex-v-center">
${$btnSendToFoundry}
<div class="ve-flex-v-center btn-group mr-2">
${$btnExport}
${$btnImport}
</div>
${$btnReset}
</div>`.appendTo(this._$wrpControls.empty());
}
_getBtnHtml_addToBlocklist () {
return `<button class="btn btn-danger btn-xs w-20p h-21p ve-flex-vh-center" title="Add to Blocklist"><span class="glyphicon glyphicon-trash"></span></button>`;
}
_getBtnHtml_removeFromBlocklist () {
return `<button class="btn btn-success btn-xs w-20p h-21p ve-flex-vh-center" title="Remove from Blocklist"><span class="glyphicon glyphicon-thumbs-up"></span></button>`;
}
_doHandleSourceCategorySelChange () {
if (this._metaSelName) this._metaSelName.unhook();
this._$wrpSelName.empty();
const filteredData = this._doHandleSourceCategorySelChange_getFilteredData();
const $selName = ComponentUiUtil.$getSelSearchable(
this._comp,
"name",
{
values: [
{hash: "*", name: "*", category: this._comp.category},
...this._getDataUids(filteredData),
],
fnDisplay: val => val.name,
},
);
this._$wrpSelName.append($selName);
}
_doHandleSourceCategorySelChange_getFilteredData () {
// If the user has not selected either of source or category, avoid displaying the entire data set
if (this._comp.source === "*" && this._comp.category === "*") return [];
if (this._comp.source === "*" && this._comp.category !== "*") {
return this._data[this._comp.category].map(it => ({...it, category: this._comp.category}));
}
if (this._comp.source !== "*" && this._comp.category === "*") {
return Object.entries(this._data).map(([cat, arr]) => arr.filter(it => it.source === this._comp.source).map(it => ({...it, category: cat}))).flat();
}
return this._data[this._comp.category].filter(it => it.source === this._comp.source).map(it => ({...it, category: this._comp.category}));
}
_getDataUids (arr) {
const copy = arr
.map(it => {
switch (it.category) {
case "subclass": {
return {...it, name: it.name, source: it.source, className: it.className, classSource: it.classSource, shortName: it.shortName};
}
case "classFeature": {
return {...it, name: it.name, source: it.source, className: it.className, classSource: it.classSource, level: it.level};
}
case "subclassFeature": {
return {...it, name: it.name, source: it.source, className: it.className, classSource: it.classSource, level: it.level, subclassShortName: it.subclassShortName, subclassSource: it.subclassSource};
}
case "adventure":
case "book": {
return {...it, name: it.name, source: it.source, id: it.id};
}
default: {
return it;
}
}
})
.sort(this.constructor._fnSortDataUids.bind(this.constructor));
const dupes = new Set();
return copy.map((it, i) => {
let prefix = "";
let hash;
if (UrlUtil.URL_TO_HASH_BUILDER[it.category]) {
hash = UrlUtil.URL_TO_HASH_BUILDER[it.category](it);
}
switch (it.category) {
case "subclass": prefix = `${it.className}: `; break;
case "classFeature": prefix = this._getDisplayNamePrefix_classFeature(it); break;
case "subclassFeature": prefix = this._getDisplayNamePrefix_subclassFeature(it); break;
}
if (!hash) hash = UrlUtil.encodeForHash([it.name, it.source]);
const displayName = `${prefix}${it.name}${(dupes.has(it.name) || (copy[i + 1] && copy[i + 1].name === it.name)) ? ` (${Parser.sourceJsonToAbv(it.source)})` : ""}`;
dupes.add(it.name);
return {
hash,
name: displayName,
category: it.category,
};
});
}
static _fnSortDataUids (a, b) {
if (a.category !== b.category) return SortUtil.ascSortLower(a.category, b.category);
switch (a.category) {
case "subclass": {
return SortUtil.ascSortLower(a.className, b.className) || SortUtil.ascSortLower(a.name, b.name) || SortUtil.ascSortLower(a.source, b.source);
}
case "classFeature": {
return SortUtil.ascSortLower(a.className, b.className) || SortUtil.ascSort(a.level, b.level) || SortUtil.ascSortLower(a.name, b.name) || SortUtil.ascSortLower(a.source, b.source);
}
case "subclassFeature": {
return SortUtil.ascSortLower(a.className, b.className) || SortUtil.ascSortLower(a.subclassShortName, b.subclassShortName) || SortUtil.ascSort(a.level, b.level) || SortUtil.ascSortLower(a.name, b.name) || SortUtil.ascSortLower(a.source, b.source);
}
default: {
return SortUtil.ascSortLower(a.name, b.name) || SortUtil.ascSortLower(a.source, b.source);
}
}
}
_addListItem (displayName, hash, category, source) {
const display = this._getDisplayValues(category, source);
const id = this._listId++;
const sourceFull = Parser.sourceJsonToFull(source);
const $btnRemove = $(`<button class="btn btn-xxs btn-danger">Remove</button>`)
.click(() => {
this._remove(id, hash, category, source);
});
const $ele = $$`<div class="${this._addListItem_getItemStyles()}">
<span class="col-4 ve-text-center">${sourceFull}</span>
<span class="col-2 ve-text-center">${display.displayCategory}</span>
<span class="col-5 ve-text-center">${displayName}</span>
<span class="col-1 ve-text-center">${$btnRemove}</span>
</div>`;
const listItem = new ListItem(
id,
$ele,
displayName,
{
category: display.displayCategory,
source: sourceFull,
},
{
displayName: displayName,
hash: hash,
category: category,
source: source,
},
);
this._list.addItem(listItem);
}
_addListItem_getItemStyles () { return `no-click ve-flex-v-center lst__row lst--border veapp__list-row lst__row-inner no-shrink`; }
async _pAdd () {
const {hash, name: displayName, category: categoryName} = this._comp.name;
const category = categoryName === "*" ? this._comp.category : categoryName;
if (
this._comp.source === "*"
&& category === "*"
&& hash === "*"
&& !await InputUiUtil.pGetUserBoolean({title: "Exclude All", htmlDescription: `This will exclude all content from all list pages. Are you sure?`, textYes: "Yes", textNo: "Cancel"})
) return;
if (this._addExclude(displayName, hash, category, this._comp.source)) {
this._addListItem(displayName, hash, category, this._comp.source);
const subBlocklist = MiscUtil.get(this._subBlocklistEntries, category, hash);
if (subBlocklist) {
subBlocklist.forEach(it => {
const {displayName, hash, category, source} = it;
this._addExclude(displayName, hash, category, source);
this._addListItem(displayName, hash, category, source);
});
}
this._list.update();
}
}
_addMassSources ({fnFilter = null} = {}) {
const sources = fnFilter
? this._allSources.filter(source => fnFilter(source))
: this._allSources;
sources
.forEach(source => {
if (this._addExclude("*", "*", "*", source)) {
this._addListItem("*", "*", "*", source);
}
});
this._list.update();
}
_removeMassSources ({fnFilter = null} = {}) {
const sources = fnFilter
? this._allSources.filter(source => fnFilter(source))
: this._allSources;
sources
.forEach(source => {
const item = this._list.items.find(it => it.data.hash === "*" && it.data.category === "*" && it.data.source === source);
if (item) {
this._remove(item.ix, "*", "*", source, {isSkipListUpdate: true});
}
});
this._list.update();
}
_addAllUa () { this._addMassSources({fnFilter: SourceUtil.isNonstandardSource}); }
_removeAllUa () { this._removeMassSources({fnFilter: SourceUtil.isNonstandardSource}); }
_addAllSources () { this._addMassSources(); }
_removeAllSources () { this._removeMassSources(); }
_addAllComedySources () { this._addMassSources({fnFilter: source => Parser.SOURCES_COMEDY.has(source)}); }
_removeAllComedySources () { this._removeMassSources({fnFilter: source => Parser.SOURCES_COMEDY.has(source)}); }
_addAllNonForgottenRealms () { this._addMassSources({fnFilter: source => Parser.SOURCES_NON_FR.has(source)}); }
_removeAllNonForgottenRealms () { this._removeMassSources({fnFilter: source => Parser.SOURCES_NON_FR.has(source)}); }
_remove (ix, hash, category, source, {isSkipListUpdate = false} = {}) {
this._removeExclude(hash, category, source);
this._list.removeItemByIndex(ix);
if (!isSkipListUpdate) this._list.update();
}
async _pDoSendToFoundry () {
await ExtensionUtil.pDoSend({type: "5etools.blocklist.excludes", data: this._excludes});
}
_export () {
DataUtil.userDownload(`content-blocklist`, {fileType: "content-blocklist", blocklist: this._excludes});
}
async _pImport_getUserUpload () {
return DataUtil.pUserUpload({expectedFileTypes: ["content-blocklist", "content-blacklist"]}); // Supports old fileType "content-blacklist"
}
async _pImport (evt) {
const {jsons, errors} = await this._pImport_getUserUpload();
DataUtil.doHandleFileLoadErrorsGeneric(errors);
if (!jsons?.length) return;
// clear list display
this._list.removeAllItems();
this._list.update();
const json = jsons[0];
// update storage
const nxtList = evt.shiftKey
// Supports old key "blacklist"
? MiscUtil.copy(this._excludes).concat(json.blocklist || json.blacklist || [])
: json.blocklist || json.blacklist || [];
this._excludes = nxtList;
if (this._isAutoSave) await ExcludeUtil.pSetList(nxtList);
// render list display
this._renderList();
}
_reset () {
this._resetExcludes();
this._list.removeAllItems();
this._list.update();
}
}
globalThis.BlocklistUi = BlocklistUi;
BlocklistUi.Component = class extends BaseComponent {
get source () { return this._state.source; }
get category () { return this._state.category; }
get name () { return this._state.name; }
addHook (prop, hk) { return this._addHookBase(prop, hk); }
_getDefaultState () {
return {
source: "*",
category: "*",
name: {
hash: "*",
name: "*",
category: "*",
},
};
}
};

19
js/blocklist.js Normal file
View File

@@ -0,0 +1,19 @@
"use strict";
class Blocklist {
static async pInit () {
const data = await BlocklistUtil.pLoadData();
const ui = new BlocklistUi({$wrpContent: $(`#blocklist-content`), data});
await ui.pInit();
window.dispatchEvent(new Event("toolsLoaded"));
}
}
window.addEventListener("load", async () => {
await Promise.all([
PrereleaseUtil.pInit(),
BrewUtil2.pInit(),
]);
await ExcludeUtil.pInitialise();
await Blocklist.pInit();
});

36
js/book.js Normal file
View File

@@ -0,0 +1,36 @@
"use strict";
const JSON_URL = "data/books.json";
window.addEventListener("load", async () => {
BookUtil.$dispBook = $(`#pagecontent`);
await Promise.all([
PrereleaseUtil.pInit(),
BrewUtil2.pInit(),
]);
ExcludeUtil.pInitialise().then(null); // don't await, as this is only used for search
DataUtil.loadJSON(JSON_URL).then(onJsonLoad);
});
async function onJsonLoad (data) {
BookUtil.baseDataUrl = "data/book/book-";
BookUtil.allPageUrl = "books.html";
BookUtil.propHomebrewData = "bookData";
BookUtil.typeTitle = "Book";
BookUtil.initLinkGrabbers();
BookUtil.initScrollTopFloat();
BookUtil.contentType = "book";
BookUtil.bookIndex = data?.book || [];
$(`.book-head-message`).text(`Select a book from the list on the left`);
$(`.book-loading-message`).text(`Select a book to begin`);
BookUtil.bookIndexPrerelease = (await PrereleaseUtil.pGetBrewProcessed())?.book || [];
BookUtil.bookIndexBrew = (await BrewUtil2.pGetBrewProcessed())?.book || [];
window.onhashchange = BookUtil.booksHashChange.bind(BookUtil);
await BookUtil.booksHashChange();
window.dispatchEvent(new Event("toolsLoaded"));
}

33
js/books.js Normal file
View File

@@ -0,0 +1,33 @@
"use strict";
class BooksList extends AdventuresBooksList {
constructor () {
super({
contentsUrl: "data/books.json",
fnSort: AdventuresBooksList._sortAdventuresBooks.bind(AdventuresBooksList),
sortByInitial: "group",
sortDirInitial: "asc",
dataProp: "book",
rootPage: "book.html",
enhanceRowDataFn: (bk) => {
bk._pubDate = new Date(bk.published || "1970-01-01");
},
rowBuilderFn: (bk) => {
return `
<span class="col-1-3 ve-text-center">${AdventuresBooksList._getGroupStr(bk)}</span>
<span class="col-8-5 bold">${bk.name}</span>
<span class="ve-grow ve-text-center code">${AdventuresBooksList._getDateStr(bk)}</span>
`;
},
});
}
}
const booksList = new BooksList();
window.addEventListener("load", () => booksList.pOnPageLoad());
function handleBrew (homebrew) {
booksList.addData(homebrew);
return Promise.resolve();
}

199
js/bookslist.js Normal file
View File

@@ -0,0 +1,199 @@
"use strict";
class AdventuresBooksList {
static _getDateStr (advBook) {
if (!advBook.published) return "\u2014";
const date = new Date(advBook.published);
return DatetimeUtil.getDateStr({date, isShort: true, isPad: true});
}
static _getGroupStr (advBook) {
const group = advBook.group || "other";
const entry = SourceUtil.ADV_BOOK_GROUPS.find(it => it.group === group);
return entry.displayName;
}
static _sortAdventuresBooks (dataList, a, b, o) {
a = dataList[a.ix];
b = dataList[b.ix];
if (o.sortBy === "name") return this._sortAdventuresBooks_byName(a, b, o);
if (o.sortBy === "storyline") return this._sortAdventuresBooks_orFallback(SortUtil.ascSort, "storyline", a, b, o);
if (o.sortBy === "level") return this._sortAdventuresBooks_orFallback(SortUtil.ascSort, "_startLevel", a, b, o);
if (o.sortBy === "group") return SortUtil.ascSortSourceGroup(a, b) || this._sortAdventuresBooks_byPublished(a, b, o);
if (o.sortBy === "published") return this._sortAdventuresBooks_byPublished(a, b, o);
}
static _sortAdventuresBooks_byPublished (a, b, o) {
return SortUtil.ascSortDate(b._pubDate, a._pubDate)
|| SortUtil.ascSort(a.publishedOrder || 0, b.publishedOrder || 0)
|| this._sortAdventuresBooks_byName(a, b, o);
}
static _sortAdventuresBooks_byName (a, b, o) { return SortUtil.ascSort(a.name, b.name); }
static _sortAdventuresBooks_orFallback (func, prop, a, b, o) {
const initial = func(a[prop] || "", b[prop] || "");
return initial || this._sortAdventuresBooks_byName(a, b, o);
}
constructor (options) {
this._contentsUrl = options.contentsUrl;
this._fnSort = options.fnSort;
this._sortByInitial = options.sortByInitial;
this._sortDirInitial = options.sortDirInitial;
this._dataProp = options.dataProp;
this._enhanceRowDataFn = options.enhanceRowDataFn;
this._rootPage = options.rootPage;
this._rowBuilderFn = options.rowBuilderFn;
this._list = null;
this._listAlt = null;
this._dataIx = 0;
this._dataList = [];
}
async pOnPageLoad () {
await Promise.all([
PrereleaseUtil.pInit(),
BrewUtil2.pInit(),
]);
const [data] = await Promise.all([
await DataUtil.loadJSON(`${Renderer.get().baseUrl}${this._contentsUrl}`),
await ExcludeUtil.pInitialise(),
]);
const $iptSearch = $(`#search`);
const fnSort = (a, b, o) => this._fnSort(this._dataList, a, b, o);
this._list = new List({
$wrpList: $(".books"),
$iptSearch,
fnSort,
sortByInitial: this._sortByInitial,
sortDirInitial: this._sortDirInitial,
isUseJquery: true,
});
SortUtil.initBtnSortHandlers($(`#filtertools`), this._list);
const $wrpBookshelf = $(".books--alt");
this._listAlt = new List({
$wrpList: $wrpBookshelf,
$iptSearch,
fnSort,
sortByInitial: this._sortByInitial,
sortDirInitial: this._sortDirInitial,
});
$("#reset").click(() => {
this._list.reset();
this._listAlt.reset();
this._list.items.forEach(li => {
if (li.data.$btnToggleExpand.text() === "[\u2012]") li.data.$btnToggleExpand.click();
});
});
this.addData(data);
await handleBrew(await PrereleaseUtil.pGetBrewProcessed());
await handleBrew(await BrewUtil2.pGetBrewProcessed());
ManageBrewUi.bindBtnOpen($(`#manage-brew`));
this._list.init();
this._listAlt.init();
if (ExcludeUtil.isAllContentExcluded(this._dataList)) $wrpBookshelf.append(ExcludeUtil.getAllContentBlocklistedHtml());
window.dispatchEvent(new Event("toolsLoaded"));
}
addData (data) {
if (!data[this._dataProp] || !data[this._dataProp].length) return;
this._dataList.push(...data[this._dataProp]);
for (; this._dataIx < this._dataList.length; this._dataIx++) {
const it = this._dataList[this._dataIx];
if (this._enhanceRowDataFn) this._enhanceRowDataFn(it);
const isExcluded = ExcludeUtil.isExcluded(UrlUtil.URL_TO_HASH_BUILDER[this._rootPage](it), this._dataProp, it.source);
const $elesContents = [];
it.contents.map((chapter, ixChapter) => {
const $lnkChapter = $$`<a href="${this._rootPage}#${UrlUtil.encodeForHash(it.id)},${ixChapter}" class="ve-flex w-100 bklist__row-chapter lst--border lst__row-inner lst__row lst__wrp-cells bold">
${Parser.bookOrdinalToAbv(chapter.ordinal)}${chapter.name}
</a>`;
$elesContents.push($lnkChapter);
if (!chapter.headers) return;
const headerCounts = {};
chapter.headers.forEach(header => {
const headerText = BookUtil.getHeaderText(header);
const headerTextClean = headerText.toLowerCase().trim();
const headerPos = headerCounts[headerTextClean] || 0;
headerCounts[headerTextClean] = (headerCounts[headerTextClean] || 0) + 1;
const $lnk = $$`<a href="${this._rootPage}#${UrlUtil.encodeForHash(it.id)},${ixChapter},${UrlUtil.encodeForHash(headerText)}${header.index ? `,${header.index}` : ""}${headerPos > 0 ? `,${headerPos}` : ""}" class="lst__row lst--border lst__row-inner lst__wrp-cells bklist__row-section ve-flex w-100">
${BookUtil.getContentsSectionHeader(header)}
</a>`;
$elesContents.push($lnk);
});
});
const $wrpContents = $$`<div class="ve-flex w-100 relative">
<div class="vr-0 absolute bklist__vr-contents"></div>
<div class="ve-flex-col w-100 bklist__wrp-rows-inner">${$elesContents}</div>
</div>`.hideVe();
const $btnToggleExpand = $(`<span class="px-2 py-1p bold mobile__hidden">[+]</span>`)
.click(evt => {
evt.stopPropagation();
evt.preventDefault();
$btnToggleExpand.text($btnToggleExpand.text() === "[+]" ? "[\u2012]" : "[+]");
$wrpContents.toggleVe();
});
const $eleLi = $$`<div class="ve-flex-col w-100">
<a href="${this._rootPage}#${UrlUtil.encodeForHash(it.id)}" class="split-v-center lst--border lst__row-inner lst__row ${isExcluded ? `lst__row--blocklisted` : ""}">
<span class="w-100 ve-flex">${this._rowBuilderFn(it)}</span>
${$btnToggleExpand}
</a>
${$wrpContents}
</div>`;
const listItem = new ListItem(
this._dataIx,
$eleLi,
it.name,
{
source: Parser.sourceJsonToAbv(it.source),
alias: (it.alias || []).map(it => `"${it}"`).join(","),
},
{
$btnToggleExpand,
},
);
this._list.addItem(listItem);
// region Alt list (covers/thumbnails)
const eleLiAlt = $(`<a href="${this._rootPage}#${UrlUtil.encodeForHash(it.id)}" class="ve-flex-col ve-flex-v-center m-3 bks__wrp-bookshelf-item ${isExcluded ? `bks__wrp-bookshelf-item--blocklisted` : ""} py-3 px-2 ${Parser.sourceJsonToColor(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>
<img src="${Renderer.adventureBook.getCoverUrl(it)}" class="mb-2 bks__bookshelf-image" loading="lazy" alt="Cover Image: ${(it.name || "").qq()}">
<div class="bks__bookshelf-item-name ve-flex-vh-center ve-text-center">${it.name}</div>
</a>`)[0];
const listItemAlt = new ListItem(
this._dataIx,
eleLiAlt,
it.name,
{source: it.id},
);
this._listAlt.addItem(listItemAlt);
// endregion
}
this._list.update();
this._listAlt.update();
}
}

1172
js/bookutils.js Normal file

File diff suppressed because it is too large Load Diff

20
js/browsercheck.js Normal file
View File

@@ -0,0 +1,20 @@
"use strict";
window.addEventListener("load", () => {
if (typeof [].flat !== "function") {
const $body = $(`body`);
$body.addClass("edge__body");
const $btnClose = $(`<button class="btn btn-danger edge__btn-close"><span class="glyphicon glyphicon-remove"/></button>`)
.click(() => {
$overlay.remove();
$body.removeClass("edge__body");
});
const $overlay = $(`<div class="ve-flex-col ve-flex-vh-center relative edge__overlay"/>`);
$btnClose.appendTo($overlay);
$overlay.append(`<div class="ve-flex-col ve-flex-vh-center">
<div class="edge__title mb-2">UPDATE YOUR BROWSER</div>
<div><i>It looks like you're using an outdated/unsupported browser.<br>
5etools recommends and supports the latest <a href="https://www.google.com/chrome/" class="edge__link">Chrome</a> and the latest <a href="https://www.mozilla.org/firefox/" class="edge__link">Firefox</a>.</i></div>
</div>`).appendTo($body);
}
});

107
js/charcreationoptions.js Normal file
View File

@@ -0,0 +1,107 @@
"use strict";
class CharCreationOptionsSublistManager extends SublistManager {
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Type",
css: "col-5 ve-text-center pl-0",
colStyle: "text-center",
}),
new SublistCellTemplate({
name: "Name",
css: "bold col-7 pr-0",
colStyle: "",
}),
];
}
pGetSublistItem (it, hash) {
const cellsText = [it.name, it._fOptionType];
const $ele = $$`<div class="lst__row lst__row--sublist ve-flex-col">
<a href="#${hash}" class="lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText})}
</a>
</div>`
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._listSub.doSelect(listItem, evt));
const listItem = new ListItem(
hash,
$ele,
it.name,
{
hash,
source: Parser.sourceJsonToAbv(it.source),
type: it._fOptionType,
},
{
entity: it,
mdRow: [...cellsText],
},
);
return listItem;
}
}
class CharCreationOptionsPage extends ListPage {
constructor () {
const pageFilter = new PageFilterCharCreationOptions();
super({
dataSource: DataUtil.charoption.loadJSON.bind(DataUtil.charoption),
dataSourceFluff: DataUtil.charoptionFluff.loadJSON.bind(DataUtil.charoptionFluff),
pFnGetFluff: Renderer.charoption.pGetFluff.bind(Renderer.charoption),
pageFilter,
dataProps: ["charoption"],
isMarkdownPopout: true,
});
}
getListItem (it, itI, isExcluded) {
this._pageFilter.mutateAndAddToFilters(it, isExcluded);
const eleLi = document.createElement("div");
eleLi.className = `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`;
const hash = UrlUtil.autoEncodeHash(it);
const source = Parser.sourceJsonToAbv(it.source);
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
<span class="col-5 ve-text-center pl-0">${it._fOptionType}</span>
<span class="bold col-5">${it.name}</span>
<span class="col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)}" title="${Parser.sourceJsonToFull(it.source)} pr-0" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
</a>`;
const listItem = new ListItem(
itI,
eleLi,
it.name,
{
hash,
source,
type: it._fOptionType,
},
{
isExcluded,
},
);
eleLi.addEventListener("click", (evt) => this._list.doSelect(listItem, evt));
eleLi.addEventListener("contextmenu", (evt) => this._openContextMenu(evt, this._list, listItem));
return listItem;
}
_renderStats_doBuildStatsTab ({ent}) {
this._$pgContent.empty().append(RenderCharCreationOptions.$getRenderedCharCreationOption(ent));
}
}
const charCreationOptionsPage = new CharCreationOptionsPage();
charCreationOptionsPage.sublistManager = new CharCreationOptionsSublistManager();
window.addEventListener("load", () => charCreationOptionsPage.pOnLoad());

2487
js/classes.js Normal file

File diff suppressed because it is too large Load Diff

113
js/conditionsdiseases.js Normal file
View File

@@ -0,0 +1,113 @@
"use strict";
class ConditionsDiseasesSublistManager extends SublistManager {
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Type",
css: "col-2 pl-0 ve-text-center",
colStyle: "text-center",
}),
new SublistCellTemplate({
name: "Name",
css: "bold col-10 pr-0",
colStyle: "",
}),
];
}
pGetSublistItem (it, hash) {
const cellsText = [PageFilterConditionsDiseases.getDisplayProp(it.__prop), it.name];
const $ele = $(`<div class="lst__row lst__row--sublist ve-flex-col">
<a href="#${hash}" class="lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText})}
</a>
</div>`)
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._listSub.doSelect(listItem, evt));
const listItem = new ListItem(
hash,
$ele,
it.name,
{
hash,
type: it.__prop,
},
{
entity: it,
mdRow: [...cellsText],
},
);
return listItem;
}
}
class ConditionsDiseasesPage extends ListPage {
constructor () {
const pageFilter = new PageFilterConditionsDiseases();
super({
dataSource: "data/conditionsdiseases.json",
pFnGetFluff: Renderer.condition.pGetFluff.bind(Renderer.condition),
pageFilter,
dataProps: ["condition", "disease", "status"],
isMarkdownPopout: true,
isPreviewable: true,
});
}
getListItem (it, cdI, isExcluded) {
this._pageFilter.mutateAndAddToFilters(it, isExcluded);
const eleLi = document.createElement("div");
eleLi.className = `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`;
const source = Parser.sourceJsonToAbv(it.source);
const hash = UrlUtil.autoEncodeHash(it);
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
<span class="col-0-3 px-0 ve-flex-vh-center lst__btn-toggle-expand ve-self-flex-stretch">[+]</span>
<span class="col-3 ve-text-center">${PageFilterConditionsDiseases.getDisplayProp(it.__prop)}</span>
<span class="bold col-6-7 px-1">${it.name}</span>
<span class="col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
</a>
<div class="ve-flex ve-hidden relative lst__wrp-preview">
<div class="vr-0 absolute lst__vr-preview"></div>
<div class="ve-flex-col py-3 ml-4 lst__wrp-preview-inner"></div>
</div>`;
const listItem = new ListItem(
cdI,
eleLi,
it.name,
{
hash,
source,
type: it.__prop,
},
{
isExcluded,
},
);
eleLi.addEventListener("click", (evt) => this._list.doSelect(listItem, evt));
eleLi.addEventListener("contextmenu", (evt) => this._openContextMenu(evt, this._list, listItem));
return listItem;
}
_renderStats_doBuildStatsTab ({ent}) {
this._$pgContent.empty().append(RenderConditionDiseases.$getRenderedConditionDisease(ent));
}
}
const conditionsDiseasesPage = new ConditionsDiseasesPage();
conditionsDiseasesPage.sublistManager = new ConditionsDiseasesSublistManager();
window.addEventListener("load", () => conditionsDiseasesPage.pOnLoad());

130
js/converter-background.js Normal file
View File

@@ -0,0 +1,130 @@
"use strict";
class _ParseStateTextBackground extends BaseParseStateText {
}
class BackgroundParser extends BaseParserFeature {
/**
* Parses backgrounds from raw text pastes
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseText (inText, options) {
options = this._getValidOptions(options);
const {toConvert, entity: background} = this._doParse_getInitialState(inText, options);
if (!toConvert) return;
const state = new _ParseStateTextBackground({toConvert, options, entity: background});
state.doPreLoop();
for (; state.ixToConvert < toConvert.length; ++state.ixToConvert) {
state.initCurLine();
if (state.isSkippableCurLine()) continue;
switch (state.stage) {
case "name": this._doParseText_stepName(state); state.stage = "entries"; break;
case "entries": this._doParseText_stepEntries(state); break;
default: throw new Error(`Unknown stage "${state.stage}"`);
}
}
state.doPostLoop();
if (!background.entries?.length) delete background.entries;
const entityOut = this._getFinalEntity(state, options);
options.cbOutput(entityOut, options.isAppend);
}
static _doParseText_stepName (state) {
const name = state.curLine.replace(/ Traits$/i, "");
state.entity.name = this._getAsTitle("name", name, state.options.titleCaseFields, state.options.isTitleCase);
}
static _doParseText_stepEntries (state) {
const ptrI = {_: state.ixToConvert};
state.entity.entries = EntryConvert.coalesceLines(
ptrI,
state.toConvert,
);
state.ixToConvert = ptrI._;
}
// SHARED UTILITY FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _getFinalEntity (state, options) {
this._doBackgroundPostProcess(state, options);
return PropOrder.getOrdered(state.entity, state.entity.__prop || "background");
}
static _doBackgroundPostProcess (state, options) {
if (!state.entity.entries) return;
// region Tag
EntryConvert.tryRun(state.entity, "entries");
TagJsons.mutTagObject(state.entity, {keySet: new Set(["entries"]), isOptimistic: false});
// endregion
// region Background-specific cleanup and generation
this._doPostProcess_setPrerequisites(state, options);
this._doBackgroundPostProcess_feature(state.entity, options);
BackgroundSkillTollLanguageEquipmentCoalesce.tryRun(state.entity, {cbWarning: options.cbWarning});
BackgroundSkillToolLanguageTag.tryRun(state.entity, {cbWarning: options.cbWarning});
this._doBackgroundPostProcess_equipment(state.entity, options);
EquipmentBreakdown.tryRun(state.entity, {cbWarning: options.cbWarning});
this._doBackgroundPostProcess_tables(state.entity, options);
// endregion
}
static _doBackgroundPostProcess_feature (background, options) {
const entFeature = background.entries.find(ent => ent.name?.startsWith("Feature: "));
if (!entFeature) return;
(entFeature.data ||= {}).isFeature = true;
const walker = MiscUtil.getWalker({isNoModification: true});
walker.walk(
entFeature.entries,
{
string: (str) => {
str.replace(/{@feat (?<tagContents>[^}]+)}/g, (...m) => {
const {name, source} = DataUtil.proxy.unpackUid("feat", m.at(-1).tagContents, "feat", {isLower: true});
(background.feats ||= []).push({[`${name}|${source}`]: true});
(background.fromFeature ||= {}).feats = true;
});
},
},
);
}
static _doBackgroundPostProcess_equipment (background, options) {
const entryEquipment = UtilBackgroundParser.getEquipmentEntry(background);
if (!entryEquipment) return;
entryEquipment.entry = ItemTag.tryRunBasicEquipment(entryEquipment.entry);
}
static _doBackgroundPostProcess_tables (background, options) {
for (let i = 1; i < background.entries.length; ++i) {
const entPrev = background.entries[i - 1];
if (!entPrev.entries?.length) continue;
const ent = background.entries[i];
if (ent.type !== "table") continue;
entPrev.entries.push(ent);
background.entries.splice(i--, 1);
}
}
}
globalThis.BackgroundParser = BackgroundParser;

1839
js/converter-creature.js Normal file

File diff suppressed because it is too large Load Diff

181
js/converter-feat.js Normal file
View File

@@ -0,0 +1,181 @@
"use strict";
class _ParseStateTextFeat extends BaseParseStateText {
}
class FeatParser extends BaseParserFeature {
/**
* Parses feats from raw text pastes
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseText (inText, options) {
options = this._getValidOptions(options);
const {toConvert, entity: feat} = this._doParse_getInitialState(inText, options);
if (!toConvert) return;
const state = new _ParseStateTextFeat({toConvert, options, entity: feat});
state.doPreLoop();
for (; state.ixToConvert < toConvert.length; ++state.ixToConvert) {
state.initCurLine();
if (state.isSkippableCurLine()) continue;
switch (state.stage) {
case "name": this._doParseText_stepName(state); state.stage = "entries"; break;
case "entries": this._doParseText_stepEntries(state, options); break;
default: throw new Error(`Unknown stage "${state.stage}"`);
}
}
state.doPostLoop();
if (!feat.entries.length) delete feat.entries;
else {
this._mutMergeHangingListItems(feat, options);
this._setAbility(feat, options);
}
const statsOut = this._getFinalState(state, options);
options.cbOutput(statsOut, options.isAppend);
}
static _doParseText_stepName (state) {
state.entity.name = this._getAsTitle("name", state.curLine, state.options.titleCaseFields, state.options.isTitleCase);
}
static _doParseText_stepEntries (state, options) {
// prerequisites
if (/^prerequisite:/i.test(state.curLine)) {
state.entity.entries = [
{
name: "Prerequisite:",
entries: [
state.curLine
.replace(/^prerequisite:/i, "")
.trim(),
],
},
];
state.ixToConvert++;
state.initCurLine();
}
const ptrI = {_: state.ixToConvert};
const entries = EntryConvert.coalesceLines(
ptrI,
state.toConvert,
);
state.ixToConvert = ptrI._;
state.entity.entries = [
...(state.entity.entries || []),
...entries,
];
}
static _getFinalState (state, options) {
this._doFeatPostProcess(state, options);
return PropOrder.getOrdered(state.entity, state.entity.__prop || "feat");
}
// SHARED UTILITY FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _doFeatPostProcess (state, options) {
TagCondition.tryTagConditions(state.entity);
if (state.entity.entries) {
state.entity.entries = state.entity.entries.map(it => DiceConvert.getTaggedEntry(it));
EntryConvert.tryRun(state, "entries");
this._doPostProcess_setPrerequisites(state, options);
state.entity.entries = SkillTag.tryRun(state.entity.entries);
state.entity.entries = ActionTag.tryRun(state.entity.entries);
state.entity.entries = SenseTag.tryRun(state.entity.entries);
}
}
// SHARED PARSING FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _mutMergeHangingListItems (feat, options) {
const ixStart = feat.entries.findIndex(ent => typeof ent === "string" && /(?:following|these) benefits:$/.test(ent));
if (!~ixStart) return;
let list;
for (let i = ixStart + 1; i < feat.entries.length; ++i) {
const ent = feat.entries[i];
if (ent.type !== "entries" || !ent.name || !ent.entries?.length) break;
if (!list) list = {type: "list", style: "list-hang-notitle", items: []};
list.items.push({
...ent,
type: "item",
});
feat.entries.splice(i, 1);
--i;
}
if (!list?.items?.length) return;
feat.entries.splice(ixStart + 1, 0, list);
}
static _setAbility (feat, options) {
const walker = MiscUtil.getWalker({
keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST,
isNoModification: true,
});
walker.walk(
feat.entries,
{
object: (obj) => {
if (obj.type !== "list") return;
const str = typeof obj.items[0] === "string" ? obj.items[0] : obj.items[0].entries?.[0];
if (typeof str !== "string") return;
if (/^increase your/i.test(str)) {
const abils = [];
str.replace(/(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)/g, (...m) => {
abils.push(m[1].toLowerCase().slice(0, 3));
});
if (abils.length === 1) {
feat.ability = [{[abils[0]]: 1}];
} else {
feat.ability = [
{
choose: {
from: abils,
amount: 1,
},
},
];
}
obj.items.shift();
} else if (/^increase (?:one|an) ability score of your choice by 1/i.test(str)) {
feat.ability = [
{
choose: {
from: [...Parser.ABIL_ABVS],
amount: 1,
},
},
];
obj.items.shift();
}
},
},
);
}
}
globalThis.FeatParser = FeatParser;

143
js/converter-feature.js Normal file
View File

@@ -0,0 +1,143 @@
class BaseParserFeature extends BaseParser {
static _doParse_getInitialState (inText, options) {
if (!inText || !inText.trim()) {
options.cbWarning("No input!");
return {};
}
const toConvert = this._getCleanInput(inText, options)
.split("\n")
.filter(it => it && it.trim());
const entity = {};
entity.source = options.source;
// for the user to fill out
entity.page = options.page;
return {toConvert, entity};
}
static _doPostProcess_setPrerequisites (state, options) {
const [entsPrereq, entsRest] = state.entity.entries.segregate(ent => ent.name === "Prerequisite:");
if (!entsPrereq.length) return;
if (entsPrereq.length > 1) {
options.cbWarning(`(${state.entity.name}) Prerequisites requires manual conversion`);
return;
}
const [entPrereq] = entsPrereq;
if (entPrereq.entries.length > 1 || (typeof entPrereq.entries[0] !== "string")) {
options.cbWarning(`(${state.entity.name}) Prerequisites requires manual conversion`);
return;
}
const [entPrereqString] = entPrereq.entries;
state.entity.entries = entsRest;
const pres = [];
const tokens = ConvertUtil.getTokens(entPrereqString);
let tkStack = [];
const handleStack = () => {
if (!tkStack.length) return;
const joinedStack = tkStack.join(" ").trim();
const parts = joinedStack.split(StrUtil.COMMA_SPACE_NOT_IN_PARENTHESES_REGEX);
const pre = {};
parts.forEach(pt => {
pt = pt.trim();
if (/^spellcasting$/i.test(pt)) return pre.spellcasting2020 = true;
if (/^pact magic feature$/i.test(pt)) return pre.spellcasting2020 = true;
if (/^spellcasting feature$/i.test(pt)) return pre.spellcastingFeature = true;
if (/^spellcasting feature from a class that prepares spells$/i.test(pt)) return pre.spellcastingPrepared = true;
if (/proficiency with a martial weapon/i.test(pt)) {
pre.proficiency = pre.proficiency || [{}];
pre.proficiency[0].weapon = "martial";
return;
}
if (/Martial Weapon Proficiency/i.test(pt)) {
pre.proficiency = pre.proficiency || [{}];
pre.proficiency[0].weaponGroup = "martial";
return;
}
const mLevel = /^(?<level>\d+).. level$/i.exec(pt);
if (mLevel) return pre.level = Number(mLevel.groups.level);
const mFeat = /^(?<name>.*?) feat$/i.exec(pt);
if (mFeat) {
pre.feat ||= [];
const rawFeat = mFeat.groups.name.toLowerCase().trim();
const [ptName, ptSpecifier] = rawFeat.split(/ \(([^)]+)\)$/);
if (!ptSpecifier) return pre.feat.push(`${rawFeat}|${state.entity.source.toLowerCase()}`);
return pre.feat.push(`${ptName}|${state.entity.source.toLowerCase()}|${rawFeat}`);
}
const mBackground = /^(?<name>.*?) background$/i.exec(pt);
if (mBackground) {
const name = mBackground.groups.name.trim();
return (pre.background = pre.background || []).push({
name,
displayEntry: `{@background ${name}}`,
});
}
const mAlignment = /^(?<align>.*?) alignment/i.exec(pt);
if (mAlignment) {
const {alignment} = AlignmentUtil.tryGetConvertedAlignment(mAlignment.groups.align);
if (alignment) {
pre.alignment = alignment;
return;
}
}
const mCampaign = /^(?<name>.*)? Campaign$/i.exec(pt);
if (mCampaign) {
return (pre.campaign = pre.campaign || []).push(mCampaign.groups.name);
}
const mClass = new RegExp(`^${ConverterConst.STR_RE_CLASS}(?: class)?$`, "i").exec(pt);
if (mClass) {
return pre.level = {
level: 1,
class: {
name: mClass.groups.name,
visible: true,
},
};
}
pre.other = pt;
options.cbWarning(`(${state.entity.name}) Prerequisite "${pt}" requires manual conversion`);
});
if (Object.keys(pre).length) pres.push(pre);
tkStack = [];
};
for (const tk of tokens) {
if (tk === "or") {
handleStack();
continue;
}
tkStack.push(tk);
}
handleStack();
if (pres.length) state.entity.prerequisite = pres;
}
}
globalThis.BaseParserFeature = BaseParserFeature;

505
js/converter-item.js Normal file
View File

@@ -0,0 +1,505 @@
"use strict";
class ItemParser extends BaseParser {
static init (itemData, classData) {
ItemParser._ALL_ITEMS = itemData;
ItemParser._ALL_CLASSES = classData.class;
}
static getItem (itemName) {
itemName = itemName.trim().toLowerCase();
itemName = ItemParser._MAPPED_ITEM_NAMES[itemName] || itemName;
const matches = ItemParser._ALL_ITEMS.filter(it => it.name.toLowerCase() === itemName);
if (matches.length > 1) throw new Error(`Multiple items found with name "${itemName}"`);
if (matches.length) return matches[0];
return null;
}
/**
* Parses items from raw text pastes
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseText (inText, options) {
options = this._getValidOptions(options);
if (!inText || !inText.trim()) return options.cbWarning("No input!");
const toConvert = this._getCleanInput(inText, options)
.split("\n")
.filter(it => it && it.trim());
const item = {};
item.source = options.source;
// for the user to fill out
item.page = options.page;
// FIXME this duplicates functionality in converterutils
let prevLine = null;
let curLine = null;
let i;
for (i = 0; i < toConvert.length; i++) {
prevLine = curLine;
curLine = toConvert[i].trim();
if (curLine === "") continue;
// name of item
if (i === 0) {
item.name = this._getAsTitle("name", curLine, options.titleCaseFields, options.isTitleCase);
continue;
}
// tagline
if (i === 1) {
this._setCleanTaglineInfo(item, curLine, options);
continue;
}
const ptrI = {_: i};
item.entries = EntryConvert.coalesceLines(
ptrI,
toConvert,
);
i = ptrI._;
}
const statsOut = this._getFinalState(item, options);
options.cbOutput(statsOut, options.isAppend);
}
static _getFinalState (item, options) {
if (!item.entries.length) delete item.entries;
else this._setWeight(item, options);
if (item.staff) this._setQuarterstaffStats(item, options);
this._mutRemoveBaseItemProps(item, options);
this._doItemPostProcess(item, options);
this._setCleanTaglineInfo_handleGenericType(item, options);
this._doVariantPostProcess(item, options);
return PropOrder.getOrdered(item, item.__prop || "item");
}
// SHARED UTILITY FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _doItemPostProcess (stats, options) {
TagCondition.tryTagConditions(stats);
ArtifactPropertiesTag.tryRun(stats);
if (stats.entries) {
stats.entries = stats.entries.map(it => DiceConvert.getTaggedEntry(it));
EntryConvert.tryRun(stats, "entries");
stats.entries = SkillTag.tryRun(stats.entries);
stats.entries = ActionTag.tryRun(stats.entries);
stats.entries = SenseTag.tryRun(stats.entries);
if (/is a (tiny|small|medium|large|huge|gargantuan) object/.test(JSON.stringify(stats.entries))) options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Item may be an object!`);
}
this._doItemPostProcess_addTags(stats, options);
BasicTextClean.tryRun(stats);
}
static _doItemPostProcess_addTags (stats, options) {
const manName = stats.name ? `(${stats.name}) ` : "";
ChargeTag.tryRun(stats);
RechargeTypeTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Recharge type requires manual conversion`)});
RechargeAmountTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Recharge amount requires manual conversion`)});
BonusTag.tryRun(stats);
ItemMiscTag.tryRun(stats);
ItemSpellcastingFocusTag.tryRun(stats);
DamageResistanceTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Damage resistance tagging requires manual conversion`)});
DamageImmunityTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Damage immunity tagging requires manual conversion`)});
DamageVulnerabilityTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Damage vulnerability tagging requires manual conversion`)});
ConditionImmunityTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Condition immunity tagging requires manual conversion`)});
ReqAttuneTagTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Attunement requirement tagging requires manual conversion`)});
TagJsons.mutTagObject(stats, {keySet: new Set(["entries"]), isOptimistic: true});
AttachedSpellTag.tryRun(stats);
// TODO
// - tag damage type?
// - tag ability score adjustments
}
static _doVariantPostProcess (stats, options) {
if (!stats.inherits) return;
BonusTag.tryRun(stats, {isVariant: true});
}
// SHARED PARSING FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _setCleanTaglineInfo (stats, curLine, options) {
const parts = curLine.trim().split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX).map(it => it.trim()).filter(Boolean);
const handlePartRarity = (rarity) => {
rarity = rarity.trim().toLowerCase();
switch (rarity) {
case "common": stats.rarity = rarity; return true;
case "uncommon": stats.rarity = rarity; return true;
case "rare": stats.rarity = rarity; return true;
case "very rare": stats.rarity = rarity; return true;
case "legendary": stats.rarity = rarity; return true;
case "artifact": stats.rarity = rarity; return true;
case "varies":
case "rarity varies": {
stats.rarity = "varies";
stats.__prop = "itemGroup";
return true;
}
case "unknown rarity": {
// Make a best-guess as to whether or not the item is magical
if (stats.wondrous || stats.staff || stats.type === "P" || stats.type === "RG" || stats.type === "RD" || stats.type === "WD" || stats.type === "SC" || stats.type === "MR") stats.rarity = "unknown (magic)";
else stats.rarity = "unknown";
return true;
}
}
return false;
};
let baseItem = null;
for (let i = 0; i < parts.length; ++i) {
let part = parts[i];
const partLower = part.toLowerCase();
// region wondrous/item type/staff/etc.
switch (partLower) {
case "wondrous item": stats.wondrous = true; continue;
case "wondrous item (tattoo)": stats.wondrous = true; stats.tattoo = true; continue;
case "potion": stats.type = "P"; continue;
case "ring": stats.type = "RG"; continue;
case "rod": stats.type = "RD"; continue;
case "wand": stats.type = "WD"; continue;
case "ammunition": stats.type = "A"; continue;
case "staff": stats.staff = true; continue;
case "master rune": stats.type = "MR"; continue;
case "scroll": stats.type = "SC"; continue;
}
// endregion
// region rarity/attunement
// Check if the part is an exact match for a rarity string
const isHandledRarity = handlePartRarity(partLower);
if (isHandledRarity) continue;
if (partLower.includes("(requires attunement")) {
const [rarityRaw, ...rest] = part.split("(");
const rarity = rarityRaw.trim().toLowerCase();
const isHandledRarity = rarity ? handlePartRarity(rarity) : true;
if (!isHandledRarity) options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Rarity "${rarityRaw}" requires manual conversion`);
let attunement = rest.join("(");
attunement = attunement.replace(/^requires attunement/i, "").replace(/\)/, "").trim();
if (!attunement) {
stats.reqAttune = true;
} else {
stats.reqAttune = attunement.toLowerCase();
}
// if specific attunement is required, absorb any further parts which are class names
if (/(^| )by a /i.test(stats.reqAttune)) {
for (let ii = i + 1; ii < parts.length; ++ii) {
const nxtPart = parts[ii]
.trim()
.replace(/^(?:or|and) /, "")
.trim()
.replace(/\)$/, "")
.trim()
.toLowerCase();
const isClassName = ItemParser._ALL_CLASSES.some(cls => cls.name.toLowerCase() === nxtPart);
if (isClassName) {
stats.reqAttune += `, ${parts[ii].replace(/\)$/, "")}`;
i = ii;
}
}
}
continue;
}
// endregion
// region weapon/armor
const isGenericWeaponArmor = this._setCleanTaglineInfo_mutIsGenericWeaponArmor({stats, part, partLower, options});
if (isGenericWeaponArmor) continue;
const mBaseWeapon = /^(?<ptPre>weapon|staff) \((?<ptParens>[^)]+)\)$/i.exec(part);
if (mBaseWeapon) {
if (mBaseWeapon.groups.ptPre.toLowerCase() === "staff") stats.staff = true;
if (mBaseWeapon.groups.ptParens === "spear or javelin") {
(stats.requires ||= []).push(...this._setCleanTaglineInfo_getGenericRequires({stats, str: "spear", options}));
stats.__genericType = true;
continue;
}
const ptsParens = ConverterUtils.splitConjunct(mBaseWeapon.groups.ptParens);
const baseItems = ptsParens.map(pt => ItemParser.getItem(pt));
if (baseItems.some(it => it == null) || !baseItems.length) throw new Error(`Could not find base item(s) for "${mBaseWeapon.groups.ptParens}"`);
if (baseItems.length === 1) {
baseItem = baseItems[0];
continue;
}
throw new Error(`Multiple base item(s) for "${mBaseWeapon.groups.ptParens}"`);
}
const mBaseArmor = /^armou?r \((?<type>[^)]+)\)$/i.exec(part);
if (mBaseArmor) {
if (this._setCleanTaglineInfo_isMutAnyArmor(stats, mBaseArmor)) {
stats.__genericType = true;
continue;
}
baseItem = this._setCleanTaglineInfo_getArmorBaseItem(mBaseArmor.groups.type);
if (!baseItem) throw new Error(`Could not find base item "${mBaseArmor.groups.type}"`);
continue;
}
// endregion
// Warn about any unprocessed input
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Tagline part "${part}" requires manual conversion`);
}
this._setCleanTaglineInfo_handleBaseItem(stats, baseItem, options);
}
static _GENERIC_CATEGORY_TO_PROP = {
"sword": "sword",
"polearm": "polearm",
};
static _setCleanTaglineInfo_mutIsGenericWeaponArmor ({stats, part, partLower, options}) {
if (partLower === "weapon" || partLower === "weapon (any)") {
(stats.requires ||= []).push(...this._setCleanTaglineInfo_getGenericRequires({stats, str: "weapon", options}));
stats.__genericType = true;
return true;
}
if (/^armou?r(?: \(any\))?$/.test(partLower)) {
(stats.requires ||= []).push(...this._setCleanTaglineInfo_getGenericRequires({stats, str: "armor", options}));
stats.__genericType = true;
return true;
}
const mWeaponAnyX = /^weapon \(any ([^)]+)\)$/i.exec(part);
if (mWeaponAnyX) {
(stats.requires ||= []).push(...this._setCleanTaglineInfo_getGenericRequires({stats, str: mWeaponAnyX[1].trim(), options}));
if (mWeaponAnyX[1].trim().toLowerCase() === "ammunition") stats.ammo = true;
stats.__genericType = true;
return true;
}
const mWeaponCategory = /^weapon \((?<category>[^)]+)\)$/i.exec(part);
if (!mWeaponCategory) return false;
const ptsCategory = ConverterUtils.splitConjunct(mWeaponCategory.groups.category);
if (!ptsCategory.length) return false;
const strs = ptsCategory
.map(pt => this._GENERIC_CATEGORY_TO_PROP[pt.toLowerCase()]);
if (strs.some(it => it == null)) return false;
(stats.requires ||= []).push(...strs.flatMap(str => this._setCleanTaglineInfo_getGenericRequires({stats, str, options})));
stats.__genericType = true;
return true;
}
static _setCleanTaglineInfo_getArmorBaseItem (name) {
let baseItem = ItemParser.getItem(name);
if (!baseItem) baseItem = ItemParser.getItem(`${name} armor`); // "armor (plate)" -> "plate armor"
return baseItem;
}
static _setCleanTaglineInfo_getProcArmorPart ({pt}) {
switch (pt) {
case "light": return {"type": "LA"};
case "medium": return {"type": "MA"};
case "heavy": return {"type": "HA"};
default: {
const baseItem = this._setCleanTaglineInfo_getArmorBaseItem(pt);
if (!baseItem) throw new Error(`Could not find base item "${pt}"`);
return {name: baseItem.name};
}
}
}
static _setCleanTaglineInfo_isMutAnyArmor (stats, mBaseArmor) {
if (/^any /i.test(mBaseArmor.groups.type)) {
const ptAny = mBaseArmor.groups.type.replace(/^any /i, "");
const [ptInclude, ptExclude] = ptAny.split(/\bexcept\b/i).map(it => it.trim()).filter(Boolean);
if (ptInclude) {
stats.requires = [
...(stats.requires || []),
...ptInclude.split(/\b(?:or|,)\b/g).map(it => it.trim()).filter(Boolean).map(it => this._setCleanTaglineInfo_getProcArmorPart({pt: it})),
];
}
if (ptExclude) {
Object.assign(
stats.excludes = stats.excludes || {},
ptExclude.split(/\b(?:or|,)\b/g).map(it => it.trim()).filter(Boolean).mergeMap(it => this._setCleanTaglineInfo_getProcArmorPart({pt: it})),
);
}
return true;
}
return ConverterUtils.splitConjunct(mBaseArmor.groups.type)
.every(ptType => {
if (!/^(?:light|medium|heavy)$/i.test(ptType)) return false;
stats.requires = [
...(stats.requires || []),
this._setCleanTaglineInfo_getProcArmorPart({pt: ptType}),
];
return true;
});
}
static _setCleanTaglineInfo_handleBaseItem (stats, baseItem, options) {
if (!baseItem) return;
const blocklistedProps = new Set([
"source",
"srd",
"basicRules",
"page",
]);
// Apply base item stats only if there's no existing data
Object.entries(baseItem)
.filter(([k]) => stats[k] === undefined && !k.startsWith("_") && !blocklistedProps.has(k))
.forEach(([k, v]) => stats[k] = v);
// Clean unwanted base properties
delete stats.armor;
delete stats.value;
stats.baseItem = `${baseItem.name.toLowerCase()}${baseItem.source === Parser.SRC_DMG ? "" : `|${baseItem.source}`}`;
}
static _setCleanTaglineInfo_getGenericRequires ({stats, str, options}) {
switch (str.toLowerCase()) {
case "weapon": return [{"weapon": true}];
case "sword": return [{"sword": true}];
case "axe": return [{"axe": true}];
case "armor": return [{"armor": true}];
case "bow": return [{"bow": true}];
case "crossbow": return [{"crossbow": true}];
case "bow or crossbow": return [{"bow": true}, {"crossbow": true}];
case "spear": return [{"spear": true}];
case "polearm": return [{"polearm": true}];
case "weapon that deals bludgeoning damage":
case "bludgeoning": return [{"dmgType": "B"}];
case "piercing": return [{"dmgType": "P"}];
case "slashing": return [{"dmgType": "S"}];
case "ammunition": return [{"type": "A"}, {"type": "AF"}];
case "arrow": return [{"arrow": true}];
case "bolt": return [{"bolt": true}];
case "arrow or bolt": return [{"arrow": true}, {"bolt": true}];
case "melee": return [{"type": "M"}];
case "martial weapon": return [{"weaponCategory": "martial"}];
case "melee bludgeoning weapon": return [{"type": "M", "dmgType": "B"}];
default: {
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Tagline part "${str}" requires manual conversion`);
return [{[str.toCamelCase()]: true}];
}
}
}
static _RE_CATEGORIES_PREFIX_SUFFIX = /(?:weapon|blade|armor|sword|polearm|bow|crossbow|axe|ammunition|arrows?|bolts?)/;
static _RE_CATEGORIES_PREFIX = new RegExp(`^${this._RE_CATEGORIES_PREFIX_SUFFIX.source} `, "i");
static _RE_CATEGORIES_SUFFIX = new RegExp(` ${this._RE_CATEGORIES_PREFIX_SUFFIX.source}$`, "i");
static _setCleanTaglineInfo_handleGenericType (stats, options) {
if (!stats.__genericType) return;
delete stats.__genericType;
let prefixSuffixName = stats.name;
prefixSuffixName = prefixSuffixName
.replace(this._RE_CATEGORIES_PREFIX, "")
.replace(this._RE_CATEGORIES_SUFFIX, "");
const isSuffix = /^\s*of /i.test(prefixSuffixName);
stats.inherits = MiscUtil.copy(stats);
// Clean/move inherit props into inherits object
["name", "requires", "excludes", "ammo"].forEach(prop => delete stats.inherits[prop]); // maintain some props on base object
Object.keys(stats.inherits).forEach(k => delete stats[k]);
if (isSuffix) stats.inherits.nameSuffix = ` ${prefixSuffixName.trim()}`;
else stats.inherits.namePrefix = `${prefixSuffixName.trim()} `;
stats.__prop = "magicvariant";
stats.type = "GV";
}
static _setWeight (stats, options) {
const strEntries = JSON.stringify(stats.entries);
strEntries.replace(/weighs ([a-zA-Z0-9,]+) (pounds?|lbs?\.|tons?)/, (...m) => {
if (m[2].toLowerCase().trim().startsWith("ton")) throw new Error(`Handling for tonnage is unimplemented!`);
const noCommas = m[1].replace(/,/g, "");
if (!isNaN(noCommas)) stats.weight = Number(noCommas);
const fromText = Parser.textToNumber(m[1]);
if (!isNaN(fromText)) stats.weight = fromText;
if (!stats.weight) options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Weight "${m[1]}" requires manual conversion`);
});
}
static _setQuarterstaffStats (stats) {
const cpyStatsQuarterstaff = MiscUtil.copy(ItemParser._ALL_ITEMS.find(it => it.name === "Quarterstaff" && it.source === Parser.SRC_PHB));
// remove unwanted properties
delete cpyStatsQuarterstaff.name;
delete cpyStatsQuarterstaff.source;
delete cpyStatsQuarterstaff.page;
delete cpyStatsQuarterstaff.rarity;
delete cpyStatsQuarterstaff.value;
delete cpyStatsQuarterstaff.srd;
delete cpyStatsQuarterstaff.basicRules;
Object.entries(cpyStatsQuarterstaff)
.filter(([k]) => !k.startsWith("_"))
.forEach(([k, v]) => {
if (stats[k] == null) stats[k] = v;
});
}
static _mutRemoveBaseItemProps (stats) {
if (stats.__prop === "baseitem") return;
// region tags found only on basic items
delete stats.armor;
delete stats.axe;
delete stats.bow;
delete stats.crossbow;
delete stats.dagger;
delete stats.mace;
delete stats.net;
delete stats.spear;
delete stats.sword;
delete stats.weapon;
delete stats.hammer;
// endregion
}
}
ItemParser._ALL_ITEMS = null;
ItemParser._ALL_CLASSES = null;
ItemParser._MAPPED_ITEM_NAMES = {
"studded leather": "studded leather armor",
"leather": "leather armor",
"scale": "scale mail",
};
globalThis.ItemParser = ItemParser;

389
js/converter-race.js Normal file
View File

@@ -0,0 +1,389 @@
"use strict";
class _ParseStateTextRace extends BaseParseStateText {
}
class _ParseStateMarkdownRace extends BaseParseStateMarkdown {
constructor (...rest) {
super(...rest);
this.stack = [];
}
}
class RaceParser extends BaseParser {
static _doParse_getInitialState (inText, options) {
if (!inText || !inText.trim()) {
options.cbWarning("No input!");
return {};
}
const toConvert = this._getCleanInput(inText, options)
.split("\n")
.filter(it => it && it.trim());
const race = {};
race.source = options.source;
// for the user to fill out
race.page = options.page;
return {toConvert, race};
}
/* -------------------------------------------- */
/**
* Parses races from raw text pastes
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseText (inText, options) {
options = this._getValidOptions(options);
const {toConvert, race} = this._doParse_getInitialState(inText, options);
if (!toConvert) return;
const state = new _ParseStateTextRace({toConvert, options, entity: race});
state.doPreLoop();
for (; state.ixToConvert < toConvert.length; ++state.ixToConvert) {
state.initCurLine();
if (state.isSkippableCurLine()) continue;
switch (state.stage) {
case "name": this._doParseText_stepName(state); state.stage = "entries"; break;
case "entries": this._doParseText_stepEntries(state); break;
default: throw new Error(`Unknown stage "${state.stage}"`);
}
}
state.doPostLoop();
if (!race.entries?.length) delete race.entries;
const raceOut = this._getFinalEntity(race, options);
options.cbOutput(raceOut, options.isAppend);
}
static _doParseText_stepName (state) {
const name = state.curLine.replace(/ Traits$/i, "");
state.entity.name = this._getAsTitle("name", name, state.options.titleCaseFields, state.options.isTitleCase);
// region Skip over intro line
const nextLineMeta = state.getNextLineMeta();
if (!/[yY]ou have the following traits[.:]$/.test(nextLineMeta.nxtLine.trim())) return;
state.ixToConvert = nextLineMeta.ixToConvertNext;
// endregion
}
static _doParseText_stepEntries (state) {
const ptrI = {_: state.ixToConvert};
state.entity.entries = EntryConvert.coalesceLines(
ptrI,
state.toConvert,
);
state.ixToConvert = ptrI._;
}
/* -------------------------------------------- */
/**
* Parses races from raw markdown pastes
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseMarkdown (inText, options) {
options = this._getValidOptions(options);
const {toConvert, race} = this._doParse_getInitialState(inText, options);
if (!toConvert) return;
const state = new _ParseStateMarkdownRace({toConvert, options, entity: race});
for (; state.ixToConvert < toConvert.length; ++state.ixToConvert) {
state.initCurLine();
if (state.isSkippableCurLine()) continue;
switch (state.stage) {
case "name": this._doParseMarkdown_stepName(state); state.stage = "entries"; break;
case "entries": this._doParseMarkdown_stepEntries(state); break;
default: throw new Error(`Unknown stage "${state.stage}"`);
}
}
if (!race.entries?.length) delete race.entries;
const raceOut = this._getFinalEntity(race, options);
options.cbOutput(raceOut, options.isAppend);
}
static _doParseMarkdown_stepName (state) {
state.curLine = ConverterUtilsMarkdown.getNoHashes(state.curLine);
state.entity.name = this._getAsTitle("name", state.curLine, state.options.titleCaseFields, state.options.isTitleCase);
}
static _doParseMarkdown_stepEntries (state) {
state.entity.entries = state.entity.entries || [];
if (ConverterUtilsMarkdown.isInlineHeader(state.curLine)) {
while (state.stack.length) state.stack.pop();
const nxt = {type: "entries", name: "", entries: []};
if (state.stack.length) delete nxt.type;
state.stack.push(nxt);
state.entity.entries.push(nxt);
const [name, text] = ConverterUtilsMarkdown.getCleanTraitText(state.curLine);
nxt.name = name;
nxt.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
return;
}
if (ConverterUtilsMarkdown.isListItem(state.curLine)) {
if (state.stack.last()?.type !== "list") {
const lst = {type: "list", items: []};
if (state.stack.length) {
state.stack.last().entries.push(lst);
state.stack.push(lst);
} else {
state.entity.entries.push(lst);
state.stack.push(lst);
}
}
state.curLine = ConverterUtilsMarkdown.getNoLeadingListSymbol(state.curLine);
if (ConverterUtilsMarkdown.isInlineHeader(state.curLine)) {
state.stack.last().style = "list-hang-notitle";
const nxt = {type: "item", name: "", entry: ""};
state.stack.last().items.push(nxt);
const [name, text] = ConverterUtilsMarkdown.getCleanTraitText(state.curLine);
nxt.name = name;
nxt.entry = ConverterUtilsMarkdown.getNoLeadingSymbols(text);
} else {
state.stack.last().items.push(ConverterUtilsMarkdown.getNoLeadingSymbols(state.curLine));
}
return;
}
while (state.stack.length && state.stack.last().type === "list") state.stack.pop();
if (state.stack.length) {
state.stack.last().entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(state.curLine));
return;
}
state.entity.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(state.curLine));
}
// SHARED UTILITY FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _getFinalEntity (race, options) {
this._doRacePostProcess(race, options);
return PropOrder.getOrdered(race, race.__prop || "race");
}
static _doRacePostProcess (race, options) {
if (!race.entries) return;
// region Tag
EntryConvert.tryRun(race, "entries");
TagJsons.mutTagObject(race, {keySet: new Set(["entries"]), isOptimistic: false});
// endregion
// region Race-specific cleanup and generation
this._doRacePostProcess_size(race, options);
this._doRacePostProcess_ability(race, options);
this._doRacePostProcess_speed(race, options);
this._doRacePostProcess_speedFlight(race, options);
this._doRacePostProcess_creatureType(race, options);
this._doRacePostProcess_darkvision(race, options);
RaceLanguageTag.tryRun(race, options);
RaceImmResVulnTag.tryRun(race, options);
RaceTraitTag.tryRun(race, options);
// endregion
}
static _doRacePostProcess_size (race, options) {
const entry = race.entries.find(it => (it.name || "").toLowerCase() === "size");
if (entry?.entries?.length !== 1) return options.cbWarning(`Could not convert size\u2014no valid "Size" entry found!`);
const text = entry.entries[0];
const mSimple = /^(?:You are|Your size is) (?<size>Medium|Small)\.?$/.exec(text);
if (mSimple) {
race.size = [
mSimple.groups.size.toUpperCase()[0],
];
// Filter out "redundant" size info, as it will be displayed in subtitle
race.entries = race.entries.filter(it => it !== entry);
return;
}
const mChoose = /^(?:You are|Your size is) Medium or Small\b/.exec(text);
if (mChoose) {
race.size = [
"S",
"M",
];
return;
}
options.cbWarning(`Size text "${text}" requires manual conversion!`);
}
static _doRacePostProcess_ability (race, options) {
const entry = race.entries.find(it => (it.name || "").toLowerCase() === "ability score increase");
if (entry) return options.cbWarning(`Ability Score Increase requires manual conversion!`);
// If there is no ASI info, we assume ~~the worst~~ it's a post-VRGR "lineage" race
race.lineage = "VRGR";
}
static _doRacePostProcess_speed (race, options) {
const entry = race.entries.find(it => (it.name || "").toLowerCase() === "speed");
if (entry?.entries?.length !== 1) return options.cbWarning(`Could not convert speed\u2014no valid "Speed" entry found!`);
const text = entry.entries[0];
const mSimple = /^Your walking speed is (?<speed>\d+) feet\.?$/.exec(text);
if (mSimple) {
race.speed = Number(mSimple.groups.speed);
// Filter out "redundant" speed info, as it will be displayed in subtitle
race.entries = race.entries.filter(it => it !== entry);
return;
}
const mAltEqual = /Your walking speed is (?<speed>\d+) feet, and you have a (?<modeAlt>swimming|climbing) speed equal to your walking speed\./.exec(text);
if (mAltEqual) {
const propAlt = mAltEqual.groups.modeAlt === "swimming" ? "swim" : "climb";
race.speed = {
walk: Number(mAltEqual.groups.speed),
[propAlt]: true,
};
return;
}
options.cbWarning(`Size text "${text}" requires manual conversion!`);
}
static _doRacePostProcess_speedFlight (race, options) {
if (race.speed == null) return;
let found = false;
const walker = MiscUtil.getWalker({isNoModification: true, isBreakOnReturn: true});
for (const entry of race.entries) {
walker.walk(
entry.entries,
{
string: (str) => {
found = /\byou have a flying speed equal to your walking speed\b/i.test(str)
&& !/\b(?:temporarily|temporary)\b/i.test(str)
&& !/\buntil [^.]+ ends\b/i.test(str);
if (found) return true;
},
},
);
if (found) break;
}
if (!found) return;
if (typeof race.speed === "number") {
race.speed = {walk: race.speed, fly: true};
} else {
race.speed.fly = true;
}
}
static _RE_CREATURE_TYPES = new RegExp(`You are(?: an?)? (?<type>${Parser.MON_TYPES.map(it => it.uppercaseFirst()).join("|")})(?:\\.|$)`);
static _doRacePostProcess_creatureType (race, options) {
const entry = race.entries.find(it => (it.name || "").toLowerCase() === "creature type");
if (entry?.entries?.length !== 1) return options.cbWarning(`Could not convert creature type\u2014no valid "Creature Type" entry found!`);
let text = entry.entries[0];
const types = [];
text = text
.replace(/^You are a Humanoid(?:\.|$)/i, () => {
types.push(Parser.TP_HUMANOID);
return "";
})
.trim()
.replace(this._RE_CREATURE_TYPES, (...m) => {
types.push(m.last().type.toLowerCase());
return "";
})
.trim()
.replace(/You are also considered an? (?<tag>[-a-z]+) for any prerequisite or effect that requires you to be an? \k<tag>\./, (...m) => {
race.creatureTypeTags = [m.last().tag.toLowerCase()];
return "";
})
;
// Filter out "redundant" creature type info, as we assume "undefined" = "humanoid"
if (types.length === 1 && types[0] === Parser.TP_HUMANOID && !race.creatureTypeTags?.length) {
race.entries = race.entries.filter(it => it !== entry);
} else {
race.creatureTypes = types;
}
if (text) {
options.cbWarning(`Creature Type "${text}" requires manual conversion!`);
}
}
static _doRacePostProcess_darkvision (race, options) {
const entry = race.entries.find(it => (it.name || "").toLowerCase() === "darkvision");
if (!entry?.entries?.length) return;
const walker = MiscUtil.getWalker({isNoModification: true, isBreakOnReturn: true});
walker.walk(
entry,
{
string: (str) => {
const mDarkvision = /\bsee [^.]+ dim light [^.]+ (?<radius>\d+) feet [^.]+ bright light/i.exec(str);
if (!mDarkvision) return;
race.darkvision = Number(mDarkvision.groups.radius);
return true;
},
},
);
}
// SHARED PARSING FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _setX (race, options, curLine) {
}
}
globalThis.RaceParser = RaceParser;

530
js/converter-spell.js Normal file
View File

@@ -0,0 +1,530 @@
"use strict";
class SpellParser extends BaseParser {
static _RE_START_RANGE = "Range";
static _RE_START_COMPONENTS = "Components?";
static _RE_START_DURATION = "Duration";
static _RE_START_CLASS = "Class(?:es)?";
/**
* Parses spells from raw text pastes
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseText (inText, options) {
options = this._getValidOptions(options);
if (!inText || !inText.trim()) return options.cbWarning("No input!");
const toConvert = this._getCleanInput(inText, options)
.split("\n")
.filter(it => it && it.trim());
const spell = {};
spell.source = options.source;
// for the user to fill out
spell.page = options.page;
let prevLine = null;
let curLine = null;
let i;
for (i = 0; i < toConvert.length; i++) {
prevLine = curLine;
curLine = toConvert[i].trim();
if (curLine === "") continue;
// name of spell
if (i === 0) {
spell.name = this._getAsTitle("name", curLine, options.titleCaseFields, options.isTitleCase);
continue;
}
// spell level, and school plus ritual
if (i === 1) {
this._setCleanLevelSchoolRitual(spell, curLine, options);
continue;
}
// casting time
if (i === 2) {
this._setCleanCastingTime(spell, curLine, options);
continue;
}
// range
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_RANGE, line: curLine})) {
this._setCleanRange(spell, curLine, options);
continue;
}
// components
if (
ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_COMPONENTS, line: curLine})
) {
this._setCleanComponents(spell, curLine, options);
continue;
}
// duration
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_DURATION, line: curLine})) {
// avoid absorbing main body text
this._setCleanDuration(spell, curLine, options);
continue;
}
// class spell lists (alt)
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_CLASS, line: curLine})) {
// avoid absorbing main body text
this._setCleanClasses(spell, curLine, options);
continue;
}
const ptrI = {_: i};
spell.entries = EntryConvert.coalesceLines(
ptrI,
toConvert,
{
fnStop: (curLine) => /^(?:At Higher Levels|Class(?:es)?|Cantrip Upgrade)/gi.test(curLine),
},
);
i = ptrI._;
spell.entriesHigherLevel = EntryConvert.coalesceLines(
ptrI,
toConvert,
{
fnStop: (curLine) => /^Classes/gi.test(curLine),
},
);
i = ptrI._;
// class spell lists
if (i < toConvert.length) {
curLine = toConvert[i].trim();
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_CLASS, line: curLine})) {
this._setCleanClasses(spell, curLine, options);
}
}
}
if (!spell.entriesHigherLevel || !spell.entriesHigherLevel.length) delete spell.entriesHigherLevel;
const statsOut = this._getFinalState(spell, options);
options.cbOutput(statsOut, options.isAppend);
}
static _getCleanInput (ipt, options = null) {
let txt = super._getCleanInput(ipt, options);
const titles = [
"Casting Time",
"Range",
"Components?",
"Duration",
];
for (let i = 0; i < titles.length - 1; ++i) {
const start = titles[i];
const end = titles[i + 1];
const re = new RegExp(`(?<line>\\n${start}.*?)(?<suffix>\\n${end})`, "is");
txt = txt.replace(re, (...m) => {
return `\n${m.last().line.replace(/\n/g, " ").trim().replace(/ +/g, " ")}${m.last().suffix}`;
});
}
return txt;
}
// SHARED UTILITY FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _tryConvertSchool (s, {cbMan = null} = {}) {
const school = (s.school || "").toLowerCase().trim();
if (!school) return cbMan ? cbMan(`Spell school "${s.school}" requires manual conversion`) : null;
const out = SpellParser._RES_SCHOOL.find(it => it.regex.test(school));
if (out) {
s.school = out.output;
return;
}
if (cbMan) cbMan(`Spell school "${s.school}" requires manual conversion`);
}
static _doSpellPostProcess (stats, options) {
const doCleanup = () => {
// remove any empty arrays
Object.keys(stats).forEach(k => {
if (stats[k] instanceof Array && stats[k].length === 0) {
delete stats[k];
}
});
};
TagCondition.tryTagConditions(stats, {isTagInflicted: true});
if (stats.entries) {
stats.entries = stats.entries.map(it => DiceConvert.getTaggedEntry(it));
EntryConvert.tryRun(stats, "entries");
stats.entries = SkillTag.tryRun(stats.entries);
stats.entries = ActionTag.tryRun(stats.entries);
stats.entries = SenseTag.tryRun(stats.entries);
}
if (stats.entriesHigherLevel) {
stats.entriesHigherLevel = stats.entriesHigherLevel.map(it => DiceConvert.getTaggedEntry(it));
EntryConvert.tryRun(stats, "entriesHigherLevel");
stats.entriesHigherLevel = SkillTag.tryRun(stats.entriesHigherLevel);
stats.entriesHigherLevel = ActionTag.tryRun(stats.entriesHigherLevel);
stats.entriesHigherLevel = SenseTag.tryRun(stats.entriesHigherLevel);
}
this._addTags(stats, options);
doCleanup();
}
static _addTags (stats, options) {
DamageInflictTagger.tryRun(stats, options);
DamageResVulnImmuneTagger.tryRun(stats, "damageResist", options);
DamageResVulnImmuneTagger.tryRun(stats, "damageImmune", options);
DamageResVulnImmuneTagger.tryRun(stats, "damageVulnerable", options);
ConditionInflictTagger.tryRun(stats, options);
SavingThrowTagger.tryRun(stats, options);
AbilityCheckTagger.tryRun(stats, options);
SpellAttackTagger.tryRun(stats, options);
// TODO areaTags
MiscTagsTagger.tryRun(stats, options);
ScalingLevelDiceTagger.tryRun(stats, options);
}
// SHARED PARSING FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _setCleanLevelSchoolRitual (stats, line, options) {
const rawLine = line;
line = ConvertUtil.cleanDashes(line).trim();
const mCantrip = /cantrip/i.exec(line);
const mSpellLeve = /^(?<level>\d+)(?:st|nd|rd|th)?[- ]level/i.exec(line)
|| /^Level (?<level>\d+)\b/i.exec(line);
if (mCantrip) {
let trailing = line.slice(mCantrip.index + "cantrip".length, line.length).trim();
line = line.slice(0, mCantrip.index).trim();
trailing = this._setCleanLevelSchoolRitual_trailingClassGroup({stats, trailing});
// TODO implement as required (see at e.g. Deep Magic series)
if (trailing) {
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Level/school/ritual trailing part "${trailing}" requires manual conversion`);
}
stats.level = 0;
stats.school = line;
this._tryConvertSchool(stats, {cbMan: options.cbWarning});
return;
}
if (mSpellLeve) {
line = line.slice(mSpellLeve.index + mSpellLeve[0].length);
let isRitual = false;
line = line.replace(/\((.*?)(?:[,;]\s*)?ritual(?:[,;]\s*)?(.*?)\)/i, (...m) => {
isRitual = true;
// preserve any extra info inside the brackets
return m[1] || m[2] ? `(${m[1]}${m[2]})` : "";
}).trim();
if (isRitual) {
stats.meta = stats.meta || {};
stats.meta.ritual = true;
}
stats.level = Number(mSpellLeve.groups.level);
const [tkSchool, ...tksSchoolRest] = line.trim().split(" ");
stats.school = tkSchool;
if (/^(?:school|spell)$/i.test(tksSchoolRest[0] || 0)) tksSchoolRest.shift();
let trailing = tksSchoolRest.join(" ");
trailing = this._setCleanLevelSchoolRitual_trailingClassGroup({stats, trailing});
// TODO further handling of non-school text (see e.g. Deep Magic series)
if (trailing) {
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Level/school/ritual trailing part "${trailing}" requires manual conversion`);
}
this._tryConvertSchool(stats, {cbMan: options.cbWarning});
return;
}
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Level/school/ritual part "${rawLine}" requires manual conversion`);
}
static _setCleanLevelSchoolRitual_trailingClassGroup ({stats, trailing}) {
if (!trailing) return trailing;
return trailing
.split(/([()])/g)
.map(tk => {
return tk
.split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX)
.map(tk => {
return tk
.replace(new RegExp(ConverterConst.STR_RE_CLASS, "i"), (...m) => {
(stats.groups ||= []).push({
name: m.last().name,
source: stats.source,
});
return "";
})
.replace(/\s+/g, " ")
;
})
.filter(it => it.trim())
.join(",");
})
.join("")
.replace(/\(\s*\)/g, "")
.trim();
}
static _setCleanRange (stats, line, options) {
const getUnit = (str) => str.toLowerCase().includes("mile") ? "miles" : "feet";
const range = ConvertUtil.cleanDashes(ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_RANGE, line}));
if (range.toLowerCase() === "self") return stats.range = {type: "point", distance: {type: "self"}};
if (range.toLowerCase() === "special") return stats.range = {type: "special"};
if (range.toLowerCase() === "unlimited") return stats.range = {type: "point", distance: {type: "unlimited"}};
if (range.toLowerCase() === "unlimited on the same plane") return stats.range = {type: "point", distance: {type: "plane"}};
if (range.toLowerCase() === "sight") return stats.range = {type: "point", distance: {type: "sight"}};
if (range.toLowerCase() === "touch") return stats.range = {type: "point", distance: {type: "touch"}};
const cleanRange = range.replace(/(\d),(\d)/g, "$1$2");
const mFeetMiles = /^(\d+) (feet|foot|miles?)$/i.exec(cleanRange);
if (mFeetMiles) return stats.range = {type: "point", distance: {type: getUnit(mFeetMiles[2]), amount: Number(mFeetMiles[1])}};
const mSelfRadius = /^self \((\d+)-(foot|mile) radius\)$/i.exec(cleanRange);
if (mSelfRadius) return stats.range = {type: "radius", distance: {type: getUnit(mSelfRadius[2]), amount: Number(mSelfRadius[1])}};
const mSelfSphere = /^self \((\d+)-(foot|mile)(?:-radius)? sphere\)$/i.exec(cleanRange);
if (mSelfSphere) return stats.range = {type: "sphere", distance: {type: getUnit(mSelfSphere[2]), amount: Number(mSelfSphere[1])}};
const mSelfCone = /^self \((\d+)-(foot|mile) cone\)$/i.exec(cleanRange);
if (mSelfCone) return stats.range = {type: "cone", distance: {type: getUnit(mSelfCone[2]), amount: Number(mSelfCone[1])}};
const mSelfLine = /^self \((\d+)-(foot|mile) line\)$/i.exec(cleanRange);
if (mSelfLine) return stats.range = {type: "line", distance: {type: getUnit(mSelfLine[2]), amount: Number(mSelfLine[1])}};
const mSelfCube = /^self \((\d+)-(foot|mile) cube\)$/i.exec(cleanRange);
if (mSelfCube) return stats.range = {type: "cube", distance: {type: getUnit(mSelfCube[2]), amount: Number(mSelfCube[1])}};
const mSelfHemisphere = /^self \((\d+)-(foot|mile)(?:-radius)? hemisphere\)$/i.exec(cleanRange);
if (mSelfHemisphere) return stats.range = {type: "hemisphere", distance: {type: getUnit(mSelfHemisphere[2]), amount: Number(mSelfHemisphere[1])}};
// region Homebrew
const mPointCube = /^(?<point>\d+) (?<unit>feet|foot|miles?) \((\d+)-(foot|mile) cube\)$/i.exec(cleanRange);
if (mPointCube) return stats.range = {type: "point", distance: {type: getUnit(mPointCube.groups.unit), amount: Number(mPointCube.groups.point)}};
// endregion
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Range part "${range}" requires manual conversion`);
}
static _getCleanTimeUnit (unit, isDuration, options) {
unit = unit.toLowerCase().trim();
switch (unit) {
case "days":
case "years":
case "hours":
case "minutes":
case "actions":
case "rounds": return unit.slice(0, -1);
case "day":
case "year":
case "hour":
case "minute":
case "action":
case "round":
case "reaction": return unit;
case "bonus action": return "bonus";
default:
options.cbWarning(`Unit part "${unit}" requires manual conversion`);
return unit;
}
}
static _setCleanCastingTime (stats, line, options) {
const allParts = ConvertUtil.getStatblockLineHeaderText({reStartStr: "Casting Time", line});
const parts = /\b(?:reaction|which you (?:take|use))\b/.test(allParts)
? [allParts]
: allParts.split(/; | or /gi);
stats.time = parts
.map(it => it.trim())
.filter(Boolean)
.map(str => {
const mNumber = /^(?<count>\d+)?(?<rest>.*?)$/.exec(str);
if (!mNumber) {
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Casting time part "${str}" requires manual conversion`);
return str;
}
const amount = mNumber.groups.count ? Number(mNumber.groups.count.trim()) : null;
const [unit, ...conditionParts] = mNumber.groups.rest.split(", ");
const out = {
number: amount ?? 1,
unit: this._getCleanTimeUnit(unit, false, options),
condition: conditionParts.join(", "),
};
if (!out.condition) delete out.condition;
return out;
})
;
}
static _setCleanComponents (stats, line, options) {
const components = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_COMPONENTS, line});
const parts = components.split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX);
stats.components = {};
parts
.map(it => it.trim())
.filter(Boolean)
.forEach(pt => {
const lowerPt = pt.toLowerCase();
switch (lowerPt) {
case "v": stats.components.v = true; break;
case "s": stats.components.s = true; break;
default: {
if (lowerPt.startsWith("m ")) {
const materialText = pt.replace(/^m\s*\((.*)\)$/i, "$1").trim();
const mCost = /(\d*,?\d+)\s?(cp|sp|ep|gp|pp)/gi.exec(materialText);
const isConsumed = pt.toLowerCase().includes("consume");
if (mCost) {
const valueMult = Parser.COIN_CONVERSIONS[Parser.COIN_ABVS.indexOf(mCost[2].toLowerCase())];
const valueNum = Number(mCost[1].replace(/,/g, ""));
stats.components.m = {
text: materialText,
cost: valueNum * valueMult,
};
if (isConsumed) stats.components.m.consume = true;
} else if (isConsumed) {
stats.components.m = {
text: materialText,
consume: true,
};
} else {
stats.components.m = materialText;
}
} else if (lowerPt.startsWith("r ")) stats.components.r = true;
else options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Components part "${pt}" requires manual conversion`);
}
}
});
}
static _setCleanDuration (stats, line, options) {
const dur = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_DURATION, line});
if (dur.toLowerCase() === "instantaneous") return stats.duration = [{type: "instant"}];
if (dur.toLowerCase() === "instantaneous (see text)") return stats.duration = [{type: "instant", condition: "see text"}];
if (dur.toLowerCase() === "special") return stats.duration = [{type: "special"}];
if (dur.toLowerCase() === "permanent") return stats.duration = [{type: "permanent"}];
const mConcOrUpTo = /^(concentration, )?up to (\d+|an?) (hour|minute|turn|round|week|day|year)(?:s)?$/i.exec(dur);
if (mConcOrUpTo) {
const amount = mConcOrUpTo[2].toLowerCase().startsWith("a") ? 1 : Number(mConcOrUpTo[2]);
const out = {type: "timed", duration: {type: this._getCleanTimeUnit(mConcOrUpTo[3], true, options), amount}, concentration: true};
if (mConcOrUpTo[1]) out.concentration = true;
else out.upTo = true;
return stats.duration = [out];
}
const mTimed = /^(\d+) (hour|minute|turn|round|week|day|year)(?:s)?$/i.exec(dur);
if (mTimed) return stats.duration = [{type: "timed", duration: {type: this._getCleanTimeUnit(mTimed[2], true, options), amount: Number(mTimed[1])}}];
const mDispelledTriggered = /^until dispelled( or triggered)?$/i.exec(dur);
if (mDispelledTriggered) {
const out = {type: "permanent", ends: ["dispel"]};
if (mDispelledTriggered[1]) out.ends.push("trigger");
return stats.duration = [out];
}
const mPermDischarged = /^permanent until discharged$/i.exec(dur);
if (mPermDischarged) {
const out = {type: "permanent", ends: ["discharge"]};
return stats.duration = [out];
}
// TODO handle splitting "or"'d durations up as required
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Duration part "${dur}" requires manual conversion`);
}
static _setCleanClasses (stats, line, options) {
const classLine = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_CLASS, line});
const classes = classLine.split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX);
stats.classes = {fromClassList: []};
classes
.map(it => it.trim())
.filter(Boolean)
.forEach(pt => {
const lowerPt = pt.toLowerCase();
switch (lowerPt) {
case "artificer":
case "artificers": stats.classes.fromClassList.push({"name": "Artificer", "source": "TCE"}); break;
case "bard":
case "bards": stats.classes.fromClassList.push({"name": "Bard", "source": "PHB"}); break;
case "cleric":
case "clerics": stats.classes.fromClassList.push({"name": "Cleric", "source": "PHB"}); break;
case "druid":
case "druids": stats.classes.fromClassList.push({"name": "Druid", "source": "PHB"}); break;
case "paladin":
case "paladins": stats.classes.fromClassList.push({"name": "Paladin", "source": "PHB"}); break;
case "ranger":
case "rangers": stats.classes.fromClassList.push({"name": "Ranger", "source": "PHB"}); break;
case "sorcerer":
case "sorcerers": stats.classes.fromClassList.push({"name": "Sorcerer", "source": "PHB"}); break;
case "warlock":
case "warlocks": stats.classes.fromClassList.push({"name": "Warlock", "source": "PHB"}); break;
case "wizard":
case "wizards": stats.classes.fromClassList.push({"name": "Wizard", "source": "PHB"}); break;
default: options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Class "${lowerPt}" requires manual conversion`); break;
}
});
if (!stats.classes.fromClassList.length) delete stats.classes;
}
static _getFinalState (spell, options) {
this._doSpellPostProcess(spell, options);
return PropOrder.getOrdered(spell, "spell");
}
}
SpellParser._RES_SCHOOL = Object.entries({
"transmutation": "T",
"necromancy": "N",
"conjuration": "C",
"abjuration": "A",
"enchantment": "E",
"evocation": "V",
"illusion": "I",
"divination": "D",
}).map(([k, v]) => ({
output: v,
regex: RegExp(`^${k}(?: school)?$`, "i"),
}));
globalThis.SpellParser = SpellParser;

140
js/converter-table.js Normal file
View File

@@ -0,0 +1,140 @@
"use strict";
class TableParser extends BaseParser {
/**
* Parses tables from HTML.
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseHtml (inText, options) {
options = this._getValidOptions(options);
if (!inText || !inText.trim()) return options.cbWarning("No input!");
inText = this._getCleanInput(inText, options);
const handleTable = ($table, caption) => {
const tbl = {
type: "table",
caption,
colLabels: [],
colStyles: [],
rows: [],
};
const getCleanHeaderText = ($ele) => {
let txt = $ele.text().trim();
// if it's all-uppercase, title-case it
if (txt.toUpperCase() === txt) txt = txt.toTitleCase();
return txt;
};
// Caption
if ($table.find(`caption`)) {
tbl.caption = $table.find(`caption`).text().trim();
}
// Columns
if ($table.find(`thead`)) {
const $headerRows = $table.find(`thead tr`);
if ($headerRows.length !== 1) options.cbWarning(`Table header had ${$headerRows.length} rows!`);
$headerRows.each((i, r) => {
const $r = $(r);
if (i === 0) { // use first tr as column headers
$r.find(`th, td`).each((i, h) => tbl.colLabels.push(getCleanHeaderText($(h))));
} else { // use others as rows
const row = [];
$r.find(`th, td`).each((i, h) => row.push(getCleanHeaderText($(h))));
if (row.length) tbl.rows.push(row);
}
});
$table.find(`thead`).remove();
} else if ($table.find(`th`)) {
$table.find(`th`).each((i, h) => tbl.colLabels.push(getCleanHeaderText($(h))));
$table.find(`th`).parent().remove();
}
// Rows
const handleTableRow = (i, r) => {
const $r = $(r);
const row = [];
$r.find(`td`).each((i, cell) => {
const $cell = $(cell);
row.push($cell.text().trim());
});
tbl.rows.push(row);
};
if ($table.find(`tbody`)) {
$table.find(`tbody tr`).each(handleTableRow);
} else {
$table.find(`tr`).each(handleTableRow);
}
MarkdownConverter.postProcessTable(tbl);
options.cbOutput(tbl, options.isAppend);
};
const $input = $(inText);
if ($input.is("table")) {
handleTable($input);
} else {
// TODO pull out any preceding text to use as the caption; pass this in
const caption = "";
$input.find("table").each((i, e) => {
const $table = $(e);
handleTable($table, caption);
});
}
}
/**
* Parses tables from Markdown.
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseMarkdown (inText, options) {
if (!inText || !inText.trim()) return options.cbWarning("No input!");
inText = this._getCleanInput(inText, options);
const lines = inText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split(/\n/g);
const stack = [];
let cur = null;
lines.forEach(l => {
if (l.trim().startsWith("##### ")) {
if (cur && cur.lines.length) stack.push(cur);
cur = {caption: l.trim().replace(/^##### /, ""), lines: []};
} else {
cur = cur || {lines: []};
cur.lines.push(l);
}
});
if (cur && cur.lines.length) stack.push(cur);
const toOutput = stack.map(tbl => MarkdownConverter.getConvertedTable(tbl.lines, tbl.caption)).reverse();
toOutput.forEach((out, i) => {
if (options.isAppend) options.cbOutput(out, true);
else {
if (i === 0) options.cbOutput(out, false);
else options.cbOutput(out, true);
}
});
}
}
globalThis.TableParser = TableParser;

1348
js/converter.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,757 @@
"use strict";
class BackgroundConverterConst {
static RE_NAME_SKILLS = /^Skill Proficienc(?:ies|y):/;
static RE_NAME_TOOLS = /^(?:Tools?|Tool Proficienc(?:ies|y)):/;
static RE_NAME_LANGUAGES = /^Languages?:/;
static RE_NAME_EQUIPMENT = /^Equipment?:/;
}
class UtilBackgroundParser {
static getEquipmentEntry (background) {
const list = background.entries.find(ent => ent.type === "list");
if (!list) return null;
return list.items.find(ent => BackgroundConverterConst.RE_NAME_EQUIPMENT.test(ent.name));
}
}
globalThis.UtilBackgroundParser = UtilBackgroundParser;
class EquipmentBreakdown {
static _WALKER;
static tryRun (
bg,
{
isSkipExisting = false,
cbWarning = () => {},
mappingsManual = {},
allowlistOrEnds = [],
blocklistSplits = [],
} = {},
) {
if (!bg.entries) return cbWarning(`No entries found on "${bg.name}"`);
if (isSkipExisting && bg.startingEquipment) return;
delete bg.startingEquipment;
this._WALKER ||= MiscUtil.getWalker({isNoModification: true, isBreakOnReturn: true});
const entryEquipment = UtilBackgroundParser.getEquipmentEntry(bg);
if (!entryEquipment.entry) throw new Error(`Unimplemented!`);
if (!entryEquipment) return;
this._convert({
bg,
entry: entryEquipment.entry,
mappingsManual,
allowlistOrEnds,
blocklistSplits,
});
}
/**
* Output structure:
*
* ```
* equipment: [
* {
* "_": [
* <equipment details>
* ]
* },
* {
* "a": [
* <equipment details; choice A>
* ],
* "b": [
* <equipment details; choice B>
* ]
* }
* ]
* ```
*
* @param bg
* @param entry
* @param mappingsManual
* @param allowlistOrEnds
* @param blocklistSplits
* @private
*/
static _convert (
{
bg,
entry,
mappingsManual,
allowlistOrEnds,
blocklistSplits,
},
) {
blocklistSplits.forEach((str, i) => entry = entry.replace(str, `__SPLIT_${i}__`));
const parts = entry
.split(/\. /g)
.map(it => it.trim())
.map(it => it.split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX).map(it => it.trim()))
.flat()
.map(it => {
return it.replace(/__SPLIT_(\d+)__/gi, (...m) => {
const ix = Number(m[1]);
return blocklistSplits[ix];
});
});
const out = parts
.map(pt => {
// Strip leading "and" and trailing punctuation
pt = pt
.trim()
.replace(/^and /i, "")
.replace(/[.!?]$/, "")
.trim();
// Split up choices
const ptChoices = this._splitChoices({str: pt, allowlistOrEnds})
.map(c => c.trim().replace(/,$/, "").trim());
const outChoices = ptChoices
.map(ch => {
const chOriginal = ch;
// Pull out quantities
let quantity = 1;
ch = ch
.replace(/^(any one|an|a|one|two|three|four|five|six|seven|eight|nine|ten|\d+)/i, (...m) => {
quantity = Parser.textToNumber(
m[1]
.replace(/^any/i, "").trim(),
);
return "";
})
.trim();
if (isNaN(quantity)) throw new Error(`Quantity found in "${chOriginal}" was not a number!`);
// Pull out coinage
let valueCp = 0;
let cntValueContainingWith = 0;
let cntValueWorth = 0;
ch = ch
// Remove trailing parenthetical parts, e.g. "... (Azorius-minted 1-zino coins)"
.replace(/(containing|with|worth) (\d+\s*[csgep]p)(\s+\([^)]+\))?/g, (...m) => {
switch (m[1].toLowerCase().trim()) {
case "containing":
case "with": cntValueContainingWith += 1; break;
case "worth": cntValueWorth += 1; break;
default: throw new Error(`Unhandled "${m[1]}"`);
}
valueCp += Parser.coinValueToNumber(m[2]);
return "";
})
.replace(/\s+/g, " ")
.trim()
// Handle e.g. "1 sp"--the quantity will have been pulled out already
.replace(/^[csgep]p$/g, (...m) => {
valueCp = Parser.coinValueToNumber(`${quantity} ${m[0]}`);
return "";
})
.trim()
// Handle e.g. "(10 gp)"
.replace(/\((\d+\s*[csgep]p)\)/g, (...m) => {
valueCp += Parser.coinValueToNumber(m[1]);
return "";
})
.trim();
// Pull out "set of... " for clothes
if (/clothes/i.test(ch)) {
ch = ch.replace(/^set of /i, "");
}
// Pull out parenthetical parts that aren't in @item tags
const ptParenMeta = this._getWithoutParenParts(ch);
let ptDisplay = ch; // Keep a copy of the part to display as the item name
let ptDisplayNoTags = Renderer.stripTags(ptDisplay);
let ptPlain = ptParenMeta.plain.map(it => it.trim()).join(" ").trim();
let ptParens = ptParenMeta.inParens.map(it => it.trim()).join(" ").trim();
// Handle any manual mappings
if (mappingsManual[chOriginal]) {
return mappingsManual[chOriginal];
}
// Handle any pure coinage
if (!ptPlain && valueCp) {
return {value: valueCp};
}
// If the plain part seems to just be a single @item tag, use this
const mItem = /{@item ([^}]+)}/.exec(ptPlain);
const mFilter = /{@filter ([^}]+)}/.exec(ptPlain);
// If the plain part is a flavorful name of an @item, use this
const mItemParens = /^\({@item ([^}]+)}\)$/.exec(ptParens);
if (mItem) {
// consider doing something with displayText?
const [name, source, displayText] = mItem[1].split("|").map(it => it.trim()).filter(Boolean);
const idItem = [name, source].join("|").toLowerCase();
if (quantity !== 1 || ptPlain !== ptDisplay || valueCp) {
const info = {item: idItem};
if (quantity !== 1) info.quantity = quantity;
if (ptPlain !== ptDisplay) info.displayName = ptDisplayNoTags;
if (valueCp) info.containsValue = valueCp;
return info;
}
if (!ptPlain.startsWith("{@") || !ptPlain.endsWith("}")) {
return {
item: idItem,
displayName: ptDisplayNoTags,
};
}
return idItem;
}
if (mFilter) {
// Strip junk text
let ptPlainFilter = ptPlain
.replace(/^set of/gi, "").trim()
.replace(/ you are proficient with$/gi, "").trim()
.replace(/ with which you are proficient$/gi, "").trim()
.replace(/ of your choice$/gi, "").trim()
.replace(/ \([^)]+\)/gi, "").trim()
// Brew
.replace(/^wind /gi, "").trim();
// We expect that the entire text is now a filter tag
if (!ptPlainFilter.startsWith("{@") || !ptPlainFilter.endsWith("}")) throw new Error(`Text "${ptPlainFilter}" was not a single tag!`);
const info = this._getFilterType(mFilter[1].split("|")[0]);
if (quantity !== 1) info.quantity = quantity;
if (valueCp) info.containsValue = valueCp;
return info;
}
if (mItemParens) {
// consider doing something with displayText?
const [name, source, displayText] = mItemParens[1].split("|").map(it => it.trim()).filter(Boolean);
const idItem = [name, source].join("|").toLowerCase();
const info = {
item: idItem,
displayName: ptPlain,
};
if (quantity !== 1) info.quantity = quantity;
if (valueCp) info.containsValue = valueCp;
return info;
}
// Otherwise, create a custom item
const info = {special: ptDisplayNoTags.trim()};
if (quantity !== 1) info.quantity = quantity;
if (valueCp) {
if (cntValueWorth > cntValueContainingWith) info.worthValue = valueCp;
else info.containsValue = valueCp;
}
return info;
})
.flat();
// Assign each choice a letter (or use underscore if it's the only choice)
if (outChoices.length === 1) {
if (outChoices[0].isList) return {"_": outChoices[0].data};
return {"_": [outChoices[0]]};
}
const outPart = {};
outChoices.forEach((ch, i) => {
const letter = Parser.ALPHABET[i].toLowerCase();
outPart[letter] = [ch];
});
return outPart;
});
// Combine no-choice sections together
const outReduced = [];
out
.forEach(info => {
if (!info._) return outReduced.push(info);
const existing = outReduced.find(x => x._);
if (existing) return existing._.push(...info._);
return outReduced.push(info);
});
bg.startingEquipment = outReduced;
}
static _splitChoices ({str, allowlistOrEnds}) {
const out = [];
let expectAt = false;
let braceDepth = 0;
let parenDepth = 0;
let stack = "";
for (let i = 0; i < str.length; ++i) {
const c = str[i];
switch (c) {
case "(": {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c;
parenDepth++;
break;
}
case ")": {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c;
if (parenDepth) parenDepth--;
break;
}
case "{": {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c;
braceDepth++;
expectAt = true;
break;
}
case "}": {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c;
if (braceDepth) braceDepth--;
break;
}
case "@": { expectAt = false; stack += c; break; }
case " ": {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c;
if (
!braceDepth
&& !parenDepth
) {
// An oxford comma implies earlier commas in this part are separating or'd parts, so back-split
if (
stack.endsWith(", or ")
&& !allowlistOrEnds.some(it => stack.endsWith(it))
) {
const backSplit = stack.slice(0, -5).split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX).map(it => it.trim());
out.push(...backSplit);
stack = "";
} else if (
stack.endsWith(" or ")
&& !allowlistOrEnds.some(it => stack.endsWith(it))
) {
out.push(stack.slice(0, -4));
stack = "";
}
}
break;
}
default: {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c;
break;
}
}
}
out.push(stack);
// Split two conjoined items
if (out.length === 1 && out[0].includes(" and ")) {
let cntItem = 0;
out[0].replace(/{@item/g, () => {
cntItem++;
return "";
});
if (cntItem > 1) {
if (cntItem !== 2) throw new Error(`Unhandled conjunction count "${cntItem}"`);
const spl = out[0].split(" and ");
out[0] = spl[0];
out.push(spl[1]);
}
}
return out;
}
static _getWithoutParenParts (str) {
const out = [];
const outParens = [];
let expectAt = false;
let braceDepth = 0;
let parenCount = 0;
let stack = "";
for (let i = 0; i < str.length; ++i) {
const c = str[i];
switch (c) {
case "(": {
if (expectAt) { braceDepth--; expectAt = false; }
if (!braceDepth) {
if (!parenCount) {
out.push(stack);
stack = "";
}
parenCount++;
}
stack += c;
break;
}
case ")": {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c;
if (!braceDepth && parenCount) {
parenCount--;
if (!parenCount) {
outParens.push(stack);
stack = "";
}
}
break;
}
case "{": {
if (expectAt) { braceDepth--; expectAt = false; }
braceDepth++;
expectAt = true;
stack += c;
break;
}
case "}": {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c;
if (braceDepth) braceDepth--;
break;
}
case "@": { expectAt = false; stack += c; break; }
default: {
if (expectAt) { braceDepth--; expectAt = false; }
stack += c; break;
}
}
}
// Gather any leftovers
if (!braceDepth) {
if (!parenCount) out.push(stack);
else outParens.push(stack);
} else {
out.push(stack);
}
return {plain: out.filter(Boolean), inParens: outParens.filter(Boolean)};
}
static _getFilterType (str) {
switch (str.toLowerCase().trim()) {
case "artisan's tools": return {equipmentType: "toolArtisan"};
case "gaming set": return {equipmentType: "setGaming"};
case "musical instrument": return {equipmentType: "instrumentMusical"};
default: throw new Error(`Unhandled filter type "${str}"`);
}
}
}
globalThis.EquipmentBreakdown = EquipmentBreakdown;
class BackgroundSkillTollLanguageEquipmentCoalesce {
static tryRun (
bg,
{
cbWarning = () => {},
} = {},
) {
if (!bg.entries) return;
const [entriesToCompact, entriesOther] = bg.entries.segregate(ent => this._isToCompact(ent));
if (!entriesToCompact.length) return;
const list = {
"type": "list",
"style": "list-hang-notitle",
};
list.items = entriesToCompact
.map(ent => {
return ent.entries.length === 1
? {
type: "item",
name: ent.name,
entry: ent.entries[0],
}
: {
type: "item",
name: ent.name,
entries: ent.entries,
};
});
bg.entries = [
list,
...entriesOther,
];
}
static _RES_COMPACT = [
BackgroundConverterConst.RE_NAME_SKILLS,
BackgroundConverterConst.RE_NAME_TOOLS,
BackgroundConverterConst.RE_NAME_LANGUAGES,
/^Equipment:/,
];
static _isToCompact (ent) {
return this._RES_COMPACT
.some(re => re.test(ent.name));
}
}
globalThis.BackgroundSkillTollLanguageEquipmentCoalesce = BackgroundSkillTollLanguageEquipmentCoalesce;
class BackgroundSkillToolLanguageTag {
static tryRun (
bg,
{
cbWarning = () => {},
} = {},
) {
const list = bg.entries.find(ent => ent.type === "list");
this._doSkillTag({bg, list, cbWarning});
this._doToolTag({bg, list, cbWarning});
this._doLanguageTag({bg, list, cbWarning});
}
static _doSkillTag ({bg, list, cbWarning}) {
const skillProf = list.items.find(ent => BackgroundConverterConst.RE_NAME_SKILLS.test(ent.name));
if (!skillProf) return;
const mOneStaticOneChoice = /^(?<predefined>.*)\band one choice from the following:(?<choices>.*)$/i.exec(skillProf.entry);
if (mOneStaticOneChoice) {
const predefined = {};
mOneStaticOneChoice.groups.predefined
.replace(/{@skill (?<skill>[^}]+)/g, (...m) => {
predefined[m.last().skill.toLowerCase().trim()] = true;
return "";
});
if (!Object.keys(predefined).length) return cbWarning(`(${bg.name}) Skills require manual tagging`);
const choices = [];
mOneStaticOneChoice.groups.choices
.replace(/{@skill (?<skill>[^}]+)/g, (...m) => {
choices.push(m.last().skill.toLowerCase().trim());
return "";
});
bg.skillProficiencies = [
{
...predefined,
choose: {
from: choices,
},
},
];
return;
}
if (/^Two of the following:/.test(skillProf.entry)) {
const choices = [];
skillProf.entry
.replace(/{@skill (?<skill>[^}]+)/g, (...m) => {
choices.push(m.last().skill.toLowerCase().trim());
return "";
});
bg.skillProficiencies = [
{
choose: {
from: choices,
count: 2,
},
},
];
return;
}
if (!/^({@skill [^}]+}(?:, )?)+$/.test(skillProf.entry)) return cbWarning(`(${bg.name}) Skills require manual tagging`);
bg.skillProficiencies = [
skillProf.entry
.split(",")
.map(ent => ent.trim())
.mergeMap(str => {
const reTag = /^{@skill (?<skill>[^}]+)}$/.exec(str);
if (reTag) return {[reTag.groups.skill.toLowerCase().trim()]: true};
throw new Error(`Couldn't find tag in ${str}`);
}),
];
}
static _doToolTag ({bg, list, cbWarning}) {
const toolProf = list.items.find(ent => BackgroundConverterConst.RE_NAME_TOOLS.test(ent.name));
if (!toolProf) return;
const entry = Renderer.stripTags(toolProf.entry.toLowerCase())
.replace(/one type of gaming set/g, "gaming set")
.replace(/one type of artisan's tools/g, "artisan's tools")
.replace(/one type of gaming set/g, "gaming set")
.replace(/one type of musical instrument/g, "musical instrument")
.replace(/one other set of artisan's tools/g, "artisan's tools")
.replace(/s' supplies/g, "'s supplies")
;
const isChoice = /\bany |\bchoose |\bone type|\bchoice|\bor /g.exec(entry);
const isSpecial = /\bspecial/.exec(entry);
if (!isChoice && !isSpecial) {
bg.toolProficiencies = [
entry.toLowerCase()
.split(/,\s?(?![^(]*\))| or | and /g)
.filter(Boolean)
.map(pt => pt.trim())
.filter(pt => pt)
.mergeMap(pt => ({[pt]: true})),
];
return;
}
if (isChoice) {
const entryClean = entry
.replace(/^either /gi, "");
const out = {};
switch (entryClean) {
case "cartographer's tools or navigator's tools":
out.choose = {from: ["navigator's tools", "cartographer's tools"]};
break;
case "disguise kit, and artisan's tools or gaming set":
out["disguise kit"] = true;
out.choose = {from: ["artisan's tools", "gaming set"]};
break;
case "any one musical instrument or gaming set of your choice, likely something native to your homeland":
out.choose = {from: ["musical instrument", "gaming set"]};
break;
case "your choice of a gaming set or a musical instrument":
out.choose = {from: ["musical instrument", "gaming set"]};
break;
case "musical instrument or artisan's tools":
out.choose = {from: ["musical instrument", "artisan's tools"]};
break;
case "one type of artistic artisan's tools and one musical instrument":
out["artisan's tools"] = true;
out["musical instrument"] = true;
break;
case "choose two from among gaming set, one musical instrument, and thieves' tools":
out.choose = {
from: ["gaming set", "musical instrument", "thieves' tools"],
count: 2,
};
break;
case "artisan's tools, or navigator's tools, or an additional language":
out.choose = {from: ["artisan's tools", "navigator's tools"]};
break;
case "gaming set or musical instrument":
out.choose = {from: ["gaming set", "musical instrument"]};
break;
case "calligrapher's supplies or alchemist's supplies":
out.choose = {from: ["calligrapher's supplies", "alchemist's supplies"]};
break;
default:
cbWarning(`(${bg.name}) Tool proficiencies require manual tagging in "${entry}"`);
}
bg.toolProficiencies = [out];
return;
}
if (isSpecial) {
cbWarning(`(${bg.name}) Tool proficiencies require manual tagging in "${entry}"`);
}
}
static _doLanguageTag ({bg, list, cbWarning}) {
const langProf = list.items.find(ent => BackgroundConverterConst.RE_NAME_LANGUAGES.test(ent.name));
if (!langProf) return;
const languageProficiencies = this._getLanguageTags({langProf});
if (!languageProficiencies) {
cbWarning(`(${bg.name}) Language proficiencies require manual tagging in "${langProf.entry}"`);
return;
}
bg.languageProficiencies = languageProficiencies;
}
static _getLanguageTags ({langProf}) {
langProf.entry = langProf.entry
.replace(/\bElven\b/, "Elvish");
let str = langProf.entry
.replace(/\([^)]+ recommended\)$/, "")
.trim();
const reStrLanguage = `(${Parser.LANGUAGES_ALL.map(it => it.escapeRegexp()).join("|")})`;
const mSingle = new RegExp(`^${reStrLanguage}$`, "i").exec(str);
if (mSingle) return [{[mSingle[1].toLowerCase()]: true}];
const mDoubleAnd = new RegExp(`^${reStrLanguage} and ${reStrLanguage}$`, "i").exec(str);
if (mDoubleAnd) return [{[mDoubleAnd[1].toLowerCase()]: true, [mDoubleAnd[2].toLowerCase()]: true}];
const mDoubleAndChoose = new RegExp(`^${reStrLanguage} and one other language of your choice$`, "i").exec(str);
if (mDoubleAndChoose) return [{[mDoubleAndChoose[1].toLowerCase()]: true, "anyStandard": true}];
const mDoubleOr = new RegExp(`^${reStrLanguage} or ${reStrLanguage}$`, "i").exec(str);
if (mDoubleOr) return [{[mDoubleOr[1].toLowerCase()]: true}, {[mDoubleOr[2].toLowerCase()]: true}];
const mNumAny = /^(?:any )?((?<count>one|two) )?of your choice$/i.exec(str);
if (mNumAny) return [{"anyStandard": Parser.textToNumber(mNumAny.groups?.count || "one")}];
const mNumExotic = /^(?:(?:any|choose) )?((?<count>one|two) )?exotic language(?: \([^)]+\))?$/i.exec(str);
if (mNumExotic) return [{"anyExotic": Parser.textToNumber(mNumExotic.groups?.count || "one")}];
const mSingleOrAlternate = new RegExp(`^${reStrLanguage} or one of your choice if you already speak ${reStrLanguage}$`, "i").exec(str);
if (mSingleOrAlternate) return [{[mSingle[1].toLowerCase()]: true}, {"anyStandard": 1}];
return null;
}
}
globalThis.BackgroundSkillToolLanguageTag = BackgroundSkillToolLanguageTag;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,637 @@
"use strict";
const LAST_KEY_ALLOWLIST = new Set([
"entries",
"entry",
"items",
"entriesHigherLevel",
"rows",
"row",
"fluff",
]);
class TagJsons {
static async pInit ({spells}) {
TagCondition.init();
SpellTag.init(spells);
await ItemTag.pInit();
await FeatTag.pInit();
await AdventureBookTag.pInit();
}
static mutTagObject (json, {keySet, isOptimistic = true, creaturesToTag = null} = {}) {
TagJsons.OPTIMISTIC = isOptimistic;
const fnCreatureTagSpecific = CreatureTag.getFnTryRunSpecific(creaturesToTag);
Object.keys(json)
.forEach(k => {
if (keySet != null && !keySet.has(k)) return;
json[k] = TagJsons.WALKER.walk(
{_: json[k]},
{
object: (obj, lastKey) => {
if (lastKey != null && !LAST_KEY_ALLOWLIST.has(lastKey)) return obj;
obj = TagCondition.tryRunBasic(obj);
obj = SkillTag.tryRun(obj);
obj = ActionTag.tryRun(obj);
obj = SenseTag.tryRun(obj);
obj = SpellTag.tryRun(obj);
obj = ItemTag.tryRun(obj);
obj = TableTag.tryRun(obj);
obj = TrapTag.tryRun(obj);
obj = HazardTag.tryRun(obj);
obj = ChanceTag.tryRun(obj);
obj = DiceConvert.getTaggedEntry(obj);
obj = QuickrefTag.tryRun(obj);
obj = FeatTag.tryRun(obj);
obj = AdventureBookTag.tryRun(obj);
if (fnCreatureTagSpecific) obj = fnCreatureTagSpecific(obj);
return obj;
},
},
)._;
});
}
}
TagJsons.OPTIMISTIC = true;
TagJsons._BLOCKLIST_FILE_PREFIXES = null;
TagJsons.WALKER_KEY_BLOCKLIST = new Set([
...MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST,
]);
TagJsons.WALKER = MiscUtil.getWalker({
keyBlocklist: TagJsons.WALKER_KEY_BLOCKLIST,
});
globalThis.TagJsons = TagJsons;
class SpellTag {
static _NON_STANDARD = new Set([
// Skip "Divination" to avoid tagging occurrences of the school
"Divination",
// Skip spells we specifically handle
"Antimagic Field",
"Dispel Magic",
].map(it => it.toLowerCase()));
static init (spells) {
spells
.forEach(sp => SpellTag._SPELL_NAMES[sp.name.toLowerCase()] = {name: sp.name, source: sp.source});
const spellNamesFiltered = Object.keys(SpellTag._SPELL_NAMES)
.filter(n => !SpellTag._NON_STANDARD.has(n));
SpellTag._SPELL_NAME_REGEX = new RegExp(`\\b(${spellNamesFiltered.map(it => it.escapeRegexp()).join("|")})\\b`, "gi");
SpellTag._SPELL_NAME_REGEX_SPELL = new RegExp(`\\b(${spellNamesFiltered.map(it => it.escapeRegexp()).join("|")}) (spell|cantrip)`, "gi");
SpellTag._SPELL_NAME_REGEX_AND = new RegExp(`\\b(${spellNamesFiltered.map(it => it.escapeRegexp()).join("|")}) (and {@spell)`, "gi");
SpellTag._SPELL_NAME_REGEX_CAST = new RegExp(`(?<prefix>casts?(?: the(?: spell)?)? )(?<spell>${spellNamesFiltered.map(it => it.escapeRegexp()).join("|")})\\b`, "gi");
}
static tryRun (it) {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@spell"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag,
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
if (TagJsons.OPTIMISTIC) {
strMod = strMod
.replace(SpellTag._SPELL_NAME_REGEX_SPELL, (...m) => {
const spellMeta = SpellTag._SPELL_NAMES[m[1].toLowerCase()];
return `{@spell ${m[1]}${spellMeta.source !== Parser.SRC_PHB ? `|${spellMeta.source}` : ""}} ${m[2]}`;
});
}
// Tag common spells which often don't have e.g. the word "spell" nearby
strMod = strMod
.replace(/\b(antimagic field|dispel magic)\b/gi, (...m) => {
const spellMeta = SpellTag._SPELL_NAMES[m[1].toLowerCase()];
return `{@spell ${m[1]}${spellMeta.source !== Parser.SRC_PHB ? `|${spellMeta.source}` : ""}}`;
});
strMod
.replace(SpellTag._SPELL_NAME_REGEX_CAST, (...m) => {
const spellMeta = SpellTag._SPELL_NAMES[m.last().spell.toLowerCase()];
return `${m.last().prefix}{@spell ${m.last().spell}${spellMeta.source !== Parser.SRC_PHB ? `|${spellMeta.source}` : ""}}`;
});
return strMod
.replace(SpellTag._SPELL_NAME_REGEX_AND, (...m) => {
const spellMeta = SpellTag._SPELL_NAMES[m[1].toLowerCase()];
return `{@spell ${m[1]}${spellMeta.source !== Parser.SRC_PHB ? `|${spellMeta.source}` : ""}} ${m[2]}`;
})
.replace(/(spells(?:|[^.!?:{]*): )([^.!?]+)/gi, (...m) => {
const spellPart = m[2].replace(SpellTag._SPELL_NAME_REGEX, (...n) => {
const spellMeta = SpellTag._SPELL_NAMES[n[1].toLowerCase()];
return `{@spell ${n[1]}${spellMeta.source !== Parser.SRC_PHB ? `|${spellMeta.source}` : ""}}`;
});
return `${m[1]}${spellPart}`;
})
.replace(SpellTag._SPELL_NAME_REGEX_CAST, (...m) => {
const spellMeta = SpellTag._SPELL_NAMES[m.last().spell.toLowerCase()];
return `${m.last().prefix}{@spell ${m.last().spell}${spellMeta.source !== Parser.SRC_PHB ? `|${spellMeta.source}` : ""}}`;
})
;
}
}
SpellTag._SPELL_NAMES = {};
SpellTag._SPELL_NAME_REGEX = null;
SpellTag._SPELL_NAME_REGEX_SPELL = null;
SpellTag._SPELL_NAME_REGEX_AND = null;
SpellTag._SPELL_NAME_REGEX_CAST = null;
globalThis.SpellTag = SpellTag;
class ItemTag {
static _ITEM_NAMES = {};
static _ITEM_NAMES_REGEX_TOOLS = null;
static _ITEM_NAMES_REGEX_OTHER = null;
static _ITEM_NAMES_REGEX_EQUIPMENT = null;
static _WALKER = MiscUtil.getWalker({
keyBlocklist: new Set([
...TagJsons.WALKER_KEY_BLOCKLIST,
"packContents", // Avoid tagging item pack contents
"items", // Avoid tagging item group item lists
]),
});
static async pInit () {
const itemArr = await Renderer.item.pBuildList();
const standardItems = itemArr.filter(it => !SourceUtil.isNonstandardSource(it.source));
// region Tools
const toolTypes = new Set(["AT", "GS", "INS", "T"]);
const tools = standardItems.filter(it => toolTypes.has(it.type) && it.name !== "Horn");
tools.forEach(tool => {
this._ITEM_NAMES[tool.name.toLowerCase()] = {name: tool.name, source: tool.source};
});
this._ITEM_NAMES_REGEX_TOOLS = new RegExp(`\\b(${tools.map(it => it.name.escapeRegexp()).join("|")})\\b`, "gi");
// endregion
// region Other items
const otherItems = standardItems.filter(it => {
if (toolTypes.has(it.type)) return false;
// Disallow specific items
if (it.name === "Wave" && it.source === Parser.SRC_DMG) return false;
// Allow all non-specific-variant DMG items
if (it.source === Parser.SRC_DMG && !Renderer.item.isMundane(it) && it._category !== "Specific Variant") return true;
// Allow "sufficiently complex name" items
return it.name.split(" ").length > 2;
});
otherItems.forEach(it => {
this._ITEM_NAMES[it.name.toLowerCase()] = {name: it.name, source: it.source};
});
this._ITEM_NAMES_REGEX_OTHER = new RegExp(`\\b(${otherItems.map(it => it.name.escapeRegexp()).join("|")})\\b`, "gi");
// endregion
// region Basic equipment
// (Has some overlap with others)
const itemsEquipment = itemArr
.filter(itm => itm.source === "PHB" && !["M", "R", "LA", "MA", "HA", "S"].includes(itm.type));
this._ITEM_NAMES_REGEX_EQUIPMENT = new RegExp(`\\b(${itemsEquipment.map(it => it.name.escapeRegexp()).join("|")})\\b`, "gi");
itemsEquipment.forEach(itm => this._ITEM_NAMES[itm.name.toLowerCase()] = {name: itm.name, source: itm.source});
// endregion
}
/* -------------------------------------------- */
static tryRun (it) {
return this._WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@item"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag,
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
return strMod
.replace(this._ITEM_NAMES_REGEX_TOOLS, (...m) => {
const itemMeta = this._ITEM_NAMES[m[1].toLowerCase()];
return `{@item ${m[1]}${itemMeta.source !== Parser.SRC_DMG ? `|${itemMeta.source}` : ""}}`;
})
.replace(this._ITEM_NAMES_REGEX_OTHER, (...m) => {
const itemMeta = this._ITEM_NAMES[m[1].toLowerCase()];
return `{@item ${m[1]}${itemMeta.source !== Parser.SRC_DMG ? `|${itemMeta.source}` : ""}}`;
})
;
}
/* -------------------------------------------- */
static tryRunBasicEquipment (it) {
return this._WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@item"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTagBasicEquipment,
},
);
return ptrStack._;
},
},
);
}
static _fnTagBasicEquipment (strMod) {
return strMod
.replace(ItemTag._ITEM_NAMES_REGEX_EQUIPMENT, (...m) => {
const itemMeta = ItemTag._ITEM_NAMES[m[1].toLowerCase()];
return `{@item ${m[1]}${itemMeta.source !== Parser.SRC_DMG ? `|${itemMeta.source}` : ""}}`;
})
;
}
}
globalThis.ItemTag = ItemTag;
class TableTag {
static tryRun (it) {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@table"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag,
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
return strMod
.replace(/Wild Magic Surge table/g, `{@table Wild Magic Surge|PHB} table`)
;
}
}
class TrapTag {
static tryRun (it) {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@trap"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag,
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
return strMod
.replace(TrapTag._RE_TRAP_SEE, (...m) => `{@trap ${m[1]}}${m[2]}`)
;
}
}
TrapTag._RE_TRAP_SEE = /\b(Fire-Breathing Statue|Sphere of Annihilation|Collapsing Roof|Falling Net|Pits|Poison Darts|Poison Needle|Rolling Sphere)( \(see)/gi;
class HazardTag {
static tryRun (it) {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@hazard"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag,
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
return strMod
.replace(HazardTag._RE_HAZARD_SEE, (...m) => `{@hazard ${m[1]}}${m[2]}`)
;
}
}
HazardTag._RE_HAZARD_SEE = /\b(High Altitude|Brown Mold|Green Slime|Webs|Yellow Mold|Extreme Cold|Extreme Heat|Heavy Precipitation|Strong Wind|Desecrated Ground|Frigid Water|Quicksand|Razorvine|Slippery Ice|Thin Ice)( \(see)/gi;
class CreatureTag {
/**
* Dynamically create a walker which can be re-used.
*/
static getFnTryRunSpecific (creaturesToTag) {
if (!creaturesToTag?.length) return null;
// region Create a regular expression per source
const bySource = {};
creaturesToTag.forEach(({name, source}) => {
(bySource[source] = bySource[source] || []).push(name);
});
const res = Object.entries(bySource)
.mergeMap(([source, names]) => {
const re = new RegExp(`\\b(${names.map(it => it.escapeRegexp()).join("|")})\\b`, "gi");
return {[source]: re};
});
// endregion
const fnTag = strMod => {
Object.entries(res)
.forEach(([source, re]) => {
strMod = strMod.replace(re, (...m) => `{@creature ${m[0]}${source !== Parser.SRC_DMG ? `|${source}` : ""}}`);
});
return strMod;
};
return (it) => {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@creature"],
ptrStack,
0,
0,
str,
{
fnTag,
},
);
return ptrStack._;
},
},
);
};
}
}
class ChanceTag {
static tryRun (it) {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@chance"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag,
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
return strMod
.replace(/\b(\d+)( percent)( chance)/g, (...m) => `{@chance ${m[1]}|${m[1]}${m[2]}}${m[3]}`)
;
}
}
class QuickrefTag {
static tryRun (it) {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@quickref"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag.bind(this),
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
return strMod
.replace(QuickrefTag._RE_BASIC, (...m) => `{@quickref ${QuickrefTag._LOOKUP_BASIC[m[0].toLowerCase()]}}`)
.replace(QuickrefTag._RE_VISION, (...m) => `{@quickref ${QuickrefTag._LOOKUP_VISION[m[0].toLowerCase()]}||${m[0]}}`)
;
}
}
QuickrefTag._RE_BASIC = /\b([Dd]ifficult [Tt]errain|Vision and Light)\b/g;
QuickrefTag._RE_VISION = /\b(dim light|bright light|lightly obscured|heavily obscured)\b/gi;
QuickrefTag._LOOKUP_BASIC = {
"difficult terrain": "difficult terrain||3",
"vision and light": "Vision and Light||2",
};
QuickrefTag._LOOKUP_VISION = {
"bright light": "Vision and Light||2",
"dim light": "Vision and Light||2",
"lightly obscured": "Vision and Light||2",
"heavily obscured": "Vision and Light||2",
};
class FeatTag {
static _FEAT_LOOKUP = [];
static async pInit () {
const featData = await DataUtil.feat.loadJSON();
const [featsNonStandard, feats] = [...featData.feat]
.sort((a, b) => SortUtil.ascSortDateString(Parser.sourceJsonToDate(a.source), Parser.sourceJsonToDate(b.source)) || SortUtil.ascSortLower(a.name, b.name) || SortUtil.ascSortLower(a.source, b.source))
.segregate(feat => SourceUtil.isNonstandardSource(feat.source));
this._FEAT_LOOKUP = [
...feats,
...featsNonStandard,
]
.map(feat => ({searchName: feat.name.toLowerCase(), feat}));
}
static tryRun (it) {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@feat"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag.bind(this),
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
return strMod
.replace(/(?<pre>\bgain the )(?<name>.*)(?<post> feat\b)/, (...m) => {
const {pre, post, name} = m.at(-1);
const feat = this._getFeat(name);
if (!feat) return m[0];
const uid = DataUtil.proxy.getUid("feat", feat, {isMaintainCase: true});
const [uidName, ...uidRest] = uid.split("|");
// Tag display name not expected
if (name.toLowerCase() !== uidName.toLowerCase()) throw new Error(`Unimplemented!`);
const uidFinal = [
name,
...uidRest,
]
.join("|");
return `${pre}{@feat ${uidFinal}}${post}`;
})
;
}
static _getFeat (name) {
const searchName = name.toLowerCase().trim();
const featMeta = this._FEAT_LOOKUP.find(it => it.searchName === searchName);
if (!featMeta) return null;
return featMeta.feat;
}
}
class AdventureBookTag {
static _ADVENTURE_RES = [];
static _BOOK_RES = [];
static async pInit () {
for (const meta of [
{
propRes: "_ADVENTURE_RES",
propData: "adventure",
tag: "adventure",
contentsUrl: `${Renderer.get().baseUrl}data/adventures.json`,
},
{
propRes: "_BOOK_RES",
propData: "book",
tag: "book",
contentsUrl: `${Renderer.get().baseUrl}data/books.json`,
},
]) {
const contents = await DataUtil.loadJSON(meta.contentsUrl);
this[meta.propRes] = contents[meta.propData]
.map(({name, id}) => {
const re = new RegExp(`\\b${name.escapeRegexp()}\\b`, "g");
return str => str.replace(re, (...m) => `{@${meta.tag} ${m[0]}|${id}}`);
});
}
}
static tryRun (it) {
return TagJsons.WALKER.walk(
it,
{
string: (str) => {
const ptrStack = {_: ""};
TaggerUtils.walkerStringHandler(
["@adventure", "@book"],
ptrStack,
0,
0,
str,
{
fnTag: this._fnTag.bind(this),
},
);
return ptrStack._;
},
},
);
}
static _fnTag (strMod) {
for (const arr of [this._ADVENTURE_RES, this._BOOK_RES]) {
strMod = arr.reduce((str, fn) => fn(str), strMod);
}
return strMod;
}
}

820
js/converterutils-item.js Normal file
View File

@@ -0,0 +1,820 @@
"use strict";
class ConverterUtilsItem {}
ConverterUtilsItem.BASIC_WEAPONS = [
"club",
"dagger",
"greatclub",
"handaxe",
"javelin",
"light hammer",
"mace",
"quarterstaff",
"sickle",
"spear",
"light crossbow",
"dart",
"shortbow",
"sling",
"battleaxe",
"flail",
"glaive",
"greataxe",
"greatsword",
"halberd",
"lance",
"longsword",
"maul",
"morningstar",
"pike",
"rapier",
"scimitar",
"shortsword",
"trident",
"war pick",
"warhammer",
"whip",
"blowgun",
"hand crossbow",
"heavy crossbow",
"longbow",
"net",
];
ConverterUtilsItem.BASIC_ARMORS = [
"padded armor",
"leather armor",
"studded leather armor",
"hide armor",
"chain shirt",
"scale mail",
"breastplate",
"half plate armor",
"ring mail",
"chain mail",
"splint armor",
"plate armor",
"shield",
];
globalThis.ConverterUtilsItem = ConverterUtilsItem;
class ChargeTag {
static _checkAndTag (obj, opts) {
opts = opts || {};
const strEntries = JSON.stringify(obj.entries);
const mCharges = /(?:have|has|with) (\d+|{@dice .*?}) charge/gi.exec(strEntries);
if (!mCharges) return;
const ix = mCharges.index;
obj.charges = isNaN(Number(mCharges[1])) ? mCharges[1] : Number(mCharges[1]);
if (opts.cbInfo) {
const ixMin = Math.max(0, ix - 10);
const ixMax = Math.min(strEntries.length, ix + 10);
opts.cbInfo(obj, strEntries, ixMin, ixMax);
}
}
static tryRun (it, opts) {
if (it.entries) this._checkAndTag(it, opts);
if (it.inherits?.entries) this._checkAndTag(it.inherits, opts);
}
}
globalThis.ChargeTag = ChargeTag;
class RechargeTypeTag {
static _checkAndTag (obj, opts) {
if (!obj.entries) return;
const strEntries = JSON.stringify(obj.entries, null, 2);
const mLongRest = /All charges are restored when you finish a long rest/i.test(strEntries);
if (mLongRest) return obj.recharge = "restLong";
const mDawn = /charges? at dawn|charges? daily at dawn|charges?(?:, which (?:are|you) regain(?:ed)?)? each day at dawn|charges and regains all of them at dawn|charges and regains[^.]+each dawn|recharging them all each dawn|charges that are replenished each dawn/gi.exec(strEntries);
if (mDawn) return obj.recharge = "dawn";
const mDusk = /charges? daily at dusk|charges? each (?:day at dusk|nightfall)|regains all charges at dusk/gi.exec(strEntries);
if (mDusk) return obj.recharge = "dusk";
const mMidnight = /charges? daily at midnight|Each night at midnight[^.]+charges/gi.exec(strEntries);
if (mMidnight) return obj.recharge = "midnight";
const mDecade = /regains [^ ]+ expended charge every ten years/gi.exec(strEntries);
if (mDecade) return obj.recharge = "decade";
if (opts.cbMan) opts.cbMan(obj.name, obj.source);
}
static tryRun (it, opts) {
if (it.charges) this._checkAndTag(it, opts);
if (it.inherits?.charges) this._checkAndTag(it.inherits, opts);
}
}
globalThis.RechargeTypeTag = RechargeTypeTag;
class RechargeAmountTag {
// Note that ordering is important--handle dice versions; text numbers first
static _PTS_CHARGES = [
"{@dice [^}]+}",
"(?:one|two|three|four|five|six|seven|eight|nine|ten)",
"\\d+",
];
static _RE_TEMPLATES_CHARGES = [
// region Dawn
[
"(?<charges>",
")[^.]*?\\b(?:charges? (?:at|each) dawn|charges? daily at dawn|charges?(?:, which (?:are|you) regain(?:ed)?)? each day at dawn)",
],
[
"charges and regains (?<charges>",
") each dawn",
],
// endregion
// region Dusk
[
"(?<charges>",
")[^.]*?\\b(?:charges? daily at dusk|charges? each (?:day at dusk|nightfall))",
],
// endregion
// region Midnight
[
"(?<charges>",
")[^.]*?\\b(?:charges? daily at midnight)",
],
[
"Each night at midnight[^.]+regains (?<charges>",
")[^.]*\\bcharges",
],
// endregion
// region Decade
[
"regains (?<charges>",
")[^.]*?\\b(?:charges? every ten years)",
],
// endregion
];
static _RES_CHARGES = null;
static _RES_ALL = [
/charges and regains all of them at dawn/i,
/recharging them all each dawn/i,
/charges that are replenished each dawn/i,
/regains? all expended charges (?:daily )?at dawn/i,
/regains all charges (?:each day )?at (?:dusk|dawn)/i,
/All charges are restored when you finish a (?:long|short) rest/i,
];
static _getRechargeAmount (str) {
if (!isNaN(str)) return Number(str);
const fromText = Parser.textToNumber(str);
if (!isNaN(fromText)) return fromText;
return str;
}
static _checkAndTag (obj, opts) {
if (!obj.entries) return;
const strEntries = JSON.stringify(obj.entries, null, 2);
this._RES_CHARGES = this._RES_CHARGES || this._PTS_CHARGES
.map(pt => {
return this._RE_TEMPLATES_CHARGES
.map(template => {
const [pre, post] = template;
return new RegExp([pre, pt, post].join(""), "i");
});
})
.flat();
for (const re of this._RES_CHARGES) {
const m = re.exec(strEntries);
if (!m) continue;
return obj.rechargeAmount = this._getRechargeAmount(m.groups.charges);
}
if (this._RES_ALL.some(re => re.test(strEntries))) return obj.rechargeAmount = obj.charges;
if (opts.cbMan) opts.cbMan(obj.name, obj.source);
}
static tryRun (it, opts) {
if (it.charges && it.recharge) this._checkAndTag(it, opts);
if (it.inherits?.charges && it.inherits?.recharge) this._checkAndTag(it.inherits, opts);
}
}
globalThis.RechargeAmountTag = RechargeAmountTag;
class AttachedSpellTag {
static _checkAndTag (obj, opts) {
const strEntries = JSON.stringify(obj.entries);
const outSet = new Set();
const regexps = [ // uses m[1]
/duplicate the effect of the {@spell ([^}]*)} spell/gi,
/a creature is under the effect of a {@spell ([^}]*)} spell/gi,
/(?:gain(?:s)?|under|produces) the (?:[a-zA-Z\\"]+ )?effect of (?:the|a|an) {@spell ([^}]*)} spell/gi,
/functions as the {@spell ([^}]*)} spell/gi,
/as with the {@spell ([^}]*)} spell/gi,
/as if using a(?:n)? {@spell ([^}]*)} spell/gi,
/cast a(?:n)? {@spell ([^}]*)} spell/gi,
/as a(?:n)? \d..-level {@spell ([^}]*)} spell/gi,
/cast(?:(?: a version of)? the)?(?: spell)? {@spell ([^}]*)}/gi,
/cast the \d..-level version of {@spell ([^}]*)}/gi,
/{@spell ([^}]*)} \([^)]*\d+ charge(?:s)?\)/gi,
];
const regexpsSeries = [ // uses m[0]
/emanate the [^.]* spell/gi,
/cast one of the following [^.]*/gi,
/can be used to cast [^.]*/gi,
/you can([^.]*expend[^.]*)? cast [^.]* (and|or) [^.]*/gi,
/you can([^.]*)? cast [^.]* (and|or) [^.]* from the weapon/gi,
/Spells are cast at their lowest level[^.]*: [^.]*/gi,
];
regexps.forEach(re => {
strEntries.replace(re, (...m) => outSet.add(m[1].toSpellCase()));
});
regexpsSeries.forEach(re => {
strEntries.replace(re, (...m) => this._checkAndTag_addTaggedSpells({str: m[0], outSet}));
});
// region Tag spells in tables
const walker = MiscUtil.getWalker({isNoModification: true});
this._checkAndTag_tables({obj, walker, outSet});
// endregion
obj.attachedSpells = [...outSet];
if (!obj.attachedSpells.length) delete obj.attachedSpells;
}
static _checkAndTag_addTaggedSpells ({str, outSet}) {
return str.replace(/{@spell ([^}]*)}/gi, (...m) => outSet.add(m[1].toSpellCase()));
}
static _checkAndTag_tables ({obj, walker, outSet}) {
const walkerHandlers = {
obj: [
(obj) => {
if (obj.type !== "table") return obj;
// Require the table to have the string "spell" somewhere in its caption/column labels
const hasSpellInCaption = obj.caption && /spell/i.test(obj.caption);
const hasSpellInColLabels = obj.colLabels && obj.colLabels.some(it => /spell/i.test(it));
if (!hasSpellInCaption && !hasSpellInColLabels) return obj;
(obj.rows || []).forEach(r => {
r.forEach(c => this._checkAndTag_addTaggedSpells({str: c, outSet}));
});
return obj;
},
],
};
const cpy = MiscUtil.copy(obj);
walker.walk(cpy, walkerHandlers);
}
static tryRun (it, opts) {
if (it.entries) this._checkAndTag(it, opts);
if (it.inherits && it.inherits.entries) this._checkAndTag(it.inherits, opts);
}
}
globalThis.AttachedSpellTag = AttachedSpellTag;
class BonusTag {
static _runOn (obj, prop, opts) {
opts = opts || {};
let strEntries = JSON.stringify(obj.entries);
// Clean the root--"inherits" data may have specific bonuses as per the variant (e.g. +3 weapon -> +3) that
// we don't want to remove.
// Legacy "bonus" data will be cleaned up if an updated bonus type is found.
if (prop !== "inherits") {
delete obj.bonusWeapon;
delete obj.bonusWeaponAttack;
delete obj.bonusAc;
delete obj.bonusSavingThrow;
delete obj.bonusSpellAttack;
delete obj.bonusSpellSaveDc;
}
strEntries = strEntries.replace(/\+\s*(\d)([^.]+(?:bonus )?(?:to|on) [^.]*(?:attack|hit) and damage rolls)/ig, (...m) => {
if (m[0].toLowerCase().includes("spell")) return m[0];
obj.bonusWeapon = `+${m[1]}`;
return opts.isVariant ? `{=bonusWeapon}${m[2]}` : m[0];
});
strEntries = strEntries.replace(/\+\s*(\d)([^.]+(?:bonus )?(?:to|on) [^.]*(?:attack rolls|hit))/ig, (...m) => {
if (obj.bonusWeapon) return m[0];
if (m[0].toLowerCase().includes("spell")) return m[0];
obj.bonusWeaponAttack = `+${m[1]}`;
return opts.isVariant ? `{=bonusWeaponAttack}${m[2]}` : m[0];
});
strEntries = strEntries.replace(/\+\s*(\d)([^.]+(?:bonus )?(?:to|on)(?: your)? [^.]*(?:AC|Armor Class|armor class))/g, (...m) => {
obj.bonusAc = `+${m[1]}`;
return opts.isVariant ? `{=bonusAc}${m[2]}` : m[0];
});
// FIXME(Future) false positives:
// - Black Dragon Scale Mail
strEntries = strEntries.replace(/\+\s*(\d)([^.\d]+(?:bonus )?(?:to|on) [^.]*saving throws)/g, (...m) => {
obj.bonusSavingThrow = `+${m[1]}`;
return opts.isVariant ? `{=bonusSavingThrow}${m[2]}` : m[0];
});
// FIXME(Future) false negatives:
// - Robe of the Archmagi
strEntries = strEntries.replace(/\+\s*(\d)([^.]+(?:bonus )?(?:to|on) [^.]*spell attack rolls)/g, (...m) => {
obj.bonusSpellAttack = `+${m[1]}`;
return opts.isVariant ? `{=bonusSpellAttack}${m[2]}` : m[0];
});
// FIXME(Future) false negatives:
// - Robe of the Archmagi
strEntries = strEntries.replace(/\+\s*(\d)([^.]+(?:bonus )?(?:to|on) [^.]*saving throw DCs)/g, (...m) => {
obj.bonusSpellSaveDc = `+${m[1]}`;
return opts.isVariant ? `{=bonusSpellSaveDc}${m[2]}` : m[0];
});
strEntries = strEntries.replace(BonusTag._RE_BASIC_WEAPONS, (...m) => {
obj.bonusWeapon = `+${m[1]}`;
return opts.isVariant ? `{=bonusWeapon}${m[2]}` : m[0];
});
strEntries = strEntries.replace(BonusTag._RE_BASIC_ARMORS, (...m) => {
obj.bonusAc = `+${m[1]}`;
return opts.isVariant ? `{=bonusAc}${m[2]}` : m[0];
});
// region Homebrew
// "this weapon is a {@i dagger +1}"
strEntries = strEntries.replace(/({@i(?:tem)? )([^}]+ )\+(\d+)((?:|[^}]+)?})/, (...m) => {
const ptItem = m[2].trim().toLowerCase();
if (ConverterUtilsItem.BASIC_WEAPONS.includes(ptItem)) {
obj.bonusWeapon = `+${m[3]}`;
return opts.isVariant ? `${m[1]}${m[2]}{=bonusWeapon}${m[2]}` : m[0];
} else if (ConverterUtilsItem.BASIC_ARMORS.includes(ptItem)) {
obj.bonusAc = `+${m[3]}`;
return opts.isVariant ? `${m[1]}${m[2]}{=bonusAc}${m[2]}` : m[0];
}
return m[0];
});
// Damage roll with no attack roll
strEntries = strEntries.replace(/\+\s*(\d)([^.]+(?:bonus )?(?:to|on) [^.]*damage rolls)/ig, (...m) => {
if (obj.bonusWeapon) return m[0];
obj.bonusWeaponDamage = `+${m[1]}`;
return opts.isVariant ? `{=bonusWeaponDamage}${m[2]}` : m[0];
});
strEntries = strEntries.replace(/(grants )\+\s*(\d)((?: to| on)?(?: your)? [^.]*(?:AC|Armor Class|armor class))/g, (...m) => {
obj.bonusAc = `+${m[2]}`;
return opts.isVariant ? `${m[1]}{=bonusAc}${m[3]}` : m[0];
});
// endregion
// If the bonus weapon attack and damage are identical, combine them
if (obj.bonusWeaponAttack && obj.bonusWeaponDamage && obj.bonusWeaponAttack === obj.bonusWeaponDamage) {
obj.bonusWeapon = obj.bonusWeaponAttack;
delete obj.bonusWeaponAttack;
delete obj.bonusWeaponDamage;
}
// TODO(Future) expand this?
strEntries.replace(/scores? a critical hit on a (?:(?:{@dice )?d20}? )?roll of 19 or 20/gi, () => {
obj.critThreshold = 19;
});
// TODO(Future) `.bonusWeaponCritDamage` (these are relatively uncommon)
// region Speed bonus
this._mutSpeedBonus({obj, strEntries});
// endregion
obj.entries = JSON.parse(strEntries);
}
static _mutSpeedBonus ({obj, strEntries}) {
strEntries.replace(BonusTag._RE_SPEED_MULTIPLE, (...m) => {
const {mode, factor} = m.last();
obj.modifySpeed = {multiplier: {[this._getSpeedKey(mode)]: Parser.textToNumber(factor)}};
});
[BonusTag._RE_SPEED_BECOMES, BonusTag._RE_SPEED_GAIN, BonusTag._RE_SPEED_GAIN__EXPEND_CHARGE, BonusTag._RE_SPEED_GIVE_YOU].forEach(re => {
strEntries.replace(re, (...m) => {
const {mode, value} = m.last();
obj.modifySpeed = MiscUtil.merge(obj.modifySpeed || {}, {static: {[this._getSpeedKey(mode)]: Number(value)}});
});
});
strEntries.replace(BonusTag._RE_SPEED_EQUAL_WALKING, (...m) => {
const {mode} = m.last();
obj.modifySpeed = MiscUtil.merge(obj.modifySpeed || {}, {equal: {[this._getSpeedKey(mode)]: "walk"}});
});
strEntries.replace(BonusTag._RE_SPEED_BONUS_ALL, (...m) => {
const {value} = m.last();
obj.modifySpeed = MiscUtil.merge(obj.modifySpeed || {}, {bonus: {"*": Number(value)}});
});
strEntries.replace(BonusTag._RE_SPEED_BONUS_SPECIFIC, (...m) => {
const {mode, value} = m.last();
obj.modifySpeed = MiscUtil.merge(obj.modifySpeed || {}, {bonus: {[this._getSpeedKey(mode)]: Number(value)}});
});
}
static _getSpeedKey (speedText) {
speedText = speedText.toLowerCase().trim();
switch (speedText) {
case "walking":
case "burrowing":
case "climbing":
case "flying": return speedText.replace(/ing$/, "");
case "swimming": return "swim";
default: throw new Error(`Unhandled speed text "${speedText}"`);
}
}
static tryRun (it, opts) {
if (it.inherits && it.inherits.entries) this._runOn(it.inherits, "inherits", opts);
else if (it.entries) this._runOn(it, null, opts);
}
}
BonusTag._RE_BASIC_WEAPONS = new RegExp(`\\+\\s*(\\d)(\\s+(?:${ConverterUtilsItem.BASIC_WEAPONS.join("|")}|weapon))`);
BonusTag._RE_BASIC_ARMORS = new RegExp(`\\+\\s*(\\d)(\\s+(?:${ConverterUtilsItem.BASIC_ARMORS.join("|")}|armor))`);
BonusTag._PT_SPEEDS = `(?<mode>walking|flying|swimming|climbing|burrowing)`;
BonusTag._PT_SPEED_VALUE = `(?<value>\\d+)`;
BonusTag._RE_SPEED_MULTIPLE = new RegExp(`(?<factor>double|triple|quadruple) your ${BonusTag._PT_SPEEDS} speed`, "gi");
BonusTag._RE_SPEED_BECOMES = new RegExp(`your ${BonusTag._PT_SPEEDS} speed becomes ${BonusTag._PT_SPEED_VALUE} feet`, "gi");
BonusTag._RE_SPEED_GAIN = new RegExp(`you (?:gain|have) a ${BonusTag._PT_SPEEDS} speed of ${BonusTag._PT_SPEED_VALUE} feet`, "gi");
BonusTag._RE_SPEED_GAIN__EXPEND_CHARGE = new RegExp(`expend (?:a|\\d+) charges? to gain a ${BonusTag._PT_SPEEDS} speed of ${BonusTag._PT_SPEED_VALUE} feet`, "gi");
BonusTag._RE_SPEED_GIVE_YOU = new RegExp(`give you a ${BonusTag._PT_SPEEDS} speed of ${BonusTag._PT_SPEED_VALUE} feet`, "gi");
BonusTag._RE_SPEED_EQUAL_WALKING = new RegExp(`you (?:gain|have) a ${BonusTag._PT_SPEEDS} speed equal to your walking speed`, "gi");
BonusTag._RE_SPEED_BONUS_ALL = new RegExp(`you (?:gain|have) a bonus to speed of ${BonusTag._PT_SPEED_VALUE} feet`, "gi");
BonusTag._RE_SPEED_BONUS_SPECIFIC = new RegExp(`increas(?:ing|e) your ${BonusTag._PT_SPEEDS} by ${BonusTag._PT_SPEED_VALUE} feet`, "gi");
globalThis.BonusTag = BonusTag;
class BasicTextClean {
static tryRun (it, opts) {
const walker = MiscUtil.getWalker({keyBlocklist: new Set(["type"])});
walker.walk(it, {
array: (arr) => {
return arr.filter(it => {
if (typeof it !== "string") return true;
if (/^\s*Proficiency with .*? allows you to add your proficiency bonus to the attack roll for any attack you make with it\.\s*$/i.test(it)) return false;
if (/^\s*A shield is made from wood or metal and is carried in one hand\. Wielding a shield increases your Armor Class by 2. You can benefit from only one shield at a time\.\s*$/i.test(it)) return false;
if (/^\s*This armor consists of a coat and leggings \(and perhaps a separate skirt\) of leather covered with overlapping pieces of metal, much like the scales of a fish\. The suit includes gauntlets\.\s*$/i.test(it)) return false;
return true;
});
},
});
}
}
globalThis.BasicTextClean = BasicTextClean;
class ItemMiscTag {
static tryRun (it, opts) {
if (!(it.entries || (it.inherits && it.inherits.entries))) return;
const isInherits = !it.entries && it.inherits.entries;
const tgt = it.entries ? it : it.inherits;
const strEntries = JSON.stringify(it.entries || it.inherits.entries);
strEntries.replace(/"Sentience"/, (...m) => tgt.sentient = true);
strEntries.replace(/"Curse"/, (...m) => tgt.curse = true);
strEntries.replace(/you[^.]* (gain|have)? proficiency/gi, (...m) => tgt.grantsProficiency = true);
strEntries.replace(/you gain[^.]* following proficiencies/gi, (...m) => tgt.grantsProficiency = true);
strEntries.replace(/you are[^.]* considered proficient/gi, (...m) => tgt.grantsProficiency = true);
strEntries.replace(/[Yy]ou can speak( and understand)? [A-Z]/g, (...m) => tgt.grantsLanguage = true);
}
}
globalThis.ItemMiscTag = ItemMiscTag;
class ItemSpellcastingFocusTag {
static tryRun (it, opts) {
const focusClasses = new Set(it.focus || []);
ItemSpellcastingFocusTag._RE_CLASS_NAMES = ItemSpellcastingFocusTag._RE_CLASS_NAMES || new RegExp(`(${Parser.ITEM_SPELLCASTING_FOCUS_CLASSES.join("|")})`, "gi");
let isMiscFocus = false;
if (it.entries || (it.inherits && it.inherits.entries)) {
const tgt = it.entries ? it : it.inherits;
const walker = MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, isNoModification: true});
walker.walk(
tgt,
{
string: (str) => {
str
.replace(/spellcasting focus for your([^.?!:]*) spells/, (...m) => {
if (!m[1].trim()) {
isMiscFocus = true;
return;
}
m[1].trim().replace(ItemSpellcastingFocusTag._RE_CLASS_NAMES, (...n) => {
focusClasses.add(n[1].toTitleCase());
});
});
return str;
},
},
);
}
// The focus type may be implicitly specified by the attunement requirement
if (isMiscFocus && it.reqAttune && typeof it.reqAttune === "string" && /^by a /i.test(it.reqAttune)) {
const validClasses = new Set(Parser.ITEM_SPELLCASTING_FOCUS_CLASSES.map(it => it.toLowerCase()));
it.reqAttune
.replace(/^by a/i, "")
.split(/, | or /gi)
.map(it => it.trim().replace(/ or | a /gi, "").toLowerCase())
.filter(Boolean)
.filter(it => validClasses.has(it))
.forEach(it => focusClasses.add(it.toTitleCase()));
}
if (focusClasses.size) it.focus = [...focusClasses].sort(SortUtil.ascSortLower);
}
}
ItemSpellcastingFocusTag._RE_CLASS_NAMES = null;
globalThis.ItemSpellcastingFocusTag = ItemSpellcastingFocusTag;
class DamageResistanceTag {
static tryRun (it, opts) {
DamageResistanceImmunityVulnerabilityTag.tryRun(
"resist",
/you (?:have|gain|are) (?:resistance|resistant) (?:to|against) [^?.!]+/ig,
it,
opts,
);
}
}
globalThis.DamageResistanceTag = DamageResistanceTag;
class DamageImmunityTag {
static tryRun (it, opts) {
DamageResistanceImmunityVulnerabilityTag.tryRun(
"immune",
/you (?:have|gain|are) (?:immune|immunity) (?:to|against) [^?.!]+/ig,
it,
opts,
);
}
}
globalThis.DamageImmunityTag = DamageImmunityTag;
class DamageVulnerabilityTag {
static tryRun (it, opts) {
DamageResistanceImmunityVulnerabilityTag.tryRun(
"vulnerable",
/you (?:have|gain|are) (?:vulnerable|vulnerability) (?:to|against) [^?.!]+/ig,
it,
opts,
);
}
}
globalThis.DamageVulnerabilityTag = DamageVulnerabilityTag;
class DamageResistanceImmunityVulnerabilityTag {
static _checkAndTag (prop, reOuter, obj, opts) {
if (prop === "resist" && obj.hasRefs) return; // Assume these are already tagged
const all = new Set();
const outer = [];
DamageResistanceImmunityVulnerabilityTag._WALKER.walk(
obj.entries,
{
string: (str) => {
str.replace(reOuter, (full, ..._) => {
outer.push(full);
full = full.split(/ except /gi)[0];
full.replace(ConverterConst.RE_DAMAGE_TYPE, (full, dmgType) => {
all.add(dmgType);
});
});
},
},
);
if (all.size) obj[prop] = [...all].sort(SortUtil.ascSortLower);
else delete obj[prop];
if (outer.length && !all.size) {
if (opts.cbMan) opts.cbMan(`Could not find damage types in string(s) ${outer.map(it => `"${it}"`).join(", ")}`);
}
}
static tryRun (prop, reOuter, it, opts) {
DamageResistanceImmunityVulnerabilityTag._WALKER = DamageResistanceImmunityVulnerabilityTag._WALKER || MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, isNoModification: true});
if (it.entries) this._checkAndTag(prop, reOuter, it, opts);
if (it.inherits && it.inherits.entries) this._checkAndTag(prop, reOuter, it.inherits, opts);
}
}
DamageResistanceImmunityVulnerabilityTag._WALKER = null;
class ConditionImmunityTag {
static _checkAndTag (obj) {
const all = new Set();
ConditionImmunityTag._WALKER.walk(
obj.entries,
{
string: (str) => {
str.replace(/you (?:have|gain|are) (?:[^.!?]+ )?immun(?:e|ity) to disease/gi, (...m) => {
all.add("disease");
});
str.replace(/you (?:have|gain|are) (?:[^.!?]+ )?(?:immune) ([^.!?]+)/gi, (...m) => {
m[1].replace(/{@condition ([^}]+)}/gi, (...n) => {
all.add(n[1].toLowerCase());
});
});
},
},
);
if (all.size) obj.conditionImmune = [...all].sort(SortUtil.ascSortLower);
else delete obj.conditionImmune;
}
static tryRun (it, opts) {
ConditionImmunityTag._WALKER = ConditionImmunityTag._WALKER || MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, isNoModification: true});
if (it.entries) this._checkAndTag(it, opts);
if (it.inherits && it.inherits.entries) this._checkAndTag(it.inherits, opts);
}
}
ConditionImmunityTag._WALKER = null;
globalThis.ConditionImmunityTag = ConditionImmunityTag;
class ReqAttuneTagTag {
static _checkAndTag (obj, opts, isAlt) {
const prop = isAlt ? "reqAttuneAlt" : "reqAttune";
if (typeof obj[prop] === "boolean" || obj[prop] === "optional") return;
let req = obj[prop].replace(/^by/i, "");
const tags = [];
// "by a creature with the Mark of Finding"
req = req.replace(/(?:a creature with the )?\bMark of ([A-Z][^ ]+)/g, (...m) => {
const races = ReqAttuneTagTag._EBERRON_MARK_RACES[`Mark of ${m[1]}`];
if (!races) return "";
races.forEach(race => tags.push({race: race.toLowerCase()}));
return "";
});
// "by a member of the Azorius guild"
req = req.replace(/(?:a member of the )?\b(Azorius|Boros|Dimir|Golgari|Gruul|Izzet|Orzhov|Rakdos|Selesnya|Simic)\b guild/g, (...m) => {
tags.push({background: ReqAttuneTagTag._RAVNICA_GUILD_BACKGROUNDS[m[1]].toLowerCase()});
return "";
});
// "by a creature with an intelligence score of 3 or higher"
req = req.replace(/(?:a creature with (?:an|a) )?\b(strength|dexterity|constitution|intelligence|wisdom|charisma)\b score of (\d+)(?: or higher)?/g, (...m) => {
const abil = m[1].slice(0, 3).toLowerCase();
tags.push({[abil]: Number(m[2])});
});
// "by a creature that can speak Infernal"
req = req.replace(/(?:a creature that can )?speak \b(Abyssal|Aquan|Auran|Celestial|Common|Deep Speech|Draconic|Druidic|Dwarvish|Elvish|Giant|Gnomish|Goblin|Halfling|Ignan|Infernal|Orc|Primordial|Sylvan|Terran|Thieves' cant|Undercommon)\b/g, (...m) => {
tags.push({languageProficiency: m[1].toLowerCase()});
return "";
});
// "by a creature that has proficiency in the Arcana skill"
req = req.replace(/(?:a creature that has )?(?:proficiency|proficient).*?\b(Acrobatics|Animal Handling|Arcana|Athletics|Deception|History|Insight|Intimidation|Investigation|Medicine|Nature|Perception|Performance|Persuasion|Religion|Sleight of Hand|Stealth|Survival)\b skill/g, (...m) => {
tags.push({skillProficiency: m[1].toLowerCase()});
return "";
});
// "by a dwarf"
req = req.replace(/(?:(?:a|an) )?\b(Dragonborn|Dwarf|Elf|Gnome|Half-Elf|Half-Orc|Halfling|Human|Tiefling|Warforged)\b/gi, (...m) => {
const source = m[1].toLowerCase() === "warforged" ? Parser.SRC_ERLW : "";
tags.push({race: `${m[1]}${source ? `|${source}` : ""}`.toLowerCase()});
return "";
});
// "by a humanoid", "by a small humanoid"
req = req.replace(/a (?:\b(tiny|small|medium|large|huge|gargantuan)\b )?\b(aberration|beast|celestial|construct|dragon|elemental|fey|fiend|giant|humanoid|monstrosity|ooze|plant|undead)\b/gi, (...m) => {
const size = m[1] ? m[1][0].toUpperCase() : null;
const out = {creatureType: m[2].toLowerCase()};
if (size) out.size = size;
tags.push(out);
return "";
});
// "by a spellcaster"
req = req.replace(/(?:a )?\bspellcaster\b/gi, (...m) => {
tags.push({spellcasting: true});
return "";
});
// "by a creature that has psionic ability"
req = req.replace(/(?:a creature that has )?\bpsionic ability/gi, (...m) => {
tags.push({psionics: true});
return "";
});
// "by a bard, cleric, druid, sorcerer, warlock, or wizard"
req = req.replace(new RegExp(`(?:(?:a|an) )?\\b${ConverterConst.STR_RE_CLASS}\\b`, "gi"), (...m) => {
const source = m.last().name.toLowerCase() === "artificer" ? Parser.SRC_TCE : null;
tags.push({class: `${m.last().name}${source ? `|${source}` : ""}`.toLowerCase()});
return "";
});
// region Alignment
// "by a creature of evil alignment"
// "by a dwarf, fighter, or paladin of good alignment"
// "by an elf or half-elf of neutral good alignment"
// "by an evil cleric or paladin"
const alignmentParts = req.split(/,| or /gi)
.map(it => it.trim())
.filter(it => it && it !== "," && it !== "or");
alignmentParts.forEach(part => {
Object.values(AlignmentUtil.ALIGNMENTS)
.forEach(it => {
if (it.regexWeak.test(part)) {
// We assume the alignment modifies all previous entries
if (tags.length) tags.forEach(by => by.alignment = [...it.output]);
else tags.push({alignment: [...it.output]});
}
});
});
// endregion
const propOut = isAlt ? "reqAttuneAltTags" : "reqAttuneTags";
if (tags.length) obj[propOut] = tags;
else delete obj[propOut];
}
static tryRun (it, opts) {
if (it.reqAttune) this._checkAndTag(it, opts);
if (it.inherits?.reqAttune) this._checkAndTag(it.inherits, opts);
if (it.reqAttuneAlt) this._checkAndTag(it, opts, true);
if (it.inherits?.reqAttuneAlt) this._checkAndTag(it.inherits, opts, true);
}
}
ReqAttuneTagTag._RAVNICA_GUILD_BACKGROUNDS = {
"Azorius": "Azorius Functionary|GGR",
"Boros": "Boros Legionnaire|GGR",
"Dimir": "Dimir Operative|GGR",
"Golgari": "Golgari Agent|GGR",
"Gruul": "Gruul Anarch|GGR",
"Izzet": "Izzet Engineer|GGR",
"Orzhov": "Orzhov Representative|GGR",
"Rakdos": "Rakdos Cultist|GGR",
"Selesnya": "Selesnya Initiate|GGR",
"Simic": "Simic Scientist|GGR",
};
ReqAttuneTagTag._EBERRON_MARK_RACES = {
"Mark of Warding": ["Dwarf (Mark of Warding)|ERLW"],
"Mark of Shadow": ["Elf (Mark of Shadow)|ERLW"],
"Mark of Scribing": ["Gnome (Mark of Scribing)|ERLW"],
"Mark of Detection": ["Half-Elf (Variant; Mark of Detection)|ERLW"],
"Mark of Storm": ["Half-Elf (Variant; Mark of Storm)|ERLW"],
"Mark of Finding": [
"Half-Orc (Variant; Mark of Finding)|ERLW",
"Human (Variant; Mark of Finding)|ERLW",
],
"Mark of Healing": ["Halfling (Mark of Healing)|ERLW"],
"Mark of Hospitality": ["Halfling (Mark of Hospitality)|ERLW"],
"Mark of Handling": ["Human (Mark of Handling)|ERLW"],
"Mark of Making": ["Human (Mark of Making)|ERLW"],
"Mark of Passage": ["Human (Mark of Passage)|ERLW"],
"Mark of Sentinel": ["Human (Mark of Sentinel)|ERLW"],
};
globalThis.ReqAttuneTagTag = ReqAttuneTagTag;

View File

@@ -0,0 +1,41 @@
"use strict";
class ConverterUtilsMarkdown { // Or "CUM" for short.
static _RE_LI_LEADING_SYMBOL = /^[-*]\s+/;
static getCleanRaw (str) {
return str.trim()
.replace(/\s*<br\s*(\/)?>\s*/gi, " "); // remove <br>
}
static getNoDashStarStar (line) { return line.replace(/\**/g, "").replace(/^-/, "").trim(); }
static getNoHashes (line) { return line.trim().replace(/^#*/, "").trim(); }
static getNoTripleHash (line) { return line.replace(/^###/, "").trim(); }
static getCleanTraitText (line) {
const [name, text] = line.replace(/^\*\*\*?/, "").split(/\.?\s*\*\*\*?\.?/).map(it => it.trim());
return [
ConvertUtil.getCleanTraitActionName(name),
text.replace(/\*Hit(\*:|:\*) /g, "Hit: "), // clean hit tags for later replacement
];
}
static getNoLeadingSymbols (line) {
const removeFirstInnerStar = line.trim().startsWith("*");
const clean = line.replace(/^[^A-Za-z0-9]*/, "").trim();
return removeFirstInnerStar ? clean.replace(/\*/, "") : clean;
}
/** It should really start with "***" but, homebrew. */
static isInlineHeader (line) { return line.trim().startsWith("**"); }
static isBlankLine (line) { return line === "" || line.toLowerCase() === "\\pagebreak" || line.toLowerCase() === "\\columnbreak"; }
static isListItem (line) { return this._RE_LI_LEADING_SYMBOL.test(line); }
static getNoLeadingListSymbol (line) { return line.replace(this._RE_LI_LEADING_SYMBOL, "").trim(); }
}
globalThis.ConverterUtilsMarkdown = ConverterUtilsMarkdown;

377
js/converterutils-race.js Normal file
View File

@@ -0,0 +1,377 @@
"use strict";
class RaceTraitTag {
static _RE_ITEMS_BASE_WEAPON = null;
static init ({itemsRaw}) {
const itemsBaseWeapon = itemsRaw.baseitem.filter(it => ["R", "M"].includes(it.type));
this._RE_ITEMS_BASE_WEAPON = new RegExp(`\\b(${itemsBaseWeapon.map(it => it.name)})\\b`, "gi");
}
static tryRun (race, {cbWarning}) {
if (!race.entries?.length) return;
const traitTags = new Set();
const walker = MiscUtil.getWalker({isNoModification: true, isBreakOnReturn: true});
race.entries
.forEach(ent => {
let isNaturalWeapon = false;
// Natural weapons are a specific class of proficiency, so pull these out first
walker.walk(
ent,
{
string: (str) => {
if (
/\bnatural weapon\b/i.test(str)
|| /\bcan use to make unarmed strikes\b/i.test(str)
) {
isNaturalWeapon = true;
traitTags.add("Natural Weapon");
return true;
}
},
},
);
walker.walk(
ent,
{
string: (str) => {
if (/\barmor class\b/i.test(str) || /\bac\b/i.test(str)) {
traitTags.add("Natural Armor");
}
if (
!isNaturalWeapon
&& /\bproficiency\b/i.test(str)
&& !/\bproficiency bonus\b/i.test(str)
) {
let found = false;
if (/\bskills?\b/i.test(str)) {
traitTags.add("Skill Proficiency");
found = true;
}
if (/\b(?:tool|poisoner's kit)\b/i.test(str)) {
traitTags.add("Tool Proficiency");
found = true;
}
if (/\b(light|medium|heavy) armor\b/i.test(str)) {
traitTags.add("Armor Proficiency");
found = true;
}
if (this._RE_ITEMS_BASE_WEAPON.test(str)) {
traitTags.add("Weapon Proficiency");
found = true;
}
if (!found) {
cbWarning(`Could not determine proficiency tags from "${str}"`);
}
}
if (/\blarger\b/i.test(str) && /\bsize\b/i.test(str) && /\bcapacity\b/i.test(str)) {
traitTags.add("Powerful Build");
}
if (
(/\bmeditate\b/i.test(str) || /\btrance\b/i.test(str) || /\bsleep\b/i.test(str))
&& /\bfey ancestry\b/i.test(ent.name || "")
) {
traitTags.add("Improved Resting");
}
if (/\bbreathe\b/i.test(str) || /\bwater\b/i.test(str)) {
traitTags.add("Amphibious");
}
if (/\bmagic resistance\b/i.test(str)) {
traitTags.add("Magic Resistance");
}
if (/\bblindsight\b/i.test(str)) {
traitTags.add("Blindsight");
}
if (/\bsunlight sensitivity\b/i.test(str) || /\bdisadvantage [^.?!]+ direct sunlight\b/i.test(str)) {
traitTags.add("Sunlight Sensitivity");
}
},
},
);
});
if (traitTags.size) race.traitTags = [...traitTags].sort(SortUtil.ascSortLower);
}
}
globalThis.RaceTraitTag = RaceTraitTag;
class RaceLanguageTag {
static tryRun (race, {cbWarning, cbError}) {
if (!race.entries?.length) return;
const entry = race.entries.find(it => /^language/i.test(it.name || ""));
if (!entry || !entry.entries?.length) return;
const langProfs = this._getLanguages(entry, {cbWarning, cbError});
if (langProfs.length) race.languageProficiencies = langProfs;
}
static _getLanguages (entry, {cbWarning, cbError}) {
const outStack = [];
const walker = MiscUtil.getWalker({isNoModification: true});
entry.entries.forEach(ent => {
walker.walk(
ent,
{
string: (str) => {
this._handleString({str, outStack, cbWarning, cbError});
},
},
);
});
return outStack;
}
static _LANGUAGES = new Set(["Abyssal", "Aquan", "Auran", "Celestial", "Common", "Draconic", "Dwarvish", "Elvish", "Giant", "Gnomish", "Goblin", "Halfling", "Ignan", "Infernal", "Orc", "Primordial", "Sylvan", "Terran", "Undercommon"]);
static _STOPWORDS = new Set(["Almost", "Elven", "Gifted", "It", "Languages", "Many", "Mimicry", "Only", "Or", "The", "Their", "They", "You", "Humans", "Conclave", "Kryta", "Hyperium", "Ithean", "Illyrian", "Speak"]);
static _isCaps (str) { return /^[A-Z]/.test(str); }
static _handleString ({str, outStack, cbWarning, cbError}) {
// Remove the first word of each sentence, as it has non-title-based caps
str = str.trim().replace(/(^\w+|[.?!]\s*\w+)/g, "");
str = str
// Combine tokens from any languages that has spaces in the name (this will be converted to "Other" later)
.replace(/\bCoalition pidgin\b/gi, "Coalitionpidgin")
// Remove anything that is not a language, but is uppercase'd
.replace(/\bBrazen Coalition\b/gi, "brazen coalition")
.replace(/\bSun Empire\b/gi, "sun empire")
.replace(/\bLegion of Dusk\b/gi, "legion of dusk")
// (Handle homebrew)
.replace(/\bother language you knew in life\b/gi, "choose")
;
// Tokenize, removing anything that we don't care about
const reChoose = /^(?:choice|choose|choosing|chosen|chooses|chose)$/;
const tokens = str.split(" ")
// replace all non-word characters (i.e. remove punctuation from tokens)
.map(it => it.replace(/\W/g, "").trim())
.filter(t => {
if (!t) return false;
if (this._isCaps(t)) return true; // keep all caps-words
if (/^(?:one|two|three|four|five|six|seven|eight|nine|ten)$/.test(t)) return true; // keep any numbers
if (reChoose.test(t)) return true; // keep any "choose" words
})
.map(it => {
// Map any "choose" flavors to a base string
if (reChoose.test(it)) return "choose";
return it;
});
// De-duplicate caps words
const reducedTokens = [];
tokens.forEach(t => {
if (this._isCaps(t)) {
if (!reducedTokens.includes(t)) reducedTokens.push(t);
return;
}
reducedTokens.push(t);
});
const reducedTokensCleaned = reducedTokens
// Filter out junk
.filter(t => !this._STOPWORDS.has(t));
if (!reducedTokensCleaned.length) return;
// Sort tokens so that any adjacent "<number>" + "choose" tokens are always ordered thus
const sortedTokens = [];
for (let i = 0; i < reducedTokensCleaned.length; ++i) {
const t0 = reducedTokensCleaned[i];
const t1 = reducedTokensCleaned[i + 1];
if (this._isCaps(t0)) {
sortedTokens.push(t0);
continue;
}
if (t0 === "choose" && t1 === "choose") {
return cbError(`Two language "choose" tokens in a row!`);
}
if (t0 === "choose") {
if (this._isCaps(t1)) {
sortedTokens.push("one");
sortedTokens.push(t0);
} else {
// flip the order so the number is first
sortedTokens.push(t1);
sortedTokens.push(t0);
++i;
}
continue;
}
if (t1 === "choose") {
if (this._isCaps(t0)) {
sortedTokens.push("one");
sortedTokens.push(t1);
} else {
sortedTokens.push(t0);
sortedTokens.push(t1);
++i;
}
continue;
}
return cbError(`Mismatched language token in: ${reducedTokensCleaned.join(" ")} (current output is ${sortedTokens.join(" ")})`);
}
let out = {};
let lastNum = null;
sortedTokens.forEach(t => {
if (this._isCaps(t)) {
out[(this._LANGUAGES.has(t) ? t : "Other").toLowerCase()] = true;
return;
}
// A meta-token
switch (t) {
case "choose": {
out.anyStandard = lastNum;
outStack.push(out);
out = {};
lastNum = null;
break;
}
default: {
const n = Parser.textToNumber(t);
if (isNaN(n)) return cbError(`Could not parse language token "${t}" as number`);
lastNum = n;
}
}
});
if (Object.keys(out).length) outStack.push(out);
}
}
globalThis.RaceLanguageTag = RaceLanguageTag;
class RaceImmResVulnTag {
static _RE_DAMAGE_TYPES = new RegExp(`(${Parser.DMG_TYPES.join("|")})`, "gi");
static _WALKER = null;
static tryRun (race, {cbWarning, cbError} = {}) {
if (!race.entries?.length) return;
this._WALKER = this._WALKER || MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, isNoModification: true});
this._handleResist(race);
this._handleImmune(race);
this._handleConditionImmune(race);
}
static _handleResist (race) {
if (!race.entries) return;
const out = new Set();
race.entries.forEach(ent => {
this._WALKER.walk(
ent.entries,
{
string: (str) => {
str.replace(/(?:resistance|resistant) (?:to|against) ([^.!?]+)/gi, (...m) => {
m[1].replace(this._RE_DAMAGE_TYPES, (...n) => {
out.add(n[1].toLowerCase());
});
});
},
},
);
});
// region Special cases
if (race.name === "Dragonborn" && race.source === Parser.SRC_PHB) {
out.add({"choose": {"from": ["acid", "cold", "fire", "lightning", "poison"]}});
} else if (race.name === "Revenant" && race.source === "UAGothicHeroes") {
out.add("necrotic");
}
// endregion
if (out.size) race.resist = [...out];
else delete race.resist;
}
static _handleImmune (race) {
if (!race.entries) return;
const out = new Set();
race.entries.forEach(ent => {
this._WALKER.walk(
ent.entries,
{
string: (str) => {
str = Renderer.stripTags(str);
const sents = str.split(/[.?!]/g);
sents.forEach(sent => {
if (!sent.toLowerCase().includes("immune ") || !sent.toLowerCase().includes(" damage")) return;
const tokens = sent.replace(/[^A-z0-9 ]/g, "").split(" ").map(it => it.trim().toLowerCase());
Parser.DMG_TYPES.filter(typ => tokens.includes(typ)).forEach(it => out.add(it));
});
},
},
);
});
if (out.size) race.immune = [...out];
else delete race.immune;
}
static _handleConditionImmune (race) {
if (!race.entries) return;
const out = new Set();
race.entries.forEach(ent => {
this._WALKER.walk(
ent.entries,
{
string: (str) => {
str.replace(/immun(?:e|ity) to disease/gi, () => {
out.add("disease");
});
str.replace(/immune ([^.!?]+)/, (...m) => {
m[1].replace(/{@condition ([^}]+)}/gi, (...n) => {
out.add(n[1].toLowerCase());
});
});
},
},
);
});
if (out.size) race.conditionImmune = [...out];
else delete race.conditionImmune;
}
}
globalThis.RaceImmResVulnTag = RaceImmResVulnTag;

View File

@@ -0,0 +1,435 @@
// region Based on `Charactermancer_AdditionalSpellsUtil`
class _SpellSourceUtil {
static _getCleanUid (uid) {
return DataUtil.proxy.getUid(
"spell",
DataUtil.proxy.unpackUid("spell", uid.split("#")[0], "spell"),
);
}
// region Data flattening
static getSpellUids (additionalSpellBlock, modalFilterSpells) {
additionalSpellBlock = MiscUtil.copyFast(additionalSpellBlock);
const outSpells = [];
Object.entries(additionalSpellBlock)
.forEach(([additionType, additionMeta]) => {
switch (additionType) {
case "innate":
case "known":
case "prepared":
case "expanded": {
this._getSpellUids_doProcessAdditionMeta({additionType, additionMeta, outSpells, modalFilterSpells});
break;
}
// Ignored
case "name":
case "ability":
case "resourceName": break;
default: throw new Error(`Unhandled spell addition type "${additionType}"`);
}
});
return outSpells.unique();
}
static _getSpellUids_doProcessAdditionMeta (opts) {
const {additionMeta, modalFilterSpells} = opts;
Object.values(additionMeta).forEach((levelMeta) => {
if (levelMeta instanceof Array) {
return levelMeta.forEach(spellItem => this._getSpellUids_doProcessSpellItem({...opts, spellItem, modalFilterSpells}));
}
Object.entries(levelMeta).forEach(([rechargeType, levelMetaInner]) => {
this._getSpellUids_doProcessSpellRechargeBlock({...opts, rechargeType, levelMetaInner, modalFilterSpells});
});
});
}
static _getSpellUids_doProcessSpellItem (opts) {
const {outSpells, spellItem, modalFilterSpells} = opts;
if (typeof spellItem === "string") {
return outSpells.push(this._getCleanUid(spellItem));
}
if (spellItem.all != null) { // A filter expression
return modalFilterSpells.getEntitiesMatchingFilterExpression({filterExpression: spellItem.all})
.forEach(ent => outSpells.push(DataUtil.generic.getUid({name: ent.name, source: ent.source})));
}
if (spellItem.choose != null) {
if (typeof spellItem.choose === "string") { // A filter expression
return modalFilterSpells.getEntitiesMatchingFilterExpression({filterExpression: spellItem.choose})
.forEach(ent => outSpells.push(DataUtil.generic.getUid({name: ent.name, source: ent.source})));
}
if (spellItem.choose.from) { // An array of choices
return spellItem.choose.from
.forEach(uid => outSpells.push(this._getCleanUid(uid)));
}
throw new Error(`Unhandled additional spell format: "${JSON.stringify(spellItem)}"`);
}
throw new Error(`Unhandled additional spell format: "${JSON.stringify(spellItem)}"`);
}
static _getSpellUids_doProcessSpellRechargeBlock (opts) {
const {rechargeType, levelMetaInner} = opts;
switch (rechargeType) {
case "rest":
case "daily": {
Object.values(levelMetaInner)
.forEach(spellList => {
spellList.forEach(spellItem => this._getSpellUids_doProcessSpellItem({...opts, spellItem}));
});
break;
}
case "resource": {
Object.values(levelMetaInner)
.forEach(spellList => {
spellList.forEach(spellItem => this._getSpellUids_doProcessSpellItem({...opts, spellItem}));
});
break;
}
case "will":
case "ritual":
case "_": {
levelMetaInner.forEach(spellItem => this._getSpellUids_doProcessSpellItem({...opts, spellItem}));
break;
}
default: throw new Error(`Unhandled spell recharge type "${rechargeType}"`);
}
}
// endregion
}
// endregion
class _SpellSource {
constructor () {
this._lookup = {};
}
/* -------------------------------------------- */
mutLookup (otherLookup) {
this._mutLookup_recurse(otherLookup, this._lookup, []);
}
_mutLookup_recurse (otherLookup, obj, path) {
Object.entries(obj)
.forEach(([k, v]) => {
if (typeof v !== "object" || v instanceof Array) {
return MiscUtil.set(otherLookup, ...path, k, v);
}
this._mutLookup_recurse(otherLookup, v, [...path, k]);
});
}
/* -------------------------------------------- */
async pInit () { throw new Error("Unimplemented!"); }
}
class _SpellSourceClasses extends _SpellSource {
constructor ({spellSourceLookupAdditional = null}) {
super();
this._spellSourceLookupAdditional = spellSourceLookupAdditional;
}
async pInit () {
this._mutAddSpellSourceLookup({spellSourceLookup: await DataUtil.loadJSON(`${Renderer.get().baseUrl}data/spells/sources.json`)});
this._mutAddSpellSourceLookup({spellSourceLookup: this._spellSourceLookupAdditional});
}
_mutAddSpellSourceLookup ({spellSourceLookup}) {
if (!spellSourceLookup) return;
Object.entries(spellSourceLookup)
.forEach(([spellSource, spellNameTo]) => {
Object.entries(spellNameTo)
.forEach(([spellName, propTo]) => {
Object.entries(propTo)
.forEach(([prop, arr]) => {
const grouped = {};
arr
.forEach(({name: className, source: classSource, definedInSource}) => {
const k = definedInSource || null;
const tgt = MiscUtil.getOrSet(grouped, classSource, className, {definedInSources: []});
if (!tgt.definedInSources.includes(k)) tgt.definedInSources.push(k);
});
Object.entries(grouped)
.forEach(([classSource, byClassSource]) => {
Object.entries(byClassSource)
.forEach(([className, byClassName]) => {
MiscUtil.set(
this._lookup,
spellSource.toLowerCase(),
spellName.toLowerCase(),
prop,
classSource,
className,
byClassName.definedInSources.some(it => it != null)
? {definedInSources: byClassName.definedInSources}
: true,
);
});
});
});
});
});
}
}
class _AdditionalSpellSource extends _SpellSource {
constructor ({props, modalFilterSpells} = {}) {
super();
this._props = props;
this._modalFilterSpells = modalFilterSpells;
}
/* -------------------------------------------- */
async pInit () {
const data = await this._pLoadData();
this._props
.forEach(prop => {
data[prop]
.filter(ent => ent.additionalSpells)
.filter(ent => !this._isSkipEntity(ent))
.forEach(ent => {
const propPath = this._getPropPath(ent);
const uidToSummary = {};
ent.additionalSpells
.forEach(additionalSpellBlock => {
_SpellSourceUtil.getSpellUids(additionalSpellBlock, this._modalFilterSpells)
.forEach(uid => {
uidToSummary[uid] = uidToSummary[uid] || {
cntAdditionalSpellBlocks: ent.additionalSpells.length,
};
if (additionalSpellBlock.name) {
(uidToSummary[uid].names = uidToSummary[uid].names || []).push(additionalSpellBlock.name);
}
});
});
Object.entries(uidToSummary)
.forEach(([uid, additionalSpellsSummary]) => {
const val = this._getLookupValue(ent, additionalSpellsSummary);
const {name, source} = DataUtil.proxy.unpackUid("spell", uid, "spell", {isLower: true});
MiscUtil.set(this._lookup, source, name, ...propPath, val);
});
});
});
}
async _pLoadData () { throw new Error("Unimplemented!"); }
_isSkipEntity (ent) { return false; }
_getPropPath (ent) { throw new Error("Unimplemented!"); }
_getPropPath_nameSource (ent) { return [ent.source, ent.name]; }
_getLookupValue (ent, additionalSpellsSummary) { return true; }
}
class _AdditionalSpellSourceClassesSubclasses extends _AdditionalSpellSource {
static _HASHES_SKIPPED = new Set([
UrlUtil.URL_TO_HASH_BUILDER["subclass"]({
name: "College of Lore",
shortName: "Lore",
source: "PHB",
className: "Bard",
classSource: "PHB",
}),
]);
constructor (opts) {
super({
...opts,
props: [
// Only include subclass additionalSpells, otherwise we add e.g. Bard Magical Secrets, which isn't helpful
// "class",
"subclass",
],
});
}
async _pLoadData () {
return DataUtil.class.loadJSON();
}
_isSkipEntity (ent) {
if (ent.className === VeCt.STR_GENERIC || ent.classSource === VeCt.STR_GENERIC) return true;
const hash = UrlUtil.URL_TO_HASH_BUILDER["subclass"](ent);
return this.constructor._HASHES_SKIPPED.has(hash);
}
_getPropPath (ent) {
switch (ent.__prop) {
case "class": return [ent.__prop, ...this._getPropPath_nameSource(ent)];
case "subclass": return [ent.__prop, ent.classSource, ent.className, ent.source, ent.shortName];
default: throw new Error(`Unhandled __prop "${ent.__prop}"`);
}
}
_getLookupValue (ent, additionalSpellsSummary) {
switch (ent.__prop) {
case "subclass": {
const out = {name: ent.name};
// Only add `subSubclasses` if a spell is not shared between every sub-subclass
if (additionalSpellsSummary.names && additionalSpellsSummary.cntAdditionalSpellBlocks !== additionalSpellsSummary.names.length) out.subSubclasses = additionalSpellsSummary.names;
return out;
}
default: return super._getLookupValue(ent);
}
}
}
class _AdditionalSpellSourceRaces extends _AdditionalSpellSource {
constructor (opts) {
super({
...opts,
props: ["race"],
});
}
async _pLoadData () {
return DataUtil.race.loadJSON();
}
_getPropPath (ent) { return ["race", ...this._getPropPath_nameSource(ent)]; }
_getLookupValue (ent) {
if (!ent._isSubRace) return super._getLookupValue(ent);
return {baseName: ent._baseName, baseSource: ent._baseSource};
}
}
class _AdditionalSpellSourceFile extends _AdditionalSpellSource {
constructor ({file, ...rest} = {}) {
super({...rest});
this._file = file;
}
async _pLoadData () {
return DataUtil.loadJSON(`./data/${this._file}`);
}
_getPropPath (ent) { return [ent.__prop, ...this._getPropPath_nameSource(ent)]; }
}
class _AdditionalSpellSourceBackgrounds extends _AdditionalSpellSourceFile {
constructor ({...rest}) {
super({
...rest,
props: ["background"],
file: "backgrounds.json",
});
}
}
class _AdditionalSpellSourceCharCreationOptions extends _AdditionalSpellSourceFile {
constructor ({...rest}) {
super({
...rest,
props: ["charoption"],
file: "charcreationoptions.json",
});
}
}
class _AdditionalSpellSourceFeats extends _AdditionalSpellSourceFile {
constructor ({...rest}) {
super({
...rest,
props: ["feat"],
file: "feats.json",
});
}
}
class _AdditionalSpellSourceOptionalFeatures extends _AdditionalSpellSourceFile {
constructor ({...rest}) {
super({
...rest,
props: ["optionalfeature"],
file: "optionalfeatures.json",
});
}
_getLookupValue (ent) {
return {featureType: [...ent.featureType]};
}
}
class _AdditionalSpellSourceRewards extends _AdditionalSpellSourceFile {
constructor ({...rest}) {
super({
...rest,
props: ["reward"],
file: "rewards.json",
});
}
}
export class SpellSourceLookupBuilder {
static async pGetLookup ({spells, spellSourceLookupAdditional = null}) {
const cpySpells = MiscUtil.copyFast(spells);
const lookup = {};
for (
const Clazz of [
_SpellSourceClasses,
_AdditionalSpellSourceClassesSubclasses,
_AdditionalSpellSourceBackgrounds,
_AdditionalSpellSourceCharCreationOptions,
_AdditionalSpellSourceFeats,
_AdditionalSpellSourceOptionalFeatures,
_AdditionalSpellSourceRaces,
_AdditionalSpellSourceRewards,
]
) {
cpySpells.forEach(sp => PageFilterSpells.unmutateForFilters(sp));
const modalFilterSpells = new ModalFilterSpells({allData: cpySpells});
await modalFilterSpells.pPopulateHiddenWrapper();
const adder = new Clazz({modalFilterSpells, spellSourceLookupAdditional});
await adder.pInit();
adder.mutLookup(lookup);
DataUtil.spell.setSpellSourceLookup(lookup, {isExternalApplication: true});
cpySpells.forEach(sp => {
DataUtil.spell.unmutEntity(sp, {isExternalApplication: true});
DataUtil.spell.mutEntity(sp, {isExternalApplication: true});
});
}
return lookup;
}
}

448
js/converterutils-spell.js Normal file
View File

@@ -0,0 +1,448 @@
"use strict";
class DamageTagger {
static _addDamageTypeToArray (arr, str, options) {
str = str.toLowerCase().trim();
if (str === "all" || str === "one" || str === "a") arr.push(...Parser.DMG_TYPES);
else if (Parser.DMG_TYPES.includes(str)) arr.push(str);
else options.cbWarning(`Unknown damage type "${str}"`);
}
}
class DamageInflictTagger extends DamageTagger {
static tryRun (sp, options) {
sp.damageInflict = [];
JSON.stringify([sp.entries, sp.entriesHigherLevel]).replace(/(?:{@damage [^}]+}|\d+) (\w+)((?:, \w+)*)(,? or \w+)? damage/ig, (...m) => {
if (m[1]) this._addDamageTypeToArray(sp.damageInflict, m[1], options);
if (m[2]) m[2].split(",").map(it => it.trim()).filter(Boolean).forEach(str => this._addDamageTypeToArray(sp.damageInflict, str, options));
if (m[3]) this._addDamageTypeToArray(sp.damageInflict, m[3].split(" ").last(), options);
});
if (!sp.damageInflict.length) delete sp.damageInflict;
else sp.damageInflict = [...new Set(sp.damageInflict)].sort(SortUtil.ascSort);
}
}
class DamageResVulnImmuneTagger extends DamageTagger {
static tryRun (sp, prop, options) {
sp[prop] = [];
JSON.stringify([sp.entries, sp.entriesHigherLevel]).replace(/resistance to (\w+)((?:, \w+)*)(,? or \w+)? damage/ig, (...m) => {
if (m[1]) this._addDamageTypeToArray(sp[prop], m[1], options);
if (m[2]) m[2].split(",").map(it => it.trim()).filter(Boolean).forEach(str => this._addDamageTypeToArray(sp[prop], str, options));
if (m[3]) this._addDamageTypeToArray(sp[prop], m[3].split(" ").last(), options);
});
if (!sp[prop].length) delete sp[prop];
else sp[prop] = [...new Set(sp[prop])].sort(SortUtil.ascSort);
}
}
class ConditionInflictTagger {
static tryRun (sp, options) {
sp.conditionInflict = [];
JSON.stringify([sp.entries, sp.entriesHigherLevel]).replace(/{@condition ([^}]+)}/ig, (...m) => sp.conditionInflict.push(m[1].toLowerCase()));
if (!sp.conditionInflict.length) delete sp.conditionInflict;
else sp.conditionInflict = [...new Set(sp.conditionInflict)].sort(SortUtil.ascSort);
}
}
class SavingThrowTagger {
static tryRun (sp, options) {
sp.savingThrow = [];
JSON.stringify([sp.entries, sp.entriesHigherLevel]).replace(/(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throw/ig, (...m) => sp.savingThrow.push(m[1].toLowerCase()));
if (!sp.savingThrow.length) delete sp.savingThrow;
else sp.savingThrow = [...new Set(sp.savingThrow)].sort(SortUtil.ascSort);
}
}
class AbilityCheckTagger {
static tryRun (sp, options) {
sp.abilityCheck = [];
JSON.stringify([sp.entries, sp.entriesHigherLevel]).replace(/a (Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) check/ig, (...m) => sp.abilityCheck.push(m[1].toLowerCase()));
if (!sp.abilityCheck.length) delete sp.abilityCheck;
else sp.abilityCheck = [...new Set(sp.abilityCheck)].sort(SortUtil.ascSort);
}
}
class SpellAttackTagger {
static tryRun (sp, options) {
sp.spellAttack = [];
JSON.stringify([sp.entries, sp.entriesHigherLevel]).replace(/make (?:a|up to [^ ]+) (ranged|melee) spell attack/ig, (...m) => sp.spellAttack.push(m[1][0].toUpperCase()));
if (!sp.spellAttack.length) delete sp.spellAttack;
else sp.spellAttack = [...new Set(sp.spellAttack)].sort(SortUtil.ascSort);
}
}
// TODO areaTags
class MiscTagsTagger {
static _addTag ({tags, tag, options}) {
if (options?.allowlistTags && !options?.allowlistTags.has(tag)) return;
tags.add(tag);
}
static tryRun (sp, options) {
const tags = new Set(sp.miscTags || []);
MiscTagsTagger._WALKER = MiscTagsTagger._WALKER || MiscUtil.getWalker({isNoModification: true, keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST});
MiscTagsTagger._WALKER.walk(
[sp.entries, sp.entriesHigherLevel],
{
string: (str) => {
const stripped = Renderer.stripTags(str);
if (/becomes permanent/ig.test(str)) this._addTag({tags, tag: "PRM", options});
if (/when you reach/ig.test(str)) this._addTag({tags, tag: "SCL", options});
if ((/regain|restore/ig.test(str) && /hit point/ig.test(str)) || /heal/ig.test(str)) this._addTag({tags, tag: "HL", options});
if (/temporary hit points/ig.test(str)) this._addTag({tags, tag: "THP", options});
if (/you summon/ig.test(str) || /creature shares your initiative count/ig.test(str)) this._addTag({tags, tag: "SMN", options});
if (/you can see/ig.test(str)) this._addTag({tags, tag: "SGT", options});
if (/you (?:can then )?teleport/i.test(str) || /instantly (?:transports you|teleport)/i.test(str) || /enters(?:[^.]+)portal instantly/i.test(str) || /entering the portal exits from the other portal/i.test(str)) this._addTag({tags, tag: "TP", options});
if ((str.includes("bonus") || str.includes("penalty")) && str.includes("AC")) this._addTag({tags, tag: "MAC", options});
if (/target's (?:base )?AC becomes/.exec(str)) this._addTag({tags, tag: "MAC", options});
if (/target's AC can't be less than/.exec(str)) this._addTag({tags, tag: "MAC", options});
if (/(?:^|\W)(?:pull(?:|ed|s)|push(?:|ed|s)) [^.!?:]*\d+\s+(?:ft|feet|foot|mile|square)/ig.test(str)) this._addTag({tags, tag: "FMV", options});
if (/rolls? (?:a )?{@dice [^}]+} and consults? the table/.test(str)) this._addTag({tags, tag: "RO", options});
if ((/\bbright light\b/i.test(str) || /\bdim light\b/i.test(str)) && /\b\d+[- ]foot[- ]radius\b/i.test(str)) {
if (/\bsunlight\b/.test(str)) this._addTag({tags, tag: "LGTS", options});
else this._addTag({tags, tag: "LGT", options});
}
if (/\bbonus action\b/i.test(str)) this._addTag({tags, tag: "UBA", options});
if (/\b(?:lightly|heavily) obscured\b/i.test(str)) this._addTag({tags, tag: "OBS", options});
if (/\b(?:is|creates an area of|becomes?) difficult terrain\b/i.test(Renderer.stripTags(str)) || /spends? \d+ (?:feet|foot) of movement for every 1 foot/.test(str)) this._addTag({tags, tag: "DFT", options});
if (
/\battacks? deals? an extra\b[^.!?]+\bdamage\b/.test(str)
|| /\bdeals? an extra\b[^.!?]+\bdamage\b[^.!?]+\b(?:weapon attack|when it hits)\b/.test(str)
|| /weapon attacks?\b[^.!?]+\b(?:takes an extra|deal an extra)\b[^.!?]+\bdamage/.test(str)
) this._addTag({tags, tag: "AAD", options});
if (
/\b(?:any|one|a) creatures? or objects?\b/i.test(str)
|| /\b(?:flammable|nonmagical|metal|unsecured) objects?\b/.test(str)
|| /\bobjects?\b[^.!?]+\b(?:created by magic|(?:that )?you touch|that is neither held nor carried)\b/.test(str)
|| /\bobject\b[^.!?]+\bthat isn't being worn or carried\b/.test(str)
|| /\bobjects? (?:of your choice|that is familiar to you|of (?:Tiny|Small|Medium|Large|Huge|Gargantuan) size)\b/.test(str)
|| /\b(?:Tiny|Small|Medium|Large|Huge|Gargantuan) or smaller object\b/.test(str)
|| /\baffected by this spell, the object is\b/.test(str)
|| /\ball creatures and objects\b/i.test(str)
|| /\ba(?:ny|n)? (?:(?:willing|visible|affected) )?(?:creature|place) or an object\b/i.test(str)
|| /\bone creature, object, or magical effect\b/i.test(str)
|| /\ba person, place, or object\b/i.test(str)
|| /\b(choose|touch|manipulate|soil) (an|one) object\b/i.test(str)
) this._addTag({tags, tag: "OBJ", options});
if (
/\b(?:and(?: it)?|each target|the( [a-z]+)+) (?:also )?(?:has|gains) advantage\b/i.test(stripped)
|| /\bcreature in the area (?:[^.!?]+ )?has advantage\b/i.test(stripped)
|| /\broll(?:made )? against (?:an affected creature|this target) (?:[^.!?]+ )?has advantage\b/i.test(stripped)
|| /\bother creatures? have advantage on(?:[^.!?]+ )? rolls\b/i.test(stripped)
|| /\byou (?:have|gain|(?:can )?give yourself) advantage\b/i.test(stripped)
|| /\b(?:has|have) advantage on (?:Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma|all)\b/i.test(stripped)
|| /\bmakes? (?:all )?(?:Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throws with advantage\b/i.test(stripped)
) this._addTag({tags, tag: "ADV", options});
},
object: (obj) => {
if (obj.type !== "table") return;
const rollMode = Renderer.table.getAutoConvertedRollMode(obj);
if (rollMode !== RollerUtil.ROLL_COL_NONE) this._addTag({tags, tag: "RO", options});
},
},
);
sp.miscTags = [...tags].sort(SortUtil.ascSortLower);
if (!sp.miscTags.length) delete sp.miscTags;
}
}
MiscTagsTagger._WALKER = null;
class ScalingLevelDiceTagger {
static _WALKER_BOR = MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, isNoModification: true, isBreakOnReturn: true});
static _isParseFirstSecondLineRolls ({sp}) {
// Two "flat" paragraphs; first is spell text, second is cantrip scaling text
if (!sp.entriesHigherLevel) return sp.entries.length === 2 && sp.entries.filter(it => typeof it === "string").length === 2;
// One paragraph of spell text; one e.g. "Cantrip Upgrade" header with one paragraph of cantrip scaling text
return sp.entries.length === 1
&& typeof sp.entries[0] === "string"
&& sp.entriesHigherLevel.length === 1
&& sp.entriesHigherLevel[0].type === "entries"
&& sp.entriesHigherLevel[0].entries?.length === 1
&& typeof sp.entriesHigherLevel[0].entries[0] === "string";
}
static _getRollsFirstSecondLine ({firstLine, secondLine}) {
const rollsFirstLine = [];
const rollsSecondLine = [];
firstLine.replace(/{@(?:damage|dice) ([^}]+)}/g, (...m) => {
rollsFirstLine.push(m[1].split("|")[0]);
});
secondLine.replace(/\({@(?:damage|dice) ([^}]+)}\)/g, (...m) => {
rollsSecondLine.push(m[1].split("|")[0]);
});
return {rollsFirstLine, rollsSecondLine};
}
static _RE_DAMAGE_TYPE = new RegExp(`\\b${ConverterConst.STR_RE_DAMAGE_TYPE}\\b`, "i");
static _getLabel ({sp, options}) {
let label;
const handlers = {
string: str => {
const mDamageType = this._RE_DAMAGE_TYPE.exec(str);
if (mDamageType) {
label = `${mDamageType[1]} damage`;
return true;
}
},
};
if (sp.entriesHigherLevel) {
this._WALKER_BOR.walk(sp.entriesHigherLevel, handlers);
if (label) return label;
}
this._WALKER_BOR.walk(sp.entries, handlers);
if (label) return label;
options.cbWarning(`${sp.name ? `(${sp.name}) ` : ""}Could not create scalingLevelDice label!`);
return "NO_LABEL";
}
static tryRun (sp, options) {
if (sp.level !== 0) return;
// Prefer `entriesHigherLevel`, as we may have e.g. a `"Cantrip Upgrade"` header
const strEntries = JSON.stringify(sp.entriesHigherLevel || sp.entries);
const rolls = [];
strEntries.replace(/{@(?:damage|dice) ([^}]+)}/g, (...m) => {
rolls.push(m[1].split("|")[0]);
});
if ((rolls.length === 4 && strEntries.includes("one die")) || rolls.length === 5) {
if (rolls.length === 5 && rolls[0] !== rolls[1]) options.cbWarning(`${sp.name ? `(${sp.name}) ` : ""}scalingLevelDice rolls may require manual checking--mismatched roll number of rolls!`);
sp.scalingLevelDice = {
label: this._getLabel({sp, options}),
scaling: rolls.length === 4
? {
1: rolls[0],
5: rolls[1],
11: rolls[2],
17: rolls[3],
} : {
1: rolls[0],
5: rolls[2],
11: rolls[3],
17: rolls[4],
},
};
return;
}
if (this._isParseFirstSecondLineRolls({sp})) {
const {rollsFirstLine, rollsSecondLine} = this._getRollsFirstSecondLine({
firstLine: sp.entries[0],
secondLine: sp.entriesHigherLevel
? sp.entriesHigherLevel[0].entries[0]
: sp.entries[1],
});
if (rollsFirstLine.length >= 1 && rollsSecondLine.length >= 3) {
if (rollsFirstLine.length > 1 || rollsSecondLine.length > 3) {
options.cbWarning(`${sp.name ? `(${sp.name}) ` : ""}scalingLevelDice rolls may require manual checking--too many dice parts!`);
}
const label = this._getLabel({sp, options});
sp.scalingLevelDice = {
label: label,
scaling: {
1: rollsFirstLine[0],
5: rollsSecondLine[0],
11: rollsSecondLine[1],
17: rollsSecondLine[2],
},
};
}
}
}
}
class AffectedCreatureTypeTagger {
static tryRun (sp, options) {
const setAffected = new Set();
const setNotAffected = new Set();
const walker = MiscUtil.getWalker({isNoModification: true});
walker.walk(
sp.entries,
{
string: (str) => {
str = Renderer.stripTags(str);
const sens = str.split(/[.!?]/g);
sens.forEach(sen => {
// region Not affected
sen
// Blight :: PHB
.replace(/This spell has no effect on (.+)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setNotAffected, type: n[1]}));
})
// Command :: PHB
.replace(/The spell has no effect if the target is (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setNotAffected, type: n[1]}));
})
// Raise Dead :: PHB
.replace(/The spell can't return an (.*?) creature/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setNotAffected, type: n[1]}));
})
// Shapechange :: PHB
.replace(/The creature can't be (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setNotAffected, type: n[1]}));
})
// Sleep :: PHB
.replace(/(.*?) aren't affected by this spell/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setNotAffected, type: n[1]}));
})
// Speak with Dead :: PHB
.replace(/The corpse\b.*?\bcan't be (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setNotAffected, type: n[1]}));
})
// Cause Fear :: XGE
.replace(/A (.*?) is immune to this effect/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setNotAffected, type: n[1]}));
})
// Healing Spirit :: XGE
.replace(/can't heal (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setNotAffected, type: n[1]}));
})
;
// endregion
// region Affected
sen
// Awaken :: PHB
.replace(/you touch a [^ ]+ or (?:smaller|larger) (.+)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Calm Emotions :: PHB
.replace(/Each (.+) in a \d+-foot/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Charm Person :: PHB
.replace(/One (.*?) of your choice/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Crown of Madness :: PHB
.replace(/You attempt to .* a (.+) you can see/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Detect Evil and Good :: PHB
.replace(/you know if there is an? (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Dispel Evil and Good :: PHB
.replace(/For the duration, (.*?) have disadvantage/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Hold Person :: PHB
.replace(/Choose (.+)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Locate Animals or Plants :: PHB
.replace(/name a specific kind of (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Magic Jar :: PHB
.replace(/You can attempt to possess any (.*?) that you can see/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Planar Binding :: PHB
.replace(/you attempt to bind a (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Protection from Evil and Good :: PHB
.replace(/types of creatures: (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Reincarnate :: PHB
.replace(/You touch a dead (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Simulacrum :: PHB
.replace(/You shape an illusory duplicate of one (.*)/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Speak with Animals :: PHB
.replace(/communicate with (.*?) for the duration/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Fast Friends :: AI
.replace(/choose one (.*?) within range/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Beast Bond :: XGE
.replace(/telepathic link with one (.*?) you touch/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Ceremony :: XGE
.replace(/You touch one (.*?) who/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
// Soul Cage :: XGE
.replace(/\bsoul of (.*?) as it dies/g, (...m) => {
m[1].replace(AffectedCreatureTypeTagger._RE_TYPES, (...n) => this._doAddType({set: setAffected, type: n[1]}));
})
;
// endregion
});
},
},
);
if (!setAffected.size && !setNotAffected.size) return;
const setAffectedOut = new Set([
...(sp.affectsCreatureType || []),
...setAffected,
]);
if (!setAffectedOut.size) Parser.MON_TYPES.forEach(it => setAffectedOut.add(it));
sp.affectsCreatureType = [...CollectionUtil.setDiff(setAffectedOut, setNotAffected)].sort(SortUtil.ascSortLower);
if (!sp.affectsCreatureType.length) delete sp.affectsCreatureType;
}
static _doAddType ({set, type}) {
type = Parser._parse_bToA(Parser.MON_TYPE_TO_PLURAL, type, type);
set.add(type);
return "";
}
}
AffectedCreatureTypeTagger._RE_TYPES = new RegExp(`\\b(${[...Parser.MON_TYPES, ...Object.values(Parser.MON_TYPE_TO_PLURAL)].map(it => it.escapeRegexp()).join("|")})\\b`, "gi");
globalThis.DamageInflictTagger = DamageInflictTagger;
globalThis.DamageResVulnImmuneTagger = DamageResVulnImmuneTagger;
globalThis.ConditionInflictTagger = ConditionInflictTagger;
globalThis.SavingThrowTagger = SavingThrowTagger;
globalThis.AbilityCheckTagger = AbilityCheckTagger;
globalThis.SpellAttackTagger = SpellAttackTagger;
globalThis.MiscTagsTagger = MiscTagsTagger;
globalThis.ScalingLevelDiceTagger = ScalingLevelDiceTagger;
globalThis.AffectedCreatureTypeTagger = AffectedCreatureTypeTagger;

1502
js/converterutils.js Normal file

File diff suppressed because it is too large Load Diff

321
js/crcalculator.js Normal file
View File

@@ -0,0 +1,321 @@
"use strict";
const MONSTER_STATS_BY_CR_JSON_URL = "data/msbcr.json";
const MONSTER_FEATURES_JSON_URL = "data/monsterfeatures.json";
let msbcr;
let monsterFeatures;
window.addEventListener("load", async () => {
await Promise.all([
PrereleaseUtil.pInit(),
BrewUtil2.pInit(),
]);
ExcludeUtil.pInitialise().then(null); // don't await, as this is only used for search
msbcr = await DataUtil.loadJSON(MONSTER_STATS_BY_CR_JSON_URL);
const mfData = await DataUtil.loadJSON(MONSTER_FEATURES_JSON_URL);
addMonsterFeatures(mfData);
window.dispatchEvent(new Event("toolsLoaded"));
});
function addMonsterFeatures (mfData) {
monsterFeatures = mfData.monsterfeatures;
for (let i = 0; i < msbcr.cr.length; i++) {
const curCr = msbcr.cr[i];
$("#msbcr").append(`<tr><td>${curCr._cr}</td><td>${Parser.crToXp(curCr._cr)}</td><td>${curCr.pb}</td><td>${curCr.ac}</td><td>${curCr.hpMin}-${curCr.hpMax}</td><td>${curCr.attackBonus}</td><td>${curCr.dprMin}-${curCr.dprMax}</td><td>${curCr.saveDc}</td></tr>`);
}
$("#crcalc input").change(calculateCr);
$("#saveprofs, #resistances").change(calculateCr);
$("#saveinstead").change(function () {
const curVal = parseInt($("#attackbonus").val());
if (!$(this).is(":checked")) $("#attackbonus").val(curVal - 10);
if ($(this).is(":checked")) $("#attackbonus").val(curVal + 10);
calculateCr();
});
function changeSize ($selSize) {
const newSize = $selSize.val();
if (newSize === "Tiny") $("#hdval").html("d4");
if (newSize === "Small") $("#hdval").html("d6");
if (newSize === "Medium") $("#hdval").html("d8");
if (newSize === "Large") $("#hdval").html("d10");
if (newSize === "Huge") $("#hdval").html("d12");
if (newSize === "Gargantuan") $("#hdval").html("d20");
$("#hp").val(calculateHp());
}
$("select#size").change(function () {
changeSize($(this));
calculateCr();
});
$("#hd, #con").change(function () {
$("#hp").val(calculateHp());
calculateCr();
});
// when clicking a row in the "Monster Statistics by Challenge Rating" table
$("#msbcr tr").not(":has(th)").click(function () {
if (!confirm("This will reset the calculator. Are you sure?")) return;
$("#expectedcr").val($(this).children("td:eq(0)").html());
const [minHp, maxHp] = $(this).children("td:eq(4)").html().split("-").map(it => parseInt(it));
$("#hp").val(minHp + (maxHp - minHp) / 2);
$("#hd").val(calculateHd());
$("#ac").val($(this).children("td:eq(3)").html());
$("#dpr").val($(this).children("td:eq(6)").html().split("-")[0]);
$("#attackbonus").val($(this).children("td:eq(5)").html());
if ($("#saveinstead").is(":checked")) $("#attackbonus").val($(this).children("td:eq(7)").html());
calculateCr();
});
$("#hp").change(function () {
$("#hd").val(calculateHd());
calculateCr();
});
// parse monsterfeatures
const $wrpMonFeatures = $(`#monsterfeatures .crc__wrp_mon_features`);
monsterFeatures.forEach(f => {
const effectOnCr = [];
if (f.hp) effectOnCr.push(`HP: ${f.hp}`);
if (f.ac) effectOnCr.push(`AC: ${f.ac}`);
if (f.dpr) effectOnCr.push(`DPR: ${f.dpr}`);
if (f.attackBonus) effectOnCr.push(`AB: ${f.attackBonus}`);
const numBox = f.hasNumberParam ? `<input type="number" value="0" min="0" class="form-control form-control--minimal crc__mon_feature_num input-xs ml-2">` : "";
$wrpMonFeatures.append(`
<label class="row crc__mon_feature ui-tip__parent">
<div class="col-1 crc__mon_feature_wrp_cb">
<input type="checkbox" id="mf-${Parser.stringToSlug(f.name)}" title="${f.name}" data-hp="${f.hp || ""}" data-ac="${f.ac || ""}" data-dpr="${f.dpr || ""}" data-attackbonus="${f.attackBonus || ""}" class="crc__mon_feature_cb">${numBox}
</div>
<div class="col-2">${f.name}</div>
<div class="col-2">${Renderer.get().render(`{@creature ${f.example}}`)}</div>
<div class="col-7"><span title="${effectOnCr.join(", ")}">${Renderer.get().render(f.effect)}</span></div>
</label>
`);
});
function parseUrl () {
if (window.location.hash) {
let curData = window.location.hash.split("#")[1].split(",");
$("#expectedcr").val(curData[0]);
$("#ac").val(curData[1]);
$("#dpr").val(curData[2]);
$("#attackbonus").val(curData[3]);
if (curData[4] === "true") $("#saveinstead").attr("checked", true);
changeSize($("#size").val(curData[5]));
$("#hd").val(curData[6]);
$("#con").val(curData[7]);
$("#hp").val(calculateHp());
if (curData[8] === "true") $("#vulnerabilities").attr("checked", true);
$("#resistances").val(curData[9]);
if (curData[10] === "true") $("#flying").attr("checked", true);
$("#saveprofs").val(curData[11]);
$(`.crc__mon_feature_cb`).each((i, e) => {
const $cb = $(e);
const idCb = $cb.attr("id");
const val = Hist.getSubHash(idCb);
if (val) {
$cb.prop("checked", true);
if (val !== "true") {
$cb.siblings("input[type=number]").val(val);
}
}
});
}
calculateCr();
}
function handleMonsterFeaturesChange ($cbFeature, $iptNum) {
const curFeature = $cbFeature.attr("id");
if ($cbFeature.prop("checked")) {
Hist.setSubhash(curFeature, $iptNum.length ? $iptNum.val() : true);
} else {
Hist.setSubhash(curFeature, null);
}
}
// Monster Features table
$(".crc__mon_feature_cb").change(function () {
const $cbFeature = $(this);
const $iptNum = $(this).siblings("input[type=number]");
handleMonsterFeaturesChange($cbFeature, $iptNum);
});
$(`.crc__mon_feature_num`).change(function () {
const $iptNum = $(this);
const $cbFeature = $(this).siblings("input[type=checkbox]");
handleMonsterFeaturesChange($cbFeature, $iptNum);
});
$("#monsterfeatures .crc__wrp_mon_features input").change(calculateCr);
$("#crcalc_reset").click(() => {
confirm("Are you sure?") && (() => {
window.location = "";
parseUrl();
})();
});
parseUrl();
}
function calculateCr () {
const expectedCr = parseInt($("#expectedcr").val());
let hp = parseInt($("#crcalc #hp").val());
if ($("#vulnerabilities").prop("checked")) hp *= 0.5;
if ($("#resistances").val() === "res") {
if (expectedCr >= 0 && expectedCr <= 4) hp *= 2;
if (expectedCr >= 5 && expectedCr <= 10) hp *= 1.5;
if (expectedCr >= 11 && expectedCr <= 16) hp *= 1.25;
}
if ($("#resistances").val() === "imm") {
if (expectedCr >= 0 && expectedCr <= 4) hp *= 2;
if (expectedCr >= 5 && expectedCr <= 10) hp *= 2;
if (expectedCr >= 11 && expectedCr <= 16) hp *= 1.5;
if (expectedCr >= 17) hp *= 1.25;
}
let ac = parseInt($("#crcalc #ac").val()) + parseInt($("#saveprofs").val()) + parseInt($("#flying").prop("checked") * 2);
let dpr = parseInt($("#crcalc #dpr").val());
let attackBonus = parseInt($("#crcalc #attackbonus").val());
const useSaveDc = $("#saveinstead").prop("checked");
let offensiveCR = -1;
let defensiveCR = -1;
// go through monster features
$("#monsterfeatures input:checked").each(function () {
// `trait` is used within the "eval"s below
let trait = 0;
if ($(this).siblings("input[type=number]").length) trait = $(this).siblings("input[type=number]").val();
/* eslint-disable */
if ($(this).attr("data-hp") !== "") hp += Number(eval($(this).attr("data-hp")));
if ($(this).attr("data-ac") !== "") ac += Number(eval($(this).attr("data-ac")));
if ($(this).attr("data-dpr") !== "") dpr += Number(eval($(this).attr("data-dpr")));
/* eslint-enable */
if (!useSaveDc && $(this).attr("data-attackbonus") !== "") attackBonus += Number($(this).attr("data-attackbonus"));
});
hp = Math.floor(hp);
dpr = Math.floor(dpr);
const effectiveHp = hp;
const effectiveDpr = dpr;
// make sure we don't break the CR
if (hp > 850) hp = 850;
if (dpr > 320) dpr = 320;
for (let i = 0; i < msbcr.cr.length; i++) {
const curCr = msbcr.cr[i];
if (hp >= parseInt(curCr.hpMin) && hp <= parseInt(curCr.hpMax)) {
let defenseDifference = parseInt(curCr.ac) - ac;
if (defenseDifference > 0) defenseDifference = Math.floor(defenseDifference / 2);
if (defenseDifference < 0) defenseDifference = Math.ceil(defenseDifference / 2);
defenseDifference = i - defenseDifference;
if (defenseDifference < 0) defenseDifference = 0;
if (defenseDifference >= msbcr.cr.length) defenseDifference = msbcr.cr.length - 1;
defensiveCR = msbcr.cr[defenseDifference]._cr;
}
if (dpr >= curCr.dprMin && dpr <= curCr.dprMax) {
let adjuster = parseInt(curCr.attackBonus);
if (useSaveDc) adjuster = parseInt(curCr.saveDc);
let attackDifference = adjuster - attackBonus;
if (attackDifference > 0) attackDifference = Math.floor(attackDifference / 2);
if (attackDifference < 0) attackDifference = Math.ceil(attackDifference / 2);
attackDifference = i - attackDifference;
if (attackDifference < 0) attackDifference = 0;
if (attackDifference >= msbcr.cr.length) attackDifference = msbcr.cr.length - 1;
offensiveCR = msbcr.cr[attackDifference]._cr;
}
}
if (offensiveCR === -1) offensiveCR = "0";
if (defensiveCR === -1) defensiveCR = "0";
let cr = ((fractionStrToDecimal(offensiveCR) + fractionStrToDecimal(defensiveCR)) / 2).toString();
if (cr === "0.5625") cr = "1/2";
if (cr === "0.5") cr = "1/2";
if (cr === "0.375") cr = "1/4";
if (cr === "0.3125") cr = "1/4";
if (cr === "0.25") cr = "1/4";
if (cr === "0.1875") cr = "1/8";
if (cr === "0.125") cr = "1/8";
if (cr === "0.0625") cr = "1/8";
if (cr.indexOf(".") !== -1) cr = Math.round(cr).toString();
let finalCr = 0;
for (let i = 0; i < msbcr.cr.length; i++) {
if (msbcr.cr[i]._cr === cr) {
finalCr = i;
break;
}
}
const hitDice = calculateHd();
const hitDiceSize = $("#hdval").html();
const conMod = Parser.getAbilityModNumber($("#con").val());
const hashParts = [
$("#expectedcr").val(), // 0
$("#ac").val(), // 1
$("#dpr").val(), // 2
$("#attackbonus").val(), // 3
useSaveDc, // 4
$("#size").val(), // 5
$("#hd").val(), // 6
$("#con").val(), // 7
$("#vulnerabilities").prop("checked"), // 8
$("#resistances").val(), // 9
$("#flying").prop("checked"), // 10
$("#saveprofs").val(), // 11
$(`.crc__mon_feature_cb`).map((i, e) => {
const $cb = $(e);
if ($cb.prop("checked")) {
const $iptNum = $cb.siblings("input[type=number]");
return `${$cb.attr("id")}:${$iptNum.length ? $iptNum.val() : true}`;
} else return false;
}).get().filter(Boolean).join(","),
];
window.location = `#${hashParts.join(",")}`;
$("#croutput").html(`
<h4>Challenge Rating: ${cr}</h4>
<p>Offensive CR: ${offensiveCR}</p>
<p>Defensive CR: ${defensiveCR}</p>
<p>Proficiency Bonus: +${msbcr.cr[finalCr].pb}</p>
<p>Effective HP: ${effectiveHp} (${hitDice}${hitDiceSize}${conMod < 0 ? "" : "+"}${conMod * hitDice})</p>
<p>Effective AC: ${ac}</p>
<p>Average Damage Per Round: ${effectiveDpr}</p>
<p>${useSaveDc ? "Save DC: " : "Effective Attack Bonus: +"}${attackBonus}</p>
<p>Experience Points: ${Parser.crToXp(msbcr.cr[finalCr]._cr)}</p>
`);
}
function calculateHd () {
const avgHp = $("#hdval").html().split("d")[1] / 2 + 0.5;
const conMod = Parser.getAbilityModNumber($("#con").val());
let curHd = Math.round(parseInt($("#hp").val()) / (avgHp + conMod));
if (!curHd) curHd = 1;
return curHd;
}
function calculateHp () {
const avgHp = $("#hdval").html().split("d")[1] / 2 + 0.5;
const conMod = Parser.getAbilityModNumber($("#con").val());
return Math.floor((avgHp + conMod) * $("#hd").val());
}
function fractionStrToDecimal (str) {
return str === "0" ? 0 : parseFloat(str.split("/").reduce((numerator, denominator) => numerator / denominator));
}

114
js/cultsboons.js Normal file
View File

@@ -0,0 +1,114 @@
"use strict";
class CultsBoonsSublistManager extends SublistManager {
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Type",
css: "col-2 ve-text-center pl-0",
colStyle: "text-center",
}),
new SublistCellTemplate({
name: "Subtype",
css: "col-2 ve-text-center",
colStyle: "text-center",
}),
new SublistCellTemplate({
name: "Name",
css: "bold col-8 pr-0",
colStyle: "",
}),
];
}
pGetSublistItem (it, hash) {
const cellsText = [it._lType, it._lSubType, it.name];
const $ele = $(`<div class="lst__row lst__row--sublist ve-flex-col">
<a href="#${hash}" class="lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText})}
</a>
</div>`)
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._listSub.doSelect(listItem, evt));
const listItem = new ListItem(
hash,
$ele,
it.name,
{
hash,
type: it._lType,
subType: it._lSubType,
},
{
entity: it,
mdRow: [...cellsText],
},
);
return listItem;
}
}
class CultsBoonsPage extends ListPage {
constructor () {
const pageFilter = new PageFilterCultsBoons();
super({
dataSource: "data/cultsboons.json",
pageFilter,
dataProps: ["cult", "boon"],
isMarkdownPopout: true,
});
}
getListItem (it, bcI, isExcluded) {
this._pageFilter.mutateAndAddToFilters(it, isExcluded);
it._lType = it.__prop === "cult" ? "Cult" : "Boon";
it._lSubType = it.type || "\u2014";
const eleLi = document.createElement("div");
eleLi.className = `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`;
const source = Parser.sourceJsonToAbv(it.source);
const hash = UrlUtil.autoEncodeHash(it);
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
<span class="col-2 ve-text-center pl-0">${it._lType}</span>
<span class="col-2 ve-text-center">${it._lSubType}</span>
<span class="bold col-6">${it.name}</span>
<span class="col-2 ve-text-center ${Parser.sourceJsonToColor(it.source)} pr-0" title="${Parser.sourceJsonToFull(it.source)}" ${Parser.sourceJsonToStyle(it.source)}>${source}</span>
</a>`;
const listItem = new ListItem(
bcI,
eleLi,
it.name,
{
hash,
source,
type: it._lType,
subType: it._lSubType,
},
{
isExcluded,
},
);
eleLi.addEventListener("click", (evt) => this._list.doSelect(listItem, evt));
eleLi.addEventListener("contextmenu", (evt) => this._openContextMenu(evt, this._list, listItem));
return listItem;
}
_renderStats_doBuildStatsTab ({ent}) {
this._$pgContent.empty().append(RenderCultsBoons.$getRenderedCultBoon(ent));
}
}
const cultsBoonsPage = new CultsBoonsPage();
cultsBoonsPage.sublistManager = new CultsBoonsSublistManager();
window.addEventListener("load", () => cultsBoonsPage.pOnLoad());

256
js/decks.js Normal file
View File

@@ -0,0 +1,256 @@
"use strict";
class DecksSublistManager extends SublistManager {
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Name",
css: "bold col-12 pl-0",
colStyle: "",
}),
];
}
pGetSublistItem (ent, hash) {
const cellsText = [ent.name];
const $ele = $(`<div class="lst__row lst__row--sublist ve-flex-col">
<a href="#${hash}" class="lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText})}
</a>
</div>`)
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._listSub.doSelect(listItem, evt));
const listItem = new ListItem(
hash,
$ele,
ent.name,
{
hash,
alias: PageFilterDecks.getListAliases(ent),
},
{
entity: ent,
mdRow: [...cellsText],
},
);
return listItem;
}
}
class DecksPageSettingsManager extends ListPageSettingsManager {
_getSettings () {
return {
...RenderDecks.SETTINGS,
};
}
}
class DecksPageCardStateManager extends ListPageStateManager {
static _STORAGE_KEY = "cardState";
async pPruneState ({dataList}) {
const knownHashes = new Set(dataList.map(deck => UrlUtil.autoEncodeHash(deck)));
Object.keys(this._state)
.filter(k => {
const hashDeck = k.split("__").slice(0, -1).join("__");
return !knownHashes.has(hashDeck);
})
.forEach(k => delete this._state[k]);
await this._pPersistState();
}
getPropCardDrawn ({deck, card, hashDeck, ixCard}) {
hashDeck = hashDeck || UrlUtil.autoEncodeHash(deck);
ixCard = ixCard ?? deck.cards.indexOf(card);
return `${hashDeck}__${ixCard}`;
}
async pDrawCard (deck, card) {
this._state[this.getPropCardDrawn({deck, card})] = true;
await this._pPersistState();
}
async pReplaceCard (deck, card) {
delete this._state[this.getPropCardDrawn({deck, card})];
await this._pPersistState();
}
async pResetDeck (deck) {
const hashDeck = UrlUtil.autoEncodeHash(deck);
deck.cards
.forEach((_, ixCard) => delete this._state[this.getPropCardDrawn({hashDeck, ixCard})]);
await this._pPersistState();
}
getUndrawnCards (deck) {
const hashDeck = UrlUtil.autoEncodeHash(deck);
return deck.cards
.filter((_, ixCard) => !this._state[this.getPropCardDrawn({hashDeck, ixCard})]);
}
get (key) { return this._state[key]; }
}
class DecksPage extends ListPage {
constructor () {
const pageFilter = new PageFilterDecks();
super({
dataSource: DataUtil.deck.loadJSON.bind(DataUtil.deck),
prereleaseDataSource: DataUtil.deck.loadPrerelease.bind(DataUtil.deck),
brewDataSource: DataUtil.deck.loadBrew.bind(DataUtil.deck),
pageFilter,
dataProps: ["deck"],
listSyntax: new ListSyntaxDecks({fnGetDataList: () => this._dataList}),
compSettings: new DecksPageSettingsManager(),
});
this._compCardState = new DecksPageCardStateManager();
this._renderFnsCleanup = [];
}
async _pOnLoad_pInitSettingsManager () {
await super._pOnLoad_pInitSettingsManager();
await this._compCardState.pInit();
}
_pOnLoad_pPostLoad () {
this._compCardState.pPruneState({dataList: this._dataList}).then(null);
}
getListItem (ent, anI, isExcluded) {
this._pageFilter.mutateAndAddToFilters(ent, isExcluded);
const eleLi = document.createElement("div");
eleLi.className = `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`;
const source = Parser.sourceJsonToAbv(ent.source);
const hash = UrlUtil.autoEncodeHash(ent);
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
<span class="col-10 bold pl-0">${ent.name}</span>
<span class="col-2 ve-text-center ${Parser.sourceJsonToColor(ent.source)} pr-0" title="${Parser.sourceJsonToFull(ent.source)}" ${Parser.sourceJsonToStyle(ent.source)}>${source}</span>
</a>`;
const listItem = new ListItem(
anI,
eleLi,
ent.name,
{
hash,
source,
},
{
isExcluded,
},
);
eleLi.addEventListener("click", (evt) => this._list.doSelect(listItem, evt));
eleLi.addEventListener("contextmenu", (evt) => this._openContextMenu(evt, this._list, listItem));
return listItem;
}
_renderStats_doBuildStatsTab ({ent}) {
this._renderFnsCleanup
.splice(1, this._renderFnsCleanup.length)
.forEach(fn => fn());
this._$wrpTabs
.find(`[data-name="deck-wrp-controls"]`).remove();
const $wrpControls = $(`<div class="ve-flex mt-auto" data-name="deck-wrp-controls"></div>`)
.prependTo(this._$wrpTabs);
const $btnDraw = $(`<button class="btn btn-xs btn-primary bb-0 bbr-0 bbl-0" title="Draw a Card (SHIFT to Skip Replacement; CTRL to Skip Animation)"><i class="fas fa-fw fa-cards"></i></button>`)
.click(async evt => {
const cards = this._compCardState.getUndrawnCards(ent);
if (!cards.length) return JqueryUtil.doToast({content: "All cards have already been drawn!", type: "warning"});
const card = RollerUtil.rollOnArray(cards);
if (!card._isReplacement || evt.shiftKey) await this._compCardState.pDrawCard(ent, card);
if (EventUtil.isCtrlMetaKey(evt)) {
const $eleChat = $$`<span>Drew card: ${Renderer.get().render(`{@card ${card.name}|${card.set}|${card.source}}`)}</span>`;
Renderer.dice.addRoll({
rolledBy: {
name: ent.name,
},
$ele: $eleChat,
});
return;
}
try {
$btnDraw.prop("disabled", true);
await RenderDecks.pRenderStgCard({deck: ent, card});
} finally {
$btnDraw.prop("disabled", false);
}
});
const $btnReset = $(`<button class="btn btn-xs btn-default bb-0 bbr-0 bbl-0" title="Reset Deck"><i class="fas fa-fw fa-undo-alt"></i></button>`)
.click(async () => {
await this._compCardState.pResetDeck(ent);
JqueryUtil.doToast("Reset deck!");
});
// region List vs Grid view
const $btnViewList = this._compSettings ? $(`<button class="btn btn-xs btn-default bb-0 bbr-0 bbl-0" title="Card List View"><i class="fas fa-fw fa-list"></i></button>`)
.click(() => {
this._compSettings.pSet("cardLayout", "list").then(null);
}) : null;
const $btnViewGrid = this._compSettings ? $(`<button class="btn btn-xs btn-default bb-0 bbr-0 bbl-0" title="Card Grid View"><i class="fas fa-fw fa-grid-2"></i></button>`)
.click(() => {
this._compSettings.pSet("cardLayout", "grid").then(null);
}) : null;
const hkCardLayout = this._compSettings.addHookBase("cardLayout", () => {
const mode = this._compSettings.get("cardLayout");
$btnViewList.toggleClass("active", mode === "list");
$btnViewGrid.toggleClass("active", mode === "grid");
});
this._renderFnsCleanup.push(() => this._compSettings.removeHookBase("cardLayout", hkCardLayout));
hkCardLayout();
// endregion
$$($wrpControls)`<div class="ve-flex">
<div class="ve-flex-v-center btn-group">
${$btnDraw}
${$btnReset}
</div>
<div class="ve-flex-v-center btn-group ml-2">
${$btnViewList}
${$btnViewGrid}
</div>
</div>`;
const {$ele, fnsCleanup} = RenderDecks.getRenderedDeckMeta(
ent,
{
settingsManager: this._compSettings,
cardStateManager: this._compCardState,
},
);
this._renderFnsCleanup.push(...fnsCleanup);
this._$pgContent
.empty()
.append($ele);
}
}
const decksPage = new DecksPage();
decksPage.sublistManager = new DecksSublistManager();
window.addEventListener("load", () => decksPage.pOnLoad());

124
js/deities.js Normal file
View File

@@ -0,0 +1,124 @@
"use strict";
class DeitiesSublistManager extends SublistManager {
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Name",
css: "bold col-4 pl-0",
colStyle: "",
}),
new SublistCellTemplate({
name: "Pantheon",
css: "col-2 ve-text-center",
colStyle: "text-center",
}),
new SublistCellTemplate({
name: "Alignment",
css: "col-2 ve-text-center",
colStyle: "text-center",
}),
new SublistCellTemplate({
name: "Domains",
css: "col-4",
colStyle: "",
}),
];
}
pGetSublistItem (it, hash) {
const alignment = it.alignment ? it.alignment.join("") : "\u2014";
const domains = it.domains.join(", ");
const cellsText = [it.name, it.pantheon, alignment, domains];
const $ele = $(`<div class="lst__row lst__row--sublist ve-flex-col">
<a href="#${hash}" class="lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText})}
</a>
</div>`)
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._listSub.doSelect(listItem, evt));
const listItem = new ListItem(
hash,
$ele,
it.name,
{
hash,
pantheon: it.pantheon,
alignment,
domains,
},
{
entity: it,
mdRow: [...cellsText],
},
);
return listItem;
}
}
class DeitiesPage extends ListPage {
constructor () {
const pageFilter = new PageFilterDeities();
super({
dataSource: DataUtil.deity.loadJSON.bind(DataUtil.deity),
pageFilter,
dataProps: ["deity"],
isMarkdownPopout: true,
});
}
getListItem (g, dtI, isExcluded) {
this._pageFilter.mutateAndAddToFilters(g, isExcluded);
const eleLi = document.createElement("div");
eleLi.className = `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`;
const source = Parser.sourceJsonToAbv(g.source);
const hash = UrlUtil.autoEncodeHash(g);
const alignment = g.alignment ? g.alignment.join("") : "\u2014";
const domains = g.domains.join(", ");
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
<span class="bold col-3 pl-0">${g.name}</span>
<span class="col-2 ve-text-center">${g.pantheon}</span>
<span class="col-2 ve-text-center">${alignment}</span>
<span class="col-3 ${g.domains[0] === VeCt.STR_NONE ? `list-entry-none` : ""}">${domains}</span>
<span class="col-2 ve-text-center ${Parser.sourceJsonToColor(g.source)} pr-0" title="${Parser.sourceJsonToFull(g.source)}" ${Parser.sourceJsonToStyle(g.source)}>${source}</span>
</a>`;
const listItem = new ListItem(
dtI,
eleLi,
g.name,
{
hash,
source,
title: g.title || "",
pantheon: g.pantheon,
alignment,
domains,
},
{
isExcluded,
},
);
eleLi.addEventListener("click", (evt) => this._list.doSelect(listItem, evt));
eleLi.addEventListener("contextmenu", (evt) => this._openContextMenu(evt, this._list, listItem));
return listItem;
}
_renderStats_doBuildStatsTab ({ent}) {
this._$pgContent.empty().append(RenderDeities.$getRenderedDeity(ent));
}
}
const deitiesPage = new DeitiesPage();
deitiesPage.sublistManager = new DeitiesSublistManager();
window.addEventListener("load", () => deitiesPage.pOnLoad());

4034
js/dmscreen.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
export const PANEL_TYP_EMPTY = 0;
export const PANEL_TYP_STATS = 1;
export const PANEL_TYP_ROLLBOX = 2;
export const PANEL_TYP_TEXTBOX = 3;
export const PANEL_TYP_RULES = 4;
export const PANEL_TYP_INITIATIVE_TRACKER = 5;
export const PANEL_TYP_INITIATIVE_TRACKER_CREATURE_VIEWER = 51;
export const PANEL_TYP_UNIT_CONVERTER = 6;
export const PANEL_TYP_CREATURE_SCALED_CR = 7;
export const PANEL_TYP_CREATURE_SCALED_SPELL_SUMMON = 71;
export const PANEL_TYP_CREATURE_SCALED_CLASS_SUMMON = 72;
export const PANEL_TYP_TIME_TRACKER = 8;
export const PANEL_TYP_MONEY_CONVERTER = 9;
export const PANEL_TYP_TUBE = 10;
export const PANEL_TYP_TWITCH = 11;
export const PANEL_TYP_TWITCH_CHAT = 12;
export const PANEL_TYP_ADVENTURES = 13;
export const PANEL_TYP_BOOKS = 14;
export const PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V1 = 15;
export const PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V0 = 151;
export const PANEL_TYP_COUNTER = 16;
export const PANEL_TYP_IMAGE = 20;
export const PANEL_TYP_ADVENTURE_DYNAMIC_MAP = 21;
export const PANEL_TYP_GENERIC_EMBED = 90;
export const PANEL_TYP_ERROR = 98;
export const PANEL_TYP_BLANK = 99;

View File

@@ -0,0 +1,161 @@
export class Counter {
static $getCounter (board, state) {
const $wrpPanel = $(`<div class="w-100 h-100 dm-cnt__root dm__panel-bg dm__data-anchor"/>`) // root class used to identify for saving
.data("getState", () => counters.getSaveableState());
const counters = new CounterRoot(board, $wrpPanel);
counters.setStateFrom(state);
counters.render($wrpPanel);
return $wrpPanel;
}
}
class CounterComponent extends BaseComponent {
constructor (board, $wrpPanel) {
super();
this._board = board;
this._$wrpPanel = $wrpPanel;
this._addHookAll("state", () => this._board.doSaveStateDebounced());
}
}
class CounterRoot extends CounterComponent {
constructor (board, $wrpPanel) {
super(board, $wrpPanel);
this._childComps = [];
this._$wrpRows = null;
}
render ($parent) {
$parent.empty();
const pod = this.getPod();
this._$wrpRows = $$`<div class="ve-flex-col w-100 h-100 overflow-y-auto relative"/>`;
this._childComps.forEach(it => it.render(this._$wrpRows, pod));
const $btnAdd = $(`<button class="btn btn-primary btn-xs"><span class="glyphicon glyphicon-plus"/> Add Counter</button>`)
.click(() => {
const comp = new CounterRow(this._board, this._$wrpPanel);
this._childComps.push(comp);
comp.render(this._$wrpRows, pod);
this._board.doSaveStateDebounced();
});
$$`<div class="w-100 h-100 ve-flex-col px-2 pb-3">
<div class="no-shrink pt-4"/>
${this._$wrpRows}
<div class="no-shrink ve-flex-h-right">${$btnAdd}</div>
</div>`.appendTo($parent);
}
_swapRowPositions (ixA, ixB) {
const a = this._childComps[ixA];
this._childComps[ixA] = this._childComps[ixB];
this._childComps[ixB] = a;
this._childComps.forEach(it => it.$row.detach().appendTo(this._$wrpRows));
this._board.doSaveStateDebounced();
}
_removeRow (comp) {
const ix = this._childComps.indexOf(comp);
if (~ix) {
this._childComps.splice(ix, 1);
comp.$row.remove();
this._board.doSaveStateDebounced();
}
}
getPod () {
const pod = super.getPod();
pod.swapRowPositions = this._swapRowPositions.bind(this);
pod.removeRow = this._removeRow.bind(this);
pod.$getChildren = () => this._childComps.map(comp => comp.$row);
return pod;
}
setStateFrom (toLoad) {
this.setBaseSaveableStateFrom(toLoad);
this._childComps = [];
if (toLoad.rowState) {
toLoad.rowState.forEach(r => {
const comp = new CounterRow(this._board, this._$wrpPanel);
comp.setStateFrom(r);
this._childComps.push(comp);
});
}
}
getSaveableState () {
return {
...this.getBaseSaveableState(),
rowState: this._childComps.map(r => r.getSaveableState()),
};
}
}
class CounterRow extends CounterComponent {
constructor (board, $wrpPanel) {
super(board, $wrpPanel);
this._$row = null;
}
get $row () { return this._$row; }
render ($parent, parent) {
this._parent = parent;
const $iptName = ComponentUiUtil.$getIptStr(this, "name").addClass("mr-2 small-caps");
const $iptCur = ComponentUiUtil.$getIptInt(this, "current", 0, {$ele: $(`<input class="form-control input-xs form-control--minimal ve-text-center dm-cnt__ipt dm-cnt__ipt--cur bold">`)});
const $iptMax = ComponentUiUtil.$getIptInt(this, "max", 0, {$ele: $(`<input class="form-control input-xs form-control--minimal ve-text-center dm-cnt__ipt dm-cnt__ipt--max mr-2 text-muted bold">`)});
const hookDisplayMinMax = () => {
$iptCur.removeClass("text-success text-danger");
if (this._state.current >= this._state.max) $iptCur.addClass("text-success");
else if (this._state.current <= 0) $iptCur.addClass("text-danger");
};
this._addHookBase("current", hookDisplayMinMax);
this._addHookBase("max", hookDisplayMinMax);
hookDisplayMinMax();
const $btnDown = $(`<button class="btn btn-danger btn-xs"><span class="glyphicon glyphicon-minus"/></button>`)
.click(() => this._state.current--);
const $btnUp = $(`<button class="btn btn-success btn-xs"><span class="glyphicon glyphicon-plus"/></button>`)
.click(() => this._state.current++);
const $btnRemove = $(`<button class="btn btn-danger btn-xxs"><span class="glyphicon glyphicon-trash"/></button>`)
.click(() => {
const {removeRow} = this._parent;
removeRow(this);
});
this._$row = $$`<div class="ve-flex-v-center w-100 my-1">
${$iptName}
<div class="relative ve-flex-vh-center">
${$iptCur}
<div class="dm-cnt__slash text-muted ve-text-center">/</div>
${$iptMax}
</div>
<div class="ve-flex btn-group mr-2">
${$btnDown}
${$btnUp}
</div>
${DragReorderUiUtil.$getDragPad2(() => this._$row, $parent, this._parent)}
${$btnRemove}
</div>`.appendTo($parent);
}
_getDefaultState () { return MiscUtil.copy(CounterRow._DEFAULT_STATE); }
}
CounterRow._DEFAULT_STATE = {
name: "",
current: 0,
max: 1,
};

View File

@@ -0,0 +1,209 @@
import {DmScreenUtil} from "./dmscreen-util.js";
import {PANEL_TYP_INITIATIVE_TRACKER} from "./dmscreen-consts.js";
export class InitiativeTrackerCreatureViewer extends BaseComponent {
static $getPanelElement (board, savedState) {
return new this({board, savedState}).render();
}
constructor ({board, savedState}) {
super();
this._board = board;
this._savedState = savedState;
this._trackerLinked = null;
}
/* -------------------------------------------- */
render () {
const $out = $$`<div class="ve-flex-col w-100 h-100 min-h-0 dm__data-anchor">
${this._render_$getStgNoTrackerAvailable()}
${this._render_$getStgConnect()}
${this._render_$getStgCreature()}
</div>`;
this._addHookBase("cntPanelsAvailable", (prop, val, prev) => {
if (prop == null) return;
if (prev || val !== 1) return;
this._setLinkedTrackerFromEle({
$eleData: DmScreenUtil.$getPanelDataElements({board: this._board, type: PANEL_TYP_INITIATIVE_TRACKER})[0],
});
})();
const hkBoardPanels = () => {
const $elesData = DmScreenUtil.$getPanelDataElements({board: this._board, type: PANEL_TYP_INITIATIVE_TRACKER});
this._state.cntPanelsAvailable = $elesData.length;
};
hkBoardPanels();
$out
.data("onDestroy", () => {
if (!this._trackerLinked) return;
this._trackerLinked.doDisconnectCreatureViewer({creatureViewer: this});
this._state.isActive = false;
})
.data("onBoardEvent", ({type, payload = {}}) => {
if (
!(
type === "panelDestroy"
|| (type === "panelPopulate" && payload.type === PANEL_TYP_INITIATIVE_TRACKER)
|| (type === "panelIdSetActive" && payload.type === PANEL_TYP_INITIATIVE_TRACKER)
)
) return;
hkBoardPanels();
});
return $out;
}
_render_$getStgNoTrackerAvailable () {
const $stg = $$`<div class="ve-flex-vh-center w-100 h-100 min-h-0">
<div class="dnd-font italic small-caps ve-muted">No Initiative Tracker available.</div>
</div>`;
const hkIsVisible = () => $stg.toggleVe(!this._state.isActive && !this._state.cntPanelsAvailable);
this._addHookBase("isActive", hkIsVisible);
this._addHookBase("cntPanelsAvailable", hkIsVisible);
hkIsVisible();
return $stg;
}
_render_$getStgConnect () {
const $btnConnect = $(`<button class="btn btn-primary min-w-200p">Connect to Tracker</button>`)
.on("click", async () => {
const $elesData = DmScreenUtil.$getPanelDataElements({board: this._board, type: PANEL_TYP_INITIATIVE_TRACKER});
if ($elesData.length === 1) return this._setLinkedTrackerFromEle({$eleData: $elesData[0]});
const {$modalInner, doClose, pGetResolved} = UiUtil.getShowModal({
isMinHeight0: true,
isHeaderBorder: true,
title: "Select Tracker",
});
const $selTracker = $(`<select class="form-control input-xs mr-1">
<option value="-1" disabled>Select tracker</option>
${$elesData.map(($e, i) => `<option value="${i}">${$e.data("getSummary")()}</option>`).join("")}
</select>`)
.on("change", () => $selTracker.removeClass("error-background"));
const $btnSubmit = $(`<button class="btn btn-primary btn-xs">Connect</button>`)
.on("click", () => {
const ix = Number($selTracker.val());
if (!~ix) {
$selTracker.addClass("error-background");
return;
}
doClose(true, ix);
});
$$($modalInner)`
${$selTracker}
${$btnSubmit}
`;
const ixSel = await pGetResolved();
if (ixSel == null) return;
this._setLinkedTrackerFromEle({$eleData: $elesData[ixSel]});
});
const $stg = $$`<div class="ve-flex-vh-center w-100 h-100 min-h-0">
${$btnConnect}
</div>`;
const hkIsVisible = () => $stg.toggleVe(!this._state.isActive && this._state.cntPanelsAvailable);
this._addHookBase("isActive", hkIsVisible);
this._addHookBase("cntPanelsAvailable", hkIsVisible);
hkIsVisible();
return $stg;
}
_render_$getStgCreature () {
const dispCreature = e_({
tag: "div",
clazz: "ve-flex-col w-100 h-100 min-h-0",
});
const lock = new VeLock({name: "Creature display"});
const hkCreature = async () => {
const mon = (this._state.creatureName && this._state.creatureSource)
? await DmScreenUtil.pGetScaledCreature({
name: this._state.creatureName,
source: this._state.creatureSource,
scaledCr: this._state.creatureScaledCr,
scaledSummonSpellLevel: this._state.creatureScaledSummonSpellLevel,
scaledSummonClassLevel: this._state.creatureScaledSummonClassLevel,
})
: null;
if (!mon) return dispCreature.innerHTML = `<div class="dnd-font italic small-caps ve-muted ve-flex-vh-center w-100 h-100">No active creature.</div>`;
dispCreature.innerHTML = `<table class="w-100 stats"><tbody>${Renderer.monster.getCompactRenderedString(mon, {isShowScalers: false})}</tbody></table>`;
};
this._addHookBase("creaturePulse", async () => {
try {
await lock.pLock();
await hkCreature();
} finally {
lock.unlock();
}
})().then(null);
const $stg = $$`<div class="ve-flex-col w-100 h-100 min-h-0 overflow-y-auto">
${dispCreature}
</div>`;
this._addHookBase("isActive", () => $stg.toggleVe(this._state.isActive))();
return $stg;
}
/* -------------------------------------------- */
_setLinkedTrackerFromEle ({$eleData}) {
this._trackerLinked = $eleData.data("getApi")().doConnectCreatureViewer({creatureViewer: this});
this._state.isActive = true;
}
/* -------------------------------------------- */
setCreatureState (state) {
if (state == null) return;
this._proxyAssignSimple(
"state",
Object.fromEntries(
Object.entries(state)
.map(([k, v]) => [`creature${k.uppercaseFirst()}`, v]),
),
);
// Avoid render spam
this._state.creaturePulse = !this._state.creaturePulse;
}
/* -------------------------------------------- */
_getDefaultState () {
return {
isActive: false,
cntPanelsAvailable: 0,
creatureName: null,
creatureSource: null,
creatureScaledCr: null,
creatureScaledSummonSpellLevel: null,
creatureScaledSummonClassLevel: null,
creaturePulse: false,
};
}
}

View File

@@ -0,0 +1,142 @@
export class DmMapper {
static $getMapper (board, state) {
const $wrpPanel = $(`<div class="w-100 h-100 dm-map__root dm__panel-bg dm__data-anchor"/>`) // root class used to identify for saving
.data("getState", () => mapper.getSaveableState());
const mapper = new DmMapperRoot(board, $wrpPanel);
mapper.setStateFrom(state);
mapper.render($wrpPanel);
return $wrpPanel;
}
static _getProps ({catId}) {
const prop = catId === Parser.CAT_ID_ADVENTURE ? "adventure" : "book";
return {prop, propData: `${prop}Data`};
}
static async pHandleMenuButtonClick (menu) {
const chosenDoc = await SearchWidget.pGetUserAdventureBookSearch({
fnFilterResults: doc => doc.hasMaps,
contentIndexName: "entity_AdventuresBooks_maps",
pFnGetDocExtras: async ({doc}) => {
// Load the adventure/book, and scan it for maps
const {propData} = this._getProps({catId: doc.c});
const {page, source, hash} = SearchWidget.docToPageSourceHash(doc);
const adventureBookPack = await DataLoader.pCacheAndGet(page, source, hash);
let hasMaps = false;
const walker = MiscUtil.getWalker({
isBreakOnReturn: true,
keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST,
isNoModification: true,
});
walker.walk(
adventureBookPack[propData],
{
object: (obj) => {
if (obj.type === "image" && obj.mapRegions?.length) return hasMaps = true;
},
},
);
return {hasMaps};
},
});
if (!chosenDoc) return;
menu.doClose();
const {$modalInner, doClose} = UiUtil.getShowModal({
title: `Select Map\u2014${chosenDoc.n}`,
isWidth100: true,
isHeight100: true,
isUncappedHeight: true,
});
$modalInner.append(`<div class="ve-flex-vh-center w-100 h-100"><i class="dnd-font ve-muted">Loading...</i></div>`);
const {page, source, hash} = SearchWidget.docToPageSourceHash(chosenDoc);
const adventureBookPack = await DataLoader.pCacheAndGet(page, source, hash);
const mapDatas = [];
const walker = MiscUtil.getWalker();
const {prop, propData} = this._getProps({catId: chosenDoc.c});
adventureBookPack[propData].data.forEach((chap, ixChap) => {
let cntChapImages = 0;
const handlers = {
object (obj) {
if (obj.mapRegions) {
const out = {
...Renderer.get().getMapRegionData(obj),
page: chosenDoc.q,
source: adventureBookPack[prop].source,
hash: UrlUtil.URL_TO_HASH_BUILDER[chosenDoc.q](adventureBookPack[prop]),
};
mapDatas.push(out);
if (obj.title) {
out.name = Renderer.stripTags(obj.title);
} else {
out.name = `${(adventureBookPack[prop].contents[ixChap] || {}).name || "(Unknown)"}, Map ${cntChapImages + 1}`;
}
cntChapImages++;
}
return obj;
},
};
walker.walk(
chap,
handlers,
);
});
if (!mapDatas.length) {
$modalInner
.empty()
.append(`<div class="ve-flex-vh-center w-100 h-100"><span class="dnd-font">Adventure did not contain any valid maps!</span></div>`);
return;
}
$modalInner
.empty()
.removeClass("ve-flex-col")
.addClass("ve-text-center");
mapDatas.map(mapData => {
$(`<div class="m-1 p-1 clickable dm-map__picker-wrp-img relative">
<div class="dm-map__picker-img" style="background-image: url(${encodeURI(mapData.hrefThumbnail || mapData.href)})"></div>
<span class="absolute ve-text-center dm-map__picker-disp-name">${mapData.name.escapeQuotes()}</span>
</div>`)
.click(() => {
doClose();
menu.pnl.doPopulate_AdventureBookDynamicMap({state: mapData});
})
.appendTo($modalInner);
});
}
}
class DmMapperRoot extends BaseComponent {
/**
* @param board DM Screen board.
* @param $wrpPanel Panel wrapper element for us to populate.
*/
constructor (board, $wrpPanel) {
super();
this._board = board;
this._$wrpPanel = $wrpPanel;
}
render ($parent) {
$parent.empty();
$parent.append(`<div class="ve-flex-vh-center w-100 h-100"><i class="dnd-font ve-muted">Loading...</i></div>`);
RenderMap.$pGetRendered(this._state)
.then($ele => $parent.empty().append($ele));
}
}

View File

@@ -0,0 +1,224 @@
// a simple money converter, i.e.: input x electrum, y silver, z copper and get the total in gold, or in any other type of coin chosen.
export class MoneyConverter {
static make$Converter (board, state) {
const disabledCurrency = state.d || {};
const COIN_WEIGHT = 0.02;
const CURRENCY = [
new MoneyConverterUnit("Copper", 1, "cp"),
new MoneyConverterUnit("Silver", 10, "sp"),
new MoneyConverterUnit("Electrum", 50, "ep"),
new MoneyConverterUnit("Gold", 100, "gp"),
new MoneyConverterUnit("Platinum", 1000, "pp"),
new MoneyConverterUnit("Nib (WDH)", 1, "nib"),
new MoneyConverterUnit("Shard (WDH)", 10, "shard"),
new MoneyConverterUnit("Taol (WDH)", 200, "taol"),
new MoneyConverterUnit("Dragon (WDH)", 100, "dgn"),
new MoneyConverterUnit("Sun (WDH)", 1000, "sun"),
new MoneyConverterUnit("Harbor Moon (WDH)", 5000, "moon"),
];
const CURRENCY_INDEXED = [...CURRENCY].map((it, i) => {
it.ix = i;
return it;
}).reverse();
const DEFAULT_CURRENCY = 3;
const $wrpConverter = $(`<div class="dm_money dm__panel-bg split-column"/>`);
const doUpdate = () => {
if (!$wrpRows.find(`.dm-money__row`).length) {
addRow();
}
Object.entries(disabledCurrency).forEach(([currency, disabled]) => {
$selOut.find(`option[value=${currency}]`).toggle(!disabled);
});
// if the current choice is disabled, deselect it, and restart
if (disabledCurrency[$selOut.val()]) {
$selOut.val("-1");
doUpdate();
return;
}
const $rows = $wrpRows.find(`.dm-money__row`)
.removeClass("form-control--error");
$iptSplit.removeClass("form-control--error");
const outCurrency = Number($selOut.val()) || 0;
const outParts = [];
let totalWeight = 0;
const splitBetweenStr = ($iptSplit.val() || "").trim();
let split = 1;
if (splitBetweenStr) {
const splitBetweenNum = Number(splitBetweenStr);
if (isNaN(splitBetweenNum)) $iptSplit.addClass("form-control--error");
else split = splitBetweenNum;
}
if (outCurrency === -1) { // only split, don't convert
const totals = [];
const extras = [];
const allowedCategories = new Set();
$rows.each((i, e) => {
const $e = $(e);
const strVal = ($e.find(`input`).val() || "").trim();
if (strVal) {
const asNum = Number(strVal);
if (isNaN(asNum)) $e.addClass("form-control--error");
else {
const ix = Number($e.find(`select`).val());
totals[ix] = (totals[ix] || 0) + asNum;
allowedCategories.add(CURRENCY[ix]._cat);
}
}
});
if (split > 1) {
CURRENCY_INDEXED.forEach((c, i) => {
const it = totals[c.ix];
if (it) {
let remainder = (it % split) * c.mult;
totals[c.ix] = Math.floor(it / split);
for (let j = i + 1; j < CURRENCY_INDEXED.length; ++j) {
const nxtCurrency = CURRENCY_INDEXED[j];
// skip and convert to a smaller denomination as required
if (disabledCurrency[nxtCurrency.ix]) continue;
if (remainder >= nxtCurrency.mult) {
totals[nxtCurrency.ix] = (totals[nxtCurrency.ix] || 0) + Math.floor(remainder / nxtCurrency.mult);
remainder %= nxtCurrency.mult;
}
}
}
});
}
CURRENCY_INDEXED.forEach(c => {
const it = totals[c.ix] || 0;
const itExtra = extras[c.ix] || 0;
if (it || itExtra) {
const val = it + itExtra;
totalWeight += val * COIN_WEIGHT;
outParts.push(`${val.toLocaleString()} ${c.abbv}`);
}
});
} else {
let total = 0;
$rows.each((i, e) => {
const $e = $(e);
const strVal = ($e.find(`input`).val() || "").trim();
if (strVal) {
const asNum = Number(strVal);
if (isNaN(asNum)) $e.addClass("form-control--error");
else {
total += asNum * (CURRENCY[$e.find(`select`).val()] || CURRENCY[0]).mult;
}
}
});
const totalSplit = Math.floor(total / split);
const toCurrencies = CURRENCY_INDEXED.filter(it => !disabledCurrency[it.ix] && it.ix <= outCurrency);
let copper = totalSplit;
toCurrencies.forEach(c => {
if (copper >= c.mult) {
const remainder = copper % c.mult;
const theseCoins = Math.floor(copper / c.mult);
totalWeight += COIN_WEIGHT * theseCoins;
copper = remainder;
outParts.push(`${theseCoins.toLocaleString()} ${c.abbv}`);
}
});
}
$iptOut.val(`${outParts.join("; ")}${totalWeight ? ` (${totalWeight.toLocaleString()} lb.)` : ""}`);
board.doSaveStateDebounced();
};
const buildCurrency$Select = (isOutput) => $(`<select class="form-control input-sm" style="padding: 5px">${isOutput ? `<option value="-1">(No conversion)</option>` : ""}${CURRENCY.map((c, i) => `<option value="${i}">${c.n}</option>`).join("")}</select>`);
const addRow = (currency, count) => {
const $row = $(`<div class="dm-money__row"/>`).appendTo($wrpRows);
const $iptCount = $(`<input type="number" step="1" placeholder="Coins" class="form-control input-sm">`).appendTo($row).change(doUpdate);
if (count != null) $iptCount.val(count);
const $selCurrency = buildCurrency$Select().appendTo($row).change(doUpdate);
$selCurrency.val(currency == null ? DEFAULT_CURRENCY : currency);
const $btnRemove = $(`<button class="btn btn-sm btn-danger" title="Remove Row"><span class="glyphicon glyphicon-trash"></span></button>`).appendTo($row).click(() => {
$row.remove();
doUpdate();
});
};
const $wrpRows = $(`<div class="dm-money__rows"/>`).appendTo($wrpConverter);
const $wrpCtrl = $(`<div class="split dm-money__ctrl"/>`).appendTo($wrpConverter);
const $wrpCtrlLhs = $(`<div class="dm-money__ctrl__lhs split-child" style="width: 66%;"/>`).appendTo($wrpCtrl);
const $wrpBtnAddSettings = $(`<div class="split"/>`).appendTo($wrpCtrlLhs);
const $btnAddRow = $(`<button class="btn btn-primary btn-sm" title="Add Row"><span class="glyphicon glyphicon-plus"/></button>`)
.appendTo($wrpBtnAddSettings)
.click(() => {
addRow();
doUpdate();
});
const $btnSettings = $(`<button class="btn btn-default btn-sm" title="Settings"><span class="glyphicon glyphicon-cog"/></button>`)
.appendTo($wrpBtnAddSettings)
.click(() => {
const {$modalInner} = UiUtil.getShowModal({
title: "Settings",
cbClose: () => doUpdate(),
});
[...CURRENCY_INDEXED].reverse().forEach(cx => {
UiUtil.$getAddModalRowCb($modalInner, `Disable ${cx.n} in Output`, disabledCurrency, cx.ix);
});
});
const $iptOut = $(`<input class="form-control input-sm dm-money__out" disabled/>`)
.appendTo($wrpCtrlLhs)
.mousedown(async () => {
await MiscUtil.pCopyTextToClipboard($iptOut.val());
JqueryUtil.showCopiedEffect($iptOut);
});
const $wrpCtrlRhs = $(`<div class="dm-money__ctrl__rhs split-child" style="width: 33%;"/>`).appendTo($wrpCtrl);
const $iptSplit = $(`<input type="number" min="1" step="1" placeholder="Split Between..." class="form-control input-sm">`).appendTo($wrpCtrlRhs).change(doUpdate);
const $selOut = buildCurrency$Select(true).appendTo($wrpCtrlRhs).change(doUpdate);
$wrpConverter.data("getState", () => {
return {
c: $selOut.val(),
s: $iptSplit.val(),
r: $wrpRows.find(`.dm-money__row`).map((i, e) => {
const $e = $(e);
return {
c: $e.find(`select`).val(),
n: $e.find(`input`).val(),
};
}).get(),
d: disabledCurrency,
};
});
if (state) {
$selOut.val(state.c == null ? DEFAULT_CURRENCY : state.c);
$iptSplit.val(state.s);
(state.r || []).forEach(r => addRow(r.c, r.n));
}
doUpdate();
return $wrpConverter;
}
}
class MoneyConverterUnit {
constructor (name, multiplier, abbreviation) {
this.n = name;
this.mult = multiplier;
this.abbv = abbreviation;
}
}

View File

@@ -0,0 +1,165 @@
import {
PANEL_TYP_INITIATIVE_TRACKER, PANEL_TYP_INITIATIVE_TRACKER_CREATURE_VIEWER,
PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V0,
PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V1,
} from "./dmscreen-consts.js";
import {InitiativeTracker} from "./initiativetracker/dmscreen-initiativetracker.js";
import {InitiativeTrackerPlayerV0, InitiativeTrackerPlayerV1} from "./dmscreen-playerinitiativetracker.js";
import {InitiativeTrackerCreatureViewer} from "./dmscreen-initiativetrackercreatureviewer.js";
export class PanelContentManagerFactory {
static _PANEL_TYPES = {};
static registerPanelType ({panelType, Cls}) {
this._PANEL_TYPES[panelType] = Cls;
}
/* -------------------------------------------- */
static async pFromSavedState ({board, saved, ixTab, panel}) {
if (!this._PANEL_TYPES[saved.t]) return undefined;
const ContentManager = new this._PANEL_TYPES[saved.t]({board, panel});
await ContentManager.pLoadState({ixTab, saved});
return true;
}
/* -------------------------------------------- */
static getSaveableContent (
{
type,
toSaveTitle,
$content,
},
) {
if (!this._PANEL_TYPES[type]) return undefined;
return this._PANEL_TYPES[type]
.getSaveableContent({
type,
toSaveTitle,
$content,
});
}
}
/* -------------------------------------------- */
class _PanelContentManager {
static _PANEL_TYPE = null;
static _TITLE = null;
static _IS_STATELESS = false;
static _register () {
PanelContentManagerFactory.registerPanelType({panelType: this._PANEL_TYPE, Cls: this});
return null;
}
static getSaveableContent (
{
type,
toSaveTitle,
$content,
},
) {
return {
t: type,
r: toSaveTitle,
s: this._IS_STATELESS
? {}
: $($content.children()[0]).data("getState")(),
};
}
/* -------------------------------------------- */
constructor (
{
board,
panel,
},
) {
this._board = board;
this._panel = panel;
}
/* -------------------------------------------- */
/**
* @abstract
* @return {jQuery}
*/
_$getPanelElement ({state}) {
throw new Error("Unimplemented!");
}
async pDoPopulate ({state = {}, title = null} = {}) {
this._panel.set$ContentTab(
this.constructor._PANEL_TYPE,
state,
$(`<div class="panel-content-wrapper-inner"></div>`).append(this._$getPanelElement({state})),
title || this.constructor._TITLE,
true,
);
this._board.fireBoardEvent({type: "panelPopulate", payload: {type: this.constructor._PANEL_TYPE}});
}
_doHandleTabRenamed ({ixTab, saved}) {
if (saved.r != null) this._panel.tabDatas[ixTab].tabRenamed = true;
}
async pLoadState ({ixTab, saved}) {
await this.pDoPopulate({state: saved.s, title: saved.r});
this._doHandleTabRenamed({ixTab, saved});
}
}
export class PanelContentManager_InitiativeTracker extends _PanelContentManager {
static _PANEL_TYPE = PANEL_TYP_INITIATIVE_TRACKER;
static _TITLE = "Initiative Tracker";
static _ = this._register();
_$getPanelElement ({state}) {
return InitiativeTracker.$getPanelElement(this._board, state);
}
}
export class PanelContentManager_InitiativeTrackerCreatureViewer extends _PanelContentManager {
static _PANEL_TYPE = PANEL_TYP_INITIATIVE_TRACKER_CREATURE_VIEWER;
static _TITLE = "Creature Viewer";
static _IS_STATELESS = true;
static _ = this._register();
_$getPanelElement ({state}) {
return InitiativeTrackerCreatureViewer.$getPanelElement(this._board, state);
}
}
export class PanelContentManager_InitiativeTrackerPlayerViewV1 extends _PanelContentManager {
static _PANEL_TYPE = PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V1;
static _TITLE = "Initiative Tracker";
static _IS_STATELESS = true;
static _ = this._register();
_$getPanelElement ({state}) {
return InitiativeTrackerPlayerV1.$getPanelElement(this._board, state);
}
}
export class PanelContentManager_InitiativeTrackerPlayerViewV0 extends _PanelContentManager {
static _PANEL_TYPE = PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V0;
static _TITLE = "Initiative Tracker";
static _IS_STATELESS = true;
static _ = this._register();
_$getPanelElement ({state}) {
return InitiativeTrackerPlayerV0.$getPanelElement(this._board, state);
}
}

View File

@@ -0,0 +1,323 @@
import {PANEL_TYP_INITIATIVE_TRACKER} from "./dmscreen-consts.js";
import {
InitiativeTrackerPlayerMessageHandlerV0,
InitiativeTrackerPlayerMessageHandlerV1,
InitiativeTrackerPlayerUiV0,
InitiativeTrackerPlayerUiV1,
} from "../initiativetracker/initiativetracker-player.js";
import {DmScreenUtil} from "./dmscreen-util.js";
// region v1
export class InitiativeTrackerPlayerV1 {
static $getPanelElement (board, state) {
const $meta = $(`<div class="initp__meta"/>`).hide();
const $head = $(`<div class="initp__header"/>`).hide();
const $rows = $(`<div class="ve-flex-col"></div>`).hide();
const $wrpTracker = $$`<div class="initp__wrp_active">
${$meta}
${$head}
${$rows}
</div>`;
const view = new InitiativeTrackerPlayerMessageHandlerScreenV1();
view.setElements($meta, $head, $rows);
let ui;
const $btnConnectRemote = $(`<button class="btn btn-primary mb-2 min-w-200p" title="Connect to a tracker outside of this browser tab.">Connect to Remote Tracker</button>`)
.click(async () => {
$btnConnectRemote.detach();
$btnConnectLocal.detach();
const $iptPlayerName = $(`<input class="form-control input-sm code">`)
.change(() => $iptPlayerName.removeClass("form-control--error"))
.disableSpellcheck();
const $iptServerToken = $(`<input class="form-control input-sm code">`)
.change(() => $iptServerToken.removeClass("form-control--error"))
.disableSpellcheck();
const $btnGenConnect = $(`<button class="btn btn-primary btn-xs mr-2">Connect</button>`);
const $btnCancel = $(`<button class="btn btn-default btn-xs">Back</button>`)
.click(() => {
// restore original state
$wrpClient.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
});
const $wrpClient = $$`<div class="ve-flex-col w-100">
<div class="ve-flex-vh-center px-4 mb-2">
<span style="min-width: fit-content;" class="mr-2">Player Name</span>
${$iptPlayerName}
</div>
<div class="ve-flex-vh-center px-4 mb-2">
<span style="min-width: fit-content;" class="mr-2">Server Token</span>
${$iptServerToken}
</div>
<div class="split px-4 ve-flex-vh-center">
${$btnGenConnect}${$btnCancel}
</div>
</div>`.appendTo(view.$wrpInitial);
$btnGenConnect.click(async () => {
if (!$iptPlayerName.val().trim()) return $iptPlayerName.addClass("form-control--error");
if (!$iptServerToken.val().trim()) return $iptServerToken.addClass("form-control--error");
try {
$btnGenConnect.attr("disabled", true);
ui = new InitiativeTrackerPlayerUiV1(view, $iptPlayerName.val(), $iptServerToken.val());
await ui.pInit();
InitiativeTrackerPlayerMessageHandlerScreenV1.initUnloadMessage();
} catch (e) {
$btnGenConnect.attr("disabled", false);
JqueryUtil.doToast({content: `Failed to connect. ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
setTimeout(() => { throw e; });
}
});
});
const $btnConnectLocal = $(`<button class="btn btn-primary min-w-200p">Connect to Local Tracker</button>`)
.click(async () => {
const $elesData = DmScreenUtil.$getPanelDataElements({board, type: PANEL_TYP_INITIATIVE_TRACKER});
if (!$elesData.length) return JqueryUtil.doToast({content: "No local trackers detected!", type: "warning"});
if ($elesData.length === 1) {
try {
const token = await $elesData[0].data("pDoConnectLocalV1")(view);
ui = new InitiativeTrackerPlayerUiV1(view, "Local", token);
await ui.pInit();
InitiativeTrackerPlayerMessageHandlerScreenV1.initUnloadMessage();
} catch (e) {
JqueryUtil.doToast({content: `Failed to connect. ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
setTimeout(() => { throw e; });
}
} else {
$btnConnectRemote.detach();
$btnConnectLocal.detach();
const $selTracker = $(`<select class="form-control input-xs mr-1">
<option value="-1" disabled>Select a local tracker</option>
</select>`).change(() => $selTracker.removeClass("form-control--error"));
$elesData.forEach(($e, i) => $selTracker.append(`<option value="${i}">${$e.data("getSummary")()}</option>`));
$selTracker.val("-1");
const $btnOk = $(`<button class="btn btn-primary btn-xs">OK</button>`)
.click(async () => {
// jQuery reads the disabled value as null
if ($selTracker.val() == null) return $selTracker.addClass("form-control--error");
$btnOk.prop("disabled", true);
try {
const token = await $elesData[Number($selTracker.val())].data("pDoConnectLocalV1")(view);
ui = new InitiativeTrackerPlayerUiV1(view, "Local", token);
await ui.pInit();
InitiativeTrackerPlayerMessageHandlerScreenV1.initUnloadMessage();
} catch (e) {
JqueryUtil.doToast({content: `Failed to connect. ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
// restore original state
$btnCancel.remove(); $wrpSel.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
setTimeout(() => { throw e; });
}
});
const $wrpSel = $$`<div class="ve-flex-vh-center mb-2">
${$selTracker}
${$btnOk}
</div>`.appendTo(view.$wrpInitial);
const $btnCancel = $(`<button class="btn btn-default btn-xs">Back</button>`)
.click(() => {
// restore original state
$btnCancel.remove(); $wrpSel.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
})
.appendTo(view.$wrpInitial);
}
});
view.$wrpInitial = $$`<div class="ve-flex-vh-center h-100 ve-flex-col dm__panel-bg">
${$btnConnectRemote}
${$btnConnectLocal}
</div>`.appendTo($wrpTracker);
return $wrpTracker;
}
}
class InitiativeTrackerPlayerMessageHandlerScreenV1 extends InitiativeTrackerPlayerMessageHandlerV1 {
constructor () {
super(true);
this._$wrpInitial = null;
}
initUi () {
if (this._isUiInit) return;
this._isUiInit = true;
this._$meta.show();
this._$head.show();
this._$rows.show();
this._$wrpInitial.addClass("hidden");
}
set $wrpInitial ($wrpInitial) { this._$wrpInitial = $wrpInitial; }
get $wrpInitial () { return this._$wrpInitial; }
static initUnloadMessage () {
$(window).on("beforeunload", evt => {
const message = `The connection will be closed`;
(evt || window.event).message = message;
return message;
});
}
}
// endregion
/// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// region v0
export class InitiativeTrackerPlayerV0 {
static $getPanelElement (board, state) {
const $meta = $(`<div class="initp__meta"/>`).hide();
const $head = $(`<div class="initp__header"/>`).hide();
const $rows = $(`<div class="ve-flex-col"></div>`).hide();
const $wrpTracker = $$`<div class="initp__wrp_active">
${$meta}
${$head}
${$rows}
</div>`;
const view = new InitiativeTrackerPlayerMessageHandlerScreenV0();
view.setElements($meta, $head, $rows);
const $btnConnectRemote = $(`<button class="btn btn-primary mb-2 min-w-200p" title="Connect to a tracker outside of this browser tab.">Connect to Remote Tracker</button>`)
.click(() => {
$btnConnectRemote.detach();
$btnConnectLocal.detach();
const $iptServerToken = $(`<input class="form-control input-sm code">`).disableSpellcheck();
const $btnGenClientToken = $(`<button class="btn btn-primary btn-xs">Generate Client Token</button>`);
const $iptClientToken = $(`<input class="form-control input-sm code copyable">`).disableSpellcheck();
const $btnCancel = $(`<button class="btn btn-default btn-xs">Back</button>`)
.click(() => {
// restore original state
$wrpClient.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
});
const $wrpClient = $$`<div class="ve-flex-col w-100">
<div class="ve-flex-vh-center px-4 mb-2">
<span style="min-width: fit-content;" class="mr-2">Server Token</span>
${$iptServerToken}
</div>
<div class="ve-flex-v-center ve-flex-h-right px-4 mb-2">
${$btnGenClientToken}
</div>
<div class="ve-flex-vh-center px-4 mb-2">
<span style="min-width: fit-content;" class="mr-2">Client Token</span>
${$iptClientToken}
</div>
<div class="ve-flex-vh-center px-4">
${$btnCancel}
</div>
</div>`.appendTo(view.$wrpInitial);
const ui = new InitiativeTrackerPlayerUiV0(view, $iptServerToken, $btnGenClientToken, $iptClientToken);
ui.init();
});
const $btnConnectLocal = $(`<button class="btn btn-primary min-w-200p" title="Connect to a tracker in this browser tab.">Connect to Local Tracker</button>`)
.click(async () => {
const $elesData = DmScreenUtil.$getPanelDataElements({board, type: PANEL_TYP_INITIATIVE_TRACKER});
if ($elesData.length) {
if ($elesData.length === 1) {
await $elesData[0].data("pDoConnectLocalV0")(view);
} else {
$btnConnectRemote.detach();
$btnConnectLocal.detach();
const $selTracker = $(`<select class="form-control input-xs mr-1">
<option value="-1" disabled>Select a local tracker</option>
</select>`).change(() => $selTracker.removeClass("error-background"));
$elesData.forEach(($e, i) => $selTracker.append(`<option value="${i}">${$e.data("getSummary")()}</option>`));
$selTracker.val("-1");
const $btnOk = $(`<button class="btn btn-primary btn-xs">OK</button>`)
.click(async () => {
if ($selTracker.val() === "-1") return $selTracker.addClass("error-background");
await $elesData[Number($selTracker.val())].data("pDoConnectLocalV0")(view);
// restore original state
$btnCancel.remove(); $wrpSel.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
});
const $wrpSel = $$`<div class="ve-flex-vh-center mb-2">
${$selTracker}
${$btnOk}
</div>`.appendTo(view.$wrpInitial);
const $btnCancel = $(`<button class="btn btn-default btn-xs">Back</button>`)
.click(() => {
// restore original state
$btnCancel.remove(); $wrpSel.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
})
.appendTo(view.$wrpInitial);
}
} else {
JqueryUtil.doToast({content: "No local trackers detected!", type: "warning"});
}
});
view.$wrpInitial = $$`<div class="ve-flex-vh-center h-100 ve-flex-col dm__panel-bg">
${$btnConnectRemote}
${$btnConnectLocal}
</div>`.appendTo($wrpTracker);
return $wrpTracker;
}
}
class InitiativeTrackerPlayerMessageHandlerScreenV0 extends InitiativeTrackerPlayerMessageHandlerV0 {
constructor () {
super(true);
this._$wrpInitial = null;
}
initUi () {
if (this._isUiInit) return;
this._isUiInit = true;
this._$meta.show();
this._$head.show();
this._$rows.show();
this._$wrpInitial.addClass("hidden");
$(window).on("beforeunload", evt => {
if (this._clientData.client.isActive) {
const message = `The connection will be closed`;
(evt || window.event).message = message;
return message;
}
});
}
set $wrpInitial ($wrpInitial) { this._$wrpInitial = $wrpInitial; }
get $wrpInitial () { return this._$wrpInitial; }
}
// endregion

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
export class DmScreenUtil {
// FIXME(Future) switch to a better interface than `data`
static $getPanelDataElements ({board, type, selector = ".dm__data-anchor"}) {
return board.getPanelsByType(type)
.flatMap(it => it.tabDatas.filter(td => td.type === type).map(td => td.$content.find(selector)));
}
/* -------------------------------------------- */
static async pGetScaledCreature ({name, source, scaledCr, scaledSummonSpellLevel, scaledSummonClassLevel}) {
const mon = await DataLoader.pCacheAndGet(
UrlUtil.PG_BESTIARY,
source,
UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY]({name: name, source: source}),
);
if (scaledCr == null && scaledSummonSpellLevel == null && scaledSummonClassLevel == null) return mon;
if (scaledCr != null) return ScaleCreature.scale(mon, scaledCr);
if (scaledSummonSpellLevel != null) return ScaleSpellSummonedCreature.scale(mon, scaledCr);
if (scaledSummonClassLevel != null) return ScaleClassSummonedCreature.scale(mon, scaledCr);
throw new Error(`Should never occur!`);
}
}

View File

@@ -0,0 +1,12 @@
export class InitiativeTrackerConditionUtil {
static getNewRowState ({name, color, turns}) {
return {
id: CryptUtil.uid(),
entity: {
name: name ?? "",
color: color ?? MiscUtil.randomColor(),
turns: turns ?? null,
},
};
}
}

View File

@@ -0,0 +1,241 @@
import {InitiativeTrackerUtil, UtilConditions} from "../../initiativetracker/initiativetracker-utils.js";
import {InitiativeTrackerConditionCustomEdit} from "./dmscreen-initiativetracker-conditioncustom.js";
import {InitiativeTrackerConditionUtil} from "./dmscreen-initiativetracker-condition.js";
class _UtilConditionsCustomView {
static $getBtnCondition ({comp, cbSubmit, cbClick}) {
const $btn = $(`<button class="btn btn-default btn-xs dm-init-cond__btn-cond" title="SHIFT to add with &quot;Unlimited&quot; duration; CTRL to add with 1-turn duration; SHIFT+CTRL to add with 10-turn duration."></button>`)
.on("click", evt => {
cbClick({
name: comp._state.name,
color: comp._state.color,
turns: comp._state.turns,
});
if (evt.shiftKey && EventUtil.isCtrlMetaKey(evt)) return cbSubmit({turns: 10});
if (EventUtil.isCtrlMetaKey(evt)) return cbSubmit({turns: 1});
if (evt.shiftKey) return cbSubmit({turns: null});
});
comp._addHookBase("color", () => $btn.css({"background-color": `${comp._state.color}`}))();
comp._addHookBase("name", () => $btn.text(comp._state.name || "\u00A0"))();
return $btn;
}
}
class _RenderableCollectionConditionsCustomView extends RenderableCollectionGenericRows {
constructor (
{
comp,
$wrpRows,
rdState,
cbDoSubmit,
},
) {
super(comp, "conditionsCustom", $wrpRows);
this._rdState = rdState;
this._cbDoSubmit = cbDoSubmit;
}
_$getWrpRow () {
return $(`<div class="ve-flex-vh-center w-33 my-1"></div>`);
}
/* -------------------------------------------- */
_populateRow ({comp, $wrpRow, entity}) {
_UtilConditionsCustomView.$getBtnCondition({
comp,
cbClick: ({name, color, turns}) => {
this._comp._state.name = name;
this._comp._state.color = color;
this._comp._state.turns = turns;
},
cbSubmit: ({turns}) => {
this._comp._state.turns = turns;
this._cbDoSubmit({rdState: this._rdState});
},
}).appendTo($wrpRow);
}
}
export class InitiativeTrackerConditionAdd extends BaseComponent {
static _RenderState = class {
constructor () {
this.cbDoClose = null;
}
};
constructor ({conditionsCustom}) {
super();
this.__state.conditionsCustom = conditionsCustom;
}
getConditionsCustom () {
return MiscUtil.copyFast(this._state.conditionsCustom);
}
async pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const {$modalInner, doClose, pGetResolved} = UiUtil.getShowModal({
isMinHeight0: true,
isHeaderBorder: true,
title: "Add Condition",
$titleSplit: this._render_$getBtnEditCustom({rdState}),
});
rdState.cbDoClose = doClose;
$$($modalInner)`
${this._render_$getStgConditionsStandard({rdState})}
<hr class="hr-3">
${this._render_$getStgConditionsCustom({rdState})}
${this._render_$getStgIpts({rdState})}
${this._render_$getStgSubmit({rdState})}
`;
return pGetResolved();
}
_render_$getBtnEditCustom ({rdState}) {
return $(`<button class="btn btn-default btn-xs" title="Manage Custom Conditions"><span class="glyphicon glyphicon-cog"></span></button>`)
.on("click", async () => {
const compEdit = new InitiativeTrackerConditionCustomEdit({conditionsCustom: MiscUtil.copyFast(this._state.conditionsCustom)});
await compEdit.pGetShowModalResults();
this._state.conditionsCustom = compEdit.getConditionsCustom();
});
}
_render_$getStgConditionsStandard ({rdState}) {
const $wrps = InitiativeTrackerUtil.CONDITIONS
.map(cond => {
const $btn = _UtilConditionsCustomView.$getBtnCondition({
comp: BaseComponent.fromObject({
name: cond.name,
color: cond.color,
turns: cond.turns,
}, "*"),
cbClick: ({name, color, turns}) => {
this._state.name = name;
this._state.color = color;
},
cbSubmit: ({turns}) => {
this._state.turns = turns;
this._doSubmit({rdState});
},
});
return $$`<div class="ve-flex-vh-center w-33 my-1">${$btn}</div>`;
});
return $$`
<div class="ve-flex-col w-100 h-100 min-h-0 ve-flex-v-center">
<div class="ve-flex-wrap w-100 h-100 min-h-0 dm-init-cond__wrp-btns">
${$wrps}
</div>
</div>
`;
}
_render_$getStgConditionsCustom ({rdState}) {
const $wrpRows = $(`<div class="ve-flex-wrap w-100 min-h-0 dm-init-cond__wrp-btns"></div>`);
const compRows = new _RenderableCollectionConditionsCustomView({
comp: this,
$wrpRows,
rdState,
cbDoSubmit: this._doSubmit.bind(this),
});
this._addHookBase("conditionsCustom", () => compRows.render())();
const $stg = $$`<div class="ve-flex-col w-100 h-100 min-h-0 ve-flex-v-center">
${$wrpRows}
<hr class="hr-3">
</div>`;
this._addHookBase("conditionsCustom", () => $stg.toggleVe(!!this._state.conditionsCustom.length))();
return $stg;
}
_render_$getStgIpts ({rdState}) {
const $iptName = ComponentUiUtil.$getIptStr(this, "name", {html: `<input class="form-control">`})
.on("keydown", evt => {
if (evt.key !== "Enter") return;
$iptName.trigger("change");
this._doSubmit({rdState});
});
const $iptColor = ComponentUiUtil.$getIptColor(this, "color", {html: `<input class="form-control" type="color">`});
const $iptTurns = ComponentUiUtil.$getIptInt(this, "turns", null, {isAllowNull: true, fallbackOnNaN: null, html: `<input class="form-control" placeholder="Unlimited">`})
.on("keydown", evt => {
if (evt.key !== "Enter") return;
$iptTurns.trigger("change");
this._doSubmit({rdState});
});
const $btnSave = $(`<button class="btn btn-default w-100" title="Save as New Custom Condition"><span class="glyphicon glyphicon-floppy-disk"></span></button>`)
.click(() => {
this._state.conditionsCustom = [
...this._state.conditionsCustom,
InitiativeTrackerConditionUtil.getNewRowState({
name: this._state.name,
color: this._state.color,
turns: this._state.turns,
}),
];
});
return $$`
<div class="ve-flex-v-center mb-2">
<div class="small-caps col-5 pr-1">Name</div>
<div class="small-caps col-2 px-1">Color</div>
<div class="small-caps col-4 px-1">Duration</div>
<div class="col-1 pl-1">&nbsp;</div>
</div>
<div class="ve-flex-v-center mb-3">
<div class="col-5 pr-1">${$iptName}</div>
<div class="col-2 px-1">${$iptColor}</div>
<div class="col-4 px-1">${$iptTurns}</div>
<div class="col-1 pl-1">${$btnSave}</div>
</div>
`;
}
_render_$getStgSubmit ({rdState}) {
const $btnAdd = $(`<button class="btn btn-primary w-100">Set Condition</button>`)
.click(() => this._doSubmit({rdState}));
return $$`
<div class="ve-flex-v-center">
${$btnAdd}
</div>
`;
}
_doSubmit ({rdState}) {
rdState.cbDoClose(
true,
UtilConditions.getDefaultState({
name: this._state.name,
color: this._state.color,
turns: this._state.turns,
}),
);
}
_getDefaultState () {
return {
name: "",
color: MiscUtil.randomColor(),
turns: null,
conditionsCustom: [],
};
}
}

View File

@@ -0,0 +1,112 @@
import {InitiativeTrackerConditionUtil} from "./dmscreen-initiativetracker-condition.js";
class _RenderableCollectionConditionsCustomEdit extends RenderableCollectionGenericRows {
constructor (
{
comp,
$wrpRows,
},
) {
super(comp, "conditionsCustom", $wrpRows);
}
/* -------------------------------------------- */
_populateRow ({comp, $wrpRow, entity}) {
const $iptName = ComponentUiUtil.$getIptStr(comp, "name");
const $iptColor = ComponentUiUtil.$getIptColor(comp, "color")
.addClass("w-100");
const $iptTurns = ComponentUiUtil.$getIptInt(comp, "turns", null, {isAllowNull: true, fallbackOnNaN: null})
.addClass("mr-2")
.placeholder("Unlimited");
const $btnDelete = this._utils.$getBtnDelete({entity});
$$($wrpRow)`
<div class="ve-flex-vh-center w-100 my-1">
<div class="col-5 pr-1 ve-flex-v-center">${$iptName}</div>
<div class="col-2 px-1 ve-flex-v-center">${$iptColor}</div>
<div class="col-5 pr-1 ve-flex-v-center">
${$iptTurns}
<div class="ve-flex-vh-center btn-group">
${$btnDelete}
</div>
</div>
</div>
`;
}
}
export class InitiativeTrackerConditionCustomEdit extends BaseComponent {
static _RenderState = class {
constructor () {
this.cbDoClose = null;
}
};
constructor ({conditionsCustom}) {
super();
this._state.conditionsCustom = conditionsCustom;
}
getConditionsCustom () {
return MiscUtil.copyFast(this._state.conditionsCustom);
}
async pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const {$modalInner, $modalFooter, doClose, pGetResolved} = UiUtil.getShowModal({
title: "Manage Custom Conditions",
isHeaderBorder: true,
hasFooter: true,
});
rdState.cbDoClose = doClose;
const $btnAdd = $(`<button class="btn btn-default btn-xs bb-0 bbr-0 bbl-0" title="Add"><span class="glyphicon glyphicon-plus"></span></button>`)
.on("click", () => {
this._state.conditionsCustom = [...this._state.conditionsCustom, InitiativeTrackerConditionUtil.getNewRowState()];
});
const $wrpRows = $(`<div class="ve-flex-col h-100 min-h-0 overflow-y-auto"></div>`);
const compRows = new _RenderableCollectionConditionsCustomEdit({comp: this, $wrpRows});
this._addHookBase("conditionsCustom", () => compRows.render())();
$$($modalInner)`
<div class="ve-flex-col mt-2 h-100 min-h-0">
<div class="ve-flex-vh-center w-100 mb-2 bb-1p-trans">
<div class="col-5">Name</div>
<div class="col-2">Color</div>
<div class="col-4">Turns</div>
<div class="col-1 ve-flex-v-center ve-flex-h-right">${$btnAdd}</div>
</div>
${$wrpRows}
</div>
`;
$$($modalFooter)`
${this._render_$getFooter({rdState})}
`;
return pGetResolved();
}
_render_$getFooter ({rdState}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => rdState.cbDoClose(true));
return $$`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
_getDefaultState () {
return {
conditionsCustom: [],
};
}
}

View File

@@ -0,0 +1,11 @@
export class InitiativeTrackerConst {
static SORT_ORDER_ALPHA = "ALPHA";
static SORT_ORDER_NUM = "NUMBER";
static SORT_DIR_ASC = "ASC";
static SORT_DIR_DESC = "DESC";
static DIR_FORWARDS = 1;
static DIR_BACKWARDS = -1;
static DIR_NEUTRAL = 0;
}

View File

@@ -0,0 +1,99 @@
import {
InitiativeTrackerRowDataViewDefaultParty,
} from "./dmscreen-initiativetracker-rowsdefaultparty.js";
export class InitiativeTrackerDefaultParty extends BaseComponent {
static _RenderState = class {
constructor () {
this.cbDoClose = null;
this.fnsCleanup = [];
}
};
constructor ({comp, roller, rowStateBuilder}) {
super();
this._comp = comp;
this._roller = roller;
this._rowStateBuilder = rowStateBuilder;
this._prop = "rowsDefaultParty";
this._viewRowsDefaultParty = null;
}
/* -------------------------------------------- */
pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const {$modalInner, $modalFooter, pGetResolved, doClose} = UiUtil.getShowModal({
title: "Edit Default Party",
isHeaderBorder: true,
isUncappedHeight: true,
hasFooter: true,
cbClose: () => rdState.fnsCleanup.forEach(fn => fn()),
$titleSplit: this._render_$getBtnAdd({rdState}),
});
rdState.cbDoClose = doClose;
this._render_renderBody({rdState, $modalInner});
this._render_renderFooter({rdState, $modalFooter});
return pGetResolved();
}
_render_$getBtnAdd ({rdState}) {
return $(`<button class="btn btn-default btn-xs" title="Add Player"><span class="glyphicon glyphicon-plus"></span></button>`)
.on("click", async () => {
this._comp._state[this._prop] = [
...this._comp._state[this._prop],
await this._rowStateBuilder.pGetNewRowState(),
];
});
}
/* -------------------------------------------- */
_render_renderBody ({rdState, $modalInner}) {
this._viewRowsDefaultParty = new InitiativeTrackerRowDataViewDefaultParty({
comp: this._comp,
prop: this._prop,
roller: this._roller,
rowStateBuilder: this._rowStateBuilder,
});
this._viewRowsDefaultPartyMeta = this._viewRowsDefaultParty.getRenderedView();
this._viewRowsDefaultPartyMeta.$ele.appendTo($modalInner);
rdState.fnsCleanup.push(this._viewRowsDefaultPartyMeta.cbDoCleanup);
}
/* -------------------------------------------- */
_render_renderFooter ({rdState, $modalFooter}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => rdState.cbDoClose(true));
$$($modalFooter)`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
/* -------------------------------------------- */
async pGetConvertedDefaultPartyActiveRows () {
const rows = MiscUtil.copyFast(this._comp._state.rowsDefaultParty);
if (!this._comp._state.isRollInit) return rows;
await rows.pSerialAwaitMap(async row => {
const {entity} = row;
const {initiativeModifier} = await this._rowStateBuilder.pGetRowInitiativeMeta({row});
// Skip rolling no-modifier-exists initiative for player rows, as we assume the user wants to input them
// manually.
if (initiativeModifier == null) return;
entity.initiative = await this._roller.pGetRollInitiative({initiativeModifier, name: entity.name});
});
return rows;
}
}

View File

@@ -0,0 +1,206 @@
import {InitiativeTrackerStatColumnFactory} from "./dmscreen-initiativetracker-statcolumns.js";
class _ConvertedEncounter {
constructor () {
this.isStatsAddColumns = false;
this.statsCols = [];
this.rows = [];
}
}
export class InitiativeTrackerEncounterConverter {
constructor (
{
roller,
rowStateBuilderActive,
importIsAddPlayers,
importIsRollGroups,
isRollInit,
isRollHp,
},
) {
this._roller = roller;
this._rowStateBuilderActive = rowStateBuilderActive;
this._importIsAddPlayers = importIsAddPlayers;
this._importIsRollGroups = importIsRollGroups;
this._isRollInit = isRollInit;
this._isRollHp = isRollHp;
}
async pGetConverted ({entityInfos, encounterInfo}) {
const out = new _ConvertedEncounter();
await this._pGetConverted_pPlayers({entityInfos, encounterInfo, out});
await this._pGetConverted_pCreatures({entityInfos, encounterInfo, out});
return out;
}
/* -------------------------------------------- */
async _pGetConverted_pPlayers ({entityInfos, encounterInfo, out}) {
if (!this._importIsAddPlayers) return;
await this._pGetConverted_pPlayers_advanced({entityInfos, encounterInfo, out});
await this._pGetConverted_pPlayers_simple({entityInfos, encounterInfo, out});
}
async _pGetConverted_pPlayers_advanced ({entityInfos, encounterInfo, out}) {
if (!encounterInfo.isAdvanced || !encounterInfo.playersAdvanced) return;
const colNameIndex = {};
encounterInfo.colsExtraAdvanced = encounterInfo.colsExtraAdvanced || [];
if (encounterInfo.colsExtraAdvanced.length) out.isStatsAddColumns = true;
encounterInfo.colsExtraAdvanced.forEach((col, i) => colNameIndex[i] = (col?.name || "").toLowerCase());
const {ixsColLookup, ixExtrasHp, statsCols} = this._pGetConverted_pPlayers_advanced_getExtrasInfo({encounterInfo});
out.statsCols.push(...statsCols);
await encounterInfo.playersAdvanced
.pSerialAwaitMap(async playerDetails => {
out.rows.push(
await this._rowStateBuilderActive
.pGetNewRowState({
isActive: false,
isPlayerVisible: true,
name: playerDetails.name || "",
initiative: null,
conditions: [],
...this._pGetConverted_pPlayers_advanced_extras({
playerDetails,
ixExtrasHp,
ixsColLookup,
}),
}),
);
});
}
_pGetConverted_pPlayers_advanced_getExtrasInfo ({encounterInfo}) {
const statsCols = [];
const ixsColLookup = {};
let ixExtrasHp = null;
encounterInfo.colsExtraAdvanced.forEach((col, i) => {
let colName = col?.name || "";
if (colName.toLowerCase() === "hp") {
ixExtrasHp = i;
return;
}
const newCol = InitiativeTrackerStatColumnFactory.fromEncounterAdvancedColName({colName});
ixsColLookup[i] = newCol;
statsCols.push(newCol);
});
return {ixsColLookup, ixExtrasHp, statsCols};
}
_pGetConverted_pPlayers_advanced_extras ({playerDetails, ixExtrasHp, ixsColLookup}) {
const out = {
hpCurrent: null,
hpMax: null,
};
if (!playerDetails.extras?.length) return out;
const rowStatColData = playerDetails.extras
.map((extra, i) => {
const val = extra?.value || "";
if (i === ixExtrasHp) return null;
const meta = ixsColLookup[i];
return meta.getInitialCellStateData({obj: {value: val}});
})
.filter(Boolean);
if (ixExtrasHp == null) {
return {
...out,
rowStatColData,
};
}
const [hpCurrent, hpMax] = (playerDetails.extras[ixExtrasHp]?.value || "")
.split("/")
.map(it => {
const clean = it.trim();
if (!clean) return null;
if (isNaN(clean)) return null;
return Number(clean);
});
return {
...out,
hpCurrent,
hpMax: hpCurrent != null && hpMax == null ? hpCurrent : hpCurrent,
rowStatColData,
};
}
async _pGetConverted_pPlayers_simple ({entityInfos, encounterInfo, out}) {
if (encounterInfo.isAdvanced || !encounterInfo.playersSimple) return;
await encounterInfo.playersSimple
.pSerialAwaitMap(async playerGroup => {
await [...new Array(playerGroup.count || 1)]
.pSerialAwaitMap(async () => {
out.rows.push(
await this._rowStateBuilderActive
.pGetNewRowState({
name: "",
hpCurrent: null,
hpMax: null,
initiative: null,
isActive: false,
conditions: [],
isPlayerVisible: true,
}),
);
});
});
}
/* -------------------------------------------- */
async _pGetConverted_pCreatures ({entityInfos, encounterInfo, out}) {
if (!entityInfos?.length) return;
await entityInfos
.filter(Boolean)
.pSerialAwaitMap(async entityInfo => {
const groupInit = this._importIsRollGroups && this._isRollInit ? await this._roller.pGetRollInitiative({mon: entityInfo.entity}) : null;
const groupHp = this._importIsRollGroups ? await this._roller.pGetOrRollHp(entityInfo.entity, {isRollHp: this._isRollHp}) : null;
await [...new Array(entityInfo.count || 1)]
.pSerialAwaitMap(async () => {
const hpVal = this._importIsRollGroups
? groupHp
: await this._roller.pGetOrRollHp(entityInfo.entity, {isRollHp: this._isRollHp});
out.rows.push(
await this._rowStateBuilderActive
.pGetNewRowState({
rows: out.rows,
name: entityInfo.entity.name,
displayName: entityInfo.entity._displayName,
scaledCr: entityInfo.entity._scaledCr,
scaledSummonSpellLevel: entityInfo.entity._summonedBySpell_level,
scaledSummonClassLevel: entityInfo.entity._summonedByClass_level,
initiative: this._isRollInit
? this._importIsRollGroups ? groupInit : await this._roller.pGetRollInitiative({mon: entityInfo.entity})
: null,
isActive: false,
source: entityInfo.entity.source,
conditions: [],
hpCurrent: hpVal,
hpMax: hpVal,
}),
);
});
});
}
}

View File

@@ -0,0 +1,68 @@
export class InitiativeTrackerSettingsImport extends BaseComponent {
static _PROPS_TRACKED = [
"isRollInit",
"isRollHp",
"importIsRollGroups",
"importIsAddPlayers",
"importIsAppend",
];
constructor ({state}) {
super();
this._proxyAssignSimple(
"state",
InitiativeTrackerSettingsImport._PROPS_TRACKED
.mergeMap(prop => ({[prop]: state[prop]})),
);
}
/* -------------------------------------------- */
getStateUpdate () {
return MiscUtil.copyFast(this._state);
}
/* -------------------------------------------- */
pGetShowModalResults () {
const {$modalInner, $modalFooter, pGetResolved, doClose} = UiUtil.getShowModal({
title: "Import Settings",
isUncappedHeight: true,
hasFooter: true,
});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_isRolls({$modalInner});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_import({$modalInner});
this._pGetShowModalResults_renderFooter({$modalFooter, doClose});
return pGetResolved();
}
/* -------------------------------------------- */
_pGetShowModalResults_renderSection_isRolls ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollInit", text: "Roll creature initiative"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollHp", text: "Roll creature hit points"});
}
_pGetShowModalResults_renderSection_import ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "importIsRollGroups", text: "Roll groups of creatures together"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "importIsAddPlayers", text: "Add players"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "importIsAppend", text: "Add to existing tracker state"});
}
/* -------------------------------------------- */
_pGetShowModalResults_renderFooter ({$modalFooter, doClose}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => doClose(true));
$$($modalFooter)`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
}

View File

@@ -0,0 +1,380 @@
class _MonstersToLoad {
constructor (
{
count,
name,
source,
isRollHp,
displayName,
customName,
scaledCr,
scaledSummonSpellLevel,
scaledSummonClassLevel,
},
) {
this.count = count;
this.name = name;
this.source = source;
this.isRollHp = isRollHp;
this.displayName = displayName;
this.customName = customName;
this.scaledCr = scaledCr;
this.scaledSummonSpellLevel = scaledSummonSpellLevel;
this.scaledSummonClassLevel = scaledSummonClassLevel;
}
}
class _InitiativeTrackerMonsterAddCustomizer extends BaseComponent {
static _RenderState = class {
constructor () {
this.cbDoClose = null;
}
};
constructor ({mon}) {
super();
this._mon = mon;
}
async pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const {$modalInner, $modalFooter, doClose, pGetResolved} = UiUtil.getShowModal({
title: `Customize Creature \u2014 ${this._mon.name}`,
isHeaderBorder: true,
hasFooter: true,
isMinHeight0: true,
});
rdState.cbDoClose = doClose;
const $iptCustomName = ComponentUiUtil.$getIptStr(this, "customName");
$$($modalInner)`
<div class="ve-flex-col py-2 w-100 h-100 overflow-y-auto">
<label class="split-v-center mb-2">
<span class="w-200p text-right no-shrink mr-2 bold">Custom Name:</span>
${$iptCustomName}
</label>
${this._render_$getRowScaler()}
</div>
`;
$$($modalFooter)`
${this._render_$getFooter({rdState})}
`;
return pGetResolved();
}
_render_$getRowScaler () {
const isShowCrScaler = Parser.crToNumber(this._mon.cr) !== VeCt.CR_UNKNOWN;
const isShowSpellLevelScaler = !isShowCrScaler && this._mon.summonedBySpellLevel != null;
const isShowClassLevelScaler = !isShowSpellLevelScaler && this._mon.summonedByClass != null;
if (!isShowCrScaler && !isShowSpellLevelScaler && !isShowClassLevelScaler) return null;
if (isShowSpellLevelScaler) {
const sel = Renderer.monster.getSelSummonSpellLevel(this._mon)
.on("change", async () => {
const val = Number(sel.val());
this._state.scaledSummonSpellLevel = !~val ? null : val;
if (this._state.scaledSummonSpellLevel == null) return delete this._state.displayName;
this._state.displayName = (await ScaleSpellSummonedCreature.scale(this._mon, this._state.scaledSummonSpellLevel))._displayName;
});
return $$`<label class="split-v-center mb-2">
<span class="w-200p text-right no-shrink mr-2 bold">Spell Level:</span>
${sel}
</label>`;
}
if (isShowClassLevelScaler) {
const sel = Renderer.monster.getSelSummonClassLevel(this._mon)
.on("change", async () => {
const val = Number(sel.val());
this._state.scaledSummonClassLevel = !~val ? null : val;
if (this._state.scaledSummonClassLevel == null) return delete this._state.displayName;
this._state.displayName = (await ScaleClassSummonedCreature.scale(this._mon, this._state.scaledSummonClassLevel))._displayName;
});
return $$`<label class="split-v-center mb-2">
<span class="w-200p text-right no-shrink mr-2 bold">Class Level:</span>
${sel}
</label>`;
}
const $dispScaledCr = $(`<span class="inline-block"></span>`);
this._addHookBase("scaledCr", () => $dispScaledCr.text(this._state.scaledCr ? Parser.numberToCr(this._state.scaledCr) : `${(this._mon.cr.cr || this._mon.cr)} (default)`))();
const $btnScaleCr = $(`<button class="btn btn-default btn-xs mr-2"><span class="glyphicon glyphicon-signal"></span></button>`)
.on("click", async () => {
const crBase = this._mon.cr.cr || this._mon.cr;
const cr = await InputUiUtil.pGetUserScaleCr({default: crBase});
if (cr == null) return;
if (crBase === cr) {
delete this._state.scaledCr;
delete this._state.displayName;
return;
}
this._state.scaledCr = Parser.crToNumber(cr);
this._state.displayName = (await ScaleCreature.scale(this._mon, this._state.scaledCr))._displayName;
});
return $$`<label class="split-v-center mb-2">
<span class="w-200p text-right no-shrink mr-2 bold">CR:</span>
<span class="ve-flex-v-center mr-auto">
${$btnScaleCr}
${$dispScaledCr}
</span>
</label>`;
}
_render_$getFooter ({rdState}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => {
rdState.cbDoClose(
true,
MiscUtil.copyFast(this.__state),
);
});
return $$`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
_getDefaultState () {
return {
customName: null,
displayName: null,
scaledCr: null,
scaledSummonSpellLevel: null,
scaledSummonClassLevel: null,
};
}
}
export class InitiativeTrackerMonsterAdd extends BaseComponent {
static _RESULTS_MAX_DISPLAY = 75; // hard cap at 75 results
static _RenderState = class {
constructor () {
this.cbDoClose = null;
}
};
constructor ({board, isRollHp}) {
super();
this._board = board;
this._state.isRollHp = isRollHp;
}
_getDefaultState () {
return {
isRollHp: false,
cntToAdd: 1,
cntToAddCustom: 13,
};
}
_getCntToAdd () {
return this._state.cntToAdd === -1
? Math.max(1, this._state.cntToAddCustom)
: this._state.cntToAdd;
}
/* -------------------------------------------- */
_$getCbCntToAdd ({cnt}) {
const $cb = $(`<input type="radio" class="ui-search__ipt-search-sub-ipt">`);
$cb.on("change", () => {
this._state.cntToAdd = cnt;
});
this._addHookBase("cntToAdd", () => $cb.prop("checked", this._state.cntToAdd === cnt))();
return $cb;
}
_$getIptCntToAddCustom () {
const $iptCntToAddCustom = ComponentUiUtil.$getIptInt(
this,
"cntToAddCustom",
1,
{
html: `<input type="number" class="form-control ui-search__ipt-search-sub-ipt-custom">`,
min: 1,
},
);
this._addHookBase("cntToAdd", () => {
if (this._state.cntToAdd !== -1) return;
$iptCntToAddCustom.select();
})();
$iptCntToAddCustom.click(() => {
this._state.cntToAdd = -1;
});
return $iptCntToAddCustom;
}
/**
* @return {Promise<[boolean, _MonstersToLoad]>}
*/
async pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const flags = {
doClickFirst: false,
isWait: false,
};
const {$modalInner, doClose, pGetResolved} = UiUtil.getShowModal();
rdState.cbDoClose = doClose;
const $iptSearch = $(`<input class="ui-search__ipt-search search form-control" autocomplete="off" placeholder="Search...">`)
.blurOnEsc();
$$`<div class="split no-shrink">
${$iptSearch}
<div class="ui-search__ipt-search-sub-wrp ve-flex-v-center pr-0">
<div class="mr-1">Add</div>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 1})} 1</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 2})} 2</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 3})} 3</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 5})} 5</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 8})} 8</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: -1})} ${this._$getIptCntToAddCustom()}</label>
</div>
<label class="ui-search__ipt-search-sub-wrp ve-flex-vh-center">${ComponentUiUtil.$getCbBool(this, "isRollHp").addClass("mr-1")} <span>Roll HP</span></label>
</div>`.appendTo($modalInner);
const $results = $(`<div class="ui-search__wrp-results"></div>`).appendTo($modalInner);
const showMsgIpt = () => {
flags.isWait = true;
$results.empty().append(SearchWidget.getSearchEnter());
};
const showMsgDots = () => $results.empty().append(SearchWidget.getSearchLoading());
const showNoResults = () => {
flags.isWait = true;
$results.empty().append(SearchWidget.getSearchNoResults());
};
const $ptrRows = {_: []};
const doSearch = () => {
const searchTerm = $iptSearch.val().trim();
const index = this._board.availContent["Creature"];
const results = index.search(searchTerm, {
fields: {
n: {boost: 5, expand: true},
s: {expand: true},
},
bool: "AND",
expand: true,
});
const resultCount = results.length ? results.length : index.documentStore.length;
const toProcess = results.length ? results : Object.values(index.documentStore.docs).slice(0, 75).map(it => ({doc: it}));
$results.empty();
$ptrRows._ = [];
if (toProcess.length) {
if (flags.doClickFirst) {
this._render_pHandleClickRow({rdState}, toProcess[0]);
flags.doClickFirst = false;
return;
}
const results = toProcess.slice(0, this.constructor._RESULTS_MAX_DISPLAY);
results.forEach(res => {
const $row = this._render_$getSearchRow({rdState, res}).appendTo($results);
SearchWidget.bindRowHandlers({result: res, $row, $ptrRows, fnHandleClick: this._render_pHandleClickRow.bind(this, {rdState}), $iptSearch});
$ptrRows._.push($row);
});
if (resultCount > this.constructor._RESULTS_MAX_DISPLAY) {
const diff = resultCount - this.constructor._RESULTS_MAX_DISPLAY;
$results.append(`<div class="ui-search__row ui-search__row--readonly">...${diff} more result${diff === 1 ? " was" : "s were"} hidden. Refine your search!</div>`);
}
} else {
if (!searchTerm.trim()) showMsgIpt();
else showNoResults();
}
};
SearchWidget.bindAutoSearch($iptSearch, {
flags,
fnSearch: doSearch,
fnShowWait: showMsgDots,
$ptrRows,
});
$iptSearch.focus();
doSearch();
return pGetResolved();
}
async _render_pHandleClickRow ({rdState}, res) {
await rdState.cbDoClose(
true,
new _MonstersToLoad({
count: this._getCntToAdd(),
name: res.doc.n,
source: res.doc.s,
isRollHp: this._state.isRollHp,
}),
);
}
_render_$getSearchRow ({rdState, res}) {
const $btnCustomize = $(`<button class="btn btn-default btn-xxs" title="Customize"><span class="glyphicon glyphicon-stats"></span></button>`)
.on("click", async evt => {
evt.stopPropagation();
await this._render_pHandleClickCustomize({rdState, res});
});
return $$`
<div class="ui-search__row ve-flex-v-center" tabindex="0">
<span>${res.doc.n}</span>
<div class="ve-flex-vh-center">
<span class="mr-2">${res.doc.s ? `<i title="${Parser.sourceJsonToFull(res.doc.s)}">${Parser.sourceJsonToAbv(res.doc.s)}${res.doc.p ? ` p${res.doc.p}` : ""}</i>` : ""}</span>
${$btnCustomize}
</div>
</div>
`;
}
async _render_pHandleClickCustomize ({rdState, res}) {
const mon = await DataLoader.pCacheAndGet(UrlUtil.PG_BESTIARY, res.doc.s, res.doc.u);
if (!mon) return;
const comp = new _InitiativeTrackerMonsterAddCustomizer({mon});
const resModal = await comp.pGetShowModalResults();
if (resModal == null) return;
const [isDataEntered, data] = resModal;
if (!isDataEntered) return;
await rdState.cbDoClose(
true,
new _MonstersToLoad({
count: this._getCntToAdd(),
name: res.doc.n,
source: res.doc.s,
isRollHp: this._state.isRollHp,
...data,
}),
);
}
}

View File

@@ -0,0 +1,561 @@
class _InitiativeTrackerNetworkingP2pMetaV1 {
constructor () {
this.rows = [];
this.serverInfo = null;
this.serverPeer = null;
}
}
class _InitiativeTrackerNetworkingP2pMetaV0 {
constructor () {
this.rows = [];
this.serverInfo = null;
}
}
export class InitiativeTrackerNetworking {
constructor ({board}) {
this._board = board;
this._p2pMetaV1 = new _InitiativeTrackerNetworkingP2pMetaV1();
this._p2pMetaV0 = new _InitiativeTrackerNetworkingP2pMetaV0();
}
/* -------------------------------------------- */
sendStateToClients ({fnGetToSend}) {
return this._sendMessageToClients({fnGetToSend});
}
sendShowImageMessageToClients ({imageHref}) {
return this._sendMessageToClients({
fnGetToSend: () => ({
type: "showImage",
payload: {
imageHref,
},
}),
});
}
_sendMessageToClients ({fnGetToSend}) {
let toSend = null;
// region V1
if (this._p2pMetaV1.serverPeer) {
if (!this._p2pMetaV1.serverPeer.hasConnections()) return;
toSend ||= fnGetToSend();
this._p2pMetaV1.serverPeer.sendMessage(toSend);
}
// endregion
// region V0
if (this._p2pMetaV0.serverInfo) {
this._p2pMetaV0.rows = this._p2pMetaV0.rows.filter(row => !row.isDeleted);
this._p2pMetaV0.serverInfo = this._p2pMetaV0.serverInfo.filter(row => {
if (row.isDeleted) {
row.server.close();
return false;
}
return true;
});
toSend ||= fnGetToSend();
try {
this._p2pMetaV0.serverInfo.filter(info => info.server.isActive).forEach(info => info.server.sendMessage(toSend));
} catch (e) { setTimeout(() => { throw e; }); }
}
// endregion
}
/* -------------------------------------------- */
/**
* @param opts
* @param opts.doUpdateExternalStates
* @param [opts.$btnStartServer]
* @param [opts.$btnGetToken]
* @param [opts.$btnGetLink]
* @param [opts.fnDispServerStoppedState]
* @param [opts.fnDispServerRunningState]
*/
async startServerV1 (opts) {
opts = opts || {};
if (this._p2pMetaV1.serverPeer) {
await this._p2pMetaV1.serverPeer.pInit();
return {
isRunning: true,
token: this._p2pMetaV1.serverPeer?.token,
};
}
try {
if (opts.$btnStartServer) opts.$btnStartServer.prop("disabled", true);
this._p2pMetaV1.serverPeer = new PeerVeServer();
await this._p2pMetaV1.serverPeer.pInit();
if (opts.$btnGetToken) opts.$btnGetToken.prop("disabled", false);
if (opts.$btnGetLink) opts.$btnGetLink.prop("disabled", false);
this._p2pMetaV1.serverPeer.on("connection", connection => {
const pConnected = new Promise(resolve => {
connection.on("open", () => {
resolve(true);
opts.doUpdateExternalStates();
});
});
const pTimeout = MiscUtil.pDelay(5 * 1000, false);
Promise.race([pConnected, pTimeout])
.then(didConnect => {
if (!didConnect) {
JqueryUtil.doToast({content: `Connecting to "${connection.label.escapeQuotes()}" has taken more than 5 seconds! The connection may need to be re-attempted.`, type: "warning"});
}
});
});
$(window).on("beforeunload", evt => {
const message = `The connection will be closed`;
(evt || window.event).message = message;
return message;
});
if (opts.fnDispServerRunningState) opts.fnDispServerRunningState();
return {
isRunning: true,
token: this._p2pMetaV1.serverPeer?.token,
};
} catch (e) {
if (opts.fnDispServerStoppedState) opts.fnDispServerStoppedState();
if (opts.$btnStartServer) opts.$btnStartServer.prop("disabled", false);
this._p2pMetaV1.serverPeer = null;
JqueryUtil.doToast({content: `Failed to start server! ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
setTimeout(() => { throw e; });
}
return {
isRunning: false,
token: this._p2pMetaV1.serverPeer?.token,
};
}
handleClick_playerWindowV1 ({doUpdateExternalStates}) {
const {$modalInner} = UiUtil.getShowModal({
title: "Configure Player View",
isUncappedHeight: true,
isHeight100: true,
cbClose: () => {
if (this._p2pMetaV1.rows.length) this._p2pMetaV1.rows.forEach(row => row.$row.detach());
if (this._p2pMetaV1.serverPeer) this._p2pMetaV1.serverPeer.offTemp("connection");
},
});
const $wrpHelp = UiUtil.$getAddModalRow($modalInner, "div");
const fnDispServerStoppedState = () => {
$btnStartServer.html(`<span class="glyphicon glyphicon-play"></span> Start Server`).prop("disabled", false);
$btnGetToken.prop("disabled", true);
$btnGetLink.prop("disabled", true);
};
const fnDispServerRunningState = () => {
$btnStartServer.html(`<span class="glyphicon glyphicon-play"></span> Server Running`).prop("disabled", true);
$btnGetToken.prop("disabled", false);
$btnGetLink.prop("disabled", false);
};
const $btnStartServer = $(`<button class="btn btn-default mr-2"></button>`)
.click(async () => {
const {isRunning} = await this.startServerV1({doUpdateExternalStates, $btnStartServer, $btnGetToken, $btnGetLink, fnDispServerStoppedState, fnDispServerRunningState});
if (!isRunning) return;
this._p2pMetaV1.serverPeer.onTemp("connection", showConnected);
showConnected();
});
const $btnGetToken = $(`<button class="btn btn-default mr-2" disabled><span class="glyphicon glyphicon-copy"></span> Copy Token</button>`).appendTo($wrpHelp)
.click(async () => {
await MiscUtil.pCopyTextToClipboard(this._p2pMetaV1.serverPeer.token);
JqueryUtil.showCopiedEffect($btnGetToken);
});
const $btnGetLink = $(`<button class="btn btn-default" disabled><span class="glyphicon glyphicon-link"></span> Copy Link</button>`).appendTo($wrpHelp)
.click(async () => {
const cleanOrigin = window.location.origin.replace(/\/+$/, "");
const url = `${cleanOrigin}/inittrackerplayerview.html#v1:${this._p2pMetaV1.serverPeer.token}`;
await MiscUtil.pCopyTextToClipboard(url);
JqueryUtil.showCopiedEffect($btnGetLink);
});
if (this._p2pMetaV1.serverPeer) fnDispServerRunningState();
else fnDispServerStoppedState();
$$`<div class="row w-100">
<div class="col-12">
<p>
The Player View is part of a peer-to-peer system to allow players to connect to a DM's initiative tracker. Players should use the <a href="inittrackerplayerview.html">Initiative Tracker Player View</a> page to connect to the DM's instance. As a DM, the usage is as follows:
<ol>
<li>Start the server.</li>
<li>Copy your link/token and share it with your players.</li>
<li>Wait for them to connect!</li>
</ol>
</p>
<p>${$btnStartServer}${$btnGetLink}${$btnGetToken}</p>
<p><i>Please note that this system is highly experimental. Your experience may vary.</i></p>
</div>
</div>`.appendTo($wrpHelp);
UiUtil.addModalSep($modalInner);
const $wrpConnected = UiUtil.$getAddModalRow($modalInner, "div").addClass("flx-col");
const showConnected = () => {
if (!this._p2pMetaV1.serverPeer) return $wrpConnected.html(`<div class="w-100 ve-flex-vh-center"><i>No clients connected.</i></div>`);
let stack = `<div class="w-100"><h5>Connected Clients:</h5><ul>`;
this._p2pMetaV1.serverPeer.getActiveConnections()
.map(it => it.label || "(Unknown)")
.sort(SortUtil.ascSortLower)
.forEach(it => stack += `<li>${it.escapeQuotes()}</li>`);
stack += "</ul></div>";
$wrpConnected.html(stack);
};
if (this._p2pMetaV1.serverPeer) this._p2pMetaV1.serverPeer.onTemp("connection", showConnected);
showConnected();
}
// nop on receiving a message; we want to send only
// TODO expand this, to allow e.g. players to set statuses or assign damage/healing (at DM approval?)
_playerWindowV0_DM_MESSAGE_RECEIVER = function () {};
_playerWindowV0_DM_ERROR_HANDLER = function (err) {
if (!this.isClosed) {
// TODO: this could be better at handling `err.error == "RTCError: User-Initiated Abort, reason=Close called"`
JqueryUtil.doToast({
content: `Server error:\n${err ? (err.message || err.error || err) : "(Unknown error)"}`,
type: "danger",
});
}
};
async _playerWindowV0_pGetServerTokens ({rowMetas}) {
const targetRows = rowMetas.filter(it => !it.isDeleted).filter(it => !it.isActive);
if (targetRows.every(it => it.isActive)) {
return JqueryUtil.doToast({
content: "No rows require Server Token generation!",
type: "warning",
});
}
let anyInvalidNames = false;
targetRows.forEach(row => {
row.$iptName.removeClass("error-background");
if (!row.$iptName.val().trim()) {
anyInvalidNames = true;
row.$iptName.addClass("error-background");
}
});
if (anyInvalidNames) return;
const names = targetRows.map(row => {
row.isActive = true;
row.$iptName.attr("disabled", true);
row.$btnGenServerToken.attr("disabled", true);
return row.$iptName.val();
});
if (this._p2pMetaV0.serverInfo) {
await this._p2pMetaV0.serverInfo;
const serverInfo = await PeerUtilV0.pInitialiseServersAddToExisting(
names,
this._p2pMetaV0.serverInfo,
this._playerWindowV0_DM_MESSAGE_RECEIVER,
this._playerWindowV0_DM_ERROR_HANDLER,
);
return targetRows.map((row, i) => {
row.name = serverInfo[i].name;
row.serverInfo = serverInfo[i];
row.$iptTokenServer.val(serverInfo[i].textifiedSdp).attr("disabled", false);
serverInfo[i].rowMeta = row;
row.$iptTokenClient.attr("disabled", false);
row.$btnAcceptClientToken.attr("disabled", false);
return serverInfo[i].textifiedSdp;
});
} else {
this._p2pMetaV0.serverInfo = (async () => {
this._p2pMetaV0.serverInfo = await PeerUtilV0.pInitialiseServers(names, this._playerWindowV0_DM_MESSAGE_RECEIVER, this._playerWindowV0_DM_ERROR_HANDLER);
targetRows.forEach((row, i) => {
row.name = this._p2pMetaV0.serverInfo[i].name;
row.serverInfo = this._p2pMetaV0.serverInfo[i];
row.$iptTokenServer.val(this._p2pMetaV0.serverInfo[i].textifiedSdp).attr("disabled", false);
this._p2pMetaV0.serverInfo[i].rowMeta = row;
row.$iptTokenClient.attr("disabled", false);
row.$btnAcceptClientToken.attr("disabled", false);
});
})();
await this._p2pMetaV0.serverInfo;
return targetRows.map(row => row.serverInfo.textifiedSdp);
}
}
handleClick_playerWindowV0 ({doUpdateExternalStates}) {
const {$modalInner} = UiUtil.getShowModal({
title: "Configure Player View",
isUncappedHeight: true,
isHeight100: true,
cbClose: () => {
if (this._p2pMetaV0.rows.length) this._p2pMetaV0.rows.forEach(row => row.$row.detach());
},
});
const $wrpHelp = UiUtil.$getAddModalRow($modalInner, "div");
const $btnAltGenAll = $(`<button class="btn btn-primary btn-text-insert">Generate All</button>`).click(() => $btnGenServerTokens.click());
const $btnAltCopyAll = $(`<button class="btn btn-primary btn-text-insert">Copy Server Tokens</button>`).click(() => $btnCopyServers.click());
$$`<div class="ve-flex w-100">
<div class="col-12">
<p>
The Player View is part of a peer-to-peer (i.e., serverless) system to allow players to connect to a DM's initiative tracker. Players should use the <a href="inittrackerplayerview.html">Initiative Tracker Player View</a> page to connect to the DM's instance. As a DM, the usage is as follows:
<ol>
<li>Add the required number of players, and input (preferably unique) player names.</li>
<li>Click "${$btnAltGenAll}," which will generate a "server token" per player. You can click "${$btnAltCopyAll}" to copy them all as a single block of text, or click on the "Server Token" values to copy them individually. Distribute these tokens to your players (via a messaging service of your choice; we recommend <a href="https://discordapp.com/">Discord</a>). Each player should paste their token into the <a href="inittrackerplayerview.html">Initiative Tracker Player View</a>, following the instructions provided therein.</li>
<li>
Get a resulting "client token" from each player via a messaging service of your choice. Then, either:
<ol type="a">
<li>Click the "Accept Multiple Clients" button, and paste in text containing multiple client tokens. <b>This will try to find tokens in <i>any</i> text, ignoring everything else.</b> Pasting a chatroom log (containing, for example, usernames and timestamps mixed with tokens) is the expected usage.</li>
<li>Paste each token into the appropriate "Client Token" field and "Accept Client" on each. A token can be identified by the slugified player name in the first few characters.</li>
</ol>
</li>
</ol>
</p>
<p>Once a player's client has been "accepted," it will receive updates from the DM's tracker. <i>Please note that this system is highly experimental. Your experience may vary.</i></p>
</div>
</div>`.appendTo($wrpHelp);
UiUtil.addModalSep($modalInner);
const $wrpTop = UiUtil.$getAddModalRow($modalInner, "div");
const $btnAddClient = $(`<button class="btn btn-xs btn-primary" title="Add Client">Add Player</button>`).click(() => addClientRow());
const $btnCopyServers = $(`<button class="btn btn-xs btn-primary" title="Copy any available server tokens to the clipboard">Copy Server Tokens</button>`)
.click(async () => {
const targetRows = this._p2pMetaV0.rows.filter(it => !it.isDeleted && !it.$iptTokenClient.attr("disabled"));
if (!targetRows.length) {
JqueryUtil.doToast({
content: `No free server tokens to copy. Generate some!`,
type: "warning",
});
} else {
await MiscUtil.pCopyTextToClipboard(targetRows.map(it => it.$iptTokenServer.val()).join("\n\n"));
JqueryUtil.showCopiedEffect($btnGenServerTokens);
}
});
const $btnAcceptClients = $(`<button class="btn btn-xs btn-primary" title="Open a prompt into which text containing client tokens can be pasted">Accept Multiple Clients</button>`)
.click(() => {
const {$modalInner, doClose} = UiUtil.getShowModal({title: "Accept Multiple Clients"});
const $iptText = $(`<textarea class="form-control dm-init-pl__textarea block mb-2"></textarea>`)
.keydown(() => $iptText.removeClass("error-background"));
const $btnAccept = $(`<button class="btn btn-xs btn-primary block ve-text-center" title="Add Client">Accept Multiple Clients</button>`)
.click(async () => {
$iptText.removeClass("error-background");
const txt = $iptText.val();
if (!txt.trim() || !PeerUtilV0.containsAnyTokens(txt)) {
$iptText.addClass("error-background");
} else {
const connected = await PeerUtilV0.pConnectClientsToServers(this._p2pMetaV0.serverInfo, txt);
this._board.doBindAlertOnNavigation();
connected.forEach(serverInfo => {
serverInfo.rowMeta.$iptTokenClient.val(serverInfo._tempTokenToDisplay || "").attr("disabled", true);
serverInfo.rowMeta.$btnAcceptClientToken.attr("disabled", true);
delete serverInfo._tempTokenToDisplay;
});
doClose();
doUpdateExternalStates();
}
});
$$`<div>
<p>Paste text containing one or more client tokens, and click "Accept Multiple Clients"</p>
${$iptText}
<div class="ve-flex-vh-center">${$btnAccept}</div>
</div>`.appendTo($modalInner);
});
$$`
<div class="ve-flex w-100">
<div class="col-12">
<div class="ve-flex-inline-v-center mr-2">
<span class="mr-1">Add a player (client):</span>
${$btnAddClient}
</div>
<div class="ve-flex-inline-v-center mr-2">
<span class="mr-1">Copy all un-paired server tokens:</span>
${$btnCopyServers}
</div>
<div class="ve-flex-inline-v-center mr-2">
<span class="mr-1">Mass-accept clients:</span>
${$btnAcceptClients}
</div>
</div>
</div>
`.appendTo($wrpTop);
UiUtil.addModalSep($modalInner);
const $btnGenServerTokens = $(`<button class="btn btn-primary btn-xs">Generate All</button>`)
.click(() => this._playerWindowV0_pGetServerTokens({rowMetas: this._p2pMetaV0.rows}));
UiUtil.$getAddModalRow($modalInner, "div")
.append($$`
<div class="ve-flex w-100">
<div class="col-2 bold">Player Name</div>
<div class="col-3-5 bold">Server Token</div>
<div class="col-1 ve-text-center">${$btnGenServerTokens}</div>
<div class="col-3-5 bold">Client Token</div>
</div>
`);
const _get$rowTemplate = (
$iptName,
$iptTokenServer,
$btnGenServerToken,
$iptTokenClient,
$btnAcceptClientToken,
$btnDeleteClient,
) => $$`<div class="w-100 mb-2 ve-flex">
<div class="col-2 pr-1">${$iptName}</div>
<div class="col-3-5 px-1">${$iptTokenServer}</div>
<div class="col-1 px-1 ve-flex-vh-center">${$btnGenServerToken}</div>
<div class="col-3-5 px-1">${$iptTokenClient}</div>
<div class="col-1-5 px-1 ve-flex-vh-center">${$btnAcceptClientToken}</div>
<div class="col-0-5 pl-1 ve-flex-vh-center">${$btnDeleteClient}</div>
</div>`;
const clientRowMetas = [];
const addClientRow = () => {
const rowMeta = {id: CryptUtil.uid()};
clientRowMetas.push(rowMeta);
const $iptName = $(`<input class="form-control input-sm">`)
.keydown(evt => {
$iptName.removeClass("error-background");
if (evt.key === "Enter") $btnGenServerToken.click();
});
const $iptTokenServer = $(`<input class="form-control input-sm copyable code" readonly disabled>`)
.click(async () => {
await MiscUtil.pCopyTextToClipboard($iptTokenServer.val());
JqueryUtil.showCopiedEffect($iptTokenServer);
}).disableSpellcheck();
const $btnGenServerToken = $(`<button class="btn btn-xs btn-primary" title="Generate Server Token">Generate</button>`)
.click(() => this._playerWindowV0_pGetServerTokens({rowMetas: [rowMeta]}));
const $iptTokenClient = $(`<input class="form-control input-sm code" disabled>`)
.keydown(evt => {
$iptTokenClient.removeClass("error-background");
if (evt.key === "Enter") $btnAcceptClientToken.click();
}).disableSpellcheck();
const $btnAcceptClientToken = $(`<button class="btn btn-xs btn-primary" title="Accept Client Token" disabled>Accept Client</button>`)
.click(async () => {
const token = $iptTokenClient.val();
if (PeerUtilV0.isValidToken(token)) {
try {
await PeerUtilV0.pConnectClientsToServers([rowMeta.serverInfo], token);
this._board.doBindAlertOnNavigation();
$iptTokenClient.prop("disabled", true);
$btnAcceptClientToken.prop("disabled", true);
doUpdateExternalStates();
} catch (e) {
JqueryUtil.doToast({
content: `Failed to accept client token! Are you sure it was valid? (See the log for more details.)`,
type: "danger",
});
setTimeout(() => { throw e; });
}
} else $iptTokenClient.addClass("error-background");
});
const $btnDeleteClient = $(`<button class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash"></span></button>`)
.click(() => {
rowMeta.$row.remove();
rowMeta.isDeleted = true;
if (rowMeta.serverInfo) {
rowMeta.serverInfo.server.close();
rowMeta.serverInfo.isDeleted = true;
}
const ix = clientRowMetas.indexOf(rowMeta);
if (~ix) clientRowMetas.splice(ix, 1);
if (!clientRowMetas.length) addClientRow();
});
rowMeta.$row = _get$rowTemplate(
$iptName,
$iptTokenServer,
$btnGenServerToken,
$iptTokenClient,
$btnAcceptClientToken,
$btnDeleteClient,
).appendTo($wrpRowsInner);
rowMeta.$iptName = $iptName;
rowMeta.$iptTokenServer = $iptTokenServer;
rowMeta.$btnGenServerToken = $btnGenServerToken;
rowMeta.$iptTokenClient = $iptTokenClient;
rowMeta.$btnAcceptClientToken = $btnAcceptClientToken;
this._p2pMetaV0.rows.push(rowMeta);
return rowMeta;
};
const $wrpRows = UiUtil.$getAddModalRow($modalInner, "div");
const $wrpRowsInner = $(`<div class="w-100"></div>`).appendTo($wrpRows);
if (this._p2pMetaV0.rows.length) this._p2pMetaV0.rows.forEach(row => row.$row.appendTo($wrpRowsInner));
else addClientRow();
}
async pHandleDoConnectLocalV0 ({clientView}) {
// generate a stub/fake row meta
const rowMeta = {
id: CryptUtil.uid(),
$row: $(),
$iptName: $(`<input value="local">`),
$iptTokenServer: $(),
$btnGenServerToken: $(),
$iptTokenClient: $(),
$btnAcceptClientToken: $(),
};
this._p2pMetaV0.rows.push(rowMeta);
const serverTokens = await this._playerWindowV0_pGetServerTokens({rowMetas: [rowMeta]});
const clientData = await PeerUtilV0.pInitialiseClient(
serverTokens[0],
msg => clientView.handleMessage(msg),
() => {}, // ignore local errors
);
clientView.clientData = clientData;
await PeerUtilV0.pConnectClientsToServers([rowMeta.serverInfo], clientData.textifiedSdp);
}
}

View File

@@ -0,0 +1,30 @@
export class InitiativeTrackerRoller {
static _getRollName (name) {
return `Initiative Tracker${name ? ` \u2014 ${name}` : ""}`;
}
async pGetRollInitiative ({mon, name, initiativeModifier}) {
name ??= mon?.name;
initiativeModifier ??= mon ? Parser.getAbilityModifier(mon.dex) : 0;
return Renderer.dice.pRoll2(`1d20${UiUtil.intToBonus(initiativeModifier)}`, {
isUser: false,
name: this.constructor._getRollName(name ?? mon?.name),
label: "Initiative",
}, {isResultUsed: true});
}
async pGetOrRollHp (mon, {isRollHp}) {
if (!isRollHp && mon.hp.average && !isNaN(mon.hp.average)) return Number(mon.hp.average);
if (isRollHp && mon.hp.formula) {
return Renderer.dice.pRoll2(mon.hp.formula, {
isUser: false,
name: this.constructor._getRollName(mon?.name),
label: "HP",
}, {isResultUsed: true});
}
return null;
}
}

View File

@@ -0,0 +1,475 @@
import {
IS_PLAYER_VISIBLE_ALL,
IS_PLAYER_VISIBLE_NONE,
} from "./dmscreen-initiativetracker-statcolumns.js";
import {InitiativeTrackerConditionAdd} from "./dmscreen-initiativetracker-conditionadd.js";
import {InitiativeTrackerUi} from "./dmscreen-initiativetracker-ui.js";
import {InitiativeTrackerConst} from "./dmscreen-initiativetracker-consts.js";
import {InitiativeTrackerSort} from "./dmscreen-initiativetracker-sort.js";
import {RenderableCollectionConditions} from "../../initiativetracker/initiativetracker-utils.js";
import {
InitiativeTrackerRowDataViewBase,
RenderableCollectionRowDataBase,
} from "./dmscreen-initiativetracker-rowsbase.js";
import {InitiativeTrackerRowStateBuilderActive} from "./dmscreen-initiativetracker-rowstatebuilder.js";
class _RenderableCollectionRowDataActive extends RenderableCollectionRowDataBase {
constructor (
{
comp,
$wrpRows,
roller,
networking,
rowStateBuilder,
},
) {
super({comp, prop: "rows", $wrpRows, roller, networking, rowStateBuilder});
}
async _pPopulateRow_pGetMonsterMeta ({comp}) {
const isMon = !!comp._state.source;
const mon = isMon
? await this._rowStateBuilder.pGetScaledCreature({isMon, ...comp._state})
: null;
const fluff = mon ? await Renderer.monster.pGetFluff(mon) : null;
return {
isMon,
mon,
fluff,
};
}
/* ----- */
_pPopulateRow_monster ({comp, $wrpLhs, isMon, mon, fluff}) {
if (!isMon) return;
const $dispOrdinal = $(`<span class="dm-init__number"></span>`);
comp._addHookBase("ordinal", () => $dispOrdinal.text(`(${comp._state.ordinal})`))();
comp._addHookBase("isShowOrdinal", () => $dispOrdinal.toggleVe(comp._state.isShowOrdinal))();
const $lnk = $(this._pPopulateRow_monster_getRenderedLink({comp}))
.attr("tabindex", "-1");
comp._addHookBase("customName", () => {
$lnk.text(comp._state.customName ? comp._state.customName : comp._state.displayName || comp._state.name);
})();
const $btnRename = $(`<button class="btn btn-default btn-xs dm-init-lockable dm-init__btn-creature" title="Rename (SHIFT to Reset)" tabindex="-1"><span class="glyphicon glyphicon-pencil"></span></button>`)
.click(async evt => {
if (this._comp._state.isLocked) return;
if (evt.shiftKey) return comp._state.customName = null;
const customName = await InputUiUtil.pGetUserString({title: "Enter Name"});
if (customName == null || !customName.trim()) return;
comp._state.customName = customName;
});
const $btnDuplicate = $(`<button class="btn btn-success btn-xs dm-init-lockable dm-init__btn-creature" title="Add Another (SHIFT for Roll New)" tabindex="-1"><span class="glyphicon glyphicon-plus"></span></button>`)
.click(async (evt) => {
if (this._comp._state.isLocked) return;
const isRollNew = !!evt.shiftKey;
const initiative = isRollNew ? await this._roller.pGetRollInitiative({mon}) : comp._state.initiative;
const isActive = isRollNew ? (initiative === comp._state.initiative) : comp._state.isActive;
const hpMax = isRollNew ? await this._roller.pGetOrRollHp(mon, {isRollHp: this._comp._state.isRollHp}) : comp._state.hpMax;
const similarCreatureRows = this._rowStateBuilder.getSimilarRows({rowEntity: comp._state});
this._comp._state[this._prop] = InitiativeTrackerSort.getSortedRows({
rows: [
...this._comp._state[this._prop],
await this._rowStateBuilder.pGetNewRowState({
isActive,
isPlayerVisible: comp._state.isPlayerVisible,
name: comp._state.name,
displayName: comp._state.displayName,
scaledCr: comp._state.scaledCr,
scaledSummonSpellLevel: comp._state.scaledSummonSpellLevel,
scaledSummonClassLevel: comp._state.scaledSummonClassLevel,
customName: comp._state.customName,
source: comp._state.source,
hpCurrent: hpMax, // Always reset to max HP
hpMax: hpMax,
initiative,
ordinal: Math.max(...similarCreatureRows.map(row => row.entity.ordinal)) + 1,
rowStatColData: isRollNew ? this._rowStateBuilder._getInitialRowStatColData({mon, fluff}) : MiscUtil.copyFast(comp._state.rowStatColData),
conditions: [],
}),
]
.filter(Boolean),
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
});
});
$$`<div class="dm-init__wrp-creature split">
<span class="dm-init__wrp-creature-link">
${$lnk}
${$dispOrdinal}
</span>
<div class="ve-flex-v-center btn-group mr-3p">
${$btnRename}
${$btnDuplicate}
</div>
</div>`.appendTo($wrpLhs);
}
_pPopulateRow_monster_getRenderedLink ({comp}) {
if (
comp._state.scaledCr == null
&& comp._state.scaledSummonSpellLevel == null
&& comp._state.scaledSummonClassLevel == null
) return Renderer.get().render(`{@creature ${comp._state.name}|${comp._state.source}}`);
const parts = [
comp._state.name,
comp._state.source,
comp._state.displayName,
comp._state.scaledCr != null
? `${VeCt.HASH_SCALED}=${Parser.numberToCr(comp._state.scaledCr)}`
: comp._state.scaledSummonSpellLevel != null
? `${VeCt.HASH_SCALED_SPELL_SUMMON}=${comp._state.scaledSummonSpellLevel}`
: comp._state.scaledSummonClassLevel != null
? `${VeCt.HASH_SCALED_CLASS_SUMMON}=${comp._state.scaledSummonClassLevel}`
: null,
];
return Renderer.get().render(`{@creature ${parts.join("|")}}`);
}
/* ----- */
_pPopulateRow_conditions ({comp, $wrpLhs}) {
const $btnAddCond = $(`<button class="btn btn-warning btn-xs dm-init__row-btn dm-init__row-btn-flag" title="Add Condition" tabindex="-1"><span class="glyphicon glyphicon-flag"></span></button>`)
.on("click", async () => {
const compAdd = new InitiativeTrackerConditionAdd({conditionsCustom: MiscUtil.copyFast(this._comp._state.conditionsCustom)});
const [isDataEntered, conditionToAdd] = await compAdd.pGetShowModalResults();
// Always update the set of custom conditions
this._comp._state.conditionsCustom = compAdd.getConditionsCustom();
if (!isDataEntered) return;
comp._state.conditions = [
...comp._state.conditions,
conditionToAdd,
];
});
const $wrpConds = $(`<div class="init__wrp_conds h-100"></div>`);
$$`<div class="split">
${$wrpConds}
${$btnAddCond}
</div>`.appendTo($wrpLhs);
const collectionConditions = new RenderableCollectionConditions({
comp: comp,
$wrpRows: $wrpConds,
});
comp._addHookBase("conditions", () => collectionConditions.render())();
}
/* ----- */
_pPopulateRow_initiative ({comp, $wrpRhs}) {
const $iptInitiative = ComponentUiUtil.$getIptNumber(
comp,
"initiative",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm score dm-init-lockable dm-init__row-input ve-text-center dm-init__ipt--rhs">`,
},
)
.on("click", () => $iptInitiative.select())
.appendTo($wrpRhs);
}
/* ----- */
_pPopulateRow_btns ({comp, entity, $wrpRhs}) {
const $btnVisible = InitiativeTrackerUi.$getBtnPlayerVisible(
comp._state.isPlayerVisible,
() => comp._state.isPlayerVisible = $btnVisible.hasClass("btn-primary")
? IS_PLAYER_VISIBLE_ALL
: IS_PLAYER_VISIBLE_NONE,
false,
)
.title("Shown in player view")
.addClass("dm-init__row-btn")
.addClass("dm-init__btn_eye")
.appendTo($wrpRhs);
$(`<button class="btn btn-danger btn-xs dm-init__row-btn dm-init-lockable" title="Delete (SHIFT to Also Delete Similar)" tabindex="-1"><span class="glyphicon glyphicon-trash"></span></button>`)
.appendTo($wrpRhs)
.on("click", evt => {
if (this._comp._state.isLocked) return;
if (evt.shiftKey) {
return this._utils.doDeleteMultiple({
entities: this._rowStateBuilder.getSimilarRows({
rows: this._comp[this._prop],
rowEntity: entity.entity,
}),
});
}
this._utils.doDelete({entity});
});
}
/* -------------------------------------------- */
_doHandleTurnStart ({rows, direction, row, isSkipRoundStart}) {
const {isRoundStart = false} = isSkipRoundStart ? {} : this._doHandleRoundStart({rows, direction, row});
this._comp._getRenderedCollection({prop: this._prop})[row.id]?.cbOnTurnStart({state: row, direction});
return {isRoundStart};
}
_doHandleRoundStart ({rows, direction, row}) {
if (!rows.length) return {isRoundStart: false};
if (rows[0]?.id !== row?.id) return {isRoundStart: false};
if (direction === InitiativeTrackerConst.DIR_FORWARDS) ++this._comp._state.round;
const rendereds = this._comp._getRenderedCollection({prop: this._prop});
rows.forEach(row => rendereds[row.id]?.cbOnRoundStart({state: row, direction}));
return {isRoundStart: true};
}
/* -------------------------------------------- */
_getSortedRowsCopy ({rows}) {
return InitiativeTrackerSort.getSortedRows({
rows: MiscUtil.copyFast(rows),
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
});
}
doEnsureAtLeastOneRowActive ({isSilent = false} = {}) {
if (this._isAnyRowActive()) return;
const rows = this._getSortedRowsCopy({rows: (isSilent ? this._comp.__state : this._comp._state)[this._prop]});
if (!rows?.length) return;
const [rowActive] = rows;
this._doHandleRowSetActive({rows, rowActive, direction: InitiativeTrackerConst.DIR_NEUTRAL});
if (isSilent) this._comp.__state.rows = rows;
else this._comp._triggerCollectionUpdate(this._prop);
}
_isAnyRowActive () {
return this._comp._state[this._prop].some(it => it.entity.isActive);
}
_doHandleRowSetActive ({rows, rowActive, direction, isSkipRoundStart}) {
const similarRows = this._rowStateBuilder.getSimilarRows({
rows,
rowEntity: rowActive.entity,
});
if (!similarRows.some(row => row.id === rowActive.id)) throw new Error(`Active row should be "similar" to itself!`); // Should never occur
const isRoundStart = similarRows
.map(row => {
const {entity} = row;
if (
this._comp._state.sort === InitiativeTrackerConst.SORT_ORDER_NUM
&& entity.initiative !== rowActive.entity.initiative
) return false;
entity.isActive = true;
return this._doHandleTurnStart({rows, direction, row, isSkipRoundStart}).isRoundStart;
})
.some(Boolean);
return {isRoundStart};
}
_pDoShiftActiveRow_doInitialShift ({direction}) {
const rows = this._getSortedRowsCopy({rows: this._comp._state[this._prop]});
if (!this._isAnyRowActive()) return this.doEnsureAtLeastOneRowActive();
if (direction === InitiativeTrackerConst.DIR_BACKWARDS) rows.reverse();
const rowsActive = rows.filter(({entity}) => entity.isActive);
// If advancing, tick down conditions
if (direction === InitiativeTrackerConst.DIR_FORWARDS) {
rowsActive
.forEach(({entity}) => {
entity.conditions = entity.conditions
.filter(cond => !(cond.entity.turns != null && (--cond.entity.turns <= 0)));
});
}
rowsActive.forEach(({entity}) => entity.isActive = false);
const ixLastActive = rows.indexOf(rowsActive.last());
const ixNextActive = ixLastActive + 1 < rows.length ? ixLastActive + 1 : 0;
rows[ixNextActive].entity.isActive = true;
const {isRoundStart} = this._doHandleRowSetActive({rows, rowActive: rows[ixNextActive], direction});
return {
isRoundStart,
rows: InitiativeTrackerSort.getSortedRows({
rows,
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
}),
};
}
async _pDoShiftActiveRow_pDoRerollAndShift ({direction, rows}) {
await this._pMutRowsRerollInitiative({rows});
rows = InitiativeTrackerSort.getSortedRows({
rows,
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
});
rows.forEach(({entity}) => entity.isActive = false);
const rowActive = rows[0];
rowActive.entity.isActive = true;
// FIXME(Future) this results in turn-start triggering twice on rows which were at the top of the pre-reroll order
// and the post-reroll order (current turn-start callbacks are idempotent, so this is not relevant)
this._doHandleRowSetActive({rows, rowActive, direction, isSkipRoundStart: true});
return rows;
}
async pDoShiftActiveRow ({direction}) {
const {isRoundStart, rows} = this._pDoShiftActiveRow_doInitialShift({direction});
if (direction !== InitiativeTrackerConst.DIR_FORWARDS || !isRoundStart || !this._comp._state.isRerollInitiativeEachRound) return this._comp._state[this._prop] = rows;
this._comp._state[this._prop] = await this._pDoShiftActiveRow_pDoRerollAndShift({direction, rows});
}
/* -------------------------------------------- */
async _pMutRowsRerollInitiative ({rows}) {
rows = rows || this._comp._state[this._prop];
if (!this._comp._state.isRollGroups) {
return rows
.pSerialAwaitMap(async row => {
const {entity} = row;
const {mon, initiativeModifier} = await this._rowStateBuilder.pGetRowInitiativeMeta({row});
entity.initiative = await this._roller.pGetRollInitiative({mon, initiativeModifier, name: mon ? null : entity.name});
});
}
await Object.values(
await rows
.pSerialAwaitReduce(
async (accum, row) => {
const rowEntityHash = InitiativeTrackerRowStateBuilderActive.getSimilarRowEntityHash({rowEntity: row.entity});
const {initiativeModifier} = await this._rowStateBuilder.pGetRowInitiativeMeta({row});
// Add the initiative modifier to the key, such that e.g. creatures with non-standard initiative
// modifiers are rolled outwith the group
const k = [rowEntityHash, initiativeModifier].join("__");
(accum[k] ||= []).push(row);
return accum;
},
{},
),
)
.pSerialAwaitMap(async rows => {
const [row] = rows;
const {mon, initiativeModifier} = await this._rowStateBuilder.pGetRowInitiativeMeta({row});
const initiative = await this._roller.pGetRollInitiative({mon, initiativeModifier, name: mon ? null : row.entity.name});
rows.forEach(({entity}) => entity.initiative = initiative);
});
}
}
export class InitiativeTrackerRowDataViewActive extends InitiativeTrackerRowDataViewBase {
_TextHeaderLhs = "Creature/Status";
_ClsRenderableCollectionRowData = _RenderableCollectionRowDataActive;
_render_$getWrpHeaderRhs ({rdState}) {
return $$`<div class="dm-init__row-rhs">
<div class="dm-init__header dm-init__header--input dm-init__header--input-wide" title="Hit Points">HP</div>
<div class="dm-init__header dm-init__header--input" title="Initiative Score">#</div>
<div class="dm-init__spc-header-buttons"></div>
</div>`;
}
_render_bindHooksRows ({rdState}) {
const hkRowsSync = () => {
// Sort rows
this._comp.__state.rows = InitiativeTrackerSort.getSortedRows({
rows: this._comp._state.rows,
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
});
// Ensure a row is active
this._compRows.doEnsureAtLeastOneRowActive({isSilent: true});
// region Show/hide creature ordinals
const ordinalsToShow = new Set(
Object.entries(
this._rowStateBuilder.getSimilarRowCounts({rows: this._comp.__state.rows}),
)
.filter(([, v]) => v > 1)
.map(([name]) => name),
);
this._comp.__state.rows
.forEach(({entity}) => entity.isShowOrdinal = ordinalsToShow.has(InitiativeTrackerRowStateBuilderActive.getSimilarRowEntityHash({rowEntity: entity})));
// endregion
};
this._comp._addHookBase(this._prop, hkRowsSync)();
rdState.fnsCleanup.push(() => this._comp._removeHookBase(this._prop, hkRowsSync));
const hkRowsAsync = async () => {
try {
await this._compRowsLock.pLock();
await this._compRows.pRender();
// region Scroll active rows into view
const rendereds = this._comp._getRenderedCollection({prop: this._prop});
const renderedsActive = this._comp._state.rows
.filter(row => row.entity.isActive)
.map(row => rendereds[row.id])
.filter(Boolean);
if (!renderedsActive.length) return;
// First scroll the last active row into view to scroll down as far as necessary...
renderedsActive.last()?.$wrpRow?.[0]?.scrollIntoView({block: "nearest", inline: "nearest"});
// ...then scroll the first active row into view, as this is the one we prioritize
renderedsActive[0]?.$wrpRow?.[0]?.scrollIntoView({block: "nearest", inline: "nearest"});
// endregion
} finally {
this._compRowsLock.unlock();
}
};
this._comp._addHookBase(this._prop, hkRowsAsync)();
rdState.fnsCleanup.push(() => this._comp._removeHookBase(this._prop, hkRowsAsync));
}
/* -------------------------------------------- */
pDoShiftActiveRow (...args) { return this._compRows.pDoShiftActiveRow(...args); }
}

View File

@@ -0,0 +1,389 @@
import {
InitiativeTrackerStatColumnFactory,
} from "./dmscreen-initiativetracker-statcolumns.js";
import {InitiativeTrackerUtil} from "../../initiativetracker/initiativetracker-utils.js";
class _RenderableCollectionRowStatColData extends RenderableCollectionGenericRows {
constructor (
{
rootComp,
comp,
$wrpRows,
networking,
mon,
},
) {
super(comp, "rowStatColData", $wrpRows);
this._rootComp = rootComp;
this._networking = networking;
this._mon = mon;
}
_$getWrpRow () {
return $(`<div class="ve-flex-vh-center"></div>`);
}
_populateRow ({comp, $wrpRow, entity}) {
const statsColData = this._rootComp._state.statsCols.find(statsCol => statsCol.id === entity.id);
if (!statsColData) return {};
const meta = InitiativeTrackerStatColumnFactory.fromStateData({data: statsColData});
meta.$getRendered({comp, mon: this._mon, networking: this._networking}).appendTo($wrpRow);
return {
cbOnTurnStart: ({state, direction}) => {
meta.onTurnStart({state, direction, mon: this._mon});
},
cbOnRoundStart: ({state, direction}) => {
meta.onRoundStart({state, direction, mon: this._mon});
},
};
}
}
/** @abstract */
export class RenderableCollectionRowDataBase extends RenderableCollectionAsyncGenericRows {
constructor (
{
comp,
prop,
$wrpRows,
roller,
networking = null,
rowStateBuilder,
},
) {
super(comp, prop, $wrpRows);
this._roller = roller;
this._networking = networking;
this._rowStateBuilder = rowStateBuilder;
}
/* -------------------------------------------- */
_$getWrpRow () {
return $(`<div class="dm-init__row overflow-hidden pr-1"></div>`);
}
async _pPopulateRow ({comp, $wrpRow, entity}) {
const fnsCleanup = [];
const {isMon, mon, fluff} = await this._pPopulateRow_pGetMonsterMeta({comp});
comp._addHookBase("isActive", () => $wrpRow.toggleClass("dm-init__row-active", !!comp._state.isActive))();
this._pPopulateRow_bindParentRowStatColsIsEditableHook({comp, entity, mon, fluff, fnsCleanup});
const $wrpLhs = $(`<div class="dm-init__row-lhs"></div>`).appendTo($wrpRow);
this._pPopulateRow_player({comp, $wrpLhs, isMon});
this._pPopulateRow_monster({comp, $wrpLhs, isMon, mon, fluff});
this._pPopulateRow_conditions({comp, $wrpLhs});
this._pPopulateRow_statsCols({comp, $wrpRow, mon, fnsCleanup});
const $wrpRhs = $(`<div class="dm-init__row-rhs"></div>`).appendTo($wrpRow);
this._pPopulateRow_hp({comp, $wrpRhs});
this._pPopulateRow_initiative({comp, $wrpRhs});
this._pPopulateRow_btns({comp, entity, $wrpRhs});
return {
cbOnTurnStart: ({state, direction}) => {
Object.values(comp._getRenderedCollection({prop: "rowStatColData"}))
.forEach(rendered => {
if (!rendered.cbOnTurnStart) return;
const stateSub = state.entity.rowStatColData
.find(cell => cell.id === rendered.id);
rendered.cbOnTurnStart({state: stateSub, direction});
});
},
cbOnRoundStart: ({state, direction}) => {
Object.values(comp._getRenderedCollection({prop: "rowStatColData"}))
.forEach(rendered => {
if (!rendered.cbOnRoundStart) return;
const stateSub = state.entity.rowStatColData
.find(cell => cell.id === rendered.id);
rendered.cbOnRoundStart({state: stateSub, direction});
});
},
fnsCleanup,
};
}
/* ----- */
/**
* @abstract
* @return {object}
*/
async _pPopulateRow_pGetMonsterMeta ({comp}) {
throw new Error("Unimplemented!");
}
/* ----- */
_pPopulateRow_bindParentRowStatColsIsEditableHook ({comp, entity, mon, fluff, fnsCleanup}) {
const hkParentStatCols = () => {
const isRowExists = this._comp._state[this._prop]
.some(row => row.id === entity.id);
if (!isRowExists) return; // Avoid race condition (row removed, but async render has not yet cleaned it up)
const rowStatColDataNxt = [];
this._comp._state.statsCols
.forEach(data => {
const existing = comp._state.rowStatColData.find(cell => cell.id === data.id);
if (existing) {
// Copy the parent isEditable flag to the child
existing.entity.isEditable = data.isEditable;
return rowStatColDataNxt.push(existing);
}
const meta = InitiativeTrackerStatColumnFactory.fromStateData({data});
const initialState = meta.getInitialCellStateData({mon, fluff});
rowStatColDataNxt.push(initialState);
});
comp._state.rowStatColData = rowStatColDataNxt;
};
this._comp._addHookBase("statsCols", hkParentStatCols)();
fnsCleanup.push(() => this._comp._removeHookBase("statsCols", hkParentStatCols));
}
/* ----- */
_pPopulateRow_player ({comp, $wrpLhs, isMon}) {
if (isMon) return;
ComponentUiUtil.$getIptStr(
comp,
"name",
{
html: `<input class="form-control input-sm name dm-init__ipt-name dm-init-lockable dm-init__row-input" placeholder="Name">`,
},
).appendTo($wrpLhs);
}
/* ----- */
/**
* @abstract
* @return void
*/
_pPopulateRow_monster ({comp, $wrpLhs, isMon, mon, fluff}) {
throw new Error("Unimplemented!");
}
/* ----- */
/**
* @abstract
* @return void
*/
_pPopulateRow_conditions ({comp, $wrpLhs}) {
throw new Error("Unimplemented!");
}
/* ----- */
_pPopulateRow_statsCols ({comp, $wrpRow, mon, fnsCleanup}) {
const $wrp = $(`<div class="dm-init__row-mid"></div>`)
.appendTo($wrpRow);
const hkParentStatsAddCols = () => $wrp.toggleVe(!!this._comp._state.isStatsAddColumns);
this._comp._addHookBase("isStatsAddColumns", hkParentStatsAddCols)();
fnsCleanup.push(() => this._comp._removeHookBase("isStatsAddColumns", hkParentStatsAddCols));
const renderableCollection = new _RenderableCollectionRowStatColData({
rootComp: this._comp,
comp,
$wrpRows: $wrp,
networking: this._networking,
mon,
});
comp._addHookBase("rowStatColData", () => renderableCollection.render())();
}
/* ----- */
_pPopulateRow_hp ({comp, $wrpRhs}) {
const $iptHpCurrent = ComponentUiUtil.$getIptNumber(
comp,
"hpCurrent",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm hp dm-init__row-input text-right w-40p mr-0 br-0">`,
},
)
.on("click", () => $iptHpCurrent.select());
const $iptHpMax = ComponentUiUtil.$getIptNumber(
comp,
"hpMax",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm hp-max dm-init__row-input w-40p mr-0 bl-0">`,
},
)
.on("click", () => $iptHpMax.select());
const hkHpColors = () => {
const woundLevel = InitiativeTrackerUtil.getWoundLevel(100 * comp._state.hpCurrent / comp._state.hpMax);
if (~woundLevel) {
const woundMeta = InitiativeTrackerUtil.getWoundMeta(woundLevel);
$iptHpCurrent.css("color", woundMeta.color);
$iptHpMax.css("color", woundMeta.color);
} else {
$iptHpCurrent.css("color", "");
$iptHpMax.css("color", "");
}
};
comp._addHookBase("hpCurrent", hkHpColors);
comp._addHookBase("hpMax", hkHpColors);
hkHpColors();
$$`<div class="ve-flex relative mr-3p">
<div class="text-right">${$iptHpCurrent}</div>
<div class="dm-init__sep-fields-slash ve-flex-vh-center">/</div>
<div class="text-left">${$iptHpMax}</div>
</div>`
.appendTo($wrpRhs);
}
/* ----- */
/**
* @abstract
* @return void
*/
_pPopulateRow_initiative ({comp, $wrpRhs}) {
throw new Error("Unimplemented!");
}
/* ----- */
/**
* @abstract
* @return void
*/
_pPopulateRow_btns ({comp, entity, $wrpRhs}) {
throw new Error("Unimplemented!");
}
/* -------------------------------------------- */
pDoDeleteExistingRender (rendered) {
rendered.fnsCleanup.forEach(fn => fn());
}
}
/** @abstract */
export class InitiativeTrackerRowDataViewBase {
static _RenderState = class {
constructor () {
this.fnsCleanup = [];
}
};
_TextHeaderLhs;
_ClsRenderableCollectionRowData;
constructor ({comp, prop, roller, networking, rowStateBuilder}) {
this._comp = comp;
this._prop = prop;
this._roller = roller;
this._networking = networking;
this._rowStateBuilder = rowStateBuilder;
this._compRowsLock = new VeLock({name: "Row render"});
this._compRows = null;
}
/* -------------------------------------------- */
getRenderedView () {
const rdState = new this.constructor._RenderState();
const $ele = $$`<div class="dm-init__wrp-header-outer">
<div class="dm-init__wrp-header pr-1">
<div class="dm-init__row-lhs dm-init__header">
<div class="w-100">${this._TextHeaderLhs}</div>
</div>
${this._render_$getWrpHeaderStatsCols({rdState})}
${this._render_$getWrpHeaderRhs({rdState})}
</div>
${this._render_$getWrpRows({rdState})}
</div>`;
return {
$ele,
cbDoCleanup: () => rdState.fnsCleanup.forEach(fn => fn()),
};
}
_render_$getWrpHeaderStatsCols ({rdState}) {
const $wrpHeaderStatsCols = $(`<div class="dm-init__row-mid"></div>`);
const hkHeaderStatsCols = () => {
$wrpHeaderStatsCols.empty();
if (!this._comp._state.isStatsAddColumns) return;
this._comp._state.statsCols.forEach(data => {
const meta = InitiativeTrackerStatColumnFactory.fromStateData({data});
$wrpHeaderStatsCols.append(meta.$getRenderedHeader());
});
};
this._comp._addHookBase("isStatsAddColumns", hkHeaderStatsCols);
this._comp._addHookBase("statsCols", hkHeaderStatsCols);
hkHeaderStatsCols();
rdState.fnsCleanup.push(
() => this._comp._removeHookBase("isStatsAddColumns", hkHeaderStatsCols),
() => this._comp._removeHookBase("statsCols", hkHeaderStatsCols),
);
return $wrpHeaderStatsCols;
}
/**
* @abstract
* @return {jQuery}
*/
_render_$getWrpHeaderRhs ({rdState}) {
throw new Error("Unimplemented!");
}
_render_$getWrpRows ({rdState}) {
const $wrpRows = $(`<div class="dm-init__wrp-entries"></div>`);
this._compRows = new this._ClsRenderableCollectionRowData({
comp: this._comp,
$wrpRows,
roller: this._roller,
networking: this._networking,
rowStateBuilder: this._rowStateBuilder,
});
this._render_bindHooksRows({rdState});
return $wrpRows;
}
/**
* @abstract
* @return void
*/
_render_bindHooksRows ({rdState}) {
throw new Error("Unimplemented!");
}
}

View File

@@ -0,0 +1,82 @@
import {
InitiativeTrackerRowDataViewBase,
RenderableCollectionRowDataBase,
} from "./dmscreen-initiativetracker-rowsbase.js";
class _RenderableCollectionRowDataDefaultParty extends RenderableCollectionRowDataBase {
constructor (
{
comp,
$wrpRows,
roller,
rowStateBuilder,
},
) {
super({comp, prop: "rowsDefaultParty", $wrpRows, roller, networking: null, rowStateBuilder});
}
async _pPopulateRow_pGetMonsterMeta ({comp}) {
return {
isMon: false,
mon: null,
fluff: null,
};
}
/* ----- */
_pPopulateRow_monster ({comp, $wrpLhs, isMon, mon, fluff}) {
/* No-op */
}
/* ----- */
_pPopulateRow_conditions ({comp, $wrpLhs}) {
/* No-op */
}
/* ----- */
_pPopulateRow_initiative ({comp, $wrpRhs}) {
/* No-op */
}
/* ----- */
_pPopulateRow_btns ({comp, entity, $wrpRhs}) {
$(`<button class="btn btn-danger btn-xs dm-init__row-btn dm-init-lockable" tabindex="-1"><span class="glyphicon glyphicon-trash"></span></button>`)
.appendTo($wrpRhs)
.on("click", () => {
if (this._comp._state.isLocked) return;
this._utils.doDelete({entity});
});
}
}
export class InitiativeTrackerRowDataViewDefaultParty extends InitiativeTrackerRowDataViewBase {
_TextHeaderLhs = "Player";
_ClsRenderableCollectionRowData = _RenderableCollectionRowDataDefaultParty;
_render_$getWrpHeaderRhs ({rdState}) {
return $$`<div class="dm-init__row-rhs">
<div class="dm-init__header dm-init__header--input dm-init__header--input-wide" title="Hit Points">HP</div>
<div class="dm-init__spc-header-buttons--single"></div>
</div>`;
}
_render_bindHooksRows ({rdState}) {
const hkRowsAsync = async () => {
try {
await this._compRowsLock.pLock();
await this._compRows.pRender();
} finally {
this._compRowsLock.unlock();
}
};
this._comp._addHookBase(this._prop, hkRowsAsync)();
rdState.fnsCleanup.push(
() => this._comp._removeHookBase(this._prop, hkRowsAsync),
() => this._comp._detachCollection(this._prop),
);
}
}

View File

@@ -0,0 +1,284 @@
import {
INITIATIVE_APPLICABILITY_NOT_APPLICABLE,
InitiativeTrackerStatColumnFactory,
} from "./dmscreen-initiativetracker-statcolumns.js";
import {DmScreenUtil} from "../dmscreen-util.js";
import {InitiativeTrackerSort} from "./dmscreen-initiativetracker-sort.js";
/** @abstract */
class _InitiativeTrackerRowStateBuilderBase {
constructor ({comp, roller}) {
this._comp = comp;
this._roller = roller;
}
/* -------------------------------------------- */
/**
* @param {?array} rows Existing/partial rows, for calculating ordinal.
* @param {?boolean} isActive
* @param {?boolean} isPlayerVisible
* @param {?string} name
* @param {?string} displayName
* @param {?number} scaledCr
* @param {?number} scaledSummonSpellLevel
* @param {?number} scaledSummonClassLevel
* @param {?string} customName
* @param {?string} source
* @param {?number} hpCurrent
* @param {?number} hpMax
* @param {?number} initiative
* @param {?number} ordinal
* @param {?array} rowStatColData
* @param {?array} conditions
*/
async pGetNewRowState (
{
rows = null,
isActive = null,
isPlayerVisible = null,
name = null,
displayName = null,
scaledCr = null,
scaledSummonSpellLevel = null,
scaledSummonClassLevel = null,
customName = null,
source = null,
hpCurrent = null,
hpMax = null,
initiative = null,
ordinal = null,
rowStatColData = null,
conditions = null,
} = {},
) {
if (rowStatColData == null) rowStatColData = this._getInitialRowStatColData();
return {
id: CryptUtil.uid(),
entity: {
isActive: !!isActive,
isPlayerVisible: !!isPlayerVisible,
name,
displayName,
scaledCr,
scaledSummonSpellLevel,
scaledSummonClassLevel,
customName,
source,
hpCurrent,
hpMax,
initiative,
ordinal,
rowStatColData: rowStatColData ?? [],
conditions: conditions ?? [],
},
};
}
/**
* @param {?object} mon
* @param {?object} fluff
*/
_getInitialRowStatColData ({mon = null, fluff = null} = {}) {
return this._comp._state.statsCols
.map(data => {
return InitiativeTrackerStatColumnFactory.fromStateData({data})
.getInitialCellStateData({mon, fluff});
});
}
/* -------------------------------------------- */
async pGetRowInitiativeMeta ({row}) {
const out = {mon: null, initiativeModifier: null};
const {entity} = row;
const initiativeInfos = this._comp._state.statsCols
.map(data => {
const cell = entity.rowStatColData.find(cell => cell.id === data.id);
if (!cell) return null;
return {
id: cell.id,
...InitiativeTrackerStatColumnFactory.fromStateData({data})
.getInitiativeInfo({state: cell}),
};
})
.filter(Boolean)
.filter(info => info.applicability !== INITIATIVE_APPLICABILITY_NOT_APPLICABLE)
.sort(InitiativeTrackerSort.sortInitiativeInfos.bind(InitiativeTrackerSort));
const maxApplicability = Math.max(INITIATIVE_APPLICABILITY_NOT_APPLICABLE, ...initiativeInfos.map(info => info.applicability));
if (maxApplicability !== INITIATIVE_APPLICABILITY_NOT_APPLICABLE) {
const initiativeInfosApplicable = initiativeInfos.filter(info => info.applicability === maxApplicability);
out.initiativeModifier = Math.max(...initiativeInfosApplicable.map(info => info.initiative));
}
return out;
}
}
export class InitiativeTrackerRowStateBuilderActive extends _InitiativeTrackerRowStateBuilderBase {
_prop = "rows";
async pGetScaledCreature ({isMon, name, source, scaledCr, scaledSummonSpellLevel, scaledSummonClassLevel}) {
if (!isMon) return null;
return DmScreenUtil.pGetScaledCreature({name, source, scaledCr, scaledSummonSpellLevel, scaledSummonClassLevel});
}
/* -------------------------------------------- */
/**
* @inheritDoc
*/
async pGetNewRowState (
{
rows = null,
isActive = null,
isPlayerVisible = null,
name = null,
displayName = null,
scaledCr = null,
scaledSummonSpellLevel = null,
scaledSummonClassLevel = null,
customName = null,
source = null,
hpCurrent = null,
hpMax = null,
initiative = null,
ordinal = null,
rowStatColData = null,
conditions = null,
} = {},
) {
const isMon = name && source;
const mon = await this.pGetScaledCreature({isMon, name, source, scaledCr, scaledSummonSpellLevel, scaledSummonClassLevel});
if (isMon && !mon) return null;
const fluff = mon ? await Renderer.monster.pGetFluff(mon) : null;
if (isMon) {
if (hpCurrent == null && hpMax == null) {
hpCurrent = hpMax = await this._roller.pGetOrRollHp(mon, {isRollHp: this._comp._state.isRollHp});
}
if (initiative == null && this._comp._state.isRollInit) {
initiative = await this._roller.pGetRollInitiative({mon});
}
if (ordinal == null) {
const existingCreatures = this.getSimilarRows({
rows,
rowEntity: {
name,
customName,
scaledCr,
scaledSummonSpellLevel,
scaledSummonClassLevel,
source,
},
});
ordinal = existingCreatures.length + 1;
}
if (isPlayerVisible == null) isPlayerVisible = !this._comp._state.playerInitHideNewMonster;
}
if (!isMon) {
if (isPlayerVisible == null) isPlayerVisible = true;
}
if (rowStatColData == null) rowStatColData = this._getInitialRowStatColData({mon, fluff});
return {
id: CryptUtil.uid(),
entity: {
isActive: !!isActive,
isPlayerVisible: !!isPlayerVisible,
name,
displayName,
scaledCr,
scaledSummonSpellLevel,
scaledSummonClassLevel,
customName,
source,
hpCurrent,
hpMax,
initiative,
ordinal,
rowStatColData: rowStatColData ?? [],
conditions: conditions ?? [],
},
};
}
/* -------------------------------------------- */
async pGetRowInitiativeMeta ({row}) {
const out = await super.pGetRowInitiativeMeta({row});
const mon = await this.pGetScaledCreature(row.entity);
if (!mon) return out;
out.mon = mon;
out.initiativeModifier ||= Parser.getAbilityModifier(mon.dex);
return out;
}
/* -------------------------------------------- */
static _SIMILAR_ROW_PROPS = [
"name",
"customName",
"scaledCr",
"scaledSummonSpellLevel",
"scaledSummonClassLevel",
"source",
];
static getSimilarRowEntityHash ({rowEntity}) {
return this._SIMILAR_ROW_PROPS
.map(prop => JSON.stringify(rowEntity[prop] ?? null))
.join("__");
}
getSimilarRows (
{
rows = null,
rowEntity,
},
) {
const rowEntityHash = this.constructor.getSimilarRowEntityHash({rowEntity});
return (rows || this._comp._state[this._prop])
.filter(({entity}) => {
return this.constructor.getSimilarRowEntityHash({rowEntity: entity}) === rowEntityHash;
});
}
getSimilarRowCounts (
{
rows = null,
},
) {
return (rows || this._comp._state[this._prop])
.reduce(
(accum, {entity}) => {
const rowEntityHash = InitiativeTrackerRowStateBuilderActive.getSimilarRowEntityHash({rowEntity: entity});
accum[rowEntityHash] = (accum[rowEntityHash] || 0) + 1;
return accum;
},
{},
);
}
}
export class InitiativeTrackerRowStateBuilderDefaultParty extends _InitiativeTrackerRowStateBuilderBase {
_prop = "rowsDefaultParty";
}

View File

@@ -0,0 +1,144 @@
import {InitiativeTrackerDataSerializerBase} from "./dmscreen-initiativetracker-util.js";
export class InitiativeTrackerStatColumnDataSerializer extends InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {
"id": "id",
"isEditable": "e",
"isPlayerVisible": "v",
"populateWith": "p",
"abbreviation": "a",
};
}
export class InitiativeTrackerConditionCustomSerializer extends InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {
"id": "id",
"entity.name": "n",
"entity.color": "c",
"entity.turns": "t",
};
}
export class InitiativeTrackerRowStatsColDataSerializer extends InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {
"id": "id",
"entity.value": "v",
"entity.current": "cur",
"entity.max": "max",
};
}
export class InitiativeTrackerRowDataSerializer extends InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {
"id": "id",
// region Flattened `"nameMeta"`
"entity.name": "n",
"entity.displayName": "n_d",
"entity.scaledCr": "n_scr",
"entity.scaledSummonSpellLevel": "n_ssp",
"entity.scaledSummonClassLevel": "n_scl",
// region Used by player tracker
"entity.customName": "n_m",
// endregion
// endregion
"entity.hpCurrent": "h",
"entity.hpMax": "g",
"entity.initiative": "i",
"entity.isActive": "a",
"entity.source": "s",
"entity.conditions": "c",
"entity.isPlayerVisible": "v",
// region Used by player tracker
"entity.hpWoundLevel": "hh",
"entity.ordinal": "o",
// endregion
// region Specific handling
// "entity.rowStatColData": "k",
// endregion
};
static fromSerial (dataSerial) {
// Handle legacy data format
if (dataSerial.n instanceof Object) {
dataSerial.n_d = dataSerial.n.d || null;
dataSerial.n_scr = dataSerial.n.scr || null;
dataSerial.n_ssp = dataSerial.n.ssp || null;
dataSerial.n_scl = dataSerial.n.scl || null;
dataSerial.n_m = dataSerial.n.m || null;
dataSerial.n = dataSerial.n.n;
}
const out = super.fromSerial(dataSerial);
// Convert legacy data
out.id = out.id || CryptUtil.uid();
out.entity.rowStatColData = (dataSerial.k || [])
.map(rowStatColData => {
const out = InitiativeTrackerRowStatsColDataSerializer.fromSerial(rowStatColData);
// If the cell had no data, the `entity` prop may have been serialized away. Ensure it exists.
if (!out.entity) out.entity = {};
return out;
});
// Convert legacy data
if (out.entity.conditions?.length) {
out.entity.conditions = out.entity.conditions
.map(cond => {
if (cond.id) return cond;
return {
id: CryptUtil.uid(),
entity: {
...cond,
},
};
});
}
// Convert legacy data
if (out.entity.ordinal == null) out.entity.ordinal = 1;
// Convert legacy numbers
[
"scaledCr",
"scaledSummonSpellLevel",
"scaledSummonClassLevel",
"hpCurrent",
"hpMax",
"initiative",
"ordinal",
]
.forEach(prop => {
if (out.entity?.[prop] == null) return;
if (isNaN(out.entity?.[prop])) return delete out.entity[prop];
out.entity[prop] = Number(out.entity[prop]);
});
// Convert legacy booleans
[
"isActive",
]
.forEach(prop => {
if (out.entity?.[prop] == null) return;
out.entity[prop] = !!out.entity[prop];
});
return out;
}
static toSerial (data) {
const out = super.toSerial(data);
out.k = (data.entity.rowStatColData || [])
.map(rowStatColData => InitiativeTrackerRowStatsColDataSerializer.toSerial(rowStatColData));
return out;
}
}

View File

@@ -0,0 +1,219 @@
import {InitiativeTrackerUi} from "./dmscreen-initiativetracker-ui.js";
import {
GROUP_DISPLAY_NAMES,
InitiativeTrackerStatColumnFactory,
IS_PLAYER_VISIBLE_ALL,
IS_PLAYER_VISIBLE_NONE,
IS_PLAYER_VISIBLE_PLAYER_UNITS_ONLY,
} from "./dmscreen-initiativetracker-statcolumns.js";
class _RenderableCollectionStatsCols extends RenderableCollectionGenericRows {
constructor (
{
comp,
doClose,
$wrpRows,
},
) {
super(comp, "statsCols", $wrpRows);
this._doClose = doClose;
}
_populateRow ({comp, $wrpRow, entity}) {
$wrpRow.addClass("py-1p");
const meta = InitiativeTrackerStatColumnFactory.fromPopulateWith({populateWith: comp._state.populateWith});
const $iptAbv = ComponentUiUtil.$getIptStr(comp, "abbreviation");
const $cbIsEditable = ComponentUiUtil.$getCbBool(comp, "isEditable");
const $btnVisible = InitiativeTrackerUi.$getBtnPlayerVisible(
comp._state.isPlayerVisible,
() => comp._state.isPlayerVisible = $btnVisible.hasClass("btn-primary--half")
? IS_PLAYER_VISIBLE_PLAYER_UNITS_ONLY
: $btnVisible.hasClass("btn-primary")
? IS_PLAYER_VISIBLE_ALL
: IS_PLAYER_VISIBLE_NONE,
true,
);
const $btnDelete = this._utils.$getBtnDelete({entity});
const $padDrag = this._utils.$getPadDrag({$wrpRow});
$$($wrpRow)`
<div class="col-5 pr-1">${meta.constructor.NAME}</div>
<div class="col-3 pr-1">${$iptAbv}</div>
<div class="col-1-5 ve-text-center">${$cbIsEditable}</div>
<div class="col-1-5 ve-text-center">${$btnVisible}</div>
<div class="col-0-5 ve-flex-vh-center">${$btnDelete}</div>
<div class="col-0-5 ve-flex-vh-center">${$padDrag}</div>
`;
}
}
export class InitiativeTrackerSettings extends BaseComponent {
static _PROPS_TRACKED = [
"isRollInit",
"isRollHp",
"isRollGroups",
"isRerollInitiativeEachRound",
"playerInitShowExactPlayerHp",
"playerInitShowExactMonsterHp",
"playerInitHideNewMonster",
"playerInitShowOrdinals",
"isStatsAddColumns",
"statsCols",
];
constructor ({state}) {
super();
this._proxyAssignSimple(
"state",
{
...InitiativeTrackerSettings._PROPS_TRACKED
.mergeMap(prop => ({[prop]: state[prop]})),
statsCols: this._getStatColsCollectionFormat(state.statsCols),
},
);
}
/* -------------------------------------------- */
// Convert from classic "flat" format to renderable collection format
_getStatColsCollectionFormat (statsCols) {
return (statsCols || [])
.map(data => {
return InitiativeTrackerStatColumnFactory.fromStateData({data})
.getAsCollectionRowStateData();
});
}
// Convert from renderable collection format to classic "flat" format
_getStatColsDataFormat (statsCols) {
return (statsCols || [])
.map(data => {
return InitiativeTrackerStatColumnFactory.fromCollectionRowStateData({data})
.getAsStateData();
});
}
/* -------------------------------------------- */
getStateUpdate () {
const out = MiscUtil.copyFast(this._state);
out.statsCols = this._getStatColsDataFormat(out.statsCols);
return out;
}
/* -------------------------------------------- */
pGetShowModalResults () {
const {$modalInner, $modalFooter, pGetResolved, doClose} = UiUtil.getShowModal({
title: "Settings",
isUncappedHeight: true,
hasFooter: true,
});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_isRolls({$modalInner});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_playerView({$modalInner});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_additionalCols({$modalInner});
this._pGetShowModalResults_renderFooter({$modalFooter, doClose});
return pGetResolved();
}
/* -------------------------------------------- */
_pGetShowModalResults_renderSection_isRolls ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollInit", text: "Roll initiative"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollHp", text: "Roll hit points"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollGroups", text: "Roll groups of creatures together"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRerollInitiativeEachRound", text: "Reroll initiative each round"});
}
_pGetShowModalResults_renderSection_playerView ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "playerInitShowExactPlayerHp", text: "Player View: Show exact player HP"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "playerInitShowExactMonsterHp", text: "Player View: Show exact monster HP"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "playerInitHideNewMonster", text: "Player View: Auto-hide new monsters"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "playerInitShowOrdinals", text: "Player View: Show ordinals", title: "For example, if you add two Goblins, one will be Goblin (1) and the other Goblin (2), rather than having identical names."});
}
_pGetShowModalResults_renderSection_additionalCols ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isStatsAddColumns", text: "Additional Columns"});
this._pGetShowModalResults_renderSection_additionalCols_head({$modalInner});
this._pGetShowModalResults_renderSection_additionalCols_body({$modalInner});
}
_pGetShowModalResults_renderSection_additionalCols_head ({$modalInner}) {
const getAction = Cls => new ContextUtil.Action(
Cls.NAME,
() => {
this._state.statsCols = [...this._state.statsCols, new Cls().getAsCollectionRowStateData()];
},
);
const menuAddStatsCol = ContextUtil.getMenu(
InitiativeTrackerStatColumnFactory.getGroupedByUi()
.map(group => {
const [ClsHead] = group;
if (group.length === 1) return getAction(ClsHead);
return new ContextUtil.ActionSubMenu(
GROUP_DISPLAY_NAMES[ClsHead.GROUP],
group.map(Cls => getAction(Cls)),
);
}),
);
const $btnAddRow = $(`<button class="btn btn-default btn-xs bb-0 bbr-0 bbl-0" title="Add"><span class="glyphicon glyphicon-plus"></span></button>`)
.click(evt => ContextUtil.pOpenMenu(evt, menuAddStatsCol));
const $wrpTblStatsHead = $$`<div class="ve-flex-vh-center w-100 mb-2 bb-1p-trans">
<div class="col-5">Contains</div>
<div class="col-3">Abbreviation</div>
<div class="col-1-5 ve-text-center help" title="Only affects creatures. Players are always editable.">Editable</div>
<div class="col-1-5">&nbsp;</div>
<div class="col-1 ve-flex-v-center ve-flex-h-right">${$btnAddRow}</div>
</div>`
.appendTo($modalInner);
this._addHookBase("isStatsAddColumns", () => $wrpTblStatsHead.toggleVe(this._state.isStatsAddColumns))();
}
_pGetShowModalResults_renderSection_additionalCols_body ({$modalInner}) {
const $wrpRows = $(`<div class="pr-1 h-120p ve-flex-col overflow-y-auto relative"></div>`).appendTo($modalInner);
this._addHookBase("isStatsAddColumns", () => $wrpRows.toggleVe(this._state.isStatsAddColumns))();
const renderableCollectionStatsCols = new _RenderableCollectionStatsCols(
{
comp: this,
$wrpRows,
},
);
this._addHookBase("statsCols", () => {
renderableCollectionStatsCols.render();
})();
}
/* -------------------------------------------- */
_pGetShowModalResults_renderFooter ({$modalFooter, doClose}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => doClose(true));
$$($modalFooter)`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
}

View File

@@ -0,0 +1,61 @@
import {InitiativeTrackerConst} from "./dmscreen-initiativetracker-consts.js";
export class InitiativeTrackerSort {
static _getSortMultiplier ({sortDir}) {
return sortDir === InitiativeTrackerConst.SORT_DIR_DESC ? -1 : 1;
}
static _sortRowsNameBasic ({sortDir}, rowA, rowB) {
return this._getSortMultiplier({sortDir}) * SortUtil.ascSortLower(
rowA.entity.customName || rowA.entity.name || "",
rowB.entity.customName || rowB.entity.name || "",
);
}
static _sortRowsInitiativeBasic ({sortDir}, rowA, rowB) {
return this._getSortMultiplier({sortDir}) * SortUtil.ascSort(
rowA.entity.initiative || 0,
rowB.entity.initiative || 0,
);
}
static _sortRowsOrdinal (rowA, rowB) {
// (Ordinals are always sorted ascending)
return SortUtil.ascSort(rowA.entity.ordinal || 0, rowB.entity.ordinal || 0);
}
static _sortRowsId (rowA, rowB) {
// (IDs are always sorted ascending)
return SortUtil.ascSort(rowA.id, rowB.id);
}
static _sortRowsName ({sortDir}, rowA, rowB) {
return this._sortRowsNameBasic({sortDir}, rowA, rowB)
|| this._sortRowsOrdinal(rowA, rowB)
|| this._sortRowsInitiativeBasic({sortDir}, rowA, rowB)
|| this._sortRowsId(rowA, rowB); // Fallback on ID to guarantee stable sort
}
static _sortRowsInitiative ({sortDir}, rowA, rowB) {
return this._sortRowsInitiativeBasic({sortDir}, rowA, rowB)
|| this._sortRowsNameBasic({sortDir}, rowA, rowB)
|| this._sortRowsOrdinal(rowA, rowB)
|| this._sortRowsId(rowA, rowB); // Fallback on ID to guarantee stable sort
}
static getSortedRows ({rows, sortBy, sortDir}) {
const fnSort = sortBy === InitiativeTrackerConst.SORT_ORDER_ALPHA
? this._sortRowsName.bind(this, {sortDir})
: this._sortRowsInitiative.bind(this, {sortDir});
return [...rows].sort(fnSort);
}
/* -------------------------------------------- */
static sortInitiativeInfos (a, b) {
return SortUtil.ascSort(b.applicability, a.applicability)
|| SortUtil.ascSort(b.initiative, a.initiative)
|| SortUtil.ascSort(a.id, b.id); // Fallback on ID to guarantee stable sort
}
}

View File

@@ -0,0 +1,718 @@
import {InitiativeTrackerConst} from "./dmscreen-initiativetracker-consts.js";
import {
InitiativeTrackerRowStatsColDataSerializer,
InitiativeTrackerStatColumnDataSerializer,
} from "./dmscreen-initiativetracker-serial.js";
import {InitiativeTrackerUtil} from "../../initiativetracker/initiativetracker-utils.js";
export const GROUP_BASE_STATS = "baseStats";
export const GROUP_SAVES = "saves";
export const GROUP_ABILITY_BONUS = "abilityBonus";
export const GROUP_ABILITY_SCORE = "abilityScore";
export const GROUP_SKILL = "skill";
export const GROUP_CHECKBOX = "checkbox";
export const GROUP_CUSTOM = "custom";
export const GROUP_DISPLAY_NAMES = {
[GROUP_BASE_STATS]: "General",
[GROUP_SAVES]: "Saving Throw",
[GROUP_ABILITY_BONUS]: "Ability Bonus",
[GROUP_ABILITY_SCORE]: "Ability Score",
[GROUP_SKILL]: "Skill",
[GROUP_CHECKBOX]: "Checkbox",
[GROUP_CUSTOM]: "Custom",
};
export const IS_PLAYER_VISIBLE_NONE = 0;
export const IS_PLAYER_VISIBLE_ALL = 1;
export const IS_PLAYER_VISIBLE_PLAYER_UNITS_ONLY = 2;
export const INITIATIVE_APPLICABILITY_NOT_APPLICABLE = 0;
const _INITIATIVE_APPLICABILITY_APPLICABLE = 1;
const _INITIATIVE_APPLICABILITY_EXACT = 2;
/** @abstract */
class _InitiativeTrackerStatColumnBase {
static _SERIALIZER_MAPPINGS = {
"entity.value": "v",
};
static _registerSerializerMappings () {
Object.entries(this._SERIALIZER_MAPPINGS)
.forEach(([kFull, kSerial]) => InitiativeTrackerRowStatsColDataSerializer.registerMapping({kFull, kSerial, isAllowDuplicates: true}));
return null;
}
static _ = this._registerSerializerMappings();
/* -------------------------------------------- */
/** Functions as an ID for the column type. */
static get POPULATE_WITH () { throw new Error("Unimplemented!"); }
/** UI group the column type belongs to. */
static GROUP;
static NAME;
static ABV_DEFAULT = "";
constructor (
{
id,
isEditable,
isPlayerVisible,
abbreviation,
} = {},
) {
this._id = id ?? CryptUtil.uid();
this._isEditable = isEditable ?? true;
this._isPlayerVisible = isPlayerVisible ?? false;
this._abbreviation = abbreviation ?? this.constructor.ABV_DEFAULT;
}
/**
* @param {?object} mon
* @param {?object} fluff
* @return {?*}
* @abstract
*/
_getInitialCellObj ({mon, fluff}) { throw new Error("Unimplemented!"); }
_getAsData () {
return {
id: this._id,
isEditable: this._isEditable,
isPlayerVisible: this._isPlayerVisible,
populateWith: this.constructor.POPULATE_WITH,
abbreviation: this._abbreviation,
};
}
getAsStateData () {
return this._getAsData();
}
getAsCollectionRowStateData () {
const data = this._getAsData();
const out = {
id: data.id,
entity: {
...data,
},
};
delete out.entity.id;
return out;
}
getPlayerFriendlyState ({cell}) {
return {id: cell.id, entity: cell.entity};
}
/* -------------------------------------------- */
/**
* @return {?object} `undefined` if the column should not auto-set at the start of the turn, or, a value the column should
* be auto-set to at the start of the turn.
*/
_getAutoTurnStartObject ({state, mon}) { return undefined; }
/**
* @return {?object} `undefined` if the column should not auto-set at the start of the round, or, a value the column should
* be auto-set to at the start of the round.
*/
_getAutoRoundStartObject ({state, mon}) { return undefined; }
_isNonNegativeDirection ({direction}) {
return ![InitiativeTrackerConst.DIR_FORWARDS, InitiativeTrackerConst.DIR_NEUTRAL].includes(direction);
}
onTurnStart ({state, direction, mon}) {
if (this._isNonNegativeDirection({direction})) return;
const obj = this._getAutoTurnStartObject({state, mon});
if (obj !== undefined) Object.assign(state.entity, obj);
}
onRoundStart ({state, direction, mon}) {
if (this._isNonNegativeDirection({direction})) return;
const obj = this._getAutoRoundStartObject({state, mon});
if (obj !== undefined) Object.assign(state.entity, obj);
}
$getRenderedHeader () {
return $(`<div class="dm-init__stat_head" ${this.constructor.NAME ? `title="${this.constructor.NAME}"` : ""}>${this._abbreviation}</div>`);
}
$getRendered ({comp, mon, networking = null}) {
const $ipt = ComponentUiUtil.$getIptStr(comp, "value")
.removeClass("input-xs")
.addClass("input-sm")
.addClass("dm-init__stat_ipt")
.addClass("ve-text-center")
.on("click", () => $ipt.select());
if (mon) {
comp._addHookBase("isEditable", () => {
$ipt.prop("disabled", !comp._state.isEditable);
})();
}
return $$`<div class="ve-flex-vh-center">${$ipt}</div>`;
}
/* -------------------------------------------- */
/**
* @param {?object} mon
* @param {?object} fluff
* @param {?object} obj
*/
getInitialCellStateData ({mon = null, fluff = null, obj = null} = {}) {
return {
id: this._id,
entity: {
...(obj ?? (this._getInitialCellObj({mon, fluff}) || {})),
isEditable: this._isEditable,
},
};
}
/* -------------------------------------------- */
getInitiativeInfo ({state}) {
return {
applicability: INITIATIVE_APPLICABILITY_NOT_APPLICABLE,
initiative: null,
};
}
}
class InitiativeTrackerStatColumn_HpFormula extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "hpFormula"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "HP Formula";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {value: (mon.hp || {}).formula || ""};
}
}
class InitiativeTrackerStatColumn_ArmorClass extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "armorClass"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Armor Class";
static ABV_DEFAULT = "AC";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {value: mon.ac[0] ? (mon.ac[0].ac || mon.ac[0]) : null};
}
}
class InitiativeTrackerStatColumn_PassivePerception extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "passivePerception"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Passive Perception";
static ABV_DEFAULT = "PP";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {value: mon.passive};
}
}
class InitiativeTrackerStatColumn_Speed extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "speed"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Speed";
static ABV_DEFAULT = "SPD";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: Math.max(0, ...Object.values(mon.speed || {})
.map(it => it.number ? it.number : it)
.filter(it => typeof it === "number")),
};
}
}
class InitiativeTrackerStatColumn_SpellDc extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "spellDc"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Spell DC";
static ABV_DEFAULT = "DC";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: Math.max(
0,
...(mon.spellcasting || [])
.filter(it => it.headerEntries)
.map(it => {
return it.headerEntries
.map(it => {
const found = [0];
it
.replace(/DC (\d+)/g, (...m) => found.push(Number(m[1])))
.replace(/{@dc (\d+)}/g, (...m) => found.push(Number(m[1])));
return Math.max(...found);
})
.filter(Boolean);
})
.flat(),
),
};
}
}
class InitiativeTrackerStatColumn_Initiative extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "initiative"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Initiative";
static ABV_DEFAULT = "INIT";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: Parser.getAbilityModifier(mon.dex),
};
}
/* -------------------------------------------- */
getInitiativeInfo ({state}) {
return {
applicability: _INITIATIVE_APPLICABILITY_EXACT,
initiative: isNaN(state.entity?.value) ? 0 : Number(state.entity.value),
};
}
}
class InitiativeTrackerStatColumn_LegendaryActions extends _InitiativeTrackerStatColumnBase {
static _SERIALIZER_MAPPINGS = {
"entity.current": "cur",
"entity.max": "max",
};
static _ = this._registerSerializerMappings();
/* -------------------------------------------- */
static get POPULATE_WITH () { return "legendaryActions"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Legendary Actions";
static ABV_DEFAULT = "LA";
getPlayerFriendlyState ({cell}) {
return {
...super.getPlayerFriendlyState({cell}),
entity: {
value: `${cell.entity.current || 0}/${cell.entity.max || 0}`,
},
};
}
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {current: null, max: null};
const cnt = mon.legendaryActions ?? (mon.legendary ? 3 : null);
return {
current: cnt,
max: cnt,
};
}
_getAutoRoundStartObject ({state, mon}) {
return {current: state.entity.max};
}
$getRenderedHeader () {
return super.$getRenderedHeader()
.addClass("w-48p");
}
$getRendered ({comp, mon}) {
const $iptCurrent = ComponentUiUtil.$getIptNumber(
comp,
"current",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm hp dm-init__row-input text-right w-24p mr-0 br-0">`,
},
)
.on("click", () => $iptCurrent.select());
const $iptMax = ComponentUiUtil.$getIptNumber(
comp,
"max",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm hp-max dm-init__row-input w-24p mr-0 bl-0">`,
},
)
.on("click", () => $iptMax.select());
if (mon) {
comp._addHookBase("isEditable", () => {
$iptCurrent.prop("disabled", !comp._state.isEditable);
$iptMax.prop("disabled", !comp._state.isEditable);
})();
}
return $$`<div class="ve-flex relative mr-3p">
<div class="text-right">${$iptCurrent}</div>
<div class="dm-init__sep-fields-slash ve-flex-vh-center">/</div>
<div class="text-left">${$iptMax}</div>
</div>`;
}
}
class InitiativeTrackerStatColumn_Image extends _InitiativeTrackerStatColumnBase {
static _SERIALIZER_MAPPINGS = {
"entity.tokenUrl": "urlt",
"entity.imageHref": "hrefi",
};
static _ = this._registerSerializerMappings();
/* -------------------------------------------- */
static get POPULATE_WITH () { return "image"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Image";
static ABV_DEFAULT = "IMG";
getPlayerFriendlyState ({cell}) {
return {
...super.getPlayerFriendlyState({cell}),
entity: {
type: "image",
tokenUrl: cell.entity.tokenUrl,
imageHref: cell.entity.imageHref,
},
};
}
_getInitialCellObj ({mon, fluff}) {
if (!mon) {
return {
tokenUrl: UrlUtil.link(Renderer.get().getMediaUrl("img", "blank-friendly.webp")),
imageHref: null,
};
}
return {
tokenUrl: Renderer.monster.getTokenUrl(mon),
imageHref: fluff?.images?.[0].href,
};
}
$getRendered ({comp, mon, networking = null}) {
const $ele = $$`<div class="mr-3p ve-flex-vh-center w-40p">
<img src="${comp._state.tokenUrl}" class="w-30p h-30p" alt="Token Image">
</div>`;
if (networking != null) {
$ele.title("Click to Show to Connected Players");
$ele
.on("click", () => {
networking.sendShowImageMessageToClients({
imageHref: InitiativeTrackerUtil.getImageOrTokenHref({imageHref: comp._state.imageHref, tokenUrl: comp._state.tokenUrl}),
});
});
// If no networking, assume this is an "edit" modal, and avoid binding events
Renderer.monster.hover.bindFluffImageMouseover({mon, $ele});
}
return $ele;
}
}
class InitiativeTrackerStatColumn_Save extends _InitiativeTrackerStatColumnBase {
static _ATT;
static get POPULATE_WITH () { return `${this._ATT}Save`; }
static GROUP = GROUP_SAVES;
static get NAME () { return `${Parser.attAbvToFull(this._ATT)} Save`; }
static get ABV_DEFAULT () { return this._ATT.toUpperCase(); }
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: mon.save?.[this.constructor._ATT] ? mon.save[this.constructor._ATT] : Parser.getAbilityModifier(mon[this.constructor._ATT]),
};
}
}
class InitiativeTrackerStatColumn_AbilityBonus extends _InitiativeTrackerStatColumnBase {
static _ATT;
static get POPULATE_WITH () { return `${this._ATT}Bonus`; }
static GROUP = GROUP_ABILITY_BONUS;
static get NAME () { return `${Parser.attAbvToFull(this._ATT)} Bonus`; }
static get ABV_DEFAULT () { return this._ATT.toUpperCase(); }
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: Parser.getAbilityModifier(mon[this.constructor._ATT]),
};
}
/* -------------------------------------------- */
getInitiativeInfo ({state}) {
if (this.constructor._ATT !== "dex") return super.getInitiativeInfo({state});
return {
applicability: _INITIATIVE_APPLICABILITY_APPLICABLE,
initiative: isNaN(state.entity?.value) ? 0 : Number(state.entity.value),
};
}
}
class InitiativeTrackerStatColumn_AbilityScore extends _InitiativeTrackerStatColumnBase {
static _ATT;
static get POPULATE_WITH () { return `${this._ATT}Score`; }
static GROUP = GROUP_ABILITY_SCORE;
static get NAME () { return `${Parser.attAbvToFull(this._ATT)} Score`; }
static get ABV_DEFAULT () { return this._ATT.toUpperCase(); }
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: mon[this.constructor._ATT],
};
}
/* -------------------------------------------- */
getInitiativeInfo ({state}) {
if (this.constructor._ATT !== "dex") return super.getInitiativeInfo({state});
return {
applicability: _INITIATIVE_APPLICABILITY_APPLICABLE,
initiative: isNaN(state.entity?.value) ? 0 : Parser.getAbilityModifier(Number(state.entity.value)),
};
}
}
class InitiativeTrackerStatColumn_Skill extends _InitiativeTrackerStatColumnBase {
static _SKILL;
static get POPULATE_WITH () { return this._SKILL.toCamelCase(); }
static GROUP = GROUP_SKILL;
static get NAME () { return this._SKILL.toTitleCase(); }
static get ABV_DEFAULT () { return Parser.skillToShort(this._SKILL).toUpperCase(); }
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: mon.skill?.[this.constructor._SKILL]
? mon.skill[this.constructor._SKILL]
: Parser.getAbilityModifier(mon[Parser.skillToAbilityAbv(this.constructor._SKILL)]),
};
}
}
class _InitiativeTrackerStatColumnCheckboxBase extends _InitiativeTrackerStatColumnBase {
static GROUP = GROUP_CHECKBOX;
static _AUTO_VALUE = undefined;
_getInitialCellObj ({mon, fluff}) { return {value: false}; }
$getRendered ({comp, mon}) {
const $cb = ComponentUiUtil.$getCbBool(comp, "value")
.addClass("dm-init__stat_ipt");
if (mon) {
comp._addHookBase("isEditable", () => {
$cb.prop("disabled", !comp._state.isEditable);
})();
}
return $$`<label class="dm-init__wrp-stat-cb h-100 ve-flex-vh-center">${$cb}</label>`;
}
}
class _InitiativeTrackerStatColumnCheckboxTurnBase extends _InitiativeTrackerStatColumnCheckboxBase {
_getAutoTurnStartObject ({state, mon}) { return {value: this.constructor._AUTO_VALUE}; }
}
class _InitiativeTrackerStatColumnCheckboxRoundBase extends _InitiativeTrackerStatColumnCheckboxBase {
_getAutoRoundStartObject ({state, mon}) { return {value: this.constructor._AUTO_VALUE}; }
}
class InitiativeTrackerStatColumn_Checkbox extends _InitiativeTrackerStatColumnCheckboxBase {
static get POPULATE_WITH () { return "cbNeutral"; }
static NAME = "Checkbox";
}
class InitiativeTrackerStatColumn_CheckboxAutoTurnLow extends _InitiativeTrackerStatColumnCheckboxTurnBase {
static get POPULATE_WITH () { return "cbAutoLow"; }
static NAME = "Checkbox; clears at start of turn";
static _AUTO_VALUE = false;
}
class InitiativeTrackerStatColumn_CheckboxAutoTurnHigh extends _InitiativeTrackerStatColumnCheckboxTurnBase {
static get POPULATE_WITH () { return "cbAutoHigh"; }
static NAME = "Checkbox; ticks at start of turn";
static _AUTO_VALUE = true;
_getInitialCellObj ({mon, fluff}) { return {value: true}; }
}
class InitiativeTrackerStatColumn_CheckboxAutoRoundLow extends _InitiativeTrackerStatColumnCheckboxRoundBase {
static get POPULATE_WITH () { return "cbAutoRoundLow"; }
static NAME = "Checkbox; clears at start of round";
static _AUTO_VALUE = false;
}
class InitiativeTrackerStatColumn_CheckboxAutoRoundHigh extends _InitiativeTrackerStatColumnCheckboxRoundBase {
static get POPULATE_WITH () { return "cbAutoRoundHigh"; }
static NAME = "Checkbox; ticks at start of round";
static _AUTO_VALUE = true;
_getInitialCellObj ({mon, fluff}) { return {value: true}; }
}
export class InitiativeTrackerStatColumn_Custom extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return ""; }
static GROUP = GROUP_CUSTOM;
static NAME = "(Custom)";
_getInitialCellObj ({mon, fluff}) { return {value: ""}; }
}
export class InitiativeTrackerStatColumnFactory {
static _COL_CLS_LOOKUP = {};
static _initLookup () {
[
InitiativeTrackerStatColumn_HpFormula,
InitiativeTrackerStatColumn_ArmorClass,
InitiativeTrackerStatColumn_PassivePerception,
InitiativeTrackerStatColumn_Speed,
InitiativeTrackerStatColumn_SpellDc,
InitiativeTrackerStatColumn_Initiative,
InitiativeTrackerStatColumn_LegendaryActions,
InitiativeTrackerStatColumn_Image,
].forEach(Cls => this._initLookup_addCls(Cls));
Parser.ABIL_ABVS
.forEach(abv => {
this._initLookup_addCls(class extends InitiativeTrackerStatColumn_Save { static _ATT = abv; });
});
Parser.ABIL_ABVS
.forEach(abv => {
this._initLookup_addCls(class extends InitiativeTrackerStatColumn_AbilityBonus { static _ATT = abv; });
});
Parser.ABIL_ABVS
.forEach(abv => {
this._initLookup_addCls(class extends InitiativeTrackerStatColumn_AbilityScore { static _ATT = abv; });
});
Object.keys(Parser.SKILL_TO_ATB_ABV)
.sort(SortUtil.ascSort)
.forEach(skill => {
this._initLookup_addCls(class extends InitiativeTrackerStatColumn_Skill { static _SKILL = skill; });
});
[
InitiativeTrackerStatColumn_Checkbox,
InitiativeTrackerStatColumn_CheckboxAutoTurnLow,
InitiativeTrackerStatColumn_CheckboxAutoTurnHigh,
InitiativeTrackerStatColumn_CheckboxAutoRoundLow,
InitiativeTrackerStatColumn_CheckboxAutoRoundHigh,
].forEach(Cls => this._initLookup_addCls(Cls));
}
static _initLookup_addCls (Cls) { this._COL_CLS_LOOKUP[Cls.POPULATE_WITH] = Cls; }
/* -------------------------------------------- */
static getGroupedByUi () {
const out = [
[InitiativeTrackerStatColumn_Custom],
];
let groupPrev = GROUP_CUSTOM;
Object.values(this._COL_CLS_LOOKUP)
.forEach(Cls => {
if (groupPrev !== Cls.GROUP) out.push([]);
out.last().push(Cls);
groupPrev = Cls.GROUP;
});
return out;
}
/* -------------------------------------------- */
/**
* @param dataSerial
* @param data
* @return {_InitiativeTrackerStatColumnBase}
*/
static fromStateData ({dataSerial, data}) {
if (dataSerial && data) throw new Error(`Only one of "dataSerial" and "data" may be provided!`);
data = data ?? InitiativeTrackerStatColumnDataSerializer.fromSerial(dataSerial);
const Cls = this._COL_CLS_LOOKUP[data.populateWith] ?? InitiativeTrackerStatColumn_Custom;
return new Cls(data);
}
/**
* @param colName
* @return {_InitiativeTrackerStatColumnBase}
*/
static fromEncounterAdvancedColName ({colName}) {
colName = colName.toLowerCase().trim();
const Cls = Object.values(this._COL_CLS_LOOKUP)
.find(Cls => Cls.ABV_DEFAULT.toLowerCase() === colName)
|| InitiativeTrackerStatColumn_Custom;
return new Cls({
id: CryptUtil.uid(),
isEditable: true,
isPlayerVisible: IS_PLAYER_VISIBLE_PLAYER_UNITS_ONLY,
abbreviation: colName,
});
}
/**
* @param populateWith
* @return {_InitiativeTrackerStatColumnBase}
*/
static fromPopulateWith ({populateWith}) {
const Cls = this._COL_CLS_LOOKUP[populateWith] ?? InitiativeTrackerStatColumn_Custom;
return new Cls({});
}
/**
* @param data
* @return {_InitiativeTrackerStatColumnBase}
*/
static fromCollectionRowStateData ({data}) {
const flat = {id: data.id, ...data.entity};
return this.fromStateData({data: flat});
}
}
InitiativeTrackerStatColumnFactory._initLookup();

View File

@@ -0,0 +1,25 @@
export class InitiativeTrackerUi {
static $getBtnPlayerVisible (isVisible, fnOnClick, isTriState, ...additionalClasses) {
let isVisNum = Number(isVisible || false);
const getTitle = () => isVisNum === 0 ? `Hidden in player view` : isVisNum === 1 ? `Shown in player view` : `Shown in player view on player characters, hidden in player view on monsters`;
const getClasses = () => `${isVisNum === 0 ? `btn-default` : isVisNum === 1 ? `btn-primary` : `btn-primary btn-primary--half`} btn btn-xs ${additionalClasses.join(" ")}`;
const getIconClasses = () => isVisNum === 0 ? `glyphicon glyphicon-eye-close` : `glyphicon glyphicon-eye-open`;
const $dispIcon = $(`<span class="glyphicon ${getIconClasses()}"></span>`);
const $btnVisible = $$`<button class="${getClasses()}" title="${getTitle()}" tabindex="-1">${$dispIcon}</button>`
.on("click", () => {
if (isVisNum === 0) isVisNum++;
else if (isVisNum === 1) isVisNum = isTriState ? 2 : 0;
else if (isVisNum === 2) isVisNum = 0;
$btnVisible.title(getTitle());
$btnVisible.attr("class", getClasses());
$dispIcon.attr("class", getIconClasses());
fnOnClick();
});
return $btnVisible;
}
}

View File

@@ -0,0 +1,44 @@
export class InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {};
static _getFieldMappings () {
return Object.entries(this._FIELD_MAPPINGS)
.map(([kFull, kSerial]) => {
const kFullParts = kFull.split(".");
return {
kFullParts,
kSerial,
};
});
}
static registerMapping ({kFull, kSerial, isAllowDuplicates = false}) {
if (!isAllowDuplicates) {
if (this._FIELD_MAPPINGS[kFull]) throw new Error(`Serializer key "${kFull}" was already registered!`);
}
if (!isAllowDuplicates || this._FIELD_MAPPINGS[kFull] == null) {
if (Object.values(this._FIELD_MAPPINGS).some(k => k === kSerial)) throw new Error(`Serializer value "${kFull}" was already registered!`);
}
this._FIELD_MAPPINGS[kFull] = kSerial;
}
/* -------------------------------------------- */
static fromSerial (dataSerial) {
const out = {};
this._getFieldMappings()
.filter(({kSerial}) => dataSerial[kSerial] != null)
.forEach(({kFullParts, kSerial}) => MiscUtil.set(out, ...kFullParts, dataSerial[kSerial]));
return out;
}
static toSerial (data) {
return this._getFieldMappings()
.filter(({kFullParts}) => MiscUtil.get(data, ...kFullParts) != null)
.mergeMap(({kFullParts, kSerial}) => ({[kSerial]: MiscUtil.get(data, ...kFullParts)}));
}
}

View File

@@ -0,0 +1,661 @@
import {InitiativeTrackerConst} from "./dmscreen-initiativetracker-consts.js";
import {InitiativeTrackerNetworking} from "./dmscreen-initiativetracker-networking.js";
import {InitiativeTrackerSettings} from "./dmscreen-initiativetracker-settings.js";
import {InitiativeTrackerSettingsImport} from "./dmscreen-initiativetracker-importsettings.js";
import {InitiativeTrackerMonsterAdd} from "./dmscreen-initiativetracker-monsteradd.js";
import {InitiativeTrackerRoller} from "./dmscreen-initiativetracker-roller.js";
import {InitiativeTrackerEncounterConverter} from "./dmscreen-initiativetracker-encounterconverter.js";
import {
InitiativeTrackerStatColumnFactory,
IS_PLAYER_VISIBLE_ALL,
} from "./dmscreen-initiativetracker-statcolumns.js";
import {
InitiativeTrackerRowDataViewActive,
} from "./dmscreen-initiativetracker-rowsactive.js";
import {
InitiativeTrackerConditionCustomSerializer,
InitiativeTrackerRowDataSerializer,
InitiativeTrackerStatColumnDataSerializer,
} from "./dmscreen-initiativetracker-serial.js";
import {InitiativeTrackerSort} from "./dmscreen-initiativetracker-sort.js";
import {InitiativeTrackerUtil} from "../../initiativetracker/initiativetracker-utils.js";
import {DmScreenUtil} from "../dmscreen-util.js";
import {
InitiativeTrackerRowStateBuilderActive,
InitiativeTrackerRowStateBuilderDefaultParty,
} from "./dmscreen-initiativetracker-rowstatebuilder.js";
import {InitiativeTrackerDefaultParty} from "./dmscreen-initiativetracker-defaultparty.js";
import {ListUtilBestiary} from "../../utils-list-bestiary.js";
export class InitiativeTracker extends BaseComponent {
constructor ({board, savedState}) {
super();
this._board = board;
this._savedState = savedState;
this._networking = new InitiativeTrackerNetworking({board});
this._roller = new InitiativeTrackerRoller();
this._rowStateBuilderActive = new InitiativeTrackerRowStateBuilderActive({comp: this, roller: this._roller});
this._rowStateBuilderDefaultParty = new InitiativeTrackerRowStateBuilderDefaultParty({comp: this, roller: this._roller});
this._viewRowsActive = null;
this._viewRowsActiveMeta = null;
this._compDefaultParty = null;
this._creatureViewers = [];
}
render () {
if (this._viewRowsActiveMeta) this._viewRowsActiveMeta.cbDoCleanup();
this._resetHooks("state");
this._resetHooksAll("state");
this._setStateFromSerialized();
this._render_bindSortDirHooks();
const $wrpTracker = $(`<div class="dm-init dm__panel-bg dm__data-anchor"></div>`);
const sendStateToClientsDebounced = MiscUtil.debounce(
() => {
this._networking.sendStateToClients({fnGetToSend: this._getPlayerFriendlyState.bind(this)});
this._sendStateToCreatureViewers();
},
100, // long delay to avoid network spam
);
const doUpdateExternalStates = () => {
this._board.doSaveStateDebounced();
sendStateToClientsDebounced();
};
this._addHookAllBase(doUpdateExternalStates);
this._viewRowsActive = new InitiativeTrackerRowDataViewActive({
comp: this,
prop: "rows",
roller: this._roller,
networking: this._networking,
rowStateBuilder: this._rowStateBuilderActive,
});
this._viewRowsActiveMeta = this._viewRowsActive.getRenderedView();
this._viewRowsActiveMeta.$ele.appendTo($wrpTracker);
this._render_$getWrpFooter({doUpdateExternalStates}).appendTo($wrpTracker);
$wrpTracker.data("pDoConnectLocalV1", async () => {
const {token} = await this._networking.startServerV1({doUpdateExternalStates});
return token;
});
$wrpTracker.data("pDoConnectLocalV0", async (clientView) => {
await this._networking.pHandleDoConnectLocalV0({clientView});
sendStateToClientsDebounced();
});
$wrpTracker.data("getState", () => this._getSerializedState());
$wrpTracker.data("getSummary", () => {
const names = this._state.rows
.map(({entity}) => entity.name)
.filter(name => name && name.trim());
return `${this._state.rows.length} creature${this._state.rows.length === 1 ? "" : "s"} ${names.length ? `(${names.slice(0, 3).join(", ")}${names.length > 3 ? "..." : ""})` : ""}`;
});
$wrpTracker.data("pDoLoadEncounter", ({entityInfos, encounterInfo}) => this._pDoLoadEncounter({entityInfos, encounterInfo}));
$wrpTracker.data("getApi", () => this);
return $wrpTracker;
}
_render_$getWrpButtonsSort () {
const $btnSortAlpha = $(`<button title="Sort Alphabetically" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-sort-by-alphabet"></span></button>`)
.on("click", () => {
if (this._state.sort === InitiativeTrackerConst.SORT_ORDER_ALPHA) return this._doReverseSortDir();
this._proxyAssignSimple(
"state",
{
sort: InitiativeTrackerConst.SORT_ORDER_ALPHA,
dir: InitiativeTrackerConst.SORT_DIR_ASC,
},
);
});
const $btnSortNumber = $(`<button title="Sort Numerically" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-sort-by-order"></span></button>`)
.on("click", () => {
if (this._state.sort === InitiativeTrackerConst.SORT_ORDER_NUM) return this._doReverseSortDir();
this._proxyAssignSimple(
"state",
{
sort: InitiativeTrackerConst.SORT_ORDER_NUM,
dir: InitiativeTrackerConst.SORT_DIR_DESC,
},
);
});
const hkSortDir = () => {
$btnSortAlpha.toggleClass("active", this._state.sort === InitiativeTrackerConst.SORT_ORDER_ALPHA);
$btnSortNumber.toggleClass("active", this._state.sort === InitiativeTrackerConst.SORT_ORDER_NUM);
};
this._addHookBase("sort", hkSortDir);
this._addHookBase("dir", hkSortDir);
hkSortDir();
return $$`<div class="btn-group ve-flex">
${$btnSortAlpha}
${$btnSortNumber}
</div>`;
}
_render_$getWrpFooter ({doUpdateExternalStates}) {
const $btnAdd = $(`<button class="btn btn-primary btn-xs dm-init-lockable" title="Add Player"><span class="glyphicon glyphicon-plus"></span></button>`)
.on("click", async () => {
if (this._state.isLocked) return;
this._state.rows = [
...this._state.rows,
await this._rowStateBuilderActive.pGetNewRowState({
isPlayerVisible: true,
}),
]
.filter(Boolean);
});
const $btnAddMonster = $(`<button class="btn btn-success btn-xs dm-init-lockable mr-2" title="Add Monster"><span class="glyphicon glyphicon-print"></span></button>`)
.on("click", async () => {
if (this._state.isLocked) return;
const [isDataEntered, monstersToLoad] = await new InitiativeTrackerMonsterAdd({board: this._board, isRollHp: this._state.isRollHp})
.pGetShowModalResults();
if (!isDataEntered) return;
this._state.isRollHp = monstersToLoad.isRollHp;
const isGroupRollEval = monstersToLoad.count > 1 && this._state.isRollGroups;
const mon = isGroupRollEval
? await DmScreenUtil.pGetScaledCreature({
name: monstersToLoad.name,
source: monstersToLoad.source,
scaledCr: monstersToLoad.scaledCr,
scaledSummonSpellLevel: monstersToLoad.scaledSummonSpellLevel,
scaledSummonClassLevel: monstersToLoad.scaledSummonClassLevel,
})
: null;
const initiative = isGroupRollEval
? await this._roller.pGetRollInitiative({mon})
: null;
const rowsNxt = [...this._state.rows];
(await [...new Array(monstersToLoad.count)]
.pSerialAwaitMap(async () => {
const rowNxt = await this._rowStateBuilderActive.pGetNewRowState({
name: monstersToLoad.name,
source: monstersToLoad.source,
initiative,
rows: rowsNxt,
displayName: monstersToLoad.displayName,
customName: monstersToLoad.customName,
scaledCr: monstersToLoad.scaledCr,
scaledSummonSpellLevel: monstersToLoad.scaledSummonSpellLevel,
scaledSummonClassLevel: monstersToLoad.scaledSummonClassLevel,
});
if (!rowNxt) return;
rowsNxt.push(rowNxt);
}));
this._state.rows = rowsNxt;
});
const $btnSetPrevActive = $(`<button class="btn btn-default btn-xs" title="Previous Turn"><span class="glyphicon glyphicon-step-backward"></span></button>`)
.click(() => this._viewRowsActive.pDoShiftActiveRow({direction: InitiativeTrackerConst.DIR_BACKWARDS}));
const $btnSetNextActive = $(`<button class="btn btn-default btn-xs mr-2" title="Next Turn"><span class="glyphicon glyphicon-step-forward"></span></button>`)
.click(() => this._viewRowsActive.pDoShiftActiveRow({direction: InitiativeTrackerConst.DIR_FORWARDS}));
const $iptRound = ComponentUiUtil.$getIptInt(this, "round", 1, {min: 1})
.addClass("dm-init__rounds")
.removeClass("text-right")
.addClass("ve-text-center")
.title("Round");
const menuPlayerWindow = ContextUtil.getMenu([
new ContextUtil.Action(
"Standard",
async () => {
this._networking.handleClick_playerWindowV1({doUpdateExternalStates});
},
),
new ContextUtil.Action(
"Manual (Legacy)",
async () => {
this._networking.handleClick_playerWindowV0({doUpdateExternalStates});
},
),
]);
const $btnNetworking = $(`<button class="btn btn-primary btn-xs mr-2" title="Player View (SHIFT to Open &quot;Standard&quot; View)"><span class="glyphicon glyphicon-user"></span></button>`)
.click(evt => {
if (evt.shiftKey) return this._networking.handleClick_playerWindowV1({doUpdateExternalStates});
return ContextUtil.pOpenMenu(evt, menuPlayerWindow);
});
const $btnLock = $(`<button class="btn btn-danger btn-xs" title="Lock Tracker"><span class="glyphicon glyphicon-lock"></span></button>`)
.on("click", () => this._state.isLocked = !this._state.isLocked);
this._addHookBase("isLocked", () => {
$btnLock
.toggleClass("btn-success", !!this._state.isLocked)
.toggleClass("btn-danger", !this._state.isLocked)
.title(this._state.isLocked ? "Unlock Tracker" : "Lock Tracker");
$(".dm-init-lockable").toggleClass("disabled", !!this._state.isLocked);
$("input.dm-init-lockable").prop("disabled", !!this._state.isLocked);
})();
this._compDefaultParty = new InitiativeTrackerDefaultParty({comp: this, roller: this._roller, rowStateBuilder: this._rowStateBuilderDefaultParty});
const pHandleClickSettings = async () => {
const compSettings = new InitiativeTrackerSettings({state: MiscUtil.copyFast(this._state)});
await compSettings.pGetShowModalResults();
Object.assign(this._state, compSettings.getStateUpdate());
};
const menuConfigure = ContextUtil.getMenu([
new ContextUtil.Action(
"Settings",
() => pHandleClickSettings(),
),
null,
new ContextUtil.Action(
"Edit Default Party",
async () => {
await this._compDefaultParty.pGetShowModalResults();
},
),
]);
const $btnConfigure = $(`<button class="btn btn-default btn-xs mr-2" title="Configure (SHIFT to Open &quot;Settings&quot;)"><span class="glyphicon glyphicon-cog"></span></button>`)
.click(async evt => {
if (evt.shiftKey) return pHandleClickSettings();
return ContextUtil.pOpenMenu(evt, menuConfigure);
});
const menuImport = ContextUtil.getMenu([
...ListUtilBestiary.getContextOptionsLoadSublist({
pFnOnSelect: this._pDoLoadEncounter.bind(this),
}),
null,
new ContextUtil.Action(
"Import Settings",
async () => {
const compImportSettings = new InitiativeTrackerSettingsImport({state: MiscUtil.copyFast(this._state)});
await compImportSettings.pGetShowModalResults();
Object.assign(this._state, compImportSettings.getStateUpdate());
},
),
]);
const $btnLoad = $(`<button title="Import an encounter from the Bestiary" class="btn btn-success btn-xs dm-init-lockable"><span class="glyphicon glyphicon-upload"></span></button>`)
.click((evt) => {
if (this._state.isLocked) return;
ContextUtil.pOpenMenu(evt, menuImport);
});
const $btnReset = $(`<button title="Reset Tracker" class="btn btn-danger btn-xs dm-init-lockable"><span class="glyphicon glyphicon-trash"></span></button>`)
.click(async () => {
if (this._state.isLocked) return;
if (!await InputUiUtil.pGetUserBoolean({title: "Reset", htmlDescription: "Are you sure?", textYes: "Yes", textNo: "Cancel"})) return;
const stateNxt = {
rows: await this._compDefaultParty.pGetConvertedDefaultPartyActiveRows(),
};
const defaultState = this._getDefaultState();
["round", "sort", "dir"]
.forEach(prop => stateNxt[prop] = defaultState[prop]);
this._proxyAssignSimple("state", stateNxt);
});
return $$`<div class="dm-init__wrp-controls">
<div class="ve-flex">
<div class="btn-group ve-flex">
${$btnAdd}
${$btnAddMonster}
</div>
<div class="btn-group">${$btnSetPrevActive}${$btnSetNextActive}</div>
${$iptRound}
</div>
${this._render_$getWrpButtonsSort()}
<div class="ve-flex">
${$btnNetworking}
<div class="btn-group ve-flex-v-center">
${$btnLock}
${$btnConfigure}
</div>
<div class="btn-group ve-flex-v-center">
${$btnLoad}
${$btnReset}
</div>
</div>
</div>`;
}
_render_bindSortDirHooks () {
const hkSortDir = () => {
this._state.rows = InitiativeTrackerSort.getSortedRows({
rows: this._state.rows,
sortBy: this._state.sort,
sortDir: this._state.dir,
});
};
this._addHookBase("sort", hkSortDir);
this._addHookBase("dir", hkSortDir);
hkSortDir();
}
/* -------------------------------------------- */
_doReverseSortDir () {
this._state.dir = this._state.dir === InitiativeTrackerConst.SORT_DIR_ASC ? InitiativeTrackerConst.SORT_DIR_DESC : InitiativeTrackerConst.SORT_DIR_ASC;
}
/* -------------------------------------------- */
_getPlayerFriendlyState () {
const visibleStatsCols = this._state.statsCols
.filter(data => data.isPlayerVisible);
const rows = this._state.rows
.map(({entity}) => {
if (!entity.isPlayerVisible) return null;
const isMon = !!entity.source;
const out = {
name: entity.name,
initiative: entity.initiative,
isActive: entity.isActive,
conditions: entity.conditions || [],
rowStatColData: entity.rowStatColData
.map(cell => {
const mappedCol = visibleStatsCols.find(sc => sc.id === cell.id);
if (!mappedCol) return null;
if (mappedCol.isPlayerVisible === IS_PLAYER_VISIBLE_ALL || !isMon) {
const meta = InitiativeTrackerStatColumnFactory.fromStateData({data: mappedCol});
return meta.getPlayerFriendlyState({cell});
}
return {id: null, entity: {isUnknown: true}};
})
.filter(Boolean),
};
if (entity.customName) out.customName = entity.customName;
if (isMon ? !!this._state.playerInitShowExactMonsterHp : !!this._state.playerInitShowExactPlayerHp) {
out.hpCurrent = entity.hpCurrent;
out.hpMax = entity.hpMax;
} else {
out.hpWoundLevel = isNaN(entity.hpCurrent) || isNaN(entity.hpMax)
? -1
: InitiativeTrackerUtil.getWoundLevel(100 * entity.hpCurrent / entity.hpMax);
}
if (this._state.playerInitShowOrdinals && entity.isShowOrdinal) out.ordinal = entity.ordinal;
return out;
})
.filter(Boolean);
return {
type: "state",
payload: {
rows,
statsCols: visibleStatsCols
.map(({id, abbreviation}) => ({id, abbreviation})),
round: this._state.round,
},
};
}
/* -------------------------------------------- */
async _pDoLoadEncounter ({entityInfos, encounterInfo}) {
const rowsPrev = [...this._state.rows];
// Reset rows early, such that our ordinals are correct for creatures from the encounter
this._state.rows = [];
const isAddPlayers = this._state.importIsAddPlayers && !this._state.rowsDefaultParty.length;
const nxtState = await new InitiativeTrackerEncounterConverter({
roller: this._roller,
rowStateBuilderActive: this._rowStateBuilderActive,
importIsAddPlayers: isAddPlayers,
importIsRollGroups: this._state.importIsRollGroups,
isRollInit: this._state.isRollInit,
isRollHp: this._state.isRollHp,
isRollGroups: this._state.isRollGroups,
}).pGetConverted({entityInfos, encounterInfo});
const rowsFromDefaultParty = await this._compDefaultParty.pGetConvertedDefaultPartyActiveRows();
const idsDefaultParty = new Set(rowsFromDefaultParty.map(({id}) => id));
const rowsPrevNonDefaultParty = rowsPrev
.filter(({id}) => !idsDefaultParty.has(id));
const stateNxt = {
// region TODO(DMS) not ideal--merge columns instead? Note that this also clobbers default party info
isStatsAddColumns: nxtState.isStatsAddColumns,
statsCols: nxtState.statsCols
.map(it => it.getAsStateData()),
// endregion
rows: this._state.importIsAppend
? [
...rowsPrevNonDefaultParty,
...rowsFromDefaultParty,
...nxtState.rows,
]
: [
...rowsFromDefaultParty,
...nxtState.rows,
],
};
if (!this._state.importIsAppend) {
const defaultState = this._getDefaultState();
["round", "sort", "dir"]
.forEach(prop => stateNxt[prop] = defaultState[prop]);
}
this._proxyAssignSimple("state", stateNxt);
}
/* -------------------------------------------- */
doConnectCreatureViewer ({creatureViewer}) {
if (this._creatureViewers.includes(creatureViewer)) return this;
this._creatureViewers.push(creatureViewer);
creatureViewer.setCreatureState(this._getCreatureViewerFriendlyState());
return this;
}
static _CREATURE_VIEWER_STATE_PROPS = [
"name",
"source",
"scaledCr",
"scaledSummonSpellLevel",
"scaledSummonClassLevel",
];
_getCreatureViewerFriendlyState () {
const activeRowPrime = this._state.rows
.filter(({entity}) => entity.isActive)
.find(Boolean);
if (!activeRowPrime) {
return Object.fromEntries(this.constructor._CREATURE_VIEWER_STATE_PROPS.map(prop => [prop, null]));
}
return Object.fromEntries(this.constructor._CREATURE_VIEWER_STATE_PROPS.map(prop => [prop, activeRowPrime.entity[prop]]));
}
doDisconnectCreatureViewer ({creatureViewer}) {
this._creatureViewers = this._creatureViewers.filter(it => it !== creatureViewer);
}
_sendStateToCreatureViewers () {
if (!this._creatureViewers.length) return;
const creatureViewerFriendlyState = this._getCreatureViewerFriendlyState();
this._creatureViewers.forEach(it => it.setCreatureState(creatureViewerFriendlyState));
}
/* -------------------------------------------- */
_setStateFromSerialized () {
const stateNxt = {
// region Config
sort: this._savedState.s || InitiativeTrackerConst.SORT_ORDER_NUM,
dir: this._savedState.d || InitiativeTrackerConst.SORT_DIR_DESC,
statsCols: (this._savedState.c || [])
.map(dataSerial => this._setStateFromSerialized_statsCol({dataSerial}))
.filter(Boolean),
// endregion
// region Custom conditions
conditionsCustom: (this._savedState.cndc || [])
.map(dataSerial => InitiativeTrackerConditionCustomSerializer.fromSerial(dataSerial)),
// endregion
// region Rows
rows: (this._savedState.r || [])
.map(dataSerial => InitiativeTrackerRowDataSerializer.fromSerial(dataSerial))
.filter(Boolean),
rowsDefaultParty: (this._savedState.rdp || [])
.map(dataSerial => InitiativeTrackerRowDataSerializer.fromSerial(dataSerial))
.filter(Boolean),
// endregion
// region Round
round: isNaN(this._savedState.n) ? 1 : Number(this._savedState.n),
// endregion
// region Temporary
isLocked: false,
// endregion
};
// region Config
if (this._savedState.ri != null) stateNxt.isRollInit = this._savedState.ri;
if (this._savedState.m != null) stateNxt.isRollHp = this._savedState.m;
if (this._savedState.rg != null) stateNxt.isRollGroups = this._savedState.rg;
if (this._savedState.rri != null) stateNxt.isRerollInitiativeEachRound = this._savedState.rri;
if (this._savedState.g != null) stateNxt.importIsRollGroups = this._savedState.g;
if (this._savedState.p != null) stateNxt.importIsAddPlayers = this._savedState.p;
if (this._savedState.a != null) stateNxt.importIsAppend = this._savedState.a;
if (this._savedState.k != null) stateNxt.isStatsAddColumns = this._savedState.k;
if (this._savedState.piHp != null) stateNxt.playerInitShowExactPlayerHp = this._savedState.piHp;
if (this._savedState.piHm != null) stateNxt.playerInitShowExactMonsterHp = this._savedState.piHm;
if (this._savedState.piV != null) stateNxt.playerInitHideNewMonster = this._savedState.piV;
if (this._savedState.piO != null) stateNxt.playerInitShowOrdinals = this._savedState.piO;
// endregion
this._proxyAssignSimple("state", stateNxt);
}
_setStateFromSerialized_statsCol ({dataSerial}) {
if (!dataSerial) return null;
return InitiativeTrackerStatColumnFactory.fromStateData({dataSerial})
.getAsStateData();
}
_getSerializedState () {
return {
// region Config
s: this._state.sort,
d: this._state.dir,
ri: this._state.isRollInit,
m: this._state.isRollHp,
rg: this._state.isRollGroups,
rri: this._state.isRerollInitiativeEachRound,
g: this._state.importIsRollGroups,
p: this._state.importIsAddPlayers,
a: this._state.importIsAppend,
k: this._state.isStatsAddColumns,
piHp: this._state.playerInitShowExactPlayerHp,
piHm: this._state.playerInitShowExactMonsterHp,
piV: this._state.playerInitHideNewMonster,
piO: this._state.playerInitShowOrdinals,
c: (this._state.statsCols || [])
.map(data => InitiativeTrackerStatColumnDataSerializer.toSerial(data)),
// endregion
// region Custom conditions
cndc: (this._state.conditionsCustom || [])
.map(data => InitiativeTrackerConditionCustomSerializer.toSerial(data)),
// endregion
// region Rows
r: (this._state.rows || [])
.map(data => InitiativeTrackerRowDataSerializer.toSerial(data)),
rdp: (this._state.rowsDefaultParty || [])
.map(data => InitiativeTrackerRowDataSerializer.toSerial(data)),
// endregion
// region Round
n: this._state.round,
// endregion
};
}
_getDefaultState () {
return {
// region Config
sort: InitiativeTrackerConst.SORT_ORDER_NUM,
dir: InitiativeTrackerConst.SORT_DIR_DESC,
isRollInit: true,
isRollHp: false,
isRollGroups: false,
isRerollInitiativeEachRound: false,
importIsRollGroups: true,
importIsAddPlayers: true,
importIsAppend: false,
isStatsAddColumns: false,
playerInitShowExactPlayerHp: false,
playerInitShowExactMonsterHp: false,
playerInitHideNewMonster: true,
playerInitShowOrdinals: false,
statsCols: [],
// endregion
// region Custom conditions
conditionsCustom: [],
// endregion
// region Rows
rows: [],
rowsDefaultParty: [],
// endregion
// region Round
round: 1,
// endregion
// region Temporary
isLocked: false,
// endregion
};
}
/* -------------------------------------------- */
static $getPanelElement (board, savedState) {
return new this({board, savedState}).render();
}
}

View File

@@ -0,0 +1,191 @@
import {EncounterBuilderConsts} from "./encounterbuilder-consts.js";
import {EncounterBuilderHelpers} from "./encounterbuilder-helpers.js";
import {EncounterBuilderCreatureMeta} from "./encounterbuilder-models.js";
export class EncounterBuilderAdjuster {
static _INCOMPLETE_EXHAUSTED = 0;
static _INCOMPLETE_FAILED = -1;
static _COMPLETE = 1;
constructor ({partyMeta}) {
this._partyMeta = partyMeta;
}
/**
* @param {string} difficulty
* @param {Array<EncounterBuilderCreatureMeta>} creatureMetas
*/
async pGetAdjustedEncounter ({difficulty, creatureMetas}) {
if (!creatureMetas.length) {
JqueryUtil.doToast({content: `The current encounter contained no creatures! Please add some first.`, type: "warning"});
return;
}
if (creatureMetas.every(it => it.isLocked)) {
JqueryUtil.doToast({content: `The current encounter contained only locked creatures! Please unlock or add some other creatures some first.`, type: "warning"});
return;
}
creatureMetas = creatureMetas.map(creatureMeta => creatureMeta.copy());
const creatureMetasAdjustable = creatureMetas
.filter(creatureMeta => !creatureMeta.isLocked && creatureMeta.getCrNumber() != null);
if (!creatureMetasAdjustable.length) {
JqueryUtil.doToast({content: `The current encounter contained only locked creatures, or creatures without XP values! Please unlock or add some other creatures some first.`, type: "warning"});
return;
}
creatureMetasAdjustable
.forEach(creatureMeta => creatureMeta.count = 1);
const ixDifficulty = EncounterBuilderConsts.TIERS.indexOf(difficulty);
if (!~ixDifficulty) throw new Error(`Unhandled difficulty level: "${difficulty}"`);
// fudge min/max numbers slightly
const [targetMin, targetMax] = [
Math.floor(this._partyMeta[EncounterBuilderConsts.TIERS[ixDifficulty]] * 0.9),
Math.ceil((this._partyMeta[EncounterBuilderConsts.TIERS[ixDifficulty + 1]] - 1) * 1.1),
];
if (EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp > targetMax) {
JqueryUtil.doToast({content: `Could not adjust the current encounter to ${difficulty.uppercaseFirst()}, try removing some creatures!`, type: "danger"});
return;
}
// only calculate this once rather than during the loop, to ensure stable conditions
// less accurate in some cases, but should prevent infinite loops
const crCutoff = EncounterBuilderHelpers.getCrCutoff(creatureMetas, this._partyMeta);
// randomly choose creatures to skip
// generate array of [0, 1, ... n-1] where n = number of unique creatures
// this will be used to determine how many of the unique creatures we want to skip
const numSkipTotals = [...new Array(creatureMetasAdjustable.length)]
.map((_, ix) => ix);
const invalidSolutions = [];
let lastAdjustResult;
for (let maxTries = 999; maxTries >= 0; --maxTries) {
// -1/1 = complete; 0 = continue
lastAdjustResult = this._pGetAdjustedEncounter_doTryAdjusting({creatureMetas, creatureMetasAdjustable, numSkipTotals, targetMin, targetMax});
if (lastAdjustResult !== this.constructor._INCOMPLETE_EXHAUSTED) break;
invalidSolutions.push(creatureMetas.map(creatureMeta => creatureMeta.copy()));
// reset for next attempt
creatureMetasAdjustable
.forEach(creatureMeta => creatureMeta.count = 1);
}
// no good solution was found, so pick the closest invalid solution
if (lastAdjustResult !== this.constructor._COMPLETE && invalidSolutions.length) {
creatureMetas = invalidSolutions
.map(creatureMetasInvalid => ({
encounter: creatureMetasInvalid,
distance: this._pGetAdjustedEncounter_getSolutionDistance({creatureMetas: creatureMetasInvalid, targetMin, targetMax}),
}))
.sort((a, b) => SortUtil.ascSort(a.distance, b.distance))[0].encounter;
}
// do a post-step to randomly bulk out our counts of "irrelevant" creatures, ensuring plenty of fireball fodder
this._pGetAdjustedEncounter_doIncreaseIrrelevantCreatureCount({creatureMetas, creatureMetasAdjustable, crCutoff, targetMax});
return creatureMetas;
}
_pGetAdjustedEncounter_getSolutionDistance ({creatureMetas, targetMin, targetMax}) {
const xp = EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp;
if (xp > targetMax) return xp - targetMax;
if (xp < targetMin) return targetMin - xp;
return 0;
}
_pGetAdjustedEncounter_doTryAdjusting ({creatureMetas, creatureMetasAdjustable, numSkipTotals, targetMin, targetMax}) {
if (!numSkipTotals.length) return this.constructor._INCOMPLETE_FAILED; // no solution possible, so exit loop
let skipIx = 0;
// 7/12 * 7/12 * ... chance of moving the skipIx along one
while (!(RollerUtil.randomise(12) > 7) && skipIx < numSkipTotals.length - 1) skipIx++;
const numSkips = numSkipTotals.splice(skipIx, 1)[0]; // remove the selected skip amount; we'll try the others if this one fails
const curUniqueCreatures = [...creatureMetasAdjustable];
if (numSkips) {
[...new Array(numSkips)].forEach(() => {
const ixRemove = RollerUtil.randomise(curUniqueCreatures.length) - 1;
if (!~ixRemove) return;
curUniqueCreatures.splice(ixRemove, 1);
});
}
for (let maxTries = 999; maxTries >= 0; --maxTries) {
const encounterXp = EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta);
if (encounterXp.adjustedXp > targetMin && encounterXp.adjustedXp < targetMax) {
return this.constructor._COMPLETE;
}
// chance to skip each creature at each iteration
// otherwise, the case where every creature is relevant produces an equal number of every creature
const pickFrom = [...curUniqueCreatures];
if (pickFrom.length > 1) {
let loops = Math.floor(pickFrom.length / 2);
// skip [half, n-1] creatures
loops = RollerUtil.randomise(pickFrom.length - 1, loops);
while (loops-- > 0) {
const ix = RollerUtil.randomise(pickFrom.length) - 1;
pickFrom.splice(ix, 1);
}
}
while (pickFrom.length) {
const ix = RollerUtil.randomise(pickFrom.length) - 1;
const picked = pickFrom.splice(ix, 1)[0];
picked.count++;
if (EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp > targetMax) {
picked.count--;
}
}
}
return this.constructor._INCOMPLETE_EXHAUSTED;
}
_pGetAdjustedEncounter_doIncreaseIrrelevantCreatureCount ({creatureMetas, creatureMetasAdjustable, crCutoff, targetMax}) {
const creatureMetasBelowCrCutoff = creatureMetasAdjustable.filter(creatureMeta => creatureMeta.getCrNumber() < crCutoff);
if (!creatureMetasBelowCrCutoff.length) return;
let budget = targetMax - EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp;
if (budget <= 0) return;
const usable = creatureMetasBelowCrCutoff.filter(creatureMeta => creatureMeta.getXp() < budget);
if (!usable.length) return;
// try to avoid flooding low-level parties
const {min: playerToCreatureRatioMin, max: playerToCreatureRatioMax} = this._pGetAdjustedEncounter_getPlayerToCreatureRatios();
const minDesired = Math.floor(playerToCreatureRatioMin * this._partyMeta.cntPlayers);
const maxDesired = Math.ceil(playerToCreatureRatioMax * this._partyMeta.cntPlayers);
// keep rolling until we fail to add a creature, or until we're out of budget
while (EncounterBuilderCreatureMeta.getEncounterXpInfo(creatureMetas, this._partyMeta).adjustedXp <= targetMax) {
const totalCreatures = creatureMetas
.map(creatureMeta => creatureMeta.count)
.sum();
// if there's less than min desired, large chance of adding more
// if there's more than max desired, small chance of adding more
// if there's between min and max desired, medium chance of adding more
const chanceToAdd = totalCreatures < minDesired ? 90 : totalCreatures > maxDesired ? 40 : 75;
const isAdd = RollerUtil.roll(100) < chanceToAdd;
if (!isAdd) break;
RollerUtil.rollOnArray(creatureMetasBelowCrCutoff).count++;
}
}
_pGetAdjustedEncounter_getPlayerToCreatureRatios () {
if (this._partyMeta.avgPlayerLevel < 5) return {min: 0.8, max: 1.3};
if (this._partyMeta.avgPlayerLevel < 11) return {min: 1, max: 2};
if (this._partyMeta.avgPlayerLevel < 17) return {min: 1, max: 3};
return {min: 1, max: 4};
}
}

View File

@@ -0,0 +1,19 @@
/**
* A cache of XP value -> creature.
*/
export class EncounterBuilderCacheBase {
reset () { throw new Error("Unimplemented!"); }
getCreaturesByXp (xp) { throw new Error("Unimplemented!"); }
getXpKeys () { throw new Error("Unimplemented!"); }
static _UNWANTED_CR_NUMS = new Set([VeCt.CR_UNKNOWN, VeCt.CR_CUSTOM]);
_isUnwantedCreature (mon) {
if (mon.isNpc) return true;
const crNum = Parser.crToNumber(mon.cr);
if (this.constructor._UNWANTED_CR_NUMS.has(crNum)) return true;
return false;
}
}

View File

@@ -0,0 +1,51 @@
export class EncounterBuilderRenderableCollectionColsExtraAdvanced extends RenderableCollectionBase {
constructor (
{
comp,
rdState,
},
) {
super(comp, "colsExtraAdvanced");
this._rdState = rdState;
}
getNewRender (colExtra, i) {
const comp = BaseComponent.fromObject(colExtra.entity, "*");
comp._addHookAll("state", () => {
this._getCollectionItem(colExtra.id).entity = comp.toObject("*");
this._comp._triggerCollectionUpdate("colsExtraAdvanced");
});
const $iptName = ComponentUiUtil.$getIptStr(comp, "name")
.addClass("w-40p form-control--minimal no-shrink ve-text-center mr-1 bb-0");
const $wrpHeader = $$`<div class="ve-flex">
${$iptName}
</div>`
.appendTo(this._rdState.$wrpHeadersAdvanced);
const $btnDelete = $(`<button class="btn btn-xxs ecgen-player__btn-inline w-40p btn-danger no-shrink mt-n2 bt-0 btl-0 btr-0" title="Remove Column" tabindex="-1"><span class="glyphicon-trash glyphicon"></span></button>`)
.click(() => this._comp.doRemoveColExtraAdvanced(colExtra.id));
const $wrpFooter = $$`<div class="w-40p ve-flex-v-baseline ve-flex-h-center no-shrink no-grow mr-1">
${$btnDelete}
</div>`
.appendTo(this._rdState.$wrpFootersAdvanced);
return {
comp,
$wrpHeader,
$wrpFooter,
fnRemoveEles: () => {
$wrpHeader.remove();
$wrpFooter.remove();
},
};
}
doUpdateExistingRender (renderedMeta, colExtra, i) {
renderedMeta.comp._proxyAssignSimple("state", colExtra.entity, true);
if (!renderedMeta.$wrpHeader.parent().is(this._rdState.$wrpHeadersAdvanced)) renderedMeta.$wrpHeader.appendTo(this._rdState.$wrpHeadersAdvanced);
if (!renderedMeta.$wrpFooter.parent().is(this._rdState.$wrpFootersAdvanced)) renderedMeta.$wrpFooter.appendTo(this._rdState.$wrpFootersAdvanced);
}
}

View File

@@ -0,0 +1,232 @@
import {EncounterPartyMeta, EncounterPartyPlayerMeta} from "./encounterbuilder-models.js";
export class EncounterBuilderComponent extends BaseComponent {
static _DEFAULT_PARTY_SIZE = 4;
/* -------------------------------------------- */
get creatureMetas () { return this._state.creatureMetas; }
set creatureMetas (val) { this._state.creatureMetas = val; }
get isAdvanced () { return this._state.isAdvanced; }
set isAdvanced (val) { this._state.isAdvanced = !!val; }
get playersSimple () { return this._state.playersSimple; }
set playersSimple (val) { this._state.playersSimple = val; }
get colsExtraAdvanced () { return this._state.colsExtraAdvanced; }
set colsExtraAdvanced (val) { this._state.colsExtraAdvanced = val; }
get playersAdvanced () { return this._state.playersAdvanced; }
set playersAdvanced (val) { this._state.playersAdvanced = val; }
/* -------------------------------------------- */
addHookCreatureMetas (hk) { return this._addHookBase("creatureMetas", hk); }
addHookIsAdvanced (hk) { return this._addHookBase("isAdvanced", hk); }
addHookPlayersSimple (hk) { return this._addHookBase("playersSimple", hk); }
addHookPlayersAdvanced (hk) { return this._addHookBase("playersAdvanced", hk); }
addHookColsExtraAdvanced (hk) { return this._addHookBase("colsExtraAdvanced", hk); }
/* -------------------------------------------- */
_addPlayerRow_advanced () {
const prevRowLevel = this._state.playersAdvanced.last()?.entity?.level;
this._state.playersAdvanced = [
...this._state.playersAdvanced,
this.constructor.getDefaultPlayerRow_advanced({
level: prevRowLevel,
colsExtraAdvanced: this._state.colsExtraAdvanced,
}),
];
}
_addPlayerRow_simple () {
const prevRowLevel = this._state.playersSimple.last()?.entity?.level;
this._state.playersSimple = [
...this._state.playersSimple,
this.constructor.getDefaultPlayerRow_simple({
level: prevRowLevel,
}),
];
}
doAddPlayer () {
if (this._state.isAdvanced) return this._addPlayerRow_advanced();
return this._addPlayerRow_simple();
}
/* -------------------------------------------- */
getPartyMeta () {
return new EncounterPartyMeta(
this._state.isAdvanced
? this._getPartyPlayerMetas_advanced()
: this._getPartyPlayerMetas_simple(),
);
}
_getPartyPlayerMetas_advanced () {
const countByLevel = {};
this._state.playersAdvanced
.forEach(it => {
countByLevel[it.entity.level] = (countByLevel[it.entity.level] || 0) + 1;
});
return Object.entries(countByLevel)
.map(([level, count]) => new EncounterPartyPlayerMeta({level: Number(level), count}));
}
_getPartyPlayerMetas_simple () {
return this._state.playersSimple
.map(it => new EncounterPartyPlayerMeta({count: it.entity.count, level: it.entity.level}));
}
/* -------------------------------------------- */
doAddColExtraAdvanced () {
this._state.colsExtraAdvanced = [
...this._state.colsExtraAdvanced,
this.constructor.getDefaultColExtraAdvanced(),
];
// region When adding a new advanced column, add a new cell to each player row
this._state.playersAdvanced.forEach(it => it.entity.extras.push(this.constructor.getDefaultPlayerAdvancedExtra()));
this._triggerCollectionUpdate("playersAdvanced");
// endregion
}
doRemoveColExtraAdvanced (id) {
// region When removing an advanced column, remove matching values from player rows
const ix = this._state.colsExtraAdvanced.findIndex(it => it.id === id);
if (!~ix) return;
this._state.playersAdvanced.forEach(player => {
player.entity.extras = player.entity.extras.filter((_, i) => i !== ix);
});
this._triggerCollectionUpdate("playersAdvanced");
// endregion
this._state.colsExtraAdvanced = this._state.colsExtraAdvanced.filter(it => it.id !== id);
}
/* -------------------------------------------- */
static getDefaultPlayerRow_advanced ({name = "", level = 1, extras = null, colsExtraAdvanced = null} = {}) {
extras = extras || [...new Array(colsExtraAdvanced?.length || 0)]
.map(() => this.getDefaultPlayerAdvancedExtra());
return {
id: CryptUtil.uid(),
entity: {
name,
level,
extras,
},
};
}
static getDefaultPlayerRow_simple (
{
count = this._DEFAULT_PARTY_SIZE,
level = 1,
} = {},
) {
return {
id: CryptUtil.uid(),
entity: {
count,
level,
},
};
}
static getDefaultColExtraAdvanced (
{
name = "",
} = {},
) {
return {
id: CryptUtil.uid(),
entity: {
name,
},
};
}
static getDefaultPlayerAdvancedExtra (
{
value = "",
} = {},
) {
return {
id: CryptUtil.uid(),
entity: {
value,
},
};
}
/* -------------------------------------------- */
setStateFromLoaded (loadedState) {
this._mutValidateLoadedState(loadedState);
const nxt = MiscUtil.copyFast(this._getDefaultState());
Object.assign(nxt, loadedState);
this._proxyAssignSimple("state", nxt, true);
}
setPartialStateFromLoaded (partialLoadedState) {
this._mutValidateLoadedState(partialLoadedState);
this._proxyAssignSimple("state", partialLoadedState);
}
_mutValidateLoadedState (loadedState) {
const defaultState = this._getDefaultState();
if (loadedState.playersSimple && !loadedState.playersSimple.length) loadedState.playersSimple = MiscUtil.copyFast(defaultState.playersSimple);
if (loadedState.playersAdvanced && !loadedState.playersAdvanced.length) {
const colsExtraAdvanced = loadedState.colsExtraAdvanced || this._state.colsExtraAdvanced;
loadedState.playersAdvanced = MiscUtil.copyFast(defaultState.playersAdvanced);
loadedState.playersAdvanced
.forEach(({entity}) => {
// Trim extras
(entity.extras = entity.extras || []).slice(0, colsExtraAdvanced.length);
// Pad extras
colsExtraAdvanced.forEach((_, i) => entity.extras[i] = entity.extras[i] ?? this.constructor.getDefaultPlayerAdvancedExtra());
});
}
}
/* -------------------------------------------- */
getDefaultStateKeys () {
return Object.keys(this.constructor._getDefaultState());
}
static _getDefaultState () {
return {
creatureMetas: [],
playersSimple: [
this.getDefaultPlayerRow_simple(),
],
isAdvanced: false,
colsExtraAdvanced: [],
playersAdvanced: [
this.getDefaultPlayerRow_advanced(),
],
};
}
_getDefaultState () {
return {
...this.constructor._getDefaultState(),
};
}
}

View File

@@ -0,0 +1,3 @@
export class EncounterBuilderConsts {
static TIERS = ["easy", "medium", "hard", "deadly", "absurd"];
}

View File

@@ -0,0 +1,85 @@
export class EncounterBuilderHelpers {
static getCrCutoff (creatureMetas, partyMeta) {
creatureMetas = creatureMetas
.filter(creatureMeta => creatureMeta.getCrNumber() != null)
.sort((a, b) => SortUtil.ascSort(b.getCrNumber(), a.getCrNumber()));
if (!creatureMetas.length) return 0;
// no cutoff for CR 0-2
if (creatureMetas[0].getCrNumber() <= 2) return 0;
// ===============================================================================================================
// "When making this calculation, don't count any monsters whose challenge rating is significantly below the average
// challenge rating of the other monsters in the group unless you think the weak monsters significantly contribute
// to the difficulty of the encounter." -- DMG, p. 82
// ===============================================================================================================
// "unless you think the weak monsters significantly contribute to the difficulty of the encounter"
// For player levels <5, always include every monster. We assume that levels 5> will have strong
// AoE/multiattack, allowing trash to be quickly cleared.
if (!partyMeta.isPartyLevelFivePlus()) return 0;
// Spread the CRs into a single array
const crValues = [];
creatureMetas.forEach(creatureMeta => {
const cr = creatureMeta.getCrNumber();
for (let i = 0; i < creatureMeta.count; ++i) crValues.push(cr);
});
// TODO(Future) allow this to be controlled by the user
let CR_THRESH_MODE = "statisticallySignificant";
switch (CR_THRESH_MODE) {
// "Statistically significant" method--note that this produces very passive filtering; the threshold is below
// the minimum CR in the vast majority of cases.
case "statisticallySignificant": {
const cpy = MiscUtil.copy(crValues)
.sort(SortUtil.ascSort);
const avg = cpy.mean();
const deviation = cpy.meanAbsoluteDeviation();
return avg - (deviation * 2);
}
case "5etools": {
// The ideal interpretation of this:
// "don't count any monsters whose challenge rating is significantly below the average
// challenge rating of the other monsters in the group"
// Is:
// Arrange the creatures in CR order, lowest to highest. Remove the lowest CR creature (or one of them, if there
// are ties). Calculate the average CR without this removed creature. If the removed creature's CR is
// "significantly below" this average, repeat the process with the next lowest CR creature.
// However, this can produce a stair-step pattern where our average CR keeps climbing as we remove more and more
// creatures. Therefore, only do this "remove creature -> calculate average CR" step _once_, and use the
// resulting average CR to calculate a cutoff.
const crMetas = [];
// If there's precisely one CR value, use it
if (crValues.length === 1) {
crMetas.push({
mean: crValues[0],
deviation: 0,
});
} else {
// Get an average CR for every possible encounter without one of the creatures in the encounter
for (let i = 0; i < crValues.length; ++i) {
const crValueFilt = crValues.filter((_, j) => i !== j);
const crMean = crValueFilt.mean();
const crStdDev = Math.sqrt((1 / crValueFilt.length) * crValueFilt.map(it => (it - crMean) ** 2).reduce((a, b) => a + b, 0));
crMetas.push({mean: crMean, deviation: crStdDev});
}
}
// Sort by descending average CR -> ascending deviation
crMetas.sort((a, b) => SortUtil.ascSort(b.mean, a.mean) || SortUtil.ascSort(a.deviation, b.deviation));
// "significantly below the average" -> cutoff at half the average
return crMetas[0].mean / 2;
}
default: return 0;
}
}
}

View File

@@ -0,0 +1,317 @@
import {EncounterBuilderHelpers} from "./encounterbuilder-helpers.js";
export class EncounterBuilderXpInfo {
static getDefault () {
return new this({
baseXp: 0,
relevantCount: 0,
count: 0,
adjustedXp: 0,
crCutoff: Parser.crToNumber(Parser.CRS.last()),
playerCount: 0,
playerAdjustedXpMult: 0,
});
}
/* -------------------------------------------- */
constructor (
{
baseXp,
relevantCount,
count,
adjustedXp,
crCutoff,
playerCount,
playerAdjustedXpMult,
},
) {
this._baseXp = baseXp;
this._relevantCount = relevantCount;
this._count = count;
this._adjustedXp = adjustedXp;
this._crCutoff = crCutoff;
this._playerCount = playerCount;
this._playerAdjustedXpMult = playerAdjustedXpMult;
}
get baseXp () { return this._baseXp; }
get relevantCount () { return this._relevantCount; }
get count () { return this._count; }
get adjustedXp () { return this._adjustedXp; }
get crCutoff () { return this._crCutoff; }
get playerCount () { return this._playerCount; }
get playerAdjustedXpMult () { return this._playerAdjustedXpMult; }
}
export class EncounterBuilderCreatureMeta {
constructor (
{
creature,
count,
isLocked = false,
customHashId = null,
baseCreature = null,
},
) {
this.creature = creature;
this.count = count;
this.isLocked = isLocked;
// used for encounter adjuster
this.customHashId = customHashId ?? null;
this.baseCreature = baseCreature;
}
/* -------------------------------------------- */
getHash () { return UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](this.creature); }
getCrNumber () {
return Parser.crToNumber(this.creature.cr, {isDefaultNull: true});
}
getXp () {
return Parser.crToXpNumber(this.creature.cr);
}
getApproxHp () {
if (this.creature.hp && this.creature.hp.average && !isNaN(this.creature.hp.average)) return Number(this.creature.hp.average);
return null;
}
getApproxAc () {
// Use the first AC listed, as this is usually the "primary"
if (this.creature.ac && this.creature.ac[0] != null) {
if (this.creature.ac[0].ac) return this.creature.ac[0].ac;
if (typeof this.creature.ac[0] === "number") return this.creature.ac[0];
}
return null;
}
/* -------------------------------------------- */
isSameCreature (other) {
if (this.customHashId !== other.customHashId) return false;
return this.getHash() === other.getHash();
}
hasCreature (mon) {
const monCustomHashId = Renderer.monster.getCustomHashId(mon);
if (this.customHashId !== monCustomHashId) return false;
return this.getHash() === UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon);
}
copy () {
return new this.constructor(this);
}
/* -------------------------------------------- */
/**
* @param {Array<EncounterBuilderCreatureMeta>} creatureMetas
* @param {?EncounterPartyMeta} partyMeta
*/
static getEncounterXpInfo (creatureMetas, partyMeta = null) {
partyMeta ??= EncounterPartyMeta.getDefault();
// Avoid including e.g. "summon" creatures.
// Note that this effectively discounts non-XP-carrying creatures from "creature count XP multiplier"
// calculations. This is intentional; we make the simplifying assumption that if a creature doesn't carry XP,
// it should have no impact on the difficulty encounter.
creatureMetas = creatureMetas.filter(creatureMeta => creatureMeta.getCrNumber() != null)
.sort((a, b) => SortUtil.ascSort(b.getCrNumber(), a.getCrNumber()));
if (!creatureMetas.length) {
return EncounterBuilderXpInfo.getDefault();
}
let baseXp = 0;
let relevantCount = 0;
let count = 0;
const crCutoff = EncounterBuilderHelpers.getCrCutoff(creatureMetas, partyMeta);
creatureMetas
.forEach(creatureMeta => {
if (creatureMeta.getCrNumber() >= crCutoff) relevantCount += creatureMeta.count;
count += creatureMeta.count;
baseXp += (Parser.crToXpNumber(Parser.numberToCr(creatureMeta.getCrNumber())) || 0) * creatureMeta.count;
});
const playerAdjustedXpMult = Parser.numMonstersToXpMult(relevantCount, partyMeta.cntPlayers);
const adjustedXp = playerAdjustedXpMult * baseXp;
return new EncounterBuilderXpInfo({
baseXp,
relevantCount,
count,
adjustedXp,
crCutoff,
playerCount: partyMeta.cntPlayers,
playerAdjustedXpMult,
});
}
}
export class EncounterBuilderCandidateEncounter {
/**
* @param {Array<EncounterBuilderCreatureMeta>} lockedEncounterCreatures
*/
constructor ({lockedEncounterCreatures = []} = {}) {
this.skipCount = 0;
this._lockedEncounterCreatures = lockedEncounterCreatures;
this._creatureMetas = [...lockedEncounterCreatures];
}
hasCreatures () { return !!this._creatureMetas.length; }
getCreatureMetas ({xp = null, isSkipLocked = false} = {}) {
return this._creatureMetas
.filter(creatureMeta => {
if (isSkipLocked && creatureMeta.isLocked) return false;
return xp == null || creatureMeta.getXp() === xp;
});
}
getEncounterXpInfo ({partyMeta}) {
return EncounterBuilderCreatureMeta.getEncounterXpInfo(this._creatureMetas, partyMeta);
}
addCreatureMeta (creatureMeta) {
const existingMeta = this._creatureMetas.find(it => it.isSameCreature(creatureMeta));
if (existingMeta?.isLocked) return false;
if (existingMeta) {
existingMeta.count++;
return true;
}
this._creatureMetas.push(creatureMeta);
return true;
}
// Try to add another copy of an existing creature
tryIncreaseExistingCreatureCount ({xp}) {
const existingMetas = this.getCreatureMetas({isSkipLocked: true, xp});
if (!existingMetas.length) return false;
const roll = RollerUtil.roll(100);
const chance = this._getChanceToAddNewCreature();
if (roll < chance) return false;
RollerUtil.rollOnArray(existingMetas).count++;
return true;
}
_getChanceToAddNewCreature () {
if (this._creatureMetas.length === 0) return 0;
// Soft-cap at 5 creatures
if (this._creatureMetas.length >= 5) return 2;
/*
* 1 -> 80% chance to add new
* 2 -> 40%
* 3 -> 27%
* 4 -> 20%
*/
return Math.round(80 / this._creatureMetas.length);
}
isCreatureLocked (mon) {
return this._lockedEncounterCreatures
.some(creatureMeta => creatureMeta.customHashId == null && creatureMeta.getHash() === UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon));
}
}
export class EncounterPartyPlayerMeta {
constructor ({level, count}) {
this.level = level;
this.count = count;
}
getXpToNextLevel () {
const ixCur = Math.min(Math.max(0, this.level - 1), VeCt.LEVEL_MAX - 1);
const ixNxt = Math.min(ixCur + 1, VeCt.LEVEL_MAX - 1);
return (Parser.LEVEL_XP_REQUIRED[ixNxt] - Parser.LEVEL_XP_REQUIRED[ixCur]) * this.count;
}
}
export class EncounterPartyMeta {
static getDefault () {
return new this(
[
new EncounterPartyPlayerMeta({level: 1, count: 1}),
],
);
}
/* -------------------------------------------- */
/**
*
* @param {Array<EncounterPartyPlayerMeta>} playerMetas
*/
constructor (playerMetas) {
/** @type {Array<EncounterPartyPlayerMeta>} */
this.levelMetas = [];
// Combine such that each `level` has at most one entry, with the total count for players of that level
playerMetas.forEach(it => {
const existingLvl = this.levelMetas.find(x => x.level === it.level);
if (existingLvl) existingLvl.count += it.count;
else this.levelMetas.push(new EncounterPartyPlayerMeta({count: it.count, level: it.level}));
});
this.cntPlayers = 0;
this.avgPlayerLevel = 0;
this.maxPlayerLevel = 0;
this.threshEasy = 0;
this.threshMedium = 0;
this.threshHard = 0;
this.threshDeadly = 0;
this.threshAbsurd = 0;
this.dailyBudget = 0;
this.xpToNextLevel = 0;
this.levelMetas
.forEach(meta => {
this.cntPlayers += meta.count;
this.avgPlayerLevel += meta.level * meta.count;
this.maxPlayerLevel = Math.max(this.maxPlayerLevel, meta.level);
this.threshEasy += Parser.LEVEL_TO_XP_EASY[meta.level] * meta.count;
this.threshMedium += Parser.LEVEL_TO_XP_MEDIUM[meta.level] * meta.count;
this.threshHard += Parser.LEVEL_TO_XP_HARD[meta.level] * meta.count;
this.threshDeadly += Parser.LEVEL_TO_XP_DEADLY[meta.level] * meta.count;
this.dailyBudget += Parser.LEVEL_TO_XP_DAILY[meta.level] * meta.count;
this.xpToNextLevel += meta.getXpToNextLevel();
});
if (this.cntPlayers) this.avgPlayerLevel /= this.cntPlayers;
this.threshAbsurd = this.threshDeadly + (this.threshDeadly - this.threshHard);
}
/** Return true if at least a third of the party is level 5+. */
isPartyLevelFivePlus () {
const [levelMetasHigher, levelMetasLower] = this.levelMetas.partition(it => it.level >= 5);
const cntLower = levelMetasLower.map(it => it.count).reduce((a, b) => a + b, 0);
const cntHigher = levelMetasHigher.map(it => it.count).reduce((a, b) => a + b, 0);
return (cntHigher / (cntLower + cntHigher)) >= 0.333;
}
// Expose these as getters to ease factoring elsewhere
get easy () { return this.threshEasy; }
get medium () { return this.threshMedium; }
get hard () { return this.threshHard; }
get deadly () { return this.threshDeadly; }
get absurd () { return this.threshAbsurd; }
}

View File

@@ -0,0 +1,93 @@
export class EncounterBuilderRenderableCollectionPlayersAdvanced extends RenderableCollectionGenericRows {
constructor (
{
comp,
rdState,
},
) {
super(comp, "playersAdvanced", rdState.$wrpRowsAdvanced);
}
_$getWrpRow () {
return $(`<div class="ve-flex-v-center mb-2 ecgen-player__wrp-row"></div>`);
}
_populateRow ({comp, $wrpRow, entity}) {
const $iptName = ComponentUiUtil.$getIptStr(comp, "name")
.addClass(`w-100p form-control--minimal no-shrink mr-1`);
const $iptLevel = ComponentUiUtil.$getIptInt(
comp,
"level",
1,
{
min: 1,
max: 20,
fallbackOnNaN: 1,
},
).addClass("w-40p form-control--minimal no-shrink mr-1 ve-text-center");
const $wrpIptsExtra = $(`<div class="ve-flex-v-center"></div>`);
const collectionExtras = new EncounterBuilderRenderableCollectionPlayerAdvancedExtras({
comp,
$wrpIptsExtra,
});
const hkExtras = () => collectionExtras.render();
comp._addHookBase("extras", hkExtras);
hkExtras();
const $btnRemove = this._utils.$getBtnDelete({entity, title: "Remove Player"})
.addClass("ecgen-player__btn-inline h-ipt-xs no-shrink ml-n1 bl-0 bbl-0 btl-0")
.attr("tabindex", "-1");
$$($wrpRow)`
${$iptName}
${$iptLevel}
${$wrpIptsExtra}
${$btnRemove}
`;
return {
$wrpIptsExtra,
};
}
}
class EncounterBuilderRenderableCollectionPlayerAdvancedExtras extends RenderableCollectionBase {
constructor (
{
comp,
$wrpIptsExtra,
},
) {
super(comp, "extras");
this._$wrpIptsExtra = $wrpIptsExtra;
}
getNewRender (extra, i) {
const comp = BaseComponent.fromObject(extra.entity, "*");
comp._addHookAll("state", () => {
this._getCollectionItem(extra.id).entity = comp.toObject("*");
this._comp._triggerCollectionUpdate("extras");
});
const $iptVal = ComponentUiUtil.$getIptStr(comp, "value")
.addClass(`w-40p no-shrink form-control--minimal ve-text-center mr-1`);
const $wrpRow = $$`<div class="ve-flex-v-h-center">
${$iptVal}
</div>`
.appendTo(this._$wrpIptsExtra);
return {
comp,
$wrpRow,
};
}
doUpdateExistingRender (renderedMeta, extra, i) {
renderedMeta.comp._proxyAssignSimple("state", extra.entity, true);
if (!renderedMeta.$wrpRow.parent().is(this._$wrpIptsExtra)) renderedMeta.$wrpRow.appendTo(this._$wrpIptsExtra);
}
}

View File

@@ -0,0 +1,42 @@
export class EncounterBuilderRenderableCollectionPlayersSimple extends RenderableCollectionGenericRows {
constructor (
{
comp,
rdState,
},
) {
super(comp, "playersSimple", rdState.$wrpRowsSimple);
}
_$getWrpRow () {
return $(`<div class="ve-flex-v-center mb-2 ecgen-player__wrp-row"></div>`);
}
_populateRow ({comp, $wrpRow, entity}) {
const $selCount = ComponentUiUtil.$getSelEnum(
comp,
"count",
{
values: [...new Array(12)].map((_, i) => i + 1),
},
).addClass("form-control--minimal no-shrink");
const $selLevel = ComponentUiUtil.$getSelEnum(
comp,
"level",
{
values: [...new Array(20)].map((_, i) => i + 1),
},
).addClass("form-control--minimal no-shrink bl-0");
const $btnRemove = this._utils.$getBtnDelete({entity, title: "Remove Player Group"})
.addClass("ecgen-player__btn-inline h-ipt-xs no-shrink bl-0 bbl-0 btl-0")
.attr("tabindex", "-1");
$$($wrpRow)`
<div class="w-20">${$selCount}</div>
<div class="w-20">${$selLevel}</div>
<div class="ve-flex-v-center">${$btnRemove}</div>
`;
}
}

View File

@@ -0,0 +1,206 @@
import {EncounterBuilderConsts} from "./encounterbuilder-consts.js";
import {EncounterBuilderCandidateEncounter, EncounterBuilderCreatureMeta} from "./encounterbuilder-models.js";
export class EncounterBuilderRandomizer {
static _NUM_SAMPLES = 20;
constructor ({partyMeta, cache}) {
this._partyMeta = partyMeta;
this._cache = cache;
// region Pre-cache various "constants" required during generation, for performance
this._STANDARD_XP_VALUES = new Set(Object.values(Parser.XP_CHART_ALT));
this._DESCENDING_AVAILABLE_XP_VALUES = this._cache.getXpKeys().sort(SortUtil.ascSort).reverse();
if (this._DESCENDING_AVAILABLE_XP_VALUES.some(k => typeof k !== "number")) throw new Error(`Expected numerical XP values!`);
/*
Sorted array of:
{
cr: "1/2",
xp: 50,
crNum: 0.5
}
*/
this._CR_METAS = Object.entries(Parser.XP_CHART_ALT)
.map(([cr, xp]) => ({cr, xp, crNum: Parser.crToNumber(cr)}))
.sort((a, b) => SortUtil.ascSort(b.crNum, a.crNum));
// endregion
}
async pGetRandomEncounter ({difficulty, lockedEncounterCreatures}) {
const ixLow = EncounterBuilderConsts.TIERS.indexOf(difficulty);
if (!~ixLow) throw new Error(`Unhandled difficulty level: "${difficulty}"`);
const budget = this._partyMeta[EncounterBuilderConsts.TIERS[ixLow + 1]] - 1;
const closestSolution = this._pDoGenerateEncounter_getSolution({budget, lockedEncounterCreatures});
if (!closestSolution) {
JqueryUtil.doToast({content: `Failed to generate a valid encounter within the provided parameters!`, type: "warning"});
return;
}
return closestSolution.getCreatureMetas();
}
_pDoGenerateEncounter_getSolution ({budget, lockedEncounterCreatures}) {
const solutions = this._pDoGenerateEncounter_getSolutions({budget, lockedEncounterCreatures});
const validSolutions = solutions.filter(it => this._isValidEncounter({candidateEncounter: it, budget}));
if (validSolutions.length) return RollerUtil.rollOnArray(validSolutions);
return null;
}
_pDoGenerateEncounter_getSolutions ({budget, lockedEncounterCreatures}) {
// If there are enough players that single-monster XP is halved, generate twice as many solutions, half with double XP cap
if (this._partyMeta.cntPlayers >= 6) {
return [...new Array(this.constructor._NUM_SAMPLES * 2)]
.map((_, i) => {
return this._pDoGenerateEncounter_generateClosestEncounter({
budget: budget * (Number((i >= this.constructor._NUM_SAMPLES)) + 1),
rawBudget: budget,
lockedEncounterCreatures,
});
});
}
return [...new Array(this.constructor._NUM_SAMPLES)]
.map(() => this._pDoGenerateEncounter_generateClosestEncounter({budget: budget, lockedEncounterCreatures}));
}
_isValidEncounter ({candidateEncounter, budget}) {
const encounterXp = candidateEncounter.getEncounterXpInfo({partyMeta: this._partyMeta});
return encounterXp.adjustedXp >= (budget * 0.6) && encounterXp.adjustedXp <= (budget * 1.1);
}
_pDoGenerateEncounter_generateClosestEncounter ({budget, rawBudget, lockedEncounterCreatures}) {
if (rawBudget == null) rawBudget = budget;
const candidateEncounter = new EncounterBuilderCandidateEncounter({lockedEncounterCreatures});
const xps = this._getUsableXpsForBudget({budget});
let nextBudget = budget;
let skips = 0;
let steps = 0;
while (xps.length) {
if (steps++ > 100) break;
if (skips) {
skips--;
xps.shift();
continue;
}
const xp = xps[0];
if (xp > nextBudget) {
xps.shift();
continue;
}
skips = this._getNumSkips({xps, candidateEncounter, xp});
if (skips) {
skips--;
xps.shift();
continue;
}
this._mutEncounterAddCreatureByXp({candidateEncounter, xp});
nextBudget = this._getBudgetRemaining({candidateEncounter, budget, rawBudget});
}
return candidateEncounter;
}
_getUsableXpsForBudget ({budget}) {
const xps = this._DESCENDING_AVAILABLE_XP_VALUES
.filter(it => {
// Make TftYP values (i.e. those that are not real XP thresholds) get skipped 9/10 times
if (!this._STANDARD_XP_VALUES.has(it) && RollerUtil.randomise(10) !== 10) return false;
return it <= budget;
});
// region Do initial skips--discard some potential XP values early
// 50% of the time, skip the first 0-1/3rd of available CRs
if (xps.length > 4 && RollerUtil.roll(2) === 1) {
const skips = RollerUtil.roll(Math.ceil(xps.length / 3));
return xps.slice(skips);
}
return xps;
// endregion
}
_getBudgetRemaining ({candidateEncounter, budget, rawBudget}) {
if (!candidateEncounter.hasCreatures()) return budget;
const curr = candidateEncounter.getEncounterXpInfo({partyMeta: this._partyMeta});
const budgetRemaining = budget - curr.adjustedXp;
const meta = this._CR_METAS.filter(it => it.xp <= budgetRemaining);
// If we're a large party, and we're doing a "single creature worth less XP" generation, force the generation
// to stop.
if (rawBudget !== budget && curr.count === 1 && (rawBudget - curr.baseXp) <= 0) {
return 0;
}
// if the highest CR creature has CR greater than the cutoff, adjust for next multiplier
if (meta.length && meta[0].crNum >= curr.crCutoff) {
const nextMult = Parser.numMonstersToXpMult(curr.relevantCount + 1, this._partyMeta.cntPlayers);
return Math.floor((budget - (nextMult * curr.baseXp)) / nextMult);
}
// otherwise, no creature has CR greater than the cutoff, don't worry about multipliers
return budgetRemaining;
}
_mutEncounterAddCreatureByXp ({candidateEncounter, xp}) {
if (candidateEncounter.tryIncreaseExistingCreatureCount({xp})) return;
// region Try to add a new creature
// We retrieve the list of all available creatures for this XP, then randomly pick creatures from that list until
// we exhaust all options.
// Generally, the first creature picked should be usable. We only need to continue our search loop if the creature
// picked is already included in our encounter, and is locked.
const availableCreatures = [...this._cache.getCreaturesByXp(xp)];
while (availableCreatures.length) {
const ixRolled = RollerUtil.randomise(availableCreatures.length) - 1;
const rolled = availableCreatures[ixRolled];
availableCreatures.splice(ixRolled, 1);
const isAdded = candidateEncounter.addCreatureMeta(
new EncounterBuilderCreatureMeta({
creature: rolled,
count: 1,
}),
);
if (!isAdded) continue;
break;
}
// endregion
}
_getNumSkips ({xps, candidateEncounter, xp}) {
// if there are existing entries at this XP, don't skip
const existing = candidateEncounter.getCreatureMetas({xp});
if (existing.length) return 0;
if (xps.length <= 1) return 0;
// skip 70% of the time by default, less 13% chance per item skipped
const isSkip = RollerUtil.roll(100) < (70 - (13 * candidateEncounter.skipCount));
if (!isSkip) return 0;
candidateEncounter.skipCount++;
const maxSkip = xps.length - 1;
// flip coins; so long as we get heads, keep skipping
for (let i = 0; i < maxSkip; ++i) {
if (RollerUtil.roll(2) === 0) {
return i;
}
}
return maxSkip - 1;
}
}

View File

@@ -0,0 +1,82 @@
export class EncounterBuilderUiHelp {
static getHelpEntry ({partyMeta, encounterXpInfo}) {
// TODO(Future) update this based on the actual method being used
return {
type: "entries",
entries: [
`{@b Adjusted by a ${encounterXpInfo.playerAdjustedXpMult}× multiplier, based on a minimum challenge rating threshold of approximately ${`${encounterXpInfo.crCutoff.toFixed(2)}`.replace(/[,.]?0+$/, "")}*&dagger;, and a party size of ${encounterXpInfo.playerCount} players.}`,
// `{@note * If the maximum challenge rating is two or less, there is no minimum threshold. Similarly, if less than a third of the party are level 5 or higher, there is no minimum threshold. Otherwise, for each creature in the encounter, the average CR of the encounter is calculated while excluding that creature. The highest of these averages is then halved to produce a minimum CR threshold. CRs less than this minimum are ignored for the purposes of calculating the final CR multiplier.}`,
`{@note * If the maximum challenge rating is two or less, there is no minimum threshold. Similarly, if less than a third of the party are level 5 or higher, there is no minimum threshold. Otherwise, for each creature in the encounter in lowest-to-highest CR order, the average CR of the encounter is calculated while excluding that creature. Then, if the removed creature's CR is more than one deviation less than this average, the process repeats. Once the process halts, this threshold value (average minus one deviation) becomes the final CR cutoff.}`,
`<hr>`,
{
type: "quote",
entries: [
`&dagger; [...] don't count any monsters whose challenge rating is significantly below the average challenge rating of the other monsters in the group [...]`,
],
"by": "{@book Dungeon Master's Guide, page 82|DMG|3|4 Modify Total XP for Multiple Monsters}",
},
`<hr>`,
{
"type": "table",
"caption": "Encounter Multipliers",
"colLabels": [
"Number of Monsters",
"Multiplier",
],
"colStyles": [
"col-6 text-center",
"col-6 text-center",
],
"rows": [
[
"1",
"×1",
],
[
"2",
"×1.5",
],
[
"3-6",
"×2",
],
[
"7-10",
"×2.5",
],
[
"11-14",
"×3",
],
[
"15 or more",
"×4",
],
],
},
...(partyMeta.cntPlayers < 3
? [
{
type: "quote",
entries: [
"If the party contains fewer than three characters, apply the next highest multiplier on the Encounter Multipliers table.",
],
"by": "{@book Dungeon Master's Guide, page 83|DMG|3|Party Size}",
},
]
: partyMeta.cntPlayers >= 6
? [
{
type: "quote",
entries: [
"If the party contains six or more characters, use the next lowest multiplier on the table. Use a multiplier of 0.5 for a single monster.",
],
"by": "{@book Dungeon Master's Guide, page 83|DMG|3|Party Size}",
},
]
: []
),
],
};
}
}

View File

@@ -0,0 +1,43 @@
export class EncounterBuilderUiTtk {
static getApproxTurnsToKill ({partyMeta, creatureMetas}) {
const playerMetas = partyMeta.levelMetas;
if (!playerMetas.length) return 0;
const totalDpt = playerMetas
.map(playerMeta => this._getApproxDpt(playerMeta.level) * playerMeta.count)
.sum();
const totalHp = creatureMetas
.map(creatureMeta => {
const approxHp = creatureMeta.getApproxHp();
const approxAc = creatureMeta.getApproxAc();
if (approxHp == null || approxAc == null) return 0;
return (approxHp * (approxAc / 10)) * creatureMeta.count;
})
.sum();
return totalHp / totalDpt;
}
static _APPROX_OUTPUT_FIGHTER_CHAMPION = [
{hit: 0, dmg: 17.38}, {hit: 0, dmg: 17.38}, {hit: 0, dmg: 17.59}, {hit: 0, dmg: 33.34}, {hit: 1, dmg: 50.92}, {hit: 2, dmg: 53.92}, {hit: 2, dmg: 53.92}, {hit: 3, dmg: 56.92}, {hit: 4, dmg: 56.92}, {hit: 4, dmg: 56.92}, {hit: 4, dmg: 76.51}, {hit: 4, dmg: 76.51}, {hit: 5, dmg: 76.51}, {hit: 5, dmg: 76.51}, {hit: 5, dmg: 77.26}, {hit: 5, dmg: 77.26}, {hit: 6, dmg: 77.26}, {hit: 6, dmg: 77.26}, {hit: 6, dmg: 77.26}, {hit: 6, dmg: 97.06},
];
static _APPROX_OUTPUT_ROGUE_TRICKSTER = [
{hit: 5, dmg: 11.4}, {hit: 5, dmg: 11.4}, {hit: 10, dmg: 15.07}, {hit: 11, dmg: 16.07}, {hit: 12, dmg: 24.02}, {hit: 12, dmg: 24.02}, {hit: 12, dmg: 27.7}, {hit: 13, dmg: 28.7}, {hit: 14, dmg: 32.38}, {hit: 14, dmg: 32.38}, {hit: 14, dmg: 40.33}, {hit: 14, dmg: 40.33}, {hit: 15, dmg: 44}, {hit: 15, dmg: 44}, {hit: 15, dmg: 47.67}, {hit: 15, dmg: 47.67}, {hit: 16, dmg: 55.63}, {hit: 16, dmg: 55.63}, {hit: 16, dmg: 59.3}, {hit: 16, dmg: 59.3},
];
static _APPROX_OUTPUT_WIZARD = [
{hit: 5, dmg: 14.18}, {hit: 5, dmg: 14.18}, {hit: 5, dmg: 22.05}, {hit: 6, dmg: 22.05}, {hit: 2, dmg: 28}, {hit: 2, dmg: 28}, {hit: 2, dmg: 36}, {hit: 3, dmg: 36}, {hit: 6, dmg: 67.25}, {hit: 6, dmg: 67.25}, {hit: 4, dmg: 75}, {hit: 4, dmg: 75}, {hit: 5, dmg: 85.5}, {hit: 5, dmg: 85.5}, {hit: 5, dmg: 96}, {hit: 5, dmg: 96}, {hit: 6, dmg: 140}, {hit: 6, dmg: 140}, {hit: 6, dmg: 140}, {hit: 6, dmg: 140},
];
static _APPROX_OUTPUT_CLERIC = [
{hit: 5, dmg: 17.32}, {hit: 5, dmg: 17.32}, {hit: 5, dmg: 23.1}, {hit: 6, dmg: 23.1}, {hit: 7, dmg: 28.88}, {hit: 7, dmg: 28.88}, {hit: 7, dmg: 34.65}, {hit: 8, dmg: 34.65}, {hit: 9, dmg: 40.42}, {hit: 9, dmg: 40.42}, {hit: 9, dmg: 46.2}, {hit: 9, dmg: 46.2}, {hit: 10, dmg: 51.98}, {hit: 10, dmg: 51.98}, {hit: 11, dmg: 57.75}, {hit: 11, dmg: 57.75}, {hit: 11, dmg: 63.52}, {hit: 11, dmg: 63.52}, {hit: 11, dmg: 63.52}, {hit: 11, dmg: 63.52},
];
static _APPROX_OUTPUTS = [this._APPROX_OUTPUT_FIGHTER_CHAMPION, this._APPROX_OUTPUT_ROGUE_TRICKSTER, this._APPROX_OUTPUT_WIZARD, this._APPROX_OUTPUT_CLERIC];
static _getApproxDpt (pcLevel) {
const approxOutput = this._APPROX_OUTPUTS.map(it => it[pcLevel - 1]);
return approxOutput.map(it => it.dmg * ((it.hit + 10.5) / 20)).mean(); // 10.5 = average d20
}
}

View File

@@ -0,0 +1,647 @@
import {EncounterBuilderRandomizer} from "./encounterbuilder-randomizer.js";
import {EncounterBuilderCreatureMeta, EncounterBuilderXpInfo, EncounterPartyMeta, EncounterPartyPlayerMeta} from "./encounterbuilder-models.js";
import {EncounterBuilderUiTtk} from "./encounterbuilder-ui-ttk.js";
import {EncounterBuilderUiHelp} from "./encounterbuilder-ui-help.js";
import {EncounterBuilderRenderableCollectionPlayersSimple} from "./encounterbuilder-playerssimple.js";
import {EncounterBuilderRenderableCollectionColsExtraAdvanced} from "./encounterbuilder-colsextraadvanced.js";
import {EncounterBuilderRenderableCollectionPlayersAdvanced} from "./encounterbuilder-playersadvanced.js";
import {EncounterBuilderAdjuster} from "./encounterbuilder-adjuster.js";
/**
* TODO rework this to use doubled multipliers for XP, so we avoid the 0.5x issue for 6+ party sizes. Then scale
* everything back down at the end.
*/
export class EncounterBuilderUi extends BaseComponent {
static _RenderState = class {
constructor () {
this.$wrpRowsSimple = null;
this.$wrpRowsAdvanced = null;
this.$wrpHeadersAdvanced = null;
this.$wrpFootersAdvanced = null;
this.infoHoverId = null;
this._collectionPlayersSimple = null;
this._collectionColsExtraAdvanced = null;
this._collectionPlayersAdvanced = null;
}
};
/* -------------------------------------------- */
_cache = null;
_comp = null;
constructor ({cache, comp}) {
super();
this._cache = cache;
this._comp = comp;
}
/**
* @param {?jQuery} $parentRandomAndAdjust
* @param {?jQuery} $parentViewer
* @param {?jQuery} $parentGroupAndDifficulty
*/
render (
{
$parentRandomAndAdjust = null,
$parentViewer = null,
$parentGroupAndDifficulty = null,
},
) {
const rdState = new this.constructor._RenderState();
this._render_randomAndAdjust({rdState, $parentRandomAndAdjust});
this._render_viewer({rdState, $parentViewer});
this._render_groupAndDifficulty({rdState, $parentGroupAndDifficulty});
this._render_addHooks({rdState});
}
/* -------------------------------------------- */
_render_randomAndAdjust ({$parentRandomAndAdjust}) {
const {
$btnRandom,
$btnRandomMode,
$liRandomEasy,
$liRandomMedium,
$liRandomHard,
$liRandomDeadly,
} = this._render_randomAndAdjust_getRandomMeta();
const {
$btnAdjust,
$btnAdjustMode,
$liAdjustEasy,
$liAdjustMedium,
$liAdjustHard,
$liAdjustDeadly,
} = this._render_randomAndAdjust_getAdjustMeta();
$$($parentRandomAndAdjust)`<div class="ve-flex-col">
<div class="ve-flex-h-right">
<div class="btn-group mr-3">
${$btnRandom}
${$btnRandomMode}
<ul class="dropdown-menu">
${$liRandomEasy}
${$liRandomMedium}
${$liRandomHard}
${$liRandomDeadly}
</ul>
</div>
<div class="btn-group">
${$btnAdjust}
${$btnAdjustMode}
<ul class="dropdown-menu">
${$liAdjustEasy}
${$liAdjustMedium}
${$liAdjustHard}
${$liAdjustDeadly}
</ul>
</div>
</div>
</div>`;
}
_render_randomAndAdjust_getRandomMeta () {
let modeRandom = "medium";
const pSetRandomMode = async (mode) => {
const randomizer = new EncounterBuilderRandomizer({
partyMeta: this._getPartyMeta(),
cache: this._cache,
});
const randomCreatureMetas = await randomizer.pGetRandomEncounter({
difficulty: mode,
lockedEncounterCreatures: this._comp.creatureMetas.filter(creatureMeta => creatureMeta.isLocked),
});
if (randomCreatureMetas != null) this._comp.creatureMetas = randomCreatureMetas;
modeRandom = mode;
$btnRandom
.text(`Random ${mode.toTitleCase()}`)
.title(`Randomly generate ${Parser.getArticle(mode)} ${mode.toTitleCase()} encounter`);
};
const $getLiRandom = (mode) => {
return $(`<li title="Randomly generate ${Parser.getArticle(mode)} ${mode.toTitleCase()} encounter"><a href="#">Random ${mode.toTitleCase()}</a></li>`)
.click(async (evt) => {
evt.preventDefault();
await pSetRandomMode(mode);
});
};
const $btnRandom = $(`<button class="btn btn-primary ecgen__btn-random-adjust" title="Randomly generate a Medium encounter">Random Medium</button>`)
.click(async evt => {
evt.preventDefault();
await pSetRandomMode(modeRandom);
});
const $btnRandomMode = $(`<button class="btn btn-primary dropdown-toggle"><span class="caret"></span></button>`);
JqueryUtil.bindDropdownButton($btnRandomMode);
return {
$btnRandom,
$btnRandomMode,
$liRandomEasy: $getLiRandom("easy"),
$liRandomMedium: $getLiRandom("medium"),
$liRandomHard: $getLiRandom("hard"),
$liRandomDeadly: $getLiRandom("deadly"),
};
}
_render_randomAndAdjust_getAdjustMeta () {
let modeAdjust = "medium";
const pSetAdjustMode = async (mode) => {
const adjuster = new EncounterBuilderAdjuster({
partyMeta: this._getPartyMeta(),
});
const adjustedCreatureMetas = await adjuster.pGetAdjustedEncounter({
difficulty: mode,
creatureMetas: this._comp.creatureMetas,
});
if (adjustedCreatureMetas != null) this._comp.creatureMetas = adjustedCreatureMetas;
modeAdjust = mode;
$btnAdjust
.text(`Adjust to ${mode.toTitleCase()}`)
.title(`Adjust the current encounter difficulty to ${mode.toTitleCase()}`);
};
const $getLiAdjust = (mode) => {
return $(`<li title="Adjust the current encounter difficulty to ${mode.toTitleCase()}"><a href="#">Adjust to ${mode.toTitleCase()}</a></li>`)
.click(async (evt) => {
evt.preventDefault();
await pSetAdjustMode(mode);
});
};
const $btnAdjust = $(`<button class="btn btn-primary ecgen__btn-random-adjust" title="Adjust the current encounter difficulty to Medium">Adjust to Medium</button>`)
.click(async evt => {
evt.preventDefault();
await pSetAdjustMode(modeAdjust);
});
const $btnAdjustMode = $(`<button class="btn btn-primary dropdown-toggle"><span class="caret"></span></button>`);
JqueryUtil.bindDropdownButton($btnAdjustMode);
return {
$btnAdjust,
$btnAdjustMode,
$liAdjustEasy: $getLiAdjust("easy"),
$liAdjustMedium: $getLiAdjust("medium"),
$liAdjustHard: $getLiAdjust("hard"),
$liAdjustDeadly: $getLiAdjust("deadly"),
};
}
/* -------------------------------------------- */
_render_viewer ({$parentViewer}) {
if (!$parentViewer) return;
const $wrpOutput = $(`<div class="py-2 mt-5" style="background: #333"></div>`);
$$($parentViewer)`${$wrpOutput}`;
this._comp.addHookCreatureMetas(() => {
const $lis = this._comp.creatureMetas
.map(creatureMeta => {
const $btnShuffle = $(`<button class="btn btn-default btn-xs"><span class="glyphicon glyphicon-random"></span></button>`)
.click(() => {
this._doShuffle({creatureMeta});
});
return $$`<li>${$btnShuffle} <span>${Renderer.get().render(`${creatureMeta.count}× {@creature ${creatureMeta.creature.name}|${creatureMeta.creature.source}}`)}</span></li>`;
});
$$($wrpOutput.empty())`<ul>
${$lis}
</ul>`;
})();
}
/* -------------------------------------------- */
_render_groupAndDifficulty ({rdState, $parentGroupAndDifficulty}) {
const {
$stg: $stgSimple,
$wrpRows: $wrpRowsSimple,
} = this._renderGroupAndDifficulty_getGroupEles_simple();
rdState.$wrpRowsSimple = $wrpRowsSimple;
const {
$stg: $stgAdvanced,
$wrpRows: $wrpRowsAdvanced,
$wrpHeaders: $wrpHeadersAdvanced,
$wrpFooters: $wrpFootersAdvanced,
} = this._renderGroupAndDifficulty_getGroupEles_advanced();
rdState.$wrpRowsAdvanced = $wrpRowsAdvanced;
rdState.$wrpHeadersAdvanced = $wrpHeadersAdvanced;
rdState.$wrpFootersAdvanced = $wrpFootersAdvanced;
const $hrHasCreatures = $(`<hr class="hr-1">`);
const $wrpDifficulty = $$`<div class="ve-flex">
${this._renderGroupAndDifficulty_$getDifficultyLhs()}
${this._renderGroupAndDifficulty_$getDifficultyRhs({rdState})}
</div>`;
this._addHookBase("derivedGroupAndDifficulty", () => {
const {
encounterXpInfo = EncounterBuilderXpInfo.getDefault(),
} = this._state.derivedGroupAndDifficulty;
$hrHasCreatures.toggleVe(encounterXpInfo.relevantCount);
$wrpDifficulty.toggleVe(encounterXpInfo.relevantCount);
})();
$$($parentGroupAndDifficulty)`
<h3 class="mt-1 m-2">Group Info</h3>
<div class="ve-flex">
${$stgSimple}
${$stgAdvanced}
${this._renderGroupAndDifficulty_$getGroupInfoRhs()}
</div>
${$hrHasCreatures}
${$wrpDifficulty}`;
rdState.collectionPlayersSimple = new EncounterBuilderRenderableCollectionPlayersSimple({
comp: this._comp,
rdState,
});
rdState.collectionColsExtraAdvanced = new EncounterBuilderRenderableCollectionColsExtraAdvanced({
comp: this._comp,
rdState,
});
rdState.collectionPlayersAdvanced = new EncounterBuilderRenderableCollectionPlayersAdvanced({
comp: this._comp,
rdState,
});
}
_renderGroupAndDifficulty_getGroupEles_simple () {
const $btnAddPlayers = $(`<button class="btn btn-primary btn-xs"><span class="glyphicon glyphicon-plus"></span> Add Another Level</button>`)
.click(() => this._comp.doAddPlayer());
const $wrpRows = $(`<div class="ve-flex-col w-100"></div>`);
const $stg = $$`<div class="w-70 ve-flex-col">
<div class="ve-flex">
<div class="w-20">Players:</div>
<div class="w-20">Level:</div>
</div>
${$wrpRows}
<div class="mb-1 ve-flex">
<div class="ecgen__wrp_add_players_btn_wrp">
${$btnAddPlayers}
</div>
</div>
${this._renderGroupAndDifficulty_$getPtAdvancedMode()}
</div>`;
this._comp.addHookIsAdvanced(() => {
$stg.toggleVe(!this._comp.isAdvanced);
})();
return {
$wrpRows,
$stg,
};
}
_renderGroupAndDifficulty_getGroupEles_advanced () {
const $btnAddPlayers = $(`<button class="btn btn-primary btn-xs"><span class="glyphicon glyphicon-plus"></span> Add Another Player</button>`)
.click(() => this._comp.doAddPlayer());
const $btnAddAdvancedCol = $(`<button class="btn btn-primary btn-xxs ecgen-player__btn-inline h-ipt-xs bl-0 bb-0 bbl-0 bbr-0 btl-0 ml-n1" title="Add Column" tabindex="-1"><span class="glyphicon glyphicon-list-alt"></span></button>`)
.click(() => this._comp.doAddColExtraAdvanced());
const $wrpHeaders = $(`<div class="ve-flex"></div>`);
const $wrpFooters = $(`<div class="ve-flex"></div>`);
const $wrpRows = $(`<div class="ve-flex-col"></div>`);
const $stg = $$`<div class="w-70 overflow-x-auto ve-flex-col">
<div class="ve-flex-h-center mb-2 bb-1p small-caps ve-self-flex-start">
<div class="w-100p mr-1 h-ipt-xs no-shrink">Name</div>
<div class="w-40p ve-text-center mr-1 h-ipt-xs no-shrink">Level</div>
${$wrpHeaders}
${$btnAddAdvancedCol}
</div>
${$wrpRows}
<div class="mb-1 ve-flex">
<div class="ecgen__wrp_add_players_btn_wrp no-shrink no-grow">
${$btnAddPlayers}
</div>
${$wrpFooters}
</div>
${this._renderGroupAndDifficulty_$getPtAdvancedMode()}
<div class="row">
<div class="w-100">
${Renderer.get().render(`{@note Additional columns will be imported into the DM Screen.}`)}
</div>
</div>
</div>`;
this._comp.addHookIsAdvanced(() => {
$stg.toggleVe(this._comp.isAdvanced);
})();
return {
$stg,
$wrpRows,
$wrpHeaders,
$wrpFooters,
};
}
_renderGroupAndDifficulty_$getPtAdvancedMode () {
const $cbAdvanced = ComponentUiUtil.$getCbBool(this._comp, "isAdvanced");
return $$`<div class="ve-flex-v-center">
<label class="ve-flex-v-center">
<div class="mr-2">Advanced Mode</div>
${$cbAdvanced}
</label>
</div>`;
}
static _TITLE_DIFFICULTIES = {
easy: "An easy encounter doesn't tax the characters' resources or put them in serious peril. They might lose a few hit points, but victory is pretty much guaranteed.",
medium: "A medium encounter usually has one or two scary moments for the players, but the characters should emerge victorious with no casualties. One or more of them might need to use healing resources.",
hard: "A hard encounter could go badly for the adventurers. Weaker characters might get taken out of the fight, and there's a slim chance that one or more characters might die.",
deadly: "A deadly encounter could be lethal for one or more player characters. Survival often requires good tactics and quick thinking, and the party risks defeat",
absurd: "An &quot;absurd&quot; encounter is a deadly encounter as per the rules, but is differentiated here to provide an additional tool for judging just how deadly a &quot;deadly&quot; encounter will be. It is calculated as: &quot;deadly + (deadly - hard)&quot;.",
};
static _TITLE_BUDGET_DAILY = "This provides a rough estimate of the adjusted XP value for encounters the party can handle before the characters will need to take a long rest.";
static _TITLE_XP_TO_NEXT_LEVEL = "The total XP required to allow each member of the party to level up to their next level.";
static _TITLE_TTK = "Time to Kill: The estimated number of turns the party will require to defeat the encounter. This assumes single-target damage only.";
static _getDifficultyKey ({partyMeta, encounterXpInfo}) {
if (encounterXpInfo.adjustedXp >= partyMeta.easy && encounterXpInfo.adjustedXp < partyMeta.medium) return "easy";
if (encounterXpInfo.adjustedXp >= partyMeta.medium && encounterXpInfo.adjustedXp < partyMeta.hard) return "medium";
if (encounterXpInfo.adjustedXp >= partyMeta.hard && encounterXpInfo.adjustedXp < partyMeta.deadly) return "hard";
if (encounterXpInfo.adjustedXp >= partyMeta.deadly && encounterXpInfo.adjustedXp < partyMeta.absurd) return "deadly";
if (encounterXpInfo.adjustedXp >= partyMeta.absurd) return "absurd";
return "trivial";
}
static _getDifficultyHtml ({partyMeta, difficulty}) {
return `<span class="help-subtle" title="${this._TITLE_DIFFICULTIES[difficulty]}">${difficulty.toTitleCase()}:</span> ${partyMeta[difficulty].toLocaleString()} XP`;
}
_renderGroupAndDifficulty_$getGroupInfoRhs () {
const $dispXpEasy = $(`<div></div>`);
const $dispXpMedium = $(`<div></div>`);
const $dispXpHard = $(`<div></div>`);
const $dispXpDeadly = $(`<div></div>`);
const $dispXpAbsurd = $(`<div></div>`);
const $dispsXpDifficulty = {
"easy": $dispXpEasy,
"medium": $dispXpMedium,
"hard": $dispXpHard,
"deadly": $dispXpDeadly,
"absurd": $dispXpAbsurd,
};
const $dispTtk = $(`<div></div>`);
const $dispBudgetDaily = $(`<div></div>`);
const $dispExpToLevel = $(`<div class="ve-muted"></div>`);
this._addHookBase("derivedGroupAndDifficulty", () => {
const {
partyMeta = EncounterPartyMeta.getDefault(),
encounterXpInfo = EncounterBuilderXpInfo.getDefault(),
} = this._state.derivedGroupAndDifficulty;
const difficulty = this.constructor._getDifficultyKey({partyMeta, encounterXpInfo});
Object.entries($dispsXpDifficulty)
.forEach(([difficulty_, $disp]) => {
$disp
.toggleClass("bold", difficulty === difficulty_)
.html(this.constructor._getDifficultyHtml({partyMeta, difficulty: difficulty_}));
});
$dispTtk
.html(`<span class="help" title="${this.constructor._TITLE_TTK}">TTK:</span> ${EncounterBuilderUiTtk.getApproxTurnsToKill({partyMeta, creatureMetas: this._comp.creatureMetas}).toFixed(2)}`);
$dispBudgetDaily
.html(`<span class="help-subtle" title="${this.constructor._TITLE_BUDGET_DAILY}">Daily Budget:</span> ${partyMeta.dailyBudget.toLocaleString()} XP`);
$dispExpToLevel
.html(`<span class="help-subtle" title="${this.constructor._TITLE_XP_TO_NEXT_LEVEL}">XP to Next Level:</span> ${partyMeta.xpToNextLevel.toLocaleString()} XP`);
})();
return $$`<div class="w-30 text-right">
${$dispXpEasy}
${$dispXpMedium}
${$dispXpHard}
${$dispXpDeadly}
${$dispXpAbsurd}
<br>
${$dispTtk}
<br>
${$dispBudgetDaily}
${$dispExpToLevel}
</div>`;
}
_renderGroupAndDifficulty_$getDifficultyLhs () {
const $dispDifficulty = $(`<h3 class="mt-2"></h3>`);
this._addHookBase("derivedGroupAndDifficulty", () => {
const {
partyMeta = EncounterPartyMeta.getDefault(),
encounterXpInfo = EncounterBuilderXpInfo.getDefault(),
} = this._state.derivedGroupAndDifficulty;
const difficulty = this.constructor._getDifficultyKey({partyMeta, encounterXpInfo});
$dispDifficulty.text(`Difficulty: ${difficulty.toTitleCase()}`);
})();
return $$`<div class="w-50">
${$dispDifficulty}
</div>`;
}
_renderGroupAndDifficulty_$getDifficultyRhs ({rdState}) {
const $dispXpRawTotal = $(`<h4></h4>`);
const $dispXpRawPerPlayer = $(`<i></i>`);
const $hovXpAdjustedInfo = $(`<span class="glyphicon glyphicon-info-sign mr-2"></span>`);
const $dispXpAdjustedTotal = $(`<h4 class="ve-flex-v-center"></h4>`);
const $dispXpAdjustedPerPlayer = $(`<i></i>`);
this._addHookBase("derivedGroupAndDifficulty", () => {
const {
partyMeta = EncounterPartyMeta.getDefault(),
encounterXpInfo = EncounterBuilderXpInfo.getDefault(),
} = this._state.derivedGroupAndDifficulty;
$dispXpRawTotal.text(`Total XP: ${encounterXpInfo.baseXp.toLocaleString()}`);
$dispXpRawPerPlayer.text(`(${Math.floor(encounterXpInfo.baseXp / partyMeta.cntPlayers).toLocaleString()} per player)`);
const infoEntry = EncounterBuilderUiHelp.getHelpEntry({partyMeta, encounterXpInfo});
if (rdState.infoHoverId == null) {
const hoverMeta = Renderer.hover.getMakePredefinedHover(infoEntry, {isBookContent: true});
rdState.infoHoverId = hoverMeta.id;
$hovXpAdjustedInfo
.off("mouseover")
.off("mousemove")
.off("mouseleave")
.on("mouseover", function (event) { hoverMeta.mouseOver(event, this); })
.on("mousemove", function (event) { hoverMeta.mouseMove(event, this); })
.on("mouseleave", function (event) { hoverMeta.mouseLeave(event, this); });
} else {
Renderer.hover.updatePredefinedHover(rdState.infoHoverId, infoEntry);
}
$dispXpAdjustedTotal.html(`Adjusted XP <span class="ve-small ve-muted ml-2" title="XP Multiplier">(×${encounterXpInfo.playerAdjustedXpMult})</span>: <b class="ml-2">${encounterXpInfo.adjustedXp.toLocaleString()}</b>`);
$dispXpAdjustedPerPlayer.text(`(${Math.floor(encounterXpInfo.adjustedXp / partyMeta.cntPlayers).toLocaleString()} per player)`);
})();
return $$`<div class="w-50 text-right">
${$dispXpRawTotal}
<div>${$dispXpRawPerPlayer}</div>
<div class="ve-flex-v-center ve-flex-h-right">${$hovXpAdjustedInfo}${$dispXpAdjustedTotal}</div>
<div>${$dispXpAdjustedPerPlayer}</div>
</div>`;
}
/* -------------------------------------------- */
_render_addHooks ({rdState}) {
this._comp.addHookPlayersSimple((valNotFirstRun) => {
rdState.collectionPlayersSimple.render();
if (valNotFirstRun == null) return;
this._render_hk_setDerivedGroupAndDifficulty();
this._render_hk_doUpdateExternalStates();
})();
this._comp.addHookPlayersAdvanced((valNotFirstRun) => {
rdState.collectionPlayersAdvanced.render();
if (valNotFirstRun == null) return;
this._render_hk_setDerivedGroupAndDifficulty();
this._render_hk_doUpdateExternalStates();
})();
this._comp.addHookIsAdvanced((valNotFirstRun) => {
if (valNotFirstRun == null) return;
this._render_hk_setDerivedGroupAndDifficulty();
this._render_hk_doUpdateExternalStates();
})();
this._comp.addHookCreatureMetas(() => {
this._render_hk_setDerivedGroupAndDifficulty();
this._render_hk_doUpdateExternalStates();
})();
this._comp.addHookColsExtraAdvanced(() => {
rdState.collectionColsExtraAdvanced.render();
this._render_hk_doUpdateExternalStates();
})();
}
_render_hk_setDerivedGroupAndDifficulty () {
const partyMeta = this._getPartyMeta();
const encounterXpInfo = EncounterBuilderCreatureMeta.getEncounterXpInfo(this._comp.creatureMetas, this._getPartyMeta());
this._state.derivedGroupAndDifficulty = {
partyMeta,
encounterXpInfo,
};
}
_render_hk_doUpdateExternalStates () {
/* Implement as required */
}
/* -------------------------------------------- */
_doShuffle ({creatureMeta}) {
if (creatureMeta.isLocked) return;
const ix = this._comp.creatureMetas.findIndex(creatureMeta_ => creatureMeta_.isSameCreature(creatureMeta));
if (!~ix) throw new Error(`Could not find creature ${creatureMeta.getHash()} (${creatureMeta.customHashId})`);
const creatureMeta_ = this._comp.creatureMetas[ix];
if (creatureMeta_.isLocked) return;
const lockedHashes = new Set(
this._comp.creatureMetas
.filter(creatureMeta => creatureMeta.isLocked)
.map(creatureMeta => creatureMeta.getHash()),
);
const monRolled = this._doShuffle_getShuffled({creatureMeta: creatureMeta_, lockedHashes});
if (!monRolled) return JqueryUtil.doToast({content: "Could not find another creature worth the same amount of XP!", type: "warning"});
const creatureMetaNxt = new EncounterBuilderCreatureMeta({
creature: monRolled,
count: creatureMeta_.count,
});
const creatureMetasNxt = [...this._comp.creatureMetas];
const withMonRolled = creatureMetasNxt.find(creatureMeta_ => creatureMeta_.hasCreature(monRolled));
if (withMonRolled) {
withMonRolled.count += creatureMetaNxt.count;
creatureMetasNxt.splice(ix, 1);
} else {
creatureMetasNxt[ix] = creatureMetaNxt;
}
this._comp.creatureMetas = creatureMetasNxt;
}
_doShuffle_getShuffled ({creatureMeta, lockedHashes}) {
const xp = creatureMeta.getXp();
const hash = creatureMeta.getHash();
const availMons = this._cache.getCreaturesByXp(xp)
.filter(mon => {
const hash_ = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon);
return !lockedHashes.has(hash) && hash_ !== hash;
});
if (!availMons.length) return null;
return RollerUtil.rollOnArray(availMons);
}
/* -------------------------------------------- */
_getPartyMeta () {
return this._comp.getPartyMeta();
}
_getDefaultState () {
return {
derivedGroupAndDifficulty: {},
};
}
}

34
js/encountergen.js Normal file
View File

@@ -0,0 +1,34 @@
"use strict";
class EncountersPage extends TableListPage {
constructor () {
super({
dataSource: "data/encounters.json",
dataProps: ["encounter"],
});
}
static _COL_NAME_1 = "Encounter";
static _FN_SORT (a, b, o) {
if (o.sortBy === "name") return SortUtil.ascSortEncounter(a, b);
if (o.sortBy === "source") return SortUtil.ascSortLower(a.source, b.source) || SortUtil.ascSortEncounter(a, b);
return 0;
}
_getHash (ent) {
return UrlUtil.encodeForHash([ent.name, ent.source, `${ent.minlvl ?? 0}-${ent.maxlvl ?? 0}-${ent.caption || ""}`]);
}
_getHeaderId (ent) {
return UrlUtil.encodeForHash([ent.name, ent.source]);
}
_getDisplayName (ent) {
return Renderer.table.getConvertedEncounterTableName(ent, ent);
}
}
const encountersPage = new EncountersPage();
window.addEventListener("load", () => encountersPage.pOnLoad());

126
js/feats.js Normal file
View File

@@ -0,0 +1,126 @@
"use strict";
class FeatsSublistManager extends SublistManager {
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Name",
css: "bold col-4 pl-0",
colStyle: "",
}),
new SublistCellTemplate({
name: "Ability",
css: "col-4",
colStyle: "",
}),
new SublistCellTemplate({
name: "Prerequisite",
css: "col-4 pr-0",
colStyle: "",
}),
];
}
pGetSublistItem (it, hash) {
const cellsText = [it.name, it._slAbility, it._slPrereq];
const $ele = $(`<div class="lst__row lst__row--sublist ve-flex-col">
<a href="#${hash}" class="lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText})}
</a>
</div>`)
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._listSub.doSelect(listItem, evt));
const listItem = new ListItem(
hash,
$ele,
it.name,
{
hash,
ability: it._slAbility,
prerequisite: it._slPrereq,
},
{
entity: it,
mdRow: [...cellsText],
},
);
return listItem;
}
}
class FeatsPage extends ListPage {
constructor () {
const pageFilter = new PageFilterFeats();
super({
dataSource: DataUtil.feat.loadJSON.bind(DataUtil.feat),
dataSourceFluff: DataUtil.featFluff.loadJSON.bind(DataUtil.featFluff),
pFnGetFluff: Renderer.feat.pGetFluff.bind(Renderer.feat),
pageFilter,
dataProps: ["feat"],
bookViewOptions: {
namePlural: "feats",
pageTitle: "Feats Book View",
},
isPreviewable: true,
isMarkdownPopout: true,
});
}
getListItem (feat, ftI, isExcluded) {
this._pageFilter.mutateAndAddToFilters(feat, isExcluded);
const eleLi = document.createElement("div");
eleLi.className = `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`;
const source = Parser.sourceJsonToAbv(feat.source);
const hash = UrlUtil.autoEncodeHash(feat);
eleLi.innerHTML = `<a href="#${hash}" class="lst--border lst__row-inner">
<span class="col-0-3 px-0 ve-flex-vh-center lst__btn-toggle-expand ve-self-flex-stretch">[+]</span>
<span class="bold col-3-5 px-1">${feat.name}</span>
<span class="col-3-5 ${feat._slAbility === VeCt.STR_NONE ? "list-entry-none " : ""}">${feat._slAbility}</span>
<span class="col-3 ${feat._slPrereq === VeCt.STR_NONE ? "list-entry-none " : ""}">${feat._slPrereq}</span>
<span class="source col-1-7 ve-text-center ${Parser.sourceJsonToColor(feat.source)} pr-0" title="${Parser.sourceJsonToFull(feat.source)}" ${Parser.sourceJsonToStyle(feat.source)}>${source}</span>
</a>
<div class="ve-flex ve-hidden relative lst__wrp-preview">
<div class="vr-0 absolute lst__vr-preview"></div>
<div class="ve-flex-col py-3 ml-4 lst__wrp-preview-inner"></div>
</div>`;
const listItem = new ListItem(
ftI,
eleLi,
feat.name,
{
hash,
source,
ability: feat._slAbility,
prerequisite: feat._slPrereq,
},
{
isExcluded,
},
);
eleLi.addEventListener("click", (evt) => this._list.doSelect(listItem, evt));
eleLi.addEventListener("contextmenu", (evt) => this._openContextMenu(evt, this._list, listItem));
return listItem;
}
_renderStats_doBuildStatsTab ({ent}) {
this._$pgContent.empty().append(RenderFeats.$getRenderedFeat(ent));
}
}
const featsPage = new FeatsPage();
featsPage.sublistManager = new FeatsSublistManager();
window.addEventListener("load", () => featsPage.pOnLoad());

52
js/filter-actions.js Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
class PageFilterActions extends PageFilter {
static getTimeText (time) {
return typeof time === "string" ? time : Parser.getTimeToFull(time);
}
constructor () {
super();
this._timeFilter = new Filter({
header: "Type",
displayFn: StrUtil.uppercaseFirst,
itemSortFn: SortUtil.ascSortLower,
});
this._miscFilter = new Filter({header: "Miscellaneous", items: ["Optional/Variant Action", "SRD", "Basic Rules"], isMiscFilter: true});
}
static mutateForFilters (it) {
it._fTime = it.time ? it.time.map(it => it.unit || it) : null;
it._fMisc = [];
if (it.srd) it._fMisc.push("SRD");
if (it.basicRules) it._fMisc.push("Basic Rules");
if (it.fromVariant) it._fMisc.push("Optional/Variant Action");
}
addToFilters (it, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(it.source);
this._timeFilter.addItem(it._fTime);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._timeFilter,
this._miscFilter,
];
}
toDisplay (values, it) {
return this._filterBox.toDisplay(
values,
it.source,
it._fTime,
it._fMisc,
);
}
}
globalThis.PageFilterActions = PageFilterActions;

182
js/filter-backgrounds.js Normal file
View File

@@ -0,0 +1,182 @@
"use strict";
class PageFilterBackgrounds extends PageFilter {
// TODO(Future) expand/move to `Renderer.generic`
static _getToolDisplayText (tool) {
if (tool === "anyTool") return "Any Tool";
if (tool === "anyArtisansTool") return "Any Artisan's Tool";
if (tool === "anyMusicalInstrument") return "Any Musical Instrument";
return tool.toTitleCase();
}
constructor () {
super();
this._skillFilter = new Filter({header: "Skill Proficiencies", displayFn: StrUtil.toTitleCase});
this._toolFilter = new Filter({header: "Tool Proficiencies", displayFn: PageFilterBackgrounds._getToolDisplayText.bind(PageFilterBackgrounds)});
this._languageFilter = new Filter({
header: "Language Proficiencies",
displayFn: it => it === "anyStandard"
? "Any Standard"
: it === "anyExotic"
? "Any Exotic"
: StrUtil.toTitleCase(it),
});
this._asiFilter = new AbilityScoreFilter({header: "Ability Scores"});
this._otherBenefitsFilter = new Filter({header: "Other Benefits"});
this._miscFilter = new Filter({header: "Miscellaneous", items: ["Has Info", "Has Images", "SRD", "Basic Rules"], isMiscFilter: true});
}
static mutateForFilters (bg) {
bg._fSources = SourceFilter.getCompleteFilterSources(bg);
const {summary: skillDisplay, collection: skills} = Renderer.generic.getSkillSummary({
skillProfs: bg.skillProficiencies,
skillToolLanguageProfs: bg.skillToolLanguageProficiencies,
isShort: true,
});
bg._fSkills = skills;
const {collection: tools} = Renderer.generic.getToolSummary({
toolProfs: bg.toolProficiencies,
skillToolLanguageProfs: bg.skillToolLanguageProficiencies,
isShort: true,
});
bg._fTools = tools;
const {collection: languages} = Renderer.generic.getLanguageSummary({
languageProfs: bg.languageProficiencies,
skillToolLanguageProfs: bg.skillToolLanguageProficiencies,
isShort: true,
});
bg._fLangs = languages;
bg._fMisc = [];
if (bg.srd) bg._fMisc.push("SRD");
if (bg.basicRules) bg._fMisc.push("Basic Rules");
if (bg.hasFluff || bg.fluff?.entries) bg._fMisc.push("Has Info");
if (bg.hasFluffImages || bg.fluff?.images) bg._fMisc.push("Has Images");
bg._fOtherBenifits = [];
if (bg.feats) bg._fOtherBenifits.push("Feat");
if (bg.additionalSpells) bg._fOtherBenifits.push("Additional Spells");
if (bg.armorProficiencies) bg._fOtherBenifits.push("Armor Proficiencies");
if (bg.weaponProficiencies) bg._fOtherBenifits.push("Weapon Proficiencies");
bg._skillDisplay = skillDisplay;
}
addToFilters (bg, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(bg._fSources);
this._skillFilter.addItem(bg._fSkills);
this._toolFilter.addItem(bg._fTools);
this._languageFilter.addItem(bg._fLangs);
this._asiFilter.addItem(bg.ability);
this._otherBenefitsFilter.addItem(bg._fOtherBenifits);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._skillFilter,
this._toolFilter,
this._languageFilter,
this._asiFilter,
this._otherBenefitsFilter,
this._miscFilter,
];
}
toDisplay (values, bg) {
return this._filterBox.toDisplay(
values,
bg._fSources,
bg._fSkills,
bg._fTools,
bg._fLangs,
bg.ability,
bg._fOtherBenifits,
bg._fMisc,
);
}
}
globalThis.PageFilterBackgrounds = PageFilterBackgrounds;
class ModalFilterBackgrounds extends ModalFilter {
/**
* @param opts
* @param opts.namespace
* @param [opts.isRadio]
* @param [opts.allData]
*/
constructor (opts) {
opts = opts || {};
super({
...opts,
modalTitle: `Background${opts.isRadio ? "" : "s"}`,
pageFilter: new PageFilterBackgrounds(),
});
}
_$getColumnHeaders () {
const btnMeta = [
{sort: "name", text: "Name", width: "4"},
{sort: "skills", text: "Skills", width: "6"},
{sort: "source", text: "Source", width: "1"},
];
return ModalFilter._$getFilterColumnHeaders(btnMeta);
}
async _pLoadAllData () {
return [
...(await DataUtil.loadJSON(`${Renderer.get().baseUrl}data/backgrounds.json`)).background,
...((await PrereleaseUtil.pGetBrewProcessed()).background || []),
...((await BrewUtil2.pGetBrewProcessed()).background || []),
];
}
_getListItem (pageFilter, bg, bgI) {
const eleRow = document.createElement("div");
eleRow.className = "px-0 w-100 ve-flex-col no-shrink";
const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BACKGROUNDS](bg);
const source = Parser.sourceJsonToAbv(bg.source);
eleRow.innerHTML = `<div class="w-100 ve-flex-vh-center lst--border veapp__list-row no-select lst__wrp-cells">
<div class="col-0-5 pl-0 ve-flex-vh-center">${this._isRadio ? `<input type="radio" name="radio" class="no-events">` : `<input type="checkbox" class="no-events">`}</div>
<div class="col-0-5 px-1 ve-flex-vh-center">
<div class="ui-list__btn-inline px-2" title="Toggle Preview (SHIFT to Toggle Info Preview)">[+]</div>
</div>
<div class="col-4 ${bg._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${bg._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${bg.name}</div>
<div class="col-6">${bg._skillDisplay}</div>
<div class="col-1 pr-0 ve-text-center ${Parser.sourceJsonToColor(bg.source)}" title="${Parser.sourceJsonToFull(bg.source)}" ${Parser.sourceJsonToStyle(bg.source)}>${source}</div>
</div>`;
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
const listItem = new ListItem(
bgI,
eleRow,
bg.name,
{
hash,
source,
sourceJson: bg.source,
skills: bg._skillDisplay,
},
{
cbSel: eleRow.firstElementChild.firstElementChild.firstElementChild,
btnShowHidePreview,
},
);
ListUiUtil.bindPreviewButton(UrlUtil.PG_BACKGROUNDS, this._allData, listItem, btnShowHidePreview);
return listItem;
}
}
globalThis.ModalFilterBackgrounds = ModalFilterBackgrounds;

727
js/filter-bestiary.js Normal file
View File

@@ -0,0 +1,727 @@
"use strict";
class PageFilterBestiary extends PageFilter {
static _NEUT_ALIGNS = ["NX", "NY"];
static MISC_FILTER_SPELLCASTER = "Spellcaster, ";
static _RE_SPELL_TAG = /{@spell ([^}]+)}/g;
static _RE_ITEM_TAG = /{@item ([^}]+)}/g;
static _WALKER = null;
static _BASIC_ENTRY_PROPS = [
"trait",
"action",
"bonus",
"reaction",
"legendary",
"mythic",
];
static _DRAGON_AGES = ["wyrmling", "young", "adult", "ancient", "greatwyrm", "aspect"];
// region static
static sortMonsters (a, b, o) {
if (o.sortBy === "count") return SortUtil.ascSort(a.data.count, b.data.count) || SortUtil.compareListNames(a, b);
switch (o.sortBy) {
case "name": return SortUtil.compareListNames(a, b);
case "type": return SortUtil.ascSort(a.values.type, b.values.type) || SortUtil.compareListNames(a, b);
case "source": return SortUtil.ascSort(a.values.source, b.values.source) || SortUtil.compareListNames(a, b);
case "cr": return SortUtil.ascSortCr(a.values.cr, b.values.cr) || SortUtil.compareListNames(a, b);
case "page": return SortUtil.ascSort(a.values.page, b.values.page) || SortUtil.compareListNames(a, b);
}
}
static ascSortMiscFilter (a, b) {
a = a.item;
b = b.item;
if (a.includes(PageFilterBestiary.MISC_FILTER_SPELLCASTER) && b.includes(PageFilterBestiary.MISC_FILTER_SPELLCASTER)) {
a = Parser.attFullToAbv(a.replace(PageFilterBestiary.MISC_FILTER_SPELLCASTER, ""));
b = Parser.attFullToAbv(b.replace(PageFilterBestiary.MISC_FILTER_SPELLCASTER, ""));
return SortUtil.ascSortAtts(a, b);
} else {
a = Parser.monMiscTagToFull(a);
b = Parser.monMiscTagToFull(b);
return SortUtil.ascSortLower(a, b);
}
}
static _ascSortDragonAgeFilter (a, b) {
a = a.item;
b = b.item;
const ixA = PageFilterBestiary._DRAGON_AGES.indexOf(a);
const ixB = PageFilterBestiary._DRAGON_AGES.indexOf(b);
if (~ixA && ~ixB) return SortUtil.ascSort(ixA, ixB);
if (~ixA) return Number.MIN_SAFE_INTEGER;
if (~ixB) return Number.MAX_SAFE_INTEGER;
return SortUtil.ascSortLower(a, b);
}
static getAllImmRest (toParse, key) {
const out = [];
for (const it of toParse) this._getAllImmRest_recurse(it, key, out); // Speed > safety
return out;
}
static _getAllImmRest_recurse (it, key, out, conditional) {
if (typeof it === "string") {
out.push(conditional ? `${it} (Conditional)` : it);
} else if (it[key]) {
it[key].forEach(nxt => this._getAllImmRest_recurse(nxt, key, out, !!it.cond));
}
}
static _getDamageTagDisplayText (tag) { return Parser.dmgTypeToFull(tag).toTitleCase(); }
static _getConditionDisplayText (uid) { return uid.split("|")[0].toTitleCase(); }
static _getAbilitySaveDisplayText (abl) { return `${abl.uppercaseFirst()} Save`; }
// endregion
constructor () {
super();
this._crFilter = new RangeFilter({
header: "Challenge Rating",
isLabelled: true,
labelSortFn: SortUtil.ascSortCr,
labels: [...Parser.CRS, "Unknown", "\u2014"],
labelDisplayFn: it => it === "\u2014" ? "None" : it,
});
this._sizeFilter = new Filter({
header: "Size",
items: [
Parser.SZ_TINY,
Parser.SZ_SMALL,
Parser.SZ_MEDIUM,
Parser.SZ_LARGE,
Parser.SZ_HUGE,
Parser.SZ_GARGANTUAN,
Parser.SZ_VARIES,
],
displayFn: Parser.sizeAbvToFull,
itemSortFn: null,
});
this._speedFilter = new RangeFilter({header: "Speed", min: 30, max: 30, suffix: " ft"});
this._speedTypeFilter = new Filter({header: "Speed Type", items: [...Parser.SPEED_MODES, "hover"], displayFn: StrUtil.uppercaseFirst});
this._strengthFilter = new RangeFilter({header: "Strength", min: 1, max: 30});
this._dexterityFilter = new RangeFilter({header: "Dexterity", min: 1, max: 30});
this._constitutionFilter = new RangeFilter({header: "Constitution", min: 1, max: 30});
this._intelligenceFilter = new RangeFilter({header: "Intelligence", min: 1, max: 30});
this._wisdomFilter = new RangeFilter({header: "Wisdom", min: 1, max: 30});
this._charismaFilter = new RangeFilter({header: "Charisma", min: 1, max: 30});
this._abilityScoreFilter = new MultiFilter({
header: "Ability Scores",
filters: [this._strengthFilter, this._dexterityFilter, this._constitutionFilter, this._intelligenceFilter, this._wisdomFilter, this._charismaFilter],
isAddDropdownToggle: true,
});
this._acFilter = new RangeFilter({header: "Armor Class"});
this._averageHpFilter = new RangeFilter({header: "Average Hit Points"});
this._typeFilter = new Filter({
header: "Type",
items: [...Parser.MON_TYPES],
displayFn: StrUtil.toTitleCase,
itemSortFn: SortUtil.ascSortLower,
});
this._tagFilter = new Filter({header: "Tag", displayFn: StrUtil.toTitleCase});
this._sidekickTypeFilter = new Filter({
header: "Sidekick Type",
items: ["expert", "spellcaster", "warrior"],
displayFn: StrUtil.toTitleCase,
itemSortFn: SortUtil.ascSortLower,
});
this._sidekickTagFilter = new Filter({header: "Sidekick Tag", displayFn: StrUtil.toTitleCase});
this._alignmentFilter = new Filter({
header: "Alignment",
items: ["L", "NX", "C", "G", "NY", "E", "N", "U", "A", "No Alignment"],
displayFn: alignment => Parser.alignmentAbvToFull(alignment).toTitleCase(),
itemSortFn: null,
});
this._languageFilter = new Filter({
header: "Languages",
displayFn: (k) => Parser.monLanguageTagToFull(k).toTitleCase(),
umbrellaItems: ["X", "XX"],
umbrellaExcludes: ["CS"],
});
this._damageTypeFilterBase = new Filter({
header: "Damage Inflicted by Traits/Actions",
displayFn: this.constructor._getDamageTagDisplayText,
displayFnMini: tag => `Deals ${this.constructor._getDamageTagDisplayText(tag)} (Trait/Action)`,
items: Object.keys(Parser.DMGTYPE_JSON_TO_FULL),
});
this._damageTypeFilterLegendary = new Filter({
header: "Damage Inflicted by Lair Actions/Regional Effects",
displayFn: this.constructor._getDamageTagDisplayText,
displayFnMini: tag => `Deals ${this.constructor._getDamageTagDisplayText(tag)} (Lair/Regional)`,
items: Object.keys(Parser.DMGTYPE_JSON_TO_FULL),
});
this._damageTypeFilterSpells = new Filter({
header: "Damage Inflicted by Spells",
displayFn: this.constructor._getDamageTagDisplayText,
displayFnMini: tag => `Deals ${this.constructor._getDamageTagDisplayText(tag)} (Spell)`,
items: Object.keys(Parser.DMGTYPE_JSON_TO_FULL),
});
this._damageTypeFilter = new MultiFilter({header: "Damage Inflicted", filters: [this._damageTypeFilterBase, this._damageTypeFilterLegendary, this._damageTypeFilterSpells]});
this._conditionsInflictedFilterBase = new Filter({
header: "Conditions Inflicted by Traits/Actions",
displayFn: this.constructor._getConditionDisplayText,
displayFnMini: uid => `Inflicts ${this.constructor._getConditionDisplayText(uid)} (Trait/Action)`,
items: [...Parser.CONDITIONS],
});
this._conditionsInflictedFilterLegendary = new Filter({
header: "Conditions Inflicted by Lair Actions/Regional Effects",
displayFn: this.constructor._getConditionDisplayText,
displayFnMini: uid => `Inflicts ${this.constructor._getConditionDisplayText(uid)} (Lair/Regional)`,
items: [...Parser.CONDITIONS],
});
this._conditionsInflictedFilterSpells = new Filter({
header: "Conditions Inflicted by Spells",
displayFn: this.constructor._getConditionDisplayText,
displayFnMini: uid => `Inflicts ${this.constructor._getConditionDisplayText(uid)} (Spell)`,
items: [...Parser.CONDITIONS],
});
this._conditionsInflictedFilter = new MultiFilter({header: "Conditions Inflicted", filters: [this._conditionsInflictedFilterBase, this._conditionsInflictedFilterLegendary, this._conditionsInflictedFilterSpells]});
this._savingThrowForcedFilterBase = new Filter({
header: "Saving Throws Required by Traits/Actions",
displayFn: this.constructor._getAbilitySaveDisplayText,
displayFnMini: abl => `Requires ${this.constructor._getAbilitySaveDisplayText(abl)} (Trait/Action)`,
items: Parser.ABIL_ABVS.map(abl => Parser.attAbvToFull(abl).toLowerCase()),
itemSortFn: null,
});
this._savingThrowForcedFilterLegendary = new Filter({
header: "Saving Throws Required by Lair Actions/Regional Effects",
displayFn: this.constructor._getAbilitySaveDisplayText,
displayFnMini: abl => `Requires ${this.constructor._getAbilitySaveDisplayText(abl)} (Lair/Regional)`,
items: Parser.ABIL_ABVS.map(abl => Parser.attAbvToFull(abl).toLowerCase()),
itemSortFn: null,
});
this._savingThrowForcedFilterSpells = new Filter({
header: "Saving Throws Required by Spells",
displayFn: this.constructor._getAbilitySaveDisplayText,
displayFnMini: abl => `Requires ${this.constructor._getAbilitySaveDisplayText(abl)} (Spell)`,
items: Parser.ABIL_ABVS.map(abl => Parser.attAbvToFull(abl).toLowerCase()),
itemSortFn: null,
});
this._savingThrowForcedFilter = new MultiFilter({header: "Saving Throw Required", filters: [this._savingThrowForcedFilterBase, this._savingThrowForcedFilterLegendary, this._savingThrowForcedFilterSpells]});
this._senseFilter = new Filter({
header: "Senses",
displayFn: (it) => Parser.monSenseTagToFull(it).toTitleCase(),
items: ["B", "D", "SD", "T", "U"],
itemSortFn: SortUtil.ascSortLower,
});
this._passivePerceptionFilter = new RangeFilter({header: "Passive Perception", min: 10, max: 10});
this._skillFilter = new Filter({
header: "Skills",
displayFn: (it) => it.toTitleCase(),
items: Object.keys(Parser.SKILL_TO_ATB_ABV),
});
this._saveFilter = new Filter({
header: "Saves",
displayFn: Parser.attAbvToFull,
items: [...Parser.ABIL_ABVS],
itemSortFn: null,
});
this._environmentFilter = new Filter({
header: "Environment",
items: ["arctic", "coastal", "desert", "forest", "grassland", "hill", "mountain", "none", "swamp", "underdark", "underwater", "urban"],
displayFn: StrUtil.uppercaseFirst,
});
this._vulnerableFilter = FilterCommon.getDamageVulnerableFilter();
this._resistFilter = FilterCommon.getDamageResistFilter();
this._immuneFilter = FilterCommon.getDamageImmuneFilter();
this._defenceFilter = new MultiFilter({header: "Damage", filters: [this._vulnerableFilter, this._resistFilter, this._immuneFilter]});
this._conditionImmuneFilter = FilterCommon.getConditionImmuneFilter();
this._traitFilter = new Filter({
header: "Traits",
items: [
"Aggressive", "Ambusher", "Amorphous", "Amphibious", "Antimagic Susceptibility", "Brute", "Charge", "Damage Absorption", "Death Burst", "Devil's Sight", "False Appearance", "Fey Ancestry", "Flyby", "Hold Breath", "Illumination", "Immutable Form", "Incorporeal Movement", "Keen Senses", "Legendary Resistances", "Light Sensitivity", "Magic Resistance", "Magic Weapons", "Pack Tactics", "Pounce", "Rampage", "Reckless", "Regeneration", "Rejuvenation", "Shapechanger", "Siege Monster", "Sneak Attack", "Spider Climb", "Sunlight Sensitivity", "Tunneler", "Turn Immunity", "Turn Resistance", "Undead Fortitude", "Water Breathing", "Web Sense", "Web Walker",
],
});
this._actionReactionFilter = new Filter({
header: "Actions & Reactions",
items: [
"Frightful Presence", "Multiattack", "Parry", "Swallow", "Teleport", "Tentacles",
],
});
this._miscFilter = new Filter({
header: "Miscellaneous",
items: ["Familiar", ...Object.keys(Parser.MON_MISC_TAG_TO_FULL), "Bonus Actions", "Lair Actions", "Legendary", "Mythic", "Adventure NPC", "Spellcaster", ...Object.values(Parser.ATB_ABV_TO_FULL).map(it => `${PageFilterBestiary.MISC_FILTER_SPELLCASTER}${it}`), "Regional Effects", "Reactions", "Reprinted", "Swarm", "Has Variants", "Modified Copy", "Has Alternate Token", "Has Info", "Has Images", "Has Token", "Has Recharge", "SRD", "Basic Rules", "AC from Item(s)", "AC from Natural Armor", "AC from Unarmored Defense", "Summoned by Spell", "Summoned by Class"],
displayFn: (it) => Parser.monMiscTagToFull(it).uppercaseFirst(),
deselFn: (it) => ["Adventure NPC", "Reprinted"].includes(it),
itemSortFn: PageFilterBestiary.ascSortMiscFilter,
isMiscFilter: true,
});
this._spellcastingTypeFilter = new Filter({
header: "Spellcasting Type",
items: ["F", "I", "P", "S", "O", "CA", "CB", "CC", "CD", "CP", "CR", "CS", "CL", "CW"],
displayFn: Parser.monSpellcastingTagToFull,
});
this._spellSlotLevelFilter = new RangeFilter({
header: "Spell Slot Level",
min: 1,
max: 9,
displayFn: it => Parser.getOrdinalForm(it),
});
this._spellKnownFilter = new SearchableFilter({header: "Spells Known", displayFn: (it) => it.split("|")[0].toTitleCase(), itemSortFn: SortUtil.ascSortLower});
this._equipmentFilter = new SearchableFilter({header: "Equipment", displayFn: (it) => it.split("|")[0].toTitleCase(), itemSortFn: SortUtil.ascSortLower});
this._dragonAgeFilter = new Filter({
header: "Dragon Age",
items: [...PageFilterBestiary._DRAGON_AGES],
itemSortFn: PageFilterBestiary._ascSortDragonAgeFilter,
displayFn: (it) => it.toTitleCase(),
});
this._dragonCastingColor = new Filter({
header: "Dragon Casting Color",
items: [...Renderer.monster.dragonCasterVariant.getAvailableColors()],
displayFn: (it) => it.toTitleCase(),
});
}
static mutateForFilters (mon) {
Renderer.monster.initParsed(mon);
if (typeof mon.speed === "number" && mon.speed > 0) {
mon._fSpeedType = ["walk"];
mon._fSpeed = mon.speed;
} else {
mon._fSpeedType = Object.keys(mon.speed).filter(k => mon.speed[k]);
if (mon._fSpeedType.length) mon._fSpeed = mon._fSpeedType.map(k => mon.speed[k].number || mon.speed[k]).filter(it => !isNaN(it)).sort((a, b) => SortUtil.ascSort(b, a))[0];
else mon._fSpeed = 0;
if (mon.speed.canHover) mon._fSpeedType.push("hover");
}
mon._fAc = mon.ac.map(it => it.special ? null : (it.ac || it)).filter(it => it !== null);
if (!mon._fAc.length) mon._fAc = null;
mon._fHp = mon.hp.average;
if (mon.alignment) {
const tempAlign = typeof mon.alignment[0] === "object"
? Array.prototype.concat.apply([], mon.alignment.map(a => a.alignment))
: [...mon.alignment];
if (tempAlign.includes("N") && !tempAlign.includes("G") && !tempAlign.includes("E")) tempAlign.push("NY");
else if (tempAlign.includes("N") && !tempAlign.includes("L") && !tempAlign.includes("C")) tempAlign.push("NX");
else if (tempAlign.length === 1 && tempAlign.includes("N")) Array.prototype.push.apply(tempAlign, PageFilterBestiary._NEUT_ALIGNS);
mon._fAlign = tempAlign;
} else {
mon._fAlign = ["No Alignment"];
}
mon._fEnvironment = mon.environment || ["none"];
mon._fVuln = mon.vulnerable ? PageFilterBestiary.getAllImmRest(mon.vulnerable, "vulnerable") : [];
mon._fRes = mon.resist ? PageFilterBestiary.getAllImmRest(mon.resist, "resist") : [];
mon._fImm = mon.immune ? PageFilterBestiary.getAllImmRest(mon.immune, "immune") : [];
mon._fCondImm = mon.conditionImmune ? PageFilterBestiary.getAllImmRest(mon.conditionImmune, "conditionImmune") : [];
mon._fSave = mon.save ? Object.keys(mon.save) : [];
mon._fSkill = mon.skill ? Object.keys(mon.skill) : [];
mon._fSources = SourceFilter.getCompleteFilterSources(mon);
mon._fPassive = !isNaN(mon.passive) ? Number(mon.passive) : null;
Parser.ABIL_ABVS
.forEach(ab => {
if (mon[ab] == null) return;
const propF = `_f${ab.uppercaseFirst()}`;
mon[propF] = typeof mon[ab] !== "number" ? null : mon[ab];
});
mon._fMisc = [...mon.miscTags || []];
for (const it of (mon.trait || [])) {
if (it.name && it.name.startsWith("Unarmored Defense")) mon._fMisc.push("AC from Unarmored Defense");
}
for (const it of (mon.ac || [])) {
if (!it.from) continue;
if (it.from.includes("natural armor")) mon._fMisc.push("AC from Natural Armor");
if (it.from.some(x => x.startsWith("{@item "))) mon._fMisc.push("AC from Item(s)");
if (!mon._fMisc.includes("AC from Unarmored Defense") && it.from.includes("Unarmored Defense")) mon._fMisc.push("AC from Unarmored Defense");
}
if (mon.legendary) mon._fMisc.push("Legendary");
if (mon.familiar) mon._fMisc.push("Familiar");
if (mon.type.swarmSize) mon._fMisc.push("Swarm");
if (mon.spellcasting) {
mon._fMisc.push("Spellcaster");
for (const sc of mon.spellcasting) {
if (sc.ability) mon._fMisc.push(`${PageFilterBestiary.MISC_FILTER_SPELLCASTER}${Parser.attAbvToFull(sc.ability)}`);
}
}
if (mon.isNpc) mon._fMisc.push("Adventure NPC");
const legGroup = DataUtil.monster.getMetaGroup(mon);
if (legGroup) {
if (legGroup.lairActions) mon._fMisc.push("Lair Actions");
if (legGroup.regionalEffects) mon._fMisc.push("Regional Effects");
}
if (mon.reaction) mon._fMisc.push("Reactions");
if (mon.bonus) mon._fMisc.push("Bonus Actions");
if (mon.variant) mon._fMisc.push("Has Variants");
if (mon._isCopy) mon._fMisc.push("Modified Copy");
if (mon.altArt) mon._fMisc.push("Has Alternate Token");
if (mon.srd) mon._fMisc.push("SRD");
if (mon.basicRules) mon._fMisc.push("Basic Rules");
if (mon.tokenUrl || mon.hasToken) mon._fMisc.push("Has Token");
if (mon.mythic) mon._fMisc.push("Mythic");
if (mon.hasFluff || mon.fluff?.entries) mon._fMisc.push("Has Info");
if (mon.hasFluffImages || mon.fluff?.images) mon._fMisc.push("Has Images");
if (this._isReprinted({reprintedAs: mon.reprintedAs, tag: "creature", prop: "monster", page: UrlUtil.PG_BESTIARY})) mon._fMisc.push("Reprinted");
if (this._hasRecharge(mon)) mon._fMisc.push("Has Recharge");
if (mon._versionBase_isVersion) mon._fMisc.push("Is Variant");
if (mon.summonedBySpell) mon._fMisc.push("Summoned by Spell");
if (mon.summonedByClass) mon._fMisc.push("Summoned by Class");
const spellcasterMeta = this._getSpellcasterMeta(mon);
if (spellcasterMeta) {
if (spellcasterMeta.spellLevels.size) mon._fSpellSlotLevels = [...spellcasterMeta.spellLevels];
if (spellcasterMeta.spellSet.size) mon._fSpellsKnown = [...spellcasterMeta.spellSet];
}
if (mon.languageTags?.length) mon._fLanguageTags = mon.languageTags;
else mon._fLanguageTags = ["None"];
mon._fEquipment = this._getEquipmentList(mon);
}
/* -------------------------------------------- */
static _getInitWalker () {
return PageFilterBestiary._WALKER = PageFilterBestiary._WALKER || MiscUtil.getWalker({isNoModification: true});
}
/* -------------------------------------------- */
static _getSpellcasterMeta (mon) {
if (!mon.spellcasting?.length) return null;
const walker = this._getInitWalker();
const spellSet = new Set();
const spellLevels = new Set();
for (const spc of mon.spellcasting) {
if (spc.spells) {
const slotLevels = Object.keys(spc.spells).map(Number).filter(Boolean);
for (const slotLevel of slotLevels) spellLevels.add(slotLevel);
}
walker.walk(
spc,
{
string: this._getSpellcasterMeta_stringHandler.bind(this, spellSet),
},
);
}
return {spellLevels, spellSet};
}
static _getSpellcasterMeta_stringHandler (spellSet, str) {
str.replace(PageFilterBestiary._RE_SPELL_TAG, (...m) => {
const parts = m[1].split("|").slice(0, 2);
parts[1] = parts[1] || Parser.SRC_PHB;
spellSet.add(parts.join("|").toLowerCase());
return "";
});
}
/* -------------------------------------------- */
static _hasRecharge (mon) {
for (const prop of PageFilterBestiary._BASIC_ENTRY_PROPS) {
if (!mon[prop]) continue;
for (const ent of mon[prop]) {
if (!ent?.name) continue;
if (ent.name.includes("{@recharge")) return true;
}
}
return false;
}
/* -------------------------------------------- */
static _getEquipmentList (mon) {
const itemSet = new Set(mon.attachedItems || []);
const walker = this._getInitWalker();
for (const acItem of (mon.ac || [])) {
if (!acItem?.from?.length) continue;
for (const from of acItem.from) this._getEquipmentList_stringHandler(itemSet, from);
}
for (const trait of (mon.trait || [])) {
if (!trait.name.toLowerCase().startsWith("special equipment")) continue;
walker.walk(
trait.entries,
{
string: this._getEquipmentList_stringHandler.bind(this, itemSet),
},
);
break;
}
return [...itemSet];
}
static _getEquipmentList_stringHandler (itemSet, str) {
str
.replace(PageFilterBestiary._RE_ITEM_TAG, (...m) => {
const unpacked = DataUtil.proxy.unpackUid("item", m[1], "item", {isLower: true});
itemSet.add(DataUtil.proxy.getUid("item", unpacked));
return "";
});
}
/* -------------------------------------------- */
addToFilters (mon, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(mon._fSources);
this._crFilter.addItem(mon._fCr);
this._strengthFilter.addItem(mon._fStr);
this._dexterityFilter.addItem(mon._fDex);
this._constitutionFilter.addItem(mon._fCon);
this._intelligenceFilter.addItem(mon._fInt);
this._wisdomFilter.addItem(mon._fWis);
this._charismaFilter.addItem(mon._fCha);
this._speedFilter.addItem(mon._fSpeed);
mon.ac.forEach(it => this._acFilter.addItem(it.ac || it));
if (mon.hp.average) this._averageHpFilter.addItem(mon.hp.average);
this._tagFilter.addItem(mon._pTypes.tags);
this._sidekickTypeFilter.addItem(mon._pTypes.typeSidekick);
this._sidekickTagFilter.addItem(mon._pTypes.tagsSidekick);
this._traitFilter.addItem(mon.traitTags);
this._actionReactionFilter.addItem(mon.actionTags);
this._environmentFilter.addItem(mon._fEnvironment);
this._vulnerableFilter.addItem(mon._fVuln);
this._resistFilter.addItem(mon._fRes);
this._immuneFilter.addItem(mon._fImm);
this._senseFilter.addItem(mon.senseTags);
this._passivePerceptionFilter.addItem(mon._fPassive);
this._spellSlotLevelFilter.addItem(mon._fSpellSlotLevels);
this._spellKnownFilter.addItem(mon._fSpellsKnown);
this._equipmentFilter.addItem(mon._fEquipment);
if (mon._versionBase_isVersion) this._miscFilter.addItem("Is Variant");
this._damageTypeFilterBase.addItem(mon.damageTags);
this._damageTypeFilterLegendary.addItem(mon.damageTagsLegendary);
this._damageTypeFilterSpells.addItem(mon.damageTagsSpell);
this._conditionsInflictedFilterBase.addItem(mon.conditionInflict);
this._conditionsInflictedFilterLegendary.addItem(mon.conditionInflictLegendary);
this._conditionsInflictedFilterSpells.addItem(mon.conditionInflictSpell);
this._savingThrowForcedFilterBase.addItem(mon.savingThrowForced);
this._savingThrowForcedFilterLegendary.addItem(mon.savingThrowForcedLegendary);
this._savingThrowForcedFilterSpells.addItem(mon.savingThrowForcedSpell);
this._dragonAgeFilter.addItem(mon.dragonAge);
this._dragonCastingColor.addItem(mon.dragonCastingColor);
}
async _pPopulateBoxOptions (opts) {
Object.entries(Parser.MON_LANGUAGE_TAG_TO_FULL)
.sort(([, vA], [, vB]) => SortUtil.ascSortLower(vA, vB))
.forEach(([k]) => this._languageFilter.addItem(k));
this._languageFilter.addItem("None");
opts.filters = [
this._sourceFilter,
this._crFilter,
this._typeFilter,
this._tagFilter,
this._sidekickTypeFilter,
this._sidekickTagFilter,
this._environmentFilter,
this._defenceFilter,
this._conditionImmuneFilter,
this._traitFilter,
this._actionReactionFilter,
this._miscFilter,
this._spellcastingTypeFilter,
this._spellSlotLevelFilter,
this._sizeFilter,
this._speedFilter,
this._speedTypeFilter,
this._alignmentFilter,
this._saveFilter,
this._skillFilter,
this._senseFilter,
this._passivePerceptionFilter,
this._languageFilter,
this._damageTypeFilter,
this._conditionsInflictedFilter,
this._savingThrowForcedFilter,
this._dragonAgeFilter,
this._dragonCastingColor,
this._acFilter,
this._averageHpFilter,
this._abilityScoreFilter,
this._spellKnownFilter,
this._equipmentFilter,
];
}
toDisplay (values, m) {
return this._filterBox.toDisplay(
values,
m._fSources,
m._fCr,
m._pTypes.types,
m._pTypes.tags,
m._pTypes.typeSidekick,
m._pTypes.tagsSidekick,
m._fEnvironment,
[
m._fVuln,
m._fRes,
m._fImm,
],
m._fCondImm,
m.traitTags,
m.actionTags,
m._fMisc,
m.spellcastingTags,
m._fSpellSlotLevels,
m.size,
m._fSpeed,
m._fSpeedType,
m._fAlign,
m._fSave,
m._fSkill,
m.senseTags,
m._fPassive,
m._fLanguageTags,
[
m.damageTags,
m.damageTagsLegendary,
m.damageTagsSpell,
],
[
m.conditionInflict,
m.conditionInflictLegendary,
m.conditionInflictSpell,
],
[
m.savingThrowForced,
m.savingThrowForcedLegendary,
m.savingThrowForcedSpell,
],
m.dragonAge,
m.dragonCastingColor,
m._fAc,
m._fHp,
[
m._fStr,
m._fDex,
m._fCon,
m._fInt,
m._fWis,
m._fCha,
],
m._fSpellsKnown,
m._fEquipment,
);
}
}
globalThis.PageFilterBestiary = PageFilterBestiary;
class ModalFilterBestiary extends ModalFilter {
/**
* @param opts
* @param opts.namespace
* @param [opts.isRadio]
* @param [opts.allData]
*/
constructor (opts) {
opts = opts || {};
super({
...opts,
modalTitle: `Creature${opts.isRadio ? "" : "s"}`,
pageFilter: new PageFilterBestiary(),
fnSort: PageFilterBestiary.sortMonsters,
});
}
_$getColumnHeaders () {
const btnMeta = [
{sort: "name", text: "Name", width: "4"},
{sort: "type", text: "Type", width: "4"},
{sort: "cr", text: "CR", width: "2"},
{sort: "source", text: "Source", width: "1"},
];
return ModalFilter._$getFilterColumnHeaders(btnMeta);
}
async _pLoadAllData () {
return [
...(await DataUtil.monster.pLoadAll()),
...((await PrereleaseUtil.pGetBrewProcessed()).monster || []),
...((await BrewUtil2.pGetBrewProcessed()).monster || []),
];
}
_getListItem (pageFilter, mon, itI) {
Renderer.monster.initParsed(mon);
pageFilter.mutateAndAddToFilters(mon);
const eleRow = document.createElement("div");
eleRow.className = "px-0 w-100 ve-flex-col no-shrink";
const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon);
const source = Parser.sourceJsonToAbv(mon.source);
const type = mon._pTypes.asText;
const cr = mon._pCr;
eleRow.innerHTML = `<div class="w-100 ve-flex-vh-center lst--border veapp__list-row no-select lst__wrp-cells">
<div class="col-0-5 pl-0 ve-flex-vh-center">${this._isRadio ? `<input type="radio" name="radio" class="no-events">` : `<input type="checkbox" class="no-events">`}</div>
<div class="col-0-5 px-1 ve-flex-vh-center">
<div class="ui-list__btn-inline px-2" title="Toggle Preview (SHIFT to Toggle Info Preview)">[+]</div>
</div>
<div class="col-4 ${mon._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${mon._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${mon.name}</div>
<div class="col-4">${type}</div>
<div class="col-2 ve-text-center">${cr}</div>
<div class="col-1 ve-text-center ${Parser.sourceJsonToColor(mon.source)} pr-0" title="${Parser.sourceJsonToFull(mon.source)}" ${Parser.sourceJsonToStyle(mon.source)}>${source}</div>
</div>`;
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
const listItem = new ListItem(
itI,
eleRow,
mon.name,
{
hash,
source,
sourceJson: mon.source,
type,
cr,
},
{
cbSel: eleRow.firstElementChild.firstElementChild.firstElementChild,
btnShowHidePreview,
},
);
ListUiUtil.bindPreviewButton(UrlUtil.PG_BESTIARY, this._allData, listItem, btnShowHidePreview);
return listItem;
}
}
globalThis.ModalFilterBestiary = ModalFilterBestiary;
class ListSyntaxBestiary extends ListUiUtil.ListSyntax {
static _INDEXABLE_PROPS_ENTRIES = [
"trait",
"spellcasting",
"action",
"bonus",
"reaction",
"legendary",
"mythic",
"variant",
];
static _INDEXABLE_PROPS_LEG_GROUP = [
"lairActions",
"regionalEffects",
"mythicEncounter",
];
_getSearchCacheStats (entity) {
const legGroup = DataUtil.monster.getMetaGroup(entity);
if (!legGroup && this.constructor._INDEXABLE_PROPS_ENTRIES.every(it => !entity[it])) return "";
const ptrOut = {_: ""};
this.constructor._INDEXABLE_PROPS_ENTRIES.forEach(it => this._getSearchCache_handleEntryProp(entity, it, ptrOut));
if (legGroup) this.constructor._INDEXABLE_PROPS_LEG_GROUP.forEach(it => this._getSearchCache_handleEntryProp(legGroup, it, ptrOut));
return ptrOut._;
}
}
globalThis.ListSyntaxBestiary = ListSyntaxBestiary;

View File

@@ -0,0 +1,51 @@
"use strict";
class PageFilterCharCreationOptions extends PageFilter {
static _filterFeatureTypeSort (a, b) {
return SortUtil.ascSort(Parser.charCreationOptionTypeToFull(a.item), Parser.charCreationOptionTypeToFull(b.item));
}
constructor () {
super();
this._typeFilter = new Filter({
header: "Feature Type",
items: [],
displayFn: Parser.charCreationOptionTypeToFull,
itemSortFn: PageFilterCharCreationOptions._filterFeatureTypeSort,
});
this._miscFilter = new Filter({header: "Miscellaneous", items: ["SRD", "Has Images", "Has Info"], isMiscFilter: true});
}
static mutateForFilters (it) {
it._fOptionType = Parser.charCreationOptionTypeToFull(it.optionType);
it._fMisc = it.srd ? ["SRD"] : [];
if (it.hasFluff || it.fluff?.entries) it._fMisc.push("Has Info");
if (it.hasFluffImages || it.fluff?.images) it._fMisc.push("Has Images");
}
addToFilters (it, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(it.source);
this._typeFilter.addItem(it._fOptionType);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._typeFilter,
this._miscFilter,
];
}
toDisplay (values, it) {
return this._filterBox.toDisplay(
values,
it.source,
it._fOptionType,
it._fMisc,
);
}
}
globalThis.PageFilterCharCreationOptions = PageFilterCharCreationOptions;

1001
js/filter-classes-raw.js Normal file

File diff suppressed because it is too large Load Diff

269
js/filter-classes.js Normal file
View File

@@ -0,0 +1,269 @@
"use strict";
class PageFilterClassesBase extends PageFilter {
constructor () {
super();
this._miscFilter = new Filter({
header: "Miscellaneous",
items: ["Reprinted", "Sidekick", "SRD", "Basic Rules"],
deselFn: (it) => { return it === "Reprinted" || it === "Sidekick"; },
displayFnMini: it => it === "Reprinted" ? "Repr." : it,
displayFnTitle: it => it === "Reprinted" ? it : "",
isMiscFilter: true,
});
this._optionsFilter = new OptionsFilter({
header: "Other/Text Options",
defaultState: {
isDisplayClassIfSubclassActive: false,
isClassFeatureVariant: true,
},
displayFn: k => {
switch (k) {
case "isClassFeatureVariant": return "Class Feature Options/Variants";
case "isDisplayClassIfSubclassActive": return "Display Class if Any Subclass is Visible";
default: throw new Error(`Unhandled key "${k}"`);
}
},
displayFnMini: k => {
switch (k) {
case "isClassFeatureVariant": return "C.F.O/V.";
case "isDisplayClassIfSubclassActive": return "Sc>C";
default: throw new Error(`Unhandled key "${k}"`);
}
},
});
}
get optionsFilter () { return this._optionsFilter; }
static mutateForFilters (cls) {
cls.source = cls.source || Parser.SRC_PHB;
cls.subclasses = cls.subclasses || [];
cls._fSources = SourceFilter.getCompleteFilterSources(cls);
cls._fSourceSubclass = [
...new Set([
cls.source,
...cls.subclasses.map(it => [it.source, ...(it.otherSources || []).map(it => it.source)]).flat(),
]),
];
cls._fMisc = [];
if (cls.isReprinted) cls._fMisc.push("Reprinted");
if (cls.srd) cls._fMisc.push("SRD");
if (cls.basicRules) cls._fMisc.push("Basic Rules");
if (cls.isSidekick) cls._fMisc.push("Sidekick");
cls.subclasses.forEach(sc => {
sc.source = sc.source || cls.source; // default subclasses to same source as parent
sc.shortName = sc.shortName || sc.name; // ensure shortName
sc._fMisc = [];
if (sc.srd) sc._fMisc.push("SRD");
if (sc.basicRules) sc._fMisc.push("Basic Rules");
if (sc.isReprinted) sc._fMisc.push("Reprinted");
});
}
_addEntrySourcesToFilter (entry) { this._addEntrySourcesToFilter_walk(entry); }
_addEntrySourcesToFilter_walk = (obj) => {
if ((typeof obj !== "object") || obj == null) return;
if (obj instanceof Array) return obj.forEach(this._addEntrySourcesToFilter_walk.bind(this));
if (obj.source) this._sourceFilter.addItem(obj.source);
// Assume anything we care about is under `entries`, for performance
if (obj.entries) this._addEntrySourcesToFilter_walk(obj.entries);
};
/**
* @param cls
* @param isExcluded
* @param opts Options object.
* @param [opts.subclassExclusions] Map of `source:name:bool` indicating if each subclass is excluded or not.
*/
addToFilters (cls, isExcluded, opts) {
if (isExcluded) return;
opts = opts || {};
const subclassExclusions = opts.subclassExclusions || {};
this._sourceFilter.addItem(cls.source);
if (cls.fluff) cls.fluff.forEach(it => this._addEntrySourcesToFilter(it));
cls.classFeatures.forEach(lvlFeatures => lvlFeatures.forEach(feature => this._addEntrySourcesToFilter(feature)));
cls.subclasses.forEach(sc => {
const isScExcluded = (subclassExclusions[sc.source] || {})[sc.name] || false;
if (!isScExcluded) {
this._sourceFilter.addItem(sc.source);
sc.subclassFeatures.forEach(lvlFeatures => lvlFeatures.forEach(feature => this._addEntrySourcesToFilter(feature)));
}
});
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._miscFilter,
this._optionsFilter,
];
opts.isCompact = true;
}
isClassNaturallyDisplayed (values, cls) {
return this._filterBox.toDisplay(
values,
...this.constructor._getIsClassNaturallyDisplayedToDisplayParams(cls),
);
}
static _getIsClassNaturallyDisplayedToDisplayParams (cls) { return [cls._fSources, cls._fMisc]; }
isAnySubclassDisplayed (values, cls) {
return values[this._optionsFilter.header].isDisplayClassIfSubclassActive && (cls.subclasses || [])
.some(sc => {
if (this._filterBox.toDisplay(
values,
...this.constructor._getIsSubclassDisplayedToDisplayParams(cls, sc),
)) return true;
return sc.otherSources?.length && sc.otherSources.some(src => this._filterBox.toDisplay(
values,
...this.constructor._getIsSubclassDisplayedToDisplayParams(cls, sc, src),
));
});
}
static _getIsSubclassDisplayedToDisplayParams (cls, sc, otherSourcesSource) {
return [
otherSourcesSource || sc.source,
sc._fMisc,
null,
];
}
isSubclassVisible (f, cls, sc) {
if (this.filterBox.toDisplay(
f,
...this.constructor._getIsSubclassVisibleToDisplayParams(cls, sc),
)) return true;
if (!sc.otherSources?.length) return false;
return sc.otherSources.some(src => this.filterBox.toDisplay(
f,
...this.constructor._getIsSubclassVisibleToDisplayParams(cls, sc, src.source),
));
}
static _getIsSubclassVisibleToDisplayParams (cls, sc, otherSourcesSource) {
return [
otherSourcesSource || sc.source,
sc._fMisc,
null,
];
}
/** Return the first active source we find; use this as a fake source for things we want to force-display. */
getActiveSource (values) {
const sourceFilterValues = values[this._sourceFilter.header];
if (!sourceFilterValues) return null;
return Object.keys(sourceFilterValues).find(it => this._sourceFilter.toDisplay(values, it));
}
toDisplay (values, it) {
return this._filterBox.toDisplay(
values,
...this._getToDisplayParams(values, it),
);
}
_getToDisplayParams (values, cls) {
return [
this.isAnySubclassDisplayed(values, cls)
? cls._fSourceSubclass
: (cls._fSources ?? cls.source),
cls._fMisc,
null,
];
}
}
globalThis.PageFilterClassesBase = PageFilterClassesBase;
class PageFilterClasses extends PageFilterClassesBase {
static _getClassSubclassLevelArray (it) {
return it.classFeatures.map((_, i) => i + 1);
}
constructor () {
super();
this._levelFilter = new RangeFilter({
header: "Feature Level",
min: 1,
max: 20,
});
}
get levelFilter () { return this._levelFilter; }
static mutateForFilters (cls) {
super.mutateForFilters(cls);
cls._fLevelRange = this._getClassSubclassLevelArray(cls);
}
/**
* @param cls
* @param isExcluded
* @param opts Options object.
* @param [opts.subclassExclusions] Map of `source:name:bool` indicating if each subclass is excluded or not.
*/
addToFilters (cls, isExcluded, opts) {
super.addToFilters(cls, isExcluded, opts);
if (isExcluded) return;
this._levelFilter.addItem(cls._fLevelRange);
}
async _pPopulateBoxOptions (opts) {
await super._pPopulateBoxOptions(opts);
opts.filters = [
this._sourceFilter,
this._miscFilter,
this._levelFilter,
this._optionsFilter,
];
}
static _getIsClassNaturallyDisplayedToDisplayParams (cls) {
return [cls._fSources, cls._fMisc, cls._fLevelRange];
}
static _getIsSubclassDisplayedToDisplayParams (cls, sc, otherSourcesSource) {
return [otherSourcesSource || sc.source, sc._fMisc, cls._fLevelRange];
}
static _getIsSubclassVisibleToDisplayParams (cls, sc, otherSourcesSource) {
return [otherSourcesSource || sc.source, sc._fMisc, cls._fLevelRange, null];
}
_getToDisplayParams (values, cls) {
return [
this.isAnySubclassDisplayed(values, cls)
? cls._fSourceSubclass
: (cls._fSources ?? cls.source),
cls._fMisc,
cls._fLevelRange,
];
}
}
globalThis.PageFilterClasses = PageFilterClasses;

114
js/filter-common.js Normal file
View File

@@ -0,0 +1,114 @@
"use strict";
class FilterCommon {
static getDamageVulnerableFilter () {
return this._getDamageResistVulnImmuneFilter({
header: "Vulnerabilities",
headerShort: "Vuln.",
});
}
static getDamageResistFilter () {
return this._getDamageResistVulnImmuneFilter({
header: "Resistance",
headerShort: "Res.",
});
}
static getDamageImmuneFilter () {
return this._getDamageResistVulnImmuneFilter({
header: "Immunity",
headerShort: "Imm.",
});
}
static _getDamageResistVulnImmuneFilter (
{
header,
headerShort,
},
) {
return new Filter({
header: header,
items: [...Parser.DMG_TYPES],
displayFnMini: str => `${headerShort} ${str.toTitleCase()}`,
displayFnTitle: str => `Damage ${header}: ${str.toTitleCase()}`,
displayFn: StrUtil.uppercaseFirst,
});
}
static _CONDS = [
"blinded",
"charmed",
"deafened",
"exhaustion",
"frightened",
"grappled",
"incapacitated",
"invisible",
"paralyzed",
"petrified",
"poisoned",
"prone",
"restrained",
"stunned",
"unconscious",
// not really a condition, but whatever
"disease",
];
static getConditionImmuneFilter () {
return new Filter({
header: "Condition Immunity",
items: this._CONDS,
displayFnMini: str => `Imm. ${str.toTitleCase()}`,
displayFnTitle: str => `Condition Immunity: ${str.toTitleCase()}`,
displayFn: StrUtil.uppercaseFirst,
});
}
static mutateForFilters_damageVulnResImmune_player (ent) {
this.mutateForFilters_damageVuln_player(ent);
this.mutateForFilters_damageRes_player(ent);
this.mutateForFilters_damageImm_player(ent);
}
static mutateForFilters_damageVuln_player (ent) {
if (!ent.vulnerable) return;
const out = new Set();
ent.vulnerable.forEach(it => this._recurseResVulnImm(out, it));
ent._fVuln = [...out];
}
static mutateForFilters_damageRes_player (ent) {
if (!ent.resist) return;
const out = new Set();
ent.resist.forEach(it => this._recurseResVulnImm(out, it));
ent._fRes = [...out];
}
static mutateForFilters_damageImm_player (ent) {
if (!ent.immune) return;
const out = new Set();
ent.immune.forEach(iti => this._recurseResVulnImm(out, iti));
ent._fImm = [...out];
}
static mutateForFilters_conditionImmune_player (ent) {
if (!ent.conditionImmune) return;
const out = new Set();
ent.conditionImmune.forEach(it => this._recurseResVulnImm(out, it));
ent._fCondImm = [...out];
}
static _recurseResVulnImm (allSet, it) {
if (typeof it === "string") return allSet.add(it);
if (it.choose?.from) it.choose?.from.forEach(itSub => this._recurseResVulnImm(allSet, itSub));
}
}
globalThis.FilterCommon = FilterCommon;

View File

@@ -0,0 +1,54 @@
"use strict";
class PageFilterConditionsDiseases extends PageFilter {
// region static
static getDisplayProp (prop) {
return prop === "status" ? "Other" : Parser.getPropDisplayName(prop);
}
// endregion
constructor () {
super();
this._typeFilter = new Filter({
header: "Type",
items: ["condition", "disease", "status"],
displayFn: PageFilterConditionsDiseases.getDisplayProp,
deselFn: (it) => it === "disease" || it === "status",
});
this._miscFilter = new Filter({header: "Miscellaneous", items: ["SRD", "Basic Rules", "Has Images", "Has Info"], isMiscFilter: true});
}
static mutateForFilters (it) {
it._fMisc = [];
if (it.srd) it._fMisc.push("SRD");
if (it.basicRules) it._fMisc.push("Basic Rules");
if (it.hasFluff || it.fluff?.entries) it._fMisc.push("Has Info");
if (it.hasFluffImages || it.fluff?.images) it._fMisc.push("Has Images");
}
addToFilters (it, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(it.source);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._typeFilter,
this._miscFilter,
];
}
toDisplay (values, it) {
return this._filterBox.toDisplay(
values,
it.source,
it.__prop,
it._fMisc,
);
}
}
globalThis.PageFilterConditionsDiseases = PageFilterConditionsDiseases;

58
js/filter-cultsboons.js Normal file
View File

@@ -0,0 +1,58 @@
"use strict";
class PageFilterCultsBoons extends PageFilter {
constructor () {
super();
this._typeFilter = new Filter({
header: "Type",
items: ["Boon, Demonic", "Cult"],
});
this._subtypeFilter = new Filter({
header: "Subtype",
items: [],
});
this._miscFilter = new Filter({
header: "Miscellaneous",
items: ["Reprinted"],
deselFn: (it) => it === "Reprinted",
isMiscFilter: true,
});
}
static mutateForFilters (it) {
it._fType = it.__prop === "cult" ? "Cult" : it.type ? `Boon, ${it.type}` : "Boon";
it._fMisc = [];
if (this._isReprinted({reprintedAs: it.reprintedAs, tag: it.__prop, prop: it.__prop, page: UrlUtil.PG_CULTS_BOONS})) it._fMisc.push("Reprinted");
}
addToFilters (it, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(it.source);
this._typeFilter.addItem(it._fType);
this._subtypeFilter.addItem(it.type);
this._miscFilter.addItem(it._fMisc);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._typeFilter,
this._subtypeFilter,
this._miscFilter,
];
}
toDisplay (values, cb) {
return this._filterBox.toDisplay(
values,
cb.source,
cb._fType,
cb.type,
cb._fMisc,
);
}
}
globalThis.PageFilterCultsBoons = PageFilterCultsBoons;

52
js/filter-decks.js Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
class PageFilterDecks extends PageFilter {
constructor () {
super();
this._miscFilter = new Filter({
header: "Miscellaneous",
items: ["Has Card Art", "SRD"],
isMiscFilter: true,
selFn: it => it === "Has Card Art",
});
}
static mutateForFilters (ent) {
ent._fMisc = [];
if (ent.srd) ent._fMisc.push("SRD");
if (ent.hasCardArt) ent._fMisc.push("Has Card Art");
}
addToFilters (ent, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(ent.source);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._miscFilter,
];
}
toDisplay (values, ent) {
return this._filterBox.toDisplay(
values,
ent.source,
ent._fMisc,
);
}
}
globalThis.PageFilterDecks = PageFilterDecks;
class ListSyntaxDecks extends ListUiUtil.ListSyntax {
static _INDEXABLE_PROPS_ENTRIES = [
"entries",
"cards",
];
}
globalThis.ListSyntaxDecks = ListSyntaxDecks;

86
js/filter-deities.js Normal file
View File

@@ -0,0 +1,86 @@
"use strict";
class PageFilterDeities extends PageFilter {
static unpackAlignment (g) {
g.alignment.sort(SortUtil.alignmentSort);
if (g.alignment.length === 2 && g.alignment.includes("N")) {
const out = [...g.alignment];
if (out[0] === "N") out[0] = "NX";
else out[1] = "NY";
return out;
}
return MiscUtil.copy(g.alignment);
}
constructor () {
super();
this._pantheonFilter = new Filter({header: "Pantheon", items: []});
this._categoryFilter = new Filter({header: "Category", items: [VeCt.STR_NONE]});
this._alignmentFilter = new Filter({
header: "Alignment",
items: ["L", "NX", "C", "G", "NY", "E", "N"],
displayFn: it => Parser.alignmentAbvToFull(it).toTitleCase(),
itemSortFn: null,
});
this._domainFilter = new Filter({
header: "Domain",
items: ["Death", "Knowledge", "Life", "Light", "Nature", VeCt.STR_NONE, "Tempest", "Trickery", "War"],
});
this._miscFilter = new Filter({
header: "Miscellaneous",
items: ["Grants Piety Features", "Has Info", "Has Images", "Reprinted", "SRD", "Basic Rules"],
displayFn: StrUtil.uppercaseFirst,
deselFn: (it) => it === "Reprinted",
isMiscFilter: true,
});
}
static mutateForFilters (g) {
g._fAlign = g.alignment ? PageFilterDeities.unpackAlignment(g) : [];
if (!g.category) g.category = VeCt.STR_NONE;
if (!g.domains) g.domains = [VeCt.STR_NONE];
g.domains.sort(SortUtil.ascSort);
g._fMisc = [];
if (g.reprinted) g._fMisc.push("Reprinted");
if (g.srd) g._fMisc.push("SRD");
if (g.basicRules) g._fMisc.push("Basic Rules");
if (g.entries) g._fMisc.push("Has Info");
if (g.symbolImg) g._fMisc.push("Has Images");
if (g.piety) g._fMisc.push("Grants Piety Features");
}
addToFilters (g, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(g.source);
this._domainFilter.addItem(g.domains);
this._pantheonFilter.addItem(g.pantheon);
this._categoryFilter.addItem(g.category);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._alignmentFilter,
this._pantheonFilter,
this._categoryFilter,
this._domainFilter,
this._miscFilter,
];
}
toDisplay (values, g) {
return this._filterBox.toDisplay(
values,
g.source,
g._fAlign,
g.pantheon,
g.category,
g.domains,
g._fMisc,
);
}
}
globalThis.PageFilterDeities = PageFilterDeities;

229
js/filter-feats.js Normal file
View File

@@ -0,0 +1,229 @@
"use strict";
class PageFilterFeats extends PageFilter {
// region static
static _PREREQ_KEY_TO_FULL = {
"other": "Special",
"spellcasting2020": "Spellcasting",
"spellcastingFeature": "Spellcasting",
"spellcastingPrepared": "Spellcasting",
};
// endregion
constructor () {
super();
this._asiFilter = new Filter({
header: "Ability Bonus",
items: [
"str",
"dex",
"con",
"int",
"wis",
"cha",
],
displayFn: Parser.attAbvToFull,
itemSortFn: null,
});
this._categoryFilter = new Filter({
header: "Category",
displayFn: StrUtil.toTitleCase,
});
this._otherPrereqFilter = new Filter({
header: "Other",
items: ["Ability", "Race", "Psionics", "Proficiency", "Special", "Spellcasting"],
});
this._levelFilter = new Filter({
header: "Level",
itemSortFn: SortUtil.ascSortNumericalSuffix,
});
this._prerequisiteFilter = new MultiFilter({header: "Prerequisite", filters: [this._otherPrereqFilter, this._levelFilter]});
this._benefitsFilter = new Filter({
header: "Benefits",
items: [
"Armor Proficiency",
"Language Proficiency",
"Skill Proficiency",
"Spellcasting",
"Tool Proficiency",
"Weapon Proficiency",
],
});
this._vulnerableFilter = FilterCommon.getDamageVulnerableFilter();
this._resistFilter = FilterCommon.getDamageResistFilter();
this._immuneFilter = FilterCommon.getDamageImmuneFilter();
this._defenceFilter = new MultiFilter({header: "Damage", filters: [this._vulnerableFilter, this._resistFilter, this._immuneFilter]});
this._conditionImmuneFilter = FilterCommon.getConditionImmuneFilter();
this._miscFilter = new Filter({header: "Miscellaneous", items: ["Has Info", "Has Images", "SRD", "Basic Rules"], isMiscFilter: true});
}
static mutateForFilters (feat) {
const ability = Renderer.getAbilityData(feat.ability);
feat._fAbility = ability.asCollection.filter(a => !ability.areNegative.includes(a)); // used for filtering
const prereqText = Renderer.utils.prerequisite.getHtml(feat.prerequisite, {isListMode: true}) || VeCt.STR_NONE;
feat._fPrereqOther = [...new Set((feat.prerequisite || []).flatMap(it => Object.keys(it)))]
.map(it => (this._PREREQ_KEY_TO_FULL[it] || it).uppercaseFirst());
if (feat.prerequisite) feat._fPrereqLevel = feat.prerequisite.filter(it => it.level != null).map(it => `Level ${it.level.level ?? it.level}`);
feat._fBenifits = [
feat.resist ? "Damage Resistance" : null,
feat.immune ? "Damage Immunity" : null,
feat.conditionImmune ? "Condition Immunity" : null,
feat.skillProficiencies ? "Skill Proficiency" : null,
feat.additionalSpells ? "Spellcasting" : null,
feat.armorProficiencies ? "Armor Proficiency" : null,
feat.weaponProficiencies ? "Weapon Proficiency" : null,
feat.toolProficiencies ? "Tool Proficiency" : null,
feat.languageProficiencies ? "Language Proficiency" : null,
].filter(it => it);
if (feat.skillToolLanguageProficiencies?.length) {
if (feat.skillToolLanguageProficiencies.some(it => (it.choose || []).some(x => x.from || [].includes("anySkill")))) feat._fBenifits.push("Skill Proficiency");
if (feat.skillToolLanguageProficiencies.some(it => (it.choose || []).some(x => x.from || [].includes("anyTool")))) feat._fBenifits.push("Tool Proficiency");
if (feat.skillToolLanguageProficiencies.some(it => (it.choose || []).some(x => x.from || [].includes("anyLanguage")))) feat._fBenifits.push("Language Proficiency");
}
feat._fMisc = feat.srd ? ["SRD"] : [];
if (feat.basicRules) feat._fMisc.push("Basic Rules");
if (feat.hasFluff || feat.fluff?.entries) feat._fMisc.push("Has Info");
if (feat.hasFluffImages || feat.fluff?.images) feat._fMisc.push("Has Images");
if (feat.repeatable != null) feat._fMisc.push(feat.repeatable ? "Repeatable" : "Not Repeatable");
feat._slAbility = ability.asText || VeCt.STR_NONE;
feat._slPrereq = prereqText;
FilterCommon.mutateForFilters_damageVulnResImmune_player(feat);
FilterCommon.mutateForFilters_conditionImmune_player(feat);
}
addToFilters (feat, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(feat.source);
this._categoryFilter.addItem(feat.category);
if (feat.prerequisite) this._levelFilter.addItem(feat._fPrereqLevel);
this._vulnerableFilter.addItem(feat._fVuln);
this._resistFilter.addItem(feat._fRes);
this._immuneFilter.addItem(feat._fImm);
this._conditionImmuneFilter.addItem(feat._fCondImm);
this._benefitsFilter.addItem(feat._fBenifits);
this._miscFilter.addItem(feat._fMisc);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._asiFilter,
this._categoryFilter,
this._prerequisiteFilter,
this._benefitsFilter,
this._defenceFilter,
this._conditionImmuneFilter,
this._miscFilter,
];
}
toDisplay (values, ft) {
return this._filterBox.toDisplay(
values,
ft.source,
ft._fAbility,
ft.category,
[
ft._fPrereqOther,
ft._fPrereqLevel,
],
ft._fBenifits,
[
ft._fVuln,
ft._fRes,
ft._fImm,
],
ft._fCondImm,
ft._fMisc,
);
}
}
globalThis.PageFilterFeats = PageFilterFeats;
class ModalFilterFeats extends ModalFilter {
/**
* @param opts
* @param opts.namespace
* @param [opts.isRadio]
* @param [opts.allData]
*/
constructor (opts) {
opts = opts || {};
super({
...opts,
modalTitle: `Feat${opts.isRadio ? "" : "s"}`,
pageFilter: new PageFilterFeats(),
});
}
_$getColumnHeaders () {
const btnMeta = [
{sort: "name", text: "Name", width: "4"},
{sort: "ability", text: "Ability", width: "3"},
{sort: "prerequisite", text: "Prerequisite", width: "3"},
{sort: "source", text: "Source", width: "1"},
];
return ModalFilter._$getFilterColumnHeaders(btnMeta);
}
async _pLoadAllData () {
return [
...(await DataUtil.loadJSON(`${Renderer.get().baseUrl}data/feats.json`)).feat,
...((await PrereleaseUtil.pGetBrewProcessed()).feat || []),
...((await BrewUtil2.pGetBrewProcessed()).feat || []),
];
}
_getListItem (pageFilter, feat, ftI) {
const eleRow = document.createElement("div");
eleRow.className = "px-0 w-100 ve-flex-col no-shrink";
const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_FEATS](feat);
const source = Parser.sourceJsonToAbv(feat.source);
eleRow.innerHTML = `<div class="w-100 ve-flex-vh-center lst--border veapp__list-row no-select lst__wrp-cells">
<div class="col-0-5 pl-0 ve-flex-vh-center">${this._isRadio ? `<input type="radio" name="radio" class="no-events">` : `<input type="checkbox" class="no-events">`}</div>
<div class="col-0-5 px-1 ve-flex-vh-center">
<div class="ui-list__btn-inline px-2" title="Toggle Preview (SHIFT to Toggle Info Preview)">[+]</div>
</div>
<div class="col-4 ${feat._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${feat._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${feat.name}</div>
<span class="col-3 ${feat._slAbility === VeCt.STR_NONE ? "italic" : ""}">${feat._slAbility}</span>
<span class="col-3 ${feat._slPrereq === VeCt.STR_NONE ? "italic" : ""}">${feat._slPrereq}</span>
<div class="col-1 pr-0 ve-text-center ${Parser.sourceJsonToColor(feat.source)}" title="${Parser.sourceJsonToFull(feat.source)}" ${Parser.sourceJsonToStyle(feat.source)}>${source}</div>
</div>`;
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
const listItem = new ListItem(
ftI,
eleRow,
feat.name,
{
hash,
source,
sourceJson: feat.source,
ability: feat._slAbility,
prerequisite: feat._slPrereq,
},
{
cbSel: eleRow.firstElementChild.firstElementChild.firstElementChild,
btnShowHidePreview,
},
);
ListUiUtil.bindPreviewButton(UrlUtil.PG_FEATS, this._allData, listItem, btnShowHidePreview);
return listItem;
}
}
globalThis.ModalFilterFeats = ModalFilterFeats;

521
js/filter-items.js Normal file
View File

@@ -0,0 +1,521 @@
"use strict";
class PageFilterEquipment extends PageFilter {
static _MISC_FILTER_ITEMS = [
"Item Group", "Bundle", "SRD", "Basic Rules", "Has Images", "Has Info", "Reprinted",
];
static _RE_FOUNDRY_ATTR = /(?:[-+*/]\s*)?@[a-z0-9.]+/gi;
static _RE_DAMAGE_DICE_JUNK = /[^-+*/0-9d]/gi;
static _RE_DAMAGE_DICE_D = /d/gi;
static _getSortableDamageTerm (t) {
try {
/* eslint-disable no-eval */
return eval(
`${t}`
.replace(this._RE_FOUNDRY_ATTR, "")
.replace(this._RE_DAMAGE_DICE_JUNK, "")
.replace(this._RE_DAMAGE_DICE_D, "*"),
);
/* eslint-enable no-eval */
} catch (ignored) {
return Number.MAX_SAFE_INTEGER;
}
}
static _sortDamageDice (a, b) {
return this._getSortableDamageTerm(a.item) - this._getSortableDamageTerm(b.item);
}
static _getMasteryDisplay (mastery) {
const {name, source} = DataUtil.proxy.unpackUid("itemMastery", mastery, "itemMastery");
if (SourceUtil.isSiteSource(source)) return name.toTitleCase();
return `${name.toTitleCase()} (${Parser.sourceJsonToAbv(source)})`;
}
constructor ({filterOpts = null} = {}) {
super();
this._typeFilter = new Filter({
header: "Type",
deselFn: (it) => PageFilterItems._DEFAULT_HIDDEN_TYPES.has(it),
displayFn: StrUtil.toTitleCase,
});
this._propertyFilter = new Filter({header: "Property", displayFn: StrUtil.toTitleCase});
this._categoryFilter = new Filter({
header: "Category",
items: ["Basic", "Generic Variant", "Specific Variant", "Other"],
deselFn: (it) => it === "Specific Variant",
itemSortFn: null,
...(filterOpts?.["Category"] || {}),
});
this._costFilter = new RangeFilter({
header: "Cost",
isLabelled: true,
isAllowGreater: true,
labelSortFn: null,
labels: [
0,
...[...new Array(9)].map((_, i) => i + 1),
...[...new Array(9)].map((_, i) => 10 * (i + 1)),
...[...new Array(100)].map((_, i) => 100 * (i + 1)),
],
labelDisplayFn: it => !it ? "None" : Parser.getDisplayCurrency(CurrencyUtil.doSimplifyCoins({cp: it})),
});
this._weightFilter = new RangeFilter({header: "Weight", min: 0, max: 100, isAllowGreater: true, suffix: " lb."});
this._focusFilter = new Filter({header: "Spellcasting Focus", items: [...Parser.ITEM_SPELLCASTING_FOCUS_CLASSES]});
this._damageTypeFilter = new Filter({header: "Weapon Damage Type", displayFn: it => Parser.dmgTypeToFull(it).uppercaseFirst(), itemSortFn: (a, b) => SortUtil.ascSortLower(Parser.dmgTypeToFull(a), Parser.dmgTypeToFull(b))});
this._damageDiceFilter = new Filter({header: "Weapon Damage Dice", items: ["1", "1d4", "1d6", "1d8", "1d10", "1d12", "2d6"], itemSortFn: (a, b) => PageFilterEquipment._sortDamageDice(a, b)});
this._miscFilter = new Filter({
header: "Miscellaneous",
items: [...PageFilterEquipment._MISC_FILTER_ITEMS, ...Object.values(Parser.ITEM_MISC_TAG_TO_FULL)],
isMiscFilter: true,
});
this._poisonTypeFilter = new Filter({header: "Poison Type", items: ["ingested", "injury", "inhaled", "contact"], displayFn: StrUtil.toTitleCase});
this._masteryFilter = new Filter({header: "Mastery", displayFn: this.constructor._getMasteryDisplay.bind(this)});
}
static mutateForFilters (item) {
item._fSources = SourceFilter.getCompleteFilterSources(item);
item._fProperties = item.property ? item.property.map(p => Renderer.item.getProperty(p).name).filter(n => n) : [];
item._fMisc = [];
if (item._isItemGroup) item._fMisc.push("Item Group");
if (item.packContents) item._fMisc.push("Bundle");
if (item.srd) item._fMisc.push("SRD");
if (item.basicRules) item._fMisc.push("Basic Rules");
if (item.hasFluff || item.fluff?.entries) item._fMisc.push("Has Info");
if (item.hasFluffImages || item.fluff?.images) item._fMisc.push("Has Images");
if (item.miscTags) item._fMisc.push(...item.miscTags.map(Parser.itemMiscTagToFull));
if (this._isReprinted({reprintedAs: item.reprintedAs, tag: "item", prop: "item", page: UrlUtil.PG_ITEMS})) item._fMisc.push("Reprinted");
if (item.focus || item.name === "Thieves' Tools" || item.type === "INS" || item.type === "SCF" || item.type === "AT") {
item._fFocus = item.focus ? item.focus === true ? [...Parser.ITEM_SPELLCASTING_FOCUS_CLASSES] : [...item.focus] : [];
if ((item.name === "Thieves' Tools" || item.type === "AT") && !item._fFocus.includes("Artificer")) item._fFocus.push("Artificer");
if (item.type === "INS" && !item._fFocus.includes("Bard")) item._fFocus.push("Bard");
if (item.type === "SCF") {
switch (item.scfType) {
case "arcane": {
if (!item._fFocus.includes("Sorcerer")) item._fFocus.push("Sorcerer");
if (!item._fFocus.includes("Warlock")) item._fFocus.push("Warlock");
if (!item._fFocus.includes("Wizard")) item._fFocus.push("Wizard");
break;
}
case "druid": {
if (!item._fFocus.includes("Druid")) item._fFocus.push("Druid");
break;
}
case "holy":
if (!item._fFocus.includes("Cleric")) item._fFocus.push("Cleric");
if (!item._fFocus.includes("Paladin")) item._fFocus.push("Paladin");
break;
}
}
}
item._fValue = Math.round(item.value || 0);
item._fDamageDice = [];
if (item.dmg1) item._fDamageDice.push(item.dmg1);
if (item.dmg2) item._fDamageDice.push(item.dmg2);
item._fMastery = item.mastery
? item.mastery.map(it => {
const {name, source} = DataUtil.proxy.unpackUid("itemMastery", it, "itemMastery", {isLower: true});
return [name, source].join("|");
})
: null;
}
addToFilters (item, isExcluded) {
if (isExcluded) return;
this._sourceFilter.addItem(item._fSources);
this._typeFilter.addItem(item._typeListText);
this._propertyFilter.addItem(item._fProperties);
this._damageTypeFilter.addItem(item.dmgType);
this._damageDiceFilter.addItem(item._fDamageDice);
this._poisonTypeFilter.addItem(item.poisonTypes);
this._miscFilter.addItem(item._fMisc);
this._masteryFilter.addItem(item._fMastery);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._typeFilter,
this._propertyFilter,
this._categoryFilter,
this._costFilter,
this._weightFilter,
this._focusFilter,
this._damageTypeFilter,
this._damageDiceFilter,
this._miscFilter,
this._poisonTypeFilter,
this._masteryFilter,
];
}
toDisplay (values, it) {
return this._filterBox.toDisplay(
values,
it._fSources,
it._typeListText,
it._fProperties,
it._category,
it._fValue,
it.weight,
it._fFocus,
it.dmgType,
it._fDamageDice,
it._fMisc,
it.poisonTypes,
it._fMastery,
);
}
}
globalThis.PageFilterEquipment = PageFilterEquipment;
class PageFilterItems extends PageFilterEquipment {
static _DEFAULT_HIDDEN_TYPES = new Set(["treasure", "futuristic", "modern", "renaissance"]);
static _FILTER_BASE_ITEMS_ATTUNEMENT = ["Requires Attunement", "Requires Attunement By...", "Attunement Optional", VeCt.STR_NO_ATTUNEMENT];
// region static
static sortItems (a, b, o) {
if (o.sortBy === "name") return SortUtil.compareListNames(a, b);
else if (o.sortBy === "type") return SortUtil.ascSortLower(a.values.type, b.values.type) || SortUtil.compareListNames(a, b);
else if (o.sortBy === "source") return SortUtil.ascSortLower(a.values.source, b.values.source) || SortUtil.compareListNames(a, b);
else if (o.sortBy === "rarity") return SortUtil.ascSortItemRarity(a.values.rarity, b.values.rarity) || SortUtil.compareListNames(a, b);
else if (o.sortBy === "attunement") return SortUtil.ascSort(a.values.attunement, b.values.attunement) || SortUtil.compareListNames(a, b);
else if (o.sortBy === "count") return SortUtil.ascSort(a.data.count, b.data.count) || SortUtil.compareListNames(a, b);
else if (o.sortBy === "weight") return SortUtil.ascSort(a.values.weight, b.values.weight) || SortUtil.compareListNames(a, b);
else if (o.sortBy === "cost") return SortUtil.ascSort(a.values.cost, b.values.cost) || SortUtil.compareListNames(a, b);
else return 0;
}
static _getBaseItemDisplay (baseItem) {
if (!baseItem) return null;
let [name, source] = baseItem.split("__");
name = name.toTitleCase();
source = source || Parser.SRC_DMG;
if (source.toLowerCase() === Parser.SRC_PHB.toLowerCase()) return name;
return `${name} (${Parser.sourceJsonToAbv(source)})`;
}
static _sortAttunementFilter (a, b) {
const ixA = PageFilterItems._FILTER_BASE_ITEMS_ATTUNEMENT.indexOf(a.item);
const ixB = PageFilterItems._FILTER_BASE_ITEMS_ATTUNEMENT.indexOf(b.item);
if (~ixA && ~ixB) return ixA - ixB;
if (~ixA) return -1;
if (~ixB) return 1;
return SortUtil.ascSortLower(a, b);
}
static _getAttunementFilterItems (item) {
const out = item._attunementCategory ? [item._attunementCategory] : [];
if (!item.reqAttuneTags && !item.reqAttuneAltTags) return out;
[...item.reqAttuneTags || [], ...item.reqAttuneAltTags || []].forEach(tagSet => {
Object.entries(tagSet)
.forEach(([prop, val]) => {
switch (prop) {
case "background": out.push(`Background: ${val.split("|")[0].toTitleCase()}`); break;
case "languageProficiency": out.push(`Language Proficiency: ${val.toTitleCase()}`); break;
case "skillProficiency": out.push(`Skill Proficiency: ${val.toTitleCase()}`); break;
case "race": out.push(`Race: ${val.split("|")[0].toTitleCase()}`); break;
case "creatureType": out.push(`Creature Type: ${val.toTitleCase()}`); break;
case "size": out.push(`Size: ${Parser.sizeAbvToFull(val)}`.toTitleCase()); break;
case "class": out.push(`Class: ${val.split("|")[0].toTitleCase()}`); break;
case "alignment": out.push(`Alignment: ${Parser.alignmentListToFull(val).toTitleCase()}`); break;
case "str":
case "dex":
case "con":
case "int":
case "wis":
case "cha": out.push(`${Parser.attAbvToFull(prop)}: ${val} or Higher`); break;
case "spellcasting": out.push("Spellcaster"); break;
case "psionics": out.push("Psionics"); break;
}
});
});
return out;
}
// endregion
constructor (opts) {
super(opts);
this._tierFilter = new Filter({header: "Tier", items: ["none", "minor", "major"], itemSortFn: null, displayFn: StrUtil.toTitleCase});
this._attachedSpellsFilter = new SearchableFilter({header: "Attached Spells", displayFn: (it) => it.split("|")[0].toTitleCase(), itemSortFn: SortUtil.ascSortLower});
this._lootTableFilter = new Filter({
header: "Found On",
items: ["Magic Item Table A", "Magic Item Table B", "Magic Item Table C", "Magic Item Table D", "Magic Item Table E", "Magic Item Table F", "Magic Item Table G", "Magic Item Table H", "Magic Item Table I"],
displayFn: it => {
const [name, sourceJson] = it.split("|");
return `${name}${sourceJson ? ` (${Parser.sourceJsonToAbv(sourceJson)})` : ""}`;
},
});
this._rarityFilter = new Filter({
header: "Rarity",
items: [...Parser.ITEM_RARITIES],
itemSortFn: null,
displayFn: StrUtil.toTitleCase,
});
this._attunementFilter = new Filter({header: "Attunement", items: [...PageFilterItems._FILTER_BASE_ITEMS_ATTUNEMENT], itemSortFn: PageFilterItems._sortAttunementFilter});
this._bonusFilter = new Filter({
header: "Bonus",
items: [
"Armor Class", "Proficiency Bonus", "Spell Attacks", "Spell Save DC", "Saving Throws",
...([...new Array(4)]).map((_, i) => `Weapon Attack and Damage Rolls${i ? ` (+${i})` : ""}`),
...([...new Array(4)]).map((_, i) => `Weapon Attack Rolls${i ? ` (+${i})` : ""}`),
...([...new Array(4)]).map((_, i) => `Weapon Damage Rolls${i ? ` (+${i})` : ""}`),
],
itemSortFn: null,
});
this._rechargeTypeFilter = new Filter({header: "Recharge Type", displayFn: Parser.itemRechargeToFull});
this._miscFilter = new Filter({header: "Miscellaneous", items: ["Ability Score Adjustment", "Charges", "Cursed", "Grants Language", "Grants Proficiency", "Magic", "Mundane", "Sentient", "Speed Adjustment", ...PageFilterEquipment._MISC_FILTER_ITEMS], isMiscFilter: true});
this._baseSourceFilter = new SourceFilter({header: "Base Source", selFn: null});
this._baseItemFilter = new Filter({header: "Base Item", displayFn: this.constructor._getBaseItemDisplay.bind(this.constructor)});
this._optionalfeaturesFilter = new Filter({
header: "Feature",
displayFn: (it) => {
const [name, source] = it.split("|");
if (!source) return name.toTitleCase();
const sourceJson = Parser.sourceJsonToJson(source);
if (!SourceUtil.isNonstandardSourceWotc(sourceJson)) return name.toTitleCase();
return `${name.toTitleCase()} (${Parser.sourceJsonToAbv(sourceJson)})`;
},
itemSortFn: SortUtil.ascSortLower,
});
}
static mutateForFilters (item) {
super.mutateForFilters(item);
item._fTier = [item.tier ? item.tier : "none"];
if (item.curse) item._fMisc.push("Cursed");
const isMundane = Renderer.item.isMundane(item);
item._fMisc.push(isMundane ? "Mundane" : "Magic");
item._fIsMundane = isMundane;
if (item.ability) item._fMisc.push("Ability Score Adjustment");
if (item.modifySpeed) item._fMisc.push("Speed Adjustment");
if (item.charges) item._fMisc.push("Charges");
if (item.sentient) item._fMisc.push("Sentient");
if (item.grantsProficiency) item._fMisc.push("Grants Proficiency");
if (item.grantsLanguage) item._fMisc.push("Grants Language");
if (item.critThreshold) item._fMisc.push("Expanded Critical Range");
const fBaseItemSelf = item._isBaseItem ? `${item.name}__${item.source}`.toLowerCase() : null;
item._fBaseItem = [
item.baseItem ? (item.baseItem.includes("|") ? item.baseItem.replace("|", "__") : `${item.baseItem}__${Parser.SRC_DMG}`).toLowerCase() : null,
item._baseName ? `${item._baseName}__${item._baseSource || item.source}`.toLowerCase() : null,
].filter(Boolean);
item._fBaseItemAll = fBaseItemSelf ? [fBaseItemSelf, ...item._fBaseItem] : item._fBaseItem;
item._fBonus = [];
if (item.bonusAc) item._fBonus.push("Armor Class");
this._mutateForFilters_bonusWeapon({prop: "bonusWeapon", item, text: "Weapon Attack and Damage Rolls"});
this._mutateForFilters_bonusWeapon({prop: "bonusWeaponAttack", item, text: "Weapon Attack Rolls"});
this._mutateForFilters_bonusWeapon({prop: "bonusWeaponDamage", item, text: "Weapon Damage Rolls"});
if (item.bonusWeaponCritDamage) item._fBonus.push("Weapon Critical Damage");
if (item.bonusSpellAttack) item._fBonus.push("Spell Attacks");
if (item.bonusSpellSaveDc) item._fBonus.push("Spell Save DC");
if (item.bonusSavingThrow) item._fBonus.push("Saving Throws");
if (item.bonusProficiencyBonus) item._fBonus.push("Proficiency Bonus");
item._fAttunement = this._getAttunementFilterItems(item);
}
static _mutateForFilters_bonusWeapon ({prop, item, text}) {
if (!item[prop]) return;
item._fBonus.push(text);
switch (item[prop]) {
case "+1":
case "+2":
case "+3": item._fBonus.push(`${text} (${item[prop]})`); break;
}
}
addToFilters (item, isExcluded) {
if (isExcluded) return;
super.addToFilters(item, isExcluded);
this._sourceFilter.addItem(item.source);
this._tierFilter.addItem(item._fTier);
this._attachedSpellsFilter.addItem(item.attachedSpells);
this._lootTableFilter.addItem(item.lootTables);
this._baseItemFilter.addItem(item._fBaseItem);
this._baseSourceFilter.addItem(item._baseSource);
this._attunementFilter.addItem(item._fAttunement);
this._rechargeTypeFilter.addItem(item.recharge);
this._optionalfeaturesFilter.addItem(item.optionalfeatures);
}
async _pPopulateBoxOptions (opts) {
await super._pPopulateBoxOptions(opts);
opts.filters = [
this._sourceFilter,
this._typeFilter,
this._tierFilter,
this._rarityFilter,
this._propertyFilter,
this._attunementFilter,
this._categoryFilter,
this._costFilter,
this._weightFilter,
this._focusFilter,
this._damageTypeFilter,
this._damageDiceFilter,
this._bonusFilter,
this._miscFilter,
this._rechargeTypeFilter,
this._poisonTypeFilter,
this._masteryFilter,
this._lootTableFilter,
this._baseItemFilter,
this._baseSourceFilter,
this._optionalfeaturesFilter,
this._attachedSpellsFilter,
];
}
toDisplay (values, it) {
return this._filterBox.toDisplay(
values,
it._fSources,
it._typeListText,
it._fTier,
it.rarity,
it._fProperties,
it._fAttunement,
it._category,
it._fValue,
it.weight,
it._fFocus,
it.dmgType,
it._fDamageDice,
it._fBonus,
it._fMisc,
it.recharge,
it.poisonTypes,
it._fMastery,
it.lootTables,
it._fBaseItemAll,
it._baseSource,
it.optionalfeatures,
it.attachedSpells,
);
}
}
globalThis.PageFilterItems = PageFilterItems;
class ModalFilterItems extends ModalFilter {
/**
* @param opts
* @param opts.namespace
* @param [opts.isRadio]
* @param [opts.allData]
* @param [opts.pageFilterOpts] Options to be passed to the underlying items page filter.
*/
constructor (opts) {
opts = opts || {};
super({
...opts,
modalTitle: `Item${opts.isRadio ? "" : "s"}`,
pageFilter: new PageFilterItems(opts?.pageFilterOpts),
});
}
_$getColumnHeaders () {
const btnMeta = [
{sort: "name", text: "Name", width: "4"},
{sort: "type", text: "Type", width: "6"},
{sort: "source", text: "Source", width: "1"},
];
return ModalFilter._$getFilterColumnHeaders(btnMeta);
}
async _pInit () {
await Renderer.item.pPopulatePropertyAndTypeReference();
}
async _pLoadAllData () {
return [
...(await Renderer.item.pBuildList()),
...(await Renderer.item.pGetItemsFromPrerelease()),
...(await Renderer.item.pGetItemsFromBrew()),
];
}
_getListItem (pageFilter, item, itI) {
if (item.noDisplay) return null;
Renderer.item.enhanceItem(item);
pageFilter.mutateAndAddToFilters(item);
const eleRow = document.createElement("div");
eleRow.className = "px-0 w-100 ve-flex-col no-shrink";
const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_ITEMS](item);
const source = Parser.sourceJsonToAbv(item.source);
const type = item._typeListText.join(", ");
eleRow.innerHTML = `<div class="w-100 ve-flex-vh-center lst--border veapp__list-row no-select lst__wrp-cells">
<div class="col-0-5 pl-0 ve-flex-vh-center">${this._isRadio ? `<input type="radio" name="radio" class="no-events">` : `<input type="checkbox" class="no-events">`}</div>
<div class="col-0-5 px-1 ve-flex-vh-center">
<div class="ui-list__btn-inline px-2" title="Toggle Preview (SHIFT to Toggle Info Preview)">[+]</div>
</div>
<div class="col-5 ${item._versionBase_isVersion ? "italic" : ""} ${this._getNameStyle()}">${item._versionBase_isVersion ? `<span class="px-3"></span>` : ""}${item.name}</div>
<div class="col-5">${type.uppercaseFirst()}</div>
<div class="col-1 ve-text-center ${Parser.sourceJsonToColor(item.source)} pr-0" title="${Parser.sourceJsonToFull(item.source)}" ${Parser.sourceJsonToStyle(item.source)}>${source}</div>
</div>`;
const btnShowHidePreview = eleRow.firstElementChild.children[1].firstElementChild;
const listItem = new ListItem(
itI,
eleRow,
item.name,
{
hash,
source,
sourceJson: item.source,
type,
},
{
cbSel: eleRow.firstElementChild.firstElementChild.firstElementChild,
btnShowHidePreview,
},
);
ListUiUtil.bindPreviewButton(UrlUtil.PG_ITEMS, this._allData, listItem, btnShowHidePreview);
return listItem;
}
}
globalThis.ModalFilterItems = ModalFilterItems;
class ListSyntaxItems extends ListUiUtil.ListSyntax {
static _INDEXABLE_PROPS_ENTRIES = [
"_fullEntries",
"entries",
];
}
globalThis.ListSyntaxItems = ListSyntaxItems;

Some files were not shown because too many files have changed in this diff Show More