diff --git a/module/BladesActiveEffect.js b/module/BladesActiveEffect.js new file mode 100644 index 00000000..ee2e26f8 --- /dev/null +++ b/module/BladesActiveEffect.js @@ -0,0 +1,347 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesActor from "./BladesActor.js"; +import U from "./core/utilities.js"; +import { Tag, BladesPhase, BladesActorType } from "./core/constants.js"; +const FUNCQUEUE = {}; +const CUSTOMFUNCS = { + addItem: async (actor, funcData, _, isReversing = false) => { + eLog.checkLog("activeEffects", "addItem", { actor, funcData, isReversing }); + if (actor.hasActiveSubItemOf(funcData)) { + if (isReversing) { + return actor.remSubItem(funcData); + } + } + else if (!isReversing) { + return actor.addSubItem(funcData); + } + return undefined; + }, + addIfChargen: async (actor, funcData, _, isReversing = false) => { + eLog.checkLog("activeEffects", "addIfChargen", { actor, funcData, isReversing }); + if (!isReversing && game.eunoblades.Tracker?.system.phase !== BladesPhase.CharGen) { + return; + } + const [target, qty] = funcData.split(/:/); + if (isReversing) { + actor.update({ [target]: U.pInt(getProperty(actor, target)) - U.pInt(qty) }); + return; + } + actor.update({ [target]: U.pInt(getProperty(actor, target)) + U.pInt(qty) }); + }, + upgradeIfChargen: async (actor, funcData, _, isReversing = false) => { + eLog.checkLog("activeEffects", "upgradeIfChargen", { actor, funcData, isReversing }); + if (!isReversing && game.eunoblades.Tracker?.system.phase !== BladesPhase.CharGen) { + return; + } + const [target, qty] = funcData.split(/:/); + if (getProperty(actor, target) < U.pInt(qty)) { + actor.update({ [target]: U.pInt(qty) }); + } + }, + APPLYTOMEMBERS: async () => undefined, + APPLYTOCOHORTS: async () => undefined, + remItem: async (actor, funcData, _, isReversing = false) => { + function testString(targetString, testDef) { + if (testDef.startsWith("rX")) { + const pat = new RegExp(testDef.replace(/^rX:\/(.*?)\//, "$1")); + return pat.test(targetString); + } + return targetString === testDef; + } + if (funcData.startsWith("{")) { + if (isReversing) { + console.error("Cannot reverse a 'remItem' custom effect that was defined with a JSON object."); + return undefined; + } + const { type, tags, name } = JSON.parse(funcData); + let activeSubItems = actor.activeSubItems; + if (activeSubItems.length === 0) { + return undefined; + } + if (name) { + activeSubItems = activeSubItems.filter((item) => testString(item.name, name)); + } + if (activeSubItems.length === 0) { + return undefined; + } + if (type) { + activeSubItems = activeSubItems.filter((item) => testString(item.type, type)); + } + if (activeSubItems.length === 0) { + return undefined; + } + if (tags) { + activeSubItems = activeSubItems.filter((item) => item.hasTag(...tags)); + } + if (activeSubItems.length === 0) { + return undefined; + } + eLog.checkLog("activeEffects", "remItem - JSON OBJECT", { actor, funcData: JSON.parse(funcData), isReversing, activeSubItems }); + activeSubItems.forEach((item) => actor.remSubItem(item)); + } + eLog.checkLog("activeEffects", "remItem", { actor, funcData, isReversing }); + if (actor.hasActiveSubItemOf(funcData)) { + return actor.remSubItem(funcData); + } + if (isReversing) { + return actor.addSubItem(funcData); + } + return undefined; + } +}; +var EffectMode; +(function (EffectMode) { + EffectMode[EffectMode["Custom"] = 0] = "Custom"; + EffectMode[EffectMode["Multiply"] = 1] = "Multiply"; + EffectMode[EffectMode["Add"] = 2] = "Add"; + EffectMode[EffectMode["Downgrade"] = 3] = "Downgrade"; + EffectMode[EffectMode["Upgrade"] = 4] = "Upgrade"; + EffectMode[EffectMode["Override"] = 5] = "Override"; +})(EffectMode || (EffectMode = {})); +class BladesActiveEffect extends ActiveEffect { + static Initialize() { + CONFIG.ActiveEffect.documentClass = BladesActiveEffect; + Hooks.on("preCreateActiveEffect", async (effect) => { + eLog.checkLog3("effect", "PRECREATE ActiveEffect", { effect, parent: effect.parent?.name }); + if (!(effect.parent instanceof BladesActor)) { + return; + } + if (effect.changes.some((change) => change.key === "APPLYTOMEMBERS")) { + if (BladesActor.IsType(effect.parent, BladesActorType.pc) && BladesActor.IsType(effect.parent.crew, BladesActorType.crew)) { + const otherMembers = effect.parent.crew.members.filter((member) => member.id !== effect.parent?.id); + if (otherMembers.length > 0) { + effect.changes = effect.changes.filter((change) => change.key !== "APPLYTOMEMBERS"); + await Promise.all(otherMembers.map(async (member) => member.createEmbeddedDocuments("ActiveEffect", [effect.toJSON()]))); + await effect.parent.setFlag("eunos-blades", `memberEffects.${effect.id}`, { + appliedTo: otherMembers.map((member) => member.id), + effect: effect.toJSON() + }); + } + } + else if (BladesActor.IsType(effect.parent, BladesActorType.crew)) { + const changeKey = U.pullElement(effect.changes, (change) => change.key === "APPLYTOMEMBERS"); + if (!changeKey) { + return; + } + if (effect.parent.members.length > 0) { + await Promise.all(effect.parent.members.map(async (member) => member.createEmbeddedDocuments("ActiveEffect", [effect.toJSON()]))); + } + await effect.parent.setFlag("eunos-blades", `memberEffects.${effect.id}`, { + appliedTo: effect.parent.members.map((member) => member.id), + effect + }); + await effect.updateSource({ changes: [changeKey] }); + } + } + else if (effect.changes.some((change) => change.key === "APPLYTOCOHORTS") + && (BladesActor.IsType(effect.parent, BladesActorType.pc) || BladesActor.IsType(effect.parent, BladesActorType.crew))) { + if (effect.parent.cohorts.length > 0) { + await Promise.all(effect.parent.cohorts.map(async (cohort) => cohort.createEmbeddedDocuments("ActiveEffect", [effect.toJSON()]))); + } + await effect.parent.setFlag("eunos-blades", `cohortEffects.${effect.id}`, { + appliedTo: effect.parent.cohorts.map((cohort) => cohort.id), + effect + }); + await effect.updateSource({ changes: effect.changes.filter((change) => change.key === "APPLYTOCOHORTS") }); + } + const [permChanges, changes] = U.partition(effect.changes, (change) => change.key.startsWith("perm")); + await effect.updateSource({ changes }); + for (const permChange of permChanges) { + const { key, value } = permChange; + const permFuncName = key.replace(/^perm/, ""); + if (permFuncName in CUSTOMFUNCS) { + const funcData = { + funcName: permFuncName, + funcData: value, + isReversing: false, + effect + }; + BladesActiveEffect.ThrottleCustomFunc(effect.parent, funcData); + } + else if (permFuncName === "Add") { + const [target, qty] = value.split(/:/); + effect.parent.update({ [target]: U.pInt(getProperty(effect.parent, target)) + U.pInt(qty) }); + } + } + }); + Hooks.on("applyActiveEffect", (actor, changeData) => { + if (!(actor instanceof BladesActor)) { + return; + } + if (changeData.key in CUSTOMFUNCS) { + const funcData = { + funcName: changeData.key, + funcData: changeData.value, + isReversing: false, + effect: changeData.effect + }; + BladesActiveEffect.ThrottleCustomFunc(actor, funcData); + } + }); + Hooks.on("updateActiveEffect", (effect, { disabled }) => { + if (!(effect.parent instanceof BladesActor)) { + return; + } + const customEffects = effect.changes.filter((changes) => changes.mode === 0); + customEffects.forEach(({ key, value }) => { + const funcData = { + funcName: key, + funcData: value, + isReversing: disabled, + effect + }; + BladesActiveEffect.ThrottleCustomFunc(effect.parent, funcData); + }); + }); + Hooks.on("deleteActiveEffect", async (effect) => { + if (!(effect.parent instanceof BladesActor)) { + return; + } + if (effect.changes.some((change) => change.key === "APPLYTOMEMBERS")) { + if (BladesActor.IsType(effect.parent, BladesActorType.pc) && BladesActor.IsType(effect.parent.crew, BladesActorType.crew)) { + const otherMembers = effect.parent.crew.members.filter((member) => member.id !== effect.parent?.id); + if (otherMembers.length > 0) { + await Promise.all(otherMembers + .map(async (member) => Promise.all(member.effects + .filter((e) => e.name === effect.name) + .map(async (e) => e.delete())))); + } + await effect.parent.unsetFlag("eunos-blades", `memberEffects.${effect.id}`); + } + else if (BladesActor.IsType(effect.parent, BladesActorType.crew)) { + if (effect.parent.members.length > 0) { + await Promise.all(effect.parent.members + .map(async (member) => Promise.all(member.effects + .filter((e) => e.name === effect.name) + .map(async (e) => e.delete())))); + } + await effect.parent.unsetFlag("eunos-blades", `memberEffects.${effect.id}`); + } + } + else if (effect.changes.some((change) => change.key === "APPLYTOCOHORTS") + && (BladesActor.IsType(effect.parent, BladesActorType.pc, BladesActorType.crew))) { + if (effect.parent.cohorts.length > 0) { + await Promise.all(effect.parent.cohorts + .map(async (cohort) => Promise.all(cohort.effects + .filter((e) => e.name === effect.name) + .map(async (e) => e.delete())))); + } + await effect.parent.unsetFlag("eunos-blades", `cohortEffects.${effect.id}`); + } + const customEffects = effect.changes.filter((changes) => changes.mode === 0); + customEffects.forEach(({ key, value }) => { + const funcData = { + funcName: key, + funcData: value, + isReversing: true, + effect + }; + BladesActiveEffect.ThrottleCustomFunc(effect.parent, funcData); + }); + }); + } + static async AddActiveEffect(doc, name, eChanges, icon = "systems/eunos-blades/assets/icons/effect-icons/default.png") { + const changes = [eChanges].flat(); + doc.createEmbeddedDocuments("ActiveEffect", [{ name, icon, changes }]); + } + static ThrottleCustomFunc(actor, data) { + const { funcName, funcData, isReversing, effect } = data; + if (!actor.id) { + return; + } + eLog.display(`Throttling Func: ${funcName}(${funcData}, ${isReversing})`); + if (actor.id && actor.id in FUNCQUEUE) { + const matchingQueue = FUNCQUEUE[actor.id].queue.find((fData) => JSON.stringify(fData) === JSON.stringify(data)); + eLog.checkLog("activeEffects", "... Checking Queue", { data, FUNCQUEUE: FUNCQUEUE[actor.id], matchingQueue }); + if (matchingQueue) { + eLog.error("... Function ALREADY QUEUED, SKIPPING"); + return; + } + FUNCQUEUE[actor.id].queue.push(data); + return; + } + eLog.display("... Creating New FUNCQUEUE, RUNNING:"); + FUNCQUEUE[actor.id] = { + curFunc: BladesActiveEffect.RunCustomFunc(actor, CUSTOMFUNCS[funcName](actor, funcData, effect, isReversing)), + queue: [] + }; + } + static async RunCustomFunc(actor, funcPromise) { + if (!actor.id) { + return; + } + eLog.checkLog("activeEffects", "... Running Func ..."); + await funcPromise; + eLog.checkLog("activeEffects", "... Function Complete!"); + if (FUNCQUEUE[actor.id].queue.length) { + const { funcName, funcData, isReversing, effect } = FUNCQUEUE[actor.id].queue.shift() ?? {}; + if (!funcName || !(funcName in CUSTOMFUNCS)) { + return; + } + if (!funcData) { + return; + } + eLog.display(`Progressing Queue: ${funcName}(${funcData}, ${isReversing}) -- ${FUNCQUEUE[actor.id].queue.length} remaining funcs.`); + FUNCQUEUE[actor.id].curFunc = BladesActiveEffect.RunCustomFunc(actor, CUSTOMFUNCS[funcName](actor, funcData, effect, isReversing)); + } + else { + eLog.display("Function Queue Complete! Deleting."); + delete FUNCQUEUE[actor.id]; + } + } + static onManageActiveEffect(event, owner) { + event.preventDefault(); + const a = event.currentTarget; + if (a.dataset.action === "create") { + return owner.createEmbeddedDocuments("ActiveEffect", [{ + name: owner.name, + icon: owner.img, + origin: owner.uuid + }]); + } + const selector = a.closest("tr"); + if (selector === null) { + return null; + } + const effect = selector.dataset.effectId ? owner.effects.get(selector.dataset.effectId) : null; + if (!effect) { + return null; + } + switch (a.dataset.action) { + case "edit": + return effect.sheet?.render(true); + case "delete": + eLog.checkLog("activeEffects", "delete effect"); + return effect.delete(); + case "toggle": + return effect.update({ disabled: !effect.disabled }); + default: return null; + } + } + async _preCreate(data, options, user) { + eLog.checkLog3("effect", "ActiveEffect._preCreate()", { data, options, user }); + super._preCreate(data, options, user); + } + async _onDelete(options, userID) { + eLog.checkLog3("effect", "ActiveEffect._onDelete()", { options, userID }); + super._onDelete(options, userID); + } + get isSuppressed() { + if (!/Actor.*Item/.test(this.origin)) { + return super.isSuppressed; + } + const [actorID, itemID] = this.origin.replace(/Actor\.|Item\./g, "").split("."); + const actor = game.actors.get(actorID); + const item = actor.items.get(itemID); + return super.isSuppressed || item?.hasTag(Tag.System.Archived); + } +} +export default BladesActiveEffect; +//# sourceMappingURL=BladesActiveEffect.js.map +//# sourceMappingURL=BladesActiveEffect.js.map diff --git a/module/BladesActor.js b/module/BladesActor.js new file mode 100644 index 00000000..27f3e8b6 --- /dev/null +++ b/module/BladesActor.js @@ -0,0 +1,1005 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import U from "./core/utilities.js"; +import C, { BladesActorType, Tag, Playbook, BladesItemType, ActionTrait, PrereqType, AdvancementPoint, Randomizers, Factor } from "./core/constants.js"; +import { BladesItem } from "./documents/BladesItemProxy.js"; +import BladesPushController from "./BladesPushController.js"; +import { SelectionCategory } from "./BladesDialog.js"; +class BladesActor extends Actor { + + static async create(data, options = {}) { + data.token = data.token || {}; + data.system = data.system ?? {}; + + data.system.world_name = data.system.world_name ?? data.name.replace(/[^A-Za-z_0-9 ]/g, "").trim().replace(/ /g, "_"); + return super.create(data, options); + } + + static get All() { return game.actors; } + static Get(actorRef) { + if (actorRef instanceof BladesActor) { + return actorRef; + } + if (U.isDocID(actorRef)) { + return BladesActor.All.get(actorRef); + } + return BladesActor.All.find((a) => a.system.world_name === actorRef) + || BladesActor.All.find((a) => a.name === actorRef); + } + static GetTypeWithTags(docType, ...tags) { + return BladesActor.All.filter((actor) => actor.type === docType) + .filter((actor) => actor.hasTag(...tags)); + } + static IsType(doc, ...types) { + const typeSet = new Set(types); + return doc instanceof BladesActor && typeSet.has(doc.type); + } + get tags() { return this.system.tags ?? []; } + hasTag(...tags) { + return tags.every((tag) => this.tags.includes(tag)); + } + async addTag(...tags) { + const curTags = this.tags; + tags.forEach((tag) => { + if (curTags.includes(tag)) { + return; + } + curTags.push(tag); + }); + eLog.checkLog2("actor", "BladesActor.addTag(...tags)", { tags, curTags }); + this.update({ "system.tags": curTags }); + } + async remTag(...tags) { + const curTags = this.tags.filter((tag) => !tags.includes(tag)); + eLog.checkLog2("actor", "BladesActor.remTag(...tags)", { tags, curTags }); + this.update({ "system.tags": curTags }); + } + get tooltip() { + const tooltipText = [this.system.concept, this.system.subtitle] + .filter(Boolean) + .join("

"); + return tooltipText ? (new Handlebars.SafeString(tooltipText)).toString() : undefined; + } + get dialogCSSClasses() { return ""; } + getFactorTotal(factor) { + switch (factor) { + case Factor.tier: { + if (BladesActor.IsType(this, BladesActorType.pc)) { + return this.system.tier.value + (this.crew?.getFactorTotal(Factor.tier) ?? 0); + } + return this.system.tier.value; + } + case Factor.quality: return this.getFactorTotal(Factor.tier); + case Factor.scale: { + if (BladesActor.IsType(this, BladesActorType.npc)) { + return this.system.scale; + } + return 0; + } + case Factor.magnitude: { + if (BladesActor.IsType(this, BladesActorType.npc)) { + return this.system.magnitude; + } + return 0; + } + default: return 0; + } + } + get subActors() { + return Object.keys(this.system.subactors) + .map((id) => this.getSubActor(id)) + .filter((subActor) => Boolean(subActor)); + } + get activeSubActors() { return this.subActors.filter((subActor) => !subActor.hasTag(Tag.System.Archived)); } + get archivedSubActors() { return this.subActors.filter((subActor) => subActor.hasTag(Tag.System.Archived)); } + checkActorPrereqs(actor) { + + return Boolean(actor); + } + processEmbeddedActorMatches(globalActors) { + return globalActors + .filter(this.checkActorPrereqs) + .filter((gActor) => !this.activeSubActors.some((aActor) => aActor.id === gActor.id)) + .map((gActor) => this.getSubActor(gActor) || gActor) + .sort((a, b) => { + if (a.name === b.name) { + return 0; + } + if (a.name === null) { + return 1; + } + if (b.name === null) { + return -1; + } + if (a.name > b.name) { + return 1; + } + if (a.name < b.name) { + return -1; + } + return 0; + }); + } + getDialogActors(category) { + + const dialogData = {}; + switch (category) { + case SelectionCategory.Contact: + case SelectionCategory.Rival: + case SelectionCategory.Friend: + case SelectionCategory.Acquaintance: { + if (!(BladesActor.IsType(this, BladesActorType.pc) || BladesActor.IsType(this, BladesActorType.crew)) || this.playbookName === null) { + return false; + } + dialogData.Main = this.processEmbeddedActorMatches(BladesActor.GetTypeWithTags(BladesActorType.npc, this.playbookName)); + return dialogData; + } + case SelectionCategory.VicePurveyor: { + if (!BladesActor.IsType(this, BladesActorType.pc) || !this.vice?.name) { + return false; + } + dialogData.Main = this.processEmbeddedActorMatches(BladesActor.GetTypeWithTags(BladesActorType.npc, this.vice.name)); + return dialogData; + } + case SelectionCategory.Crew: { + dialogData.Main = BladesActor.GetTypeWithTags(BladesActorType.crew); + return dialogData; + } + default: return false; + } + } + async addSubActor(actorRef, tags) { + let BladesActorUniqueTags; + (function (BladesActorUniqueTags) { + BladesActorUniqueTags["CharacterCrew"] = "CharacterCrew"; + BladesActorUniqueTags["VicePurveyor"] = "VicePurveyor"; + })(BladesActorUniqueTags || (BladesActorUniqueTags = {})); + let focusSubActor; + if (this.hasSubActorOf(actorRef)) { + const subActor = this.getSubActor(actorRef); + if (!subActor) { + return; + } + if (subActor.hasTag(Tag.System.Archived)) { + await subActor.remTag(Tag.System.Archived); + } + focusSubActor = subActor; + } + else { + const actor = BladesActor.Get(actorRef); + if (!actor) { + return; + } + const subActorData = {}; + if (tags) { + subActorData.tags = U.unique([ + ...actor.tags, + ...tags + ]); + } + await this.update({ [`system.subactors.${actor.id}`]: subActorData }); + focusSubActor = this.getSubActor(actor.id); + } + if (!focusSubActor) { + return; + } + const uniqueTags = focusSubActor.tags.filter((tag) => tag in BladesActorUniqueTags); + if (uniqueTags.length > 0) { + uniqueTags.forEach((uTag) => this.activeSubActors + .filter((subActor) => Boolean(focusSubActor?.id && subActor.id !== focusSubActor.id && subActor.hasTag(uTag))) + .map((subActor) => this.remSubActor(subActor.id))); + } + } + getSubActor(actorRef) { + const actor = BladesActor.Get(actorRef); + if (!actor?.id) { + return undefined; + } + if (!BladesActor.IsType(actor, BladesActorType.npc, BladesActorType.faction)) { + return actor; + } + const subActorData = this.system.subactors[actor.id] ?? {}; + Object.assign(actor.system, mergeObject(actor.system, subActorData)); + actor.parentActor = this; + return actor; + } + hasSubActorOf(actorRef) { + const actor = BladesActor.Get(actorRef); + if (!actor) { + return false; + } + return actor?.id ? actor.id in this.system.subactors : false; + } + async updateSubActor(actorRef, upData) { + const updateData = U.objExpand(upData); + if (!updateData.system) { + return undefined; + } + const actor = BladesActor.Get(actorRef); + if (!actor) { + return undefined; + } + const diffUpdateSystem = U.objDiff(actor.system, updateData.system); + const mergedSubActorSystem = U.objMerge(this.system.subactors[actor.id] ?? {}, diffUpdateSystem, { isReplacingArrays: true, isConcatenatingArrays: false }); + if (JSON.stringify(this.system.subactors[actor.id]) === JSON.stringify(mergedSubActorSystem)) { + return undefined; + } + return this.update({ [`system.subactors.${actor.id}`]: null }, undefined, true) + .then(() => this.update({ [`system.subactors.${actor.id}`]: mergedSubActorSystem }, undefined, true)) + .then(() => actor.sheet?.render()); + } + async remSubActor(actorRef) { + const subActor = this.getSubActor(actorRef); + if (!subActor) { + return; + } + this.update({ ["system.subactors"]: mergeObject(this.system.subactors, { [`-=${subActor.id}`]: null }) }, undefined, true); + } + async clearSubActors(isReRendering = true) { + this.subActors.forEach((subActor) => { + if (subActor.parentActor?.id === this.id) { + subActor.clearParentActor(isReRendering); + } + }); + this.sheet?.render(); + } + async clearParentActor(isReRendering = true) { + const { parentActor } = this; + if (!parentActor) { + return; + } + this.parentActor = undefined; + this.system = this._source.system; + this.ownership = this._source.ownership; + this.prepareData(); + if (isReRendering) { + this.sheet?.render(); + } + } + get subItems() { return Array.from(this.items); } + get activeSubItems() { return this.items.filter((item) => !item.hasTag(Tag.System.Archived)); } + get archivedSubItems() { return this.items.filter((item) => item.hasTag(Tag.System.Archived)); } + _checkItemPrereqs(item) { + if (!item.system.prereqs) { + return true; + } + for (const [pType, pReqs] of Object.entries(item.system.prereqs)) { + const pReqArray = Array.isArray(pReqs) ? pReqs : [pReqs.toString()]; + const hitRecord = {}; + if (!this._processPrereqArray(pReqArray, pType, hitRecord)) { + return false; + } + } + return true; + } + _processPrereqArray(pReqArray, pType, hitRecord) { + while (pReqArray.length) { + const pString = pReqArray.pop(); + hitRecord[pType] ??= []; + if (!this._processPrereqType(pType, pString, hitRecord)) { + return false; + } + } + return true; + } + _processPrereqType(pType, pString, hitRecord) { + switch (pType) { + case PrereqType.HasActiveItem: { + return this._processActiveItemPrereq(pString, hitRecord, pType); + } + case PrereqType.HasActiveItemsByTag: { + return this._processActiveItemsByTagPrereq(pString, hitRecord, pType); + } + case PrereqType.AdvancedPlaybook: { + return this._processAdvancedPlaybookPrereq(); + } + default: return true; + } + } + _processActiveItemPrereq(pString, hitRecord, pType) { + const thisItem = this.activeSubItems + .filter((i) => !hitRecord[pType]?.includes(i.id)) + .find((i) => i.system.world_name === pString); + if (thisItem) { + hitRecord[pType]?.push(thisItem.id); + return true; + } + else { + return false; + } + } + _processActiveItemsByTagPrereq(pString, hitRecord, pType) { + const thisItem = this.activeSubItems + .filter((i) => !hitRecord[pType]?.includes(i.id)) + .find((i) => i.hasTag(pString)); + if (thisItem) { + hitRecord[pType]?.push(thisItem.id); + return true; + } + else { + return false; + } + } + _processAdvancedPlaybookPrereq() { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return false; + } + if (!this.playbookName || ![Playbook.Ghost, Playbook.Hull, Playbook.Vampire].includes(this.playbookName)) { + return false; + } + return true; + } + _processEmbeddedItemMatches(globalItems) { + return globalItems + .filter((item) => this._checkItemPrereqs(item)) + .filter((gItem) => gItem.hasTag(Tag.System.MultiplesOK) || (gItem.system.max_per_score ?? 1) > this.activeSubItems.filter((sItem) => sItem.system.world_name === gItem.system.world_name).length) + .map((gItem) => { + const matchingSubItems = this.archivedSubItems.filter((sItem) => sItem.system.world_name === gItem.system.world_name); + if (matchingSubItems.length > 0) { + return matchingSubItems; + } + else { + return gItem; + } + }) + .flat() + .map((sItem) => { + sItem.dialogCSSClasses = ""; + const cssClasses = []; + if (sItem.isEmbedded) { + cssClasses.push("embedded"); + } + if (sItem.hasTag(Tag.Gear.Fine)) { + cssClasses.push("fine-quality"); + } + if (sItem.hasTag(Tag.System.Featured)) { + cssClasses.push("featured-item"); + } + if ([BladesItemType.ability, BladesItemType.crew_ability].includes(sItem.type)) { + if (this.getAvailableAdvancements("Ability") === 0) { + cssClasses.push("locked"); + } + else if ((sItem.system.price ?? 1) > this.getAvailableAdvancements("Ability")) { + cssClasses.push("locked", "unaffordable"); + } + else if ((sItem.system.price ?? 1) > 1) { + cssClasses.push("expensive"); + } + } + if ([BladesItemType.crew_upgrade].includes(sItem.type)) { + if (this.getAvailableAdvancements("Upgrade") === 0) { + cssClasses.push("locked"); + } + else if ((sItem.system.price ?? 1) > this.getAvailableAdvancements("Upgrade")) { + cssClasses.push("locked", "unaffordable"); + } + else if ((sItem.system.price ?? 1) > 1) { + cssClasses.push("expensive"); + } + } + if (cssClasses.length > 0) { + sItem.dialogCSSClasses = cssClasses.join(" "); + } + return sItem; + }) + .sort((a, b) => { + if (a.hasTag(Tag.System.Featured) && !b.hasTag(Tag.System.Featured)) { + return -1; + } + if (!a.hasTag(Tag.System.Featured) && b.hasTag(Tag.System.Featured)) { + return 1; + } + if (a.hasTag(Tag.Gear.Fine) && !b.hasTag(Tag.Gear.Fine)) { + return -1; + } + if (!a.hasTag(Tag.Gear.Fine) && b.hasTag(Tag.Gear.Fine)) { + return 1; + } + if (a.system.world_name > b.system.world_name) { + return 1; + } + if (a.system.world_name < b.system.world_name) { + return -1; + } + if (a.isEmbedded && !b.isEmbedded) { + return -1; + } + if (!a.isEmbedded && b.isEmbedded) { + return 1; + } + if (a.name === b.name) { + return 0; + } + if (a.name === null) { + return 1; + } + if (b.name === null) { + return -1; + } + if (a.name > b.name) { + return 1; + } + if (a.name < b.name) { + return -1; + } + return 0; + }); + } + getDialogItems(category) { + const dialogData = {}; + const isPC = BladesActor.IsType(this, BladesActorType.pc); + const isCrew = BladesActor.IsType(this, BladesActorType.crew); + if (!BladesActor.IsType(this, BladesActorType.pc) && !BladesActor.IsType(this, BladesActorType.crew)) { + return false; + } + const { playbookName } = this; + if (category === SelectionCategory.Heritage && isPC) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.heritage)); + } + else if (category === SelectionCategory.Background && isPC) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.background)); + } + else if (category === SelectionCategory.Vice && isPC && playbookName !== null) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.vice, playbookName)); + } + else if (category === SelectionCategory.Playbook) { + if (this.type === BladesActorType.pc) { + dialogData.Basic = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.playbook).filter((item) => !item.hasTag(Tag.Gear.Advanced))); + dialogData.Advanced = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.playbook, Tag.Gear.Advanced)); + } + else if (this.type === BladesActorType.crew) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_playbook)); + } + } + else if (category === SelectionCategory.Reputation && isCrew) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_reputation)); + } + else if (category === SelectionCategory.Preferred_Op && isCrew && playbookName !== null) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.preferred_op, playbookName)); + } + else if (category === SelectionCategory.Gear && BladesActor.IsType(this, BladesActorType.pc)) { + const self = this; + if (playbookName === null) { + return false; + } + const gearItems = this._processEmbeddedItemMatches([ + ...BladesItem.GetTypeWithTags(BladesItemType.gear, playbookName), + ...BladesItem.GetTypeWithTags(BladesItemType.gear, Tag.Gear.General) + ]) + .filter((item) => self.remainingLoad >= item.system.load); + dialogData[playbookName] = gearItems.filter((item) => item.hasTag(playbookName)); + dialogData.General = gearItems + .filter((item) => item.hasTag(Tag.Gear.General)) + .map((item) => { + if (item.dialogCSSClasses) { + item.dialogCSSClasses = item.dialogCSSClasses.replace(/featured-item\s?/g, ""); + } + return item; + }) + .sort((a, b) => { + if (a.system.world_name > b.system.world_name) { + return 1; + } + if (a.system.world_name < b.system.world_name) { + return -1; + } + return 0; + }); + } + else if (category === SelectionCategory.Ability) { + if (isPC) { + if (playbookName === null) { + return false; + } + dialogData[playbookName] = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.ability, playbookName)); + dialogData.Veteran = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.ability)) + .filter((item) => !item.hasTag(playbookName)) + .map((item) => { + if (item.dialogCSSClasses) { + item.dialogCSSClasses = item.dialogCSSClasses.replace(/featured-item\s?/g, ""); + } + return item; + }) + .sort((a, b) => { + if (a.system.world_name > b.system.world_name) { + return 1; + } + if (a.system.world_name < b.system.world_name) { + return -1; + } + return 0; + }); + } + else if (isCrew) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_ability, playbookName)); + } + } + else if (category === SelectionCategory.Upgrade && isCrew && playbookName !== null) { + dialogData[playbookName] = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_upgrade, playbookName)); + dialogData.General = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_upgrade, Tag.Gear.General)); + } + return dialogData; + } + getSubItem(itemRef, activeOnly = false) { + const activeCheck = (i) => !activeOnly || !i.hasTag(Tag.System.Archived); + if (typeof itemRef === "string" && this.items.get(itemRef)) { + const returnItem = this.items.get(itemRef); + if (returnItem && activeCheck(returnItem)) { + return returnItem; + } + else { + return undefined; + } + } + else { + const globalItem = BladesItem.Get(itemRef); + if (!globalItem) { + return undefined; + } + return this.items.find((item) => item.name === globalItem.name && activeCheck(item)) + ?? this.items.find((item) => item.system.world_name === globalItem.system.world_name && activeCheck(item)); + } + } + hasSubItemOf(itemRef) { + const item = BladesItem.Get(itemRef); + if (!item) { + return false; + } + return Boolean(this.items.find((i) => i.system.world_name === item.system.world_name)); + } + hasActiveSubItemOf(itemRef) { + const item = BladesItem.Get(itemRef); + if (!item) { + return false; + } + return Boolean(this.items.find((i) => !i.hasTag(Tag.System.Archived) && i.system.world_name === item.system.world_name)); + } + async addSubItem(itemRef) { + let BladesItemUniqueTypes; + (function (BladesItemUniqueTypes) { + BladesItemUniqueTypes["background"] = "background"; + BladesItemUniqueTypes["vice"] = "vice"; + BladesItemUniqueTypes["crew_playbook"] = "crew_playbook"; + BladesItemUniqueTypes["crew_reputation"] = "crew_reputation"; + BladesItemUniqueTypes["heritage"] = "heritage"; + BladesItemUniqueTypes["playbook"] = "playbook"; + BladesItemUniqueTypes["preferred_op"] = "preferred_op"; + })(BladesItemUniqueTypes || (BladesItemUniqueTypes = {})); + function isBladesItemUniqueTypes(type) { + return Object.values(BladesItemUniqueTypes).includes(type); + } + eLog.checkLog3("subitems", "[addSubItem] itemRef", itemRef); + let focusItem; + const embeddedItem = this.getSubItem(itemRef); + if (embeddedItem) { + if (embeddedItem.hasTag(Tag.System.Archived)) { + await embeddedItem.remTag(Tag.System.Archived); + focusItem = embeddedItem; + eLog.checkLog3("subitems", `[addSubItem] IS ARCHIVED EMBEDDED > Removing 'Archived' Tag, '${focusItem.id}':`, focusItem); + } + else { + focusItem = await BladesItem.create([embeddedItem], { parent: this }); + eLog.checkLog3("subitems", `[addSubItem] IS ACTIVE EMBEDDED > Duplicating, focusItem '${focusItem.id}':`, focusItem); + } + } + else { + const globalItem = BladesItem.Get(itemRef); + eLog.checkLog3("subitems", `[addSubItem] IS NOT EMBEDDED > Fetching Global, globalItem '${globalItem?.id}':`, globalItem); + if (!globalItem) { + return; + } + focusItem = await BladesItem.create([globalItem], { parent: this }); + focusItem = this.items.getName(globalItem.name); + } + if (focusItem && isBladesItemUniqueTypes(focusItem.type)) { + await Promise.all(this.activeSubItems + .filter((subItem) => subItem.type === focusItem?.type && subItem.system.world_name !== focusItem?.system.world_name && !subItem.hasTag(Tag.System.Archived)) + .map(this.remSubItem.bind(this))); + } + } + async remSubItem(itemRef) { + const subItem = this.getSubItem(itemRef); + if (!subItem) { + return; + } + if (subItem.type !== BladesItemType.gear) { + this.purgeSubItem(itemRef); + return; + } + eLog.checkLog("actorTrigger", "Removing SubItem " + subItem.name, subItem); + if (subItem.hasTag(Tag.System.Archived)) { + return; + } + subItem.addTag(Tag.System.Archived); + } + async purgeSubItem(itemRef) { + const subItem = this.getSubItem(itemRef); + if (!subItem || subItem.hasTag(Tag.System.Archived)) { + return; + } + subItem.delete(); + } + + async grantAdvancementPoints(allowedTypes, amount = 1) { + const aPtKey = Array.isArray(allowedTypes) + ? [...allowedTypes].sort((a, b) => a.localeCompare(b)).join("_") + : allowedTypes; + this.update({ [`system.advancement_points.${aPtKey}`]: (this.system.advancement_points?.[aPtKey] ?? 0) + amount }); + } + async removeAdvancementPoints(allowedTypes, amount = 1) { + const aPtKey = Array.isArray(allowedTypes) + ? [...allowedTypes].sort((a, b) => a.localeCompare(b)).join("_") + : allowedTypes; + const newCount = this.system.advancement_points?.[aPtKey] ?? 0 - amount; + if (newCount <= 0 && aPtKey in (this.system.advancement_points ?? [])) { + this.update({ [`system.advancement_points.-=${aPtKey}`]: null }); + } + else { + this.update({ [`system.advancement_points.${aPtKey}`]: newCount }); + } + } + getAvailableAdvancements(trait) { + if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { + return 0; + } + if (trait in ActionTrait) { + return 1; + } + if (trait === "Cohort") { + const pointsCohort = this.system.advancement_points?.[AdvancementPoint.Cohort] ?? 0; + const spentCohort = this.cohorts.length; + return Math.max(0, pointsCohort - spentCohort); + } + const pointsAbility = this.system.advancement_points?.[AdvancementPoint.Ability] ?? 0; + const pointsCohortType = this.system.advancement_points?.[AdvancementPoint.CohortType] ?? 0; + const pointsUpgrade = this.system.advancement_points?.[AdvancementPoint.Upgrade] ?? 0; + const pointsUpgradeOrAbility = this.system.advancement_points?.[AdvancementPoint.UpgradeOrAbility] ?? 0; + const spentAbility = U.sum(this.items + .filter((item) => BladesItem.IsType(item, BladesItemType.ability, BladesItemType.crew_ability)) + .map((abil) => abil.system.price ?? 1)); + const spentCohortType = U.sum(this.cohorts.map((cohort) => Math.max(0, U.unique(Object.values(cohort.system.subtypes)).length - 1))); + const spentUpgrade = U.sum(this.items + .filter((item) => BladesItem.IsType(item, BladesItemType.crew_upgrade)) + .map((upgrade) => upgrade.system.price ?? 1)); + const excessUpgrade = Math.max(0, spentUpgrade - pointsUpgrade); + const excessCohortType = Math.max(0, spentCohortType - pointsCohortType); + const excessAbility = Math.max(0, spentAbility - pointsAbility); + const remainingAbility = Math.max(0, pointsAbility - spentAbility); + const remainingCohortType = Math.max(0, pointsCohortType - spentCohortType); + const remainingUpgrade = Math.max(0, pointsUpgrade - spentUpgrade); + const remainingUpgradeOrAbility = Math.max(0, pointsUpgradeOrAbility - excessUpgrade - (2 * excessAbility) - (2 * excessCohortType)); + if (trait === "Ability") { + return remainingAbility + Math.floor(0.5 * remainingUpgradeOrAbility); + } + if (trait === "Upgrade") { + return remainingUpgrade + remainingUpgradeOrAbility; + } + if (trait === "CohortType") { + return remainingCohortType + remainingUpgradeOrAbility; + } + return 0; + } + get availableAbilityPoints() { return this.getAvailableAdvancements("Ability"); } + get availableUpgradePoints() { return this.getAvailableAdvancements("Upgrade"); } + get availableCohortPoints() { return this.getAvailableAdvancements("Cohort"); } + get availableCohortTypePoints() { return this.getAvailableAdvancements("CohortType"); } + get canPurchaseAbility() { return this.availableAbilityPoints > 0; } + get canPurchaseUpgrade() { return this.availableUpgradePoints > 0; } + get canPurchaseCohort() { return this.availableCohortPoints > 0; } + get canPurchaseCohortType() { return this.availableCohortTypePoints > 0; } + async advancePlaybook() { + if (!(BladesActor.IsType(this, BladesActorType.pc) || BladesActor.IsType(this, BladesActorType.crew)) || !this.playbook) { + return; + } + await this.update({ "system.experience.playbook.value": 0 }); + if (BladesActor.IsType(this, BladesActorType.pc)) { + BladesPushController.Get().pushToAll("GM", `${this.name} Advances their Playbook!`, `${this.name}, select a new Ability on your Character Sheet.`); + this.grantAdvancementPoints(AdvancementPoint.Ability); + return; + } + if (BladesActor.IsType(this, BladesActorType.crew)) { + BladesPushController.Get().pushToAll("GM", `${this.name} Advances their Playbook!`, "Select new Upgrades and/or Abilities on your Crew Sheet."); + this.members.forEach((member) => { + const coinGained = this.system.tier.value + 2; + BladesPushController.Get().pushToAll("GM", `${member.name} Gains ${coinGained} Stash (Crew Advancement)`, undefined); + member.addStash(coinGained); + }); + this.grantAdvancementPoints(AdvancementPoint.UpgradeOrAbility, 2); + } + } + async advanceAttribute(attribute) { + await this.update({ [`system.experience.${attribute}.value`]: 0 }); + const actions = C.Action[attribute].map((action) => `${U.tCase(action)}`); + BladesPushController.Get().pushToAll("GM", `${this.name} Advances their ${U.uCase(attribute)}!`, `${this.name}, add a dot to one of ${U.oxfordize(actions, true, "or")}.`); + } + parentActor; + get isSubActor() { return this.parentActor !== undefined; } + + + get members() { + if (!BladesActor.IsType(this, BladesActorType.crew)) { + return []; + } + const self = this; + return BladesActor.GetTypeWithTags(BladesActorType.pc).filter((actor) => actor.isMember(self)); + } + get contacts() { + if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { + return []; + } + const self = this; + return this.activeSubActors.filter((actor) => actor.hasTag(self.playbookName)); + } + get claims() { + if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { + return {}; + } + return this.playbook.system.turfs; + } + get turfCount() { + if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { + return 0; + } + return Object.values(this.playbook.system.turfs) + .filter((claim) => claim.isTurf && claim.value).length; + } + get upgrades() { + if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { + return []; + } + return this.activeSubItems.filter((item) => item.type === BladesItemType.crew_upgrade); + } + get cohorts() { + return this.activeSubItems.filter((item) => [BladesItemType.cohort_gang, BladesItemType.cohort_expert].includes(item.type)); + } + getTaggedItemBonuses(tags) { + return tags.length; + } + + prepareDerivedData() { + if (BladesActor.IsType(this, BladesActorType.pc)) { + this._preparePCData(this.system); + } + if (BladesActor.IsType(this, BladesActorType.crew)) { + this._prepareCrewData(this.system); + } + } + _preparePCData(system) { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return; + } + if (this.playbook) { + system.experience.clues = [...system.experience.clues, ...Object.values(this.playbook.system.experience_clues).filter((clue) => Boolean(clue.trim()))]; + } + if (this.playbook) { + system.gather_info = [...system.gather_info, ...Object.values(this.playbook.system.gather_info_questions).filter((question) => Boolean(question.trim()))]; + } + } + _prepareCrewData(system) { + if (!BladesActor.IsType(this, BladesActorType.crew)) { + return; + } + if (this.playbook) { + system.experience.clues = [...system.experience.clues, ...Object.values(this.playbook.system.experience_clues).filter((clue) => Boolean(clue.trim()))]; + system.turfs = this.playbook.system.turfs; + } + } + + async _onCreateDescendantDocuments(parent, collection, docs, data, options, userId) { + await Promise.all(docs.map(async (doc) => { + if (BladesItem.IsType(doc, BladesItemType.playbook, BladesItemType.crew_playbook)) { + await Promise.all(this.activeSubItems + .filter((aItem) => aItem.type === doc.type && aItem.system.world_name !== doc.system.world_name) + .map((aItem) => this.remSubItem(aItem))); + } + })); + await super._onCreateDescendantDocuments(parent, collection, docs, data, options, userId); + eLog.checkLog("actorTrigger", "_onCreateDescendantDocuments", { parent, collection, docs, data, options, userId }); + docs.forEach((doc) => { + if (BladesItem.IsType(doc, BladesItemType.vice) && BladesActor.IsType(this, BladesActorType.pc)) { + this.activeSubActors + .filter((subActor) => subActor.hasTag(Tag.NPC.VicePurveyor) && !subActor.hasTag(doc.name)) + .forEach((subActor) => { this.remSubActor(subActor); }); + } + }); + } + async update(updateData, context, isSkippingSubActorCheck = false) { + if (!updateData) { + return super.update(updateData); + } + if (BladesActor.IsType(this, BladesActorType.crew)) { + if (!this.playbook) { + return undefined; + } + eLog.checkLog("actorTrigger", "Updating Crew", { updateData }); + const playbookUpdateData = Object.fromEntries(Object.entries(flattenObject(updateData)) + .filter(([key, _]) => key.startsWith("system.turfs."))); + updateData = Object.fromEntries(Object.entries(flattenObject(updateData)) + .filter(([key, _]) => !key.startsWith("system.turfs."))); + eLog.checkLog("actorTrigger", "Updating Crew", { crewUpdateData: updateData, playbookUpdateData }); + const diffPlaybookData = diffObject(flattenObject(this.playbook), playbookUpdateData); + delete diffPlaybookData._id; + if (!U.isEmpty(diffPlaybookData)) { + await this.playbook.update(playbookUpdateData, context) + .then(() => this.sheet?.render(false)); + } + } + else if ((BladesActor.IsType(this, BladesActorType.npc) + || BladesActor.IsType(this, BladesActorType.faction)) + && this.parentActor + && !isSkippingSubActorCheck) { + return this.parentActor.updateSubActor(this.id, updateData) + .then(() => this); + } + return super.update(updateData, context); + } + + + createListOfDiceMods(rs, re, s) { + let text = ""; + if (s === "") { + s = 0; + } + for (let i = rs; i <= re; i++) { + let plus = ""; + if (i >= 0) { + plus = "+"; + } + text += ``; + } + return text; + } + + updateRandomizers() { + if (!BladesActor.IsType(this, BladesActorType.npc)) { + return; + } + const titleChance = 0.05; + const suffixChance = 0.01; + const { persona, secret, random } = this.system; + function sampleArray(arr, ...curVals) { + arr = arr.filter((elem) => !curVals.includes(elem)); + if (!arr.length) { + return ""; + } + return arr[Math.floor(Math.random() * arr.length)]; + } + const randomGen = { + name: (gen) => { + return [ + Math.random() <= titleChance + ? sampleArray(Randomizers.NPC.name_title) + : "", + sampleArray([ + ...((gen ?? "").charAt(0).toLowerCase() !== "m" ? Randomizers.NPC.name_first.female : []), + ...((gen ?? "").charAt(0).toLowerCase() !== "f" ? Randomizers.NPC.name_first.male : []) + ]), + `"${sampleArray(Randomizers.NPC.name_alias)}"`, + sampleArray(Randomizers.NPC.name_surname), + Math.random() <= suffixChance + ? sampleArray(Randomizers.NPC.name_suffix) + : "" + ].filter((val) => Boolean(val)).join(" "); + }, + background: () => sampleArray(Randomizers.NPC.background, random.background.value), + heritage: () => sampleArray(Randomizers.NPC.heritage, random.heritage.value), + profession: () => sampleArray(Randomizers.NPC.profession, random.profession.value), + gender: () => sampleArray(Randomizers.NPC.gender, persona.gender.value), + appearance: () => sampleArray(Randomizers.NPC.appearance, persona.appearance.value), + goal: () => sampleArray(Randomizers.NPC.goal, persona.goal.value, secret.goal.value), + method: () => sampleArray(Randomizers.NPC.method, persona.method.value, secret.method.value), + trait: () => sampleArray(Randomizers.NPC.trait, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value), + interests: () => sampleArray(Randomizers.NPC.interests, persona.interests.value, secret.interests.value), + quirk: () => sampleArray(Randomizers.NPC.quirk, persona.quirk.value), + style: (gen = "") => sampleArray([ + ...(gen.charAt(0).toLowerCase() !== "m" ? Randomizers.NPC.style.female : []), + ...(gen.charAt(0).toLowerCase() !== "f" ? Randomizers.NPC.style.male : []) + ], persona.style.value) + }; + const gender = persona.gender.isLocked ? persona.gender.value : randomGen.gender(); + const updateKeys = [ + ...Object.keys(persona).filter((key) => !persona[key]?.isLocked), + ...Object.keys(random).filter((key) => !random[key]?.isLocked), + ...Object.keys(secret).filter((key) => !secret[key]?.isLocked) + .map((secretKey) => `secret-${secretKey}`) + ]; + eLog.checkLog("Update Keys", { updateKeys }); + const updateData = {}; + updateKeys.forEach((key) => { + switch (key) { + case "name": + case "heritage": + case "background": + case "profession": { + const randomVal = randomGen[key](); + updateData[`system.random.${key}`] = { + isLocked: false, + value: randomVal || random[key].value + }; + break; + } + case "secret-goal": + case "secret-interests": + case "secret-method": { + key = key.replace(/^secret-/, ""); + const randomVal = randomGen[key](); + updateData[`system.secret.${key}`] = { + isLocked: false, + value: randomVal || secret[key].value + }; + break; + } + case "gender": { + updateData[`system.persona.${key}`] = { + isLocked: persona.gender.isLocked, + value: gender + }; + break; + } + case "trait1": + case "trait2": + case "trait3": + case "secret-trait": { + const trait1 = persona.trait1.isLocked + ? persona.trait1.value + : sampleArray(Randomizers.NPC.trait, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); + const trait2 = persona.trait2.isLocked + ? persona.trait2.value + : sampleArray(Randomizers.NPC.trait, trait1, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); + const trait3 = persona.trait3.isLocked + ? persona.trait3.value + : sampleArray(Randomizers.NPC.trait, trait1, trait2, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); + const secretTrait = secret.trait.isLocked + ? secret.trait.value + : sampleArray(Randomizers.NPC.trait, trait1, trait2, trait3, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); + if (!persona.trait1.isLocked) { + updateData["system.persona.trait1"] = { + isLocked: false, + value: trait1 + }; + } + if (!persona.trait2.isLocked) { + updateData["system.persona.trait2"] = { + isLocked: false, + value: trait2 + }; + } + if (!persona.trait3.isLocked) { + updateData["system.persona.trait3"] = { + isLocked: false, + value: trait3 + }; + } + if (!secret.trait.isLocked) { + updateData["system.secret.trait"] = { + isLocked: false, + value: secretTrait + }; + } + break; + } + default: { + const randomVal = randomGen[key](); + updateData[`system.persona.${key}`] = { + isLocked: false, + value: randomVal || persona[key].value + }; + break; + } + } + }); + this.update(updateData); + } +} +export default BladesActor; +//# sourceMappingURL=BladesActor.js.map +//# sourceMappingURL=BladesActor.js.map diff --git a/module/BladesDialog.js b/module/BladesDialog.js new file mode 100644 index 00000000..0f72ba44 --- /dev/null +++ b/module/BladesDialog.js @@ -0,0 +1,138 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import { ApplyTooltipListeners } from "./core/gsap.js"; +import U from "./core/utilities.js"; +export var SelectionCategory; +(function (SelectionCategory) { + SelectionCategory["Heritage"] = "Heritage"; + SelectionCategory["Background"] = "Background"; + SelectionCategory["Vice"] = "Vice"; + SelectionCategory["Playbook"] = "Playbook"; + SelectionCategory["Reputation"] = "Reputation"; + SelectionCategory["Preferred_Op"] = "Preferred_Op"; + SelectionCategory["Gear"] = "Gear"; + SelectionCategory["Ability"] = "Ability"; + SelectionCategory["Faction"] = "Faction"; + SelectionCategory["Upgrade"] = "Upgrade"; + SelectionCategory["Cohort_Gang"] = "Cohort_Gang"; + SelectionCategory["Cohort_Expert"] = "Cohort_Expert"; + SelectionCategory["Feature"] = "Feature"; + SelectionCategory["Stricture"] = "Stricture"; + SelectionCategory["VicePurveyor"] = "VicePurveyor"; + SelectionCategory["Acquaintance"] = "Acquaintance"; + SelectionCategory["Friend"] = "Friend"; + SelectionCategory["Rival"] = "Rival"; + SelectionCategory["Crew"] = "Crew"; + SelectionCategory["Member"] = "Member"; + SelectionCategory["Contact"] = "Contact"; +})(SelectionCategory || (SelectionCategory = {})); +class BladesSelectorDialog extends Dialog { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "dialog"], + template: "systems/eunos-blades/templates/dialog.hbs", + width: "auto", + height: "auto", + tabs: [{ navSelector: ".nav-tabs", contentSelector: ".tab-content", initial: "front" }] + }); + } + static Initialize() { + return loadTemplates([ + "systems/eunos-blades/templates/dialog.hbs" + ]); + } + static async Display(parent, title, docType, tabs, tags) { + const app = new BladesSelectorDialog({ + parent, + title, + docType, + tabs, + "tags": tags?.filter((tag) => tag !== ""), + "content": "", + "buttons": { + cancel: { + icon: '', + label: game.i18n.localize("Cancel"), + callback: () => false + } + }, + "default": "cancel" + }); + return app.hasItems ? app.render(true, { width: app.width }) : undefined; + } + get hasItems() { + return Object.values(this.tabs).some((tabItems) => tabItems.length > 0); + } + parent; + tabs; + tags = []; + width; + docType; + constructor(data, options) { + super(data, options); + const validTabs = []; + for (const [tabName, tabItems] of Object.entries(data.tabs)) { + if (tabItems.length === 0) { + delete data.tabs[tabName]; + } + else { + validTabs.push(tabName); + } + } + if (validTabs.length === 1 && !("Main" in data.tabs)) { + data.tabs.Main = [...data.tabs[validTabs[0]]]; + delete data.tabs[validTabs[0]]; + } + this.docType = data.docType; + this.parent = data.parent; + this.tabs = data.tabs; + this.tags = data.tags ?? []; + this.width = 150 * Math.ceil(Math.sqrt(Object.values(data.tabs)[0].length)); + } + getData() { + const data = super.getData(); + data.title = this.title; + data.tabs = this.tabs; + data.docType = this.docType; + data.tags = this.tags; + return data; + } + activateListeners(html) { + super.activateListeners(html); + const self = this; + + html.find(".nav-tabs .tab-selector").on("click", (event) => { + const tabIndex = U.pInt($(event.currentTarget).data("tab")); + const numItems = Object.values(self.tabs)[tabIndex].length; + const width = U.pInt(150 * Math.ceil(Math.sqrt(numItems))); + eLog.checkLog3("nav", "Nav Tab Size Recalculation", { tabIndex, numItems, width }); + this.render(false, { width }); + }); + + ApplyTooltipListeners(html); + + html.find("[data-item-id]").on("click", function () { + if ($(this).parent().hasClass("locked")) { + return; + } + const docId = $(this).data("itemId"); + const docType = $(this).data("docType"); + eLog.checkLog("dialog", "[BladesDialog] on Click", { elem: this, docId, docType, parent: self.parent }); + if (docType === "Actor") { + self.parent.addSubActor(docId, self.tags); + } + else if (docType === "Item") { + self.parent.addSubItem(docId); + } + self.close(); + }); + } +} +export default BladesSelectorDialog; +//# sourceMappingURL=BladesDialog.js.map +//# sourceMappingURL=BladesDialog.js.map diff --git a/module/BladesItem.js b/module/BladesItem.js new file mode 100644 index 00000000..cc101b56 --- /dev/null +++ b/module/BladesItem.js @@ -0,0 +1,296 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import C, { BladesActorType, BladesItemType, Tag, Factor } from "./core/constants.js"; +import U from "./core/utilities.js"; +import { BladesActor } from "./documents/BladesActorProxy.js"; +import { BladesRollMod } from "./BladesRollCollab.js"; +class BladesItem extends Item { + + static async create(data, options = {}) { + if (Array.isArray(data)) { + data = data[0]; + } + data.system = data.system ?? {}; + eLog.checkLog2("item", "BladesItem.create(data,options)", { data, options }); + + data.system.world_name = data.system.world_name ?? data.name.replace(/[^A-Za-z_0-9 ]/g, "").trim().replace(/ /g, "_"); + return super.create(data, options); + } + + static get All() { return game.items; } + static Get(itemRef) { + if (itemRef instanceof BladesItem) { + return itemRef; + } + if (U.isDocID(itemRef)) { + return BladesItem.All.get(itemRef); + } + return BladesItem.All.find((a) => a.system.world_name === itemRef) + || BladesItem.All.find((a) => a.name === itemRef); + } + static GetTypeWithTags(docType, ...tags) { + if (Array.isArray(docType)) { + return docType + .map((dType) => BladesItem.All.filter((item) => item.type === dType)) + .flat(); + } + return BladesItem.All.filter((item) => item.type === docType) + .filter((item) => item.hasTag(...tags)); + } + static IsType(doc, ...types) { + const typeSet = new Set(types); + return doc instanceof BladesItem && typeSet.has(doc.type); + } + get tags() { return this.system.tags ?? []; } + hasTag(...tags) { + return tags.every((tag) => this.tags.includes(tag)); + } + async addTag(...tags) { + const curTags = this.tags; + tags.forEach((tag) => { + if (curTags.includes(tag)) { + return; + } + curTags.push(tag); + }); + this.update({ "system.tags": curTags }); + } + async remTag(...tags) { + const curTags = this.tags.filter((tag) => !tags.includes(tag)); + this.update({ "system.tags": curTags }); + } + get tooltip() { + const tooltipText = [ + this.system.concept, + this.system.rules, + this.system.notes + ].filter(Boolean).join(""); + if (tooltipText) { + return (new Handlebars.SafeString(tooltipText)).toString(); + } + return undefined; + } + dialogCSSClasses = ""; + getFactorTotal(factor) { + switch (factor) { + case Factor.tier: { + if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { + return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); + } + if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { + return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); + } + if (BladesItem.IsType(this, BladesItemType.gear)) { + return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); + } + return this.system.tier.value; + } + case Factor.quality: { + if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { + return this.getFactorTotal(Factor.tier) + (this.system.quality_bonus ?? 0); + } + if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { + return this.getFactorTotal(Factor.tier) + (this.system.quality_bonus ?? 0) + 1; + } + if (BladesItem.IsType(this, BladesItemType.gear)) { + return this.getFactorTotal(Factor.tier) + + (this.hasTag("Fine") ? 1 : 0) + + (this.parent?.getTaggedItemBonuses(this.tags) ?? 0) + + (BladesActor.IsType(this.parent, BladesActorType.pc) + && BladesActor.IsType(this.parent.crew, BladesActorType.crew) + ? this.parent.crew.getTaggedItemBonuses(this.tags) + : 0); + } + if (BladesItem.IsType(this, BladesItemType.design)) { + return this.system.min_quality; + } + return this.getFactorTotal(Factor.tier); + } + case Factor.scale: { + if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { + return this.getFactorTotal(Factor.tier) + (this.system.scale_bonus ?? 0); + } + if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { + return 0 + (this.system.scale_bonus ?? 0); + } + return 0; + } + case Factor.magnitude: { + if (BladesItem.IsType(this, BladesItemType.ritual)) { + return this.system.magnitude.value; + } + return 0; + } + default: return 0; + } + } + + async archive() { + await this.addTag(Tag.System.Archived); + return this; + } + async unarchive() { + await this.remTag(Tag.System.Archived); + return this; + } + + + get rollFactors() { + const factorsMap = { + [BladesItemType.cohort_gang]: [Factor.quality, Factor.scale], + [BladesItemType.cohort_expert]: [Factor.quality, Factor.scale], + [BladesItemType.gear]: [Factor.quality], + [BladesItemType.project]: [Factor.quality], + [BladesItemType.ritual]: [Factor.magnitude], + [BladesItemType.design]: [Factor.quality] + }; + if (!factorsMap[this.type]) { + return {}; + } + const factors = factorsMap[this.type]; + const factorData = {}; + (factors ?? []).forEach((factor, i) => { + const factorTotal = this.getFactorTotal(factor); + factorData[factor] = { + name: factor, + value: factorTotal, + max: factorTotal, + baseVal: factorTotal, + display: [Factor.tier, Factor.quality].includes(factor) ? U.romanizeNum(factorTotal) : `${factorTotal}`, + isActive: i === 0, + isPrimary: i === 0, + isDominant: false, + highFavorsPC: true, + cssClasses: `factor-gold${i === 0 ? " factor-main" : ""}` + }; + }); + return factorData; + } + + get rollPrimaryID() { return this.id; } + get rollPrimaryDoc() { return this; } + get rollPrimaryName() { return this.name; } + get rollPrimaryType() { return this.type; } + get rollPrimaryImg() { return this.img; } + get rollModsData() { + + return BladesRollMod.ParseDocRollMods(this); + } + + get rollOppID() { return this.id; } + get rollOppDoc() { return this; } + get rollOppImg() { return this.img; } + get rollOppName() { return this.name; } + get rollOppSubName() { return ""; } + get rollOppType() { return this.type; } + get rollOppModsData() { return []; } + + get rollParticipantID() { return this.id; } + get rollParticipantDoc() { return this; } + get rollParticipantIcon() { return this.img; } + get rollParticipantName() { return this.name; } + get rollParticipantType() { return this.type; } + get rollParticipantModsData() { return []; } + + prepareDerivedData() { + super.prepareDerivedData(); + if (BladesItem.IsType(this, BladesItemType.cohort_gang, BladesItemType.cohort_expert)) { + this._prepareCohortData(this.system); + } + if (BladesItem.IsType(this, BladesItemType.crew_playbook)) { + this._preparePlaybookData(this.system); + } + if (BladesItem.IsType(this, BladesItemType.gear)) { + this._prepareGearData(this.system); + } + if (BladesItem.IsType(this, BladesItemType.playbook)) { + this._preparePlaybookData(this.system); + } + } + _prepareCohortData(system) { + if (!BladesItem.IsType(this, BladesItemType.cohort_gang, BladesItemType.cohort_expert)) { + return; + } + system.tier.name = "Quality"; + const subtypes = U.unique(Object.values(system.subtypes) + .map((subtype) => subtype.trim()) + .filter((subtype) => /[A-Za-z]/.test(subtype))); + const eliteSubtypes = U.unique([ + ...Object.values(system.elite_subtypes), + ...(this.parent?.upgrades ?? []) + .filter((upgrade) => /^Elite/.test(upgrade.name ?? "")) + .map((upgrade) => (upgrade.name ?? "").trim().replace(/^Elite /, "")) + ] + .map((subtype) => subtype.trim()) + .filter((subtype) => /[A-Za-z]/.test(subtype) && subtypes.includes(subtype))); + system.subtypes = Object.fromEntries(subtypes.map((subtype, i) => [`${i + 1}`, subtype])); + system.elite_subtypes = Object.fromEntries(eliteSubtypes.map((subtype, i) => [`${i + 1}`, subtype])); + system.edges = Object.fromEntries(Object.values(system.edges ?? []) + .filter((edge) => /[A-Za-z]/.test(edge)) + .map((edge, i) => [`${i + 1}`, edge.trim()])); + system.flaws = Object.fromEntries(Object.values(system.flaws ?? []) + .filter((flaw) => /[A-Za-z]/.test(flaw)) + .map((flaw, i) => [`${i + 1}`, flaw.trim()])); + system.quality = this.getFactorTotal(Factor.quality); + if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { + if ([...subtypes, ...eliteSubtypes].includes(Tag.GangType.Vehicle)) { + system.scale = this.getFactorTotal(Factor.scale); + system.scaleExample = "(1 vehicle)"; + } + else { + system.scale = this.getFactorTotal(Factor.scale); + const scaleIndex = Math.min(6, system.scale); + system.scaleExample = C.ScaleExamples[scaleIndex]; + system.subtitle = C.ScaleSizes[scaleIndex]; + } + if (subtypes.length + eliteSubtypes.length === 0) { + system.subtitle = system.subtitle.replace(/\s+of\b/g, "").trim(); + } + } + else { + system.scale = 0; + system.scaleExample = [...subtypes, ...eliteSubtypes].includes("Pet") ? "(1 animal)" : "(1 person)"; + system.subtitle = "An Expert"; + } + if (subtypes.length + eliteSubtypes.length > 0) { + if ([...subtypes, ...eliteSubtypes].includes(Tag.GangType.Vehicle)) { + system.subtitle = C.VehicleDescriptors[Math.min(6, this.getFactorTotal(Factor.tier))]; + } + else { + system.subtitle += ` ${U.oxfordize([ + ...subtypes.filter((subtype) => !eliteSubtypes.includes(subtype)), + ...eliteSubtypes.map((subtype) => `Elite ${subtype}`) + ], false, "&")}`; + } + } + } + _prepareGearData(system) { + if (!BladesItem.IsType(this, BladesItemType.gear)) { + return; + } + system.tier.name = "Quality"; + } + _preparePlaybookData(system) { + if (!BladesItem.IsType(this, BladesItemType.playbook, BladesItemType.crew_playbook)) { + return; + } + const expClueData = {}; + [...Object.values(system.experience_clues).filter((clue) => /[A-Za-z]/.test(clue)), " "].forEach((clue, i) => { expClueData[(i + 1).toString()] = clue; }); + system.experience_clues = expClueData; + eLog.checkLog3("experienceClues", { expClueData }); + if (BladesItem.IsType(this, BladesItemType.playbook)) { + const gatherInfoData = {}; + [...Object.values(system.gather_info_questions).filter((question) => /[A-Za-z]/.test(question)), " "].forEach((question, i) => { gatherInfoData[(i + 1).toString()] = question; }); + system.gather_info_questions = gatherInfoData; + eLog.checkLog3("gatherInfoQuestions", { gatherInfoData }); + } + } +} +export default BladesItem; +//# sourceMappingURL=BladesItem.js.map +//# sourceMappingURL=BladesItem.js.map diff --git a/module/BladesPushController.js b/module/BladesPushController.js new file mode 100644 index 00000000..2eda59d8 --- /dev/null +++ b/module/BladesPushController.js @@ -0,0 +1,96 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import U from "./core/utilities.js"; +export default class BladesPushController { + static Get() { + if (!game.eunoblades.PushController) { + throw new Error("Attempt to Get BladesPushController before 'ready' hook."); + } + return game.eunoblades.PushController; + } + static isInitialized = false; + static Initialize() { + game.eunoblades ??= {}; + Hooks.once("ready", async () => { + let pushController = game.eunoblades.PushController; + if (!(pushController instanceof BladesPushController)) { + pushController = new BladesPushController(); + } + game.eunoblades.PushController = pushController; + pushController.initOverlay(); + }); + Hooks.on("canvasReady", async () => { game.eunoblades.PushController?.initOverlay(); }); + } + static InitSockets() { + if (game.eunoblades.PushController) { + socketlib.system.register("pushNotice", game.eunoblades.PushController.push); + return true; + } + return false; + } + initOverlay() { + $("#sidebar").append($("
")); + BladesPushController.isInitialized = true; + } + get elem$() { return $("#blades-push-notifications"); } + get elem() { return this.elem$[0]; } + activeNotifications = {}; + push(blockClass, charName, titleText, bodyText) { + const pushController = BladesPushController.Get(); + const pushID = randomID(); + const pushLines = [ + `
` + ]; + if (charName !== "GM") { + pushLines.push(`
${charName}
`); + } + if (titleText) { + pushLines.push(`
${titleText}
`); + } + if (bodyText) { + pushLines.push(`
${bodyText}
`); + } + pushLines.push("
"); + const pushElem$ = $(pushLines.join("\n")); + pushController.elem$.append(pushElem$); + pushElem$.on("click", () => pushController.removePush(pushElem$[0])); + U.gsap.from(pushElem$[0], { + x: "-=200", + scale: 1.25, + duration: 1, + ease: "power2" + }); + U.gsap.from(pushElem$[0], { + background: "rgb(255, 231, 92)", + borderColor: "rgb(255, 255, 255)", + duration: 10, + ease: "power2" + }); + } + removePush(pushElem) { + U.gsap.effects.slideUp(pushElem) + .then(() => $(pushElem).remove()); + } + pushToAll(...args) { + socketlib.system.executeForEveryone("pushNotice", "", ...args); + } + pushToSome(...args) { + const users = (args.pop() ?? []) + .filter((user) => Boolean(user?.id)); + if (!users || users.length === 0) { + return; + } + const pushArgs = args.slice(0, 3); + socketlib.system.executeForUsers("pushNotice", users.map((user) => user.id), "", ...pushArgs); + } + pushToGM(...args) { + socketlib.system.executeForAllGMs("pushNotice", "to-gm-notice", ...args); + } +} +//# sourceMappingURL=BladesPushController.js.map +//# sourceMappingURL=BladesPushController.js.map diff --git a/module/BladesRollCollab.js b/module/BladesRollCollab.js new file mode 100644 index 00000000..14b312f6 --- /dev/null +++ b/module/BladesRollCollab.js @@ -0,0 +1,2291 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import U from "./core/utilities.js"; +import C, { BladesActorType, BladesItemType, RollType, RollModStatus, RollModCategory, ActionTrait, AttributeTrait, Position, Effect, Factor, RollResult, ConsequenceType } from "./core/constants.js"; +import BladesActor from "./BladesActor.js"; +import BladesItem from "./BladesItem.js"; +import { ApplyTooltipListeners } from "./core/gsap.js"; +function isAction(trait) { + return Boolean(trait && typeof trait === "string" && U.lCase(trait) in ActionTrait); +} +function isAttribute(trait) { + return Boolean(trait && typeof trait === "string" && U.lCase(trait) in AttributeTrait); +} +function isFactor(trait) { + return Boolean(trait && typeof trait === "string" && U.lCase(trait) in Factor); +} +function isModStatus(str) { + return typeof str === "string" && str in RollModStatus; +} +export class BladesRollMod { + static ParseDocRollMods(doc) { + const { roll_mods } = doc.system; + if (!roll_mods || roll_mods.length === 0) { + return []; + } + return roll_mods + .filter((elem) => typeof elem === "string") + .map((modString) => { + const pStrings = modString.split(/@/); + const nameString = U.pullElement(pStrings, (v) => typeof v === "string" && /^na/i.test(v)); + const nameVal = (typeof nameString === "string" && nameString.replace(/^.*:/, "")); + if (!nameVal) { + throw new Error(`RollMod Missing Name: '${modString}'`); + } + const catString = U.pullElement(pStrings, (v) => typeof v === "string" && /^cat/i.test(v)); + const catVal = (typeof catString === "string" && catString.replace(/^.*:/, "")); + if (!catVal || !(catVal in RollModCategory)) { + throw new Error(`RollMod Missing Category: '${modString}'`); + } + const posNegString = (U.pullElement(pStrings, (v) => typeof v === "string" && /^p/i.test(v)) || "posNeg:positive"); + const posNegVal = posNegString.replace(/^.*:/, ""); + const rollModData = { + id: `${nameVal}-${posNegVal}-${catVal}`, + name: nameVal, + category: catVal, + base_status: RollModStatus.ToggledOff, + modType: "general", + value: 1, + posNeg: posNegVal, + tooltip: "" + }; + pStrings.forEach((pString) => { + const [keyString, valString] = pString.split(/:/); + let val = /\|/.test(valString) ? valString.split(/\|/) : valString; + let key; + if (/^stat/i.test(keyString)) { + key = "base_status"; + } + else if (/^val/i.test(keyString)) { + key = "value"; + } + else if (/^eff|^ekey/i.test(keyString)) { + key = "effectKeys"; + } + else if (/^side|^ss/i.test(keyString)) { + key = "sideString"; + } + else if (/^s.*ame/i.test(keyString)) { + key = "source_name"; + } + else if (/^tool|^tip/i.test(keyString)) { + key = "tooltip"; + } + else if (/^ty/i.test(keyString)) { + key = "modType"; + } + else if (/^c.{0,10}r?.{0,3}ty/i.test(keyString)) { + key = "conditionalRollTypes"; + } + else if (/^a.{0,3}r?.{0,3}y/i.test(keyString)) { + key = "autoRollTypes"; + } + else if (/^c.{0,10}r?.{0,3}tr/i.test(keyString)) { + key = "conditionalRollTraits"; + } + else if (/^a.{0,3}r?.{0,3}tr/i.test(keyString)) { + key = "autoRollTraits"; + } + else { + throw new Error(`Bad Roll Mod Key: ${keyString}`); + } + if (key === "base_status" && val === "Conditional") { + val = RollModStatus.Hidden; + } + let valProcessed; + if (["value"].includes(key)) { + valProcessed = U.pInt(val); + } + else if (["effectKeys", "conditionalRollTypes", "autoRollTypes,", "conditionalRollTraits", "autoRollTraits"].includes(key)) { + valProcessed = [val].flat(); + } + else { + valProcessed = val.replace(/%COLON%/g, ":"); + } + Object.assign(rollModData, { [key]: valProcessed }); + }); + return rollModData; + }); + } + get status() { + if (this.userStatus && [RollModStatus.ForcedOn, RollModStatus.ForcedOff, RollModStatus.Hidden].includes(this.userStatus)) { + return this.userStatus; + } + if (this.heldStatus && [RollModStatus.ToggledOff, RollModStatus.ToggledOn].includes(this.heldStatus)) { + return this.userStatus ?? this.heldStatus; + } + return this.heldStatus ?? this.userStatus ?? this.baseStatus; + } + get isActive() { return [RollModStatus.ToggledOn, RollModStatus.ForcedOn].includes(this.status); } + get isVisible() { return this.status !== RollModStatus.Hidden; } + _heldStatus; + get heldStatus() { return this._heldStatus; } + set heldStatus(val) { + this._heldStatus = val; + } + get flagParams() { return [C.SYSTEM_ID, `rollCollab.rollModsData.${this.id}`]; } + getFlag() { return this.rollInstance.document.getFlag(...this.flagParams); } + get userStatus() { + return this.rollInstance.document.getFlag(...this.flagParams); + } + set userStatus(val) { + if (val === this.userStatus) { + return; + } + if (!val || val === this.baseStatus) { + this.rollInstance.document.unsetFlag(...this.flagParams); + } + else { + if ([RollModStatus.ForcedOn, RollModStatus.ForcedOff, RollModStatus.Hidden].includes(val) + && !game.user.isGM) { + return; + } + if (this.userStatus && [RollModStatus.ForcedOn, RollModStatus.ForcedOff, RollModStatus.Hidden].includes(this.userStatus) + && !game.user.isGM) { + return; + } + this.rollInstance.document.setFlag(...this.flagParams, val); + } + } + get sourceName() { return this._sourceName; } + get isConditional() { + return [ + ...this.conditionalRollTraits, + ...this.autoRollTraits, + ...this.conditionalRollTypes, + ...this.autoRollTypes + ].length > 0; + } + get isInInactiveBlock() { + if (game.user.isGM) { + return [RollModStatus.Hidden, RollModStatus.ForcedOff, RollModStatus.ToggledOff].includes(this.status) + && (this.isConditional || [BladesItemType.ability].includes(this.modType)); + } + return [RollModStatus.ForcedOff, RollModStatus.ToggledOff].includes(this.status) + && (this.isConditional || [BladesItemType.ability].includes(this.modType)); + } + get isPush() { + return Boolean(U.lCase(this.name) === "push" + || this.effectKeys.find((eKey) => eKey === "Is-Push")); + } + get isBasicPush() { return U.lCase(this.name) === "push"; } + get stressCost() { + const costKeys = this.effectKeys.filter((key) => /^Cost-Stress/.test(key)); + if (costKeys.length === 0) { + return 0; + } + let stressCost = 0; + costKeys.forEach((key) => { + const [thisParam] = (key.split(/-/) ?? []).slice(1); + const [_, valStr] = (thisParam.match(/([A-Za-z]+)(\d*)/) ?? []).slice(1); + stressCost += U.pInt(valStr); + }); + return stressCost; + } + setConditionalStatus() { + if (!this.isConditional) { + return false; + } + if (this.autoRollTypes.includes(this.rollInstance.rollType) + || this.autoRollTraits.includes(this.rollInstance.rollTrait)) { + this.heldStatus = RollModStatus.ForcedOn; + return false; + } + if ((this.conditionalRollTypes.length === 0 || this.conditionalRollTypes.includes(this.rollInstance.rollType)) + && (this.conditionalRollTraits.length === 0 || this.conditionalRollTraits.includes(this.rollInstance.rollTrait))) { + this.heldStatus = RollModStatus.ToggledOff; + return false; + } + this.heldStatus = RollModStatus.Hidden; + return true; + } + processKey(key) { + const [thisKey, thisParam] = key.split(/-/) ?? []; + const positions = [Position.controlled, Position.risky, Position.desperate]; + if (positions.includes(U.lCase(thisParam)) && this.rollInstance.finalPosition === U.lCase(thisParam)) { + if (thisKey === "AutoRevealOn") { + this.heldStatus = RollModStatus.ToggledOff; + return true; + } + else if (thisKey === "AutoEnableOn") { + this.heldStatus = RollModStatus.ForcedOn; + return true; + } + } + return false; + } + setAutoStatus() { + const holdKeys = this.effectKeys.filter((key) => /^Auto/.test(key)); + if (holdKeys.length === 0) { + return false; + } + for (const key of holdKeys) { + if (this.processKey(key)) { + return false; + } + } + this.heldStatus = RollModStatus.Hidden; + return true; + } + setRelevancyStatus() { + const holdKeys = this.effectKeys.filter((key) => /^Negate|^Increase/.test(key)); + if (holdKeys.length === 0) { + return false; + } + const relevantKeys = holdKeys + .filter((key) => { + const [thisKey, thisParam] = key.split(/-/) ?? []; + const negateOperations = { + PushCost: () => this.rollInstance.isPushed(), + Consequence: () => this.rollInstance.rollType === RollType.Resistance && Boolean(this.rollInstance.rollConsequence), + HarmLevel: () => this.rollInstance.rollType === RollType.Resistance && this.rollInstance.rollConsequence && [ConsequenceType.Harm1, ConsequenceType.Harm2, ConsequenceType.Harm3, ConsequenceType.Harm4].includes(this.rollInstance.rollConsequence.type), + QualityPenalty: () => this.rollInstance.isTraitRelevant(Factor.quality) && (this.rollInstance.rollFactors.source[Factor.quality]?.value ?? 0) < (this.rollInstance.rollFactors.opposition[Factor.quality]?.value ?? 0), + ScalePenalty: () => this.rollInstance.isTraitRelevant(Factor.scale) && (this.rollInstance.rollFactors.source[Factor.scale]?.value ?? 0) < (this.rollInstance.rollFactors.opposition[Factor.scale]?.value ?? 0), + TierPenalty: () => this.rollInstance.isTraitRelevant(Factor.tier) && (this.rollInstance.rollFactors.source[Factor.tier]?.value ?? 0) < (this.rollInstance.rollFactors.opposition[Factor.tier]?.value ?? 0) + }; + if (thisKey === "Negate") { + if (Object.hasOwn(negateOperations, thisParam)) { + return negateOperations[thisParam](); + } + else { + throw new Error(`Unrecognized Negate parameter: ${thisParam}`); + } + } + else if (thisKey === "Increase") { + const [_, traitStr] = thisParam.match(/(\w+)\d+/) ?? []; + return this.rollInstance.isTraitRelevant(traitStr); + } + else { + throw new Error(`Unrecognized Function Key: ${thisKey}`); + } + }); + if (relevantKeys.length === 0) { + this.heldStatus = RollModStatus.Hidden; + return true; + } + return false; + } + setPayableStatus() { + const holdKeys = this.effectKeys.filter((key) => /^Cost/.test(key)); + if (holdKeys.length === 0) { + return false; + } + const payableKeys = holdKeys + .filter((key) => { + const [thisParam] = (key.split(/-/) ?? []).slice(1); + const [traitStr, valStr] = (thisParam.match(/([A-Za-z]+)(\d*)/) ?? []).slice(1); + const { rollPrimaryDoc } = this.rollInstance.rollPrimary ?? {}; + if (!BladesRollPrimary.IsDoc(rollPrimaryDoc)) { + return false; + } + switch (traitStr) { + case "SpecialArmor": { + return BladesActor.IsType(rollPrimaryDoc, BladesActorType.pc) + && rollPrimaryDoc.system.armor.active.special + && !rollPrimaryDoc.system.armor.checked.special; + } + case "Stress": { + const val = U.pInt(valStr); + return BladesActor.IsType(rollPrimaryDoc, BladesActorType.pc) + && rollPrimaryDoc.system.stress.max - rollPrimaryDoc.system.stress.value >= val; + } + default: throw new Error(`Unrecognize Payable Key: ${traitStr}`); + } + }); + if (payableKeys.length === 0) { + this.heldStatus = RollModStatus.ForcedOff; + return true; + } + return false; + } + applyRollModEffectKeys() { + if (!this.isActive) { + return; + } + const holdKeys = this.effectKeys.filter((key) => /^Negate|^Increase/.test(key)); + if (holdKeys.length === 0) { + return; + } + holdKeys.forEach((key) => { + const [thisKey, thisParam] = key.split(/"-"/) ?? []; + const negateOperations = { + PushCost: () => { + const costlyPushMod = this.rollInstance.getActiveRollMods() + .find((mod) => mod.isPush && mod.stressCost > 0); + if (costlyPushMod) { + U.pullElement(costlyPushMod.effectKeys, (k) => k.startsWith("Cost-Stress")); + } + }, + Consequence: () => { + }, + HarmLevel: () => { + if (!this.rollInstance.rollConsequence) { + return; + } + const consequenceType = this.rollInstance.rollConsequence.type; + if (!consequenceType || !/^Harm/.test(consequenceType)) { + return; + } + const curLevel = [ConsequenceType.Harm1, ConsequenceType.Harm2, ConsequenceType.Harm3, ConsequenceType.Harm4] + .findIndex((cType) => cType === consequenceType) + 1; + if (curLevel > 1) { + this.rollInstance.rollConsequence.type = `Harm${curLevel - 1}`; + } + else { + } + }, + QualityPenalty: () => { + this.rollInstance.negateFactorPenalty(Factor.quality); + }, + ScalePenalty: () => { + this.rollInstance.negateFactorPenalty(Factor.scale); + }, + TierPenalty: () => { + this.rollInstance.negateFactorPenalty(Factor.tier); + } + }; + if (thisKey === "Negate") { + if (Object.hasOwn(negateOperations, thisParam)) { + return negateOperations[thisParam](); + } + else { + throw new Error(`Unrecognized Negate parameter: ${thisParam}`); + } + } + else if (thisKey === "Increase") { + const [_, traitStr] = /(\w+)\d+/.exec(thisParam) ?? []; + return this.rollInstance.isTraitRelevant(traitStr); + } + else { + throw new Error(`Unrecognized Function Key: ${thisKey}`); + } + }); + } + get tooltip() { + if (this.sideString) { + return this._tooltip + .replace(/%COLON%/g, ":") + .replace(/%DOC_NAME%/g, this.sideString); + } + return this._tooltip.replace(/%COLON%/g, ":"); + } + get sideString() { + if (this._sideString) { + return this._sideString; + } + switch (this.category) { + case RollModCategory.roll: { + if (this.name === "Assist") { + const docID = this.rollInstance.document.getFlag("eunos-blades", "rollCollab.docSelections.roll.Assist"); + if (!docID) { + return undefined; + } + return (game.actors.get(docID) ?? game.items.get(docID))?.name ?? undefined; + } + return undefined; + } + case RollModCategory.position: { + if (this.name === "Setup") { + const docID = this.rollInstance.document.getFlag("eunos-blades", "rollCollab.docSelections.position.Setup"); + if (!docID) { + return undefined; + } + return (game.actors.get(docID) ?? game.items.get(docID))?.name ?? undefined; + } + return undefined; + } + case RollModCategory.effect: { + if (this.name === "Setup") { + const docID = this.rollInstance.document.getFlag("eunos-blades", "rollCollab.docSelections.effect.Setup"); + if (!docID) { + return undefined; + } + return (game.actors.get(docID) ?? game.items.get(docID))?.name ?? undefined; + } + return undefined; + } + default: return undefined; + } + } + get allFlagData() { + return this.rollInstance.document.getFlag("eunos-blades", "rollCollab"); + } + get data() { + return { + id: this.id, + name: this.name, + base_status: this.baseStatus, + user_status: this.userStatus, + value: this.value, + effectKeys: this.effectKeys, + sideString: this._sideString, + tooltip: this._tooltip, + posNeg: this.posNeg, + isOppositional: this.isOppositional, + modType: this.modType, + conditionalRollTypes: this.conditionalRollTypes, + autoRollTypes: this.autoRollTypes, + conditionalRollTraits: this.conditionalRollTraits, + autoRollTraits: this.autoRollTraits, + category: this.category + }; + } + get costs() { + if (!this.isActive) { + return undefined; + } + const holdKeys = this.effectKeys.filter((key) => /^Cost/.test(key)); + if (holdKeys.length === 0) { + return undefined; + } + return holdKeys.map((key) => { + const [thisParam] = (key.split(/-/) ?? []).slice(1); + const [traitStr, valStr] = (thisParam.match(/([A-Za-z]+)(\d*)/) ?? []).slice(1); + let label = this.name; + if (this.isBasicPush) { + if (this.posNeg === "negative") { + label = `${this.name} (To Act)`; + } + else { + const effect = this.category === RollModCategory.roll ? "+1d" : "+1 effect"; + label = `${this.name} (${effect})`; + } + } + return { + id: this.id, + label, + costType: traitStr, + costAmount: valStr ? U.pInt(valStr) : 1 + }; + }); + } + id; + name; + _sourceName; + baseStatus; + value; + effectKeys; + _sideString; + _tooltip; + posNeg; + isOppositional; + modType; + conditionalRollTypes; + autoRollTypes; + conditionalRollTraits; + autoRollTraits; + category; + rollInstance; + constructor(modData, rollInstance) { + this.rollInstance = rollInstance; + this.id = modData.id; + this.name = modData.name; + this._sourceName = modData.source_name ?? modData.name; + this.baseStatus = modData.base_status; + this.value = modData.value; + this.effectKeys = modData.effectKeys ?? []; + this._sideString = modData.sideString; + this._tooltip = modData.tooltip; + this.posNeg = modData.posNeg; + this.isOppositional = modData.isOppositional ?? false; + this.modType = modData.modType; + this.conditionalRollTypes = modData.conditionalRollTypes ?? []; + this.autoRollTypes = modData.autoRollTypes ?? []; + this.conditionalRollTraits = modData.conditionalRollTraits ?? []; + this.autoRollTraits = modData.autoRollTraits ?? []; + this.category = modData.category; + } +} +class BladesRollPrimary { + static IsDoc(doc) { + return BladesActor.IsType(doc, BladesActorType.pc, BladesActorType.crew) + || BladesItem.IsType(doc, BladesItemType.cohort_expert, BladesItemType.cohort_gang, BladesItemType.gm_tracker); + } + rollInstance; + rollPrimaryID; + rollPrimaryDoc; + rollPrimaryName; + rollPrimaryType; + rollPrimaryImg; + rollModsData; + rollFactors; + constructor(rollInstance, { rollPrimaryID, rollPrimaryDoc, rollPrimaryName, rollPrimaryType, rollPrimaryImg, rollModsData, rollFactors } = {}) { + this.rollInstance = rollInstance; + let doc = rollPrimaryDoc; + if (!doc && rollPrimaryID) { + doc = game.items.get(rollPrimaryID) ?? game.actors.get(rollPrimaryID); + } + if (!doc && rollPrimaryName) { + doc = game.items.getName(rollPrimaryName) ?? game.actors.getName(rollPrimaryName); + } + if (BladesRollPrimary.IsDoc(doc)) { + this.rollPrimaryDoc = doc; + } + if (BladesRollPrimary.IsDoc(this.rollPrimaryDoc)) { + this.rollPrimaryID = this.rollPrimaryDoc.rollPrimaryID; + this.rollPrimaryName = rollPrimaryName ?? this.rollPrimaryDoc.rollPrimaryName; + this.rollPrimaryType = this.rollPrimaryDoc.rollPrimaryType; + this.rollPrimaryImg = rollPrimaryImg ?? this.rollPrimaryDoc.rollPrimaryImg ?? ""; + this.rollModsData = [ + ...rollModsData ?? [], + ...this.rollPrimaryDoc.rollModsData ?? [] + ]; + this.rollFactors = Object.assign(this.rollPrimaryDoc.rollFactors, rollFactors ?? {}); + } + else { + if (!rollPrimaryName) { + throw new Error("Must include a rollPrimaryName when constructing a BladesRollPrimary object."); + } + if (!rollPrimaryImg) { + throw new Error("Must include a rollPrimaryImg when constructing a BladesRollPrimary object."); + } + if (!rollPrimaryType) { + throw new Error("Must include a rollPrimaryType when constructing a BladesRollPrimary object."); + } + if (!rollFactors) { + throw new Error("Must include a rollFactors when constructing a BladesRollPrimary object."); + } + this.rollPrimaryID = rollPrimaryID; + this.rollPrimaryName = rollPrimaryName; + this.rollPrimaryType = rollPrimaryType; + this.rollPrimaryImg = rollPrimaryImg; + this.rollModsData = rollModsData ?? []; + this.rollFactors = rollFactors; + } + } +} +class BladesRollOpposition { + static IsDoc(doc) { + return BladesActor.IsType(doc, BladesActorType.npc, BladesActorType.faction) + || BladesItem.IsType(doc, BladesItemType.cohort_expert, BladesItemType.cohort_gang, BladesItemType.gm_tracker, BladesItemType.project, BladesItemType.design, BladesItemType.ritual); + } + rollInstance; + rollOppID; + rollOppDoc; + rollOppName; + rollOppSubName; + rollOppType; + rollOppImg; + rollOppModsData; + rollFactors; + constructor(rollInstance, { rollOppID, rollOppDoc, rollOppName, rollOppSubName, rollOppType, rollOppImg, rollOppModsData, rollFactors } = {}) { + this.rollInstance = rollInstance; + let doc = rollOppDoc; + if (!doc && rollOppID) { + doc = game.items.get(rollOppID) ?? game.actors.get(rollOppID); + } + if (!doc && rollOppName) { + doc = game.items.getName(rollOppName) ?? game.actors.getName(rollOppName); + } + if (BladesRollOpposition.IsDoc(doc)) { + this.rollOppDoc = doc; + } + if (BladesRollOpposition.IsDoc(this.rollOppDoc)) { + this.rollOppID = this.rollOppDoc.rollOppID; + this.rollOppName = rollOppName ?? this.rollOppDoc.rollOppName; + this.rollOppSubName = rollOppSubName ?? this.rollOppDoc.rollOppSubName; + this.rollOppType = this.rollOppDoc.rollOppType; + this.rollOppImg = rollOppImg ?? this.rollOppDoc.rollOppImg ?? ""; + this.rollOppModsData = [ + ...rollOppModsData ?? [], + ...this.rollOppDoc.rollOppModsData ?? [] + ]; + this.rollFactors = Object.assign(this.rollOppDoc.rollFactors, rollFactors ?? {}); + } + else { + if (!rollOppName) { + throw new Error("Must include a rollOppName when constructing a BladesRollOpposition object."); + } + if (!rollOppSubName) { + throw new Error("Must include a rollOppSubName when constructing a BladesRollOpposition object."); + } + if (!rollOppType) { + throw new Error("Must include a rollOppType when constructing a BladesRollOpposition object."); + } + if (!rollFactors) { + throw new Error("Must include a rollFactors when constructing a BladesRollOpposition object."); + } + this.rollOppID = rollOppID; + this.rollOppName = rollOppName; + this.rollOppSubName = rollOppSubName; + this.rollOppType = rollOppType; + this.rollOppImg = rollOppImg ?? ""; + this.rollOppModsData = rollOppModsData ?? []; + this.rollFactors = rollFactors; + } + if (this.rollOppModsData.length === 0) { + this.rollOppModsData = undefined; + } + } +} +class BladesRollParticipant { + static IsDoc(doc) { + return BladesActor.IsType(doc, BladesActorType.pc, BladesActorType.crew, BladesActorType.npc) + || BladesItem.IsType(doc, BladesItemType.cohort_expert, BladesItemType.cohort_gang, BladesItemType.gm_tracker); + } + rollInstance; + rollParticipantID; + rollParticipantDoc; + rollParticipantName; + rollParticipantType; + rollParticipantIcon; + rollParticipantModsData; + rollFactors; + constructor(rollInstance, { rollParticipantID, rollParticipantDoc, rollParticipantName, rollParticipantType, rollParticipantIcon, rollParticipantModsData, rollFactors } = {}) { + this.rollInstance = rollInstance; + let doc = rollParticipantDoc; + if (!doc && rollParticipantID) { + doc = game.items.get(rollParticipantID) ?? game.actors.get(rollParticipantID); + } + if (!doc && rollParticipantName) { + doc = game.items.getName(rollParticipantName) ?? game.actors.getName(rollParticipantName); + } + if (BladesRollParticipant.IsDoc(doc)) { + this.rollParticipantDoc = doc; + } + if (this.rollParticipantDoc) { + this.rollParticipantID = this.rollParticipantDoc.rollParticipantID; + this.rollParticipantName = rollParticipantName ?? this.rollParticipantDoc.rollParticipantName ?? this.rollParticipantDoc.name; + this.rollParticipantIcon = rollParticipantIcon ?? this.rollParticipantDoc.rollParticipantIcon ?? this.rollParticipantDoc.img; + this.rollParticipantType = this.rollParticipantDoc.rollParticipantType; + this.rollParticipantModsData = [ + ...rollParticipantModsData ?? [], + ...this.rollParticipantDoc.rollParticipantModsData ?? [] + ]; + this.rollFactors = Object.assign(this.rollParticipantDoc.rollFactors, rollFactors ?? {}); + } + else { + if (!rollParticipantName) { + throw new Error("Must include a rollParticipantName when constructing a BladesRollParticipant object."); + } + if (!rollParticipantType) { + throw new Error("Must include a rollParticipantType when constructing a BladesRollParticipant object."); + } + if (!rollParticipantIcon) { + throw new Error("Must include a rollParticipantIcon when constructing a BladesRollParticipant object."); + } + if (!rollFactors) { + throw new Error("Must include a rollFactors when constructing a BladesRollParticipant object."); + } + this.rollParticipantID = rollParticipantID; + this.rollParticipantName = rollParticipantName; + this.rollParticipantType = rollParticipantType; + this.rollParticipantIcon = rollParticipantIcon; + this.rollParticipantModsData = rollParticipantModsData ?? []; + this.rollFactors = rollFactors; + } + if (this.rollParticipantModsData.length === 0) { + this.rollParticipantModsData = undefined; + } + } +} + +class BladesRollCollab extends DocumentSheet { + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "roll-collab", game.user.isGM ? "gm-roll-collab" : ""], + template: `systems/eunos-blades/templates/roll/roll-collab${game.user.isGM ? "-gm" : ""}.hbs`, + submitOnChange: true, + width: 500, + dragDrop: [ + { dragSelector: null, dropSelector: "[data-action='gm-drop-opposition'" } + ] + }); + } + static Initialize() { + + return loadTemplates([ + "systems/eunos-blades/templates/roll/roll-collab.hbs", + "systems/eunos-blades/templates/roll/roll-collab-gm.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-gm-number-line.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-gm-select-doc.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-gm-factor-control.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-action.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-action-gm.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-resistance.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-resistance-gm.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-downtime.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-downtime-gm.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-fortune.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-fortune-gm.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-incarceration.hbs", + "systems/eunos-blades/templates/roll/partials/roll-collab-incarceration-gm.hbs" + ]); + } + static InitSockets() { + socketlib.system.register("renderRollCollab", BladesRollCollab.RenderRollCollab); + socketlib.system.register("closeRollCollab", BladesRollCollab.CloseRollCollab); + } + static get DefaultRollMods() { + return [ + { + id: "Push-positive-roll", + name: "PUSH", + category: RollModCategory.roll, + base_status: RollModStatus.ToggledOff, + posNeg: "positive", + modType: "general", + value: 1, + effectKeys: ["ForceOff-Bargain", "Cost-Stress2"], + tooltip: "

Push for +1d

For 2 Stress, add 1 die to your pool.

(You cannot also accept a Devil's Bargain to increase your dice pool: It's one or the other.)

" + }, + { + id: "Bargain-positive-roll", + name: "Bargain", + category: RollModCategory.roll, + base_status: RollModStatus.Hidden, + posNeg: "positive", + modType: "general", + value: 1, + effectKeys: [], + tooltip: "

Devil's Bargain

The GM has offered you a Devil's Bargain.

Accept the terms to add 1 die to your pool.

(You cannot also Push for +1d to increase your dice pool: It's one or the other.)

" + }, + { + id: "Assist-positive-roll", + name: "Assist", + category: RollModCategory.roll, + base_status: RollModStatus.Hidden, + posNeg: "positive", + modType: "teamwork", + value: 1, + tooltip: "

%DOC_NAME% Assists

%DOC_NAME% is Assisting your efforts, adding 1 die to your pool.

" + }, + { + id: "Setup-positive-position", + name: "Setup", + category: RollModCategory.position, + base_status: RollModStatus.Hidden, + posNeg: "positive", + modType: "teamwork", + value: 1, + tooltip: "

%DOC_NAME% Sets You Up

%DOC_NAME% has set you up for success with a preceding Setup action, increasing your Position by one level.

" + }, + { + id: "Push-positive-effect", + name: "PUSH", + category: RollModCategory.effect, + base_status: RollModStatus.ToggledOff, + posNeg: "positive", + modType: "general", + value: 1, + effectKeys: ["Cost-Stress2"], + tooltip: "

Push for Effect

For 2 Stress, increase your Effect by one level.

" + }, + { + id: "Setup-positive-effect", + name: "Setup", + category: RollModCategory.effect, + base_status: RollModStatus.Hidden, + posNeg: "positive", + modType: "teamwork", + value: 1, + tooltip: "

%DOC_NAME% Sets You Up

%DOC_NAME% has set you up for success with a preceding Setup action, increasing your Effect by one level.

" + }, + { + id: "Potency-positive-effect", + name: "Potency", + category: RollModCategory.effect, + base_status: RollModStatus.Hidden, + posNeg: "positive", + modType: "general", + value: 1, + tooltip: "

Potency

By circumstance or advantage, you have Potency in this action, increasing your Effect by one level.

" + }, + { + id: "Potency-negative-effect", + name: "Potency", + category: RollModCategory.effect, + base_status: RollModStatus.Hidden, + posNeg: "negative", + modType: "general", + value: 1, + tooltip: "

Potency

By circumstance or advantage, @OPPOSITION_NAME@ has Potency against you, reducing your Effect by one level." + } + ]; + } + static get DefaultFlagData() { + return { + rollID: randomID(), + rollType: RollType.Action, + rollTrait: Factor.tier, + rollModsData: {}, + rollPositionInitial: Position.risky, + rollEffectInitial: Effect.standard, + rollPosEffectTrade: false, + isGMReady: false, + GMBoosts: { + [Factor.tier]: 0, + [Factor.quality]: 0, + [Factor.scale]: 0, + [Factor.magnitude]: 0 + }, + GMOppBoosts: { + [Factor.tier]: 0, + [Factor.quality]: 0, + [Factor.scale]: 0, + [Factor.magnitude]: 0 + }, + docSelections: { + [RollModCategory.roll]: { + Assist: false, + Group_1: false, + Group_2: false, + Group_3: false, + Group_4: false, + Group_5: false, + Group_6: false + }, + [RollModCategory.position]: { + Setup: false + }, + [RollModCategory.effect]: { + Setup: false + } + }, + rollFactorToggles: { + source: { + [Factor.tier]: { + display: "", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + }, + [Factor.quality]: { + display: "", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + }, + [Factor.scale]: { + display: "", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + }, + [Factor.magnitude]: { + display: "", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + } + }, + opposition: { + [Factor.tier]: { + display: "", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + }, + [Factor.quality]: { + display: "", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + }, + [Factor.scale]: { + display: "", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + }, + [Factor.magnitude]: { + display: "", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + } + } + } + }; + } + + static Current = {}; + static _Active; + static get Active() { + if (BladesRollCollab._Active) { + return BladesRollCollab._Active; + } + if (U.objSize(BladesRollCollab.Current) > 0) { + return Object.values(BladesRollCollab.Current)[0]; + } + return undefined; + } + static set Active(val) { + BladesRollCollab._Active = val; + } + + static async RenderRollCollab({ userID, rollID }) { + const user = game.users.get(userID); + if (!user) { + return; + } + await BladesRollCollab.Current[rollID]._render(true); + } + static async CloseRollCollab(rollID) { + eLog.checkLog3("rollCollab", "CloseRollCollab()", { rollID }); + await BladesRollCollab.Current[rollID]?.close({ rollID }); + delete BladesRollCollab.Current[rollID]; + } + static async NewRoll(config) { + if (game.user.isGM && BladesActor.IsType(config.rollPrimary, BladesActorType.pc)) { + const rSource = config.rollPrimary; + if (rSource.primaryUser?.id) { + config.userID = rSource.primaryUser.id; + } + } + const user = game.users.get(config.userID ?? game.user._id); + if (!(user instanceof User)) { + eLog.error("rollCollab", `[NewRoll()] Can't Find User '${config.userID}'`, config); + return; + } + await user.unsetFlag(C.SYSTEM_ID, "rollCollab"); + const flagUpdateData = { ...BladesRollCollab.DefaultFlagData }; + flagUpdateData.rollType = config.rollType; + if (!(flagUpdateData.rollType in RollType)) { + eLog.error("rollCollab", `[RenderRollCollab()] Invalid rollType: ${flagUpdateData.rollType}`, config); + return; + } + const rollPrimaryData = config.rollPrimary ?? user.character; + if (!rollPrimaryData) { + eLog.error("rollCollab", "[RenderRollCollab()] Invalid rollPrimary", { rollPrimaryData, config }); + return; + } + if (U.isInt(config.rollTrait)) { + flagUpdateData.rollTrait = config.rollTrait; + } + else if (!config.rollTrait) { + eLog.error("rollCollab", "[RenderRollCollab()] No RollTrait in Config", config); + return; + } + else { + switch (flagUpdateData.rollType) { + case RollType.Action: { + if (!(U.lCase(config.rollTrait) in { ...ActionTrait, ...Factor })) { + eLog.error("rollCollab", `[RenderRollCollab()] Bad RollTrait for Action Roll: ${config.rollTrait}`, config); + return; + } + flagUpdateData.rollTrait = U.lCase(config.rollTrait); + break; + } + case RollType.Downtime: { + if (!(U.lCase(config.rollTrait) in { ...ActionTrait, ...Factor })) { + eLog.error("rollCollab", `[RenderRollCollab()] Bad RollTrait for Downtime Roll: ${config.rollTrait}`, config); + return; + } + flagUpdateData.rollTrait = U.lCase(config.rollTrait); + break; + } + case RollType.Fortune: { + if (!(U.lCase(config.rollTrait) in { ...ActionTrait, ...AttributeTrait, ...Factor })) { + eLog.error("rollCollab", `[RenderRollCollab()] Bad RollTrait for Fortune Roll: ${config.rollTrait}`, config); + return; + } + flagUpdateData.rollTrait = U.lCase(config.rollTrait); + break; + } + case RollType.Resistance: { + if (!(U.lCase(config.rollTrait) in AttributeTrait)) { + eLog.error("rollCollab", `[RenderRollCollab()] Bad RollTrait for Resistance Roll: ${config.rollTrait}`, config); + return; + } + break; + } + default: throw new Error(`Unrecognized RollType: ${flagUpdateData.rollType}`); + } + flagUpdateData.rollTrait = U.lCase(config.rollTrait); + } + await user.setFlag(C.SYSTEM_ID, "rollCollab", flagUpdateData); + BladesRollCollab.RenderRollCollab({ userID: user._id, rollID: flagUpdateData.rollID }); + socketlib.system.executeForAllGMs("renderRollCollab", { userID: user._id, rollID: flagUpdateData.rollID }); + } + userID; + rollID; + constructor(user, primaryData, oppData) { + if (!user || !user.id) { + throw new Error(`Unable to retrieve user id from user '${user}'`); + } + super(user); + this.userID = user.id; + this.rollID = randomID(); + if (primaryData) { + this.rollPrimary = new BladesRollPrimary(this, primaryData); + } + if (oppData) { + this.rollOpposition = new BladesRollOpposition(this, oppData); + } + } + + get rData() { + if (!this.document.getFlag(C.SYSTEM_ID, "rollCollab")) { + throw new Error("[get flags()] No RollCollab Flags Found on User"); + } + return this.document.getFlag(C.SYSTEM_ID, "rollCollab"); + } + _rollPrimary; + get rollPrimary() { + if (this._rollPrimary instanceof BladesRollPrimary) { + return this._rollPrimary; + } + return undefined; + } + set rollPrimary(val) { + if (val === undefined) { + this._rollPrimary = undefined; + } + else { + this._rollPrimary = val; + } + } + _rollOpposition; + get rollOpposition() { + if (this._rollOpposition instanceof BladesRollOpposition) { + return this._rollOpposition; + } + return undefined; + } + set rollOpposition(val) { + if (val === undefined) { + this._rollOpposition = undefined; + } + else { + this._rollOpposition = val; + } + } + _rollParticipants = []; + get rollType() { return this.rData.rollType; } + get rollSubType() { return this.rData.rollSubType; } + get rollDowntimeAction() { return this.rData.rollDowntimeAction; } + get rollTrait() { return this.rData.rollTrait; } + _rollTraitValOverride; + get rollTraitValOverride() { return this._rollTraitValOverride; } + set rollTraitValOverride(val) { this._rollTraitValOverride = val; } + get rollTraitData() { + const { rollPrimaryDoc } = this.rollPrimary ?? {}; + if (!BladesRollPrimary.IsDoc(rollPrimaryDoc)) { + throw new Error("[get rollTraitData()] Missing Roll Primary!"); + } + if (BladesActor.IsType(rollPrimaryDoc, BladesActorType.pc)) { + if (isAction(this.rollTrait)) { + return { + name: this.rollTrait, + value: this.rollTraitValOverride ?? rollPrimaryDoc.actions[this.rollTrait], + max: this.rollTraitValOverride ?? rollPrimaryDoc.actions[this.rollTrait], + pcTooltip: rollPrimaryDoc.rollTraitPCTooltipActions, + gmTooltip: C.ActionTooltipsGM[this.rollTrait] + }; + } + if (isAttribute(this.rollTrait)) { + return { + name: this.rollTrait, + value: this.rollTraitValOverride ?? rollPrimaryDoc.attributes[this.rollTrait], + max: this.rollTraitValOverride ?? rollPrimaryDoc.attributes[this.rollTrait], + pcTooltip: rollPrimaryDoc.rollTraitPCTooltipAttributes, + gmTooltip: C.AttributeTooltips[this.rollTrait] + }; + } + } + if (U.isInt(this.rollTrait)) { + return { + name: `+${this.rollTraitValOverride ?? this.rollTrait}`, + value: this.rollTraitValOverride ?? this.rollTrait, + max: this.rollTraitValOverride ?? this.rollTrait + }; + } + if (isFactor(this.rollTrait)) { + return { + name: U.tCase(this.rollTrait), + value: this.rollTraitValOverride ?? this.rollPrimary?.rollFactors[this.rollTrait]?.value ?? 0, + max: this.rollTraitValOverride ?? this.rollPrimary?.rollFactors[this.rollTrait]?.max ?? 10 + }; + } + throw new Error(`[get rollTraitData] Invalid rollTrait: '${this.rollTrait}'`); + } + get rollTraitOptions() { + if (BladesActor.IsType(this.rollPrimary, BladesActorType.pc)) { + if (isAction(this.rollTrait)) { + return Object.values(ActionTrait) + .map((action) => ({ + name: U.uCase(action), + value: action + })); + } + if (isAttribute(this.rollTrait)) { + return Object.values(AttributeTrait) + .map((attribute) => ({ + name: U.uCase(attribute), + value: attribute + })); + } + } + if (U.isInt(this.rollTrait)) { + return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + .map((num) => ({ + name: `+${num}`, + value: num + })); + } + if (isFactor(this.rollTrait)) { + return []; + } + throw new Error(`[get rollTraitOptions] Invalid rollTrait: '${this.rollTrait}'`); + } + get posEffectTrade() { + return this.rData?.rollPosEffectTrade ?? false; + } + get initialPosition() { + return this.rData?.rollPositionInitial ?? Position.risky; + } + set initialPosition(val) { + this.document.setFlag(C.SYSTEM_ID, "rollCollab.rollPositionInitial", val); + } + get initialEffect() { + return this.rData?.rollEffectInitial ?? Effect.standard; + } + set initialEffect(val) { + this.document.setFlag(C.SYSTEM_ID, "rollCollab.rollEffectInitial", val); + } + get rollConsequence() { + return this.rData?.rollConsequence; + } + + get finalPosition() { + return Object.values(Position)[U.clampNum(Object.values(Position) + .indexOf(this.initialPosition) + + this.getModsDelta(RollModCategory.position) + + (this.posEffectTrade === "position" ? 1 : 0) + + (this.posEffectTrade === "effect" ? -1 : 0), [0, 2])]; + } + get finalEffect() { + return Object.values(Effect)[U.clampNum(Object.values(Effect) + .indexOf(this.initialEffect) + + this.getModsDelta(RollModCategory.effect) + + (this.posEffectTrade === "effect" ? 1 : 0) + + (this.posEffectTrade === "position" ? -1 : 0), [0, 4])]; + } + get finalResult() { + return this.getModsDelta(RollModCategory.result) + + (this.rData?.GMBoosts.Result ?? 0) + + (this.tempGMBoosts.Result ?? 0); + } + get finalDicePool() { + return Math.max(0, this.rollTraitData.value + + this.getModsDelta(RollModCategory.roll) + + (this.rData.GMBoosts.Dice ?? 0) + + (this.tempGMBoosts.Dice ?? 0)); + } + get isRollingZero() { + return Math.max(0, this.rollTraitData.value + + this.getModsDelta(RollModCategory.roll) + + (this.rData.GMBoosts.Dice ?? 0) + + (this.tempGMBoosts.Dice ?? 0)) <= 0; + } + _roll; + get roll() { + this._roll ??= new Roll(`${this.isRollingZero ? 2 : this.finalDicePool}d6`, {}); + return this._roll; + } + get rollFactors() { + const sourceFactors = Object.fromEntries(Object.entries(this.rollPrimary?.rollFactors ?? {}) + .map(([factor, factorData]) => [ + factor, + { + ...factorData, + ...this.rData.rollFactorToggles.source[factor] ?? [] + } + ])); + Object.entries(this.rData.rollFactorToggles.source).forEach(([factor, factorData]) => { + if (!(factor in sourceFactors)) { + sourceFactors[factor] = { + name: factor, + value: 0, + max: 0, + baseVal: 0, + cssClasses: "factor-gold", + isActive: factorData.isActive ?? false, + isPrimary: factorData.isPrimary ?? (factor === Factor.tier), + isDominant: factorData.isDominant ?? false, + highFavorsPC: factorData.highFavorsPC ?? true + }; + } + }); + Object.keys(sourceFactors) + .filter(isFactor) + .forEach((factor) => { + const factorData = sourceFactors[factor]; + if (!factorData) { + return; + } + factorData.value ??= 0; + factorData.value + += (this.rData.GMBoosts[factor] ?? 0) + + (this.tempGMBoosts[factor] ?? 0); + }); + const rollOppFactors = this.rollOpposition?.rollFactors + ?? Object.fromEntries(([ + Factor.tier, + Factor.quality, + Factor.scale, + Factor.magnitude + ]).map((factor) => [ + factor, + { + name: factor, + value: 0, + max: 0, + baseVal: 0, + cssClasses: "factor-gold", + isActive: false, + isPrimary: factor === Factor.tier, + isDominant: false, + highFavorsPC: true + } + ])); + const oppFactors = {}; + Object.entries(rollOppFactors) + .forEach(([factor, factorData]) => { + if (!isFactor(factor)) { + return; + } + oppFactors[factor] = { + ...factorData, + ...this.rData.rollFactorToggles.opposition[factor] ?? [] + }; + }); + Object.entries(this.rData.rollFactorToggles.opposition) + .forEach(([factor, factorData]) => { + if (!isFactor(factor)) { + return; + } + if (!(factor in oppFactors)) { + oppFactors[factor] = { + name: factor, + value: 0, + max: 0, + baseVal: 0, + cssClasses: "factor-gold", + isActive: factorData.isActive ?? false, + isPrimary: factorData.isPrimary ?? (factor === Factor.tier), + isDominant: factorData.isDominant ?? false, + highFavorsPC: factorData.highFavorsPC ?? true + }; + } + }); + Object.keys(oppFactors).forEach((factor) => { + if (!isFactor(factor)) { + return; + } + const factorData = oppFactors[factor]; + if (!factorData) { + return; + } + factorData.value += this.rData.GMOppBoosts[factor] ?? 0; + if (factor === Factor.tier) { + factorData.display = U.romanizeNum(factorData.value); + } + else { + factorData.display = `${factorData.value}`; + } + }); + return { + source: sourceFactors, + opposition: oppFactors + }; + } + initRollMods(modsData) { + this.rollTraitValOverride = undefined; + this.rollFactorPenaltiesNegated = {}; + this.tempGMBoosts = {}; + this.rollMods = modsData.map((modData) => new BladesRollMod(modData, this)); + const initReport = {}; + + this.rollMods + .filter((rollMod) => !rollMod.setConditionalStatus()) + .filter((rollMod) => !rollMod.setAutoStatus()) + .forEach((rollMod) => { rollMod.setPayableStatus(); }); + const parseForceOnKeys = (mod) => { + const holdKeys = mod.effectKeys.filter((key) => /^ForceOn/.test(key)); + if (holdKeys.length === 0) { + return; + } + while (holdKeys.length) { + const thisTarget = holdKeys.pop()?.split(/-/)?.pop(); + if (thisTarget === "BestAction") { + const { rollPrimaryDoc } = this.rollPrimary ?? {}; + if (BladesActor.IsType(rollPrimaryDoc, BladesActorType.pc)) { + this.rollTraitValOverride = Math.max(...Object.values(rollPrimaryDoc.actions)); + } + } + else { + const [targetName, targetCat, targetPosNeg] = thisTarget?.split(/,/) ?? []; + if (!targetName) { + throw new Error(`No targetName found in thisTarget: ${thisTarget}.`); + } + let targetMod = this.getRollModByName(targetName) + ?? this.getRollModByName(targetName, targetCat ?? mod.category); + if (!targetMod && targetName === "Push") { + [targetMod] = [ + ...this.getActiveBasicPushMods(targetCat ?? mod.category, "negative").filter((m) => m.status === RollModStatus.ToggledOn), + ...this.getActiveBasicPushMods(targetCat ?? mod.category, "positive").filter((m) => m.status === RollModStatus.ToggledOn), + ...this.getInactiveBasicPushMods(targetCat ?? mod.category, "positive").filter((m) => m.status === RollModStatus.ToggledOff) + ]; + } + targetMod ??= this.getRollModByName(targetName, targetCat ?? mod.category, targetPosNeg ?? mod.posNeg); + if (!targetMod) { + throw new Error(`No mod found matching ${targetName}/${targetCat}/${targetPosNeg}`); + } + if (!targetMod.isActive) { + targetMod.heldStatus = RollModStatus.ForcedOn; + parseForceOnKeys(targetMod); + } + else { + targetMod.heldStatus = RollModStatus.ForcedOn; + } + } + } + }; + this.getActiveRollMods().forEach((rollMod) => parseForceOnKeys(rollMod)); + + if (this.isForcePushed()) { + this.getInactivePushMods() + .filter((mod) => !mod.isBasicPush) + .forEach((mod) => { mod.heldStatus = RollModStatus.ForcedOff; }); + } + [RollModCategory.roll, RollModCategory.effect].forEach((cat) => { + if (this.isPushed(cat)) { + if (cat === RollModCategory.roll && this.isPushed(cat, "positive")) { + const bargainMod = this.getRollModByID("Bargain-positive-roll"); + if (bargainMod?.isVisible) { + bargainMod.heldStatus = RollModStatus.ForcedOff; + } + } + } + else { + this.getInactivePushMods(cat) + .filter((mod) => !mod.isBasicPush) + .forEach((mod) => { mod.heldStatus = RollModStatus.Hidden; }); + } + }); + + this.getVisibleRollMods() + .forEach((mod) => { mod.setRelevancyStatus(); }); + + const activeArmorCostMod = this.getActiveRollMods().find((mod) => mod.effectKeys.includes("Cost-SpecialArmor")); + if (activeArmorCostMod) { + this.getVisibleRollMods() + .filter((mod) => !mod.isActive && mod.effectKeys.includes("Cost-SpecialArmor")) + .forEach((mod) => { mod.heldStatus = RollModStatus.ForcedOff; }); + } + eLog.checkLog2("rollMods", "*** initRollMods() PASS ***", initReport); + } + isTraitRelevant(trait) { + if (trait in Factor) { + const { source, opposition } = this.rollFactors; + return Boolean(trait in source && trait in opposition && source[trait]?.isActive); + } + return false; + } + rollFactorPenaltiesNegated = {}; + negateFactorPenalty(factor) { + this.rollFactorPenaltiesNegated[factor] = true; + } + tempGMBoosts = {}; + isPushed(cat, posNeg) { return this.getActiveBasicPushMods(cat, posNeg).length > 0; } + hasOpenPush(cat, posNeg) { return this.isPushed(cat) && this.getOpenPushMods(cat, posNeg).length > 0; } + isForcePushed(cat, posNeg) { return this.isPushed(cat) && this.getForcedPushMods(cat, posNeg).length > 0; } + get rollCosts() { + if (!this.isPushed) { + return 0; + } + const harmPush = this.getRollModByID("Push-negative-roll"); + const rollPush = this.getRollModByID("Push-positive-roll"); + const effectPush = this.getRollModByID("Push-positive-effect"); + const negatePushCostMods = this.getActiveRollMods(RollModCategory.after, "positive") + .filter((mod) => mod.effectKeys.includes("Negate-PushCost")); + return ((harmPush?.isActive && harmPush?.stressCost) || 0) + + ((rollPush?.isActive && rollPush?.stressCost) || 0) + + ((effectPush?.isActive && effectPush?.stressCost) || 0) + - (negatePushCostMods.length * 2); + } + get rollCostData() { + return this.getActiveRollMods() + .map((rollMod) => rollMod.costs ?? []) + .flat(); + } + getRollModByName(name, cat, posNeg) { + const modMatches = this.rollMods.filter((rollMod) => { + if (U.lCase(rollMod.name) !== U.lCase(name)) { + return false; + } + if (cat && rollMod.category !== cat) { + return false; + } + if (posNeg && rollMod.posNeg !== posNeg) { + return false; + } + return true; + }); + if (modMatches.length === 0) { + return undefined; + } + if (modMatches.length > 1) { + return undefined; + } + return modMatches[0]; + } + getRollModByID(id) { return this.rollMods.find((rollMod) => rollMod.id === id); } + getRollMods(cat, posNeg) { + return this.rollMods.filter((rollMod) => (!cat || rollMod.category === cat) + && (!posNeg || rollMod.posNeg === posNeg)); + } + getVisibleRollMods(cat, posNeg) { + return this.getRollMods(cat, posNeg).filter((rollMod) => rollMod.isVisible); + } + getActiveRollMods(cat, posNeg) { + return this.getRollMods(cat, posNeg).filter((rollMod) => rollMod.isActive); + } + getVisibleInactiveRollMods(cat, posNeg) { + return this.getVisibleRollMods(cat, posNeg).filter((rollMod) => !rollMod.isActive); + } + getPushMods(cat, posNeg) { + return this.getRollMods(cat, posNeg).filter((rollMod) => rollMod.isPush); + } + getVisiblePushMods(cat, posNeg) { + return this.getPushMods(cat, posNeg).filter((rollMod) => rollMod.isVisible); + } + getActivePushMods(cat, posNeg) { + return this.getVisiblePushMods(cat, posNeg).filter((rollMod) => rollMod.isActive); + } + getActiveBasicPushMods(cat, posNeg) { + return this.getActivePushMods(cat, posNeg).filter((rollMod) => rollMod.isBasicPush); + } + getInactivePushMods(cat, posNeg) { + return this.getVisiblePushMods(cat, posNeg).filter((rollMod) => !rollMod.isActive); + } + getInactiveBasicPushMods(cat, posNeg) { + return this.getInactivePushMods(cat, posNeg).filter((rollMod) => rollMod.isBasicPush); + } + getForcedPushMods(cat, posNeg) { + return this.getActivePushMods(cat, posNeg) + .filter((rollMod) => rollMod.isBasicPush + && rollMod.status === RollModStatus.ForcedOn); + } + getOpenPushMods(cat, posNeg) { + return this.getActivePushMods(cat, posNeg) + .filter((rollMod) => rollMod.isBasicPush + && rollMod.status === RollModStatus.ToggledOn); + } + getModsDelta = (cat) => { + return U.sum([ + ...this.getActiveRollMods(cat, "positive").map((mod) => mod.value), + ...this.getActiveRollMods(cat, "negative").map((mod) => -mod.value) + ]); + }; + _rollMods; + compareMods(modA, modB) { + const modOrder = ["Bargain", "Assist", "Setup"]; + if (modA.isBasicPush) { + return -1; + } + if (modB.isBasicPush) { + return 1; + } + if (modA.name === "Bargain" && modA.isActive) { + return -1; + } + if (modB.name === "Bargain" && modB.isActive) { + return 1; + } + if (modA.isPush) { + return -1; + } + if (modB.isPush) { + return 1; + } + const modAIndex = modOrder.indexOf(modA.name); + const modBIndex = modOrder.indexOf(modB.name); + if (modAIndex !== -1 && modBIndex !== -1) { + return modAIndex - modBIndex; + } + return modA.name.localeCompare(modB.name); + } + get rollMods() { + if (!this._rollMods) { + throw new Error("[get rollMods] No roll mods found!"); + } + return [...this._rollMods].sort((modA, modB) => this.compareMods(modA, modB)); + } + set rollMods(val) { this._rollMods = val; } + + + async getData() { + const context = super.getData(); + if (!this.rollPrimary) { + throw new Error("No roll source configured for roll."); + } + this.initRollMods(this.getRollModsData()); + this.rollMods.forEach((rollMod) => rollMod.applyRollModEffectKeys()); + const sheetData = this.getSheetData(this.getIsGM(), this.getRollCosts()); + return { ...context, ...sheetData }; + } + getRollModsData() { + const defaultMods = [ + ...BladesRollCollab.DefaultRollMods, + ...this.rollPrimary?.rollModsData ?? [] + ]; + if (this.rollOpposition?.rollOppModsData) { + return [ + ...defaultMods, + ...this.rollOpposition.rollOppModsData + ]; + } + return defaultMods; + } + getIsGM() { + return game.eunoblades.Tracker?.system.is_spoofing_player ? false : game.user.isGM; + } + getRollCosts() { + return this.getActiveRollMods() + .map((rollMod) => rollMod.costs) + .flat() + .filter((costData) => costData !== undefined); + } + getStressCosts(rollCosts) { + return rollCosts.filter((costData) => costData.costType === "Stress"); + } + getTotalStressCost(stressCosts) { + return U.sum(stressCosts.map((costData) => costData.costAmount)); + } + getSpecArmorCost(rollCosts) { + return rollCosts.find((costData) => costData.costType === "SpecialArmor"); + } + getSheetData(isGM, rollCosts) { + const { rData, rollPrimary, rollTraitData, rollTraitOptions, finalDicePool, finalPosition, finalEffect, finalResult, rollMods, rollFactors } = this; + if (!rollPrimary) { + throw new Error("A primary roll source is required for BladesRollCollab."); + } + const baseData = { + ...this.rData, + cssClass: "roll-collab", + editable: this.options.editable, + isGM, + system: this.rollPrimary?.rollPrimaryDoc?.system, + rollMods, + rollPrimary, + rollTraitData, + rollTraitOptions, + diceTotal: finalDicePool, + rollOpposition: this.rollOpposition, + rollEffects: Object.values(Effect), + teamworkDocs: game.actors.filter((actor) => BladesActor.IsType(actor, BladesActorType.pc)), + rollTraitValOverride: this.rollTraitValOverride, + rollFactorPenaltiesNegated: this.rollFactorPenaltiesNegated, + posRollMods: Object.fromEntries(Object.values(RollModCategory) + .map((cat) => [cat, this.getRollMods(cat, "positive")])), + negRollMods: Object.fromEntries(Object.values(RollModCategory) + .map((cat) => [cat, this.getRollMods(cat, "negative")])), + hasInactiveConditionals: this.calculateHasInactiveConditionalsData(), + rollFactors, + oddsGradient: this.calculateOddsGradient(finalDicePool, finalResult), + costData: this.parseCostsHTML(this.getStressCosts(rollCosts), this.getSpecArmorCost(rollCosts)) + }; + const rollPositionData = this.calculatePositionData(finalPosition); + const rollEffectData = this.calculateEffectData(isGM, finalEffect); + const rollResultData = this.calculateResultData(isGM, finalResult); + const GMBoostsData = this.calculateGMBoostsData(rData); + const positionEffectTradeData = this.calculatePositionEffectTradeData(); + return { + ...baseData, + ...rollPositionData, + ...rollEffectData, + ...rollResultData, + ...GMBoostsData, + ...positionEffectTradeData + }; + } + calculatePositionData(finalPosition) { + return { + rollPositions: Object.values(Position), + rollPositionFinal: finalPosition + }; + } + calculateEffectData(isGM, finalEffect) { + return { + rollEffects: Object.values(Effect), + rollEffectFinal: finalEffect, + isAffectingAfter: this.getVisibleRollMods(RollModCategory.after).length > 0 + || (isGM && this.getRollMods(RollModCategory.after).length > 0) + }; + } + calculateResultData(isGM, finalResult) { + return { + rollResultFinal: finalResult, + isAffectingResult: finalResult > 0 + || this.getVisibleRollMods(RollModCategory.result).length > 0 + || (isGM && this.getRollMods(RollModCategory.result).length > 0) + }; + } + calculateGMBoostsData(flagData) { + return { + GMBoosts: { + Dice: flagData.GMBoosts.Dice ?? 0, + [Factor.tier]: flagData.GMBoosts[Factor.tier] ?? 0, + [Factor.quality]: flagData.GMBoosts[Factor.quality] ?? 0, + [Factor.scale]: flagData.GMBoosts[Factor.scale] ?? 0, + [Factor.magnitude]: flagData.GMBoosts[Factor.magnitude] ?? 0, + Result: flagData.GMBoosts.Result ?? 0 + }, + GMOppBoosts: { + [Factor.tier]: flagData.GMOppBoosts[Factor.tier] ?? 0, + [Factor.quality]: flagData.GMOppBoosts[Factor.quality] ?? 0, + [Factor.scale]: flagData.GMOppBoosts[Factor.scale] ?? 0, + [Factor.magnitude]: flagData.GMOppBoosts[Factor.magnitude] ?? 0 + } + }; + } + + calculateOddsGradient(diceTotal, finalResult) { + const oddsColors = { + crit: "var(--blades-cyan)", + success: "var(--blades-gold)", + partial: "var(--blades-grey-bright)", + fail: "var(--blades-black-dark)" + }; + const odds = { ...C.DiceOdds[diceTotal] }; + if (finalResult < 0) { + for (let i = finalResult; i < 0; i++) { + oddsColors.crit = oddsColors.success; + oddsColors.success = oddsColors.partial; + oddsColors.partial = oddsColors.fail; + } + } + else if (finalResult > 0) { + for (let i = 0; i < finalResult; i++) { + oddsColors.fail = oddsColors.partial; + oddsColors.partial = oddsColors.success; + oddsColors.success = oddsColors.crit; + } + } + const gradientStops = { + fail: odds.fail, + partial: odds.fail + odds.partial, + success: odds.fail + odds.partial + odds.success + }; + gradientStops.fail = Math.min(100, Math.max(0, Math.max(gradientStops.fail / 2, gradientStops.fail - 10))); + const critSpan = 100 - gradientStops.success; + gradientStops.success = Math.min(100, Math.max(0, gradientStops.success - Math.max(critSpan / 2, critSpan - 10))); + return [ + "linear-gradient(to right", + `${oddsColors.fail} ${gradientStops.fail}%`, + `${oddsColors.partial} ${gradientStops.partial}%`, + `${oddsColors.success} ${gradientStops.success}%`, + `${oddsColors.crit})` + ].join(", "); + } + calculatePositionEffectTradeData() { + const canTradePosition = this.posEffectTrade === "position" || (this.posEffectTrade === false + && this.finalPosition !== Position.desperate + && this.finalEffect !== Effect.extreme); + const canTradeEffect = this.posEffectTrade === "effect" || (this.posEffectTrade === false + && this.finalPosition !== Position.controlled + && this.finalEffect !== Effect.zero); + return { canTradePosition, canTradeEffect }; + } + calculateHasInactiveConditionalsData() { + const hasInactive = {}; + for (const category of Object.values(RollModCategory)) { + hasInactive[category] = this.getRollMods(category).filter((mod) => mod.isInInactiveBlock).length > 0; + } + return hasInactive; + } + parseCostsHTML(stressCosts, specArmorCost) { + if (specArmorCost || stressCosts.length > 0) { + const totalStressCost = this.getTotalStressCost(stressCosts); + return { + footerLabel: [ + "( Roll Costs", + totalStressCost > 0 ? `${totalStressCost} Stress` : null, + specArmorCost && totalStressCost ? "and" : null, + specArmorCost ? "your Special Armor" : null, + ")" + ].filter((line) => Boolean(line)).join(" "), + tooltip: [ + "

Roll Costs

" + ].filter((line) => Boolean(line)).join("") + }; + } + return undefined; + } + async OLDgetData() { + const context = super.getData(); + const rData = this.rData; + if (!this.rollPrimary) { + throw new Error("No roll source configured for roll."); + } + const rollModsData = [ + ...BladesRollCollab.DefaultRollMods, + ...this.rollPrimary.rollModsData ?? [] + ]; + if (this.rollOpposition?.rollOppModsData) { + rollModsData.push(...this.rollOpposition.rollOppModsData); + } + this.initRollMods(rollModsData); + this.rollMods.forEach((rollMod) => rollMod.applyRollModEffectKeys()); + const isGM = game.eunoblades.Tracker?.system.is_spoofing_player ? false : game.user.isGM; + const { rollPrimary, rollOpposition, rollTraitData, rollTraitOptions, finalPosition, finalEffect, finalResult, rollMods, posEffectTrade, rollFactors } = this; + const rollCosts = this.getActiveRollMods() + .map((rollMod) => rollMod.costs) + .flat() + .filter((costData) => costData !== undefined); + const stressCosts = rollCosts.filter((costData) => costData.costType === "Stress"); + const specArmorCost = rollCosts.find((costData) => costData.costType === "SpecialArmor"); + const totalStressCost = U.sum(stressCosts.map((costData) => costData.costAmount)); + const sheetData = { + ...rData, + cssClass: "roll-collab", + editable: this.options.editable, + isGM, + system: this.rollPrimary.rollPrimaryDoc?.system, + rollMods, + rollPrimary, + rollTraitData, + rollTraitOptions, + diceTotal: this.finalDicePool, + rollOpposition, + rollPositions: Object.values(Position), + rollEffects: Object.values(Effect), + teamworkDocs: game.actors.filter((actor) => BladesActor.IsType(actor, BladesActorType.pc)), + rollPositionFinal: finalPosition, + rollEffectFinal: finalEffect, + rollResultFinal: finalResult, + isAffectingResult: finalResult > 0 + || this.getVisibleRollMods(RollModCategory.result).length > 0 + || (isGM && this.getRollMods(RollModCategory.result).length > 0), + isAffectingAfter: this.getVisibleRollMods(RollModCategory.after).length > 0 + || (isGM && this.getRollMods(RollModCategory.after).length > 0), + rollFactorPenaltiesNegated: this.rollFactorPenaltiesNegated, + GMBoosts: { + Dice: this.rData.GMBoosts.Dice ?? 0, + [Factor.tier]: this.rData.GMBoosts[Factor.tier] ?? 0, + [Factor.quality]: this.rData.GMBoosts[Factor.quality] ?? 0, + [Factor.scale]: this.rData.GMBoosts[Factor.scale] ?? 0, + [Factor.magnitude]: this.rData.GMBoosts[Factor.magnitude] ?? 0, + Result: this.rData.GMBoosts.Result ?? 0 + }, + GMOppBoosts: { + [Factor.tier]: this.rData.GMOppBoosts[Factor.tier] ?? 0, + [Factor.quality]: this.rData.GMOppBoosts[Factor.quality] ?? 0, + [Factor.scale]: this.rData.GMOppBoosts[Factor.scale] ?? 0, + [Factor.magnitude]: this.rData.GMOppBoosts[Factor.magnitude] ?? 0 + }, + canTradePosition: posEffectTrade === "position" + || (posEffectTrade === false + && finalPosition !== Position.desperate + && finalEffect !== Effect.extreme), + canTradeEffect: posEffectTrade === "effect" + || (posEffectTrade === false + && finalPosition !== Position.controlled + && finalEffect !== Effect.zero), + posRollMods: Object.fromEntries(Object.values(RollModCategory) + .map((cat) => [cat, this.getRollMods(cat, "positive")])), + negRollMods: Object.fromEntries(Object.values(RollModCategory) + .map((cat) => [cat, this.getRollMods(cat, "negative")])), + hasInactiveConditionals: { + [RollModCategory.roll]: this.getRollMods(RollModCategory.roll) + .filter((mod) => mod.isInInactiveBlock) + .length > 0, + [RollModCategory.position]: this.getRollMods(RollModCategory.position) + .filter((mod) => mod.isInInactiveBlock) + .length > 0, + [RollModCategory.effect]: this.getRollMods(RollModCategory.effect) + .filter((mod) => mod.isInInactiveBlock) + .length > 0, + [RollModCategory.result]: this.getRollMods(RollModCategory.result) + .filter((mod) => mod.isInInactiveBlock) + .length > 0, + [RollModCategory.after]: this.getRollMods(RollModCategory.after) + .filter((mod) => mod.isInInactiveBlock) + .length > 0 + }, + rollFactors, + oddsGradient: "" + }; + if (specArmorCost || totalStressCost) { + sheetData.costData = { + footerLabel: [ + "( Roll Costs", + totalStressCost > 0 + ? `${totalStressCost} Stress` + : null, + specArmorCost && totalStressCost + ? "and" + : null, + specArmorCost + ? "your Special Armor" + : null, + ")" + ] + .filter((line) => Boolean(line)) + .join(" "), + tooltip: [ + "

Roll Costs

" + ] + .filter((line) => Boolean(line)) + .join("") + }; + } + const oddsColors = { + crit: "var(--blades-cyan)", + success: "var(--blades-gold)", + partial: "var(--blades-grey-bright)", + fail: "var(--blades-black-dark)" + }; + const odds = { ...C.DiceOdds[sheetData.diceTotal ?? 0] }; + if ((sheetData.rollResultFinal ?? 0) < 0) { + for (let i = sheetData.rollResultFinal ?? 0; i < 0; i++) { + oddsColors.crit = oddsColors.success; + oddsColors.success = oddsColors.partial; + oddsColors.partial = oddsColors.fail; + } + } + else if ((sheetData.rollResultFinal ?? 0) > 0) { + for (let i = 0; i < (sheetData.rollResultFinal ?? 0); i++) { + oddsColors.fail = oddsColors.partial; + oddsColors.partial = oddsColors.success; + oddsColors.success = oddsColors.crit; + } + } + const gradientStops = { + fail: odds.fail, + partial: odds.fail + odds.partial, + success: odds.fail + odds.partial + odds.success + }; + gradientStops.fail = Math.min(100, Math.max(0, Math.max(gradientStops.fail / 2, gradientStops.fail - 10))); + const critSpan = 100 - gradientStops.success; + gradientStops.success = Math.min(100, Math.max(0, gradientStops.success - Math.max(critSpan / 2, critSpan - 10))); + sheetData.oddsGradient = [ + "linear-gradient(to right", + `${oddsColors.fail} ${gradientStops.fail}%`, + `${oddsColors.partial} ${gradientStops.partial}%`, + `${oddsColors.success} ${gradientStops.success}%`, + `${oddsColors.crit})` + ].join(", "); + sheetData.oddsGradientTestHTML = [ + "
", + `
`, + `
`, + `
`, + `
`, + "
" + ].join(""); + return { + ...context, + ...sheetData + }; + } + + _dieVals; + get dieVals() { + this._dieVals ??= this.roll.terms[0].results + .map((result) => result.result) + .sort() + .reverse(); + return this._dieVals; + } + getDieClass(val, i) { + if (val === 6 && i <= 1 && this.rollResult === RollResult.critical) { + val++; + } + return [ + "", + "blades-die-fail", + "blades-die-fail", + "blades-die-fail", + "blades-die-partial", + "blades-die-partial", + "blades-die-success", + "blades-die-critical" + ][val]; + } + get dieValsHTML() { + const dieVals = [...this.dieVals]; + const ghostNum = this.isRollingZero ? dieVals.shift() : null; + if (this.rollType === RollType.Resistance) { + return [ + ...dieVals.map((val, i) => ``), + ghostNum ? `` : null + ] + .filter((val) => typeof val === "string") + .join(""); + } + else { + return [ + ...dieVals.map(this.getDieClass), + ghostNum ? `` : null + ] + .filter((val) => typeof val === "string") + .join(""); + } + } + get rollResult() { + const dieVals = this.isRollingZero + ? [[...this.dieVals].pop()] + : this.dieVals; + if (dieVals.filter((val) => val === 6).length >= 2) { + return RollResult.critical; + } + if (dieVals.find((val) => val === 6)) { + return RollResult.success; + } + if (dieVals.find((val) => val && val >= 4)) { + return RollResult.partial; + } + return RollResult.fail; + } + async outputRollToChat() { + const speaker = ChatMessage.getSpeaker(); + let renderedHTML; + switch (this.rollType) { + case RollType.Action: { + renderedHTML = await renderTemplate("systems/eunos-blades/templates/chat/action-roll.hbs", { + sourceName: this.rollPrimary?.rollPrimaryName ?? "", + oppName: this.rollOpposition?.rollOppName, + type: U.lCase(this.rollType), + subType: U.lCase(this.rollSubType), + downtimeAction: U.lCase(this.rollDowntimeAction), + position: this.finalPosition, + effect: this.finalEffect, + result: this.rollResult, + trait_label: typeof this.rollTrait === "number" ? `${this.rollTrait} Dice` : U.tCase(this.rollTrait), + dieVals: this.dieValsHTML + }); + break; + } + case RollType.Resistance: { + renderedHTML = await renderTemplate("systems/eunos-blades/templates/chat/resistance-roll.hbs", { + dieVals: this.dieValsHTML, + result: this.rollResult, + trait_label: typeof this.rollTrait === "number" ? `${this.rollTrait} Dice` : U.tCase(this.rollTrait), + stress: this.resistanceStressCost + }); + break; + } + case RollType.Downtime: { + break; + } + case RollType.Fortune: { + break; + } + default: throw new Error(`Unrecognized RollType: ${this.rollType}`); + } + const messageData = { + speaker, + content: renderedHTML, + type: CONST.CHAT_MESSAGE_TYPES.ROLL, + roll: this.roll + }; + CONFIG.ChatMessage.documentClass.create(messageData, {}); + } + async makeRoll() { + await this.roll.evaluate({ async: true }); + await this.outputRollToChat(); + this.close(); + } + + _toggleRollModClick(event) { + event.preventDefault(); + const elem$ = $(event.currentTarget); + const id = elem$.data("id"); + const rollMod = this.getRollModByID(id); + if (!rollMod) { + throw new Error(`Unable to find roll mod with id '${id}'`); + } + switch (rollMod.status) { + case RollModStatus.Hidden: + rollMod.userStatus = RollModStatus.ForcedOff; + return; + case RollModStatus.ForcedOff: + rollMod.userStatus = RollModStatus.ToggledOff; + return; + case RollModStatus.ToggledOff: + rollMod.userStatus = RollModStatus.ToggledOn; + return; + case RollModStatus.ToggledOn: + rollMod.userStatus = game.user.isGM ? RollModStatus.ForcedOn : RollModStatus.ToggledOff; + return; + case RollModStatus.ForcedOn: + rollMod.userStatus = RollModStatus.Hidden; + return; + default: throw new Error(`Unrecognized RollModStatus: ${rollMod.status}`); + } + } + _toggleRollModContext(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const id = elem$.data("id"); + const rollMod = this.getRollModByID(id); + if (!rollMod) { + throw new Error(`Unable to find roll mod with id '${id}'`); + } + switch (rollMod.status) { + case RollModStatus.Hidden: + rollMod.userStatus = RollModStatus.ToggledOff; + return; + case RollModStatus.ForcedOff: + rollMod.userStatus = RollModStatus.Hidden; + return; + case RollModStatus.ToggledOff: + rollMod.userStatus = RollModStatus.ForcedOff; + return; + case RollModStatus.ToggledOn: + rollMod.userStatus = RollModStatus.ToggledOff; + return; + case RollModStatus.ForcedOn: + rollMod.userStatus = RollModStatus.Hidden; + return; + default: throw new Error(`Unrecognized RollModStatus: ${rollMod.status}`); + } + } + _gmControlSet(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const id = elem$.data("id"); + const status = elem$.data("status"); + if (!isModStatus(status)) { + return; + } + const rollMod = this.getRollModByID(id); + if (rollMod) { + rollMod.userStatus = status; + } + } + async _gmControlSetTargetToValue(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const target = elem$.data("target").replace(/flags\.eunos-blades\./, ""); + const value = elem$.data("value"); + await this.document.setFlag(C.SYSTEM_ID, target, value); + } + async _gmControlResetTarget(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const target = elem$.data("target").replace(/flags\.eunos-blades\./, ""); + await this.document.unsetFlag(C.SYSTEM_ID, target); + } + _gmControlReset(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const id = elem$.data("id"); + const rollMod = this.getRollModByID(id); + if (rollMod) { + rollMod.userStatus = undefined; + } + } + async _gmControlSetPosition(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const position = elem$.data("status"); + this.initialPosition = position; + } + async _gmControlSetEffect(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const effect = elem$.data("status"); + this.initialEffect = effect; + } + async _gmControlToggleFactor(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const target = elem$.data("target"); + const value = !elem$.data("value"); + eLog.checkLog3("toggleFactor", "_gmControlToggleFactor", { event, target, value }); + if (value && /isPrimary/.test(target)) { + const [_, thisSource, thisFactor] = target.match(/([^.]+)\.([^.]+)\.isPrimary/); + eLog.checkLog3("toggleFactor", "_gmControlToggleFactor - IN", { thisSource, thisFactor }); + await Promise.all(Object.values(Factor).map((factor) => { + if (factor === thisFactor) { + eLog.checkLog3("toggleFactor", `_gmControlToggleFactor - Checking ${factor} === ${thisFactor} === TRUE`, { factor, thisFactor, target, customTarget: `rollCollab.rollFactorToggles.${thisSource}.${factor}.isPrimary` }); + return this.document.setFlag(C.SYSTEM_ID, `rollCollab.rollFactorToggles.${thisSource}.${factor}.isPrimary`, true); + } + else { + eLog.checkLog3("toggleFactor", `_gmControlToggleFactor - Checking ${factor} === ${thisFactor} === FALSE`, { factor, thisFactor, target, customTarget: `rollCollab.rollFactorToggles.${thisSource}.${factor}.isPrimary` }); + return this.document.setFlag(C.SYSTEM_ID, `rollCollab.rollFactorToggles.${thisSource}.${factor}.isPrimary`, false); + } + })); + eLog.checkLog3("toggleFactor", "_gmControlToggleFactor - ALL DONE", { flags: this.document.getFlag(C.SYSTEM_ID, "rollCollab.rollFactorToggles") }); + } + else { + this.document.setFlag(C.SYSTEM_ID, `rollCollab.${target}`, value); + } + } + async _gmControlResetFactor(event) { + event.preventDefault(); + if (!game.user.isGM) { + return; + } + const elem$ = $(event.currentTarget); + const target = elem$.data("target"); + this.document.unsetFlag(C.SYSTEM_ID, `rollCollab.${target}`); + } + get resistanceStressCost() { + const dieVals = this.dieVals; + if (this.rollResult === RollResult.critical) { + return -1; + } + if (this.isRollingZero) { + dieVals.shift(); + } + return 6 - (dieVals.shift() ?? 0); + } + + activateListeners(html) { + super.activateListeners(html); + ApplyTooltipListeners(html); + html.find(".roll-mod[data-action='toggle']").on({ + click: this._toggleRollModClick.bind(this) + }); + html.find("[data-action='tradePosition']").on({ + click: (event) => { + const curVal = `${$(event.currentTarget).data("value")}`; + if (curVal === "false") { + this.document.setFlag(C.SYSTEM_ID, "rollCollab.rollPosEffectTrade", "effect"); + } + else { + this.document.setFlag(C.SYSTEM_ID, "rollCollab.rollPosEffectTrade", false); + } + } + }); + html.find("[data-action='tradeEffect']").on({ + click: (event) => { + const curVal = `${$(event.currentTarget).data("value")}`; + if (curVal === "false") { + this.document.setFlag(C.SYSTEM_ID, "rollCollab.rollPosEffectTrade", "position"); + } + else { + this.document.setFlag(C.SYSTEM_ID, "rollCollab.rollPosEffectTrade", false); + } + } + }); + html.find("[data-action='roll']").on({ + click: () => this.makeRoll() + }); + if (!game.user.isGM) { + return; + } + html.on({ + focusin: () => { BladesRollCollab.Active = this; } + }); + html.find("[data-action='gm-set'").on({ + click: this._gmControlSet.bind(this) + }); + html.find("[data-action='gm-reset'").on({ + click: this._gmControlReset.bind(this) + }); + html.find("[data-action='gm-set-position'").on({ + click: this._gmControlSetPosition.bind(this) + }); + html.find("[data-action='gm-set-effect'").on({ + click: this._gmControlSetEffect.bind(this) + }); + html.find("[data-action='gm-set-target'").on({ + click: this._gmControlSetTargetToValue.bind(this), + contextmenu: this._gmControlResetTarget.bind(this) + }); + html.find("[data-action='gm-toggle-factor'").on({ + click: this._gmControlToggleFactor.bind(this), + contextmenu: this._gmControlResetFactor.bind(this) + }); + html.find(".controls-toggle").on({ + click: (event) => { + event.preventDefault(); + $(event.currentTarget).parents(".controls-panel").toggleClass("active"); + } + }); + } + + _canDragDrop(selector) { + eLog.checkLog3("canDragDrop", "Can DragDrop Selector", { selector }); + return game.user.isGM; + } + _onDrop(event) { + const data = TextEditor.getDragEventData(event); + const { type, uuid } = data; + const [id] = (uuid.match(new RegExp(`${type}\\.(.+)`)) ?? []).slice(1); + const oppDoc = game[`${U.lCase(type)}s`].get(id); + if (BladesRollOpposition.IsDoc(oppDoc)) { + this.rollOpposition = new BladesRollOpposition(this, { rollOppDoc: oppDoc }); + } + } + async _onSubmit(event, { updateData } = {}) { + return super._onSubmit(event, { updateData, preventClose: true }) + .then((returnVal) => { this.render(); return returnVal; }); + } + async close(options = {}) { + if (options.rollID) { + return super.close({}); + } + this.document.setFlag(C.SYSTEM_ID, "rollCollab", null); + socketlib.system.executeForEveryone("closeRollCollab", this.rollID); + return undefined; + } + render(force = false, options) { + if (!this.document.getFlag(C.SYSTEM_ID, "rollCollab")) { + return this; + } + return super.render(force, options); + } +} +export const BladesRollCollabComps = { + Mod: BladesRollMod, + Primary: BladesRollPrimary, + Opposition: BladesRollOpposition, + Participant: BladesRollParticipant +}; +export default BladesRollCollab; +//# sourceMappingURL=BladesRollCollab.js.map +//# sourceMappingURL=BladesRollCollab.js.map diff --git a/module/blades-actor.js b/module/blades-actor.js index 30415ee6..76bc6420 100644 --- a/module/blades-actor.js +++ b/module/blades-actor.js @@ -7,7 +7,8 @@ import U from "./core/utilities.js"; import C, { BladesActorType, Tag, Playbook, BladesItemType, Action, PrereqType, AdvancementPoint, Randomizers, Factor } from "./core/constants.js"; -import BladesItem from "./blades-item.js"; +import { BladesItem } from "./documents/blades-item-proxy.js"; +import BladesPushController from "./blades-push-notifications.js"; import { SelectionCategory } from "./blades-dialog.js"; class BladesActor extends Actor { @@ -697,7 +698,7 @@ class BladesActor extends Actor { } await this.update({ "system.experience.playbook.value": 0 }); if (BladesActor.IsType(this, BladesActorType.pc)) { - game.eunoblades.PushController?.pushToAll("GM", `${this.name} Advances their Playbook!`, `${this.name}, select a new Ability on your Character Sheet.`); + BladesPushController.Get().pushToAll("GM", `${this.name} Advances their Playbook!`, `${this.name}, select a new Ability on your Character Sheet.`); this.grantAdvancementPoints(AdvancementPoint.Ability); return; } diff --git a/module/blades-push-notifications.js b/module/blades-push-notifications.js index 72cc3758..4d280979 100644 --- a/module/blades-push-notifications.js +++ b/module/blades-push-notifications.js @@ -7,7 +7,13 @@ import U from "./core/utilities.js"; export default class BladesPushController { - static Get() { return game.eunoblades.PushController; } + static Get() { + if (!game.eunoblades.PushController) { + throw new Error("Attempt to Get BladesPushController before 'ready' hook."); + } + return game.eunoblades.PushController; + } + static isInitialized = false; static Initialize() { game.eunoblades ??= {}; Hooks.once("ready", async () => { @@ -29,6 +35,7 @@ export default class BladesPushController { } initOverlay() { $("#sidebar").append($("
")); + BladesPushController.isInitialized = true; } get elem$() { return $("#blades-push-notifications"); } get elem() { return this.elem$[0]; } diff --git a/module/blades.js b/module/blades.js index a6c26608..9e7491d3 100644 --- a/module/blades.js +++ b/module/blades.js @@ -8,34 +8,36 @@ import C from "./core/constants.js"; import registerSettings, { initTinyMCEStyles, initCanvasStyles } from "./core/settings.js"; import { registerHandlebarHelpers, preloadHandlebarsTemplates } from "./core/helpers.js"; -import BladesPushController from "./blades-push-notifications.js"; +import BladesPushController from "./BladesPushController.js"; import U from "./core/utilities.js"; -import registerDebugger from "./core/logger.js"; +import logger from "./core/logger.js"; import G, { Initialize as GsapInitialize } from "./core/gsap.js"; -import BladesActor from "./blades-actor.js"; -import BladesActorProxy from "./documents/blades-actor-proxy.js"; -import BladesItemProxy, { BladesItem, BladesClockKeeper, BladesGMTracker, BladesLocation, BladesScore } from "./documents/blades-item-proxy.js"; -import BladesItemSheet from "./sheets/item/blades-item-sheet.js"; -import BladesPCSheet from "./sheets/actor/blades-pc-sheet.js"; -import BladesCrewSheet from "./sheets/actor/blades-crew-sheet.js"; -import BladesNPCSheet from "./sheets/actor/blades-npc-sheet.js"; -import BladesFactionSheet from "./sheets/actor/blades-faction-sheet.js"; -import BladesRollCollab, { ApplyRollEffects, ApplyDescriptions } from "./blades-roll-collab.js"; -import BladesSelectorDialog from "./blades-dialog.js"; -import BladesActiveEffect from "./blades-active-effect.js"; -import BladesTrackerSheet from "./sheets/item/blades-tracker-sheet.js"; -import BladesClockKeeperSheet from "./sheets/item/blades-clock-keeper-sheet.js"; -import { updateClaims, updateContacts, updateOps, updateFactions } from "./data-import/data-import.js"; +import BladesActorProxy, { BladesActor } from "./documents/BladesActorProxy.js"; +import BladesItemProxy, { BladesItem, BladesClockKeeper, BladesGMTracker, BladesLocation, BladesScore } from "./documents/BladesItemProxy.js"; +import BladesItemSheet from "./sheets/item/BladesItemSheet.js"; +import BladesPCSheet from "./sheets/actor/BladesPCSheet.js"; +import BladesCrewSheet from "./sheets/actor/BladesCrewSheet.js"; +import BladesNPCSheet from "./sheets/actor/BladesNPCSheet.js"; +import BladesFactionSheet from "./sheets/actor/BladesFactionSheet.js"; +import BladesRollCollab from "./BladesRollCollab.js"; +import BladesSelectorDialog from "./BladesDialog.js"; +import BladesActiveEffect from "./BladesActiveEffect.js"; +import BladesGMTrackerSheet from "./sheets/item/BladesGMTrackerSheet.js"; +import BladesClockKeeperSheet from "./sheets/item/BladesClockKeeperSheet.js"; +import { updateClaims, updateContacts, updateOps, updateFactions, updateDescriptions, updateRollMods } from "./data-import/data-import.js"; CONFIG.debug.logging = false; -CONFIG.debug.logging = true; +CONFIG.debug.logging = true; +Object.assign(globalThis, { eLog: logger }); +Handlebars.registerHelper("eLog", logger.hbsLog); let socket; -registerDebugger(); Object.assign(globalThis, { updateClaims, updateContacts, updateOps, updateFactions, + applyDescriptions: updateDescriptions, + applyRollEffects: updateRollMods, BladesActor, BladesPCSheet, BladesCrewSheet, @@ -44,8 +46,6 @@ Object.assign(globalThis, { BladesActiveEffect, BladesPushController, BladesRollCollab, - ApplyRollEffects, - ApplyDescriptions, G, U, C, @@ -55,7 +55,7 @@ Object.assign(globalThis, { BladesLocation, BladesItemSheet, BladesClockKeeperSheet, - BladesTrackerSheet + BladesGMTrackerSheet }); Hooks.once("init", async () => { @@ -73,7 +73,7 @@ Hooks.once("init", async () => { await Promise.all([ BladesPCSheet.Initialize(), BladesActiveEffect.Initialize(), - BladesTrackerSheet.Initialize(), + BladesGMTrackerSheet.Initialize(), BladesScore.Initialize(), BladesSelectorDialog.Initialize(), BladesClockKeeperSheet.Initialize(), @@ -83,10 +83,9 @@ Hooks.once("init", async () => { ]); registerHandlebarHelpers(); }); -Hooks.once("ready", async () => { +Hooks.once("ready", () => { initCanvasStyles(); initTinyMCEStyles(); - DebugPC(); }); Hooks.once("socketlib.ready", () => { diff --git a/module/core/constants.js b/module/core/constants.js index 44d2adb4..d87aeeac 100644 --- a/module/core/constants.js +++ b/module/core/constants.js @@ -102,12 +102,12 @@ export var OtherDistrict; OtherDistrict["Old North Port"] = "Old North Port"; OtherDistrict["Deathlands"] = "Deathlands"; })(OtherDistrict || (OtherDistrict = {})); -export var Attribute; -(function (Attribute) { - Attribute["insight"] = "insight"; - Attribute["prowess"] = "prowess"; - Attribute["resolve"] = "resolve"; -})(Attribute || (Attribute = {})); +export var AttributeTrait; +(function (AttributeTrait) { + AttributeTrait["insight"] = "insight"; + AttributeTrait["prowess"] = "prowess"; + AttributeTrait["resolve"] = "resolve"; +})(AttributeTrait || (AttributeTrait = {})); export var InsightActions; (function (InsightActions) { InsightActions["hunt"] = "hunt"; @@ -129,28 +129,28 @@ export var ResolveActions; ResolveActions["consort"] = "consort"; ResolveActions["sway"] = "sway"; })(ResolveActions || (ResolveActions = {})); -export var Action; -(function (Action) { - Action["hunt"] = "hunt"; - Action["study"] = "study"; - Action["survey"] = "survey"; - Action["tinker"] = "tinker"; - Action["finesse"] = "finesse"; - Action["prowl"] = "prowl"; - Action["skirmish"] = "skirmish"; - Action["wreck"] = "wreck"; - Action["attune"] = "attune"; - Action["command"] = "command"; - Action["consort"] = "consort"; - Action["sway"] = "sway"; -})(Action || (Action = {})); +export var ActionTrait; +(function (ActionTrait) { + ActionTrait["hunt"] = "hunt"; + ActionTrait["study"] = "study"; + ActionTrait["survey"] = "survey"; + ActionTrait["tinker"] = "tinker"; + ActionTrait["finesse"] = "finesse"; + ActionTrait["prowl"] = "prowl"; + ActionTrait["skirmish"] = "skirmish"; + ActionTrait["wreck"] = "wreck"; + ActionTrait["attune"] = "attune"; + ActionTrait["command"] = "command"; + ActionTrait["consort"] = "consort"; + ActionTrait["sway"] = "sway"; +})(ActionTrait || (ActionTrait = {})); export var DowntimeAction; (function (DowntimeAction) { - DowntimeAction["Acquire"] = "Acquire"; + DowntimeAction["AcquireAsset"] = "AcquireAsset"; + DowntimeAction["IndulgeVice"] = "IndulgeVice"; + DowntimeAction["LongTermProject"] = "LongTermProject"; DowntimeAction["Recover"] = "Recover"; - DowntimeAction["Vice"] = "Vice"; - DowntimeAction["Project"] = "Project"; - DowntimeAction["Heat"] = "Heat"; + DowntimeAction["ReduceHeat"] = "ReduceHeat"; DowntimeAction["Train"] = "Train"; })(DowntimeAction || (DowntimeAction = {})); export var RollType; @@ -163,7 +163,6 @@ export var RollType; export var RollSubType; (function (RollSubType) { RollSubType["Incarceration"] = "Incarceration"; - RollSubType["Healing"] = "Healing"; RollSubType["Engagement"] = "Engagement"; RollSubType["GatherInfo"] = "GatherInfo"; })(RollSubType || (RollSubType = {})); @@ -333,7 +332,7 @@ export var Tag; (function (GearCategory) { GearCategory["ArcaneImplement"] = "ArcaneImplement"; GearCategory["Document"] = "Document"; - GearCategory["Gear"] = "Gear"; + GearCategory["GearKit"] = "GearKit"; GearCategory["SubterfugeSupplies"] = "SubterfugeSupplies"; GearCategory["Tool"] = "Tool"; GearCategory["Weapon"] = "Weapon"; @@ -384,56 +383,56 @@ const C = { levels: ["BITD.Light", "BITD.Normal", "BITD.Heavy", "BITD.Encumbered", "BITD.OverMax"] }, AttributeTooltips: { - [Attribute.insight]: "

Resists consequences from deception or understanding

", - [Attribute.prowess]: "

Resists consequences from physical strain or injury

", - [Attribute.resolve]: "

Resists consequences from mental strain or willpower

" + [AttributeTrait.insight]: "

Resists consequences from deception or understanding

", + [AttributeTrait.prowess]: "

Resists consequences from physical strain or injury

", + [AttributeTrait.resolve]: "

Resists consequences from mental strain or willpower

" }, ShortAttributeTooltips: { - [Attribute.insight]: "vs. deception or (mis)understanding", - [Attribute.prowess]: "vs. physical strain or injury", - [Attribute.resolve]: "vs. mental strain or willpower" + [AttributeTrait.insight]: "vs. deception or (mis)understanding", + [AttributeTrait.prowess]: "vs. physical strain or injury", + [AttributeTrait.resolve]: "vs. mental strain or willpower" }, ShortActionTooltips: { - [Action.hunt]: "carefully track a target", - [Action.study]: "scrutinize details and interpret evidence", - [Action.survey]: "observe the situation and anticipate outcomes", - [Action.tinker]: "fiddle with devices and mechanisms", - [Action.finesse]: "employ dexterity or subtle misdirection", - [Action.prowl]: "traverse skillfully and quietly", - [Action.skirmish]: "entangle a target in melee so they can't escape", - [Action.wreck]: "unleash savage force", - [Action.attune]: "open your mind to the ghost field or channel nearby electroplasmic energy through your body", - [Action.command]: "compel swift obedience", - [Action.consort]: "socialize with friends and contacts", - [Action.sway]: "influence someone with guile, charm, or argument" + [ActionTrait.hunt]: "carefully track a target", + [ActionTrait.study]: "scrutinize details and interpret evidence", + [ActionTrait.survey]: "observe the situation and anticipate outcomes", + [ActionTrait.tinker]: "fiddle with devices and mechanisms", + [ActionTrait.finesse]: "employ dexterity or subtle misdirection", + [ActionTrait.prowl]: "traverse skillfully and quietly", + [ActionTrait.skirmish]: "entangle a target in melee so they can't escape", + [ActionTrait.wreck]: "unleash savage force", + [ActionTrait.attune]: "open your mind to the ghost field or channel nearby electroplasmic energy through your body", + [ActionTrait.command]: "compel swift obedience", + [ActionTrait.consort]: "socialize with friends and contacts", + [ActionTrait.sway]: "influence someone with guile, charm, or argument" }, ActionTooltips: { - [Action.hunt]: "

When you Hunt, you carefully track a target.

", - [Action.study]: "

When you Study, you scrutinize details and interpret evidence.

", - [Action.survey]: "

When you Survey, you observe the situation and anticipate outcomes.

", - [Action.tinker]: "

When you Tinker, you fiddle with devices and mechanisms.

", - [Action.finesse]: "

When you Finesse, you employ dexterity or subtle misdirection.

", - [Action.prowl]: "

When you Prowl, you traverse skillfully and quietly.

", - [Action.skirmish]: "

When you Skirmish, you entangle a target in melee so they can't escape.

", - [Action.wreck]: "

When you Wreck, you unleash savage force.

", - [Action.attune]: "

When you Attune, you open your mind to the ghost field or channel nearby electroplasmic energy through your body.

", - [Action.command]: "

When you Command, you compel swift obedience.

", - [Action.consort]: "

When you Consort, you socialize with friends and contacts.

", - [Action.sway]: "

When you Sway, you influence someone with guile, charm, or argument.

" + [ActionTrait.hunt]: "

When you Hunt, you carefully track a target.

", + [ActionTrait.study]: "

When you Study, you scrutinize details and interpret evidence.

", + [ActionTrait.survey]: "

When you Survey, you observe the situation and anticipate outcomes.

", + [ActionTrait.tinker]: "

When you Tinker, you fiddle with devices and mechanisms.

", + [ActionTrait.finesse]: "

When you Finesse, you employ dexterity or subtle misdirection.

", + [ActionTrait.prowl]: "

When you Prowl, you traverse skillfully and quietly.

", + [ActionTrait.skirmish]: "

When you Skirmish, you entangle a target in melee so they can't escape.

", + [ActionTrait.wreck]: "

When you Wreck, you unleash savage force.

", + [ActionTrait.attune]: "

When you Attune, you open your mind to the ghost field or channel nearby electroplasmic energy through your body.

", + [ActionTrait.command]: "

When you Command, you compel swift obedience.

", + [ActionTrait.consort]: "

When you Consort, you socialize with friends and contacts.

", + [ActionTrait.sway]: "

When you Sway, you influence someone with guile, charm, or argument.

" }, ActionTooltipsGM: { - [Action.hunt]: "

When you Hunt, you carefully track a target.


", - [Action.study]: "

When you Study, you scrutinize details and interpret evidence.


", - [Action.survey]: "

When you Survey, you observe the situation and anticipate outcomes.


", - [Action.tinker]: "

When you Tinker, you fiddle with devices and mechanisms.


", - [Action.finesse]: "

When you Finesse, you employ dexterity or subtle misdirection.


", - [Action.prowl]: "

When you Prowl, you traverse skillfully and quietly.


", - [Action.skirmish]: "

When you Skirmish, you entangle a target in melee so they can't escape.


", - [Action.wreck]: "

When you Wreck, you unleash savage force.


", - [Action.attune]: "

When you Attune, you open your mind to the ghost field or channel nearby electroplasmic energy through your body.


", - [Action.command]: "

When you Command, you compel swift obedience.


", - [Action.consort]: "

When you Consort, you socialize with friends and contacts.


", - [Action.sway]: "

When you Sway, you influence someone with guile, charm, or argument.


" + [ActionTrait.hunt]: "

When you Hunt, you carefully track a target.


", + [ActionTrait.study]: "

When you Study, you scrutinize details and interpret evidence.


", + [ActionTrait.survey]: "

When you Survey, you observe the situation and anticipate outcomes.


", + [ActionTrait.tinker]: "

When you Tinker, you fiddle with devices and mechanisms.


", + [ActionTrait.finesse]: "

When you Finesse, you employ dexterity or subtle misdirection.


", + [ActionTrait.prowl]: "

When you Prowl, you traverse skillfully and quietly.


", + [ActionTrait.skirmish]: "

When you Skirmish, you entangle a target in melee so they can't escape.


", + [ActionTrait.wreck]: "

When you Wreck, you unleash savage force.


", + [ActionTrait.attune]: "

When you Attune, you open your mind to the ghost field or channel nearby electroplasmic energy through your body.


", + [ActionTrait.command]: "

When you Command, you compel swift obedience.


", + [ActionTrait.consort]: "

When you Consort, you socialize with friends and contacts.


", + [ActionTrait.sway]: "

When you Sway, you influence someone with guile, charm, or argument.


" }, TraumaTooltips: { Cold: "You're not moved by emotional appeals or social bonds.", @@ -964,14 +963,14 @@ const C = { BladesItemType.stricture ], Attribute: [ - Attribute.insight, - Attribute.prowess, - Attribute.resolve + AttributeTrait.insight, + AttributeTrait.prowess, + AttributeTrait.resolve ], Action: { - [Attribute.insight]: [Action.hunt, Action.study, Action.survey, Action.tinker], - [Attribute.prowess]: [Action.finesse, Action.prowl, Action.skirmish, Action.wreck], - [Attribute.resolve]: [Action.attune, Action.command, Action.consort, Action.sway] + [AttributeTrait.insight]: [ActionTrait.hunt, ActionTrait.study, ActionTrait.survey, ActionTrait.tinker], + [AttributeTrait.prowess]: [ActionTrait.finesse, ActionTrait.prowl, ActionTrait.skirmish, ActionTrait.wreck], + [AttributeTrait.resolve]: [ActionTrait.attune, ActionTrait.command, ActionTrait.consort, ActionTrait.sway] }, Vices: [ Vice.Faith, Vice.Gambling, Vice.Luxury, Vice.Obligation, Vice.Pleasure, Vice.Stupor, Vice.Weird, Vice.Worship, Vice.Living_Essence, Vice.Life_Essence, Vice.Electroplasmic_Power diff --git a/module/core/logger.js b/module/core/logger.js index 3d892b1d..62413413 100644 --- a/module/core/logger.js +++ b/module/core/logger.js @@ -77,8 +77,9 @@ const STYLES = { const eLogger = (type = "base", ...content) => { if (!(type === "error" || CONFIG.debug.logging)) { return; - } - let dbLevel = [0, 1, 2, 3, 4, 5].includes(U.getLast(content)) + } + const lastElem = U.getLast(content); + let dbLevel = typeof lastElem === "number" && [0, 1, 2, 3, 4, 5].includes(lastElem) ? content.pop() : 3; let key = false; @@ -157,7 +158,7 @@ const eLogger = (type = "base", ...content) => { .join("\n"); } }; -const eLog = { +const logger = { display: (...content) => eLogger("display", ...content), log0: (...content) => eLogger("log", ...content, 0), log1: (...content) => eLogger("log", ...content, 1), @@ -176,10 +177,6 @@ const eLog = { error: (...content) => eLogger("error", ...content), hbsLog: (...content) => eLogger("handlebars", ...content) }; -const registerDebugger = () => { - Object.assign(globalThis, { eLog }); - Handlebars.registerHelper("eLog", eLog.hbsLog); -}; -export default registerDebugger; +export default logger; //# sourceMappingURL=logger.js.map //# sourceMappingURL=logger.js.map diff --git a/module/core/mixins.js b/module/core/mixins.js index d90de5b8..5911f1f5 100644 --- a/module/core/mixins.js +++ b/module/core/mixins.js @@ -6,7 +6,7 @@ \* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ import { BladesItemType } from "./constants.js"; -import BladesItem from "../blades-item.js"; +import BladesItem from "../BladesItem.js"; class MixinBuilder { superclass; constructor(superclass) { this.superclass = superclass; } diff --git a/module/core/utilities.js b/module/core/utilities.js index 123ecac9..5601a1b8 100644 --- a/module/core/utilities.js +++ b/module/core/utilities.js @@ -151,7 +151,26 @@ const isDefined = (ref) => !isUndefined(ref); const isEmpty = (ref) => Object.keys(ref).length === 0; const hasItems = (ref) => !isEmpty(ref); const isInstance = (classRef, ref) => ref instanceof classRef; -const isInstanceFunc = (clazz) => (instance) => instance instanceof clazz; +function assertNonNullType(val, type) { + let valStr; + try { + valStr = JSON.stringify(val); + } + catch { + valStr = String(val); + } + if (val === undefined) { + throw new Error(`Value ${valStr} is undefined!`); + } + if (typeof type === "string") { + if (typeof val !== type) { + throw new Error(`Value ${valStr} is not a ${type}!`); + } + } + else if (!(val instanceof type)) { + throw new Error(`Value ${valStr} is not a ${type.name}!`); + } +} const areEqual = (...refs) => { do { const ref = refs.pop(); @@ -420,9 +439,9 @@ const verbalizeNum = (num) => { numWords.push("negative"); } const [integers, decimals] = num.replace(/[,\s-]/g, "").split("."); - const intArray = integers.split("").reverse().join("") + const intArray = [...integers.split("")].reverse().join("") .match(/.{1,3}/g) - ?.map((v) => v.split("").reverse().join("")) ?? []; + ?.map((v) => [...v.split("")].reverse().join("")) ?? []; const intStrings = []; while (intArray.length) { const thisTrio = intArray.pop(); @@ -469,8 +488,7 @@ const romanizeNum = (num, isUsingGroupedChars = true) => { return "0"; } const romanRef = _romanNumerals[isUsingGroupedChars ? "grouped" : "ungrouped"]; - const romanNum = stringifyNum(num) - .split("") + const romanNum = [...stringifyNum(num).split("")] .reverse() .map((digit, i) => romanRef[i][pInt(digit)]) .reverse() @@ -772,9 +790,9 @@ export function toDict(items, key) { return dict; function indexString(str) { if (/_\d+$/.test(str)) { - const [curIndex, ...subStr] = str.split(/_/).reverse(); + const [curIndex, ...subStr] = [...str.split(/_/)].reverse(); return [ - ...subStr.reverse(), + ...[...subStr].reverse(), parseInt(curIndex, 10) + 1 ].join("_"); } @@ -786,17 +804,22 @@ const partition = (obj, predicate = () => true) => [ objFilter(obj, (v, k) => !predicate(v, k)) ]; function objMap(obj, keyFunc, valFunc) { - if (!valFunc) { - valFunc = keyFunc; - keyFunc = false; - } - if (!keyFunc) { - keyFunc = ((k) => k); - } + let valFuncTyped = valFunc; + let keyFuncTyped = keyFunc; + if (!valFuncTyped) { + valFuncTyped = keyFunc; + keyFuncTyped = false; + } + if (!keyFuncTyped) { + keyFuncTyped = ((k) => k); + } if (isArray(obj)) { - return obj.map(valFunc); - } - return Object.fromEntries(Object.entries(obj).map(([key, val]) => [keyFunc(key, val), valFunc(val, key)])); + return obj.map(valFuncTyped); + } + return Object.fromEntries(Object.entries(obj).map(([key, val]) => { + assertNonNullType(valFuncTyped, "function"); + return [keyFuncTyped(key, val), valFuncTyped(val, key)]; + })); } const objSize = (obj) => Object.values(obj).filter((val) => val !== undefined && val !== null).length; const objFindKey = (obj, keyFunc, valFunc) => { @@ -1156,7 +1179,8 @@ export default { isUndefined, isDefined, isEmpty, hasItems, isInstance, areEqual, pFloat, pInt, radToDeg, degToRad, - getKey, + getKey, + assertNonNullType, FILTERS, testRegExp, regExtract, diff --git a/module/data-import/data-import.js b/module/data-import/data-import.js index be5a215e..3401fa1f 100644 --- a/module/data-import/data-import.js +++ b/module/data-import/data-import.js @@ -7,7 +7,7 @@ import { BladesActorType, BladesItemType } from "../core/constants.js"; import U from "../core/utilities.js"; -import { BladesItem } from "../documents/blades-item-proxy.js"; +import { BladesItem } from "../documents/BladesItemProxy.js"; const JSONDATA = { FACTIONS: { "the Billhooks": { @@ -2303,7 +2303,731 @@ const JSONDATA = { rules: "You get +1d to acquire asset rolls.", flavor: "You have space to hold all the various items and supplies you end up with from your smuggling runs. They can be useful on their own or for barter when you need it." } - ] + ], + ABILITIES: { + Descriptions: { + "Battleborn": "

If you 'reduce harm' that means the level of harm you're facing right now is reduced by one.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Bodyguard": "

The protect teamwork maneuver lets you face a consequence for a teammate.

If you choose to resist that consequence, this ability gives you +1d to your resistance roll.

Also, when you read a situation to gather information about hidden dangers or potential attackers, you get +1 effect—which means more detailed information.

", + "Ghost Fighter": "

When you're imbued, you can strongly interact with ghosts and spirit-stuff, rather than weakly interact.

When you imbue yourself with spirit energy, how do you do it? What does it look like when the energy manifests?

", + "Leader": "

This ability makes your cohorts more effective in battle and also allows them to resist harm by using armor.

While you lead your cohorts, they won't stop fighting until they take fatal harm (level 4) or you order them to cease.

What do you do to inspire such bravery in battle?

", + "Mule": "

This ability is great if you want to wear heavy armor and pack a heavy weapon without attracting lots of attention. Since your exact gear is determined on-the-fly during an operation, having more load also gives you more options to get creative with when dealing with problems during a score.

", + "Not to Be Trifled With": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) in addition to the special ability.

If you perform a feat that verges on the superhuman, you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.

If you engage a small gang on equal footing, you don't suffer reduced effect due to scale against a small gang (up to six people).

", + "Savage": "

You instill fear in those around you when you get violent. How they react depends on the person. Some people will flee from you, some will be impressed, some will get violent in return. The GM judges the response of a given NPC.

In addition, when you Command someone who's affected by fear (from this ability or otherwise), take +1d to your roll.

", + "Vigorous": "

Your healing clock becomes a 3-clock, and you get a bonus die when you recover.

", + "Sharpshooter": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) in addition to the special ability.

The first use of this ability allows you to attempt long-range sniper shots that would otherwise be impossible with the rudimentary firearms of Duskwall.

The second use allows you to keep up a steady rate of fire in a battle (enough to 'suppress' a small gang up to six people), rather than stopping for a slow reload or discarding a gun after each shot. When an enemy is suppressed, they're reluctant to maneuver or attack (usually calling for a fortune roll to see if they can manage it).

", + "Focused": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Ghost Hunter (Arrow-Swift)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to move extremely quickly, out-pacing any other creature or vehicle.

", + "Ghost Hunter (Ghost Form)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to transform into electroplasmic vapor as if it were a spirit.

", + "Ghost Hunter (Mind Link)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to share senses and thoughts telepathically with their master.

", + "Scout": "

A 'target' can be a person, a destination, a good ambush spot, an item, etc.

", + "Survivor": "

This ability gives you an additional stress box, so you have 10 instead of 9. The maximum number of stress boxes a PC can have (from any number of additional special abilities or upgrades) is 12.

", + "Tough As Nails": "

With this ability, level 3 harm doesn't incapacitate you; instead you take -1d to your rolls (as if it were level 2 harm). Level 2 harm affects you as if it were level 1 (less effect). Level 1 harm has no effect on you (but you still write it on your sheet, and must recover to heal it). Record the harm at its original level—for healing purposes, the original harm level applies.

", + "Alchemist": "

Follow the Inventing procedure with the GM (page 224) to define your first special alchemical formula.

", + "Artificer": "

Follow the Inventing procedure with the GM (page 224) to define your first spark-craft design.

", + "Fortitude": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Ghost Ward": "

If you make an area anathema to spirits, they will do everything they can to avoid it, and will suffer torment if forced inside the area.

If you make an area enticing to spirits, they will seek it out and linger in the area, and will suffer torment if forced to leave.

This effect lasts for several days over an area the size of a small room.

Particularly powerful or prepared spirits may roll their quality or arcane magnitude to see how well they're able to resist the effect.

", + "Physicker": "

Knowledge of anatomy and healing is a rare and esoteric thing in Duskwall. Without this ability, any attempts at treatment are likely to fail or make things worse.

You can use this ability to give first aid (rolling Tinker) to allow your patient to ignore a harm penalty for an hour or two.

", + "Saboteur": "

You can drill holes in things, melt stuff with acid, even use a muffled explosive, and it will all be very quiet and extremely hard to notice.

", + "Venomous": "

You choose the type of drug or poison when you get this ability. Only a single drug or poison may be chosen—you can't become immune to any essences, oils, or other alchemical substances.

You may change the drug or poison by completing a long-term project.

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

", + "Infiltrator": "

This ability lets you contend with higher-Tier enemies on equal footing. When you're cracking a safe, picking a lock, or sneaking past elite guards, your effect level is never reduced due to superior Tier or quality level of your opposition.

Are you a renowned safe cracker? Do people tell stories of how you slipped under the noses of two Chief Inspectors, or are your exceptional talents yet to be discovered?

", + "Ambush": "

This ability benefits from preparation— so don't forget you can do that in a flashback.

", + "Daredevil": "

This special ability is a bit of a gamble. The bonus die helps you, but if you suffer consequences, they'll probably be more costly to resist. But hey, you're a daredevil, so no big deal, right?

", + "The Devil's Footsteps": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

If you perform an athletic feat (running, tumbling, balance, climbing, etc.) that verges on the superhuman, you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.

If you maneuver to confuse your enemies, they attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.

", + "Expertise": "

This special ability is good for covering for your team. If they're all terrible at your favored action, you don't have to worry about suffering a lot of stress when you lead their group action.

", + "Ghost Veil": "

This ability transforms you into an intangible shadow for a few moments. If you spend additional stress, you can extend the effect for additional benefits, which may improve your position or effect for action rolls, depending on the circumstances, as usual.

", + "Reflexes": "

This ability gives you the initiative in most situations. Some specially trained NPCs (and some demons and spirits) might also have reflexes, but otherwise, you're always the first to act, and can interrupt anyone else who tries to beat you to the punch.

This ability usually doesn't negate the need to make an action roll that you would otherwise have to make, but it may improve your position or effect.

", + "Shadow": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Rook's Gambit": "

This is the 'jack-of-all-trades' ability. If you want to attempt lots of different sorts of actions and still have a good dice pool to roll, this is the special ability for you.

", + "Cloak & Dagger": "

This ability gives you the chance to more easily get out of trouble if a covert operation goes haywire. Also, don't forget your fine disguise kit gear, which boosts the effect of your covert deception methods.

", + "Ghost Voice": "

The first part of this ability gives you permission to do something that is normally impossible: when you speak to a spirit, it always listens and understands you, even if it would otherwise be too bestial or insane to do so.

The second part of the ability increases your effect when you use social actions with the supernatural.

", + "Like Looking Into a Mirror": "

This ability works in all situations without restriction. It is very powerful, but also a bit of a curse. You see though every lie, even the kind ones.

", + "A Little Something on the Side": "

Since this money comes at the end of downtime, after all downtime actions are resolved, you can't remove it from your stash and spend it on extra activities until your next downtime phase.

", + "Mesmerism": "

The victims' memory 'glosses over' the missing time, so it's not suspicious that they've forgotten something.

When you next interact with the victim, they remember everything clearly, including the strange effect of this ability.

", + "Subterfuge": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Trust in Me": "

This ability isn't just for social interactions. Any action can get the bonus. 'Intimate' is for you and the group to define, it need not exclusively mean romantic intimacy.

", + "Foresight": "

You can narrate an event in the past that helps your teammate now, or you might explain how you expected this situation and planned a helpful contingency that you reveal now.

", + "Calculating": "

If you forget to use this ability during downtime, you can still activate it during the score and flashback to the previous downtime when the extra activity happened.

", + "Connected": "

Your array of underworld connections can be leveraged to loan assets, pressure a vendor to give you a better deal, intimidate witnesses, etc.

", + "Functioning Vice": "

If you indulged your vice and rolled a 4, you could increase the result to 5 or 6, or you could reduce the result to 3 or 2 (perhaps to avoid overindulgence).

Allies that join you don't need to have the same vice as you, just one that could be indulged alongside yours somehow.

", + "Ghost Contract": "

The mark of the oath is obvious to anyone who sees it (perhaps a magical rune appears on the skin).

When you suffer 'Cursed' harm, you're incapacitated by withering: enfeebled muscles, hair falling out, bleeding from the eyes and ears, etc., until you either fulfill the deal or discover a way to heal the curse.

", + "Jail Bird": "

Zero is the minimum wanted level; this ability can't make your wanted level negative.

", + "Mastermind": "

If you protect a teammate, this ability negates or reduces the severity of a consequence or harm that your teammate is facing. You don't have to be present to use this ability—say how you prepared for this situation in the past.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Weaving the Web": "

Your network of underworld connections can always be leveraged to gain insight for a job—even when your contacts aren't aware that they're helping you.

", + "Compel": "

The GM will tell you if you sense any ghosts nearby. If you don't, you can gather information (maybe Attune, Survey, or Study) to attempt to locate one.

By default, a ghost wants to satisfy its need for life essence and to exact vengeance. When you compel it, you can give it a general or specific command, but the more general it is (like 'Protect me') the more the ghost will interpret it according to its own desires.

Your control over the ghost lasts until the command is fulfilled, or until a day has passed, whichever comes first.

", + "Iron Will": "

With this ability, you do not freeze up or flee when confronted by any kind of supernatural entity or strange occult event.

", + "Occultist": "

Consorting with a given entity may require special preparations or travel to a specific place. The GM will tell you about any requirements.

You get the bonus die to your Command rolls because you can demonstrate a secret knowledge of or influence over the entity when you interact with cultists.

", + "Ritual": "

Without this special ability, the study and practice of rituals leaves you utterly vulnerable to the powers you supplicate. Such endeavors are not recommended.

", + "Strange Methods": "

Follow the Inventing procedure with the GM (page 224) to define your first arcane design.

", + "Tempest": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

When you unleash lightning as a weapon, the GM will describe its effect level and significant collateral damage. If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

When you summon a storm, the GM will describe its effect level. If you're using this power as cover or distraction, it's probably a setup teamwork maneuver, using Attune.

", + "Warded": "

If you resist a consequence, this ability negates it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

" + }, + RollMods: { + "Battleborn": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Battleborn@cat:after@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Battleborn

You may expend your special armor instead of paying 2 stress to Push yourself during a fight.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Battleborn@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-HarmLevel@status:Hidden@tooltip:

Battleborn

You may expend your special armor to reduce the level of harm you are resisting by one.

" + } + ], + "Bodyguard": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Bodyguard@cat:roll@type:ability@cTypes:Resistance@status:Hidden@tooltip:

Bodyguard

When you protect a teammate, take +1d to your resistance roll.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Bodyguard@cat:effect@type:ability@status:Hidden@tooltip:

Bodyguard

When you gather information to anticipate possible threats in the current situation, you get +1 effect.

" + } + ], + "Ghost Fighter": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ghost Fighter@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Fighter

You may imbue your hands, melee weapons, or tools with spirit energy, giving you Potency in combat vs. the supernatural.

You may also grapple with spirits to restrain and capture them.

" + } + ], + "Leader": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Leader@cat:effect@type:ability@cTypes:Action@cTraits:command@status:Hidden@tooltip:

Leader

When a Leader Commands this cohort in combat, it gains +1 effect.

" + } + ], + "Not to Be Trifled With": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Superhuman Feat@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|command@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Superhuman Feat@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|command@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Engage Gang@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:Is-Push|ForceOn-Push|Negate-ScalePenalty@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Engage Gang

You can Push yourself to engage a gang of up to six members on equal footing (negating any Scale penalties).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Engage Gang@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:Is-Push|ForceOn-Push|Negate-ScalePenalty@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Engage Gang

You can Push yourself to engage a gang of up to six members on equal footing (negating any Scale penalties).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Savage": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Savage@cat:roll@type:ability@cTypes:Action@cTraits:command@status:Hidden@tooltip:

Savage

When you Command a fightened target, gain +1d to your roll.

" + } + ], + "Vigorous": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Vigorous@cat:roll@type:ability@cTypes:Downtime@aTypes:Engagement|Recover@status:Hidden@tooltip:

Vigorous

You gain +1d to healing treatment rolls.

" + } + ], + "Sharpshooter": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Extreme Range@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Extreme Range

You can Push yourself to make a ranged attack at extreme distance, one that would otherwise be impossible with the rudimentary firearms of Duskwall.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Extreme Range@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Extreme Range

You can Push yourself to make a ranged attack at extreme distance, one that would otherwise be impossible with the rudimentary firearms of Duskwall.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Suppression Fire@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Suppression Fire

You can Push yourself to maintain a steady rate of suppression fire during a battle, enough to suppress a small gang of up to six members. (When an enemy is suppressed, they're reluctant to maneuver or attack, usually calling for a fortune roll to see if they can manage it.)

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Suppression Fire@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Suppression Fire

You can Push yourself to maintain a steady rate of suppression fire during a battle, enough to suppress a small gang of up to six members. When an enemy is suppressed, they're reluctant to maneuver or attack, usually calling for a fortune roll to see if they can manage it.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Focused": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Focused@cat:roll@type:ability@cTypes:Resistance@cTraits:Insight|Resolve@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Focused

You may expend your special armor to completely negate a consequence of surprise or mental harm.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Focused@cat:after@type:ability@cTypes:Action@cTraits:hunt|study|survey|finesse|prowl|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Focused

You may expend your special armor instead of paying 2 stress to Push yourself for ranged combat or tracking.

" + } + ], + "Ghost Hunter (Arrow-Swift)": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Ghost Hunter (Arrow-Swift)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Arrow-Swift)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" + } + ], + "Ghost Hunter (Ghost Form)": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Ghost Hunter (Ghost Form)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Ghost Form)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" + } + ], + "Ghost Hunter (Mind Link)": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Ghost Hunter (Mind Link)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Mind Link)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" + } + ], + "Scout": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Scout@cat:effect@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:hunt|study|survey|attune|consort|sway@status:Hidden@tooltip:

Scout

When you gather information to discover the location of a target (a person, a destination, a good ambush spot, etc), you gain +1 effect.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Scout@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl@status:Hidden@tooltip:

Scout

When you hide in a prepared position or use camouflage, you get +1d to rolls to avoid detection.

" + } + ], + "Alchemist": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Alchemist@cat:result@type:ability@cTypes:Downtime|LongTermProject@status:Hidden@tooltip:

Alchemist

When you invent or craft a creation with alchemical features, you gain +1 result level to your roll.

" + } + ], + "Artificer": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Artificer@cat:result@type:ability@cTypes:Downtime|LongTermProject@cTraits:study|tinker@status:Hidden@tooltip:

Artificer

When you invent or craft a creation with spark-craft features, you gain +1 result level to your roll.

" + } + ], + "Fortitude": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Fortitude@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Fortitude

You may expend your special armor to completely negate a consequence of fatigue, weakness, or chemical effects.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Fortitude@cat:after@type:ability@cTypes:Action@cTraits:study|survey|tinker|finesse|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Fortitude

You may expend your special armor instead of paying 2 stress to Push yourself when working with technical skill or handling alchemicals.

" + } + ], + "Ghost Ward": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ghost Ward@cat:after@type:ability@cTypes:Action@cTraits:wreck@val:0@status:Hidden@tooltip:

Ghost Ward

When you Wreck an area with arcane substances, ruining it for any other use, it becomes anathema or enticing to spirits (your choice).

" + } + ], + "Physicker": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Physicker@cat:roll@type:ability@cTypes:Downtime@aTypes:Engagement|Recover@status:Hidden@tooltip:

Physicker

You gain +1d to your healing treatment rolls.

" + } + ], + "Saboteur": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Saboteur@cat:after@type:ability@cTypes:Action|Downtime|LongTermProject@aTraits:wreck@val:0@status:Hidden@tooltip:

Saboteur

When you Wreck, your work is much quieter than it should be and the damage is very well-hidden from casual inspection.

" + } + ], + "Venomous": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Venomous@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@status:Hidden@tooltip:

Venomous

You can Push yourself to secrete your chosen drug or poison through your skin or saliva, or exhale it as a vapor.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Venomous@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@status:Hidden@tooltip:

Venomous

You can Push yourself to secrete your chosen drug or poison through your skin or saliva, or exhale it as a vapor.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Infiltrator": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Infiltrator@cat:effect@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:tinker|finesse|wreck|attune@val:0@eKey:Negate-QualityPenalty|Negate-TierPenalty@status:Hidden@tooltip:

Infiltrator

You are not affected by low Quality or Tier when you bypass security measures.

" + } + ], + "Ambush": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ambush@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune@status:Hidden@tooltip:

Ambush

When you attack from hiding or spring a trap, you get +1d to your roll.

" + } + ], + "Daredevil": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Daredevil@cat:roll@type:ability@eKey:AutoRevealOn-Desperate|ForceOn-(Daredevil),after@status:ToggledOff@tooltip:

Daredevil

When you make a desperate action roll, you may gain +1d to your roll, if you also take −1d to resistance rolls against any consequences.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Daredevil@cat:roll@posNeg:negative@type:ability@cTypes:Resistance@status:Hidden@tooltip:

Daredevil

By choosing to gain +1d to your desperate action roll, you suffer −1d to resistance rolls against the consequences of that action.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:(Daredevil)@cat:after@posNeg:negative@type:ability@val:0@sourceName:Daredevil@status:Hidden@tooltip:

Daredevil

You will suffer −1d to resistance rolls against any consequences of this action roll.

" + } + ], + "The Devil's Footsteps": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Superhuman Feat@cat:roll@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Superhuman Feat@cat:effect@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Sow Confusion@cat:roll@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Sow Confusion

You can Push yourself to maneuver to confuse your enemies so they mistakenly attack each other. (They attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Sow Confusion@cat:effect@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Sow Confusion

You can Push yourself to maneuver to confuse your enemies so they mistakenly attack each other. (They attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Shadow": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Shadow@cat:after@type:ability@cTypes:Action@cTraits:hunt|study|survey|tinker|finesse|prowl|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Shadow

You may expend your special armor instead of paying 2 stress to Push yourself for a feat of athletics or stealth.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Shadow@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-HarmLevel@status:Hidden@tooltip:

Shadow

You may expend your special armor to completely negate a consequence of detection or security measures.

" + } + ], + "Rook's Gambit": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Rook's Gambit@cat:roll@type:ability@cTypes:Action|Downtime|AcquireAsset|LongTermProject@val:0@eKey:ForceOn-BestAction|Cost-Stress2@status:Hidden@tooltip:

Rook's Gambit

Take 2 stress to roll your best action rating while performing a different action.

(Describe how you adapt your skill to this use.)

" + } + ], + "Cloak & Dagger": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Cloak & Dagger@cat:roll@type:ability@cTypes:Action|Resistance@cTraits:finesse|prowl|attune|command|consort|sway|Insight@status:Hidden@tooltip:

Cloak & Dagger

When you use a disguise or other form of covert misdirection, you get +1d to rolls to confuse or deflect suspicion.

" + } + ], + "Ghost Voice": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ghost Voice@cat:effect@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:attune|command|consort|sway@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Voice

You gain Potency when communicating with the supernatural.

" + } + ], + "Mesmerism": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Mesmerism@cat:after@type:ability@cTypes:Action@cTraits:sway@val:0@status:Hidden@tooltip:

Mesmerism

When you Sway someone, you may cause them to forget that it's happened until they next interact with you.

" + } + ], + "Subterfuge": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Subterfuge@cat:roll@type:ability@cTypes:Resistance@cTraits:Insight@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Subterfuge

You may expend your special armor to completely negate a consequence of suspicion or persuasion.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Subterfuge@cat:after@type:ability@cTypes:Action@cTraits:finesse|attune|consort|sway@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Subterfuge

You may expend your special armor instead of paying 2 stress to Push yourself for subterfuge.

" + } + ], + "Trust in Me": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Trust in Me@cat:roll@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:hunt|study|survey|tinker|finesse|prowl|skirmish|wreck|attune|command|consort|sway|Insight|Prowess|Resolve|tier|quality|magnitude|number@status:Hidden@tooltip:

Trust in Me

You gain +1d to rolls opposed by a target with whom you have an intimate relationship.

" + } + ], + "Connected": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Connected@cat:result@type:ability@cTypes:Downtime@aTypes:AcquireAsset|ReduceHeat@status:Hidden@tooltip:

Connected

When you acquire an asset or reduce heat, you get +1 result level.

" + } + ], + "Jail Bird": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Jail Bird@cat:effect@type:ability@cTypes:Downtime@aTypes:Incarceration@eKey:Increase-Tier1@status:Hidden@tooltip:

Jail Bird

You gain +1 Tier while incarcerated.

" + } + ], + "Mastermind": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Mastermind@cat:after@type:ability@cTypes:Action|Downtime|LongTermProject@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Mastermind

You may expend your special armor instead of paying 2 stress to Push yourself when you gather information or work on a long-term project.

" + } + ], + "Weaving the Web": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Weaving the Web@cat:roll@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:consort@status:Hidden@tooltip:

Weaving the Web

You gain +1d to Consort when you gather information on a target for a score.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Weaving the Web@cat:roll@type:ability@cTypes:GatherInfo@status:Hidden@tooltip:

Weaving the Web

You gain +1d to the engagement roll for the targeted score.

" + } + ], + "Ghost Mind": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ghost Mind@cat:roll@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:hunt|study|survey|tinker|prowl|attune|command|consort|sway@status:Hidden@tooltip:

Ghost Mind

You gain +1d to rolls to gather information about the supernatural by any means.

" + } + ], + "Iron Will": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Iron Will@cat:roll@type:ability@cTypes:Resistance@aTraits:Resolve@status:Hidden@tooltip:

Iron Will

You gain +1d to Resolve resistance rolls.

" + } + ], + "Occultist": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Occultist@cat:roll@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:command@status:Hidden@tooltip:

Occultist

You gain +1d to rolls to Command cultists following ancient powers, forgotten gods or demons with whom you have previously Consorted

" + } + ], + "Strange Methods": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Strange Methods@cat:result@type:ability@cTypes:Downtime|LongTermProject@status:Hidden@tooltip:

Strange Methods

When you invent or craft a creation with arcane features, you gain +1 result level to your roll.

" + } + ], + "Tempest": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Throw Lightning@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Throw Lightning

You can Push yourself to unleash a stroke of lightning as a weapon. The GM will describe its effect level and significant collateral damage.

If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Throw Lightning@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Throw Lightning

You can Push yourself to unleash a stroke of lightning as a weapon. The GM will describe its effect level and significant collateral damage.

If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Summon Storm@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Summon Storm

You can Push yourself to summon a storm in your immediate vicinity (torrential rain, roaring winds, heavy fog, chilling frost and snow, etc.). The GM will describe its effect level.

If you're using this power as cover or distraction, it's probably a Setup teamwork maneuver, using Attune.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Summon Storm@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Summon Storm

You can Push yourself to summon a storm in your immediate vicinity (torrential rain, roaring winds, heavy fog, chilling frost and snow, etc.). The GM will describe its effect level.

If you're using this power as cover or distraction, it's probably a Setup teamwork maneuver, using Attune.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Warded": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Warded@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Warded

You may expend your special armor to completely negate a consequence of supernatural origin.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Warded@cat:after@type:ability@cTypes:Action@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Warded

You may expend your special armor instead of paying 2 stress to Push yourself when you contend with or employ arcane forces.

" + } + ] + } + }, + CREW_ABILITIES: { + Descriptions: { + "Deadly": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Crow's Veil": "

The bells don't ring at the crematorium when a member of your crew kills someone. Do you have a 'membership ritual' now that conveys this talent?

", + "Emberdeath": "

This ability activates at the moment of the target's death (spend 3 stress then or lose the opportunity to use it). It can only be triggered by a killing blow. Some particularly powerful supernatural entities or specially protected targets may be resistant or immune to this ability.

", + "No Traces": "

There are many clients who value quiet operations. This ability rewards you for keeping a low profile.

", + "Patron": "

Who is your patron? Why do they help you?

", + "Predators": "

This ability applies when the goal is murder. It doesn't apply to other stealth or deception operations you attempt that happen to involve killing.

", + "Vipers": "

The poison immunity lasts for the entire score, until you next have downtime.

", + "Dangerous": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Blood Brothers": "

If you have the Elite Thugs upgrade, it stacks with this ability. So, if you had an Adepts gang cohort, and the Elite Thugs upgrade, and then took Blood Brothers, your Adepts would add the Thugs type and also get +1d to rolls when they did Thug-type actions.

This ability may result in a gang with three types, surpassing the normal limit of two.

", + "Door Kickers": "

This ability applies when the goal is to attack an enemy. It doesn't apply to other operations you attempt that happen to involve fighting.

", + "Fiends": "

The maximum wanted level is 4. Regardless of how much turf you hold (from this ability or otherwise) the minimum rep cost to advance your Tier is always 6.

", + "Forged In The Fire": "

This ability applies to PCs in the crew. It doesn't confer any special toughness to your cohorts.

", + "Chosen": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Bound in Darkness": "

By what occult means does your teamwork manifest over distance? How is it strange or disturbing? By what ritualistic method are cult members initiated into this ability?

", + "Conviction": "

What sort of sacrifice does your deity find pleasing?

", + "Silver Tongues": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Accord": "

If your status changes, you lose the turf until it becomes +3 again. Regardless of how much turf you hold (from this ability or otherwise) the minimum rep cost to advance your Tier is always 6.

", + "Ghost Market": "

They do not pay in coin. What do they pay with?

The GM will certainly have an idea about how your strange new clients pay, but jump in with your own ideas, too! This ability is usually a big shift in the game, so talk it out and come up with something that everyone is excited about. If it's a bit mysterious and uncertain, that's good. You have more to explore that way.

", + "The Good Stuff": "

The quality of your product might be used for a fortune roll to find out how impressed a potential client is, to find out how enthralled or incapacitated a user is in their indulgence of it, to discover if a strange variation has side-effects, etc.

", + "Everyone Steals": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Ghost Echoes": "

You might explore the echo of an ancient building, crumbled to dust in the real world, but still present in the ghost field; or discern the electroplasmic glow of treasures lost in the depths of the canals; or use a sorcerous ghost door from the pre-cataclysm to infiltrate an otherwise secure location; etc.

The GM will tell you what echoes persist nearby when you gather information about them. You might also undertake investigations to discover particular echoes you hope to find.

", + "Pack Rats": "

This ability might mean that you actually have the item you need in your pile of stuff, or it could mean you have extra odds and ends to barter with.

", + "Slippery": "

The GM might sometimes want to choose an entanglement instead of rolling. In that case, they'll choose two and you can pick between them.

", + "Synchronized": "

For example, Lyric leads a group action to Attune to the ghost field to overcome a magical ward on the Dimmer Sisters' door. Emily, Lyric's player, rolls and gets a 6, and so does Matt! Because the crew has Synchronized, their two separate 6s count as a critical success on the roll.

", + "Ghost Passage": "

What do you do to 'carry' a spirit? Must the spirit consent, or can you use this ability to trap an unwilling spirit within?

", + "Reavers": "

If your vehicle already has armor, this ability gives an additional armor box.

", + "Renegades": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

" + }, + RollMods: { + "Predators": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Predators@cat:roll@type:crew_ability@cTypes:GatherInfo@status:Hidden@tooltip:

Predators

When you use a stealth or deception plan to commit murder, take +1d to the engagement roll.

" + } + ], + "Vipers": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Vipers (Crew Ability)@cat:result@type:crew_ability@cTypes:Downtime|AcquireAsset|LongTermProject@sourceName:Vipers@status:Hidden@tooltip:

Vipers (Crew Ability)

When you acquire or craft poisons, you get +1 result level to your roll.

" + } + ], + "Blood Brothers": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Blood Brothers (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@sourceName:Blood Brothers@status:Hidden@tooltip:

Blood Brothers (Crew Ability)

When fighting alongside crew members in combat, gain +1d for assist, setup and group teamwork actions.

" + } + ], + "Door Kickers": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Door Kickers@cat:roll@type:crew_ability@cTypes:GatherInfo@status:Hidden@tooltip:

Door Kickers

When you use an assault plan, take +1d to the engagement roll.

" + } + ], + "Anointed": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Anointed (Crew Ability)@cat:roll@type:crew_ability@cTypes:Resistance@sourceName:Anointed@status:Hidden@tooltip:

Anointed (Crew Ability)

Gain +1d to resistance rolls against supernatural threats.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Anointed (Crew Ability)@cat:roll@type:crew_ability@cTypes:Downtime|Engagement|Recover@sourceName:Anointed@status:Hidden@tooltip:

Anointed (Crew Ability)

Gain +1d to healing treatment rolls when you have supernatural harm.

" + } + ], + "Conviction": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Conviction (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action@sourceName:Conviction@status:Hidden@tooltip:

Conviction (Crew Ability)

You may call upon your deity to assist any one action roll you make.

You cannot use this ability again until you indulge your Worship vice.

" + } + ], + "Zealotry": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Zealotry (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action|Downtime@sourceName:Zealotry@status:Hidden@tooltip:

Zealotry (Crew Ability)

Gain +1d when acting against enemies of the faith.

" + } + ], + "The Good Stuff": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:The Good Stuff (Crew Ability)@cat:effect@type:crew_ability@cTypes:Action|Downtime@val:0@eKey:Increase-Quality2@sourceName:The Good Stuff@status:Hidden@tooltip:

The Good Stuff (Crew Ability)

The quality of your product is equal to your Tier +2.

" + } + ], + "High Society": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:High Society (Crew Ability)@cat:roll@type:crew_ability@sourceName:High Society@status:Hidden@tooltip:

High Society (Crew Ability)

Gain +1d to gather information about the city's elite.

" + } + ], + "Pack Rats": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Pack Rats (Crew Ability)@cat:roll@type:crew_ability@aTypes:AcquireAsset@sourceName:Pack Rats@status:Hidden@tooltip:

Pack Rats (Crew Ability)

Gain +1d to acquire an asset.

" + } + ], + "Second Story": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Second Story@cat:roll@type:crew_ability@cTypes:GatherInfo@status:Hidden@tooltip:

Second Story

When you execute a clandestine infiltration plan, gain +1d to the engagement roll.

" + } + ], + "Slippery": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Slippery (Crew Ability)@cat:roll@type:crew_ability@cTypes:Downtime@aTypes:ReduceHeat@sourceName:Slippery@status:Hidden@tooltip:

Slippery (Crew Ability)

Gain +1d to reduce heat rolls.

" + } + ], + "Synchronized": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + isCohort: true, + value: "name:Synchronized (Crew Ability)@cat:after@type:crew_ability@cTypes:Action@sourceName:Synchronized@status:Hidden@tooltip:

Synchronized (Crew Ability)

When you perform a group teamwork action, you may count multiple 6s from different rolls as a critical success.

" + } + ], + "Just Passing Through": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Just Passing Through (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action|Downtime@cTraits:finesse|prowl|consort|sway@sourceName:Just Passing Through@status:Hidden@tooltip:

Just Passing Through (Crew Ability)

When your heat is 4 or less, gain +1d to rolls to deceive people when you pass yourself off as ordinary citizens.

" + } + ], + "Reavers": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Reavers (Crew Ability)@cat:effect@type:crew_ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@sourceName:Reavers@status:Hidden@tooltip:

Reavers (Crew Ability)

When you go into conflict aboard a vehicle, gain +1 effect for vehicle damage and speed.

" + } + ] + } + }, + CREW_UPGRADES: { + Descriptions: {}, + RollMods: { + "Ironhook Contacts": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Ironhook Contacts (Crew Upgrade)@cat:roll@type:crew_upgrade@cTypes:Downtime@aTypes:Incarceration@eKey:Increase-Tier1@sourceName:Ironhook Contacts@status:Hidden@tooltip:

Ironhook Contacts (Crew Upgrade)

Gain +1 Tier while in prison, including the incarceration roll.

" + } + ] + } + } }; const problemLog = []; function parseColumnItem(item, isFaction = false) { @@ -2477,5 +3201,328 @@ export const updateFactions = async () => { }); console.log(problemLog); }; +export const updateRollMods = async () => { + Object.entries(JSONDATA.ABILITIES.RollMods) + .forEach(async ([aName, eData]) => { + const abilityDoc = game.items.getName(aName); + if (!abilityDoc) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} Not Found.`); + return; + } + const abilityEffects = Array.from(abilityDoc.effects ?? []); + const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); + const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); + const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); + const testChange = eData[0]; + if ((testChange.isMember && eData.some((change) => !change.isMember)) + || (!testChange.isMember && eData.some((change) => change.isMember))) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} has inconsistent 'isMember' entries.`); + return; + } + if ((testChange.isCohort && eData.some((change) => !change.isCohort)) + || (!testChange.isCohort && eData.some((change) => change.isCohort))) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} has inconsistent 'isCohort' entries.`); + return; + } + if (testChange.isMember) { + if (toMemberEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); + return; + } + const effectData = { + name: aName, + icon: abilityDoc.img ?? "", + changes: eData.map((change) => { + delete change.isMember; + return change; + }) + }; + if (toMemberEffects.length === 1) { + const abilityEffect = toMemberEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + else { + effectData.changes.unshift({ + key: "APPLYTOMEMBERS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Scoundrel Ability)` + }); + } + await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + else if (testChange.isCohort) { + if (toCohortEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); + return; + } + const effectData = { + name: aName, + icon: abilityDoc.img ?? "", + changes: eData.map((change) => { + delete change.isCohort; + return change; + }) + }; + if (toCohortEffects.length === 1) { + const abilityEffect = toCohortEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + else { + effectData.changes.unshift({ + key: "APPLYTOCOHORTS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Scoundrel Ability)` + }); + } + await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + else { + if (standardEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} Has Multiple Active Effects`); + return; + } + const effectData = { + name: aName, + icon: abilityDoc.img ?? "", + changes: eData + }; + if (standardEffects.length === 1) { + const abilityEffect = standardEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + }); + Object.entries(JSONDATA.CREW_ABILITIES.RollMods) + .forEach(async ([aName, eData]) => { + const crewAbilityDoc = game.items.getName(aName); + if (!crewAbilityDoc) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} Not Found.`); + return; + } + const abilityEffects = Array.from(crewAbilityDoc.effects ?? []); + const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); + const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); + const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); + const testChange = eData[0]; + if ((testChange.isMember && eData.some((change) => !change.isMember)) + || (!testChange.isMember && eData.some((change) => change.isMember))) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} has inconsistent 'isMember' entries.`); + return; + } + if ((testChange.isCohort && eData.some((change) => !change.isCohort)) + || (!testChange.isCohort && eData.some((change) => change.isCohort))) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} has inconsistent 'isCohort' entries.`); + return; + } + if (testChange.isMember) { + if (toMemberEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); + return; + } + const effectData = { + name: aName, + icon: crewAbilityDoc.img ?? "", + changes: eData.map((change) => { + delete change.isMember; + return change; + }) + }; + if (toMemberEffects.length === 1) { + const abilityEffect = toMemberEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + else { + effectData.changes.unshift({ + key: "APPLYTOMEMBERS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Ability)` + }); + } + await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + else if (testChange.isCohort) { + if (toCohortEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); + return; + } + const effectData = { + name: aName, + icon: crewAbilityDoc.img ?? "", + changes: eData.map((change) => { + delete change.isCohort; + return change; + }) + }; + if (toCohortEffects.length === 1) { + const abilityEffect = toCohortEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + else { + effectData.changes.unshift({ + key: "APPLYTOCOHORTS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Ability)` + }); + } + await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + else { + if (standardEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} Has Multiple Active Effects`); + return; + } + const effectData = { + name: aName, + icon: crewAbilityDoc.img ?? "", + changes: eData + }; + if (standardEffects.length === 1) { + const abilityEffect = standardEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + }); + Object.entries(JSONDATA.CREW_UPGRADES.RollMods) + .forEach(async ([aName, eData]) => { + const crewUpgradeDoc = game.items.getName(aName); + if (!crewUpgradeDoc) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} Not Found.`); + return; + } + const abilityEffects = Array.from(crewUpgradeDoc.effects ?? []); + const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); + const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); + const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); + const testChange = eData[0]; + if ((testChange.isMember && eData.some((change) => !change.isMember)) + || (!testChange.isMember && eData.some((change) => change.isMember))) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} has inconsistent 'isMember' entries.`); + return; + } + if ((testChange.isCohort && eData.some((change) => !change.isCohort)) + || (!testChange.isCohort && eData.some((change) => change.isCohort))) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} has inconsistent 'isCohort' entries.`); + return; + } + if (testChange.isMember) { + if (toMemberEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); + return; + } + const effectData = { + name: aName, + icon: crewUpgradeDoc.img ?? "", + changes: eData.map((change) => { + delete change.isMember; + return change; + }) + }; + if (toMemberEffects.length === 1) { + const abilityEffect = toMemberEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + else { + effectData.changes.unshift({ + key: "APPLYTOMEMBERS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Upgrade)` + }); + } + await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + else if (testChange.isCohort) { + if (toCohortEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); + return; + } + const effectData = { + name: aName, + icon: crewUpgradeDoc.img ?? "", + changes: eData.map((change) => { + delete change.isCohort; + return change; + }) + }; + if (toCohortEffects.length === 1) { + const abilityEffect = toCohortEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + else { + effectData.changes.unshift({ + key: "APPLYTOCOHORTS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Upgrade)` + }); + } + await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + else { + if (standardEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} Has Multiple Active Effects`); + return; + } + const effectData = { + name: aName, + icon: crewUpgradeDoc.img ?? "", + changes: eData + }; + if (standardEffects.length === 1) { + const abilityEffect = standardEffects[0]; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + }); +}; +export const updateDescriptions = async () => { + Object.entries({ + ...JSONDATA.ABILITIES.Descriptions, + ...JSONDATA.CREW_ABILITIES.Descriptions, + ...JSONDATA.CREW_UPGRADES.Descriptions + }) + .forEach(async ([aName, desc]) => { + const itemDoc = game.items.getName(aName); + if (!itemDoc) { + eLog.error("applyRollEffects", `ApplyDescriptions: Item Doc ${aName} Not Found.`); + return; + } + itemDoc.update({ "system.notes": desc }); + }); +}; //# sourceMappingURL=data-import.js.map //# sourceMappingURL=data-import.js.map diff --git a/module/documents/BladesActorProxy.js b/module/documents/BladesActorProxy.js new file mode 100644 index 00000000..974a743d --- /dev/null +++ b/module/documents/BladesActorProxy.js @@ -0,0 +1,59 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import U from "../core/utilities.js"; +import { BladesActorType } from "../core/constants.js"; +import BladesActor from "../BladesActor.js"; +import BladesPC from "./actors/BladesPC.js"; +import BladesNPC from "./actors/BladesNPC.js"; +import BladesFaction from "./actors/BladesFaction.js"; +import BladesCrew from "./actors/BladesCrew.js"; +const ActorsMap = { + [BladesActorType.pc]: BladesPC, + [BladesActorType.npc]: BladesNPC, + [BladesActorType.faction]: BladesFaction, + [BladesActorType.crew]: BladesCrew +}; +const BladesActorProxy = new Proxy(function () { }, { + construct(_, args) { + const [{ type }] = args; + if (!type) { + throw new Error(`Invalid Actor Type: ${String(type)}`); + } + const MappedConstructor = ActorsMap[type]; + if (!MappedConstructor) { + return new BladesActor(...args); + } + return new MappedConstructor(...args); + }, + get(_, prop) { + switch (prop) { + case "create": + case "createDocuments": + return function (data, options = {}) { + if (U.isArray(data)) { + return data.map((i) => CONFIG.Actor.documentClass.create(i, options)); + } + const MappedConstructor = ActorsMap[data.type]; + if (!MappedConstructor) { + return BladesActor.create(data, options); + } + return MappedConstructor.create(data, options); + }; + case Symbol.hasInstance: + return function (instance) { + return Object.values(ActorsMap).some((i) => instance instanceof i); + }; + default: + return BladesActor[prop]; + } + } +}); +export default BladesActorProxy; +export { BladesActor, BladesPC, BladesCrew, BladesNPC, BladesFaction }; +//# sourceMappingURL=BladesActorProxy.js.map +//# sourceMappingURL=BladesActorProxy.js.map diff --git a/module/documents/BladesItemProxy.js b/module/documents/BladesItemProxy.js new file mode 100644 index 00000000..2980ae49 --- /dev/null +++ b/module/documents/BladesItemProxy.js @@ -0,0 +1,59 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import U from "../core/utilities.js"; +import { BladesItemType } from "../core/constants.js"; +import BladesItem from "../BladesItem.js"; +import BladesLocation from "./items/BladesLocation.js"; +import BladesClockKeeper from "./items/BladesClockKeeper.js"; +import BladesGMTracker from "./items/BladesGMTracker.js"; +import BladesScore from "./items/BladesScore.js"; +const ItemsMap = { + [BladesItemType.clock_keeper]: BladesClockKeeper, + [BladesItemType.gm_tracker]: BladesGMTracker, + [BladesItemType.location]: BladesLocation, + [BladesItemType.score]: BladesScore +}; +const BladesItemProxy = new Proxy(function () { }, { + construct(_, args) { + const [{ type }] = args; + if (!type) { + throw new Error(`Invalid Item Type: ${String(type)}`); + } + const MappedConstructor = ItemsMap[type]; + if (!MappedConstructor) { + return new BladesItem(...args); + } + return new MappedConstructor(...args); + }, + get(_, prop) { + switch (prop) { + case "create": + case "createDocuments": + return function (data, options = {}) { + if (U.isArray(data)) { + return data.map((i) => CONFIG.Item.documentClass.create(i, options)); + } + const MappedConstructor = ItemsMap[data.type]; + if (!MappedConstructor) { + return BladesItem.create(data, options); + } + return MappedConstructor.create(data, options); + }; + case Symbol.hasInstance: + return function (instance) { + return Object.values(ItemsMap).some((i) => instance instanceof i); + }; + default: + return BladesItem[prop]; + } + } +}); +export default BladesItemProxy; +export { BladesItem, BladesClockKeeper, BladesGMTracker, BladesLocation, BladesScore }; +//# sourceMappingURL=BladesItemProxy.js.map +//# sourceMappingURL=BladesItemProxy.js.map diff --git a/module/documents/actors/BladesCrew.js b/module/documents/actors/BladesCrew.js new file mode 100644 index 00000000..630ae209 --- /dev/null +++ b/module/documents/actors/BladesCrew.js @@ -0,0 +1,85 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import { BladesItemType, Factor } from "../../core/constants.js"; +import BladesActor from "../../BladesActor.js"; +import { BladesRollMod } from "../../BladesRollCollab.js"; +class BladesCrew extends BladesActor { + + static async create(data, options = {}) { + data.token = data.token || {}; + data.system = data.system ?? {}; + eLog.checkLog2("actor", "BladesActor.create(data,options)", { data, options }); + + data.token.actorLink = true; + + data.system.world_name = data.system.world_name ?? data.name.replace(/[^A-Za-z_0-9 ]/g, "").trim().replace(/ /g, "_"); + + data.system.experience = { + playbook: { value: 0, max: 8 }, + clues: [], + ...data.system.experience ?? {} + }; + return super.create(data, options); + } + get rollModsData() { + return BladesRollMod.ParseDocRollMods(this); + } + get rollFactors() { + const factorData = { + [Factor.tier]: { + name: Factor.tier, + value: this.getFactorTotal(Factor.tier), + max: this.getFactorTotal(Factor.tier), + baseVal: this.getFactorTotal(Factor.tier), + isActive: true, + isPrimary: true, + isDominant: false, + highFavorsPC: true + }, + [Factor.quality]: { + name: Factor.quality, + value: this.getFactorTotal(Factor.quality), + max: this.getFactorTotal(Factor.quality), + baseVal: this.getFactorTotal(Factor.quality), + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + } + }; + return factorData; + } + get rollPrimaryID() { return this.id; } + get rollPrimaryDoc() { return this; } + get rollPrimaryName() { return this.name; } + get rollPrimaryType() { return this.type; } + get rollPrimaryImg() { return this.img; } + + get rollParticipantID() { return this.id; } + get rollParticipantDoc() { return this; } + get rollParticipantIcon() { return this.playbook?.img ?? this.img; } + get rollParticipantName() { return this.name; } + get rollParticipantType() { return this.type; } + get rollParticipantModsData() { return []; } + + get abilities() { + if (!this.playbook) { + return []; + } + return this.activeSubItems.filter((item) => [BladesItemType.ability, BladesItemType.crew_ability].includes(item.type)); + } + get playbookName() { + return this.playbook?.name; + } + get playbook() { + return this.activeSubItems.find((item) => item.type === BladesItemType.crew_playbook); + } +} +export default BladesCrew; +//# sourceMappingURL=BladesCrew.js.map +//# sourceMappingURL=BladesCrew.js.map diff --git a/module/documents/actors/BladesFaction.js b/module/documents/actors/BladesFaction.js new file mode 100644 index 00000000..9c70ac9d --- /dev/null +++ b/module/documents/actors/BladesFaction.js @@ -0,0 +1,63 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import { Factor } from "../../core/constants.js"; +import BladesActor from "../../BladesActor.js"; +class BladesFaction extends BladesActor { + get rollFactors() { + const factorData = { + [Factor.tier]: { + name: Factor.tier, + value: this.getFactorTotal(Factor.tier), + max: this.getFactorTotal(Factor.tier), + baseVal: this.getFactorTotal(Factor.tier), + isActive: true, + isPrimary: true, + isDominant: false, + highFavorsPC: true + }, + [Factor.quality]: { + name: Factor.quality, + value: this.getFactorTotal(Factor.quality), + max: this.getFactorTotal(Factor.quality), + baseVal: this.getFactorTotal(Factor.quality), + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + } + }; + return factorData; + } + + get rollOppID() { return this.id; } + get rollOppDoc() { return this; } + get rollOppImg() { return this.img ?? ""; } + get rollOppName() { return this.name ?? ""; } + get rollOppSubName() { return ""; } + get rollOppType() { return this.type; } + get rollOppModsData() { return []; } + + async addClock(clockData = {}) { + clockData.id ??= clockData.id ?? randomID(); + clockData.color ??= "white"; + clockData.display ??= ""; + clockData.isVisible ??= false; + clockData.isNameVisible ??= false; + clockData.isActive ??= false; + clockData.max ??= 4; + clockData.target ??= `system.clocks.${clockData.id}.value`; + clockData.value ??= 0; + return this.update({ [`system.clocks.${clockData.id}`]: clockData }); + } + async deleteClock(clockID) { + return this.update({ [`system.clocks.-=${clockID}`]: null }); + } +} +export default BladesFaction; +//# sourceMappingURL=BladesFaction.js.map +//# sourceMappingURL=BladesFaction.js.map diff --git a/module/documents/actors/BladesNPC.js b/module/documents/actors/BladesNPC.js new file mode 100644 index 00000000..fc675532 --- /dev/null +++ b/module/documents/actors/BladesNPC.js @@ -0,0 +1,77 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import { BladesActorType, Factor } from "../../core/constants.js"; +import BladesActor from "../../BladesActor.js"; +class BladesNPC extends BladesActor { + get rollFactors() { + const factorData = { + [Factor.tier]: { + name: Factor.tier, + value: this.getFactorTotal(Factor.tier), + max: this.getFactorTotal(Factor.tier), + baseVal: this.getFactorTotal(Factor.tier), + isActive: true, + isPrimary: true, + isDominant: false, + highFavorsPC: true + }, + [Factor.quality]: { + name: Factor.quality, + value: this.getFactorTotal(Factor.quality), + max: this.getFactorTotal(Factor.quality), + baseVal: this.getFactorTotal(Factor.quality), + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + } + }; + if (BladesActor.IsType(this, BladesActorType.npc)) { + factorData[Factor.scale] = { + name: Factor.scale, + value: this.getFactorTotal(Factor.scale), + max: this.getFactorTotal(Factor.scale), + baseVal: this.getFactorTotal(Factor.scale), + cssClasses: "factor-grey", + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + }; + factorData[Factor.magnitude] = { + name: Factor.magnitude, + value: this.getFactorTotal(Factor.magnitude), + max: this.getFactorTotal(Factor.magnitude), + baseVal: this.getFactorTotal(Factor.magnitude), + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + }; + } + return factorData; + } + + get rollOppID() { return this.id; } + get rollOppDoc() { return this; } + get rollOppImg() { return this.img ?? ""; } + get rollOppName() { return this.name ?? ""; } + get rollOppSubName() { return ""; } + get rollOppType() { return this.type; } + get rollOppModsData() { return []; } + + get rollParticipantID() { return this.id; } + get rollParticipantDoc() { return this; } + get rollParticipantIcon() { return this.img ?? ""; } + get rollParticipantName() { return this.name ?? ""; } + get rollParticipantType() { return this.type; } + get rollParticipantModsData() { return []; } +} +export default BladesNPC; +//# sourceMappingURL=BladesNPC.js.map +//# sourceMappingURL=BladesNPC.js.map diff --git a/module/documents/actors/BladesPC.js b/module/documents/actors/BladesPC.js new file mode 100644 index 00000000..398f26d3 --- /dev/null +++ b/module/documents/actors/BladesPC.js @@ -0,0 +1,307 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesItem from "../../BladesItem.js"; +import C, { AttributeTrait, Harm, BladesActorType, BladesItemType, Tag, RollModCategory, Factor, RollModStatus } from "../../core/constants.js"; +import U from "../../core/utilities.js"; +import BladesActor from "../../BladesActor.js"; +import { BladesRollMod } from "../../BladesRollCollab.js"; +class BladesPC extends BladesActor { + + static async create(data, options = {}) { + data.token = data.token || {}; + data.system = data.system ?? {}; + eLog.checkLog2("actor", "BladesPC.create(data,options)", { data, options }); + + data.token.actorLink = true; + + data.system.experience = { + playbook: { value: 0, max: 8 }, + insight: { value: 0, max: 6 }, + prowess: { value: 0, max: 6 }, + resolve: { value: 0, max: 6 }, + clues: [], + ...data.system.experience ?? {} + }; + return super.create(data, options); + } + + get primaryUser() { + return game.users?.find((user) => user.character?.id === this?.id) || null; + } + async clearLoadout() { + this.update({ "system.loadout.selected": "" }); + this.updateEmbeddedDocuments("Item", [ + ...this.activeSubItems.filter((item) => BladesItem.IsType(item, BladesItemType.gear) && !item.hasTag(Tag.System.Archived)) + .map((item) => ({ + "_id": item.id, + "system.tags": [...item.tags, Tag.System.Archived], + "system.uses_per_score.value": 0 + })), + ...this.activeSubItems.filter((item) => BladesItem.IsType(item, BladesItemType.ability) && item.system.uses_per_score.max) + .map((item) => ({ + "_id": item.id, + "system.uses_per_score.value": 0 + })) + ]); + } + getSubActor(actorRef) { + const actor = super.getSubActor(actorRef); + if (!actor) { + return undefined; + } + if (this.primaryUser?.id) { + actor.ownership[this.primaryUser.id] = CONST.DOCUMENT_PERMISSION_LEVELS.OWNER; + } + return actor; + } + get armorStatus() { + const armorData = {}; + if (this.system.armor.active.special) { + armorData.special = this.system.armor.checked.special; + } + if (this.system.armor.active.heavy) { + armorData.max = 2; + if (this.system.armor.checked.light) { + armorData.value = 0; + } + else if (this.system.armor.checked.heavy) { + armorData.value = 1; + } + else { + armorData.value = 2; + } + } + else if (this.system.armor.active.light) { + armorData.max = 1; + if (this.system.armor.checked.light) { + armorData.value = 0; + } + else { + armorData.value = 1; + } + } + else { + armorData.max = 0; + armorData.value = 0; + } + return armorData; + } + isMember(crew) { return this.crew?.id === crew.id; } + get vice() { + if (this.type !== BladesActorType.pc) { + return undefined; + } + return this.activeSubItems.find((item) => item.type === BladesItemType.vice); + } + get crew() { + return this.activeSubActors.find((subActor) => BladesActor.IsType(subActor, BladesActorType.crew)); + } + get abilities() { + if (!this.playbook) { + return []; + } + return this.activeSubItems.filter((item) => [BladesItemType.ability, BladesItemType.crew_ability].includes(item.type)); + } + get playbookName() { + return this.playbook?.name; + } + get playbook() { + return this.activeSubItems.find((item) => item.type === BladesItemType.playbook); + } + get attributes() { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return undefined; + } + return { + insight: Object.values(this.system.attributes.insight).filter(({ value }) => value > 0).length + this.system.resistance_bonus.insight, + prowess: Object.values(this.system.attributes.prowess).filter(({ value }) => value > 0).length + this.system.resistance_bonus.prowess, + resolve: Object.values(this.system.attributes.resolve).filter(({ value }) => value > 0).length + this.system.resistance_bonus.resolve + }; + } + get actions() { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return undefined; + } + return U.objMap({ + ...this.system.attributes.insight, + ...this.system.attributes.prowess, + ...this.system.attributes.resolve + }, ({ value, max }) => U.gsap.utils.clamp(0, max, value)); + } + get rollable() { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return undefined; + } + return { + ...this.attributes, + ...this.actions + }; + } + get trauma() { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return 0; + } + return Object.keys(this.system.trauma.checked) + .filter((traumaName) => + this.system.trauma.active[traumaName] && this.system.trauma.checked[traumaName]) + .length; + } + get traumaList() { + return BladesActor.IsType(this, BladesActorType.pc) ? Object.keys(this.system.trauma.active).filter((key) => this.system.trauma.active[key]) : []; + } + get activeTraumaConditions() { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return {}; + } + return U.objFilter(this.system.trauma.checked, + (_v, traumaName) => Boolean(traumaName in this.system.trauma.active && this.system.trauma.active[traumaName])); + } + get currentLoad() { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return 0; + } + const activeLoadItems = this.activeSubItems.filter((item) => item.type === BladesItemType.gear); + return U.gsap.utils.clamp(0, 10, activeLoadItems.reduce((tot, i) => tot + U.pInt(i.system.load), 0)); + } + get remainingLoad() { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return 0; + } + if (!this.system.loadout.selected) { + return 0; + } + const maxLoad = this.system.loadout.levels[game.i18n.localize(this.system.loadout.selected.toString()).toLowerCase()]; + return Math.max(0, maxLoad - this.currentLoad); + } + async addStash(amount) { + if (!BladesActor.IsType(this, BladesActorType.pc)) { + return; + } + this.update({ "system.stash.value": Math.min(this.system.stash.value + amount, this.system.stash.max) }); + } + get rollFactors() { + const factorData = { + [Factor.tier]: { + name: Factor.tier, + value: this.getFactorTotal(Factor.tier), + max: this.getFactorTotal(Factor.tier), + baseVal: this.getFactorTotal(Factor.tier), + isActive: true, + isPrimary: true, + isDominant: false, + highFavorsPC: true + }, + [Factor.quality]: { + name: Factor.quality, + value: this.getFactorTotal(Factor.quality), + max: this.getFactorTotal(Factor.quality), + baseVal: this.getFactorTotal(Factor.quality), + isActive: false, + isPrimary: false, + isDominant: false, + highFavorsPC: true + } + }; + return factorData; + } + get rollPrimaryID() { return this.id; } + get rollPrimaryDoc() { return this; } + get rollPrimaryName() { return this.name; } + get rollPrimaryType() { return this.type; } + get rollPrimaryImg() { return this.img; } + get rollModsData() { + const rollModsData = BladesRollMod.ParseDocRollMods(this); + [[/1d/, RollModCategory.roll], [/Less Effect/, RollModCategory.effect]].forEach(([effectPat, effectCat]) => { + const { one: harmConditionOne, two: harmConditionTwo } = Object.values(this.system.harm) + .find((harmData) => effectPat.test(harmData.effect)) ?? {}; + const harmString = U.objCompact([harmConditionOne, harmConditionTwo === "" ? null : harmConditionTwo]).join(" & "); + if (harmString.length > 0) { + rollModsData.push({ + id: `Harm-negative-${effectCat}`, + name: harmString, + category: effectCat, + posNeg: "negative", + base_status: RollModStatus.ToggledOn, + modType: "harm", + value: 1, + tooltip: [ + `

${effectCat === RollModCategory.roll ? Harm.Impaired : Harm.Weakened} (Harm)

`, + `

${harmString}

`, + effectCat === RollModCategory.roll + ? "

If your injuries apply to the situation at hand, you suffer −1d to your roll.

" + : "

If your injuries apply to the situation at hand, you suffer −1 effect." + ].join("") + }); + } + }); + const { one: harmCondition } = Object.values(this.system.harm).find((harmData) => /Need Help/.test(harmData.effect)) ?? {}; + if (harmCondition && harmCondition.trim() !== "") { + rollModsData.push({ + id: "Push-negative-roll", + name: "PUSH", + sideString: harmCondition.trim(), + category: RollModCategory.roll, + posNeg: "negative", + base_status: RollModStatus.ToggledOn, + modType: "harm", + value: 0, + effectKeys: ["Cost-Stress2"], + tooltip: [ + "

Broken (Harm)

", + `

${harmCondition.trim()}

`, + "

If your injuries apply to the situation at hand, you must Push to act.

" + ].join("") + }); + } + return rollModsData; + } + + + get rollParticipantID() { return this.id; } + get rollParticipantDoc() { return this; } + get rollParticipantIcon() { return this.playbook?.img ?? this.img; } + get rollParticipantName() { return this.name ?? ""; } + get rollParticipantType() { return this.type; } + get rollParticipantModsData() { return []; } + + get rollTraitPCTooltipActions() { + const tooltipStrings = [""]; + const actionRatings = this.actions; + Object.values(AttributeTrait).forEach((attribute) => { + C.Action[attribute].forEach((action) => { + tooltipStrings.push([ + "", + ``, + ``, + ``, + "" + ].join("")); + }); + }); + tooltipStrings.push("
${U.uCase(action)}${"⚪".repeat(actionRatings[action])}(${C.ShortActionTooltips[action]})
"); + return tooltipStrings.join(""); + } + get rollTraitPCTooltipAttributes() { + const tooltipStrings = [""]; + const attributeRatings = this.attributes; + Object.values(AttributeTrait).forEach((attribute) => { + tooltipStrings.push([ + "", + ``, + ``, + ``, + "" + ].join("")); + }); + tooltipStrings.push("
${U.uCase(attribute)}${"⚪".repeat(attributeRatings[attribute])}(${C.ShortAttributeTooltips[attribute]})
"); + return tooltipStrings.join(""); + } +} +export default BladesPC; +//# sourceMappingURL=BladesPC.js.map +//# sourceMappingURL=BladesPC.js.map diff --git a/module/documents/items/BladesClockKeeper.js b/module/documents/items/BladesClockKeeper.js new file mode 100644 index 00000000..45e519b0 --- /dev/null +++ b/module/documents/items/BladesClockKeeper.js @@ -0,0 +1,189 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesItem from "../../BladesItem.js"; +import C, { SVGDATA, BladesActorType, BladesItemType } from "../../core/constants.js"; +import U from "../../core/utilities.js"; +import BladesActor from "../../BladesActor.js"; +class BladesClockKeeper extends BladesItem { + + _overlayElement; + get overlayElement() { + this._overlayElement ??= $("#clocks-overlay")[0]; + if (!this._overlayElement) { + $("body.vtt.game.system-eunos-blades").append("
"); + [this._overlayElement] = $("#clocks-overlay"); + } + return this._overlayElement; + } + async renderOverlay() { + if (!game.scenes?.current) { + return; + } + if (!game.eunoblades.ClockKeeper) { + return; + } + if (!game.eunoblades.ClockKeeper.overlayElement) { + eLog.error("clocksOverlay", "[ClocksOverlay] Cannot locate overlay element."); + return; + } + game.eunoblades.ClockKeeper.overlayElement.innerHTML = (await getTemplate("systems/eunos-blades/templates/overlays/clock-overlay.hbs"))({ + ...game.eunoblades.ClockKeeper.system, + currentScene: game.scenes?.current.id, + clockSizes: C.ClockSizes, + svgData: SVGDATA + }); + game.eunoblades.ClockKeeper.activateOverlayListeners(); + } + async activateOverlayListeners() { + if (!game?.user?.isGM) { + return; + } + $("#clocks-overlay").find(".clock-frame").on("wheel", async (event) => { + if (!event.currentTarget) { + return; + } + if (!BladesItem.IsType(game.eunoblades.ClockKeeper, BladesItemType.clock_keeper)) { + return; + } + if (!(event.originalEvent instanceof WheelEvent)) { + return; + } + event.preventDefault(); + const clock$ = $(event.currentTarget).closest(".clock"); + const [key] = clock$.closest(".clock-key"); + if (!(key instanceof HTMLElement)) { + return; + } + const keyID = key.id; + const clockNum = clock$.data("index"); + const curClockVal = U.pInt(clock$.data("value")); + const delta = event.originalEvent.deltaY < 0 ? 1 : -1; + const max = U.pInt(clock$.data("size")); + const newClockVal = U.gsap.utils.clamp(0, max, curClockVal + delta); + if (curClockVal === newClockVal) { + return; + } + await game.eunoblades.ClockKeeper.update({ + [`system.clock_keys.${keyID}.clocks.${clockNum}.value`]: `${newClockVal}` + }); + }); + $("#clocks-overlay").find(".key-label").on({ + click: async (event) => { + if (!event.currentTarget) { + return; + } + if (!BladesItem.IsType(game.eunoblades.ClockKeeper, BladesItemType.clock_keeper)) { + return; + } + event.preventDefault(); + const keyID = $(event.currentTarget).data("keyId"); + eLog.checkLog3("clocksOverlay", "Updating Key isActive", { + current: game.eunoblades.ClockKeeper.system.clock_keys[keyID]?.isActive, + update: !(game.eunoblades.ClockKeeper.system.clock_keys[keyID]?.isActive) + }); + await game.eunoblades.ClockKeeper.update({ [`system.clock_keys.${keyID}.isActive`]: !(game.eunoblades.ClockKeeper.system.clock_keys[keyID]?.isActive) }); + }, + contextmenu: () => { + if (!game.user.isGM) { + return; + } + game.eunoblades.ClockKeeper?.sheet?.render(true); + } + }); + } + async addClockKey() { + if (!BladesItem.IsType(game.eunoblades.ClockKeeper, BladesItemType.clock_keeper)) { + return undefined; + } + const keyID = randomID(); + return game.eunoblades.ClockKeeper.update({ [`system.clock_keys.${keyID}`]: { + id: keyID, + display: "", + isVisible: false, + isNameVisible: true, + isActive: true, + scene: game.eunoblades.ClockKeeper.system.targetScene, + numClocks: 1, + clocks: { + 1: { + display: "", + isVisible: false, + isNameVisible: false, + isActive: false, + color: "yellow", + max: 4, + value: 0 + } + } + } }); + } + async deleteClockKey(keyID) { + if (!BladesItem.IsType(game.eunoblades.ClockKeeper, BladesItemType.clock_keeper)) { + return undefined; + } + return game.eunoblades.ClockKeeper.update({ [`system.clock_keys.-=${keyID}`]: null }); + } + async setKeySize(keyID, keySize = 1) { + if (!BladesItem.IsType(game.eunoblades.ClockKeeper, BladesItemType.clock_keeper)) { + return undefined; + } + keySize = parseInt(`${keySize}`, 10); + const updateData = { + [`system.clock_keys.${keyID}.numClocks`]: keySize + }; + const clockKey = game.eunoblades.ClockKeeper.system.clock_keys[keyID]; + if (!clockKey) { + return game.eunoblades.ClockKeeper; + } + const currentSize = Object.values(clockKey.clocks).length; + if (currentSize < keySize) { + for (let i = (currentSize + 1); i <= keySize; i++) { + updateData[`system.clock_keys.${keyID}.clocks.${i}`] = { + display: "", + value: 0, + max: 4, + color: "yellow", + isVisible: false, + isNameVisible: true, + isActive: false + }; + } + } + else if (currentSize > keySize) { + for (let i = (keySize + 1); i <= currentSize; i++) { + updateData[`system.clock_keys.${keyID}.clocks.-=${i}`] = null; + } + } + eLog.checkLog("clock_key", "Clock Key Update Data", { clockKey, updateData }); + return game.eunoblades.ClockKeeper.update(updateData); + } + + prepareDerivedData() { + super.prepareDerivedData(); + this.system.scenes = game.scenes.map((scene) => ({ id: scene.id, name: scene.name ?? "" })); + this.system.targetScene ??= game.scenes.current?.id || null; + this.system.clock_keys = Object.fromEntries(Object.entries(this.system.clock_keys ?? {}) + .filter(([_, keyData]) => keyData?.id) + .map(([keyID, keyData]) => { + if (keyData === null) { + return [keyID, null]; + } + keyData.clocks = Object.fromEntries(Object.entries(keyData.clocks ?? {}) + .filter(([_, clockData]) => Boolean(clockData))); + return [keyID, keyData]; + })); + } + async _onUpdate(changed, options, userId) { + super._onUpdate(changed, options, userId); + BladesActor.GetTypeWithTags(BladesActorType.pc).forEach((actor) => actor.render()); + socketlib.system.executeForEveryone("renderOverlay"); + } +} +export default BladesClockKeeper; +//# sourceMappingURL=BladesClockKeeper.js.map +//# sourceMappingURL=BladesClockKeeper.js.map diff --git a/module/documents/items/BladesGMTracker.js b/module/documents/items/BladesGMTracker.js new file mode 100644 index 00000000..f3a8dcfd --- /dev/null +++ b/module/documents/items/BladesGMTracker.js @@ -0,0 +1,29 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesItem from "../../BladesItem.js"; +import { BladesActorType, BladesItemType, BladesPhase } from "../../core/constants.js"; +import BladesActor from "../../BladesActor.js"; +class BladesGMTracker extends BladesItem { + get phase() { return BladesItem.IsType(this, BladesItemType.gm_tracker) && this.system.phase; } + set phase(phase) { + if (phase && BladesItem.IsType(this, BladesItemType.gm_tracker)) { + this.update({ "system.phase": phase }); + } + } + prepareDerivedData() { + this.system.phases = Object.values(BladesPhase); + } + + async _onUpdate(changed, options, userId) { + await super._onUpdate(changed, options, userId); + BladesActor.GetTypeWithTags(BladesActorType.pc).forEach((actor) => actor.render()); + } +} +export default BladesGMTracker; +//# sourceMappingURL=BladesGMTracker.js.map +//# sourceMappingURL=BladesGMTracker.js.map diff --git a/module/documents/items/BladesLocation.js b/module/documents/items/BladesLocation.js new file mode 100644 index 00000000..32e24bce --- /dev/null +++ b/module/documents/items/BladesLocation.js @@ -0,0 +1,53 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesItem from "../../BladesItem.js"; +import { BladesActorType, Factor } from "../../core/constants.js"; +import U from "../../core/utilities.js"; +import BladesActor from "../../BladesActor.js"; +class BladesLocation extends BladesItem { + get rollFactors() { + const factorData = {}; + [ + Factor.tier, + Factor.quality, + Factor.scale + ].forEach((factor, i) => { + const factorTotal = this.getFactorTotal(factor); + factorData[factor] = { + name: factor, + value: factorTotal, + max: factorTotal, + baseVal: factorTotal, + display: factor === Factor.tier ? U.romanizeNum(factorTotal) : `${factorTotal}`, + isActive: i === 0, + isPrimary: i === 0, + isDominant: false, + highFavorsPC: true, + cssClasses: `factor-gold${i === 0 ? " factor-main" : ""}` + }; + }); + return factorData; + } + getFactorTotal(factor) { + switch (factor) { + case Factor.tier: return this.system.tier.value; + case Factor.quality: return this.getFactorTotal(Factor.tier); + case Factor.scale: return this.system.scale; + } + return 0; + } + get rollOppImg() { return this.img ?? ""; } + + async _onUpdate(changed, options, userId) { + await super._onUpdate(changed, options, userId); + BladesActor.GetTypeWithTags(BladesActorType.pc).forEach((actor) => actor.render()); + } +} +export default BladesLocation; +//# sourceMappingURL=BladesLocation.js.map +//# sourceMappingURL=BladesLocation.js.map diff --git a/module/documents/items/BladesScore.js b/module/documents/items/BladesScore.js new file mode 100644 index 00000000..8e3e5a39 --- /dev/null +++ b/module/documents/items/BladesScore.js @@ -0,0 +1,69 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesItem from "../../BladesItem.js"; +import { BladesActorType, BladesItemType, Factor } from "../../core/constants.js"; +import U from "../../core/utilities.js"; +import BladesActor from "../../BladesActor.js"; +import BladesScoreSheet from "../../sheets/item/BladesScoreSheet.js"; +class BladesScore extends BladesItem { + + static async Initialize() { + game.eunoblades ??= {}; + Object.assign(globalThis, { BladesScore, BladesScoreSheet }); + Items.registerSheet("blades", BladesScoreSheet, { types: ["score"], makeDefault: true }); + return loadTemplates(["systems/eunos-blades/templates/items/score-sheet.hbs"]); + } + static get Active() { + return BladesItem.GetTypeWithTags(BladesItemType.score).find((score) => score.system.isActive); + } + static set Active(val) { + BladesItem.GetTypeWithTags(BladesItemType.score) + .find((score) => score.system.isActive)?.update({ "system.isActive": false }) + .then(() => { + if (val) { + val.update({ "system.isActive": true }); + } + }); + } + + get rollFactors() { + const tierTotal = this.getFactorTotal(Factor.tier); + return { + [Factor.tier]: { + name: "Tier", + value: tierTotal, + max: tierTotal, + baseVal: tierTotal, + display: U.romanizeNum(tierTotal), + isActive: true, + isPrimary: true, + isDominant: false, + highFavorsPC: true, + cssClasses: "factor-gold factor-main" + } + }; + } + get rollOppImg() { return this.img ?? ""; } + getFactorTotal(factor) { + switch (factor) { + case Factor.tier: return this.system.tier.value; + case Factor.quality: return this.getFactorTotal(Factor.tier); + case Factor.scale: return 0; + case Factor.magnitude: return 0; + default: return 0; + } + } + + async _onUpdate(changed, options, userId) { + super._onUpdate(changed, options, userId); + BladesActor.GetTypeWithTags(BladesActorType.pc).forEach((actor) => actor.render()); + } +} +export default BladesScore; +//# sourceMappingURL=BladesScore.js.map +//# sourceMappingURL=BladesScore.js.map diff --git a/module/sheets/actor/BladesActorSheet.js b/module/sheets/actor/BladesActorSheet.js new file mode 100644 index 00000000..9f0347f1 --- /dev/null +++ b/module/sheets/actor/BladesActorSheet.js @@ -0,0 +1,400 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + + +import U from "../../core/utilities.js"; +import G, { ApplyTooltipListeners } from "../../core/gsap.js"; +import C, { BladesActorType, BladesItemType, AttributeTrait, ActionTrait, Factor, RollType } from "../../core/constants.js"; +import Tags from "../../core/tags.js"; +import BladesActor from "../../BladesActor.js"; +import BladesItem from "../../BladesItem.js"; +import BladesSelectorDialog from "../../BladesDialog.js"; +import BladesActiveEffect from "../../BladesActiveEffect.js"; +import BladesRollCollab, { BladesRollCollabComps } from "../../BladesRollCollab.js"; +class BladesActorSheet extends ActorSheet { + getData() { + const context = super.getData(); + const sheetData = { + cssClass: this.actor.type, + editable: this.options.editable, + isGM: game.eunoblades.Tracker?.system.is_spoofing_player ? false : game.user.isGM, + actor: this.actor, + system: this.actor.system, + tierTotal: this.actor.getFactorTotal(Factor.tier) > 0 ? U.romanizeNum(this.actor.getFactorTotal(Factor.tier)) : "0", + rollData: this.actor.getRollData(), + activeEffects: Array.from(this.actor.effects), + hasFullVision: game.user.isGM || this.actor.testUserPermission(game.user, CONST.DOCUMENT_PERMISSION_LEVELS.OBSERVER), + hasLimitedVision: game.user.isGM || this.actor.testUserPermission(game.user, CONST.DOCUMENT_PERMISSION_LEVELS.LIMITED), + hasControl: game.user.isGM || this.actor.testUserPermission(game.user, CONST.DOCUMENT_PERMISSION_LEVELS.OWNER), + preparedItems: { + cohorts: { + gang: this.actor.activeSubItems + .filter((item) => item.type === BladesItemType.cohort_gang) + .map((item) => { + const subtypes = U.unique(Object.values(item.system.subtypes) + .map((subtype) => subtype.trim()) + .filter((subtype) => /[A-Za-z]/.test(subtype))); + const eliteSubtypes = U.unique([ + ...Object.values(item.system.elite_subtypes), + ...(item.parent?.upgrades ?? []) + .map((upgrade) => (upgrade.name ?? "").trim().replace(/^Elite /, "")) + ] + .map((subtype) => subtype.trim()) + .filter((subtype) => /[A-Za-z]/.test(subtype) && subtypes.includes(subtype))); + const imgTypes = [...eliteSubtypes]; + if (imgTypes.length < 2) { + imgTypes.push(...subtypes.filter((subtype) => !imgTypes.includes(subtype))); + } + if (U.unique(imgTypes).length === 1) { + item.system.image = Object.values(item.system.elite_subtypes).includes(imgTypes[0]) ? `elite-${U.lCase(imgTypes[0])}.svg` : `${U.lCase(imgTypes[0])}.svg`; + } + else if (U.unique(imgTypes).length > 1) { + const [rightType, leftType] = imgTypes; + item.system.imageLeft = Object.values(item.system.elite_subtypes).includes(leftType) ? `elite-${U.lCase(leftType)}.svg` : `${U.lCase(leftType)}.svg`; + item.system.imageRight = Object.values(item.system.elite_subtypes).includes(rightType) ? `elite-${U.lCase(rightType)}.svg` : `${U.lCase(rightType)}.svg`; + } + Object.assign(item.system, { + tierTotal: item.getFactorTotal(Factor.tier) > 0 ? U.romanizeNum(item.getFactorTotal(Factor.tier)) : "0", + cohortRollData: [ + { mode: "untrained", label: "Untrained", color: "transparent", tooltip: "

Roll Untrained

" } + ], + edgeData: Object.fromEntries(Object.values(item.system.edges ?? []) + .filter((edge) => /[A-Za-z]/.test(edge)) + .map((edge) => [edge.trim(), C.EdgeTooltips[edge]])), + flawData: Object.fromEntries(Object.values(item.system.flaws ?? []) + .filter((flaw) => /[A-Za-z]/.test(flaw)) + .map((flaw) => [flaw.trim(), C.FlawTooltips[flaw]])) + }); + return item; + }), + expert: this.actor.activeSubItems + .filter((item) => item.type === BladesItemType.cohort_expert) + .map((item) => { + Object.assign(item.system, { + tierTotal: item.getFactorTotal(Factor.tier) > 0 ? U.romanizeNum(item.getFactorTotal(Factor.tier)) : "0", + cohortRollData: [ + { mode: "untrained", label: "Untrained", tooltip: "

Roll Untrained

" } + ], + edgeData: Object.fromEntries(Object.values(item.system.edges ?? []) + .filter((edge) => /[A-Za-z]/.test(edge)) + .map((edge) => [edge.trim(), C.EdgeTooltips[edge]])), + flawData: Object.fromEntries(Object.values(item.system.flaws ?? []) + .filter((flaw) => /[A-Za-z]/.test(flaw)) + .map((flaw) => [flaw.trim(), C.FlawTooltips[flaw]])) + }); + return item; + }) + } + } + }; + if (BladesActor.IsType(this.actor, BladesActorType.pc) || BladesActor.IsType(this.actor, BladesActorType.crew)) { + sheetData.playbookData = { + dotline: { + data: this.actor.system.experience.playbook, + dotlineClass: "xp-playbook", + target: "system.experience.playbook.value", + svgKey: "teeth.tall", + svgFull: "full|frame", + svgEmpty: "full|half|frame", + advanceButton: "advance-playbook" + } + }; + if (this.actor.system.experience.playbook.value !== this.actor.system.experience.playbook.max) { + sheetData.playbookData.tooltip = (new Handlebars.SafeString([ + "

At the End of the Session, Gain XP If ...

", + "" + ].join(""))).toString(); + } + sheetData.coinsData = { + dotline: { + data: this.actor.system.coins, + target: "system.coins.value", + iconEmpty: "coin-full.svg", + iconFull: "coin-full.svg" + } + }; + } + return { + ...context, + ...sheetData + }; + } + activateListeners(html) { + super.activateListeners(html); + if (game.user.isGM) { + html.attr("style", "--secret-text-display: initial"); + } + else { + html.find('.editor:not(.tinymce) [data-is-secret="true"]').remove(); + } + + ApplyTooltipListeners(html); + Tags.InitListeners(html, this.actor); + if (!this.options.editable) { + return; + } + html.find(".dotline").each((__, elem) => { + if ($(elem).hasClass("locked")) { + return; + } + let targetDoc = this.actor; + let targetField = $(elem).data("target"); + const comp$ = $(elem).closest("comp"); + if (targetField.startsWith("item")) { + targetField = targetField.replace(/^item\./, ""); + const itemId = $(elem).closest("[data-comp-id]").data("compId"); + if (!itemId) { + return; + } + const item = this.actor.items.get(itemId); + if (!item) { + return; + } + targetDoc = item; + } + const curValue = U.pInt($(elem).data("value")); + $(elem) + .find(".dot") + .each((_, dot) => { + $(dot).on("click", (event) => { + event.preventDefault(); + const thisValue = U.pInt($(dot).data("value")); + if (thisValue !== curValue) { + if (comp$.hasClass("comp-coins") + || comp$.hasClass("comp-stash")) { + G.effects + .fillCoins($(dot).prevAll(".dot")) + .then(() => targetDoc.update({ [targetField]: thisValue })); + } + else { + targetDoc.update({ [targetField]: thisValue }); + } + } + }); + $(dot).on("contextmenu", (event) => { + event.preventDefault(); + const thisValue = U.pInt($(dot).data("value")) - 1; + if (thisValue !== curValue) { + targetDoc.update({ [targetField]: thisValue }); + } + }); + }); + }); + html + .find(".clock-container") + .on("click", this._onClockLeftClick.bind(this)); + html + .find(".clock-container") + .on("contextmenu", this._onClockRightClick.bind(this)); + html + .find("[data-comp-id]") + .find(".comp-title") + .on("click", this._onItemOpenClick.bind(this)); + html + .find(".comp-control.comp-add") + .on("click", this._onItemAddClick.bind(this)); + html + .find(".comp-control.comp-delete") + .on("click", this._onItemRemoveClick.bind(this)); + html + .find(".comp-control.comp-delete-full") + .on("click", this._onItemFullRemoveClick.bind(this)); + html + .find(".comp-control.comp-toggle") + .on("click", this._onItemToggleClick.bind(this)); + html + .find(".advance-button") + .on("click", this._onAdvanceClick.bind(this)); + html + .find(".effect-control") + .on("click", this._onActiveEffectControlClick.bind(this)); + html + .find("[data-roll-trait]") + .on("click", this._onRollTraitClick.bind(this)); + if (this.options.submitOnChange) { + html.on("change", "textarea", this._onChangeInput.bind(this)); + } + } + async _onSubmit(event, params = {}) { + if (!game.user.isGM && !this.actor.testUserPermission(game.user, CONST.DOCUMENT_PERMISSION_LEVELS.OWNER)) { + eLog.checkLog("actorSheetTrigger", "User does not have permission to edit this actor", { user: game.user, actor: this.actor }); + return {}; + } + return super._onSubmit(event, params); + } + async close(options) { + if (this.actor.type === BladesActorType.pc) { + return super.close(options).then(() => this.actor.clearSubActors()); + } + else if (this.actor.type === BladesActorType.npc && this.actor.parentActor) { + return super.close(options).then(() => this.actor.clearParentActor(false)); + } + return super.close(options); + } + + async _onClockLeftClick(event) { + event.preventDefault(); + const clock$ = $(event.currentTarget).find(".clock[data-target]"); + if (!clock$[0]) { + return; + } + const target = clock$.data("target"); + const curValue = U.pInt(clock$.data("value")); + const maxValue = U.pInt(clock$.data("size")); + G.effects.pulseClockWedges(clock$.find("wedges")).then(() => this.actor.update({ + [target]: G.utils.wrap(0, maxValue + 1, curValue + 1) + })); + } + async _onClockRightClick(event) { + event.preventDefault(); + const clock$ = $(event.currentTarget).find(".clock[data-target]"); + if (!clock$[0]) { + return; + } + const target = clock$.data("target"); + const curValue = U.pInt(clock$.data("value")); + G.effects.reversePulseClockWedges(clock$.find("wedges")).then(() => this.actor.update({ + [target]: Math.max(0, curValue - 1) + })); + } + + _getCompData(event) { + const elem$ = $(event.currentTarget).closest(".comp"); + const compData = { + elem$, + docID: elem$.data("compId"), + docCat: elem$.data("compCat"), + docType: elem$.data("compType"), + docTags: (elem$.data("compTags") ?? "").split(/\s+/g) + }; + eLog.checkLog2("dialog", "Component Data", { elem: elem$, ...compData }); + if (compData.docID && compData.docType) { + compData.doc = { + Actor: this.actor.getSubActor(compData.docID), + Item: this.actor.getSubItem(compData.docID) + }[compData.docType]; + } + if (compData.docCat && compData.docType) { + compData.dialogDocs = { + Actor: this.actor.getDialogActors(compData.docCat), + Item: this.actor.getDialogItems(compData.docCat) + }[compData.docType]; + } + return compData; + } + async _onItemOpenClick(event) { + event.preventDefault(); + const { doc } = this._getCompData(event); + if (!doc) { + return; + } + doc.sheet?.render(true); + } + async _onItemAddClick(event) { + event.preventDefault(); + const addType = $(event.currentTarget).closest(".comp").data("addType"); + if (addType && addType in BladesItemType) { + await this.actor.createEmbeddedDocuments("Item", [ + { + name: { + [BladesItemType.cohort_gang]: "A Gang", + [BladesItemType.cohort_expert]: "An Expert" + }[addType] ?? randomID(), + type: addType + } + ]); + return; + } + const { docCat, docType, dialogDocs, docTags } = this._getCompData(event); + if (!dialogDocs || !docCat || !docType) { + return; + } + await BladesSelectorDialog.Display(this.actor, U.tCase(`Add ${docCat.replace(/_/g, " ")}`), docType, dialogDocs, docTags); + } + async _onItemRemoveClick(event) { + event.preventDefault(); + const { elem$, doc } = this._getCompData(event); + if (!doc) { + return; + } + G.effects.blurRemove(elem$).then(() => { + if (doc instanceof BladesItem) { + this.actor.remSubItem(doc); + } + else { + this.actor.remSubActor(doc); + } + }); + } + async _onItemFullRemoveClick(event) { + event.preventDefault(); + const { elem$, doc } = this._getCompData(event); + if (!doc) { + return; + } + G.effects.blurRemove(elem$).then(() => doc.delete()); + } + async _onItemToggleClick(event) { + event.preventDefault(); + const target = $(event.currentTarget).data("target"); + this.actor.update({ + [target]: !getProperty(this.actor, target) + }); + } + async _onAdvanceClick(event) { + event.preventDefault(); + if ($(event.currentTarget).data("action") === "advance-playbook") { + this.actor.advancePlaybook(); + } + } + + async _onRollTraitClick(event) { + const traitName = $(event.currentTarget).data("rollTrait"); + const rollType = $(event.currentTarget).data("rollType"); + const rollData = {}; + if (U.lCase(traitName) in { ...ActionTrait, ...AttributeTrait, ...Factor }) { + rollData.rollTrait = U.lCase(traitName); + } + else if (U.isInt(traitName)) { + rollData.rollTrait = U.pInt(traitName); + } + if (U.tCase(rollType) in RollType) { + rollData.rollType = U.tCase(rollType); + } + else if (typeof rollData.rollTrait === "string") { + if (rollData.rollTrait in AttributeTrait) { + rollData.rollType = RollType.Resistance; + } + else if (rollData.rollTrait in ActionTrait) { + rollData.rollType = RollType.Action; + } + } + if (game.user.isGM) { + if (BladesRollCollabComps.Primary.IsDoc(this.actor)) { + rollData.rollPrimary = { rollPrimaryDoc: this.actor }; + } + else if (BladesRollCollabComps.Opposition.IsDoc(this.actor)) { + rollData.rollOpp = { rollOppDoc: this.actor }; + } + } + if (rollData.rollType) { + BladesRollCollab.NewRoll(rollData); + } + else { + throw new Error("Unable to determine roll type of roll."); + } + } + + async _onActiveEffectControlClick(event) { + BladesActiveEffect.onManageActiveEffect(event, this.actor); + } +} +export default BladesActorSheet; +//# sourceMappingURL=BladesActorSheet.js.map +//# sourceMappingURL=BladesActorSheet.js.map diff --git a/module/sheets/actor/BladesCrewSheet.js b/module/sheets/actor/BladesCrewSheet.js new file mode 100644 index 00000000..f51103b8 --- /dev/null +++ b/module/sheets/actor/BladesCrewSheet.js @@ -0,0 +1,157 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesActorSheet from "./BladesActorSheet.js"; +import { BladesItemType } from "../../core/constants.js"; +class BladesCrewSheet extends BladesActorSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "actor", "crew"], + template: "systems/eunos-blades/templates/crew-sheet.hbs", + width: 940, + height: 820, + tabs: [{ navSelector: ".nav-tabs", contentSelector: ".tab-content", initial: "claims" }] + }); + } + getData() { + const context = super.getData(); + eLog.checkLog("actor", "[BladesCrewSheet] super.getData()", { ...context }); + const { activeSubItems } = this.actor; + const sheetData = {}; + + sheetData.preparedItems = Object.assign(context.preparedItems ?? {}, { + abilities: activeSubItems.filter((item) => item.type === BladesItemType.crew_ability), + playbook: this.actor.playbook, + reputation: activeSubItems.find((item) => item.type === BladesItemType.crew_reputation), + upgrades: activeSubItems.filter((item) => item.type === BladesItemType.crew_upgrade), + preferredOp: activeSubItems.find((item) => item.type === BladesItemType.preferred_op) + }); + sheetData.preparedActors = { + members: this.actor.members, + contacts: this.actor.contacts + }; + sheetData.tierData = { + label: "Tier", + dotline: { + data: this.actor.system.tier, + target: "system.tier.value", + iconEmpty: "dot-empty.svg", + iconEmptyHover: "dot-empty-hover.svg", + iconFull: "dot-full.svg", + iconFullHover: "dot-full-hover.svg" + } + }; + sheetData.upgradeData = { + dotline: { + dotlineClass: "dotline-right", + data: { + value: this.actor.availableUpgradePoints, + max: this.actor.availableUpgradePoints + }, + dotlineLabel: "Available Upgrade Points", + isLocked: true, + iconFull: "dot-full.svg" + } + }; + sheetData.abilityData = { + dotline: { + dotlineClass: "dotline-right", + data: { + value: this.actor.availableAbilityPoints, + max: this.actor.availableAbilityPoints + }, + dotlineLabel: "Available Ability Points", + isLocked: true, + iconFull: "dot-full.svg" + } + }; + sheetData.cohortData = { + dotline: { + dotlineClass: "dotline-right", + data: { + value: this.actor.availableCohortPoints, + max: this.actor.availableCohortPoints + }, + dotlineLabel: "Available Cohort Points", + isLocked: true, + iconFull: "dot-full.svg" + } + }; + sheetData.repData = { + label: "Rep", + dotlines: [ + { + data: { + value: Math.min(this.actor.system.rep.value, this.actor.system.rep.max - this.actor.turfCount), + max: this.actor.system.rep.max - this.actor.turfCount + }, + target: "system.rep.value", + svgKey: "teeth.tall", + svgFull: "full|half|frame", + svgEmpty: "full|half|frame" + }, + { + data: { + value: this.actor.turfCount, + max: this.actor.turfCount + }, + target: "none", + svgKey: "teeth.tall", + svgFull: "full|half|frame", + svgEmpty: "full|half|frame", + dotlineClass: "flex-row-reverse", + isLocked: true + } + ] + }; + sheetData.heatData = { + label: "Heat", + dotline: { + data: this.actor.system.heat, + target: "system.heat.value", + svgKey: "teeth.tall", + svgFull: "full|half|frame", + svgEmpty: "full|half|frame" + } + }; + sheetData.wantedData = { + label: "Wanted", + dotline: { + data: this.actor.system.wanted, + target: "system.wanted.value", + svgKey: "teeth.short", + svgFull: "full|frame", + svgEmpty: "frame" + } + }; + eLog.checkLog("actor", "[BladesCrewSheet] return getData()", { ...context, ...sheetData }); + return { ...context, ...sheetData }; + } + activateListeners(html) { + super.activateListeners(html); + if (!this.options.editable) { + return; + } + html.find(".item-sheet-open").on("click", (event) => { + const element = $(event.currentTarget).parents(".item"); + const item = this.actor.items.get(element.data("itemId")); + item?.sheet?.render(true); + }); + html.find(".hold-toggle").on("click", () => { + this.actor.update({ "system.hold": this.actor.system.hold === "weak" ? "strong" : "weak" }); + }); + html.find(".turf-select").on("click", async (event) => { + const turf_id = $(event.currentTarget).data("turfId"); + const turf_current_status = $(event.currentTarget).data("turfStatus"); + this.actor.playbook?.update({ ["system.turfs." + turf_id + ".value"]: !turf_current_status }) + .then(() => this.render(false)); + }); + } +} +export default BladesCrewSheet; +//# sourceMappingURL=BladesCrewSheet.js.map +//# sourceMappingURL=BladesCrewSheet.js.map diff --git a/module/sheets/actor/BladesFactionSheet.js b/module/sheets/actor/BladesFactionSheet.js new file mode 100644 index 00000000..2f884558 --- /dev/null +++ b/module/sheets/actor/BladesFactionSheet.js @@ -0,0 +1,77 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesActor from "../../BladesActor.js"; +import BladesActorSheet from "./BladesActorSheet.js"; +import { BladesActorType } from "../../core/constants.js"; +class BladesFactionSheet extends BladesActorSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "actor", "faction"], + template: "systems/eunos-blades/templates/faction-sheet.hbs", + width: 900, + height: "auto", + tabs: [{ navSelector: ".nav-tabs", contentSelector: ".tab-content", initial: "overview" }] + }); + } + getData() { + const context = super.getData(); + if (!BladesActor.IsType(this.actor, BladesActorType.faction)) { + return context; + } + const sheetData = { + tierData: { + "class": "comp-tier comp-vertical comp-teeth", + "label": "Tier", + "labelClass": "filled-label full-width", + "dotline": { + data: this.actor.system.tier, + target: "system.tier.value", + svgKey: "teeth.tall", + svgFull: "full|half|frame", + svgEmpty: "full|half|frame" + } + } + }; + return { + ...context, + ...sheetData + }; + } + async _onClockAddClick(event) { + event.preventDefault(); + this.actor.addClock(); + } + async _onClockDeleteClick(event) { + event.preventDefault(); + const clockID = $(event.currentTarget).data("clockId"); + if (!clockID) { + return; + } + this.actor.deleteClock(clockID); + } + activateListeners(html) { + super.activateListeners(html); + if (!this.options.editable) { + return; + } + html.find(".item-body").on("click", (event) => { + const element = $(event.currentTarget).parents(".item"); + const item = this.actor.items.get(element.data("itemId")); + item?.sheet?.render(true); + }); + html + .find(".comp-control.comp-add-clock") + .on("click", this._onClockAddClick.bind(this)); + html + .find(".comp-control.comp-delete-clock") + .on("click", this._onClockDeleteClick.bind(this)); + } +} +export default BladesFactionSheet; +//# sourceMappingURL=BladesFactionSheet.js.map +//# sourceMappingURL=BladesFactionSheet.js.map diff --git a/module/sheets/actor/BladesNPCSheet.js b/module/sheets/actor/BladesNPCSheet.js new file mode 100644 index 00000000..cfaf3a93 --- /dev/null +++ b/module/sheets/actor/BladesNPCSheet.js @@ -0,0 +1,95 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesActorSheet from "./BladesActorSheet.js"; +import U from "../../core/utilities.js"; +class BladesNPCSheet extends BladesActorSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "actor", "npc"], + template: "systems/eunos-blades/templates/npc-sheet.hbs", + width: 500, + height: 400, + tabs: [{ navSelector: ".nav-tabs", contentSelector: ".tab-content", initial: "description" }] + }); + } + getData() { + const context = super.getData(); + context.isSubActor = context.actor.isSubActor; + context.parentActor = context.actor.parentActor; + context.persona = context.actor.system.persona; + context.random = context.actor.system.random; + context.secret = context.actor.system.secret; + const rStatus = { + name: { size: 3, label: "Name" }, + gender: { size: "half", label: "Gender" }, + heritage: { size: "third", label: "Heritage" }, + background: { size: "third", label: "Background" }, + profession: { size: "third", label: "Profession" }, + appearance: { size: 2, label: "Appearance" }, + style: { size: 2, label: "Style" }, + quirk: { size: 4, label: "Quirk" }, + goal: { size: 2, label: "Goal" }, + method: { size: 2, label: "Method" }, + interests: { size: 4, label: "Interests" }, + trait: { size: "half", label: "Trait" }, + trait1: { size: "half", label: null }, + trait2: { size: "half", label: null }, + trait3: { size: "half", label: null } + }; + for (const cat of ["persona", "random", "secret"]) { + for (const [key] of Object.entries(context[cat])) { + if (key in rStatus) { + Object.assign(context[cat][key], rStatus[key]); + } + } + } + console.log({ persona: context.persona, random: context.random, secret: context.secret }); + return context; + } + activateListeners(html) { + super.activateListeners(html); + if (!this.options.editable) { + return; + } + html.find(".gm-alert-header").on("click", async (event) => { + event.preventDefault(); + this.actor.clearParentActor(); + }); + + html.find("[data-action=\"randomize\"").on("click", (event) => { + this.actor.updateRandomizers(); + }); + + html.find(".comp-status-toggle") + .on("click", () => { + const { tags } = this.actor; + if (this.actor.system.status === 1) { + U.remove(tags, "Friend"); + tags.push("Rival"); + this.actor.update({ + "system.status": -1, + "system.tags": U.unique(tags) + }); + } + else { + U.remove(tags, "Rival"); + tags.push("Friend"); + this.actor.update({ + "system.status": 1, + "system.tags": U.unique(tags) + }); + } + }) + .on("contextmenu", () => { + this.actor.update({ "system.status": 0 }); + }); + } +} +export default BladesNPCSheet; +//# sourceMappingURL=BladesNPCSheet.js.map +//# sourceMappingURL=BladesNPCSheet.js.map diff --git a/module/sheets/actor/BladesPCSheet.js b/module/sheets/actor/BladesPCSheet.js new file mode 100644 index 00000000..e560b23e --- /dev/null +++ b/module/sheets/actor/BladesPCSheet.js @@ -0,0 +1,346 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import C, { BladesActorType, BladesItemType, AttributeTrait, Tag, BladesPhase } from "../../core/constants.js"; +import U from "../../core/utilities.js"; +import BladesActorSheet from "./BladesActorSheet.js"; +import { BladesActor } from "../../documents/BladesActorProxy.js"; +import BladesGMTrackerSheet from "../item/BladesGMTrackerSheet.js"; +class BladesPCSheet extends BladesActorSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "actor", "pc"], + template: "systems/eunos-blades/templates/actor-sheet.hbs", + width: 775, + height: 775, + tabs: [{ navSelector: ".nav-tabs", contentSelector: ".tab-content", initial: "abilities" }] + }); + } + static Initialize() { + Actors.registerSheet("blades", BladesPCSheet, { types: ["pc"], makeDefault: true }); + Hooks.on("dropActorSheetData", async (parentActor, _, { uuid }) => { + const doc = await fromUuid(uuid); + if (doc instanceof BladesActor) { + if (parentActor.type === BladesActorType.crew && doc.type === BladesActorType.pc) { + doc.addSubActor(parentActor); + } + else if (parentActor.type === BladesActorType.pc && doc.type === BladesActorType.crew) { + parentActor.addSubActor(doc); + } + } + }); + return loadTemplates([ + "systems/eunos-blades/templates/items/clock_keeper-sheet.hbs", + "systems/eunos-blades/templates/parts/clock-sheet-row.hbs" + ]); + } + getData() { + const context = super.getData(); + const { activeSubItems, activeSubActors } = this.actor; + const sheetData = {}; + + sheetData.preparedItems = Object.assign(context.preparedItems ?? {}, { + abilities: activeSubItems + .filter((item) => item.type === BladesItemType.ability) + .map((item) => { + if (item.system.uses_per_score.max) { + Object.assign(item, { + inRuleDotline: { + data: item.system.uses_per_score, + dotlineLabel: "Uses", + target: "item.system.uses_per_score.value", + iconEmpty: "dot-empty.svg", + iconEmptyHover: "dot-empty-hover.svg", + iconFull: "dot-full.svg", + iconFullHover: "dot-full-hover.svg" + } + }); + } + return item; + }), + background: activeSubItems.find((item) => item.type === BladesItemType.background), + heritage: activeSubItems.find((item) => item.type === BladesItemType.heritage), + vice: activeSubItems.find((item) => item.type === BladesItemType.vice), + loadout: activeSubItems.filter((item) => item.type === BladesItemType.gear).map((item) => { + if (item.system.load) { + Object.assign(item, { + numberCircle: item.system.load, + numberCircleClass: "item-load" + }); + } + if (item.system.uses_per_score.max) { + Object.assign(item, { + inRuleDotline: { + data: item.system.uses_per_score, + dotlineLabel: "Uses", + target: "item.system.uses_per_score.value", + iconEmpty: "dot-empty.svg", + iconEmptyHover: "dot-empty-hover.svg", + iconFull: "dot-full.svg", + iconFullHover: "dot-full-hover.svg" + } + }); + } + return item; + }), + playbook: this.actor.playbook + }); + sheetData.preparedActors = { + crew: activeSubActors.find((actor) => actor.type === BladesActorType.crew), + vice_purveyor: activeSubActors.find((actor) => actor.hasTag(Tag.NPC.VicePurveyor)), + acquaintances: activeSubActors.filter((actor) => actor.hasTag(Tag.NPC.Acquaintance)) + }; + sheetData.hasVicePurveyor = Boolean(this.actor.playbook?.hasTag(Tag.Gear.Advanced) === false + && activeSubItems.find((item) => item.type === BladesItemType.vice)); + sheetData.healing_clock = { + display: "Healing", + target: "system.healing.value", + color: "white", + isVisible: true, + isNameVisible: false, + isActive: false, + ...this.actor.system.healing, + id: randomID() + }; + sheetData.stashData = { + label: "Stash:", + dotline: { + data: this.actor.system.stash, + target: "system.stash.value", + iconEmpty: "coin-empty.svg", + iconEmptyHover: "coin-empty-hover.svg", + iconFull: "coin-full.svg", + iconFullHover: "coin-full-hover.svg", + altIconFull: "coin-ten.svg", + altIconFullHover: "coin-ten-hover.svg", + altIconStep: 10 + } + }; + sheetData.stressData = { + label: this.actor.system.stress.name, + dotline: { + data: this.actor.system.stress, + dotlineClass: this.actor.system.stress.max >= 13 ? "narrow-stress" : "", + target: "system.stress.value", + svgKey: "teeth.tall", + svgFull: "full|half|frame", + svgEmpty: "full|half|frame" + } + }; + if (BladesActor.IsType(this.actor, BladesActorType.pc)) { + sheetData.traumaData = { + label: this.actor.system.trauma.name, + dotline: { + data: { value: this.actor.trauma, max: this.actor.system.trauma.max }, + svgKey: "teeth.short", + svgFull: "full|frame", + svgEmpty: "frame", + isLocked: true + }, + compContainer: { + "class": "comp-trauma-conditions comp-vertical full-width", + "blocks": [ + this.actor.traumaList.slice(0, Math.ceil(this.actor.traumaList.length / 2)) + .map((tName) => ({ + checkLabel: tName, + checkClasses: { + active: "comp-toggle-red", + inactive: "comp-toggle-grey" + }, + checkTarget: `system.trauma.checked.${tName}`, + checkValue: this.actor.system.trauma.checked[tName] ?? false, + tooltip: C.TraumaTooltips[tName], + tooltipClass: "tooltip-trauma" + })), + this.actor.traumaList.slice(Math.ceil(this.actor.traumaList.length / 2)) + .map((tName) => ({ + checkLabel: tName, + checkClasses: { + active: "comp-toggle-red", + inactive: "comp-toggle-grey" + }, + checkTarget: `system.trauma.checked.${tName}`, + checkValue: this.actor.system.trauma.checked[tName] ?? false, + tooltip: C.TraumaTooltips[tName], + tooltipClass: "tooltip-trauma" + })) + ] + } + }; + } + sheetData.abilityData = { + dotline: { + dotlineClass: "dotline-right dotline-glow", + data: { + value: this.actor.getAvailableAdvancements("Ability"), + max: this.actor.getAvailableAdvancements("Ability") + }, + dotlineLabel: "Available Abilities", + isLocked: true, + iconFull: "dot-full.svg" + } + }; + sheetData.loadData = { + curLoad: this.actor.currentLoad, + selLoadCount: this.actor.system.loadout.levels[U.lCase(game.i18n.localize(this.actor.system.loadout.selected.toString()))], + selections: C.Loadout.selections, + selLoadLevel: this.actor.system.loadout.selected.toString() + }; + sheetData.armor = Object.fromEntries(Object.entries(this.actor.system.armor.active) + .filter(([, isActive]) => isActive) + .map(([armor]) => [armor, this.actor.system.armor.checked[armor]])); + sheetData.attributeData = {}; + const attrEntries = Object.entries(this.actor.system.attributes); + for (const [attribute, attrData] of attrEntries) { + sheetData.attributeData[attribute] = { + tooltip: C.AttributeTooltips[attribute], + actions: {} + }; + const actionEntries = Object.entries(attrData); + for (const [action, actionData] of actionEntries) { + sheetData.attributeData[attribute].actions[action] = { + tooltip: C.ActionTooltips[action], + value: actionData.value, + max: BladesGMTrackerSheet.Get().phase === BladesPhase.CharGen ? 2 : this.actor.system.attributes[attribute][action].max + }; + } + } + sheetData.gatherInfoTooltip = (new Handlebars.SafeString([ + "

Gathering Information: Questions to Consider

", + "" + ].join(""))).toString(); + eLog.checkLog("Attribute", "[BladesPCSheet] attributeData", { attributeData: sheetData.attributeData }); + eLog.checkLog("actor", "[BladesPCSheet] getData()", { ...context, ...sheetData }); + return { ...context, ...sheetData }; + } + get activeArmor() { + return Object.keys(U.objFilter(this.actor.system.armor.active, (val) => val === true)); + } + get checkedArmor() { + return Object.keys(U.objFilter(this.actor.system.armor.checked, (val, key) => val === true + && this.actor.system.armor.active[key] === true)); + } + get uncheckedArmor() { + return Object.keys(U.objFilter(this.actor.system.armor.active, (val, key) => val === true + && this.actor.system.armor.checked[key] === false)); + } + _getHoverArmor() { + if (!this.activeArmor.length) { + return false; + } + if (this.activeArmor.includes("heavy")) { + return this.checkedArmor.includes("heavy") ? "light" : "heavy"; + } + else if (this.activeArmor.includes("light")) { + return "light"; + } + return "special"; + } + _getClickArmor() { + if (!this.uncheckedArmor.length) { + return false; + } + if (this.uncheckedArmor.includes("heavy")) { + return "heavy"; + } + if (this.uncheckedArmor.includes("light")) { + return "light"; + } + return "special"; + } + _getContextMenuArmor() { + if (!this.checkedArmor.length) { + return false; + } + if (this.checkedArmor.includes("light")) { + return "light"; + } + if (this.checkedArmor.includes("heavy")) { + return "heavy"; + } + return "special"; + } + async _onAdvanceClick(event) { + event.preventDefault(); + super._onAdvanceClick(event); + const action = $(event.currentTarget).data("action").replace(/^advance-/, ""); + if (action in AttributeTrait) { + this.actor.advanceAttribute(action); + } + } + activateListeners(html) { + super.activateListeners(html); + + if (!this.options.editable) { + return; + } + const self = this; + + html.find(".main-armor-control").on({ + click() { + const targetArmor = self._getClickArmor(); + if (!targetArmor) { + return; + } + self.actor.update({ [`system.armor.checked.${targetArmor}`]: true }); + }, + contextmenu() { + const targetArmor = self._getContextMenuArmor(); + if (!targetArmor) { + return; + } + self.actor.update({ [`system.armor.checked.${targetArmor}`]: false }); + }, + mouseenter() { + const targetArmor = self._getHoverArmor(); + eLog.log4("Mouse Enter", targetArmor, this, $(this), $(this).next()); + if (!targetArmor) { + return; + } + $(this).siblings(`.svg-armor.armor-${targetArmor}`).addClass("hover-over"); + }, + mouseleave() { + const targetArmor = self._getHoverArmor(); + if (!targetArmor) { + return; + } + $(this).siblings(`.svg-armor.armor-${targetArmor}`).removeClass("hover-over"); + } + }); + html.find(".special-armor-control").on({ + click() { + if (!self.activeArmor.includes("special")) { + return; + } + self.actor.update({ ["system.armor.checked.special"]: self.uncheckedArmor.includes("special") }); + }, + contextmenu() { + if (!self.activeArmor.includes("special")) { + return; + } + self.actor.update({ ["system.armor.checked.special"]: self.uncheckedArmor.includes("special") }); + }, + mouseenter() { + if (!self.activeArmor.includes("special") || self.activeArmor.length === 1) { + return; + } + $(this).siblings(".svg-armor.armor-special").addClass("hover-over"); + }, + mouseleave() { + if (!self.activeArmor.includes("special") || self.activeArmor.length === 1) { + return; + } + $(this).siblings(".svg-armor.armor-special").removeClass("hover-over"); + } + }); + } +} +export default BladesPCSheet; +//# sourceMappingURL=BladesPCSheet.js.map +//# sourceMappingURL=BladesPCSheet.js.map diff --git a/module/sheets/item/BladesClockKeeperSheet.js b/module/sheets/item/BladesClockKeeperSheet.js new file mode 100644 index 00000000..0ce8cc66 --- /dev/null +++ b/module/sheets/item/BladesClockKeeperSheet.js @@ -0,0 +1,83 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import BladesItemSheet from "./BladesItemSheet.js"; +import BladesClockKeeper from "../../documents/items/BladesClockKeeper.js"; +class BladesClockKeeperSheet extends BladesItemSheet { + static Get() { return game.eunoblades.ClockKeeper; } + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "item", "clock-keeper"], + template: "systems/eunos-blades/templates/items/clock_keeper-sheet.hbs", + width: 700, + height: 970 + }); + } + static async Initialize() { + game.eunoblades ??= {}; + Items.registerSheet("blades", BladesClockKeeperSheet, { types: ["clock_keeper"], makeDefault: true }); + Hooks.once("ready", async () => { + let clockKeeper = game.items.find((item) => item.type === "clock_keeper"); + if (!clockKeeper) { + clockKeeper = (await BladesClockKeeper.create({ + name: "Clock Keeper", + type: "clock_keeper", + img: "systems/eunos-blades/assets/icons/misc-icons/clock-keeper.svg" + })); + } + game.eunoblades.ClockKeeper = clockKeeper; + game.eunoblades.ClockKeeper.renderOverlay(); + }); + Hooks.on("canvasReady", async () => { game.eunoblades.ClockKeeper?.renderOverlay(); }); + return loadTemplates([ + "systems/eunos-blades/templates/items/clock_keeper-sheet.hbs", + "systems/eunos-blades/templates/parts/clock-sheet-row.hbs" + ]); + } + static InitSockets() { + if (game.eunoblades.ClockKeeper) { + socketlib.system.register("renderOverlay", game.eunoblades.ClockKeeper.renderOverlay); + return true; + } + return false; + } + getData() { + const context = super.getData(); + const sheetData = { + clock_keys: Object.fromEntries((Object.entries(context.system.clock_keys ?? {}) + .filter(([keyID, keyData]) => Boolean(keyData && keyData.scene === context.system.targetScene)))) + }; + return { ...context, ...sheetData }; + } + addKey(event) { + event.preventDefault(); + this.item.addClockKey(); + } + deleteKey(event) { + event.preventDefault(); + const keyID = event.currentTarget.dataset.id; + if (keyID) { + this.item.deleteClockKey(keyID); + } + } + setKeySize(event) { + event.preventDefault(); + const keyID = event.target.dataset.id; + if (keyID) { + this.item.setKeySize(keyID, parseInt(event.target.value, 10)); + } + } + async activateListeners(html) { + super.activateListeners(html); + html.find("[data-action=\"add-key\"").on("click", this.addKey.bind(this)); + html.find("[data-action=\"delete-key\"").on("click", this.deleteKey.bind(this)); + html.find(".key-clock-counter").on("change", this.setKeySize.bind(this)); + } +} +export default BladesClockKeeperSheet; +//# sourceMappingURL=BladesClockKeeperSheet.js.map +//# sourceMappingURL=BladesClockKeeperSheet.js.map diff --git a/module/sheets/item/BladesGMTrackerSheet.js b/module/sheets/item/BladesGMTrackerSheet.js new file mode 100644 index 00000000..43997f3f --- /dev/null +++ b/module/sheets/item/BladesGMTrackerSheet.js @@ -0,0 +1,132 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import { BladesActorType, BladesItemType, BladesPhase } from "../../core/constants.js"; +import BladesItemSheet from "./BladesItemSheet.js"; +import BladesItem from "../../BladesItem.js"; +import BladesGMTracker from "../../documents/items/BladesGMTracker.js"; +import BladesActor from "../../BladesActor.js"; +import BladesPC from "../../documents/actors/BladesPC.js"; +export var BladesTipContext; +(function (BladesTipContext) { + BladesTipContext["DiceRoll"] = "DiceRoll"; + BladesTipContext["Combat"] = "Combat"; + BladesTipContext["General"] = "General"; +})(BladesTipContext || (BladesTipContext = {})); +class BladesTipGenerator { + static Test(pcActor) { + if (BladesActor.IsType(pcActor, BladesActorType.pc)) { + return pcActor; + } + return undefined; + } + testActor = new BladesPC({ name: "blah", type: "pc" }); + static get Tips() { + return { + [BladesTipContext.DiceRoll]: [], + [BladesTipContext.Combat]: [ + "Every combat encounter should advance the main plot, or else it's filler.", + "Inject dialogue into combat encounters, especially from important adversaries.", + "Combat encounters should be a challenge, but not a slog. Don't be afraid to end them early.", + "Infiltrate/Rescue/Destroy: Use these as additional/secondary goals in combat encounters.", + "Tell the next player in the initiative order that they're on deck.", + "Don't trigger combats automatically: Use alternate objectives to incite the players to fight, giving them agency.", + "Add another layer by drawing focus to collateral effects of the combat: a fire, a hostage, a collapsing building, innocents in danger" + ], + [BladesTipContext.General]: [ + "Rolling the dice always means SOMETHING happens.", + "Jump straight to the action; don't waste time on establishing scenes or filler.", + "Invoke elements of characters' backstories or beliefs to make any scene more personal." + ] + }; + } + tipContext; + constructor(tipContext) { + this.tipContext = tipContext; + } +} +class BladesGMTrackerSheet extends BladesItemSheet { + static Get() { return game.eunoblades.Tracker; } + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "item", "gm-tracker"], + template: "systems/eunos-blades/templates/items/gm_tracker-sheet.hbs", + width: 700, + height: 970 + }); + } + static async Initialize() { + game.eunoblades ??= {}; + Items.registerSheet("blades", BladesGMTrackerSheet, { types: ["gm_tracker"], makeDefault: true }); + Hooks.once("ready", async () => { + let tracker = game.items.find((item) => BladesItem.IsType(item, BladesItemType.gm_tracker)); + if (!tracker) { + tracker = (await BladesGMTracker.create({ + name: "GM Tracker", + type: "gm_tracker", + img: "systems/eunos-blades/assets/icons/misc-icons/gm-tracker.svg" + })); + } + game.eunoblades.Tracker = tracker; + }); + return loadTemplates([ + "systems/eunos-blades/templates/items/gm_tracker-sheet.hbs" + ]); + } + async activateListeners(html) { + super.activateListeners(html); + } + async _onSubmit(event, params = {}) { + const prevPhase = this.item.system.phase; + const submitData = await super._onSubmit(event, params); + const newPhase = this.item.system.phase; + let isForcingRender = true; + if (prevPhase !== newPhase) { + switch (prevPhase) { + case BladesPhase.CharGen: { + break; + } + case BladesPhase.Freeplay: { + break; + } + case BladesPhase.Score: { + isForcingRender = false; + game.actors.filter((actor) => BladesActor.IsType(actor, BladesActorType.pc)) + .forEach((actor) => actor.clearLoadout()); + break; + } + case BladesPhase.Downtime: { + break; + } + default: break; + } + switch (newPhase) { + case BladesPhase.CharGen: { + break; + } + case BladesPhase.Freeplay: { + break; + } + case BladesPhase.Score: { + break; + } + case BladesPhase.Downtime: { + break; + } + default: break; + } + } + if (isForcingRender) { + game.actors.filter((actor) => actor.type === BladesActorType.pc) + .forEach((actor) => actor.sheet?.render()); + } + return submitData; + } +} +export default BladesGMTrackerSheet; +//# sourceMappingURL=BladesGMTrackerSheet.js.map +//# sourceMappingURL=BladesGMTrackerSheet.js.map diff --git a/module/sheets/item/BladesItemSheet.js b/module/sheets/item/BladesItemSheet.js new file mode 100644 index 00000000..e0baeb6a --- /dev/null +++ b/module/sheets/item/BladesItemSheet.js @@ -0,0 +1,406 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import C, { BladesItemType, BladesPhase, Factor } from "../../core/constants.js"; +import U from "../../core/utilities.js"; +import G, { ApplyTooltipListeners } from "../../core/gsap.js"; +import BladesItem from "../../BladesItem.js"; +import BladesActiveEffect from "../../BladesActiveEffect.js"; +import Tags from "../../core/tags.js"; +class BladesItemSheet extends ItemSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "item"], + width: 560, + height: 500, + tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "description" }] + }); + } + + + getData() { + const context = super.getData(); + const sheetData = { + cssClass: this.item.type, + editable: this.options.editable, + isGM: (game.eunoblades.Tracker?.system.is_spoofing_player ? false : Boolean(game.user.isGM)), + isEmbeddedItem: Boolean(this.item.parent), + item: this.item, + system: this.item.system, + tierTotal: this.item.getFactorTotal(Factor.tier) > 0 ? U.romanizeNum(this.item.getFactorTotal(Factor.tier)) : "0", + activeEffects: Array.from(this.item.effects) + }; + return this._getTypedItemData[this.item.type]({ ...context, ...sheetData }); + } + _getTypedItemData = { + [BladesItemType.ability]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.ability)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.background]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.background)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.clock_keeper]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.clock_keeper)) { + return undefined; + } + const sheetData = { + phases: Object.values(BladesPhase) + }; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.cohort_gang]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.cohort_gang, BladesItemType.cohort_expert)) { + return undefined; + } + context.tierTotal = this.item.system.quality > 0 ? U.romanizeNum(this.item.system.quality) : "0"; + context.system.subtypes ??= {}; + context.system.elite_subtypes ??= {}; + const sheetData = { + tierData: { + "class": "comp-tier comp-vertical comp-teeth", + "dotline": { + data: this.item.system.tier, + target: "system.tier.value", + iconEmpty: "dot-empty.svg", + iconEmptyHover: "dot-empty-hover.svg", + iconFull: "dot-full.svg", + iconFullHover: "dot-full-hover.svg" + } + } + }; + sheetData.edgeData = Object.fromEntries(Object.values(context.system.edges ?? []) + .filter((edge) => /[A-Za-z]/.test(edge)) + .map((edge) => [edge.trim(), C.EdgeTooltips[edge]])); + sheetData.flawData = Object.fromEntries(Object.values(context.system.flaws ?? []) + .filter((flaw) => /[A-Za-z]/.test(flaw)) + .map((flaw) => [flaw.trim(), C.FlawTooltips[flaw]])); + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.cohort_expert]: (context) => this._getTypedItemData[BladesItemType.cohort_gang](context), + [BladesItemType.crew_ability]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.crew_ability)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.crew_reputation]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.crew_reputation)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.crew_playbook]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.crew_playbook)) { + return undefined; + } + if (context.isGM) { + const expClueData = {}; + [...Object.values(context.system.experience_clues ?? []).filter((clue) => /[A-Za-z]/.test(clue)), " "].forEach((clue, i) => { expClueData[(i + 1).toString()] = clue; }); + context.system.experience_clues = expClueData; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.crew_upgrade]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.crew_upgrade)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.feature]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.feature)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.gm_tracker]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.gm_tracker)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.heritage]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.heritage)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.gear]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.gear)) { + return undefined; + } + const sheetData = { + tierData: { + "class": "comp-tier comp-vertical comp-teeth", + "label": "Quality", + "labelClass": "filled-label full-width", + "dotline": { + data: this.item.system.tier, + target: "system.tier.value", + iconEmpty: "dot-empty.svg", + iconEmptyHover: "dot-empty-hover.svg", + iconFull: "dot-full.svg", + iconFullHover: "dot-full-hover.svg" + } + } + }; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.playbook]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.playbook)) { + return undefined; + } + if (context.isGM) { + const expClueData = {}; + [...Object.values(context.system.experience_clues ?? []).filter((clue) => /[A-Za-z]/.test(clue)), " "].forEach((clue, i) => { expClueData[(i + 1).toString()] = clue; }); + context.system.experience_clues = expClueData; + const gatherInfoData = {}; + [...Object.values(context.system.gather_info_questions ?? []).filter((question) => /[A-Za-z]/.test(question)), " "].forEach((question, i) => { gatherInfoData[(i + 1).toString()] = question; }); + context.system.gather_info_questions = gatherInfoData; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.preferred_op]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.preferred_op)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.stricture]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.stricture)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.vice]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.vice)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.project]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.project)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.ritual]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.ritual)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.design]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.design)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.location]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.location)) { + return undefined; + } + const sheetData = {}; + return { + ...context, + ...sheetData + }; + }, + [BladesItemType.score]: (context) => { + if (!BladesItem.IsType(this.item, BladesItemType.score)) { + return undefined; + } + return context; + } + }; + get template() { + const pathComps = [ + "systems/eunos-blades/templates/items" + ]; + if (C.SimpleItemTypes.includes(this.item.type)) { + pathComps.push("simple-sheet.hbs"); + } + else { + pathComps.push(`${this.item.type}-sheet.hbs`); + } + return pathComps.join("/"); + } + + activateListeners(html) { + super.activateListeners(html); + const self = this; + Tags.InitListeners(html, this.item); + ApplyTooltipListeners(html); + if (!this.options.editable) { + return; + } + html.find(".dotline").each((__, elem) => { + if ($(elem).hasClass("locked")) { + return; + } + const targetDoc = this.item; + const targetField = $(elem).data("target"); + const comp$ = $(elem).closest("comp"); + const curValue = U.pInt($(elem).data("value")); + $(elem) + .find(".dot") + .each((_, dot) => { + $(dot).on("click", (event) => { + event.preventDefault(); + const thisValue = U.pInt($(dot).data("value")); + if (thisValue !== curValue) { + if (comp$.hasClass("comp-coins") + || comp$.hasClass("comp-stash")) { + G.effects + .fillCoins($(dot).prevAll(".dot")) + .then(() => targetDoc.update({ [targetField]: thisValue })); + } + else { + targetDoc.update({ [targetField]: thisValue }); + } + } + }); + $(dot).on("contextmenu", (event) => { + event.preventDefault(); + const thisValue = U.pInt($(dot).data("value")) - 1; + if (thisValue !== curValue) { + targetDoc.update({ [targetField]: thisValue }); + } + }); + }); + }); + if (BladesItem.IsType(this.item, BladesItemType.cohort_expert, BladesItemType.cohort_gang)) { + html.find("[data-harm-click]").on({ + click: (event) => { + event.preventDefault(); + const harmLevel = U.pInt($(event.currentTarget).data("harmClick")); + if (this.item.system.harm?.value !== harmLevel) { + this.item.update({ "system.harm.value": harmLevel }); + } + }, + contextmenu: (event) => { + event.preventDefault(); + const harmLevel = Math.max(0, U.pInt($(event.currentTarget).data("harmClick")) - 1); + if (this.item.system.harm?.value !== harmLevel) { + this.item.update({ "system.harm.value": harmLevel }); + } + } + }); + } + if (this.options.submitOnChange) { + html.on("change", "textarea", this._onChangeInput.bind(this)); + } + html.find(".effect-control").on("click", (ev) => { + if (self.item.isOwned) { + ui.notifications?.warn(game.i18n.localize("BITD.EffectWarning")); + return; + } + BladesActiveEffect.onManageActiveEffect(ev, self.item); + }); + html.find("[data-action=\"toggle-turf-connection\"").on("click", this.toggleTurfConnection.bind(this)); + } + toggleTurfConnection(event) { + const button$ = $(event.currentTarget); + const connector$ = button$.parent(); + const turfNum = parseInt(connector$.data("index") ?? 0, 10); + const turfDir = connector$.data("dir"); + if (!turfNum || !turfDir) { + return; + } + const toggleState = connector$.hasClass("no-connect"); + const updateData = { + [`system.turfs.${turfNum}.connects.${turfDir}`]: toggleState + }; + const partner = connector$.data("partner"); + if (typeof partner === "string" && /-/.test(partner)) { + const [partnerNum, partnerDir] = partner.split("-"); + updateData[`system.turfs.${partnerNum}.connects.${partnerDir}`] = toggleState; + } + this.item.update(updateData); + } +} +export default BladesItemSheet; +//# sourceMappingURL=BladesItemSheet.js.map +//# sourceMappingURL=BladesItemSheet.js.map diff --git a/module/sheets/item/BladesScoreSheet.js b/module/sheets/item/BladesScoreSheet.js new file mode 100644 index 00000000..82aaee06 --- /dev/null +++ b/module/sheets/item/BladesScoreSheet.js @@ -0,0 +1,291 @@ +/* ****▌███████████████████████████████████████████████████████████████████████████▐**** *\ +|* ▌████░░░░░░░░░░░ Euno's Blades in the Dark for Foundry VTT ░░░░░░░░░░░░░████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░ by Eunomiac ░░░░░░░░░░░░░██████████████████▐ *| +|* ▌████████████████████████████ License █ v0.1.0 ████████████████████████████▐ *| +|* ▌██████████████████░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░███████████████████▐ *| +\* ****▌███████████████████████████████████████████████████████████████████████████▐**** */ + +import U from "../../core/utilities.js"; +import { BladesActorType, BladesPhase, Tag, Randomizers } from "../../core/constants.js"; +import BladesItemSheet from "./BladesItemSheet.js"; +import { BladesActor } from "../../documents/BladesActorProxy.js"; +import { BladesScore } from "../../documents/BladesItemProxy.js"; +import BladesRollCollab, { BladesRollCollabComps } from "../../BladesRollCollab.js"; +export var BladesTipContext; +(function (BladesTipContext) { + BladesTipContext["DiceRoll"] = "DiceRoll"; + BladesTipContext["Combat"] = "Combat"; + BladesTipContext["General"] = "General"; +})(BladesTipContext || (BladesTipContext = {})); +class BladesTipGenerator { + static get Tips() { + return { + [BladesTipContext.DiceRoll]: [], + [BladesTipContext.Combat]: [ + "Every combat encounter should advance the main plot, or else it's filler.", + "Inject dialogue into combat encounters, especially from important adversaries.", + "Combat encounters should be a challenge, but not a slog. Don't be afraid to end them early.", + "Infiltrate/Rescue/Destroy: Use these as additional/secondary goals in combat encounters.", + "Tell the next player in the initiative order that they're on deck.", + "Don't trigger combats automatically: Use alternate objectives to incite the players to fight, giving them agency.", + "Add another layer by drawing focus to collateral effects of the combat: a fire, a hostage, a collapsing building, innocents in danger" + ], + [BladesTipContext.General]: [ + "Rolling the dice always means SOMETHING happens.", + "Jump straight to the action; don't waste time on establishing scenes or filler.", + "Invoke elements of characters' backstories or beliefs to make any scene more personal." + ] + }; + } + tipContext; + constructor(tipContext) { + this.tipContext = tipContext; + } +} +class BladesScoreSheet extends BladesItemSheet { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["eunos-blades", "sheet", "item", "score-sheet"], + template: "systems/eunos-blades/templates/items/score-sheet.hbs", + width: 900, + submitOnChange: false, + height: 970 + }); + } + async generateRandomizerData(category) { + const randomData = { + Bargains: Object.fromEntries(Object.entries(U.sample(Randomizers.GM.Bargains + .filter((bData) => !Object.values(this.document.system.randomizers.Bargains) + .some((_bData) => _bData.name === bData.name || _bData.effect === bData.effect)), 3, true, (e, a) => a + .filter((_e) => e.category === _e.category).length === 0)) + .map(([k, v]) => { + k = `${k}`; + Object.assign(v, { notes: "" }); + return [k, v]; + })), + Obstacles: Object.fromEntries(Object.entries(U.sample(Randomizers.GM.Obstacles + .filter((bData) => !Object.values(this.document.system.randomizers.Obstacles) + .some((_bData) => _bData.name === bData.name || _bData.desc === bData.desc)), 3, true, (e, a) => a + .filter((_e) => e.category === _e.category).length === 0)) + .map(([k, v]) => { + k = `${k}`; + Object.assign(v, { notes: "" }); + return [k, v]; + })), + NPCs: Object.fromEntries(Object.entries(U.sample(Randomizers.GM.NPCs + .filter((bData) => !Object.values(this.document.system.randomizers.NPCs) + .some((_bData) => _bData.name === bData.name || _bData.description === bData.description)), 3, true, (e, a) => a + .filter((_e) => e.arena === _e.arena).length === 0)) + .map(([k, v]) => { + k = `${k}`; + Object.assign(v, { notes: "" }); + return [k, v]; + })), + Scores: Object.fromEntries(Object.entries(U.sample(Randomizers.GM.Scores + .filter((bData) => !Object.values(this.document.system.randomizers.Scores) + .some((_bData) => _bData.name === bData.name || _bData.desc === bData.desc)), 3, true, (e, a) => a + .filter((_e) => e.category === _e.category).length === 0)) + .map(([k, v]) => { + k = `${k}`; + Object.assign(v, { notes: "" }); + return [k, v]; + })) + }; + if (category) { + Object.keys(randomData) + .filter((cat) => cat !== category) + .forEach((cat) => { + const _cat = cat; + randomData[_cat] = this.document.system.randomizers[_cat]; + }); + } + const finalRandomData = { + Bargains: {}, + Obstacles: {}, + NPCs: {}, + Scores: {} + }; + Object.keys(randomData).forEach((cat) => { + const _cat = cat; + Object.entries(randomData[_cat]).forEach(([index, randData]) => { + if (this.document.system.randomizers?.[_cat][index].isLocked) { + finalRandomData[_cat][index] = this.document.system.randomizers[_cat][index]; + } + else { + finalRandomData[_cat][index] = randomData[_cat][index]; + } + }); + }); + this.document.update({ "system.randomizers": finalRandomData }); + } + getData() { + const context = super.getData(); + const sheetData = {}; + sheetData.playerCharacters = BladesActor.GetTypeWithTags(BladesActorType.pc, Tag.PC.ActivePC) + .map((pc) => { + return Object.assign(pc, { + actionData: Object.fromEntries(Object.entries(pc.system.attributes) + .map(([attrName, attrData]) => { + return [ + attrName, + Object.fromEntries(Object.entries(attrData) + .map(([actionName, actionData]) => { + return [ + U.uCase(actionName).slice(0, 3), + actionData + ]; + })) + ]; + })) + }); + }); + const validOppositions = {}; + for (const [id, data] of Object.entries(context.system.oppositions)) { + if (!data.rollOppName && !data.rollOppSubName) { + continue; + } + validOppositions[id] = data; + } + context.system.oppositions = validOppositions; + return { + ...context, + ...sheetData + }; + } + _toggleRandomizerLock(event) { + const elem$ = $(event.currentTarget); + const elemCat = elem$.data("category"); + const elemIndex = `${elem$.data("index")}`; + const elemValue = elem$.data("value"); + if (`${elemValue}` === "true") { + this.document.update({ [`system.randomizers.${elemCat}.${elemIndex}.isLocked`]: false }); + } + else { + this.document.update({ [`system.randomizers.${elemCat}.${elemIndex}.isLocked`]: true }); + } + } + _selectImage(event) { + const elem$ = $(event.currentTarget); + const imageNum = elem$.data("imgNum"); + this.document.update({ "system.imageSelected": imageNum }); + } + _deselectOrDeleteImage(event) { + const elem$ = $(event.currentTarget); + const imageNum = elem$.data("imgNum"); + if (this.document.system.imageSelected === imageNum) { + this.document.update({ "system.-=imageSelected": null }); + return; + } + const images = { ...this.document.system.images }; + this.document.update({ "system.-=images": null }).then(() => this.document.update({ + "system.images": Object.fromEntries(Object.entries(Object.values(images) + .filter((_, i) => U.pInt(imageNum) !== i))) + })); + } + _addImage() { + U.displayImageSelector(path => { + const imgIndex = U.objSize(this.document.system.images); + return this.document.update({ [`system.images.${imgIndex}`]: path }); + }, "systems/eunos-blades/assets", this.position); + } + _selectRollOpposition(event) { + eLog.checkLog3("Select Roll Opposition", { event }); + const elem$ = $(event.currentTarget); + const oppId = elem$.data("oppId"); + this.document.update({ "system.oppositionSelected": oppId }); + if (BladesScore.Active?.id === this.document.id && BladesRollCollab.Active) { + BladesRollCollab.Active.rollOpposition = new BladesRollCollabComps.Opposition(BladesRollCollab.Active, this.document.system.oppositions[oppId]); + } + } + _triggerRandomize(event) { + const elem$ = $(event.currentTarget); + const category = elem$.data("category"); + if (category && category in Randomizers.GM) { + this.generateRandomizerData(category); + } + else { + this.generateRandomizerData(); + } + } + async _updateGMNotesOnPC(event) { + const elem$ = $(event.currentTarget); + const actor = BladesActor.Get(elem$.data("id")); + if (!actor) { + throw new Error(`Unable to retrieve actor with id '${elem$.data("id")}'`); + } + const updateText = event.currentTarget.innerHTML; + eLog.checkLog3("scoreSheet", "Retrieved Text, Updating ...", { updateText }); + await actor.update({ "system.gm_notes": updateText }); + eLog.checkLog3("scoreSheet", "Updated!", { gm_notes: actor.system.gm_notes }); + } + async activateListeners(html) { + super.activateListeners(html); + html.find("[data-action='select-image']").on({ + click: this._selectImage.bind(this), + contextmenu: this._deselectOrDeleteImage.bind(this) + }); + html.find("[data-action='add-image']").on({ + click: this._addImage.bind(this) + }); + html.find(".roll-opposition-name").on({ + dblclick: this._selectRollOpposition.bind(this) + }); + html.find(".toggle-lock").on({ + click: this._toggleRandomizerLock.bind(this) + }); + html.find("[data-action='randomize'").on({ + click: this._triggerRandomize.bind(this) + }); + html.find("textarea.pc-summary-notes-body").on({ + change: this._updateGMNotesOnPC.bind(this) + }); + } + async _onSubmit(event, params = {}) { + eLog.checkLog3("scoreSheet", "_onSubmit()", { event, params, elemText: event.currentTarget.innerHTML }); + let isForcingRender = true; + const prevPhase = this.item.system.phase; + const submitData = await super._onSubmit(event, params); + const newPhase = this.item.system.phase; + if (prevPhase !== newPhase) { + switch (prevPhase) { + case BladesPhase.CharGen: { + break; + } + case BladesPhase.Freeplay: { + break; + } + case BladesPhase.Score: { + isForcingRender = false; + game.actors.filter((actor) => BladesActor.IsType(actor, BladesActorType.pc)) + .forEach((actor) => actor.clearLoadout()); + break; + } + case BladesPhase.Downtime: { + break; + } + } + switch (newPhase) { + case BladesPhase.CharGen: { + break; + } + case BladesPhase.Freeplay: { + break; + } + case BladesPhase.Score: { + break; + } + case BladesPhase.Downtime: { + break; + } + } + } + if (isForcingRender) { + game.actors.filter((actor) => actor.type === BladesActorType.pc) + .forEach((actor) => actor.sheet?.render()); + } + return submitData; + } +} +export default BladesScoreSheet; +//# sourceMappingURL=BladesScoreSheet.js.map +//# sourceMappingURL=BladesScoreSheet.js.map diff --git a/package-lock.json b/package-lock.json index c396df8c..96a68c1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,14 +17,15 @@ "@tsconfig/node16": "^1.0.3", "@types/jquery": "^3.5.14", "@types/node": "^18.11.4", - "@typescript-eslint/eslint-plugin": "^5.40.1", - "@typescript-eslint/parser": "^5.40.1", + "@typescript-eslint/eslint-plugin": "^5.62.0", + "@typescript-eslint/parser": "^5.62.0", "@yaireo/tagify": "^4.17.8", "autoprefixer": "^10.4.12", "codehawk-cli": "^10.0.1", "cssnano": "^5.1.13", "del": "^7.0.0", "eslint": "^8.26", + "eslint-plugin-etc": "^2.0.3", "eslint-plugin-import": "^2.26", "eslint-plugin-jsdoc": "^39.6.2", "fancy-log": "^2.0.0", @@ -2936,9 +2937,9 @@ } }, "node_modules/@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", + "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", "dev": true }, "node_modules/@types/json5": { @@ -2999,9 +3000,9 @@ "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" }, "node_modules/@types/semver": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", - "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==", "dev": true }, "node_modules/@types/simple-peer": { @@ -3038,16 +3039,31 @@ "@types/yaireo__tagify": "*" } }, + "node_modules/@types/yargs": { + "version": "17.0.25", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.25.tgz", + "integrity": "sha512-gy7iPgwnzNvxgAEi2bXOHWCVOG6f7xsprVJH4MjlAWeBmJ7vh/Y1kwMtUrs64ztf24zVIRCpr3n/z6gm9QIkgg==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.1.tgz", + "integrity": "sha512-axdPBuLuEJt0c4yI5OZssC19K2Mq1uKdrfZBzuxLvaztgqUtFYZUNw7lETExPYJR9jdEoIg4mb7RQKRQzOkeGQ==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.61.0.tgz", - "integrity": "sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.61.0", - "@typescript-eslint/type-utils": "5.61.0", - "@typescript-eslint/utils": "5.61.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.0", @@ -3087,15 +3103,34 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, + "node_modules/@typescript-eslint/experimental-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz", + "integrity": "sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.61.0.tgz", - "integrity": "sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.61.0", - "@typescript-eslint/types": "5.61.0", - "@typescript-eslint/typescript-estree": "5.61.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" }, "engines": { @@ -3115,13 +3150,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.61.0.tgz", - "integrity": "sha512-W8VoMjoSg7f7nqAROEmTt6LoBpn81AegP7uKhhW5KzYlehs8VV0ZW0fIDVbcZRcaP3aPSW+JZFua+ysQN+m/Nw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.61.0", - "@typescript-eslint/visitor-keys": "5.61.0" + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3132,13 +3167,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.61.0.tgz", - "integrity": "sha512-kk8u//r+oVK2Aj3ph/26XdH0pbAkC2RiSjUYhKD+PExemG4XSjpGFeyZ/QM8lBOa7O8aGOU+/yEbMJgQv/DnCg==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "5.61.0", - "@typescript-eslint/utils": "5.61.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -3174,9 +3209,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.61.0.tgz", - "integrity": "sha512-ldyueo58KjngXpzloHUog/h9REmHl59G1b3a5Sng1GfBo14BkS3ZbMEb3693gnP1k//97lh7bKsp6/V/0v1veQ==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3187,13 +3222,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.61.0.tgz", - "integrity": "sha512-Fud90PxONnnLZ36oR5ClJBLTLfU4pIWBmnvGwTbEa2cXIqj70AEDEmOmpkFComjBZ/037ueKrOdHuYmSFVD7Rw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.61.0", - "@typescript-eslint/visitor-keys": "5.61.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -3229,17 +3264,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.61.0.tgz", - "integrity": "sha512-mV6O+6VgQmVE6+xzlA91xifndPW9ElFW8vbSF0xCT/czPXVhwDewKila1jOyRwa9AE19zKnrr7Cg5S3pJVrTWQ==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.61.0", - "@typescript-eslint/types": "5.61.0", - "@typescript-eslint/typescript-estree": "5.61.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", "eslint-scope": "^5.1.1", "semver": "^7.3.7" }, @@ -3255,12 +3290,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.61.0.tgz", - "integrity": "sha512-50XQ5VdbWrX06mQXhy93WywSFZZGsv3EOjq+lqp6WC2t+j3mb6A9xYVdrRxafvK88vg9k9u+CT4l6D8PEatjKg==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.61.0", + "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -5804,6 +5839,128 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-etc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-etc/-/eslint-plugin-etc-2.0.3.tgz", + "integrity": "sha512-o5RS/0YwtjlGKWjhKojgmm82gV1b4NQUuwk9zqjy9/EjxNFKKYCaF+0M7DkYBn44mJ6JYFZw3Ft249dkKuR1ew==", + "dev": true, + "dependencies": { + "@phenomnomnominal/tsquery": "^5.0.0", + "@typescript-eslint/experimental-utils": "^5.0.0", + "eslint-etc": "^5.1.0", + "requireindex": "~1.2.0", + "tslib": "^2.0.0", + "tsutils": "^3.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.0", + "typescript": ">=4.0.0" + } + }, + "node_modules/eslint-plugin-etc/node_modules/@phenomnomnominal/tsquery": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@phenomnomnominal/tsquery/-/tsquery-5.0.1.tgz", + "integrity": "sha512-3nVv+e2FQwsW8Aw6qTU6f+1rfcJ3hrcnvH/mu9i8YhxO+9sqbOfpL8m6PbET5+xKOlz/VSbp0RoYWYCtIsnmuA==", + "dev": true, + "dependencies": { + "esquery": "^1.4.0" + }, + "peerDependencies": { + "typescript": "^3 || ^4 || ^5" + } + }, + "node_modules/eslint-plugin-etc/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/eslint-plugin-etc/node_modules/eslint-etc": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-etc/-/eslint-etc-5.2.1.tgz", + "integrity": "sha512-lFJBSiIURdqQKq9xJhvSJFyPA+VeTh5xvk24e8pxVL7bwLBtGF60C/KRkLTMrvCZ6DA3kbPuYhLWY0TZMlqTsg==", + "dev": true, + "dependencies": { + "@typescript-eslint/experimental-utils": "^5.0.0", + "tsutils": "^3.17.1", + "tsutils-etc": "^1.4.1" + }, + "peerDependencies": { + "eslint": "^8.0.0", + "typescript": ">=4.0.0" + } + }, + "node_modules/eslint-plugin-etc/node_modules/eslint-etc/node_modules/tsutils-etc": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/tsutils-etc/-/tsutils-etc-1.4.2.tgz", + "integrity": "sha512-2Dn5SxTDOu6YWDNKcx1xu2YUy6PUeKrWZB/x2cQ8vY2+iz3JRembKn/iZ0JLT1ZudGNwQQvtFX9AwvRHbXuPUg==", + "dev": true, + "dependencies": { + "@types/yargs": "^17.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "ts-flags": "bin/ts-flags", + "ts-kind": "bin/ts-kind" + }, + "peerDependencies": { + "tsutils": "^3.0.0", + "typescript": ">=4.0.0" + } + }, + "node_modules/eslint-plugin-etc/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/eslint-plugin-etc/node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/eslint-plugin-etc/node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/eslint-plugin-etc/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/eslint-plugin-import": { "version": "2.27.5", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", @@ -12190,6 +12347,15 @@ "integrity": "sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==", "dev": true }, + "node_modules/requireindex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", + "dev": true, + "engines": { + "node": ">=0.10.5" + } + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -17528,9 +17694,9 @@ } }, "@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", + "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", "dev": true }, "@types/json5": { @@ -17591,9 +17757,9 @@ "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" }, "@types/semver": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", - "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==", "dev": true }, "@types/simple-peer": { @@ -17630,16 +17796,31 @@ "@types/yaireo__tagify": "*" } }, + "@types/yargs": { + "version": "17.0.25", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.25.tgz", + "integrity": "sha512-gy7iPgwnzNvxgAEi2bXOHWCVOG6f7xsprVJH4MjlAWeBmJ7vh/Y1kwMtUrs64ztf24zVIRCpr3n/z6gm9QIkgg==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.1.tgz", + "integrity": "sha512-axdPBuLuEJt0c4yI5OZssC19K2Mq1uKdrfZBzuxLvaztgqUtFYZUNw7lETExPYJR9jdEoIg4mb7RQKRQzOkeGQ==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.61.0.tgz", - "integrity": "sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.61.0", - "@typescript-eslint/type-utils": "5.61.0", - "@typescript-eslint/utils": "5.61.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.0", @@ -17659,36 +17840,45 @@ } } }, + "@typescript-eslint/experimental-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz", + "integrity": "sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==", + "dev": true, + "requires": { + "@typescript-eslint/utils": "5.62.0" + } + }, "@typescript-eslint/parser": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.61.0.tgz", - "integrity": "sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.61.0", - "@typescript-eslint/types": "5.61.0", - "@typescript-eslint/typescript-estree": "5.61.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.61.0.tgz", - "integrity": "sha512-W8VoMjoSg7f7nqAROEmTt6LoBpn81AegP7uKhhW5KzYlehs8VV0ZW0fIDVbcZRcaP3aPSW+JZFua+ysQN+m/Nw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", "dev": true, "requires": { - "@typescript-eslint/types": "5.61.0", - "@typescript-eslint/visitor-keys": "5.61.0" + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" } }, "@typescript-eslint/type-utils": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.61.0.tgz", - "integrity": "sha512-kk8u//r+oVK2Aj3ph/26XdH0pbAkC2RiSjUYhKD+PExemG4XSjpGFeyZ/QM8lBOa7O8aGOU+/yEbMJgQv/DnCg==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "5.61.0", - "@typescript-eslint/utils": "5.61.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -17705,19 +17895,19 @@ } }, "@typescript-eslint/types": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.61.0.tgz", - "integrity": "sha512-ldyueo58KjngXpzloHUog/h9REmHl59G1b3a5Sng1GfBo14BkS3ZbMEb3693gnP1k//97lh7bKsp6/V/0v1veQ==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.61.0.tgz", - "integrity": "sha512-Fud90PxONnnLZ36oR5ClJBLTLfU4pIWBmnvGwTbEa2cXIqj70AEDEmOmpkFComjBZ/037ueKrOdHuYmSFVD7Rw==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", "dev": true, "requires": { - "@typescript-eslint/types": "5.61.0", - "@typescript-eslint/visitor-keys": "5.61.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -17737,28 +17927,28 @@ } }, "@typescript-eslint/utils": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.61.0.tgz", - "integrity": "sha512-mV6O+6VgQmVE6+xzlA91xifndPW9ElFW8vbSF0xCT/czPXVhwDewKila1jOyRwa9AE19zKnrr7Cg5S3pJVrTWQ==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.61.0", - "@typescript-eslint/types": "5.61.0", - "@typescript-eslint/typescript-estree": "5.61.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", "eslint-scope": "^5.1.1", "semver": "^7.3.7" } }, "@typescript-eslint/visitor-keys": { - "version": "5.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.61.0.tgz", - "integrity": "sha512-50XQ5VdbWrX06mQXhy93WywSFZZGsv3EOjq+lqp6WC2t+j3mb6A9xYVdrRxafvK88vg9k9u+CT4l6D8PEatjKg==", + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", "dev": true, "requires": { - "@typescript-eslint/types": "5.61.0", + "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" } }, @@ -19833,6 +20023,103 @@ } } }, + "eslint-plugin-etc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-etc/-/eslint-plugin-etc-2.0.3.tgz", + "integrity": "sha512-o5RS/0YwtjlGKWjhKojgmm82gV1b4NQUuwk9zqjy9/EjxNFKKYCaF+0M7DkYBn44mJ6JYFZw3Ft249dkKuR1ew==", + "dev": true, + "requires": { + "@phenomnomnominal/tsquery": "^5.0.0", + "@typescript-eslint/experimental-utils": "^5.0.0", + "eslint-etc": "^5.1.0", + "requireindex": "~1.2.0", + "tslib": "^2.0.0", + "tsutils": "^3.0.0" + }, + "dependencies": { + "@phenomnomnominal/tsquery": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@phenomnomnominal/tsquery/-/tsquery-5.0.1.tgz", + "integrity": "sha512-3nVv+e2FQwsW8Aw6qTU6f+1rfcJ3hrcnvH/mu9i8YhxO+9sqbOfpL8m6PbET5+xKOlz/VSbp0RoYWYCtIsnmuA==", + "dev": true, + "requires": { + "esquery": "^1.4.0" + } + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "eslint-etc": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-etc/-/eslint-etc-5.2.1.tgz", + "integrity": "sha512-lFJBSiIURdqQKq9xJhvSJFyPA+VeTh5xvk24e8pxVL7bwLBtGF60C/KRkLTMrvCZ6DA3kbPuYhLWY0TZMlqTsg==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "^5.0.0", + "tsutils": "^3.17.1", + "tsutils-etc": "^1.4.1" + }, + "dependencies": { + "tsutils-etc": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/tsutils-etc/-/tsutils-etc-1.4.2.tgz", + "integrity": "sha512-2Dn5SxTDOu6YWDNKcx1xu2YUy6PUeKrWZB/x2cQ8vY2+iz3JRembKn/iZ0JLT1ZudGNwQQvtFX9AwvRHbXuPUg==", + "dev": true, + "requires": { + "@types/yargs": "^17.0.0", + "yargs": "^17.0.0" + } + } + } + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } + } + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + } + } + }, "eslint-plugin-import": { "version": "2.27.5", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", @@ -24596,6 +24883,12 @@ "integrity": "sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==", "dev": true }, + "requireindex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", + "dev": true + }, "resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", diff --git a/package.json b/package.json index 0ab7c0e1..bf8a2a1b 100644 --- a/package.json +++ b/package.json @@ -22,14 +22,15 @@ "@tsconfig/node16": "^1.0.3", "@types/jquery": "^3.5.14", "@types/node": "^18.11.4", - "@typescript-eslint/eslint-plugin": "^5.40.1", - "@typescript-eslint/parser": "^5.40.1", + "@typescript-eslint/eslint-plugin": "^5.62.0", + "@typescript-eslint/parser": "^5.62.0", "@yaireo/tagify": "^4.17.8", "autoprefixer": "^10.4.12", "codehawk-cli": "^10.0.1", "cssnano": "^5.1.13", "del": "^7.0.0", "eslint": "^8.26", + "eslint-plugin-etc": "^2.0.3", "eslint-plugin-import": "^2.26", "eslint-plugin-jsdoc": "^39.6.2", "fancy-log": "^2.0.0", diff --git a/ts/@types/blades-actor-sheet.d.ts b/ts/@types/blades-actor-sheet.d.ts index d617d3d6..f1a06f6f 100644 --- a/ts/@types/blades-actor-sheet.d.ts +++ b/ts/@types/blades-actor-sheet.d.ts @@ -1,5 +1,5 @@ -import { Attribute, Action, BladesPhase, BladesActorType, BladesItemType } from '../core/constants'; -import BladesActiveEffect from '../blades-active-effect'; +import { AttributeTrait, ActionTrait, BladesPhase, BladesActorType, BladesItemType } from '../core/constants'; +import BladesActiveEffect from '../BladesActiveEffect'; declare global { @@ -54,9 +54,9 @@ declare global { traumaData: BladesCompData, abilityData: BladesCompData, - attributeData: Record + actions: Record }>, gatherInfoTooltip: string, diff --git a/ts/@types/blades-actor.d.ts b/ts/@types/blades-actor.d.ts index ae00468c..781de18a 100644 --- a/ts/@types/blades-actor.d.ts +++ b/ts/@types/blades-actor.d.ts @@ -1,9 +1,9 @@ -import { BladesActorType, Tag, District, Attribute, Action, AdvancementPoint } from "../core/constants.js"; -import BladesActor from "../blades-actor.js"; -import BladesPC from "../documents/actors/blades-pc.js"; -import BladesNPC from "../documents/actors/blades-npc.js"; -import BladesFaction from "../documents/actors/blades-faction.js"; -import BladesCrew from "../documents/actors/blades-crew.js"; +import { BladesActorType, Tag, District, AttributeTrait, ActionTrait, AdvancementPoint } from "../core/constants.js"; +import BladesActor from "../BladesActor.js"; +import BladesPC from "../documents/actors/BladesPC.js"; +import BladesNPC from "../documents/actors/BladesNPC.js"; +import BladesFaction from "../documents/actors/BladesFaction.js"; +import BladesCrew from "../documents/actors/BladesCrew.js"; declare global { // Extending Type Definitions of 'Actor' Base Class to Foundry V11 @@ -14,7 +14,8 @@ declare global { // Basic & Utility Types for BladesActors type BladesRandomizer = { isLocked: boolean, value: T } type SubActorData = Partial - type AdvancementTrait = Action|"Ability"|"Upgrade"|"Cohort"|"CohortType" + type AdvancementTrait = ActionTrait|"Ability"|"Upgrade"|"Cohort"|"CohortType" + type Loadout = "heavy"|"normal"|"light"|"encumbered" // #region SCHEMA DATA: TEMPLATE.JSON & SYSTEM @@ -88,14 +89,14 @@ declare global { checked: Record<"light" | "heavy" | "special", boolean> }, - attributes: Record>, - resistance_bonus: Record, + attributes: Record>, + resistance_bonus: Record, conditional_bonus: Record experience: BladesActorSchemaTemplate.pcChar["experience"] & { - [Attribute.insight]: ValueMax, - [Attribute.prowess]: ValueMax, - [Attribute.resolve]: ValueMax + [AttributeTrait.insight]: ValueMax, + [AttributeTrait.prowess]: ValueMax, + [AttributeTrait.resolve]: ValueMax }, gather_info: string[] } @@ -244,7 +245,7 @@ declare global { getAvailableAdvancements(trait: AdvancementTrait): number advancePlaybook(): Promise; - advanceAttribute(attr: Attribute): Promise; + advanceAttribute(attr: AttributeTrait): Promise; } } @@ -265,9 +266,9 @@ declare global { get crew(): BladesActor | undefined; get abilities(): BladesItem[]; - get attributes(): Record; - get actions(): Record; - get rollable(): Record; + get attributes(): Record; + get actions(): Record; + get rollable(): Record; get trauma(): number; get traumaList(): string[]; diff --git a/ts/@types/blades-dialog.d.ts b/ts/@types/blades-dialog.d.ts index b4b5091d..29fd987a 100644 --- a/ts/@types/blades-dialog.d.ts +++ b/ts/@types/blades-dialog.d.ts @@ -1,5 +1,5 @@ -import BladesActor from "../blades-actor"; -import BladesItem from "../blades-item"; +import BladesActor from "../BladesActor"; +import BladesItem from "../BladesItem"; declare global { namespace BladesDialog { diff --git a/ts/@types/blades-document.d.ts b/ts/@types/blades-document.d.ts index fe06ed65..3e827a56 100644 --- a/ts/@types/blades-document.d.ts +++ b/ts/@types/blades-document.d.ts @@ -1,6 +1,6 @@ import {BladesActorType, BladesItemType, Factor} from "../core/constants"; -import BladesActor from "../blades-actor"; -import BladesItem from "../blades-item"; +import BladesActor from "../BladesActor"; +import BladesItem from "../BladesItem"; declare global { diff --git a/ts/@types/blades-general-types.d.ts b/ts/@types/blades-general-types.d.ts index f0bc8894..287c6970 100644 --- a/ts/@types/blades-general-types.d.ts +++ b/ts/@types/blades-general-types.d.ts @@ -1,6 +1,6 @@ -import {Attribute, Action, District} from "../core/constants"; -import BladesItem from "../blades-item"; -import BladesActor from "../blades-actor"; +import {AttributeTrait, ActionTrait, District} from "../core/constants"; +import BladesItem from "../BladesItem"; +import BladesActor from "../BladesActor"; import {gsap} from "gsap/all"; @@ -34,6 +34,11 @@ declare global { // Represents falsy values and empty objects to be pruned when cleaning list of values type UncleanValues = false | null | undefined | "" | 0 | Record | never[]; + // Represents a string conversion to title case + type tCase = S extends `${infer A} ${infer B}` + ? `${tCase} ${tCase}` + : Capitalize>; + // Represents an allowed gender key type Gender = "M"|"F"|"U"|"X"; @@ -75,9 +80,17 @@ declare global { [Prop in keyof T as string extends Prop ? never : number extends Prop ? never : Prop]: T[Prop] }; + // Represents a deep-partial of an object + type FullPartial = { + [P in keyof T]?: T[P] extends object ? FullPartial : T[P]; + }; + // Represents a key of a certain type type KeyOf = keyof T; + // Represents a value of a certain type + type ValOf = T extends Array | ReadonlyArray ? T[number] : T[keyof T]; + // Represents a gsap animation type gsapAnim = gsap.core.Tween | gsap.core.Timeline; @@ -96,7 +109,7 @@ declare global { // Utility Types for Variable Template Values type ValueMax = { max: number, value: number }; type NamedValueMax = ValueMax & { name: string }; - type RollableStat = Attribute | Action; + type RollableStat = AttributeTrait | ActionTrait; // Component Types for Sheets type BladesCompData = { diff --git a/ts/@types/blades-item-sheet.d.ts b/ts/@types/blades-item-sheet.d.ts index 6611e760..bceb7d3a 100644 --- a/ts/@types/blades-item-sheet.d.ts +++ b/ts/@types/blades-item-sheet.d.ts @@ -1,4 +1,4 @@ -import {BladesActorType, BladesItemType, Attribute, Action, BladesPhase} from "../core/constants"; +import {BladesActorType, BladesItemType, AttributeTrait, ActionTrait, BladesPhase} from "../core/constants"; declare global { diff --git a/ts/@types/blades-item.d.ts b/ts/@types/blades-item.d.ts index 2f2d1027..ad99e108 100644 --- a/ts/@types/blades-item.d.ts +++ b/ts/@types/blades-item.d.ts @@ -1,7 +1,7 @@ import { BladesItemType, District, BladesPhase, Randomizers } from "../core/constants.js"; -import BladesItem from "../blades-item.js"; -import BladesClockKeeper from '../documents/items/blades-clock-keeper'; -import BladesGMTracker from '../documents/items/blades-gm-tracker'; +import BladesItem from "../BladesItem.js"; +import BladesClockKeeper from '../documents/items/BladesClockKeeper.js'; +import BladesGMTracker from '../documents/items/BladesGMTracker.js'; declare global { diff --git a/ts/@types/blades-roll.d.ts b/ts/@types/blades-roll.d.ts index de5f1c61..ab0524f0 100644 --- a/ts/@types/blades-roll.d.ts +++ b/ts/@types/blades-roll.d.ts @@ -1,7 +1,7 @@ -import {BladesActorType, BladesItemType, RollType, RollSubType, ConsequenceType, RollModStatus, RollModCategory, Action, DowntimeAction, Attribute, Position, Effect, Factor} from "../core/constants.js"; -import BladesActor from "../blades-actor.js"; -import BladesItem from "../blades-item.js"; -import {BladesRollMod} from "../blades-roll-collab.js"; +import {BladesActorType, BladesItemType, RollType, RollSubType, ConsequenceType, RollModStatus, RollModCategory, ActionTrait, DowntimeAction, AttributeTrait, Position, Effect, Factor} from "../core/constants.js"; +import BladesActor from "../BladesActor.js"; +import BladesItem from "../BladesItem.js"; +import {BladesRollMod} from "../BladesRollCollab.js"; declare global { @@ -9,8 +9,8 @@ declare global { export interface Config { rollType: RollType, userID?: string, - rollPrimary?: PrimaryDocData, - rollOpposition?: OppositionDocData, + rollPrimary?: PrimaryDoc|Partial; + rollOpp?: OppositionDoc|Partial; rollSubType?: RollSubType, rollDowntimeAction?: DowntimeAction, rollTrait?: RollTrait @@ -43,17 +43,16 @@ declare global { rollType: RollType; rollSubType?: RollSubType; rollDowntimeAction?: DowntimeAction; - rollPrimaryType: string; - rollPrimaryID?: string; - rollPrimaryData?: PrimaryDocData; + + rollPrimaryData?: Partial; + rollOppData?: Partial; + rollParticipantData?: Partial; + rollTrait: RollTrait; rollModsData: Record; rollPositionInitial: Position; rollEffectInitial: Effect; rollPosEffectTrade: "position"|"effect"|false, - rollOppositionType?: string; - rollOppositionID?: string, - rollOppositionData?: OppositionDocData, rollConsequence?: ConsequenceData, isGMReady: boolean, GMBoosts: Partial>, @@ -124,7 +123,7 @@ declare global { export type PartialSheetData = Partial & FlagData; - export type RollTrait = Action|Attribute|Factor|number; + export type RollTrait = ActionTrait|AttributeTrait|Factor|number; export interface FactorData extends NamedValueMax { baseVal: number, diff --git a/ts/@types/index.d.ts b/ts/@types/index.d.ts index 1269bd7b..2a824ed8 100644 --- a/ts/@types/index.d.ts +++ b/ts/@types/index.d.ts @@ -1,9 +1,9 @@ -import BladesItem from "../blades-item"; -import BladesActor from "../blades-actor"; -import BladesClockKeeper from "../documents/items/blades-clock-keeper"; -import BladesGMTracker from "../documents/items/blades-gm-tracker"; -import BladesPushController from "../blades-push-notifications"; +import BladesItem from "../BladesItem"; +import BladesActor from "../BladesActor"; +import BladesClockKeeper from "../documents/items/BladesClockKeeper"; +import BladesGMTracker from "../documents/items/BladesGMTracker"; +import BladesPushController from "../BladesPushController"; import type gsap from "/scripts/greensock/esm/all"; import "./blades-general-types"; diff --git a/ts/blades-active-effect.ts b/ts/BladesActiveEffect.ts similarity index 99% rename from ts/blades-active-effect.ts rename to ts/BladesActiveEffect.ts index 6e1f5e1b..a6155733 100644 --- a/ts/blades-active-effect.ts +++ b/ts/BladesActiveEffect.ts @@ -1,6 +1,6 @@ -import BladesActor from "./blades-actor.js"; +import BladesActor from "./BladesActor.js"; import U from "./core/utilities.js"; -import BladesItem from "./blades-item.js"; +import BladesItem from "./BladesItem.js"; import {Tag, BladesPhase, BladesActorType} from "./core/constants.js"; import type {ActiveEffectDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/activeEffectData.js"; import {DocumentModificationOptions} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/document.mjs.js"; diff --git a/ts/blades-actor.ts b/ts/BladesActor.ts similarity index 93% rename from ts/blades-actor.ts rename to ts/BladesActor.ts index f3a9edf6..7dfd04bc 100644 --- a/ts/blades-actor.ts +++ b/ts/BladesActor.ts @@ -1,1082 +1,1081 @@ -// #region Imports ~ -import U from "./core/utilities.js"; -import type {Vice} from "./core/constants.js"; -import C, {BladesActorType, Tag, Playbook, BladesItemType, Attribute, Action, PrereqType, AdvancementPoint, Randomizers, Factor} from "./core/constants.js"; - -import BladesPC from "./documents/actors/blades-pc.js"; -import BladesCrew from "./documents/actors/blades-crew.js"; -import BladesNPC from "./documents/actors/blades-npc.js"; -import BladesFaction from "./documents/actors/blades-faction.js"; -import type {ActorData, ActorDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/actorData.js"; -import type {ItemDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/itemData.js"; -import BladesItem from "./blades-item.js"; -import {SelectionCategory} from "./blades-dialog.js"; -import type BladesActiveEffect from "./blades-active-effect"; -import type EmbeddedCollection from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/embedded-collection.mjs.js"; -import type {MergeObjectOptions} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/utils/helpers.mjs.js"; -// #endregion - - -// Blades Theme Song: "Bangkok" from The Gray Man soundtrack: https://www.youtube.com/watch?v=cjjImvMqYlo&list=OLAK5uy_k9cZDd1Fbpd25jfDtte5A6HyauD2-cwgk&index=2 -// Also check out Discord thread: https://discord.com/channels/325094888133885952/1152316839163068527 - -class BladesActor extends Actor implements BladesDocument { - - // #region Static Overrides: Create ~ - static override async create(data: ActorDataConstructorData & { system?: Partial }, options = {}) { - data.token = data.token || {}; - data.system = data.system ?? {}; - - //~ Create world_name - data.system.world_name = data.system.world_name ?? data.name.replace(/[^A-Za-z_0-9 ]/g, "").trim().replace(/ /g, "_"); - - return super.create(data, options); - } - // #endregion - - // #region BladesDocument Implementation ~ - static get All() { return game.actors } - static Get(actorRef: ActorRef): BladesActor | undefined { - if (actorRef instanceof BladesActor) { return actorRef } - if (U.isDocID(actorRef)) { return BladesActor.All.get(actorRef) } - return BladesActor.All.find((a) => a.system.world_name === actorRef) - || BladesActor.All.find((a) => a.name === actorRef); - } - static GetTypeWithTags(docType: T, ...tags: BladesTag[]): Array> { - return BladesActor.All.filter((actor) => actor.type === docType) - .filter((actor) => actor.hasTag(...tags)) as Array>; - } - - static IsType(doc: unknown, ...types: T[]): doc is BladesActorOfType { - const typeSet = new Set(types); - return doc instanceof BladesActor && typeSet.has(doc.type); - } - - get tags(): BladesTag[] { return this.system.tags ?? [] } - - hasTag(...tags: BladesTag[]): boolean { - return tags.every((tag) => this.tags.includes(tag)); - } - - async addTag(...tags: BladesTag[]) { - const curTags = this.tags; - tags.forEach((tag) => { - if (curTags.includes(tag)) { return } - curTags.push(tag); - }); - eLog.checkLog2("actor", "BladesActor.addTag(...tags)", {tags, curTags}); - this.update({"system.tags": curTags}); - } - - async remTag(...tags: BladesTag[]) { - const curTags = this.tags.filter((tag) => !tags.includes(tag)); - eLog.checkLog2("actor", "BladesActor.remTag(...tags)", {tags, curTags}); - this.update({"system.tags": curTags}); - } - - get tooltip(): string | undefined { - const tooltipText = [this.system.concept, this.system.subtitle] - .filter(Boolean) - .join("

"); - return tooltipText ? (new Handlebars.SafeString(tooltipText)).toString() : undefined; - } - - get dialogCSSClasses(): string { return "" } - - getFactorTotal(factor: Factor): number { - switch (factor) { - case Factor.tier: { - if (BladesActor.IsType(this, BladesActorType.pc)) { - return this.system.tier.value + (this.crew?.getFactorTotal(Factor.tier) ?? 0); - } - return this.system.tier.value; - } - case Factor.quality: return this.getFactorTotal(Factor.tier); - case Factor.scale: { - if (BladesActor.IsType(this, BladesActorType.npc)) { - return this.system.scale; - } - return 0; - } - case Factor.magnitude: { - if (BladesActor.IsType(this, BladesActorType.npc)) { - return this.system.magnitude; - } - return 0; - } - default: return 0; - } - } - // #endregion - - // #region SubActorControl Implementation ~ - - get subActors(): BladesActor[] { - return Object.keys(this.system.subactors) - .map((id) => this.getSubActor(id)) - .filter((subActor): subActor is BladesActor => Boolean(subActor)); - } - get activeSubActors(): BladesActor[] { return this.subActors.filter((subActor) => !subActor.hasTag(Tag.System.Archived)) } - get archivedSubActors(): BladesActor[] { return this.subActors.filter((subActor) => subActor.hasTag(Tag.System.Archived)) } - - checkActorPrereqs(actor: BladesActor): boolean { - - /* Implement any prerequisite checks for embedding actors */ - - return Boolean(actor); - } - private processEmbeddedActorMatches(globalActors: BladesActor[]) { - - return globalActors - // Step 1: Filter out globals that fail prereqs. - .filter(this.checkActorPrereqs) - // Step 2: Filter out actors that are already active subActors - .filter((gActor) => !this.activeSubActors.some((aActor) => aActor.id === gActor.id)) - // Step 3: Merge subactor data onto matching global actors - .map((gActor) => this.getSubActor(gActor) || gActor) - // Step 4: Sort by name - .sort((a, b) => { - if (a.name === b.name) { return 0 } - if (a.name === null) { return 1 } - if (b.name === null) { return -1 } - if (a.name > b.name) { return 1 } - if (a.name < b.name) { return -1 } - return 0; - }); - } - - getDialogActors(category: SelectionCategory): Record | false { - - /* **** NEED TO FILTER OUT ACTORS PLAYER DOESN'T HAVE PERMISSION TO SEE **** */ - - const dialogData: Record = {}; - - switch (category) { - case SelectionCategory.Contact: - case SelectionCategory.Rival: - case SelectionCategory.Friend: - case SelectionCategory.Acquaintance: { - if (!(BladesActor.IsType(this, BladesActorType.pc) || BladesActor.IsType(this, BladesActorType.crew)) || this.playbookName === null) { return false } - dialogData.Main = this.processEmbeddedActorMatches(BladesActor.GetTypeWithTags(BladesActorType.npc, this.playbookName)); - return dialogData; - } - case SelectionCategory.VicePurveyor: { - if (!BladesActor.IsType(this, BladesActorType.pc) || !this.vice?.name) { return false } - dialogData.Main = this.processEmbeddedActorMatches(BladesActor.GetTypeWithTags(BladesActorType.npc, this.vice.name as Vice)); - return dialogData; - } - case SelectionCategory.Crew: { - dialogData.Main = BladesActor.GetTypeWithTags(BladesActorType.crew); - return dialogData; - } - default: return false; - } - } - - async addSubActor(actorRef: ActorRef, tags?: BladesTag[]): Promise { - - enum BladesActorUniqueTags { - CharacterCrew = Tag.PC.CharacterCrew, - VicePurveyor = Tag.NPC.VicePurveyor - } - let focusSubActor: BladesActor | undefined; - - // Does an embedded subActor of this Actor already exist on the character? - if (this.hasSubActorOf(actorRef)) { - const subActor = this.getSubActor(actorRef); - if (!subActor) { return } - // Is it an archived Item? - if (subActor.hasTag(Tag.System.Archived)) { - // Unarchive it - await subActor.remTag(Tag.System.Archived); - } - // Make it the focus item. - focusSubActor = subActor; - } else { - // Is it not embedded at all? Create new entry in system.subactors from global actor - const actor = BladesActor.Get(actorRef); - if (!actor) { return } - const subActorData: SubActorData = {}; - if (tags) { - subActorData.tags = U.unique([ - ...actor.tags, - ...tags - ]); - } - // Await the update, then make the retrieved subactor the focus - await this.update({[`system.subactors.${actor.id}`]: subActorData}); - focusSubActor = this.getSubActor(actor.id); - } - - if (!focusSubActor) { return } - - // Does this Actor contain any tags limiting it to one per actor? - const uniqueTags = focusSubActor.tags.filter((tag) => tag in BladesActorUniqueTags); - if (uniqueTags.length > 0) { - // ... then archive all other versions. - uniqueTags.forEach((uTag) => this.activeSubActors - .filter((subActor): subActor is BladesActor => Boolean(focusSubActor?.id && subActor.id !== focusSubActor.id && subActor.hasTag(uTag))) - .map((subActor) => this.remSubActor(subActor.id))); - } - } - - getSubActor(actorRef: ActorRef): BladesActor | undefined { - const actor = BladesActor.Get(actorRef); - if (!actor?.id) { return undefined } - if (!BladesActor.IsType(actor, BladesActorType.npc, BladesActorType.faction)) { return actor } - const subActorData = this.system.subactors[actor.id] ?? {}; - Object.assign( - actor.system, - mergeObject(actor.system, subActorData) - ); - actor.parentActor = this; - return actor; - } - - hasSubActorOf(actorRef: ActorRef): boolean { - const actor = BladesActor.Get(actorRef); - if (!actor) { return false } - return actor?.id ? actor.id in this.system.subactors : false; - } - - async updateSubActor(actorRef: ActorRef, upData: List>>): Promise { - const updateData = U.objExpand(upData) as {system: DeepPartial}; - if (!updateData.system) { return undefined } - const actor = BladesActor.Get(actorRef); - if (!actor) { return undefined } - - // diffObject new update data against actor data. - const diffUpdateSystem = U.objDiff(actor.system as BladesActorSystem & Record, updateData.system); - - // Merge new update data onto current subactor data. - const mergedSubActorSystem = U.objMerge(this.system.subactors[actor.id] ?? {}, diffUpdateSystem, {isReplacingArrays: true, isConcatenatingArrays: false}); - - // Confirm this update changes data: - if (JSON.stringify(this.system.subactors[actor.id]) === JSON.stringify(mergedSubActorSystem)) { return undefined } - // Update actor with new subactor data. - return this.update({[`system.subactors.${actor.id}`]: null}, undefined, true) - .then(() => this.update({[`system.subactors.${actor.id}`]: mergedSubActorSystem}, undefined, true)) - .then(() => actor.sheet?.render()) as Promise; - } - - async remSubActor(actorRef: ActorRef): Promise { - const subActor = this.getSubActor(actorRef); - if (!subActor) { return } - this.update({["system.subactors"]: mergeObject(this.system.subactors, {[`-=${subActor.id}`]: null})}, undefined, true); - } - - async clearSubActors(isReRendering = true): Promise { - this.subActors.forEach((subActor) => { - if (subActor.parentActor?.id === this.id) { subActor.clearParentActor(isReRendering) } - }); - this.sheet?.render(); - } - - async clearParentActor(isReRendering = true): Promise { - const {parentActor} = this; - if (!parentActor) { return } - this.parentActor = undefined; - this.system = this._source.system; - this.ownership = this._source.ownership; - - this.prepareData(); - if (isReRendering) { - this.sheet?.render(); - } - } - - - // #endregion - // #region SubItemControl Implementation ~ - - get subItems() { return Array.from(this.items) } - get activeSubItems() { return this.items.filter((item) => !item.hasTag(Tag.System.Archived)) } - get archivedSubItems() { return this.items.filter((item) => item.hasTag(Tag.System.Archived)) } - - private _checkItemPrereqs(item: BladesItem): boolean { - if (!item.system.prereqs) { return true } - for (const [pType, pReqs] of Object.entries(item.system.prereqs as Partial>)) { - const pReqArray = Array.isArray(pReqs) ? pReqs : [pReqs.toString()]; - const hitRecord: Partial> = {}; - if (!this._processPrereqArray(pReqArray, pType as PrereqType, hitRecord)) { - return false; - } - } - return true; - } - - private _processPrereqArray(pReqArray: string[], pType: PrereqType, hitRecord: Partial>): boolean { - while (pReqArray.length) { - const pString = pReqArray.pop(); - hitRecord[pType as PrereqType] ??= []; - if (!this._processPrereqType(pType, pString, hitRecord)) { - return false; - } - } - return true; - } - - private _processPrereqType(pType: PrereqType, pString: string | undefined, hitRecord: Partial>): boolean { - switch (pType) { - case PrereqType.HasActiveItem: { - return this._processActiveItemPrereq(pString, hitRecord, pType); - } - case PrereqType.HasActiveItemsByTag: { - return this._processActiveItemsByTagPrereq(pString, hitRecord, pType); - } - case PrereqType.AdvancedPlaybook: { - return this._processAdvancedPlaybookPrereq(); - } - default: return true; - } - } - - private _processActiveItemPrereq(pString: string | undefined, hitRecord: Partial>, pType: PrereqType): boolean { - const thisItem = this.activeSubItems - .filter((i) => !hitRecord[pType]?.includes(i.id)) - .find((i) => i.system.world_name === pString); - if (thisItem) { - hitRecord[pType]?.push(thisItem.id); - return true; - } else { - return false; - } - } - - private _processActiveItemsByTagPrereq(pString: string | undefined, hitRecord: Partial>, pType: PrereqType): boolean { - const thisItem = this.activeSubItems - .filter((i) => !hitRecord[pType]?.includes(i.id)) - .find((i) => i.hasTag(pString as BladesTag)); - if (thisItem) { - hitRecord[pType]?.push(thisItem.id); - return true; - } else { - return false; - } - } - - private _processAdvancedPlaybookPrereq(): boolean { - if (!BladesActor.IsType(this, BladesActorType.pc)) { return false } - if (!this.playbookName || ![Playbook.Ghost, Playbook.Hull, Playbook.Vampire].includes(this.playbookName)) { - return false; - } - return true; - } - private _processEmbeddedItemMatches(globalItems: Array>): Array> { - - return globalItems - - // Step 1: Filter out globals that fail prereqs. - .filter((item) => this._checkItemPrereqs(item)) - - // Step 2: Filter out already-active items based on max_per_score (unless MultiplesOk) - .filter((gItem) => gItem.hasTag(Tag.System.MultiplesOK) || (gItem.system.max_per_score ?? 1) > this.activeSubItems.filter((sItem) => sItem.system.world_name === gItem.system.world_name).length) - - // Step 3: Replace with matching Archived, Embedded subItems - .map((gItem) => { - const matchingSubItems = this.archivedSubItems.filter((sItem): sItem is BladesItemOfType => sItem.system.world_name === gItem.system.world_name); - if (matchingSubItems.length > 0) { - return matchingSubItems; - } else { - return gItem; - } - }) - .flat() - - // Step 4: Apply CSS classes - .map((sItem) => { - sItem.dialogCSSClasses = ""; - const cssClasses: string[] = []; - if (sItem.isEmbedded) { - cssClasses.push("embedded"); - } - if (sItem.hasTag(Tag.Gear.Fine)) { - cssClasses.push("fine-quality"); - } - if (sItem.hasTag(Tag.System.Featured)) { - cssClasses.push("featured-item"); - } - if ([BladesItemType.ability, BladesItemType.crew_ability].includes(sItem.type)) { - if (this.getAvailableAdvancements("Ability") === 0) { - cssClasses.push("locked"); - } else if ((sItem.system.price ?? 1) > this.getAvailableAdvancements("Ability")) { - cssClasses.push("locked", "unaffordable"); - } else if ((sItem.system.price ?? 1) > 1) { - cssClasses.push("expensive"); - } - } - if ([BladesItemType.crew_upgrade].includes(sItem.type)) { - if (this.getAvailableAdvancements("Upgrade") === 0) { - cssClasses.push("locked"); - } else if ((sItem.system.price ?? 1) > this.getAvailableAdvancements("Upgrade")) { - cssClasses.push("locked", "unaffordable"); - } else if ((sItem.system.price ?? 1) > 1) { - cssClasses.push("expensive"); - } - } - - if (cssClasses.length > 0) { - sItem.dialogCSSClasses = cssClasses.join(" "); - } - - return sItem; - }) - - // Step 5: Sort by featured, then by fine, then by world_name, then embedded first sorted by name - .sort((a, b) => { - if (a.hasTag(Tag.System.Featured) && !b.hasTag(Tag.System.Featured)) { return -1 } - if (!a.hasTag(Tag.System.Featured) && b.hasTag(Tag.System.Featured)) { return 1 } - if (a.hasTag(Tag.Gear.Fine) && !b.hasTag(Tag.Gear.Fine)) { return -1 } - if (!a.hasTag(Tag.Gear.Fine) && b.hasTag(Tag.Gear.Fine)) { return 1 } - if (a.system.world_name > b.system.world_name) { return 1 } - if (a.system.world_name < b.system.world_name) { return -1 } - if (a.isEmbedded && !b.isEmbedded) { return -1 } - if (!a.isEmbedded && b.isEmbedded) { return 1 } - if (a.name === b.name) { return 0 } - if (a.name === null) { return 1 } - if (b.name === null) { return -1 } - if (a.name > b.name) { return 1 } - if (a.name < b.name) { return -1 } - return 0; - }); - } - - getDialogItems(category: SelectionCategory): Record | false { - const dialogData: Record = {}; - const isPC = BladesActor.IsType(this, BladesActorType.pc); - const isCrew = BladesActor.IsType(this, BladesActorType.crew); - if (!BladesActor.IsType(this, BladesActorType.pc) && !BladesActor.IsType(this, BladesActorType.crew)) { return false } - const {playbookName} = this; - - if (category === SelectionCategory.Heritage && isPC) { - dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.heritage)); - } else if (category === SelectionCategory.Background && isPC) { - dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.background)); - } else if (category === SelectionCategory.Vice && isPC && playbookName !== null) { - dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.vice, playbookName)); - } else if (category === SelectionCategory.Playbook) { - if (this.type === BladesActorType.pc) { - dialogData.Basic = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.playbook).filter((item) => !item.hasTag(Tag.Gear.Advanced))); - dialogData.Advanced = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.playbook, Tag.Gear.Advanced)); - } else if (this.type === BladesActorType.crew) { - dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_playbook)); - } - } else if (category === SelectionCategory.Reputation && isCrew) { - dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_reputation)); - } else if (category === SelectionCategory.Preferred_Op && isCrew && playbookName !== null) { - dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.preferred_op, playbookName as BladesTag)); - } else if (category === SelectionCategory.Gear && BladesActor.IsType(this, BladesActorType.pc)) { - const self = this; - if (playbookName === null) { return false } - const gearItems = this._processEmbeddedItemMatches([ - ...BladesItem.GetTypeWithTags(BladesItemType.gear, playbookName), - ...BladesItem.GetTypeWithTags(BladesItemType.gear, Tag.Gear.General) - ]) - .filter((item) => self.remainingLoad >= item.system.load); - - // Two tabs, one for playbook and the other for general items - dialogData[playbookName] = gearItems.filter((item) => item.hasTag(playbookName)); - dialogData.General = gearItems - .filter((item) => item.hasTag(Tag.Gear.General)) - // Remove featured class from General items - .map((item) => { - if (item.dialogCSSClasses) { - item.dialogCSSClasses = item.dialogCSSClasses.replace(/featured-item\s?/g, ""); - } - return item; - }) - // Re-sort by world_name - .sort((a, b) => { - if (a.system.world_name > b.system.world_name) { return 1 } - if (a.system.world_name < b.system.world_name) { return -1 } - return 0; - }); - } else if (category === SelectionCategory.Ability) { - if (isPC) { - if (playbookName === null) { return false } - dialogData[playbookName] = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.ability, playbookName)); - dialogData.Veteran = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.ability)) - .filter((item) => !item.hasTag(playbookName)) - // Remove featured class from Veteran items - .map((item) => { - if (item.dialogCSSClasses) { - item.dialogCSSClasses = item.dialogCSSClasses.replace(/featured-item\s?/g, ""); - } - return item; - }) - // Re-sort by world_name - .sort((a, b) => { - if (a.system.world_name > b.system.world_name) { return 1 } - if (a.system.world_name < b.system.world_name) { return -1 } - return 0; - }); - } else if (isCrew) { - dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_ability, playbookName)); - } - } else if (category === SelectionCategory.Upgrade && isCrew && playbookName !== null) { - dialogData[playbookName] = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_upgrade, playbookName)); - dialogData.General = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_upgrade, Tag.Gear.General)); - } - - return dialogData; - } - - getSubItem(itemRef: ItemRef, activeOnly = false): BladesItem | undefined { - const activeCheck = (i: BladesItem) => !activeOnly || !i.hasTag(Tag.System.Archived); - if (typeof itemRef === "string" && this.items.get(itemRef)) { - const returnItem = this.items.get(itemRef); - if (returnItem && activeCheck(returnItem)) { - return returnItem; - } else { - return undefined; - } - } else { - const globalItem = BladesItem.Get(itemRef); - if (!globalItem) { return undefined } - return this.items.find((item) => item.name === globalItem.name && activeCheck(item)) - ?? this.items.find((item) => item.system.world_name === globalItem.system.world_name && activeCheck(item)); - } - } - - hasSubItemOf(itemRef: ItemRef): boolean { - const item = BladesItem.Get(itemRef); - if (!item) { return false } - return Boolean(this.items.find((i) => i.system.world_name === item.system.world_name)); - } - - hasActiveSubItemOf(itemRef: ItemRef): boolean { - const item = BladesItem.Get(itemRef); - if (!item) { return false } - return Boolean(this.items.find((i) => !i.hasTag(Tag.System.Archived) && i.system.world_name === item.system.world_name)); - } - - async addSubItem(itemRef: ItemRef): Promise { - enum BladesItemUniqueTypes { - background = BladesItemType.background, - vice = BladesItemType.vice, - crew_playbook = BladesItemType.crew_playbook, - crew_reputation = BladesItemType.crew_reputation, - heritage = BladesItemType.heritage, - playbook = BladesItemType.playbook, - preferred_op = BladesItemType.preferred_op, - } - function isBladesItemUniqueTypes(type: unknown): type is BladesItemUniqueTypes { - return Object.values(BladesItemUniqueTypes).includes(type as BladesItemUniqueTypes); - } - - eLog.checkLog3("subitems", "[addSubItem] itemRef", itemRef); - - let focusItem: BladesItem | undefined; - - // Does an embedded copy of this item already exist on the character? - const embeddedItem: BladesItem | undefined = this.getSubItem(itemRef); - - - if (embeddedItem) { - - // Is it an archived Item? - if (embeddedItem.hasTag(Tag.System.Archived)) { - // Unarchive it & make it the focus item. - await embeddedItem.remTag(Tag.System.Archived); - focusItem = embeddedItem; - eLog.checkLog3("subitems", `[addSubItem] IS ARCHIVED EMBEDDED > Removing 'Archived' Tag, '${focusItem.id}':`, focusItem); - } else { // Otherwise... - // Duplicate the item, and make the newly-created item the focus. - focusItem = await BladesItem.create([embeddedItem] as unknown as ItemDataConstructorData, {parent: this}) as BladesItem; - eLog.checkLog3("subitems", `[addSubItem] IS ACTIVE EMBEDDED > Duplicating, focusItem '${focusItem.id}':`, focusItem); - } - } else { - // Is it not embedded at all? Embed from global instance. - const globalItem = BladesItem.Get(itemRef); - - eLog.checkLog3("subitems", `[addSubItem] IS NOT EMBEDDED > Fetching Global, globalItem '${globalItem?.id}':`, globalItem); - - if (!globalItem) { return } - focusItem = await BladesItem.create([globalItem] as unknown as ItemDataConstructorData, {parent: this}) as BladesItem; - focusItem = this.items.getName(globalItem.name); - } - - // Is this item type limited to one per actor? - if (focusItem && isBladesItemUniqueTypes(focusItem.type)) { - // ... then archive all other versions. - await Promise.all(this.activeSubItems - .filter((subItem) => subItem.type === focusItem?.type && subItem.system.world_name !== focusItem?.system.world_name && !subItem.hasTag(Tag.System.Archived)) - .map(this.remSubItem.bind(this))); - } - } - - async remSubItem(itemRef: ItemRef): Promise { - const subItem = this.getSubItem(itemRef); - if (!subItem) { return } - if (subItem.type !== BladesItemType.gear) { - this.purgeSubItem(itemRef); - return; - } - eLog.checkLog("actorTrigger", "Removing SubItem " + subItem.name, subItem); - if (subItem.hasTag(Tag.System.Archived)) { return } - subItem.addTag(Tag.System.Archived); - } - - async purgeSubItem(itemRef: ItemRef): Promise { - const subItem = this.getSubItem(itemRef); - if (!subItem || subItem.hasTag(Tag.System.Archived)) { return } - subItem.delete(); - } - - // #endregion - // #region Advancement Implementation ~ - // get totalAbilityPoints(): number { - // if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { return 0 } - // if (!this.playbook) { return 0 } - // switch (this.type) { - // case BladesActorType.pc: return this.system.advancement.ability ?? 0; - // case BladesActorType.crew: return Math.floor(0.5 * (this.system.advancement.general ?? 0)) + (this.system.advancement.ability ?? 0); - // default: return 0; - // } - // } - // get spentAbilityPoints(): number { - // if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { return 0 } - // if (!this.playbook) { return 0 } - // return this.abilities.reduce((total, ability) => total + (ability.system.price ?? 1), 0); - // } - // get getAvailableAdvancements("Ability")(): number { - // if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { return 0 } - // if (!this.playbook) { return 0 } - // return this.totalAbilityPoints - this.spentAbilityPoints; - // } - - /* Need simple getters for total ability & upgrade points that check for PRICES of items - (upgrade.system.price ?? 1) */ - - async grantAdvancementPoints(allowedTypes: AdvancementPoint|AdvancementPoint[], amount = 1) { - const aPtKey: string = Array.isArray(allowedTypes) - ? allowedTypes.sort((a, b) => a.localeCompare(b)).join("_") - : allowedTypes; - this.update({[`system.advancement_points.${aPtKey}`]: (this.system.advancement_points?.[aPtKey] ?? 0) + amount}); - } - - async removeAdvancementPoints(allowedTypes: AdvancementPoint|AdvancementPoint[], amount = 1) { - const aPtKey: string = Array.isArray(allowedTypes) - ? allowedTypes.sort((a, b) => a.localeCompare(b)).join("_") - : allowedTypes; - const newCount = this.system.advancement_points?.[aPtKey] ?? 0 - amount; - if (newCount <= 0 && aPtKey in (this.system.advancement_points ?? [])) { - this.update({[`system.advancement_points.-=${aPtKey}`]: null}); - } else { - this.update({[`system.advancement_points.${aPtKey}`]: newCount}); - } - } - - getAvailableAdvancements(trait: AdvancementTrait): number { - if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { return 0 } - if (trait in Action) { - return 1; - } - if (trait === "Cohort") { - const pointsCohort = this.system.advancement_points?.[AdvancementPoint.Cohort] ?? 0; - const spentCohort = this.cohorts.length; - return Math.max(0, pointsCohort - spentCohort); - } - - const pointsAbility = this.system.advancement_points?.[AdvancementPoint.Ability] ?? 0; - const pointsCohortType = this.system.advancement_points?.[AdvancementPoint.CohortType] ?? 0; - const pointsUpgrade = this.system.advancement_points?.[AdvancementPoint.Upgrade] ?? 0; - const pointsUpgradeOrAbility = this.system.advancement_points?.[AdvancementPoint.UpgradeOrAbility] ?? 0; - - const spentAbility = U.sum(this.items - .filter((item) => BladesItem.IsType(item, BladesItemType.ability, BladesItemType.crew_ability)) - .map((abil) => abil.system.price ?? 1)); - const spentCohortType = U.sum(this.cohorts.map((cohort) => Math.max(0, U.unique(Object.values(cohort.system.subtypes)).length - 1))); - const spentUpgrade = U.sum(this.items - .filter((item) => BladesItem.IsType(item, BladesItemType.crew_upgrade)) - .map((upgrade) => upgrade.system.price ?? 1)); - - const excessUpgrade = Math.max(0, spentUpgrade - pointsUpgrade); - const excessCohortType = Math.max(0, spentCohortType - pointsCohortType); - const excessAbility = Math.max(0, spentAbility - pointsAbility); - - const remainingAbility = Math.max(0, pointsAbility - spentAbility); - const remainingCohortType = Math.max(0, pointsCohortType - spentCohortType); - const remainingUpgrade = Math.max(0, pointsUpgrade - spentUpgrade); - const remainingUpgradeOrAbility = Math.max(0, pointsUpgradeOrAbility - excessUpgrade - (2 * excessAbility) - (2 * excessCohortType)); - - if (trait === "Ability") { - return remainingAbility + Math.floor(0.5 * remainingUpgradeOrAbility); - } - if (trait === "Upgrade") { - return remainingUpgrade + remainingUpgradeOrAbility; - } - if (trait === "CohortType") { - return remainingCohortType + remainingUpgradeOrAbility; - } - - return 0 as never; - } - - get availableAbilityPoints() { return this.getAvailableAdvancements("Ability") } - get availableUpgradePoints() { return this.getAvailableAdvancements("Upgrade") } - get availableCohortPoints() { return this.getAvailableAdvancements("Cohort") } - get availableCohortTypePoints() { return this.getAvailableAdvancements("CohortType") } - - get canPurchaseAbility() { return this.availableAbilityPoints > 0 } - get canPurchaseUpgrade() { return this.availableUpgradePoints > 0 } - get canPurchaseCohort() { return this.availableCohortPoints > 0 } - get canPurchaseCohortType() { return this.availableCohortTypePoints > 0 } - - async advancePlaybook() { - if (!(BladesActor.IsType(this, BladesActorType.pc) || BladesActor.IsType(this, BladesActorType.crew)) || !this.playbook) { return } - await this.update({"system.experience.playbook.value": 0}); - if (BladesActor.IsType(this, BladesActorType.pc)) { - game.eunoblades.PushController?.pushToAll("GM", `${this.name} Advances their Playbook!`, `${this.name}, select a new Ability on your Character Sheet.`); - this.grantAdvancementPoints(AdvancementPoint.Ability); - return; - } - if (BladesActor.IsType(this, BladesActorType.crew)) { - game.eunoblades.PushController?.pushToAll("GM", `${this.name} Advances their Playbook!`, "Select new Upgrades and/or Abilities on your Crew Sheet."); - this.members.forEach((member) => { - const coinGained = this.system.tier.value + 2; - game.eunoblades.PushController?.pushToAll("GM", `${member.name} Gains ${coinGained} Stash (Crew Advancement)`, undefined); - member.addStash(coinGained); - }); - this.grantAdvancementPoints(AdvancementPoint.UpgradeOrAbility, 2); - } - } - - async advanceAttribute(attribute: Attribute) { - await this.update({[`system.experience.${attribute}.value`]: 0}); - const actions = C.Action[attribute].map((action) => `${U.tCase(action)}`); - game.eunoblades.PushController?.pushToAll("GM", `${this.name} Advances their ${U.uCase(attribute)}!`, `${this.name}, add a dot to one of ${U.oxfordize(actions, true, "or")}.`); - } - - - // #endregion - // #region BladesSubActor Implementation ~ - - parentActor?: BladesActor; - get isSubActor() { return this.parentActor !== undefined } - - // #endregion - - // #region BladesCrew Implementation ~ - - get members(): BladesPC[] { - if (!BladesActor.IsType(this, BladesActorType.crew)) { return [] } - const self = this as BladesCrew; - return BladesActor.GetTypeWithTags(BladesActorType.pc).filter((actor): actor is BladesPC => actor.isMember(self)); - } - get contacts(): Array { - if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { return [] } - const self: BladesCrew = this as BladesCrew; - return this.activeSubActors.filter((actor): actor is BladesNPC|BladesFaction => actor.hasTag(self.playbookName)); - } - get claims(): Record { - if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { return {} } - return this.playbook.system.turfs; - } - get turfCount(): number { - if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { return 0 } - return Object.values(this.playbook.system.turfs) - .filter((claim) => claim.isTurf && claim.value).length; - } - - get upgrades(): Array> { - if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { return [] } - return this.activeSubItems.filter((item): item is BladesItemOfType => item.type === BladesItemType.crew_upgrade); - } - get cohorts(): Array> { - return this.activeSubItems.filter((item): item is BladesItemOfType => [BladesItemType.cohort_gang, BladesItemType.cohort_expert].includes(item.type)); - } - - getTaggedItemBonuses(tags: BladesTag[]): number { - // Check ACTIVE EFFECTS supplied by upgrade/ability against submitted tags? - return tags.length; // Placeholder to avoid linter error - } - // #endregion - - // #region PREPARING DERIVED DATA - override prepareDerivedData() { - if (BladesActor.IsType(this, BladesActorType.pc)) { this._preparePCData(this.system) } - if (BladesActor.IsType(this, BladesActorType.crew)) { this._prepareCrewData(this.system) } - } - - _preparePCData(system: ExtractBladesActorSystem) { - if (!BladesActor.IsType(this, BladesActorType.pc)) { return } - - // Extract experience clues from playbook item, if any - if (this.playbook) { - system.experience.clues = [...system.experience.clues, ...Object.values(this.playbook.system.experience_clues).filter((clue) => Boolean(clue.trim()))]; - } - // Extract gather information questions from playbook item, if any - if (this.playbook) { - system.gather_info = [...system.gather_info, ...Object.values(this.playbook.system.gather_info_questions).filter((question) => Boolean(question.trim()))]; - } - } - - _prepareCrewData(system: ExtractBladesActorSystem) { - if (!BladesActor.IsType(this, BladesActorType.crew)) { return } - - // Extract experience clues and turfs from playbook item, if any - if (this.playbook) { - system.experience.clues = [...system.experience.clues, ...Object.values(this.playbook.system.experience_clues).filter((clue) => Boolean(clue.trim()))]; - system.turfs = this.playbook.system.turfs; - } - } - - // #endregion - - // #region OVERRIDES: _onCreateDescendantDocuments, update ~ - // @ts-expect-error New method not defined in @league VTT types. - override async _onCreateDescendantDocuments(parent: BladesActor, collection: "items"|"effects", docs: BladesItem[]|BladesActiveEffect[], data: BladesItem[]|BladesActiveEffect[], options: Record, userId: string) { - await Promise.all(docs.map(async (doc) => { - if (BladesItem.IsType(doc, BladesItemType.playbook, BladesItemType.crew_playbook)) { - await Promise.all(this.activeSubItems - .filter((aItem) => aItem.type === doc.type && aItem.system.world_name !== doc.system.world_name) - .map((aItem) => this.remSubItem(aItem))); - } - })); - - // @ts-expect-error New method not defined in @league VTT types. - await super._onCreateDescendantDocuments(parent, collection, docs, data, options, userId); - - eLog.checkLog("actorTrigger", "_onCreateDescendantDocuments", {parent, collection, docs, data, options, userId}); - - docs.forEach((doc) => { - if (BladesItem.IsType(doc, BladesItemType.vice) && BladesActor.IsType(this, BladesActorType.pc)) { - this.activeSubActors - .filter((subActor) => subActor.hasTag(Tag.NPC.VicePurveyor) && !subActor.hasTag(doc.name as Vice)) - .forEach((subActor) => { this.remSubActor(subActor) }); - } - }); - } - - override async update(updateData: DeepPartial<(ActorDataConstructorData & SubActorData) | (ActorDataConstructorData & SubActorData & Record)> | undefined, context?: (DocumentModificationContext & MergeObjectOptions) | undefined, isSkippingSubActorCheck = false): Promise { - if (!updateData) { return super.update(updateData) } - if (BladesActor.IsType(this, BladesActorType.crew)) { - if (!this.playbook) { return undefined } - - eLog.checkLog("actorTrigger", "Updating Crew", {updateData}); - const playbookUpdateData: Partial = Object.fromEntries(Object.entries(flattenObject(updateData)) - .filter(([key, _]: [string, unknown]) => key.startsWith("system.turfs."))); - updateData = Object.fromEntries(Object.entries(flattenObject(updateData)) - .filter(([key, _]: [string, unknown]) => !key.startsWith("system.turfs."))); - eLog.checkLog("actorTrigger", "Updating Crew", {crewUpdateData: updateData, playbookUpdateData}); - - const diffPlaybookData = diffObject(flattenObject(this.playbook), playbookUpdateData) as Record> & {_id?: string}; - delete diffPlaybookData._id; - - if (!U.isEmpty(diffPlaybookData)) { - await this.playbook.update(playbookUpdateData, context) - .then(() => this.sheet?.render(false)); - } - } else if ( - (BladesActor.IsType(this, BladesActorType.npc) - || BladesActor.IsType(this, BladesActorType.faction)) - && this.parentActor - && !isSkippingSubActorCheck) { - // This is an embedded Actor: Update it as a subActor of parentActor. - return this.parentActor.updateSubActor(this.id, updateData as List>>) - .then(() => this); - } - - return super.update(updateData, context); - } - - // #endregion - - // #region Rolling Dice ~ - - /** - * Creates modifiers for dice roll. - * - * @param {int} rs - * Min die modifier - * @param {int} re - * Max die modifier - * @param {int} s - * Selected die - */ - createListOfDiceMods(rs: number, re: number, s: number | string) { - - let text = ""; - - if (s === "") { - s = 0; - } - - for (let i = rs; i <= re; i++) { - let plus = ""; - if (i >= 0) { plus = "+" } - text += ``; - } - - return text; - } - - // #endregion Rolling Dice - - // #region NPC Randomizers ~ - updateRandomizers() { - if (!BladesActor.IsType(this, BladesActorType.npc)) { return } - const titleChance = 0.05; - const suffixChance = 0.01; - - const {persona, secret, random} = this.system; - - function sampleArray(arr: string[], ...curVals: string[]): string { - arr = arr.filter((elem) => !curVals.includes(elem)); - if (!arr.length) { return "" } - return arr[Math.floor(Math.random() * arr.length)]; - } - const randomGen: Record string> = { - name: (gen?: string) => { - return [ - Math.random() <= titleChance - ? sampleArray(Randomizers.NPC.name_title) - : "", - sampleArray([ - ...((gen ?? "").charAt(0).toLowerCase() !== "m" ? Randomizers.NPC.name_first.female : []), - ...((gen ?? "").charAt(0).toLowerCase() !== "f" ? Randomizers.NPC.name_first.male : []) - ]), - `"${sampleArray(Randomizers.NPC.name_alias)}"`, - sampleArray(Randomizers.NPC.name_surname), - Math.random() <= suffixChance - ? sampleArray(Randomizers.NPC.name_suffix) - : "" - ].filter((val) => Boolean(val)).join(" "); - }, - background: () => sampleArray(Randomizers.NPC.background, random.background.value), - heritage: () => sampleArray(Randomizers.NPC.heritage, random.heritage.value), - profession: () => sampleArray(Randomizers.NPC.profession, random.profession.value), - gender: () => sampleArray(Randomizers.NPC.gender, persona.gender.value) as Gender, - appearance: () => sampleArray(Randomizers.NPC.appearance, persona.appearance.value), - goal: () => sampleArray(Randomizers.NPC.goal, persona.goal.value, secret.goal.value), - method: () => sampleArray(Randomizers.NPC.method, persona.method.value, secret.method.value), - trait: () => sampleArray(Randomizers.NPC.trait, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value), - interests: () => sampleArray(Randomizers.NPC.interests, persona.interests.value, secret.interests.value), - quirk: () => sampleArray(Randomizers.NPC.quirk, persona.quirk.value), - style: (gen = "") => sampleArray([ - ...(gen.charAt(0).toLowerCase() !== "m" ? Randomizers.NPC.style.female : []), - ...(gen.charAt(0).toLowerCase() !== "f" ? Randomizers.NPC.style.male : []) - ], persona.style.value) - }; - - const gender = persona.gender.isLocked ? persona.gender.value : randomGen.gender(); - const updateKeys = [ - ...Object.keys(persona).filter((key) => !persona[key as KeyOf]?.isLocked), - ...Object.keys(random).filter((key) => !random[key as KeyOf]?.isLocked), - ...Object.keys(secret).filter((key) => !secret[key as KeyOf]?.isLocked) - .map((secretKey) => `secret-${secretKey}`) - ]; - - eLog.checkLog("Update Keys", {updateKeys}); - const updateData: Record = {}; - - updateKeys.forEach((key) => { - switch (key) { - case "name": - case "heritage": - case "background": - case "profession": { - const randomVal = randomGen[key](); - updateData[`system.random.${key}`] = { - isLocked: false, - value: randomVal || random[key as KeyOf].value - }; - break; - } - case "secret-goal": - case "secret-interests": - case "secret-method": { - key = key.replace(/^secret-/, ""); - const randomVal = randomGen[key](); - updateData[`system.secret.${key}`] = { - isLocked: false, - value: randomVal || secret[key as KeyOf].value - }; - break; - } - case "gender": { - updateData[`system.persona.${key}`] = { - isLocked: persona.gender.isLocked, - value: gender - }; - break; - } - case "trait1": - case "trait2": - case "trait3": - case "secret-trait": { - const trait1 = persona.trait1.isLocked - ? persona.trait1.value - : sampleArray(Randomizers.NPC.trait, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); - const trait2 = persona.trait2.isLocked - ? persona.trait2.value - : sampleArray(Randomizers.NPC.trait, trait1, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); - const trait3 = persona.trait3.isLocked - ? persona.trait3.value - : sampleArray(Randomizers.NPC.trait, trait1, trait2, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); - const secretTrait = secret.trait.isLocked - ? secret.trait.value - : sampleArray(Randomizers.NPC.trait, trait1, trait2, trait3, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); - if (!persona.trait1.isLocked) { - updateData["system.persona.trait1"] = { - isLocked: false, - value: trait1 - }; - } - if (!persona.trait2.isLocked) { - updateData["system.persona.trait2"] = { - isLocked: false, - value: trait2 - }; - } - if (!persona.trait3.isLocked) { - updateData["system.persona.trait3"] = { - isLocked: false, - value: trait3 - }; - } - if (!secret.trait.isLocked) { - updateData["system.secret.trait"] = { - isLocked: false, - value: secretTrait - }; - } - break; - } - default: { - const randomVal = randomGen[key](); - updateData[`system.persona.${key}`] = { - isLocked: false, - value: randomVal || persona[key as KeyOf].value - }; - break; - } - } - }); - - this.update(updateData); - } - // #endregion NPC Randomizers - -} - -declare interface BladesActor { - get id(): string; - get name(): string; - get img(): string; - get type(): BladesActorType; - get items(): EmbeddedCollection; - system: BladesActorSystem, - getRollData(): BladesActorRollData; - parent: TokenDocument | null; - ownership: Record>; - _source: BladesActor; -} - +// #region Imports ~ +import U from "./core/utilities.js"; +import C, {BladesActorType, Tag, Playbook, BladesItemType, AttributeTrait, ActionTrait, PrereqType, AdvancementPoint, Randomizers, Factor, Vice} from "./core/constants.js"; + +import {BladesPC, BladesCrew, BladesNPC, BladesFaction} from "./documents/BladesActorProxy.js"; +import {BladesItem} from "./documents/BladesItemProxy.js"; + +import BladesPushController from "./BladesPushController.js"; +import {SelectionCategory} from "./BladesDialog.js"; + +import type {ActorData, ActorDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/actorData.js"; +import type {ItemDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/itemData.js"; +import type BladesActiveEffect from "./BladesActiveEffect.js"; +import type EmbeddedCollection from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/embedded-collection.mjs.js"; +import type {MergeObjectOptions} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/utils/helpers.mjs.js"; +// #endregion + + +// Blades Theme Song: "Bangkok" from The Gray Man soundtrack: https://www.youtube.com/watch?v=cjjImvMqYlo&list=OLAK5uy_k9cZDd1Fbpd25jfDtte5A6HyauD2-cwgk&index=2 +// Also check out Discord thread: https://discord.com/channels/325094888133885952/1152316839163068527 + +class BladesActor extends Actor implements BladesDocument { + + // #region Static Overrides: Create ~ + static override async create(data: ActorDataConstructorData & { system?: Partial }, options = {}) { + data.token = data.token || {}; + data.system = data.system ?? {}; + + //~ Create world_name + data.system.world_name = data.system.world_name ?? data.name.replace(/[^A-Za-z_0-9 ]/g, "").trim().replace(/ /g, "_"); + + return super.create(data, options); + } + // #endregion + + // #region BladesDocument Implementation ~ + static get All() { return game.actors } + static Get(actorRef: ActorRef): BladesActor | undefined { + if (actorRef instanceof BladesActor) { return actorRef } + if (U.isDocID(actorRef)) { return BladesActor.All.get(actorRef) } + return BladesActor.All.find((a) => a.system.world_name === actorRef) + || BladesActor.All.find((a) => a.name === actorRef); + } + static GetTypeWithTags(docType: T, ...tags: BladesTag[]): Array> { + return BladesActor.All.filter((actor) => actor.type === docType) + .filter((actor) => actor.hasTag(...tags)) as Array>; + } + + static IsType(doc: unknown, ...types: T[]): doc is BladesActorOfType { + const typeSet = new Set(types); + return doc instanceof BladesActor && typeSet.has(doc.type); + } + + get tags(): BladesTag[] { return this.system.tags ?? [] } + + hasTag(...tags: BladesTag[]): boolean { + return tags.every((tag) => this.tags.includes(tag)); + } + + async addTag(...tags: BladesTag[]) { + const curTags = this.tags; + tags.forEach((tag) => { + if (curTags.includes(tag)) { return } + curTags.push(tag); + }); + eLog.checkLog2("actor", "BladesActor.addTag(...tags)", {tags, curTags}); + this.update({"system.tags": curTags}); + } + + async remTag(...tags: BladesTag[]) { + const curTags = this.tags.filter((tag) => !tags.includes(tag)); + eLog.checkLog2("actor", "BladesActor.remTag(...tags)", {tags, curTags}); + this.update({"system.tags": curTags}); + } + + get tooltip(): string | undefined { + const tooltipText = [this.system.concept, this.system.subtitle] + .filter(Boolean) + .join("

"); + return tooltipText ? (new Handlebars.SafeString(tooltipText)).toString() : undefined; + } + + get dialogCSSClasses(): string { return "" } + + getFactorTotal(factor: Factor): number { + switch (factor) { + case Factor.tier: { + if (BladesActor.IsType(this, BladesActorType.pc)) { + return this.system.tier.value + (this.crew?.getFactorTotal(Factor.tier) ?? 0); + } + return this.system.tier.value; + } + case Factor.quality: return this.getFactorTotal(Factor.tier); + case Factor.scale: { + if (BladesActor.IsType(this, BladesActorType.npc)) { + return this.system.scale; + } + return 0; + } + case Factor.magnitude: { + if (BladesActor.IsType(this, BladesActorType.npc)) { + return this.system.magnitude; + } + return 0; + } + default: return 0; + } + } + // #endregion + + // #region SubActorControl Implementation ~ + + get subActors(): BladesActor[] { + return Object.keys(this.system.subactors) + .map((id) => this.getSubActor(id)) + .filter((subActor): subActor is BladesActor => Boolean(subActor)); + } + get activeSubActors(): BladesActor[] { return this.subActors.filter((subActor) => !subActor.hasTag(Tag.System.Archived)) } + get archivedSubActors(): BladesActor[] { return this.subActors.filter((subActor) => subActor.hasTag(Tag.System.Archived)) } + + checkActorPrereqs(actor: BladesActor): boolean { + + /* Implement any prerequisite checks for embedding actors */ + + return Boolean(actor); + } + private processEmbeddedActorMatches(globalActors: BladesActor[]) { + + return globalActors + // Step 1: Filter out globals that fail prereqs. + .filter(this.checkActorPrereqs) + // Step 2: Filter out actors that are already active subActors + .filter((gActor) => !this.activeSubActors.some((aActor) => aActor.id === gActor.id)) + // Step 3: Merge subactor data onto matching global actors + .map((gActor) => this.getSubActor(gActor) || gActor) + // Step 4: Sort by name + .sort((a, b) => { + if (a.name === b.name) { return 0 } + if (a.name === null) { return 1 } + if (b.name === null) { return -1 } + if (a.name > b.name) { return 1 } + if (a.name < b.name) { return -1 } + return 0; + }); + } + + getDialogActors(category: SelectionCategory): Record | false { + + /* **** NEED TO FILTER OUT ACTORS PLAYER DOESN'T HAVE PERMISSION TO SEE **** */ + + const dialogData: Record = {}; + + switch (category) { + case SelectionCategory.Contact: + case SelectionCategory.Rival: + case SelectionCategory.Friend: + case SelectionCategory.Acquaintance: { + if (!(BladesActor.IsType(this, BladesActorType.pc) || BladesActor.IsType(this, BladesActorType.crew)) || this.playbookName === null) { return false } + dialogData.Main = this.processEmbeddedActorMatches(BladesActor.GetTypeWithTags(BladesActorType.npc, this.playbookName)); + return dialogData; + } + case SelectionCategory.VicePurveyor: { + if (!BladesActor.IsType(this, BladesActorType.pc) || !this.vice?.name) { return false } + dialogData.Main = this.processEmbeddedActorMatches(BladesActor.GetTypeWithTags(BladesActorType.npc, this.vice.name as Vice)); + return dialogData; + } + case SelectionCategory.Crew: { + dialogData.Main = BladesActor.GetTypeWithTags(BladesActorType.crew); + return dialogData; + } + default: return false; + } + } + + async addSubActor(actorRef: ActorRef, tags?: BladesTag[]): Promise { + + enum BladesActorUniqueTags { + CharacterCrew = Tag.PC.CharacterCrew, + VicePurveyor = Tag.NPC.VicePurveyor + } + let focusSubActor: BladesActor | undefined; + + // Does an embedded subActor of this Actor already exist on the character? + if (this.hasSubActorOf(actorRef)) { + const subActor = this.getSubActor(actorRef); + if (!subActor) { return } + // Is it an archived Item? + if (subActor.hasTag(Tag.System.Archived)) { + // Unarchive it + await subActor.remTag(Tag.System.Archived); + } + // Make it the focus item. + focusSubActor = subActor; + } else { + // Is it not embedded at all? Create new entry in system.subactors from global actor + const actor = BladesActor.Get(actorRef); + if (!actor) { return } + const subActorData: SubActorData = {}; + if (tags) { + subActorData.tags = U.unique([ + ...actor.tags, + ...tags + ]); + } + // Await the update, then make the retrieved subactor the focus + await this.update({[`system.subactors.${actor.id}`]: subActorData}); + focusSubActor = this.getSubActor(actor.id); + } + + if (!focusSubActor) { return } + + // Does this Actor contain any tags limiting it to one per actor? + const uniqueTags = focusSubActor.tags.filter((tag) => tag in BladesActorUniqueTags); + if (uniqueTags.length > 0) { + // ... then archive all other versions. + uniqueTags.forEach((uTag) => this.activeSubActors + .filter((subActor): subActor is BladesActor => Boolean(focusSubActor?.id && subActor.id !== focusSubActor.id && subActor.hasTag(uTag))) + .map((subActor) => this.remSubActor(subActor.id))); + } + } + + getSubActor(actorRef: ActorRef): BladesActor | undefined { + const actor = BladesActor.Get(actorRef); + if (!actor?.id) { return undefined } + if (!BladesActor.IsType(actor, BladesActorType.npc, BladesActorType.faction)) { return actor } + const subActorData = this.system.subactors[actor.id] ?? {}; + Object.assign( + actor.system, + mergeObject(actor.system, subActorData) + ); + actor.parentActor = this; + return actor; + } + + hasSubActorOf(actorRef: ActorRef): boolean { + const actor = BladesActor.Get(actorRef); + if (!actor) { return false } + return actor?.id ? actor.id in this.system.subactors : false; + } + + async updateSubActor(actorRef: ActorRef, upData: List>): Promise { + const updateData = U.objExpand(upData) as {system: FullPartial}; + if (!updateData.system) { return undefined } + const actor = BladesActor.Get(actorRef); + if (!actor) { return undefined } + + // diffObject new update data against actor data. + const diffUpdateSystem = U.objDiff(actor.system as BladesActorSystem & Record, updateData.system); + + // Merge new update data onto current subactor data. + const mergedSubActorSystem = U.objMerge(this.system.subactors[actor.id] ?? {}, diffUpdateSystem, {isReplacingArrays: true, isConcatenatingArrays: false}); + + // Confirm this update changes data: + if (JSON.stringify(this.system.subactors[actor.id]) === JSON.stringify(mergedSubActorSystem)) { return undefined } + // Update actor with new subactor data. + return this.update({[`system.subactors.${actor.id}`]: null}, undefined, true) + .then(() => this.update({[`system.subactors.${actor.id}`]: mergedSubActorSystem}, undefined, true)) + .then(() => actor.sheet?.render()) as Promise; + } + + async remSubActor(actorRef: ActorRef): Promise { + const subActor = this.getSubActor(actorRef); + if (!subActor) { return } + this.update({["system.subactors"]: mergeObject(this.system.subactors, {[`-=${subActor.id}`]: null})}, undefined, true); + } + + async clearSubActors(isReRendering = true): Promise { + this.subActors.forEach((subActor) => { + if (subActor.parentActor?.id === this.id) { subActor.clearParentActor(isReRendering) } + }); + this.sheet?.render(); + } + + async clearParentActor(isReRendering = true): Promise { + const {parentActor} = this; + if (!parentActor) { return } + this.parentActor = undefined; + this.system = this._source.system; + this.ownership = this._source.ownership; + + this.prepareData(); + if (isReRendering) { + this.sheet?.render(); + } + } + + + // #endregion + // #region SubItemControl Implementation ~ + + get subItems() { return Array.from(this.items) } + get activeSubItems() { return this.items.filter((item) => !item.hasTag(Tag.System.Archived)) } + get archivedSubItems() { return this.items.filter((item) => item.hasTag(Tag.System.Archived)) } + + private _checkItemPrereqs(item: BladesItem): boolean { + if (!item.system.prereqs) { return true } + for (const [pType, pReqs] of Object.entries(item.system.prereqs as Partial>)) { + const pReqArray = Array.isArray(pReqs) ? pReqs : [pReqs.toString()]; + const hitRecord: Partial> = {}; + if (!this._processPrereqArray(pReqArray, pType as PrereqType, hitRecord)) { + return false; + } + } + return true; + } + + private _processPrereqArray(pReqArray: string[], pType: PrereqType, hitRecord: Partial>): boolean { + while (pReqArray.length) { + const pString = pReqArray.pop(); + hitRecord[pType] ??= []; + if (!this._processPrereqType(pType, pString, hitRecord)) { + return false; + } + } + return true; + } + + private _processPrereqType(pType: PrereqType, pString: string | undefined, hitRecord: Partial>): boolean { + switch (pType) { + case PrereqType.HasActiveItem: { + return this._processActiveItemPrereq(pString, hitRecord, pType); + } + case PrereqType.HasActiveItemsByTag: { + return this._processActiveItemsByTagPrereq(pString, hitRecord, pType); + } + case PrereqType.AdvancedPlaybook: { + return this._processAdvancedPlaybookPrereq(); + } + default: return true; + } + } + + private _processActiveItemPrereq(pString: string | undefined, hitRecord: Partial>, pType: PrereqType): boolean { + const thisItem = this.activeSubItems + .filter((i) => !hitRecord[pType]?.includes(i.id)) + .find((i) => i.system.world_name === pString); + if (thisItem) { + hitRecord[pType]?.push(thisItem.id); + return true; + } else { + return false; + } + } + + private _processActiveItemsByTagPrereq(pString: string | undefined, hitRecord: Partial>, pType: PrereqType): boolean { + const thisItem = this.activeSubItems + .filter((i) => !hitRecord[pType]?.includes(i.id)) + .find((i) => i.hasTag(pString as BladesTag)); + if (thisItem) { + hitRecord[pType]?.push(thisItem.id); + return true; + } else { + return false; + } + } + + private _processAdvancedPlaybookPrereq(): boolean { + if (!BladesActor.IsType(this, BladesActorType.pc)) { return false } + if (!this.playbookName || ![Playbook.Ghost, Playbook.Hull, Playbook.Vampire].includes(this.playbookName)) { + return false; + } + return true; + } + private _processEmbeddedItemMatches(globalItems: Array>): Array> { + + return globalItems + + // Step 1: Filter out globals that fail prereqs. + .filter((item) => this._checkItemPrereqs(item)) + + // Step 2: Filter out already-active items based on max_per_score (unless MultiplesOk) + .filter((gItem) => gItem.hasTag(Tag.System.MultiplesOK) || (gItem.system.max_per_score ?? 1) > this.activeSubItems.filter((sItem) => sItem.system.world_name === gItem.system.world_name).length) + + // Step 3: Replace with matching Archived, Embedded subItems + .map((gItem) => { + const matchingSubItems = this.archivedSubItems.filter((sItem): sItem is BladesItemOfType => sItem.system.world_name === gItem.system.world_name); + if (matchingSubItems.length > 0) { + return matchingSubItems; + } else { + return gItem; + } + }) + .flat() + + // Step 4: Apply CSS classes + .map((sItem) => { + sItem.dialogCSSClasses = ""; + const cssClasses: string[] = []; + if (sItem.isEmbedded) { + cssClasses.push("embedded"); + } + if (sItem.hasTag(Tag.Gear.Fine)) { + cssClasses.push("fine-quality"); + } + if (sItem.hasTag(Tag.System.Featured)) { + cssClasses.push("featured-item"); + } + if ([BladesItemType.ability, BladesItemType.crew_ability].includes(sItem.type)) { + if (this.getAvailableAdvancements("Ability") === 0) { + cssClasses.push("locked"); + } else if ((sItem.system.price ?? 1) > this.getAvailableAdvancements("Ability")) { + cssClasses.push("locked", "unaffordable"); + } else if ((sItem.system.price ?? 1) > 1) { + cssClasses.push("expensive"); + } + } + if ([BladesItemType.crew_upgrade].includes(sItem.type)) { + if (this.getAvailableAdvancements("Upgrade") === 0) { + cssClasses.push("locked"); + } else if ((sItem.system.price ?? 1) > this.getAvailableAdvancements("Upgrade")) { + cssClasses.push("locked", "unaffordable"); + } else if ((sItem.system.price ?? 1) > 1) { + cssClasses.push("expensive"); + } + } + + if (cssClasses.length > 0) { + sItem.dialogCSSClasses = cssClasses.join(" "); + } + + return sItem; + }) + + // Step 5: Sort by featured, then by fine, then by world_name, then embedded first sorted by name + .sort((a, b) => { + if (a.hasTag(Tag.System.Featured) && !b.hasTag(Tag.System.Featured)) { return -1 } + if (!a.hasTag(Tag.System.Featured) && b.hasTag(Tag.System.Featured)) { return 1 } + if (a.hasTag(Tag.Gear.Fine) && !b.hasTag(Tag.Gear.Fine)) { return -1 } + if (!a.hasTag(Tag.Gear.Fine) && b.hasTag(Tag.Gear.Fine)) { return 1 } + if (a.system.world_name > b.system.world_name) { return 1 } + if (a.system.world_name < b.system.world_name) { return -1 } + if (a.isEmbedded && !b.isEmbedded) { return -1 } + if (!a.isEmbedded && b.isEmbedded) { return 1 } + if (a.name === b.name) { return 0 } + if (a.name === null) { return 1 } + if (b.name === null) { return -1 } + if (a.name > b.name) { return 1 } + if (a.name < b.name) { return -1 } + return 0; + }); + } + + getDialogItems(category: SelectionCategory): Record | false { + const dialogData: Record = {}; + const isPC = BladesActor.IsType(this, BladesActorType.pc); + const isCrew = BladesActor.IsType(this, BladesActorType.crew); + if (!BladesActor.IsType(this, BladesActorType.pc) && !BladesActor.IsType(this, BladesActorType.crew)) { return false } + const {playbookName} = this; + + if (category === SelectionCategory.Heritage && isPC) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.heritage)); + } else if (category === SelectionCategory.Background && isPC) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.background)); + } else if (category === SelectionCategory.Vice && isPC && playbookName !== null) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.vice, playbookName)); + } else if (category === SelectionCategory.Playbook) { + if (this.type === BladesActorType.pc) { + dialogData.Basic = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.playbook).filter((item) => !item.hasTag(Tag.Gear.Advanced))); + dialogData.Advanced = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.playbook, Tag.Gear.Advanced)); + } else if (this.type === BladesActorType.crew) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_playbook)); + } + } else if (category === SelectionCategory.Reputation && isCrew) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_reputation)); + } else if (category === SelectionCategory.Preferred_Op && isCrew && playbookName !== null) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.preferred_op, playbookName)); + } else if (category === SelectionCategory.Gear && BladesActor.IsType(this, BladesActorType.pc)) { + const self = this; + if (playbookName === null) { return false } + const gearItems = this._processEmbeddedItemMatches([ + ...BladesItem.GetTypeWithTags(BladesItemType.gear, playbookName), + ...BladesItem.GetTypeWithTags(BladesItemType.gear, Tag.Gear.General) + ]) + .filter((item) => self.remainingLoad >= item.system.load); + + // Two tabs, one for playbook and the other for general items + dialogData[playbookName] = gearItems.filter((item) => item.hasTag(playbookName)); + dialogData.General = gearItems + .filter((item) => item.hasTag(Tag.Gear.General)) + // Remove featured class from General items + .map((item) => { + if (item.dialogCSSClasses) { + item.dialogCSSClasses = item.dialogCSSClasses.replace(/featured-item\s?/g, ""); + } + return item; + }) + // Re-sort by world_name + .sort((a, b) => { + if (a.system.world_name > b.system.world_name) { return 1 } + if (a.system.world_name < b.system.world_name) { return -1 } + return 0; + }); + } else if (category === SelectionCategory.Ability) { + if (isPC) { + if (playbookName === null) { return false } + dialogData[playbookName] = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.ability, playbookName)); + dialogData.Veteran = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.ability)) + .filter((item) => !item.hasTag(playbookName)) + // Remove featured class from Veteran items + .map((item) => { + if (item.dialogCSSClasses) { + item.dialogCSSClasses = item.dialogCSSClasses.replace(/featured-item\s?/g, ""); + } + return item; + }) + // Re-sort by world_name + .sort((a, b) => { + if (a.system.world_name > b.system.world_name) { return 1 } + if (a.system.world_name < b.system.world_name) { return -1 } + return 0; + }); + } else if (isCrew) { + dialogData.Main = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_ability, playbookName)); + } + } else if (category === SelectionCategory.Upgrade && isCrew && playbookName !== null) { + dialogData[playbookName] = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_upgrade, playbookName)); + dialogData.General = this._processEmbeddedItemMatches(BladesItem.GetTypeWithTags(BladesItemType.crew_upgrade, Tag.Gear.General)); + } + + return dialogData; + } + + getSubItem(itemRef: ItemRef, activeOnly = false): BladesItem | undefined { + const activeCheck = (i: BladesItem) => !activeOnly || !i.hasTag(Tag.System.Archived); + if (typeof itemRef === "string" && this.items.get(itemRef)) { + const returnItem = this.items.get(itemRef); + if (returnItem && activeCheck(returnItem)) { + return returnItem; + } else { + return undefined; + } + } else { + const globalItem = BladesItem.Get(itemRef); + if (!globalItem) { return undefined } + return this.items.find((item) => item.name === globalItem.name && activeCheck(item)) + ?? this.items.find((item) => item.system.world_name === globalItem.system.world_name && activeCheck(item)); + } + } + + hasSubItemOf(itemRef: ItemRef): boolean { + const item = BladesItem.Get(itemRef); + if (!item) { return false } + return Boolean(this.items.find((i) => i.system.world_name === item.system.world_name)); + } + + hasActiveSubItemOf(itemRef: ItemRef): boolean { + const item = BladesItem.Get(itemRef); + if (!item) { return false } + return Boolean(this.items.find((i) => !i.hasTag(Tag.System.Archived) && i.system.world_name === item.system.world_name)); + } + + async addSubItem(itemRef: ItemRef): Promise { + enum BladesItemUniqueTypes { + background = BladesItemType.background, + vice = BladesItemType.vice, + crew_playbook = BladesItemType.crew_playbook, + crew_reputation = BladesItemType.crew_reputation, + heritage = BladesItemType.heritage, + playbook = BladesItemType.playbook, + preferred_op = BladesItemType.preferred_op, + } + function isBladesItemUniqueTypes(type: unknown): type is BladesItemUniqueTypes { + return Object.values(BladesItemUniqueTypes).includes(type as BladesItemUniqueTypes); + } + + eLog.checkLog3("subitems", "[addSubItem] itemRef", itemRef); + + let focusItem: BladesItem | undefined; + + // Does an embedded copy of this item already exist on the character? + const embeddedItem: BladesItem | undefined = this.getSubItem(itemRef); + + + if (embeddedItem) { + + // Is it an archived Item? + if (embeddedItem.hasTag(Tag.System.Archived)) { + // Unarchive it & make it the focus item. + await embeddedItem.remTag(Tag.System.Archived); + focusItem = embeddedItem; + eLog.checkLog3("subitems", `[addSubItem] IS ARCHIVED EMBEDDED > Removing 'Archived' Tag, '${focusItem.id}':`, focusItem); + } else { // Otherwise... + // Duplicate the item, and make the newly-created item the focus. + focusItem = await BladesItem.create([embeddedItem] as unknown as ItemDataConstructorData, {parent: this}) as BladesItem; + eLog.checkLog3("subitems", `[addSubItem] IS ACTIVE EMBEDDED > Duplicating, focusItem '${focusItem.id}':`, focusItem); + } + } else { + // Is it not embedded at all? Embed from global instance. + const globalItem = BladesItem.Get(itemRef); + + eLog.checkLog3("subitems", `[addSubItem] IS NOT EMBEDDED > Fetching Global, globalItem '${globalItem?.id}':`, globalItem); + + if (!globalItem) { return } + focusItem = await BladesItem.create([globalItem] as unknown as ItemDataConstructorData, {parent: this}) as BladesItem; + focusItem = this.items.getName(globalItem.name); + } + + // Is this item type limited to one per actor? + if (focusItem && isBladesItemUniqueTypes(focusItem.type)) { + // ... then archive all other versions. + await Promise.all(this.activeSubItems + .filter((subItem) => subItem.type === focusItem?.type && subItem.system.world_name !== focusItem?.system.world_name && !subItem.hasTag(Tag.System.Archived)) + .map(this.remSubItem.bind(this))); + } + } + + async remSubItem(itemRef: ItemRef): Promise { + const subItem = this.getSubItem(itemRef); + if (!subItem) { return } + if (subItem.type !== BladesItemType.gear) { + this.purgeSubItem(itemRef); + return; + } + eLog.checkLog("actorTrigger", "Removing SubItem " + subItem.name, subItem); + if (subItem.hasTag(Tag.System.Archived)) { return } + subItem.addTag(Tag.System.Archived); + } + + async purgeSubItem(itemRef: ItemRef): Promise { + const subItem = this.getSubItem(itemRef); + if (!subItem || subItem.hasTag(Tag.System.Archived)) { return } + subItem.delete(); + } + + // #endregion + // #region Advancement Implementation ~ + // get totalAbilityPoints(): number { + // if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { return 0 } + // if (!this.playbook) { return 0 } + // switch (this.type) { + // case BladesActorType.pc: return this.system.advancement.ability ?? 0; + // case BladesActorType.crew: return Math.floor(0.5 * (this.system.advancement.general ?? 0)) + (this.system.advancement.ability ?? 0); + // default: return 0; + // } + // } + // get spentAbilityPoints(): number { + // if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { return 0 } + // if (!this.playbook) { return 0 } + // return this.abilities.reduce((total, ability) => total + (ability.system.price ?? 1), 0); + // } + // get getAvailableAdvancements("Ability")(): number { + // if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { return 0 } + // if (!this.playbook) { return 0 } + // return this.totalAbilityPoints - this.spentAbilityPoints; + // } + + /* Need simple getters for total ability & upgrade points that check for PRICES of items + (upgrade.system.price ?? 1) */ + + async grantAdvancementPoints(allowedTypes: AdvancementPoint|AdvancementPoint[], amount = 1) { + const aPtKey: string = Array.isArray(allowedTypes) + ? [...allowedTypes].sort((a, b) => a.localeCompare(b)).join("_") + : allowedTypes; + this.update({[`system.advancement_points.${aPtKey}`]: (this.system.advancement_points?.[aPtKey] ?? 0) + amount}); + } + + async removeAdvancementPoints(allowedTypes: AdvancementPoint|AdvancementPoint[], amount = 1) { + const aPtKey: string = Array.isArray(allowedTypes) + ? [...allowedTypes].sort((a, b) => a.localeCompare(b)).join("_") + : allowedTypes; + const newCount = this.system.advancement_points?.[aPtKey] ?? 0 - amount; + if (newCount <= 0 && aPtKey in (this.system.advancement_points ?? [])) { + this.update({[`system.advancement_points.-=${aPtKey}`]: null}); + } else { + this.update({[`system.advancement_points.${aPtKey}`]: newCount}); + } + } + + getAvailableAdvancements(trait: AdvancementTrait): number { + if (!BladesActor.IsType(this, BladesActorType.pc, BladesActorType.crew)) { return 0 } + if (trait in ActionTrait) { + return 1; + } + if (trait === "Cohort") { + const pointsCohort = this.system.advancement_points?.[AdvancementPoint.Cohort] ?? 0; + const spentCohort = this.cohorts.length; + return Math.max(0, pointsCohort - spentCohort); + } + + const pointsAbility = this.system.advancement_points?.[AdvancementPoint.Ability] ?? 0; + const pointsCohortType = this.system.advancement_points?.[AdvancementPoint.CohortType] ?? 0; + const pointsUpgrade = this.system.advancement_points?.[AdvancementPoint.Upgrade] ?? 0; + const pointsUpgradeOrAbility = this.system.advancement_points?.[AdvancementPoint.UpgradeOrAbility] ?? 0; + + const spentAbility = U.sum(this.items + .filter((item) => BladesItem.IsType(item, BladesItemType.ability, BladesItemType.crew_ability)) + .map((abil) => abil.system.price ?? 1)); + const spentCohortType = U.sum(this.cohorts.map((cohort) => Math.max(0, U.unique(Object.values(cohort.system.subtypes)).length - 1))); + const spentUpgrade = U.sum(this.items + .filter((item) => BladesItem.IsType(item, BladesItemType.crew_upgrade)) + .map((upgrade) => upgrade.system.price ?? 1)); + + const excessUpgrade = Math.max(0, spentUpgrade - pointsUpgrade); + const excessCohortType = Math.max(0, spentCohortType - pointsCohortType); + const excessAbility = Math.max(0, spentAbility - pointsAbility); + + const remainingAbility = Math.max(0, pointsAbility - spentAbility); + const remainingCohortType = Math.max(0, pointsCohortType - spentCohortType); + const remainingUpgrade = Math.max(0, pointsUpgrade - spentUpgrade); + const remainingUpgradeOrAbility = Math.max(0, pointsUpgradeOrAbility - excessUpgrade - (2 * excessAbility) - (2 * excessCohortType)); + + if (trait === "Ability") { + return remainingAbility + Math.floor(0.5 * remainingUpgradeOrAbility); + } + if (trait === "Upgrade") { + return remainingUpgrade + remainingUpgradeOrAbility; + } + if (trait === "CohortType") { + return remainingCohortType + remainingUpgradeOrAbility; + } + + return 0 as never; + } + + get availableAbilityPoints() { return this.getAvailableAdvancements("Ability") } + get availableUpgradePoints() { return this.getAvailableAdvancements("Upgrade") } + get availableCohortPoints() { return this.getAvailableAdvancements("Cohort") } + get availableCohortTypePoints() { return this.getAvailableAdvancements("CohortType") } + + get canPurchaseAbility() { return this.availableAbilityPoints > 0 } + get canPurchaseUpgrade() { return this.availableUpgradePoints > 0 } + get canPurchaseCohort() { return this.availableCohortPoints > 0 } + get canPurchaseCohortType() { return this.availableCohortTypePoints > 0 } + + async advancePlaybook() { + if (!(BladesActor.IsType(this, BladesActorType.pc) || BladesActor.IsType(this, BladesActorType.crew)) || !this.playbook) { return } + await this.update({"system.experience.playbook.value": 0}); + if (BladesActor.IsType(this, BladesActorType.pc)) { + BladesPushController.Get().pushToAll("GM", `${this.name} Advances their Playbook!`, `${this.name}, select a new Ability on your Character Sheet.`); + this.grantAdvancementPoints(AdvancementPoint.Ability); + return; + } + if (BladesActor.IsType(this, BladesActorType.crew)) { + BladesPushController.Get().pushToAll("GM", `${this.name} Advances their Playbook!`, "Select new Upgrades and/or Abilities on your Crew Sheet."); + this.members.forEach((member) => { + const coinGained = this.system.tier.value + 2; + BladesPushController.Get().pushToAll("GM", `${member.name} Gains ${coinGained} Stash (Crew Advancement)`, undefined); + member.addStash(coinGained); + }); + this.grantAdvancementPoints(AdvancementPoint.UpgradeOrAbility, 2); + } + } + + async advanceAttribute(attribute: AttributeTrait) { + await this.update({[`system.experience.${attribute}.value`]: 0}); + const actions = C.Action[attribute].map((action) => `${U.tCase(action)}`); + BladesPushController.Get().pushToAll("GM", `${this.name} Advances their ${U.uCase(attribute)}!`, `${this.name}, add a dot to one of ${U.oxfordize(actions, true, "or")}.`); + } + + + // #endregion + // #region BladesSubActor Implementation ~ + + parentActor?: BladesActor; + get isSubActor() { return this.parentActor !== undefined } + + // #endregion + + // #region BladesCrew Implementation ~ + + get members(): BladesPC[] { + if (!BladesActor.IsType(this, BladesActorType.crew)) { return [] } + const self = this as BladesCrew; + return BladesActor.GetTypeWithTags(BladesActorType.pc).filter((actor): actor is BladesPC => actor.isMember(self)); + } + get contacts(): Array { + if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { return [] } + const self: BladesCrew = this as BladesCrew; + return this.activeSubActors.filter((actor): actor is BladesNPC|BladesFaction => actor.hasTag(self.playbookName)); + } + get claims(): Record { + if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { return {} } + return this.playbook.system.turfs; + } + get turfCount(): number { + if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { return 0 } + return Object.values(this.playbook.system.turfs) + .filter((claim) => claim.isTurf && claim.value).length; + } + + get upgrades(): Array> { + if (!BladesActor.IsType(this, BladesActorType.crew) || !this.playbook) { return [] } + return this.activeSubItems.filter((item): item is BladesItemOfType => item.type === BladesItemType.crew_upgrade); + } + get cohorts(): Array> { + return this.activeSubItems.filter((item): item is BladesItemOfType => [BladesItemType.cohort_gang, BladesItemType.cohort_expert].includes(item.type)); + } + + getTaggedItemBonuses(tags: BladesTag[]): number { + // Check ACTIVE EFFECTS supplied by upgrade/ability against submitted tags? + return tags.length; // Placeholder to avoid linter error + } + // #endregion + + // #region PREPARING DERIVED DATA + override prepareDerivedData() { + if (BladesActor.IsType(this, BladesActorType.pc)) { this._preparePCData(this.system) } + if (BladesActor.IsType(this, BladesActorType.crew)) { this._prepareCrewData(this.system) } + } + + _preparePCData(system: ExtractBladesActorSystem) { + if (!BladesActor.IsType(this, BladesActorType.pc)) { return } + + // Extract experience clues from playbook item, if any + if (this.playbook) { + system.experience.clues = [...system.experience.clues, ...Object.values(this.playbook.system.experience_clues).filter((clue) => Boolean(clue.trim()))]; + } + // Extract gather information questions from playbook item, if any + if (this.playbook) { + system.gather_info = [...system.gather_info, ...Object.values(this.playbook.system.gather_info_questions).filter((question) => Boolean(question.trim()))]; + } + } + + _prepareCrewData(system: ExtractBladesActorSystem) { + if (!BladesActor.IsType(this, BladesActorType.crew)) { return } + + // Extract experience clues and turfs from playbook item, if any + if (this.playbook) { + system.experience.clues = [...system.experience.clues, ...Object.values(this.playbook.system.experience_clues).filter((clue) => Boolean(clue.trim()))]; + system.turfs = this.playbook.system.turfs; + } + } + + // #endregion + + // #region OVERRIDES: _onCreateDescendantDocuments, update ~ + // @ts-expect-error New method not defined in @league VTT types. + override async _onCreateDescendantDocuments(parent: BladesActor, collection: "items"|"effects", docs: BladesItem[]|BladesActiveEffect[], data: BladesItem[]|BladesActiveEffect[], options: Record, userId: string) { + await Promise.all(docs.map(async (doc) => { + if (BladesItem.IsType(doc, BladesItemType.playbook, BladesItemType.crew_playbook)) { + await Promise.all(this.activeSubItems + .filter((aItem) => aItem.type === doc.type && aItem.system.world_name !== doc.system.world_name) + .map((aItem) => this.remSubItem(aItem))); + } + })); + + // @ts-expect-error New method not defined in @league VTT types. + await super._onCreateDescendantDocuments(parent, collection, docs, data, options, userId); + + eLog.checkLog("actorTrigger", "_onCreateDescendantDocuments", {parent, collection, docs, data, options, userId}); + + docs.forEach((doc) => { + if (BladesItem.IsType(doc, BladesItemType.vice) && BladesActor.IsType(this, BladesActorType.pc)) { + this.activeSubActors + .filter((subActor) => subActor.hasTag(Tag.NPC.VicePurveyor) && !subActor.hasTag(doc.name as Vice)) + .forEach((subActor) => { this.remSubActor(subActor) }); + } + }); + } + + override async update(updateData: FullPartial<(ActorDataConstructorData & SubActorData) | (ActorDataConstructorData & SubActorData & Record)> | undefined, context?: (DocumentModificationContext & MergeObjectOptions) | undefined, isSkippingSubActorCheck = false): Promise { + if (!updateData) { return super.update(updateData) } + if (BladesActor.IsType(this, BladesActorType.crew)) { + if (!this.playbook) { return undefined } + + eLog.checkLog("actorTrigger", "Updating Crew", {updateData}); + const playbookUpdateData: Partial = Object.fromEntries(Object.entries(flattenObject(updateData)) + .filter(([key, _]: [string, unknown]) => key.startsWith("system.turfs."))); + updateData = Object.fromEntries(Object.entries(flattenObject(updateData)) + .filter(([key, _]: [string, unknown]) => !key.startsWith("system.turfs."))); + eLog.checkLog("actorTrigger", "Updating Crew", {crewUpdateData: updateData, playbookUpdateData}); + + const diffPlaybookData = diffObject(flattenObject(this.playbook), playbookUpdateData) as Record> & {_id?: string}; + delete diffPlaybookData._id; + + if (!U.isEmpty(diffPlaybookData)) { + await this.playbook.update(playbookUpdateData, context) + .then(() => this.sheet?.render(false)); + } + } else if ( + (BladesActor.IsType(this, BladesActorType.npc) + || BladesActor.IsType(this, BladesActorType.faction)) + && this.parentActor + && !isSkippingSubActorCheck) { + // This is an embedded Actor: Update it as a subActor of parentActor. + return this.parentActor.updateSubActor(this.id, updateData as List>>) + .then(() => this); + } + + return super.update(updateData, context); + } + + // #endregion + + // #region Rolling Dice ~ + + /** + * Creates modifiers for dice roll. + * + * @param {int} rs + * Min die modifier + * @param {int} re + * Max die modifier + * @param {int} s + * Selected die + */ + createListOfDiceMods(rs: number, re: number, s: number | string) { + + let text = ""; + + if (s === "") { + s = 0; + } + + for (let i = rs; i <= re; i++) { + let plus = ""; + if (i >= 0) { plus = "+" } + text += ``; + } + + return text; + } + + // #endregion Rolling Dice + + // #region NPC Randomizers ~ + updateRandomizers() { + if (!BladesActor.IsType(this, BladesActorType.npc)) { return } + const titleChance = 0.05; + const suffixChance = 0.01; + + const {persona, secret, random} = this.system; + + function sampleArray(arr: string[], ...curVals: string[]): string { + arr = arr.filter((elem) => !curVals.includes(elem)); + if (!arr.length) { return "" } + return arr[Math.floor(Math.random() * arr.length)]; + } + const randomGen: Record string> = { + name: (gen?: string) => { + return [ + Math.random() <= titleChance + ? sampleArray(Randomizers.NPC.name_title) + : "", + sampleArray([ + ...((gen ?? "").charAt(0).toLowerCase() !== "m" ? Randomizers.NPC.name_first.female : []), + ...((gen ?? "").charAt(0).toLowerCase() !== "f" ? Randomizers.NPC.name_first.male : []) + ]), + `"${sampleArray(Randomizers.NPC.name_alias)}"`, + sampleArray(Randomizers.NPC.name_surname), + Math.random() <= suffixChance + ? sampleArray(Randomizers.NPC.name_suffix) + : "" + ].filter((val) => Boolean(val)).join(" "); + }, + background: () => sampleArray(Randomizers.NPC.background, random.background.value), + heritage: () => sampleArray(Randomizers.NPC.heritage, random.heritage.value), + profession: () => sampleArray(Randomizers.NPC.profession, random.profession.value), + gender: () => sampleArray(Randomizers.NPC.gender, persona.gender.value) as Gender, + appearance: () => sampleArray(Randomizers.NPC.appearance, persona.appearance.value), + goal: () => sampleArray(Randomizers.NPC.goal, persona.goal.value, secret.goal.value), + method: () => sampleArray(Randomizers.NPC.method, persona.method.value, secret.method.value), + trait: () => sampleArray(Randomizers.NPC.trait, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value), + interests: () => sampleArray(Randomizers.NPC.interests, persona.interests.value, secret.interests.value), + quirk: () => sampleArray(Randomizers.NPC.quirk, persona.quirk.value), + style: (gen = "") => sampleArray([ + ...(gen.charAt(0).toLowerCase() !== "m" ? Randomizers.NPC.style.female : []), + ...(gen.charAt(0).toLowerCase() !== "f" ? Randomizers.NPC.style.male : []) + ], persona.style.value) + }; + + const gender = persona.gender.isLocked ? persona.gender.value : randomGen.gender(); + const updateKeys = [ + ...Object.keys(persona).filter((key) => !persona[key as KeyOf]?.isLocked), + ...Object.keys(random).filter((key) => !random[key as KeyOf]?.isLocked), + ...Object.keys(secret).filter((key) => !secret[key as KeyOf]?.isLocked) + .map((secretKey) => `secret-${secretKey}`) + ]; + + eLog.checkLog("Update Keys", {updateKeys}); + const updateData: Record = {}; + + updateKeys.forEach((key) => { + switch (key) { + case "name": + case "heritage": + case "background": + case "profession": { + const randomVal = randomGen[key](); + updateData[`system.random.${key}`] = { + isLocked: false, + value: randomVal || random[key].value + }; + break; + } + case "secret-goal": + case "secret-interests": + case "secret-method": { + key = key.replace(/^secret-/, ""); + const randomVal = randomGen[key](); + updateData[`system.secret.${key}`] = { + isLocked: false, + value: randomVal || secret[key as KeyOf].value + }; + break; + } + case "gender": { + updateData[`system.persona.${key}`] = { + isLocked: persona.gender.isLocked, + value: gender + }; + break; + } + case "trait1": + case "trait2": + case "trait3": + case "secret-trait": { + const trait1 = persona.trait1.isLocked + ? persona.trait1.value + : sampleArray(Randomizers.NPC.trait, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); + const trait2 = persona.trait2.isLocked + ? persona.trait2.value + : sampleArray(Randomizers.NPC.trait, trait1, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); + const trait3 = persona.trait3.isLocked + ? persona.trait3.value + : sampleArray(Randomizers.NPC.trait, trait1, trait2, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); + const secretTrait = secret.trait.isLocked + ? secret.trait.value + : sampleArray(Randomizers.NPC.trait, trait1, trait2, trait3, persona.trait1.value, persona.trait2.value, persona.trait3.value, secret.trait.value); + if (!persona.trait1.isLocked) { + updateData["system.persona.trait1"] = { + isLocked: false, + value: trait1 + }; + } + if (!persona.trait2.isLocked) { + updateData["system.persona.trait2"] = { + isLocked: false, + value: trait2 + }; + } + if (!persona.trait3.isLocked) { + updateData["system.persona.trait3"] = { + isLocked: false, + value: trait3 + }; + } + if (!secret.trait.isLocked) { + updateData["system.secret.trait"] = { + isLocked: false, + value: secretTrait + }; + } + break; + } + default: { + const randomVal = randomGen[key](); + updateData[`system.persona.${key}`] = { + isLocked: false, + value: randomVal || persona[key as KeyOf].value + }; + break; + } + } + }); + + this.update(updateData); + } + // #endregion NPC Randomizers + +} + +declare interface BladesActor { + get id(): string; + get name(): string; + get img(): string; + get type(): BladesActorType; + get items(): EmbeddedCollection; + system: BladesActorSystem, + getRollData(): BladesActorRollData; + parent: TokenDocument | null; + ownership: Record>; + _source: BladesActor; +} + export default BladesActor; \ No newline at end of file diff --git a/ts/blades-dialog.ts b/ts/BladesDialog.ts similarity index 97% rename from ts/blades-dialog.ts rename to ts/BladesDialog.ts index d4538a18..64b09271 100644 --- a/ts/blades-dialog.ts +++ b/ts/BladesDialog.ts @@ -1,7 +1,7 @@ import {ApplyTooltipListeners} from "./core/gsap.js"; import U from "./core/utilities.js"; -import BladesActor from "./blades-actor.js"; -import BladesItem from "./blades-item.js"; +import BladesActor from "./BladesActor.js"; +import BladesItem from "./BladesItem.js"; export enum SelectionCategory { Heritage = "Heritage", diff --git a/ts/blades-item.ts b/ts/BladesItem.ts similarity index 96% rename from ts/blades-item.ts rename to ts/BladesItem.ts index 550e61a8..a6dafa9b 100644 --- a/ts/blades-item.ts +++ b/ts/BladesItem.ts @@ -1,334 +1,334 @@ -import C, {BladesActorType, BladesItemType, Tag, Factor} from "./core/constants.js"; -import U from "./core/utilities.js"; -import {BladesActor} from "./documents/blades-actor-proxy.js"; -import {BladesRollMod} from "./blades-roll-collab.js"; -import type {ItemDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/itemData.js"; - -class BladesItem extends Item implements BladesDocument, - BladesItemSubClass.Ability, - BladesItemSubClass.Background, - BladesItemSubClass.Cohort_Gang, - BladesItemSubClass.Cohort_Expert, - BladesItemSubClass.Crew_Ability, - BladesItemSubClass.Crew_Reputation, - BladesItemSubClass.Crew_Playbook, - BladesItemSubClass.Crew_Upgrade, - BladesItemSubClass.Feature, - BladesItemSubClass.Heritage, - BladesItemSubClass.Gear, - BladesItemSubClass.Playbook, - BladesItemSubClass.Preferred_Op, - BladesItemSubClass.Stricture, - BladesItemSubClass.Vice, - BladesItemSubClass.Project, - BladesItemSubClass.Ritual, - BladesItemSubClass.Design { - - // #region Static Overrides: Create ~ - static override async create(data: ItemDataConstructorData & { system?: { world_name?: string, description?: string } }, options = {}) { - if (Array.isArray(data)) { data = data[0] } - data.system = data.system ?? {}; - - eLog.checkLog2("item", "BladesItem.create(data,options)", {data, options}); - - //~ Create world_name - data.system.world_name = data.system.world_name ?? data.name.replace(/[^A-Za-z_0-9 ]/g, "").trim().replace(/ /g, "_"); - - return super.create(data, options); - } - // #endregion - - // #region BladesDocument Implementation - static get All() { return game.items } - static Get(itemRef: ItemRef): BladesItem|undefined { - if (itemRef instanceof BladesItem) { return itemRef } - if (U.isDocID(itemRef)) { return BladesItem.All.get(itemRef) } - return BladesItem.All.find((a) => a.system.world_name === itemRef) - || BladesItem.All.find((a) => a.name === itemRef); - } - static GetTypeWithTags(docType: T|T[], ...tags: BladesTag[]): Array> { - if (Array.isArray(docType)) { - return docType - .map((dType) => BladesItem.All.filter((item): item is BladesItemOfType => item.type === dType)) - .flat(); - } - return BladesItem.All.filter((item): item is BladesItemOfType => item.type === docType) - .filter((item) => item.hasTag(...tags)); - } - - static IsType(doc: unknown, ...types: T[]): doc is BladesItemOfType { - const typeSet = new Set(types); - return doc instanceof BladesItem && typeSet.has(doc.type); - } - - get tags(): BladesTag[] { return this.system.tags ?? [] } - hasTag(...tags: BladesTag[]): boolean { - return tags.every((tag) => this.tags.includes(tag)); - } - async addTag(...tags: BladesTag[]) { - const curTags = this.tags; - tags.forEach((tag) => { - if (curTags.includes(tag)) { return } - curTags.push(tag); - }); - this.update({"system.tags": curTags}); - } - async remTag(...tags: BladesTag[]) { - const curTags = this.tags.filter((tag) => !tags.includes(tag)); - this.update({"system.tags": curTags}); - } - - get tooltip(): string|undefined { - const tooltipText = [ - this.system.concept, - this.system.rules, - this.system.notes - ].filter(Boolean).join(""); - if (tooltipText) { return (new Handlebars.SafeString(tooltipText)).toString() } - return undefined; - } - dialogCSSClasses = ""; - - getFactorTotal(factor: Factor): number { - switch (factor) { - case Factor.tier: { - if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { - return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); - } - if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { - return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); - } - if (BladesItem.IsType(this, BladesItemType.gear)) { - return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); - } - return this.system.tier.value; - } - case Factor.quality: { - if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { - return this.getFactorTotal(Factor.tier) + (this.system.quality_bonus ?? 0); - } - if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { - return this.getFactorTotal(Factor.tier) + (this.system.quality_bonus ?? 0) + 1; - } - if (BladesItem.IsType(this, BladesItemType.gear)) { - return this.getFactorTotal(Factor.tier) - + (this.hasTag("Fine") ? 1 : 0) - + (this.parent?.getTaggedItemBonuses(this.tags) ?? 0) - + ( - BladesActor.IsType(this.parent, BladesActorType.pc) - && BladesActor.IsType(this.parent.crew, BladesActorType.crew) - ? this.parent.crew.getTaggedItemBonuses(this.tags) - : 0 - ); - } - if (BladesItem.IsType(this, BladesItemType.design)) { return this.system.min_quality } - return this.getFactorTotal(Factor.tier); - } - case Factor.scale: { - if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { - return this.getFactorTotal(Factor.tier) + (this.system.scale_bonus ?? 0); - } - if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { - return 0 + (this.system.scale_bonus ?? 0); - } - return 0; - } - case Factor.magnitude: { - if (BladesItem.IsType(this, BladesItemType.ritual)) { - return this.system.magnitude.value; - } - return 0; - } - default: return 0; - } - } - // #endregion - // #region BladesItemDocument Implementation - - async archive() { - await this.addTag(Tag.System.Archived); - return this; - } - async unarchive() { - await this.remTag(Tag.System.Archived); - return this; - } - - // #endregion - - // #region BladesRollCollab Implementation - - get rollFactors(): Partial> { - const factorsMap: Partial> = { - [BladesItemType.cohort_gang]: [Factor.quality, Factor.scale], - [BladesItemType.cohort_expert]: [Factor.quality, Factor.scale], - [BladesItemType.gear]: [Factor.quality], - [BladesItemType.project]: [Factor.quality], - [BladesItemType.ritual]: [Factor.magnitude], - [BladesItemType.design]: [Factor.quality] - }; - if (!factorsMap[this.type]) { return {} } - - const factors = factorsMap[this.type]; - - const factorData: Partial> = {}; - (factors ?? []).forEach((factor, i) => { - const factorTotal = this.getFactorTotal(factor); - factorData[factor] = { - name: factor, - value: factorTotal, - max: factorTotal, - baseVal: factorTotal, - display: [Factor.tier, Factor.quality].includes(factor) ? U.romanizeNum(factorTotal) : `${factorTotal}`, - isActive: i === 0, - isPrimary: i === 0, - isDominant: false, - highFavorsPC: true, - cssClasses: `factor-gold${i === 0 ? " factor-main" : ""}` - }; - }); - - return factorData; - } - - // #region BladesRollCollab.PrimaryDoc Implementation - get rollPrimaryID() { return this.id } - get rollPrimaryDoc() { return this } - get rollPrimaryName() { return this.name } - get rollPrimaryType() { return this.type } - get rollPrimaryImg() { return this.img } - - get rollModsData(): BladesRollCollab.RollModData[] { - // const rollModData = BladesRollMod.ParseDocRollMods(this); - // Add roll mods from COHORT harm - - return BladesRollMod.ParseDocRollMods(this); - } - - // #endregion - - // #region BladesRollCollab.OppositionDoc Implementation - get rollOppID() { return this.id } - get rollOppDoc() { return this } - get rollOppImg() { return this.img } - get rollOppName() { return this.name } - get rollOppSubName() { return "" } - get rollOppType() { return this.type } - - get rollOppModsData(): BladesRollCollab.RollModData[] { return [] } - // #endregion - - // #region BladesRollCollab.ParticipantDoc Implementation - get rollParticipantID() { return this.id } - get rollParticipantDoc() { return this } - get rollParticipantIcon() { return this.img } - get rollParticipantName() { return this.name } - get rollParticipantType() { return this.type } - - get rollParticipantModsData(): BladesRollCollab.RollModData[] { return [] } - // #endregion - - // #endregion - - // #region PREPARING DERIVED DATA - override prepareDerivedData() { - super.prepareDerivedData(); - if (BladesItem.IsType(this, BladesItemType.cohort_gang, BladesItemType.cohort_expert)) { this._prepareCohortData(this.system) } - if (BladesItem.IsType(this, BladesItemType.crew_playbook)) { this._preparePlaybookData(this.system) } - if (BladesItem.IsType(this, BladesItemType.gear)) { this._prepareGearData(this.system) } - if (BladesItem.IsType(this, BladesItemType.playbook)) { this._preparePlaybookData(this.system) } - } - - - _prepareCohortData(system: ExtractBladesItemSystem) { - if (!BladesItem.IsType(this, BladesItemType.cohort_gang, BladesItemType.cohort_expert)) { return } - - system.tier.name = "Quality"; - - const subtypes = U.unique(Object.values(system.subtypes) - .map((subtype) => subtype.trim()) - .filter((subtype) => /[A-Za-z]/.test(subtype))); - const eliteSubtypes = U.unique([ - ...Object.values(system.elite_subtypes), - ...(this.parent?.upgrades ?? []) - .filter((upgrade) => /^Elite/.test(upgrade.name ?? "")) - .map((upgrade) => (upgrade.name ?? "").trim().replace(/^Elite /, "")) - ] - .map((subtype) => subtype.trim()) - .filter((subtype) => /[A-Za-z]/.test(subtype) && subtypes.includes(subtype))); - - system.subtypes = Object.fromEntries(subtypes.map((subtype, i) => [`${i + 1}`, subtype])); - system.elite_subtypes = Object.fromEntries(eliteSubtypes.map((subtype, i) => [`${i + 1}`, subtype])); - system.edges = Object.fromEntries(Object.values(system.edges ?? []) - .filter((edge) => /[A-Za-z]/.test(edge)) - .map((edge, i) => [`${i + 1}`, edge.trim()])); - system.flaws = Object.fromEntries(Object.values(system.flaws ?? []) - .filter((flaw) => /[A-Za-z]/.test(flaw)) - .map((flaw, i) => [`${i + 1}`, flaw.trim()])); - - system.quality = this.getFactorTotal(Factor.quality); - - if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { - if ([...subtypes, ...eliteSubtypes].includes(Tag.GangType.Vehicle)) { - system.scale = this.getFactorTotal(Factor.scale); - system.scaleExample = "(1 vehicle)"; - } else { - system.scale = this.getFactorTotal(Factor.scale); - const scaleIndex = Math.min(6, system.scale); - system.scaleExample = C.ScaleExamples[scaleIndex]; - system.subtitle = C.ScaleSizes[scaleIndex]; - } - if (subtypes.length + eliteSubtypes.length === 0) { - system.subtitle = system.subtitle.replace(/\s+of\b/g, "").trim(); - } - } else { - system.scale = 0; - system.scaleExample = [...subtypes, ...eliteSubtypes].includes("Pet") ? "(1 animal)" : "(1 person)"; - system.subtitle = "An Expert"; - } - - if (subtypes.length + eliteSubtypes.length > 0) { - if ([...subtypes, ...eliteSubtypes].includes(Tag.GangType.Vehicle)) { - system.subtitle = C.VehicleDescriptors[Math.min(6, this.getFactorTotal(Factor.tier))]; - } else { - system.subtitle += ` ${U.oxfordize([ - ...subtypes.filter((subtype) => !eliteSubtypes.includes(subtype)), - ...eliteSubtypes.map((subtype) => `Elite ${subtype}`) - ], false, "&")}`; - } - } - } - - _prepareGearData(system: ExtractBladesItemSystem) { - if (!BladesItem.IsType(this, BladesItemType.gear)) { return } - system.tier.name = "Quality"; - } - - _preparePlaybookData(system: ExtractBladesItemSystem) { - if (!BladesItem.IsType(this, BladesItemType.playbook, BladesItemType.crew_playbook)) { return } - const expClueData: Record = {}; - [...Object.values(system.experience_clues).filter((clue) => /[A-Za-z]/.test(clue)), " "].forEach((clue, i) => { expClueData[(i + 1).toString()] = clue }); - system.experience_clues = expClueData; - eLog.checkLog3("experienceClues", {expClueData}); - - if (BladesItem.IsType(this, BladesItemType.playbook)) { - const gatherInfoData: Record = {}; - [...Object.values(system.gather_info_questions).filter((question) => /[A-Za-z]/.test(question)), " "].forEach((question, i) => { gatherInfoData[(i + 1).toString()] = question }); - system.gather_info_questions = gatherInfoData; - eLog.checkLog3("gatherInfoQuestions", {gatherInfoData}); - - } - } - // #endregion -} - -declare interface BladesItem { - get id(): string; - get name(): string; - get img(): string; - get type(): BladesItemType, - parent: BladesActor | null, - system: BladesItemSystem -} - +import C, {BladesActorType, BladesItemType, Tag, Factor} from "./core/constants.js"; +import U from "./core/utilities.js"; +import {BladesActor} from "./documents/BladesActorProxy.js"; +import {BladesRollMod} from "./BladesRollCollab.js"; +import type {ItemDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/itemData.js"; + +class BladesItem extends Item implements BladesDocument, + BladesItemSubClass.Ability, + BladesItemSubClass.Background, + BladesItemSubClass.Cohort_Gang, + BladesItemSubClass.Cohort_Expert, + BladesItemSubClass.Crew_Ability, + BladesItemSubClass.Crew_Reputation, + BladesItemSubClass.Crew_Playbook, + BladesItemSubClass.Crew_Upgrade, + BladesItemSubClass.Feature, + BladesItemSubClass.Heritage, + BladesItemSubClass.Gear, + BladesItemSubClass.Playbook, + BladesItemSubClass.Preferred_Op, + BladesItemSubClass.Stricture, + BladesItemSubClass.Vice, + BladesItemSubClass.Project, + BladesItemSubClass.Ritual, + BladesItemSubClass.Design { + + // #region Static Overrides: Create ~ + static override async create(data: ItemDataConstructorData & { system?: { world_name?: string, description?: string } }, options = {}) { + if (Array.isArray(data)) { data = data[0] } + data.system = data.system ?? {}; + + eLog.checkLog2("item", "BladesItem.create(data,options)", {data, options}); + + //~ Create world_name + data.system.world_name = data.system.world_name ?? data.name.replace(/[^A-Za-z_0-9 ]/g, "").trim().replace(/ /g, "_"); + + return super.create(data, options); + } + // #endregion + + // #region BladesDocument Implementation + static get All() { return game.items } + static Get(itemRef: ItemRef): BladesItem|undefined { + if (itemRef instanceof BladesItem) { return itemRef } + if (U.isDocID(itemRef)) { return BladesItem.All.get(itemRef) } + return BladesItem.All.find((a) => a.system.world_name === itemRef) + || BladesItem.All.find((a) => a.name === itemRef); + } + static GetTypeWithTags(docType: T|T[], ...tags: BladesTag[]): Array> { + if (Array.isArray(docType)) { + return docType + .map((dType) => BladesItem.All.filter((item): item is BladesItemOfType => item.type === dType)) + .flat(); + } + return BladesItem.All.filter((item): item is BladesItemOfType => item.type === docType) + .filter((item) => item.hasTag(...tags)); + } + + static IsType(doc: unknown, ...types: T[]): doc is BladesItemOfType { + const typeSet = new Set(types); + return doc instanceof BladesItem && typeSet.has(doc.type); + } + + get tags(): BladesTag[] { return this.system.tags ?? [] } + hasTag(...tags: BladesTag[]): boolean { + return tags.every((tag) => this.tags.includes(tag)); + } + async addTag(...tags: BladesTag[]) { + const curTags = this.tags; + tags.forEach((tag) => { + if (curTags.includes(tag)) { return } + curTags.push(tag); + }); + this.update({"system.tags": curTags}); + } + async remTag(...tags: BladesTag[]) { + const curTags = this.tags.filter((tag) => !tags.includes(tag)); + this.update({"system.tags": curTags}); + } + + get tooltip(): string|undefined { + const tooltipText = [ + this.system.concept, + this.system.rules, + this.system.notes + ].filter(Boolean).join(""); + if (tooltipText) { return (new Handlebars.SafeString(tooltipText)).toString() } + return undefined; + } + dialogCSSClasses = ""; + + getFactorTotal(factor: Factor): number { + switch (factor) { + case Factor.tier: { + if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { + return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); + } + if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { + return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); + } + if (BladesItem.IsType(this, BladesItemType.gear)) { + return this.system.tier.value + (this.parent?.getFactorTotal(Factor.tier) ?? 0); + } + return this.system.tier.value; + } + case Factor.quality: { + if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { + return this.getFactorTotal(Factor.tier) + (this.system.quality_bonus ?? 0); + } + if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { + return this.getFactorTotal(Factor.tier) + (this.system.quality_bonus ?? 0) + 1; + } + if (BladesItem.IsType(this, BladesItemType.gear)) { + return this.getFactorTotal(Factor.tier) + + (this.hasTag("Fine") ? 1 : 0) + + (this.parent?.getTaggedItemBonuses(this.tags) ?? 0) + + ( + BladesActor.IsType(this.parent, BladesActorType.pc) + && BladesActor.IsType(this.parent.crew, BladesActorType.crew) + ? this.parent.crew.getTaggedItemBonuses(this.tags) + : 0 + ); + } + if (BladesItem.IsType(this, BladesItemType.design)) { return this.system.min_quality } + return this.getFactorTotal(Factor.tier); + } + case Factor.scale: { + if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { + return this.getFactorTotal(Factor.tier) + (this.system.scale_bonus ?? 0); + } + if (BladesItem.IsType(this, BladesItemType.cohort_expert)) { + return 0 + (this.system.scale_bonus ?? 0); + } + return 0; + } + case Factor.magnitude: { + if (BladesItem.IsType(this, BladesItemType.ritual)) { + return this.system.magnitude.value; + } + return 0; + } + default: return 0; + } + } + // #endregion + // #region BladesItemDocument Implementation + + async archive() { + await this.addTag(Tag.System.Archived); + return this; + } + async unarchive() { + await this.remTag(Tag.System.Archived); + return this; + } + + // #endregion + + // #region BladesRollCollab Implementation + + get rollFactors(): Partial> { + const factorsMap: Partial> = { + [BladesItemType.cohort_gang]: [Factor.quality, Factor.scale], + [BladesItemType.cohort_expert]: [Factor.quality, Factor.scale], + [BladesItemType.gear]: [Factor.quality], + [BladesItemType.project]: [Factor.quality], + [BladesItemType.ritual]: [Factor.magnitude], + [BladesItemType.design]: [Factor.quality] + }; + if (!factorsMap[this.type]) { return {} } + + const factors = factorsMap[this.type]; + + const factorData: Partial> = {}; + (factors ?? []).forEach((factor, i) => { + const factorTotal = this.getFactorTotal(factor); + factorData[factor] = { + name: factor, + value: factorTotal, + max: factorTotal, + baseVal: factorTotal, + display: [Factor.tier, Factor.quality].includes(factor) ? U.romanizeNum(factorTotal) : `${factorTotal}`, + isActive: i === 0, + isPrimary: i === 0, + isDominant: false, + highFavorsPC: true, + cssClasses: `factor-gold${i === 0 ? " factor-main" : ""}` + }; + }); + + return factorData; + } + + // #region BladesRollCollab.PrimaryDoc Implementation + get rollPrimaryID() { return this.id } + get rollPrimaryDoc() { return this } + get rollPrimaryName() { return this.name } + get rollPrimaryType() { return this.type } + get rollPrimaryImg() { return this.img } + + get rollModsData(): BladesRollCollab.RollModData[] { + // const rollModData = BladesRollMod.ParseDocRollMods(this); + // Add roll mods from COHORT harm + + return BladesRollMod.ParseDocRollMods(this); + } + + // #endregion + + // #region BladesRollCollab.OppositionDoc Implementation + get rollOppID() { return this.id } + get rollOppDoc() { return this } + get rollOppImg() { return this.img } + get rollOppName() { return this.name } + get rollOppSubName() { return "" } + get rollOppType() { return this.type } + + get rollOppModsData(): BladesRollCollab.RollModData[] { return [] } + // #endregion + + // #region BladesRollCollab.ParticipantDoc Implementation + get rollParticipantID() { return this.id } + get rollParticipantDoc() { return this } + get rollParticipantIcon() { return this.img } + get rollParticipantName() { return this.name } + get rollParticipantType() { return this.type } + + get rollParticipantModsData(): BladesRollCollab.RollModData[] { return [] } + // #endregion + + // #endregion + + // #region PREPARING DERIVED DATA + override prepareDerivedData() { + super.prepareDerivedData(); + if (BladesItem.IsType(this, BladesItemType.cohort_gang, BladesItemType.cohort_expert)) { this._prepareCohortData(this.system) } + if (BladesItem.IsType(this, BladesItemType.crew_playbook)) { this._preparePlaybookData(this.system) } + if (BladesItem.IsType(this, BladesItemType.gear)) { this._prepareGearData(this.system) } + if (BladesItem.IsType(this, BladesItemType.playbook)) { this._preparePlaybookData(this.system) } + } + + + _prepareCohortData(system: ExtractBladesItemSystem) { + if (!BladesItem.IsType(this, BladesItemType.cohort_gang, BladesItemType.cohort_expert)) { return } + + system.tier.name = "Quality"; + + const subtypes = U.unique(Object.values(system.subtypes) + .map((subtype) => subtype.trim()) + .filter((subtype) => /[A-Za-z]/.test(subtype))); + const eliteSubtypes = U.unique([ + ...Object.values(system.elite_subtypes), + ...(this.parent?.upgrades ?? []) + .filter((upgrade) => /^Elite/.test(upgrade.name ?? "")) + .map((upgrade) => (upgrade.name ?? "").trim().replace(/^Elite /, "")) + ] + .map((subtype) => subtype.trim()) + .filter((subtype) => /[A-Za-z]/.test(subtype) && subtypes.includes(subtype))); + + system.subtypes = Object.fromEntries(subtypes.map((subtype, i) => [`${i + 1}`, subtype])); + system.elite_subtypes = Object.fromEntries(eliteSubtypes.map((subtype, i) => [`${i + 1}`, subtype])); + system.edges = Object.fromEntries(Object.values(system.edges ?? []) + .filter((edge) => /[A-Za-z]/.test(edge)) + .map((edge, i) => [`${i + 1}`, edge.trim()])); + system.flaws = Object.fromEntries(Object.values(system.flaws ?? []) + .filter((flaw) => /[A-Za-z]/.test(flaw)) + .map((flaw, i) => [`${i + 1}`, flaw.trim()])); + + system.quality = this.getFactorTotal(Factor.quality); + + if (BladesItem.IsType(this, BladesItemType.cohort_gang)) { + if ([...subtypes, ...eliteSubtypes].includes(Tag.GangType.Vehicle)) { + system.scale = this.getFactorTotal(Factor.scale); + system.scaleExample = "(1 vehicle)"; + } else { + system.scale = this.getFactorTotal(Factor.scale); + const scaleIndex = Math.min(6, system.scale); + system.scaleExample = C.ScaleExamples[scaleIndex]; + system.subtitle = C.ScaleSizes[scaleIndex]; + } + if (subtypes.length + eliteSubtypes.length === 0) { + system.subtitle = system.subtitle.replace(/\s+of\b/g, "").trim(); + } + } else { + system.scale = 0; + system.scaleExample = [...subtypes, ...eliteSubtypes].includes("Pet") ? "(1 animal)" : "(1 person)"; + system.subtitle = "An Expert"; + } + + if (subtypes.length + eliteSubtypes.length > 0) { + if ([...subtypes, ...eliteSubtypes].includes(Tag.GangType.Vehicle)) { + system.subtitle = C.VehicleDescriptors[Math.min(6, this.getFactorTotal(Factor.tier))]; + } else { + system.subtitle += ` ${U.oxfordize([ + ...subtypes.filter((subtype) => !eliteSubtypes.includes(subtype)), + ...eliteSubtypes.map((subtype) => `Elite ${subtype}`) + ], false, "&")}`; + } + } + } + + _prepareGearData(system: ExtractBladesItemSystem) { + if (!BladesItem.IsType(this, BladesItemType.gear)) { return } + system.tier.name = "Quality"; + } + + _preparePlaybookData(system: ExtractBladesItemSystem) { + if (!BladesItem.IsType(this, BladesItemType.playbook, BladesItemType.crew_playbook)) { return } + const expClueData: Record = {}; + [...Object.values(system.experience_clues).filter((clue) => /[A-Za-z]/.test(clue)), " "].forEach((clue, i) => { expClueData[(i + 1).toString()] = clue }); + system.experience_clues = expClueData; + eLog.checkLog3("experienceClues", {expClueData}); + + if (BladesItem.IsType(this, BladesItemType.playbook)) { + const gatherInfoData: Record = {}; + [...Object.values(system.gather_info_questions).filter((question) => /[A-Za-z]/.test(question)), " "].forEach((question, i) => { gatherInfoData[(i + 1).toString()] = question }); + system.gather_info_questions = gatherInfoData; + eLog.checkLog3("gatherInfoQuestions", {gatherInfoData}); + + } + } + // #endregion +} + +declare interface BladesItem { + get id(): string; + get name(): string; + get img(): string; + get type(): BladesItemType, + parent: BladesActor | null, + system: BladesItemSystem +} + export default BladesItem; \ No newline at end of file diff --git a/ts/blades-push-notifications.ts b/ts/BladesPushController.ts similarity index 91% rename from ts/blades-push-notifications.ts rename to ts/BladesPushController.ts index f38c28ef..c71af4e8 100644 --- a/ts/blades-push-notifications.ts +++ b/ts/BladesPushController.ts @@ -2,7 +2,14 @@ import U from "./core/utilities.js"; export default class BladesPushController { - static Get() { return game.eunoblades.PushController! } + static Get(): BladesPushController { + if (!game.eunoblades.PushController) { + throw new Error("Attempt to Get BladesPushController before 'ready' hook."); + } + return game.eunoblades.PushController; + } + + static isInitialized = false; static Initialize() { game.eunoblades ??= {}; Hooks.once("ready", async () => { @@ -26,6 +33,7 @@ export default class BladesPushController { initOverlay() { $("#sidebar").append($("
")); + BladesPushController.isInitialized = true; } get elem$() { return $("#blades-push-notifications") } diff --git a/ts/blades-roll-collab.ts b/ts/BladesRollCollab.ts similarity index 52% rename from ts/blades-roll-collab.ts rename to ts/BladesRollCollab.ts index a4d12c34..3c09e490 100644 --- a/ts/blades-roll-collab.ts +++ b/ts/BladesRollCollab.ts @@ -1,1185 +1,21 @@ // #region IMPORTS ~ import U from "./core/utilities.js"; -import C, {BladesActorType, BladesItemType, RollType, RollSubType, RollModStatus, RollModCategory, Action, DowntimeAction, Attribute, Position, Effect, Factor, RollResult, ConsequenceType} from "./core/constants.js"; -import BladesActor from "./blades-actor.js"; -import BladesPC from "./documents/actors/blades-pc.js"; -import BladesItem from "./blades-item.js"; -import BladesActiveEffect from "./blades-active-effect.js"; +import C, {BladesActorType, BladesItemType, RollType, RollSubType, RollModStatus, RollModCategory, ActionTrait, DowntimeAction, AttributeTrait, Position, Effect, Factor, RollResult, ConsequenceType} from "./core/constants.js"; +import BladesActor from "./BladesActor.js"; +import BladesPC from "./documents/actors/BladesPC.js"; +import BladesItem from "./BladesItem.js"; import {ApplyTooltipListeners} from "./core/gsap.js"; -import {EffectChangeData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/effectChangeData.js"; -// #endregion -// #region Google Sheets Data Import ~ -const DescriptionChanges: Record = { - "Battleborn": "

If you 'reduce harm' that means the level of harm you're facing right now is reduced by one.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", - "Bodyguard": "

The protect teamwork maneuver lets you face a consequence for a teammate.

If you choose to resist that consequence, this ability gives you +1d to your resistance roll.

Also, when you read a situation to gather information about hidden dangers or potential attackers, you get +1 effect—which means more detailed information.

", - "Ghost Fighter": "

When you're imbued, you can strongly interact with ghosts and spirit-stuff, rather than weakly interact.

When you imbue yourself with spirit energy, how do you do it? What does it look like when the energy manifests?

", - "Leader": "

This ability makes your cohorts more effective in battle and also allows them to resist harm by using armor.

While you lead your cohorts, they won't stop fighting until they take fatal harm (level 4) or you order them to cease.

What do you do to inspire such bravery in battle?

", - "Mule": "

This ability is great if you want to wear heavy armor and pack a heavy weapon without attracting lots of attention. Since your exact gear is determined on-the-fly during an operation, having more load also gives you more options to get creative with when dealing with problems during a score.

", - "Not to Be Trifled With": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) in addition to the special ability.

If you perform a feat that verges on the superhuman, you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.

If you engage a small gang on equal footing, you don't suffer reduced effect due to scale against a small gang (up to six people).

", - "Savage": "

You instill fear in those around you when you get violent. How they react depends on the person. Some people will flee from you, some will be impressed, some will get violent in return. The GM judges the response of a given NPC.

In addition, when you Command someone who's affected by fear (from this ability or otherwise), take +1d to your roll.

", - "Vigorous": "

Your healing clock becomes a 3-clock, and you get a bonus die when you recover.

", - "Sharpshooter": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) in addition to the special ability.

The first use of this ability allows you to attempt long-range sniper shots that would otherwise be impossible with the rudimentary firearms of Duskwall.

The second use allows you to keep up a steady rate of fire in a battle (enough to 'suppress' a small gang up to six people), rather than stopping for a slow reload or discarding a gun after each shot. When an enemy is suppressed, they're reluctant to maneuver or attack (usually calling for a fortune roll to see if they can manage it).

", - "Focused": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", - "Ghost Hunter (Arrow-Swift)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to move extremely quickly, out-pacing any other creature or vehicle.

", - "Ghost Hunter (Ghost Form)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to transform into electroplasmic vapor as if it were a spirit.

", - "Ghost Hunter (Mind Link)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to share senses and thoughts telepathically with their master.

", - "Scout": "

A 'target' can be a person, a destination, a good ambush spot, an item, etc.

", - "Survivor": "

This ability gives you an additional stress box, so you have 10 instead of 9. The maximum number of stress boxes a PC can have (from any number of additional special abilities or upgrades) is 12.

", - "Tough As Nails": "

With this ability, level 3 harm doesn't incapacitate you; instead you take -1d to your rolls (as if it were level 2 harm). Level 2 harm affects you as if it were level 1 (less effect). Level 1 harm has no effect on you (but you still write it on your sheet, and must recover to heal it). Record the harm at its original level—for healing purposes, the original harm level applies.

", - "Alchemist": "

Follow the Inventing procedure with the GM (page 224) to define your first special alchemical formula.

", - "Artificer": "

Follow the Inventing procedure with the GM (page 224) to define your first spark-craft design.

", - "Fortitude": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", - "Ghost Ward": "

If you make an area anathema to spirits, they will do everything they can to avoid it, and will suffer torment if forced inside the area.

If you make an area enticing to spirits, they will seek it out and linger in the area, and will suffer torment if forced to leave.

This effect lasts for several days over an area the size of a small room.

Particularly powerful or prepared spirits may roll their quality or arcane magnitude to see how well they're able to resist the effect.

", - "Physicker": "

Knowledge of anatomy and healing is a rare and esoteric thing in Duskwall. Without this ability, any attempts at treatment are likely to fail or make things worse.

You can use this ability to give first aid (rolling Tinker) to allow your patient to ignore a harm penalty for an hour or two.

", - "Saboteur": "

You can drill holes in things, melt stuff with acid, even use a muffled explosive, and it will all be very quiet and extremely hard to notice.

", - "Venomous": "

You choose the type of drug or poison when you get this ability. Only a single drug or poison may be chosen—you can't become immune to any essences, oils, or other alchemical substances.

You may change the drug or poison by completing a long-term project.

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

", - "Infiltrator": "

This ability lets you contend with higher-Tier enemies on equal footing. When you're cracking a safe, picking a lock, or sneaking past elite guards, your effect level is never reduced due to superior Tier or quality level of your opposition.

Are you a renowned safe cracker? Do people tell stories of how you slipped under the noses of two Chief Inspectors, or are your exceptional talents yet to be discovered?

", - "Ambush": "

This ability benefits from preparation— so don't forget you can do that in a flashback.

", - "Daredevil": "

This special ability is a bit of a gamble. The bonus die helps you, but if you suffer consequences, they'll probably be more costly to resist. But hey, you're a daredevil, so no big deal, right?

", - "The Devil's Footsteps": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

If you perform an athletic feat (running, tumbling, balance, climbing, etc.) that verges on the superhuman, you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.

If you maneuver to confuse your enemies, they attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.

", - "Expertise": "

This special ability is good for covering for your team. If they're all terrible at your favored action, you don't have to worry about suffering a lot of stress when you lead their group action.

", - "Ghost Veil": "

This ability transforms you into an intangible shadow for a few moments. If you spend additional stress, you can extend the effect for additional benefits, which may improve your position or effect for action rolls, depending on the circumstances, as usual.

", - "Reflexes": "

This ability gives you the initiative in most situations. Some specially trained NPCs (and some demons and spirits) might also have reflexes, but otherwise, you're always the first to act, and can interrupt anyone else who tries to beat you to the punch.

This ability usually doesn't negate the need to make an action roll that you would otherwise have to make, but it may improve your position or effect.

", - "Shadow": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", - "Rook's Gambit": "

This is the 'jack-of-all-trades' ability. If you want to attempt lots of different sorts of actions and still have a good dice pool to roll, this is the special ability for you.

", - "Cloak & Dagger": "

This ability gives you the chance to more easily get out of trouble if a covert operation goes haywire. Also, don't forget your fine disguise kit gear, which boosts the effect of your covert deception methods.

", - "Ghost Voice": "

The first part of this ability gives you permission to do something that is normally impossible: when you speak to a spirit, it always listens and understands you, even if it would otherwise be too bestial or insane to do so.

The second part of the ability increases your effect when you use social actions with the supernatural.

", - "Like Looking Into a Mirror": "

This ability works in all situations without restriction. It is very powerful, but also a bit of a curse. You see though every lie, even the kind ones.

", - "A Little Something on the Side": "

Since this money comes at the end of downtime, after all downtime actions are resolved, you can't remove it from your stash and spend it on extra activities until your next downtime phase.

", - "Mesmerism": "

The victims' memory 'glosses over' the missing time, so it's not suspicious that they've forgotten something.

When you next interact with the victim, they remember everything clearly, including the strange effect of this ability.

", - "Subterfuge": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", - "Trust in Me": "

This ability isn't just for social interactions. Any action can get the bonus. 'Intimate' is for you and the group to define, it need not exclusively mean romantic intimacy.

", - "Foresight": "

You can narrate an event in the past that helps your teammate now, or you might explain how you expected this situation and planned a helpful contingency that you reveal now.

", - "Calculating": "

If you forget to use this ability during downtime, you can still activate it during the score and flashback to the previous downtime when the extra activity happened.

", - "Connected": "

Your array of underworld connections can be leveraged to loan assets, pressure a vendor to give you a better deal, intimidate witnesses, etc.

", - "Functioning Vice": "

If you indulged your vice and rolled a 4, you could increase the result to 5 or 6, or you could reduce the result to 3 or 2 (perhaps to avoid overindulgence).

Allies that join you don't need to have the same vice as you, just one that could be indulged alongside yours somehow.

", - "Ghost Contract": "

The mark of the oath is obvious to anyone who sees it (perhaps a magical rune appears on the skin).

When you suffer 'Cursed' harm, you're incapacitated by withering: enfeebled muscles, hair falling out, bleeding from the eyes and ears, etc., until you either fulfill the deal or discover a way to heal the curse.

", - "Jail Bird": "

Zero is the minimum wanted level; this ability can't make your wanted level negative.

", - "Mastermind": "

If you protect a teammate, this ability negates or reduces the severity of a consequence or harm that your teammate is facing. You don't have to be present to use this ability—say how you prepared for this situation in the past.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", - "Weaving the Web": "

Your network of underworld connections can always be leveraged to gain insight for a job—even when your contacts aren't aware that they're helping you.

", - "Compel": "

The GM will tell you if you sense any ghosts nearby. If you don't, you can gather information (maybe Attune, Survey, or Study) to attempt to locate one.

By default, a ghost wants to satisfy its need for life essence and to exact vengeance. When you compel it, you can give it a general or specific command, but the more general it is (like 'Protect me') the more the ghost will interpret it according to its own desires.

Your control over the ghost lasts until the command is fulfilled, or until a day has passed, whichever comes first.

", - "Iron Will": "

With this ability, you do not freeze up or flee when confronted by any kind of supernatural entity or strange occult event.

", - "Occultist": "

Consorting with a given entity may require special preparations or travel to a specific place. The GM will tell you about any requirements.

You get the bonus die to your Command rolls because you can demonstrate a secret knowledge of or influence over the entity when you interact with cultists.

", - "Ritual": "

Without this special ability, the study and practice of rituals leaves you utterly vulnerable to the powers you supplicate. Such endeavors are not recommended.

", - "Strange Methods": "

Follow the Inventing procedure with the GM (page 224) to define your first arcane design.

", - "Tempest": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

When you unleash lightning as a weapon, the GM will describe its effect level and significant collateral damage. If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

When you summon a storm, the GM will describe its effect level. If you're using this power as cover or distraction, it's probably a setup teamwork maneuver, using Attune.

", - "Warded": "

If you resist a consequence, this ability negates it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", - "Deadly": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", - "Crow's Veil": "

The bells don't ring at the crematorium when a member of your crew kills someone. Do you have a 'membership ritual' now that conveys this talent?

", - "Emberdeath": "

This ability activates at the moment of the target's death (spend 3 stress then or lose the opportunity to use it). It can only be triggered by a killing blow. Some particularly powerful supernatural entities or specially protected targets may be resistant or immune to this ability.

", - "No Traces": "

There are many clients who value quiet operations. This ability rewards you for keeping a low profile.

", - "Patron": "

Who is your patron? Why do they help you?

", - "Predators": "

This ability applies when the goal is murder. It doesn't apply to other stealth or deception operations you attempt that happen to involve killing.

", - "Vipers": "

The poison immunity lasts for the entire score, until you next have downtime.

", - "Dangerous": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", - "Blood Brothers": "

If you have the Elite Thugs upgrade, it stacks with this ability. So, if you had an Adepts gang cohort, and the Elite Thugs upgrade, and then took Blood Brothers, your Adepts would add the Thugs type and also get +1d to rolls when they did Thug-type actions.

This ability may result in a gang with three types, surpassing the normal limit of two.

", - "Door Kickers": "

This ability applies when the goal is to attack an enemy. It doesn't apply to other operations you attempt that happen to involve fighting.

", - "Fiends": "

The maximum wanted level is 4. Regardless of how much turf you hold (from this ability or otherwise) the minimum rep cost to advance your Tier is always 6.

", - "Forged In The Fire": "

This ability applies to PCs in the crew. It doesn't confer any special toughness to your cohorts.

", - "Chosen": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", - "Bound in Darkness": "

By what occult means does your teamwork manifest over distance? How is it strange or disturbing? By what ritualistic method are cult members initiated into this ability?

", - "Conviction": "

What sort of sacrifice does your deity find pleasing?

", - "Silver Tongues": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", - "Accord": "

If your status changes, you lose the turf until it becomes +3 again. Regardless of how much turf you hold (from this ability or otherwise) the minimum rep cost to advance your Tier is always 6.

", - "Ghost Market": "

They do not pay in coin. What do they pay with?

The GM will certainly have an idea about how your strange new clients pay, but jump in with your own ideas, too! This ability is usually a big shift in the game, so talk it out and come up with something that everyone is excited about. If it's a bit mysterious and uncertain, that's good. You have more to explore that way.

", - "The Good Stuff": "

The quality of your product might be used for a fortune roll to find out how impressed a potential client is, to find out how enthralled or incapacitated a user is in their indulgence of it, to discover if a strange variation has side-effects, etc.

", - "Everyone Steals": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", - "Ghost Echoes": "

You might explore the echo of an ancient building, crumbled to dust in the real world, but still present in the ghost field; or discern the electroplasmic glow of treasures lost in the depths of the canals; or use a sorcerous ghost door from the pre-cataclysm to infiltrate an otherwise secure location; etc.

The GM will tell you what echoes persist nearby when you gather information about them. You might also undertake investigations to discover particular echoes you hope to find.

", - "Pack Rats": "

This ability might mean that you actually have the item you need in your pile of stuff, or it could mean you have extra odds and ends to barter with.

", - "Slippery": "

The GM might sometimes want to choose an entanglement instead of rolling. In that case, they'll choose two and you can pick between them.

", - "Synchronized": "

For example, Lyric leads a group action to Attune to the ghost field to overcome a magical ward on the Dimmer Sisters' door. Emily, Lyric's player, rolls and gets a 6, and so does Matt! Because the crew has Synchronized, their two separate 6s count as a critical success on the roll.

", - "Ghost Passage": "

What do you do to 'carry' a spirit? Must the spirit consent, or can you use this ability to trap an unwilling spirit within?

", - "Reavers": "

If your vehicle already has armor, this ability gives an additional armor box.

", - "Renegades": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

" -}; -const RollCollabEffectChanges: Partial>>> = { - [BladesItemType.ability]: { - "Battleborn": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Battleborn@cat:after@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Battleborn

You may expend your special armor instead of paying 2 stress to Push yourself during a fight.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Battleborn@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-HarmLevel@status:Hidden@tooltip:

Battleborn

You may expend your special armor to reduce the level of harm you are resisting by one.

" - } - ], - "Bodyguard": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Bodyguard@cat:roll@type:ability@cTypes:Resistance@status:Hidden@tooltip:

Bodyguard

When you protect a teammate, take +1d to your resistance roll.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Bodyguard@cat:effect@type:ability@cTypes:Engagement@status:Hidden@tooltip:

Bodyguard

When you gather information to anticipate possible threats in the current situation, you get +1 effect.

" - } - ], - "Ghost Fighter": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Ghost Fighter@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Fighter

You may imbue your hands, melee weapons, or tools with spirit energy, giving you Potency in combat vs. the supernatural.

You may also grapple with spirits to restrain and capture them.

" - } - ], - "Leader": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isCohort: true, - value: "name:Leader@cat:effect@type:ability@cTypes:Action@cTraits:command@status:Hidden@tooltip:

Leader

When a Leader Commands this cohort in combat, it gains +1 effect.

" - } - ], - "Not to Be Trifled With": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Superhuman Feat@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|command@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Superhuman Feat@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|command@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Engage Gang@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:Is-Push|ForceOn-Push|Negate-ScalePenalty@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Engage Gang

You can Push yourself to engage a gang of up to six members on equal footing (negating any Scale penalties).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Engage Gang@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:Is-Push|ForceOn-Push|Negate-ScalePenalty@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Engage Gang

You can Push yourself to engage a gang of up to six members on equal footing (negating any Scale penalties).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - } - ], - "Savage": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Savage@cat:roll@type:ability@cTypes:Action@cTraits:command@status:Hidden@tooltip:

Savage

When you Command a fightened target, gain +1d to your roll.

" - } - ], - "Vigorous": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Vigorous@cat:roll@type:ability@cTypes:Downtime@aTypes:Incarceration@status:Hidden@tooltip:

Vigorous

You gain +1d to healing treatment rolls.

" - } - ], - "Sharpshooter": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Extreme Range@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Extreme Range

You can Push yourself to make a ranged attack at extreme distance, one that would otherwise be impossible with the rudimentary firearms of Duskwall.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Extreme Range@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Extreme Range

You can Push yourself to make a ranged attack at extreme distance, one that would otherwise be impossible with the rudimentary firearms of Duskwall.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Suppression Fire@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Suppression Fire

You can Push yourself to maintain a steady rate of suppression fire during a battle, enough to suppress a small gang of up to six members. (When an enemy is suppressed, they're reluctant to maneuver or attack, usually calling for a fortune roll to see if they can manage it.)

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Suppression Fire@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Suppression Fire

You can Push yourself to maintain a steady rate of suppression fire during a battle, enough to suppress a small gang of up to six members. When an enemy is suppressed, they're reluctant to maneuver or attack, usually calling for a fortune roll to see if they can manage it.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - } - ], - "Focused": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Focused@cat:roll@type:ability@cTypes:Resistance@cTraits:Insight|Resolve@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Focused

You may expend your special armor to completely negate a consequence of surprise or mental harm.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Focused@cat:after@type:ability@cTypes:Action@cTraits:hunt|study|survey|finesse|prowl|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Focused

You may expend your special armor instead of paying 2 stress to Push yourself for ranged combat or tracking.

" - } - ], - "Ghost Hunter (Arrow-Swift)": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isCohort: true, - value: "name:Ghost Hunter (Arrow-Swift)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Arrow-Swift)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" - } - ], - "Ghost Hunter (Ghost Form)": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isCohort: true, - value: "name:Ghost Hunter (Ghost Form)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Ghost Form)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" - } - ], - "Ghost Hunter (Mind Link)": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isCohort: true, - value: "name:Ghost Hunter (Mind Link)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Mind Link)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" - } - ], - "Scout": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Scout@cat:effect@type:ability@cTypes:Action|Downtime|Clock@cTraits:hunt|study|survey|attune|consort|sway@status:Hidden@tooltip:

Scout

When you gather information to discover the location of a target (a person, a destination, a good ambush spot, etc), you gain +1 effect.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Scout@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl@status:Hidden@tooltip:

Scout

When you hide in a prepared position or use camouflage, you get +1d to rolls to avoid detection.

" - } - ], - "Alchemist": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Alchemist@cat:result@type:ability@cTypes:Downtime|GatherInfo|Craft@status:Hidden@tooltip:

Alchemist

When you invent or craft a creation with alchemical features, you gain +1 result level to your roll.

" - } - ], - "Artificer": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Artificer@cat:result@type:ability@cTypes:Downtime|GatherInfo|Craft@cTraits:study|tinker@status:Hidden@tooltip:

Artificer

When you invent or craft a creation with spark-craft features, you gain +1 result level to your roll.

" - } - ], - "Fortitude": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Fortitude@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Fortitude

You may expend your special armor to completely negate a consequence of fatigue, weakness, or chemical effects.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Fortitude@cat:after@type:ability@cTypes:Action@cTraits:study|survey|tinker|finesse|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Fortitude

You may expend your special armor instead of paying 2 stress to Push yourself when working with technical skill or handling alchemicals.

" - } - ], - "Ghost Ward": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Ghost Ward@cat:after@type:ability@cTypes:Action@cTraits:wreck@val:0@status:Hidden@tooltip:

Ghost Ward

When you Wreck an area with arcane substances, ruining it for any other use, it becomes anathema or enticing to spirits (your choice).

" - } - ], - "Physicker": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Physicker@cat:roll@type:ability@cTypes:Downtime@aTypes:Incarceration@status:Hidden@tooltip:

Physicker

You gain +1d to your healing treatment rolls.

" - } - ], - "Saboteur": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Saboteur@cat:after@type:ability@cTypes:Action|Downtime|Clock@aTraits:wreck@val:0@status:Hidden@tooltip:

Saboteur

When you Wreck, your work is much quieter than it should be and the damage is very well-hidden from casual inspection.

" - } - ], - "Venomous": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Venomous@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@status:Hidden@tooltip:

Venomous

You can Push yourself to secrete your chosen drug or poison through your skin or saliva, or exhale it as a vapor.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Venomous@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@status:Hidden@tooltip:

Venomous

You can Push yourself to secrete your chosen drug or poison through your skin or saliva, or exhale it as a vapor.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - } - ], - "Infiltrator": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Infiltrator@cat:effect@type:ability@cTypes:Action|Downtime|Clock@cTraits:tinker|finesse|wreck|attune@val:0@eKey:Negate-QualityPenalty|Negate-TierPenalty@status:Hidden@tooltip:

Infiltrator

You are not affected by low Quality or Tier when you bypass security measures.

" - } - ], - "Ambush": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Ambush@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune@status:Hidden@tooltip:

Ambush

When you attack from hiding or spring a trap, you get +1d to your roll.

" - } - ], - "Daredevil": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Daredevil@cat:roll@type:ability@eKey:AutoRevealOn-Desperate|ForceOn-(Daredevil),after@status:ToggledOff@tooltip:

Daredevil

When you make a desperate action roll, you may gain +1d to your roll, if you also take −1d to resistance rolls against any consequences.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Daredevil@cat:roll@posNeg:negative@type:ability@cTypes:Resistance@status:Hidden@tooltip:

Daredevil

By choosing to gain +1d to your desperate action roll, you suffer −1d to resistance rolls against the consequences of that action.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:(Daredevil)@cat:after@posNeg:negative@type:ability@val:0@sourceName:Daredevil@status:Hidden@tooltip:

Daredevil

You will suffer −1d to resistance rolls against any consequences of this action roll.

" - } - ], - "The Devil's Footsteps": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Superhuman Feat@cat:roll@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Superhuman Feat@cat:effect@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Sow Confusion@cat:roll@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Sow Confusion

You can Push yourself to maneuver to confuse your enemies so they mistakenly attack each other. (They attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Sow Confusion@cat:effect@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Sow Confusion

You can Push yourself to maneuver to confuse your enemies so they mistakenly attack each other. (They attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - } - ], - "Shadow": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Shadow@cat:after@type:ability@cTypes:Action@cTraits:hunt|study|survey|tinker|finesse|prowl|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Shadow

You may expend your special armor instead of paying 2 stress to Push yourself for a feat of athletics or stealth.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Shadow@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-HarmLevel@status:Hidden@tooltip:

Shadow

You may expend your special armor to completely negate a consequence of detection or security measures.

" - } - ], - "Rook's Gambit": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Rook's Gambit@cat:roll@type:ability@cTypes:Action|Downtime|GatherInfo|Craft|Acquire|Clock@val:0@eKey:ForceOn-BestAction|Cost-Stress2@status:Hidden@tooltip:

Rook's Gambit

Take 2 stress to roll your best action rating while performing a different action.

(Describe how you adapt your skill to this use.)

" - } - ], - "Cloak & Dagger": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Cloak & Dagger@cat:roll@type:ability@cTypes:Action|Resistance@cTraits:finesse|prowl|attune|command|consort|sway|Insight@status:Hidden@tooltip:

Cloak & Dagger

When you use a disguise or other form of covert misdirection, you get +1d to rolls to confuse or deflect suspicion.

" - } - ], - "Ghost Voice": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Ghost Voice@cat:effect@type:ability@cTypes:Action|Downtime|Clock@cTraits:attune|command|consort|sway@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Voice

You gain Potency when communicating with the supernatural.

" - } - ], - "Mesmerism": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Mesmerism@cat:after@type:ability@cTypes:Action@cTraits:sway@val:0@status:Hidden@tooltip:

Mesmerism

When you Sway someone, you may cause them to forget that it's happened until they next interact with you.

" - } - ], - "Subterfuge": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Subterfuge@cat:roll@type:ability@cTypes:Resistance@cTraits:Insight@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Subterfuge

You may expend your special armor to completely negate a consequence of suspicion or persuasion.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Subterfuge@cat:after@type:ability@cTypes:Action@cTraits:finesse|attune|consort|sway@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Subterfuge

You may expend your special armor instead of paying 2 stress to Push yourself for subterfuge.

" - } - ], - "Trust in Me": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Trust in Me@cat:roll@type:ability@cTypes:Action|Downtime|Clock@cTraits:hunt|study|survey|tinker|finesse|prowl|skirmish|wreck|attune|command|consort|sway|Insight|Prowess|Resolve|tier|quality|magnitude|number@status:Hidden@tooltip:

Trust in Me

You gain +1d to rolls opposed by a target with whom you have an intimate relationship.

" - } - ], - "Connected": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Connected@cat:result@type:ability@cTypes:Downtime@aTypes:Heat|Acquire@status:Hidden@tooltip:

Connected

When you acquire an asset or reduce heat, you get +1 result level.

" - } - ], - "Jail Bird": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Jail Bird@cat:effect@type:ability@cTypes:Downtime@eKey:Increase-Tier1@status:Hidden@tooltip:

Jail Bird

You gain +1 Tier while incarcerated.

" - } - ], - "Mastermind": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Mastermind@cat:after@type:ability@cTypes:Action|Downtime|GatherInfo|Craft|Clock@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Mastermind

You may expend your special armor instead of paying 2 stress to Push yourself when you gather information or work on a long-term project.

" - } - ], - "Weaving the Web": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Weaving the Web@cat:roll@type:ability@cTypes:Action|Downtime|Clock@cTraits:consort@status:Hidden@tooltip:

Weaving the Web

You gain +1d to Consort when you gather information on a target for a score.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Weaving the Web@cat:roll@type:ability@cTypes:Healing@status:Hidden@tooltip:

Weaving the Web

You gain +1d to the engagement roll for the targeted score.

" - } - ], - "Ghost Mind": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Ghost Mind@cat:roll@type:ability@cTypes:Action|Downtime|Clock@cTraits:hunt|study|survey|tinker|prowl|attune|command|consort|sway@status:Hidden@tooltip:

Ghost Mind

You gain +1d to rolls to gather information about the supernatural by any means.

" - } - ], - "Iron Will": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Iron Will@cat:roll@type:ability@cTypes:Resistance@aTraits:Resolve@status:Hidden@tooltip:

Iron Will

You gain +1d to Resolve resistance rolls.

" - } - ], - "Occultist": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Occultist@cat:roll@type:ability@cTypes:Action|Downtime|Clock@cTraits:command@status:Hidden@tooltip:

Occultist

You gain +1d to rolls to Command cultists following ancient powers, forgotten gods or demons with whom you have previously Consorted

" - } - ], - "Strange Methods": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Strange Methods@cat:result@type:ability@cTypes:Downtime|GatherInfo|Craft@status:Hidden@tooltip:

Strange Methods

When you invent or craft a creation with arcane features, you gain +1 result level to your roll.

" - } - ], - "Tempest": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Throw Lightning@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Throw Lightning

You can Push yourself to unleash a stroke of lightning as a weapon. The GM will describe its effect level and significant collateral damage.

If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Throw Lightning@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Throw Lightning

You can Push yourself to unleash a stroke of lightning as a weapon. The GM will describe its effect level and significant collateral damage.

If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Summon Storm@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Summon Storm

You can Push yourself to summon a storm in your immediate vicinity (torrential rain, roaring winds, heavy fog, chilling frost and snow, etc.). The GM will describe its effect level.

If you're using this power as cover or distraction, it's probably a Setup teamwork maneuver, using Attune.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Summon Storm@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Summon Storm

You can Push yourself to summon a storm in your immediate vicinity (torrential rain, roaring winds, heavy fog, chilling frost and snow, etc.). The GM will describe its effect level.

If you're using this power as cover or distraction, it's probably a Setup teamwork maneuver, using Attune.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" - } - ], - "Warded": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Warded@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Warded

You may expend your special armor to completely negate a consequence of supernatural origin.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Warded@cat:after@type:ability@cTypes:Action@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Warded

You may expend your special armor instead of paying 2 stress to Push yourself when you contend with or employ arcane forces.

" - } - ] - }, - [BladesItemType.crew_ability]: { - "Predators": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Predators@cat:roll@type:crew_ability@cTypes:Healing@val@status:Hidden@tooltip:

Predators

When you use a stealth or deception plan to commit murder, take +1d to the engagement roll.

" - } - ], - "Vipers": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Vipers (Crew Ability)@cat:result@type:crew_ability@cTypes:GatherInfo|Craft|Acquire@val@sourceName:Vipers@status:Hidden@tooltip:

Vipers (Crew Ability)

When you acquire or craft poisons, you get +1 result level to your roll.

" - } - ], - "Blood Brothers": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isCohort: true, - value: "name:Blood Brothers (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val@sourceName:Blood Brothers@status:Hidden@tooltip:

Blood Brothers (Crew Ability)

When fighting alongside crew members in combat, gain +1d for assist, setup and group teamwork actions.

" - } - ], - "Door Kickers": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Door Kickers@cat:roll@type:crew_ability@cTypes:Healing@val@status:Hidden@tooltip:

Door Kickers

When you use an assault plan, take +1d to the engagement roll.

" - } - ], - "Anointed": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Anointed (Crew Ability)@cat:roll@type:crew_ability@cTypes:Resistance@val@sourceName:Anointed@status:Hidden@tooltip:

Anointed (Crew Ability)

Gain +1d to resistance rolls against supernatural threats.

" - }, - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Anointed (Crew Ability) (Crew Ability)@cat:roll@type:crew_ability@cTypes:Incarceration@val@sourceName:Anointed@status:Hidden@tooltip:

Anointed (Crew Ability) (Crew Ability)

Gain +1d to healing treatment rolls when you have supernatural harm.

" - } - ], - "Conviction": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Conviction (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action@val@sourceName:Conviction@status:Hidden@tooltip:

Conviction (Crew Ability)

You may call upon your deity to assist any one action roll you make.

You cannot use this ability again until you indulge your Worship vice.

" - } - ], - "Zealotry": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isCohort: true, - value: "name:Zealotry (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action|Downtime@val@sourceName:Zealotry@status:Hidden@tooltip:

Zealotry (Crew Ability)

Gain +1d when acting against enemies of the faith.

" - } - ], - "The Good Stuff": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:The Good Stuff (Crew Ability)@cat:effect@type:crew_ability@cTypes:Action|Downtime@val:0@eKey:Increase-Quality2@sourceName:The Good Stuff@status:Hidden@tooltip:

The Good Stuff (Crew Ability)

The quality of your product is equal to your Tier +2.

" - } - ], - "High Society": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:High Society (Crew Ability)@cat:roll@type:crew_ability@cTypes:Engagement@val@sourceName:High Society@status:Hidden@tooltip:

High Society (Crew Ability)

Gain +1d to gather information about the city's elite.

" - } - ], - "Pack Rats": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Pack Rats (Crew Ability)@cat:roll@type:crew_ability@aTypes:Acquire@val@sourceName:Pack Rats@status:Hidden@tooltip:

Pack Rats (Crew Ability)

Gain +1d to acquire an asset.

" - } - ], - "Second Story": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - value: "name:Second Story@cat:roll@type:crew_ability@cTypes:Healing@val@status:Hidden@tooltip:

Second Story

When you execute a clandestine infiltration plan, gain +1d to the engagement roll.

" - } - ], - "Slippery": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Slippery (Crew Ability)@cat:roll@type:crew_ability@aTypes:Heat@val@sourceName:Slippery@status:Hidden@tooltip:

Slippery (Crew Ability)

Gain +1d to reduce heat rolls.

" - } - ], - "Synchronized": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - isCohort: true, - value: "name:Synchronized (Crew Ability)@cat:after@type:crew_ability@cTypes:Action@val@sourceName:Synchronized@status:Hidden@tooltip:

Synchronized (Crew Ability)

When you perform a group teamwork action, you may count multiple 6s from different rolls as a critical success.

" - } - ], - "Just Passing Through": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Just Passing Through (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action|Downtime@cTraits:finesse|prowl|consort|sway@val@sourceName:Just Passing Through@status:Hidden@tooltip:

Just Passing Through (Crew Ability)

When your heat is 4 or less, gain +1d to rolls to deceive people when you pass yourself off as ordinary citizens.

" - } - ], - "Reavers": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Reavers (Crew Ability)@cat:effect@type:crew_ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val@sourceName:Reavers@status:Hidden@tooltip:

Reavers (Crew Ability)

When you go into conflict aboard a vehicle, gain +1 effect for vehicle damage and speed.

" - } - ] - } -}; - -RollCollabEffectChanges[BladesItemType.crew_upgrade] = { - "Ironhook Contacts": [ - { - key: "system.roll_mods", - mode: 2, - priority: null, - isMember: true, - value: "name:Ironhook Contacts@cat:roll@type:ability@cTypes:Downtime@eKey:Increase-Tier1@status:Conditional@tooltip:

Ironhook Contacts

Gain G>+1 Tier< while in prison, including the >incarceration< roll.

" - } - ] -}; - -export const ApplyRollEffects = async () => { - Object.entries(RollCollabEffectChanges[BladesItemType.ability] ?? {}) - .forEach(async ([aName, eData]) => { - // Get ability doc - const abilityDoc = game.items.getName(aName); - if (!abilityDoc) { - eLog.error("applyRollEffects", `ApplyRollEffects: Ability ${aName} Not Found.`); - return; - } - - // Get active effects on abilityDoc - const abilityEffects = Array.from(abilityDoc.effects ?? []) as BladesActiveEffect[]; - - // Separate out 'APPLYTOMEMBERS' and 'APPLYTOCOHORTS' ActiveEffects - const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); - const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); - const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); - - // Confirm eData.isMember and eData.isCohort are consistent across all changes. - const testChange = eData[0]; - if ( - (testChange.isMember && eData.some((change) => !change.isMember)) - || (!testChange.isMember && eData.some((change) => change.isMember)) - ) { eLog.error("applyRollEffects", `ApplyRollEffects: Ability ${aName} has inconsistent 'isMember' entries.`); return } - if ( - (testChange.isCohort && eData.some((change) => !change.isCohort)) - || (!testChange.isCohort && eData.some((change) => change.isCohort)) - ) { eLog.error("applyRollEffects", `ApplyRollEffects: Ability ${aName} has inconsistent 'isCohort' entries.`); return } - - // If eData.isMember or eData.isCohort, first see if there already is such an effect on the doc - if (testChange.isMember) { - if (toMemberEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Ability ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: abilityDoc.img ?? "", - changes: eData.map((change) => { - delete change.isMember; - return change; - }) - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (toMemberEffects.length === 1) { - const abilityEffect = toMemberEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } else { - effectData.changes.unshift({ - key: "APPLYTOMEMBERS", - mode: 0, - priority: null, - value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Scoundrel Ability)` - }); - } - - // Create new ActiveEffect - await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } else if (testChange.isCohort) { - if (toCohortEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Ability ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: abilityDoc.img ?? "", - changes: eData.map((change) => { - delete change.isCohort; - return change; - }) - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (toCohortEffects.length === 1) { - const abilityEffect = toCohortEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } else { - effectData.changes.unshift({ - key: "APPLYTOCOHORTS", - mode: 0, - priority: null, - value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Scoundrel Ability)` - }); - } - - // Create new ActiveEffect - await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } else { - if (standardEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Ability ${aName} Has Multiple Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: abilityDoc.img ?? "", - changes: eData - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (standardEffects.length === 1) { - const abilityEffect = standardEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } - - // Create new ActiveEffect - await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } - }); - Object.entries(RollCollabEffectChanges[BladesItemType.crew_ability] ?? {}) - .forEach(async ([aName, eData]) => { - // Get crew ability doc - const crewAbilityDoc = game.items.getName(aName); - if (!crewAbilityDoc) { - eLog.error("applyRollEffects", `ApplyRollEffects: Crew Ability ${aName} Not Found.`); - return; - } - - // Get active effects on crewAbilityDoc - const abilityEffects = Array.from(crewAbilityDoc.effects ?? []) as BladesActiveEffect[]; - - // Separate out 'APPLYTOMEMBERS' and 'APPLYTOCOHORTS' ActiveEffects - const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); - const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); - const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); - - // Confirm eData.isMember and eData.isCohort are consistent across all changes. - const testChange = eData[0]; - if ( - (testChange.isMember && eData.some((change) => !change.isMember)) - || (!testChange.isMember && eData.some((change) => change.isMember)) - ) { eLog.error("applyRollEffects", `ApplyRollEffects: Crew Ability ${aName} has inconsistent 'isMember' entries.`); return } - if ( - (testChange.isCohort && eData.some((change) => !change.isCohort)) - || (!testChange.isCohort && eData.some((change) => change.isCohort)) - ) { eLog.error("applyRollEffects", `ApplyRollEffects: Crew Ability ${aName} has inconsistent 'isCohort' entries.`); return } - - // If eData.isMember or eData.isCohort, first see if there already is such an effect on the doc - if (testChange.isMember) { - if (toMemberEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Crew Ability ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: crewAbilityDoc.img ?? "", - changes: eData.map((change) => { - delete change.isMember; - return change; - }) - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (toMemberEffects.length === 1) { - const abilityEffect = toMemberEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } else { - effectData.changes.unshift({ - key: "APPLYTOMEMBERS", - mode: 0, - priority: null, - value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Ability)` - }); - } - - // Create new ActiveEffect - await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } else if (testChange.isCohort) { - if (toCohortEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Crew Ability ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: crewAbilityDoc.img ?? "", - changes: eData.map((change) => { - delete change.isCohort; - return change; - }) - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (toCohortEffects.length === 1) { - const abilityEffect = toCohortEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } else { - effectData.changes.unshift({ - key: "APPLYTOCOHORTS", - mode: 0, - priority: null, - value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Ability)` - }); - } - - // Create new ActiveEffect - await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } else { - if (standardEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Crew Ability ${aName} Has Multiple Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: crewAbilityDoc.img ?? "", - changes: eData - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (standardEffects.length === 1) { - const abilityEffect = standardEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } - - // Create new ActiveEffect - await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } - }); - Object.entries(RollCollabEffectChanges[BladesItemType.crew_upgrade] ?? {}) - .forEach(async ([aName, eData]) => { - // Get crew upgrade doc - const crewUpgradeDoc = game.items.getName(aName); - if (!crewUpgradeDoc) { - eLog.error("applyRollEffects", `ApplyRollEffects: Crew Upgrade ${aName} Not Found.`); - return; - } - - // Get active effects on crewAbilityDoc - const abilityEffects = Array.from(crewUpgradeDoc.effects ?? []) as BladesActiveEffect[]; - - // Separate out 'APPLYTOMEMBERS' and 'APPLYTOCOHORTS' ActiveEffects - const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); - const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); - const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); - - // Confirm eData.isMember and eData.isCohort are consistent across all changes. - const testChange = eData[0]; - if ( - (testChange.isMember && eData.some((change) => !change.isMember)) - || (!testChange.isMember && eData.some((change) => change.isMember)) - ) { eLog.error("applyRollEffects", `ApplyRollEffects: Crew Upgrade ${aName} has inconsistent 'isMember' entries.`); return } - if ( - (testChange.isCohort && eData.some((change) => !change.isCohort)) - || (!testChange.isCohort && eData.some((change) => change.isCohort)) - ) { eLog.error("applyRollEffects", `ApplyRollEffects: Crew Upgrade ${aName} has inconsistent 'isCohort' entries.`); return } - - // If eData.isMember or eData.isCohort, first see if there already is such an effect on the doc - if (testChange.isMember) { - if (toMemberEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Crew Upgrade ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: crewUpgradeDoc.img ?? "", - changes: eData.map((change) => { - delete change.isMember; - return change; - }) - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (toMemberEffects.length === 1) { - const abilityEffect = toMemberEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } else { - effectData.changes.unshift({ - key: "APPLYTOMEMBERS", - mode: 0, - priority: null, - value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Upgrade)` - }); - } - - // Create new ActiveEffect - await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } else if (testChange.isCohort) { - if (toCohortEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Crew Upgrade ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: crewUpgradeDoc.img ?? "", - changes: eData.map((change) => { - delete change.isCohort; - return change; - }) - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (toCohortEffects.length === 1) { - const abilityEffect = toCohortEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } else { - effectData.changes.unshift({ - key: "APPLYTOCOHORTS", - mode: 0, - priority: null, - value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Upgrade)` - }); - } - - // Create new ActiveEffect - await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } else { - if (standardEffects.length > 1) { - eLog.error("applyRollEffects", `ApplyRollEffects: Crew Upgrade ${aName} Has Multiple Active Effects`); - return; - } - - // Initialize new effect data - const effectData: { - name: string, - icon: string, - changes: Array> - } = { - name: aName, - icon: crewUpgradeDoc.img ?? "", - changes: eData - }; - - // Derive new effect data from existing effect, if any, then delete existing effect - if (standardEffects.length === 1) { - const abilityEffect = standardEffects[0] as BladesActiveEffect; - effectData.name = abilityEffect.name ?? effectData.name; - effectData.icon = abilityEffect.icon ?? effectData.icon; - effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); - await abilityEffect.delete(); - } - - // Create new ActiveEffect - await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); - } - }); -}; - -export const ApplyDescriptions = async () => { - Object.entries(DescriptionChanges) - .forEach(async ([aName, desc]) => { - // Get crew upgrade doc - const itemDoc = game.items.getName(aName); - if (!itemDoc) { - eLog.error("applyRollEffects", `ApplyDescriptions: Item Doc ${aName} Not Found.`); - return; - } - - // Update system.notes - itemDoc.update({"system.notes": desc}); - }); -}; - // #endregion // #region Types & Type Checking ~ -type EffectData = { - key: "system.roll_mods", - mode: 2, - priority: null, - value: string -}; - -function compareRollModStatus(mod: BladesRollMod, lastStatusData: string) { - const lastStatus = JSON.parse(lastStatusData) as Record<"status"|"user_status"|"held_status"|"base_status",RollModStatus|undefined>; - const statusChangeData: Partial> = {}; - if (lastStatus.status !== mod.status) { statusChangeData.status = `${lastStatus.status} -> ${mod.status}` } - if (lastStatus.base_status !== mod.baseStatus) { statusChangeData.base_status = `${lastStatus.base_status} -> ${mod.baseStatus}` } - if (lastStatus.held_status !== mod.heldStatus) { statusChangeData.held_status = `${lastStatus.held_status} -> ${mod.heldStatus}` } - if (lastStatus.user_status !== mod.userStatus) { statusChangeData.user_status = `${lastStatus.user_status} -> ${mod.userStatus}` } - return statusChangeData; -} - -function isAction(trait: unknown): trait is BladesRollCollab.RollTrait & Action { - return Boolean(trait && typeof trait === "string" && U.lCase(trait) in Action); +function isAction(trait: unknown): trait is BladesRollCollab.RollTrait & ActionTrait { + return Boolean(trait && typeof trait === "string" && U.lCase(trait) in ActionTrait); } -function isAttribute(trait: unknown): trait is BladesRollCollab.RollTrait & Attribute { - return Boolean(trait && typeof trait === "string" && U.lCase(trait) in Attribute); +function isAttribute(trait: unknown): trait is BladesRollCollab.RollTrait & AttributeTrait { + return Boolean(trait && typeof trait === "string" && U.lCase(trait) in AttributeTrait); } function isFactor(trait: unknown): trait is BladesRollCollab.RollTrait & Factor { return Boolean(trait && typeof trait === "string" && U.lCase(trait) in Factor); } -function isNumber(trait: string | number): trait is BladesRollCollab.RollTrait & number { return U.isInt(trait) } function isModStatus(str: unknown): str is RollModStatus { return typeof str === "string" && str in RollModStatus; } @@ -1199,7 +35,7 @@ export class BladesRollMod { .map((modString) => { const pStrings = modString.split(/@/); const nameString = U.pullElement(pStrings, (v) => typeof v === "string" && /^na/i.test(v)); - const nameVal = (typeof nameString === "string" && nameString.replace(/^.*:/, "")) as string|false; + const nameVal = (typeof nameString === "string" && nameString.replace(/^.*:/, "")); if (!nameVal) { throw new Error(`RollMod Missing Name: '${modString}'`) } const catString = U.pullElement(pStrings, (v) => typeof v === "string" && /^cat/i.test(v)); const catVal = (typeof catString === "string" && catString.replace(/^.*:/, "")) as RollModCategory|false; @@ -1283,7 +119,7 @@ export class BladesRollMod { get userStatus(): RollModStatus | undefined { return this.rollInstance.document.getFlag(...this.flagParams) as - ValueOf | undefined; + ValOf | undefined; } set userStatus(val: RollModStatus | undefined) { if (val === this.userStatus) { return } @@ -1513,7 +349,7 @@ export class BladesRollMod { throw new Error(`Unrecognized Negate parameter: ${thisParam}`); } } else if (thisKey === "Increase") { - const [_, traitStr] = thisParam.match(/(\w+)\d+/) ?? []; + const [_, traitStr] = /(\w+)\d+/.exec(thisParam) ?? []; return this.rollInstance.isTraitRelevant(traitStr as BladesRollCollab.RollTrait); } else { throw new Error(`Unrecognized Function Key: ${thisKey}`); @@ -2003,8 +839,7 @@ class BladesRollCollab extends DocumentSheet { return { rollID: randomID(), rollType: RollType.Action, - rollPrimaryType: BladesActorType.pc, - rollPrimaryID: "", + rollTrait: Factor.tier, rollModsData: {}, rollPositionInitial: Position.risky, @@ -2119,11 +954,18 @@ class BladesRollCollab extends DocumentSheet { BladesRollCollab._Active = val; } + // static async PrepareRollCollab(rollInstance: BladesRollCollab) { + // const user = game.users.get(rollInstance.userID); + // if (!user) { throw new Error("No user associated with provided roll instance!") } + // BladesRollCollab.InitializeUserFlags(rollInstance, user); + + // } + static async RenderRollCollab({userID, rollID}: { userID: string, rollID: string }) { const user = game.users.get(userID); // as User & {flags: {["eunos-blades"]: {rollCollab: BladesRollCollab.FlagData}}}; if (!user) { return } - BladesRollCollab.Current[rollID] = new BladesRollCollab(user, rollID); - BladesRollCollab.Current[rollID].render(true); + // BladesRollCollab.Current[rollID] = new BladesRollCollab(user, rollID); + await BladesRollCollab.Current[rollID]._render(true); } static async CloseRollCollab(rollID: string) { @@ -2154,13 +996,15 @@ class BladesRollCollab extends DocumentSheet { eLog.error("rollCollab", `[RenderRollCollab()] Invalid rollType: ${flagUpdateData.rollType}`, config); return; } - const rollPrimaryData: BladesRollCollab.PrimaryDocData|undefined = config.rollPrimary ?? (user.character as BladesPC|undefined); + + + const rollPrimaryData: Partial|undefined = config.rollPrimary ?? (user.character as BladesPC|undefined); if (!rollPrimaryData) { eLog.error("rollCollab", "[RenderRollCollab()] Invalid rollPrimary", {rollPrimaryData, config}); return; } - flagUpdateData.rollPrimaryID = rollPrimaryData.rollPrimaryID; - flagUpdateData.rollPrimaryType = rollPrimaryData.rollPrimaryType; + // flagUpdateData.rollPrimaryID = rollPrimaryData.rollPrimaryID; + // flagUpdateData.rollPrimaryType = rollPrimaryData.rollPrimaryType; if (U.isInt(config.rollTrait)) { flagUpdateData.rollTrait = config.rollTrait; } else if (!config.rollTrait) { @@ -2169,31 +1013,31 @@ class BladesRollCollab extends DocumentSheet { } else { switch (flagUpdateData.rollType) { case RollType.Action: { - if (!(U.lCase(config.rollTrait) in {...Action, ...Factor})) { + if (!(U.lCase(config.rollTrait) in {...ActionTrait, ...Factor})) { eLog.error("rollCollab", `[RenderRollCollab()] Bad RollTrait for Action Roll: ${config.rollTrait}`, config); return; } - flagUpdateData.rollTrait = U.lCase(config.rollTrait) as Action | Factor; + flagUpdateData.rollTrait = U.lCase(config.rollTrait) as ActionTrait | Factor; break; } case RollType.Downtime: { - if (!(U.lCase(config.rollTrait) in {...Action, ...Factor})) { + if (!(U.lCase(config.rollTrait) in {...ActionTrait, ...Factor})) { eLog.error("rollCollab", `[RenderRollCollab()] Bad RollTrait for Downtime Roll: ${config.rollTrait}`, config); return; } - flagUpdateData.rollTrait = U.lCase(config.rollTrait) as Action | Factor; + flagUpdateData.rollTrait = U.lCase(config.rollTrait) as ActionTrait | Factor; break; } case RollType.Fortune: { - if (!(U.lCase(config.rollTrait) in {...Action, ...Attribute, ...Factor})) { + if (!(U.lCase(config.rollTrait) in {...ActionTrait, ...AttributeTrait, ...Factor})) { eLog.error("rollCollab", `[RenderRollCollab()] Bad RollTrait for Fortune Roll: ${config.rollTrait}`, config); return; } - flagUpdateData.rollTrait = U.lCase(config.rollTrait) as Action | Attribute | Factor; + flagUpdateData.rollTrait = U.lCase(config.rollTrait) as ActionTrait | AttributeTrait | Factor; break; } case RollType.Resistance: { - if (!(U.lCase(config.rollTrait) in Attribute)) { + if (!(U.lCase(config.rollTrait) in AttributeTrait)) { eLog.error("rollCollab", `[RenderRollCollab()] Bad RollTrait for Resistance Roll: ${config.rollTrait}`, config); return; } @@ -2209,10 +1053,25 @@ class BladesRollCollab extends DocumentSheet { socketlib.system.executeForAllGMs("renderRollCollab", {userID: user._id, rollID: flagUpdateData.rollID}); } + userID: string; rollID: string; - constructor(user: User, rollID: string) { + constructor( + user: User, + primaryData?: BladesRollCollab.PrimaryDoc|Partial, + oppData?: BladesRollCollab.OppositionDoc|Partial + ) { + if (!user || !user.id) { + throw new Error(`Unable to retrieve user id from user '${user}'`); + } super(user); - this.rollID = rollID; + this.userID = user.id; + this.rollID = randomID(); + if (primaryData) { + this.rollPrimary = new BladesRollPrimary(this, primaryData); + } + if (oppData) { + this.rollOpposition = new BladesRollOpposition(this, oppData); + } } // #endregion @@ -2226,43 +1085,38 @@ class BladesRollCollab extends DocumentSheet { _rollPrimary?: BladesRollPrimary; get rollPrimary(): BladesRollPrimary | undefined { - if (!this._rollPrimary && this.rData.rollPrimaryData) { - this._rollPrimary = new BladesRollPrimary(this, this.rData.rollPrimaryData); - } if (this._rollPrimary instanceof BladesRollPrimary) { return this._rollPrimary; } return undefined; } - set rollPrimary(val: BladesRollCollab.PrimaryDocData | undefined) { + set rollPrimary(val: BladesRollPrimary | undefined) { if (val === undefined) { this._rollPrimary = undefined; } else { - this._rollPrimary = new BladesRollPrimary(this, val); + this._rollPrimary = val; } } - _rollParticipants: BladesRollCollab.ParticipantDocData[] = []; - // get rollParticipants(): Array { if (BladesActor.IsType(this.rollPrimary, BladesActorType.pc)) { if (isAction(this.rollTrait)) { - return Object.values(Action) + return Object.values(ActionTrait) .map((action) => ({ name: U.uCase(action), value: action })); } if (isAttribute(this.rollTrait)) { - return Object.values(Attribute) + return Object.values(AttributeTrait) .map((attribute) => ({ name: U.uCase(attribute), value: attribute @@ -2760,7 +1614,7 @@ class BladesRollCollab extends DocumentSheet { } get rollMods(): BladesRollMod[] { if (!this._rollMods) { throw new Error("[get rollMods] No roll mods found!") } - return this._rollMods.sort((modA, modB) => this.compareMods(modA, modB)); + return [...this._rollMods].sort((modA: BladesRollMod, modB: BladesRollMod) => this.compareMods(modA, modB)); } set rollMods(val: BladesRollMod[]) { this._rollMods = val } @@ -3411,7 +2265,7 @@ class BladesRollCollab extends DocumentSheet { // #endregion // #region LISTENER FUNCTIONS ~ - async _toggleRollModClick(event: ClickEvent) { + _toggleRollModClick(event: ClickEvent) { event.preventDefault(); const elem$ = $(event.currentTarget); const id = elem$.data("id"); @@ -3428,7 +2282,7 @@ class BladesRollCollab extends DocumentSheet { } } - async _toggleRollModContext(event: ClickEvent) { + _toggleRollModContext(event: ClickEvent) { event.preventDefault(); if (!game.user.isGM) { return } const elem$ = $(event.currentTarget); @@ -3446,7 +2300,7 @@ class BladesRollCollab extends DocumentSheet { } } - async _gmControlSet(event: ClickEvent) { + _gmControlSet(event: ClickEvent) { event.preventDefault(); if (!game.user.isGM) { return } const elem$ = $(event.currentTarget); @@ -3468,7 +2322,7 @@ class BladesRollCollab extends DocumentSheet { const target = elem$.data("target").replace(/flags\.eunos-blades\./, ""); const value = elem$.data("value"); - this.document.setFlag(C.SYSTEM_ID, target, value); + await this.document.setFlag(C.SYSTEM_ID, target, value); } async _gmControlResetTarget(event: ClickEvent) { @@ -3477,10 +2331,10 @@ class BladesRollCollab extends DocumentSheet { const elem$ = $(event.currentTarget); const target = elem$.data("target").replace(/flags\.eunos-blades\./, ""); - this.document.unsetFlag(C.SYSTEM_ID, target); + await this.document.unsetFlag(C.SYSTEM_ID, target); } - async _gmControlReset(event: ClickEvent) { + _gmControlReset(event: ClickEvent) { event.preventDefault(); if (!game.user.isGM) { return } const elem$ = $(event.currentTarget); @@ -3634,7 +2488,7 @@ class BladesRollCollab extends DocumentSheet { const [id] = (uuid.match(new RegExp(`${type}\\.(.+)`)) ?? []).slice(1); const oppDoc = game[`${U.lCase(type)}s`].get(id); if (BladesRollOpposition.IsDoc(oppDoc)) { - this.rollOpposition = oppDoc; + this.rollOpposition = new BladesRollOpposition(this, {rollOppDoc: oppDoc}); } } diff --git a/ts/blades.ts b/ts/blades.ts index d8b38afb..fdab71ff 100644 --- a/ts/blades.ts +++ b/ts/blades.ts @@ -2,34 +2,35 @@ import C from "./core/constants.js"; import registerSettings, {initTinyMCEStyles, initCanvasStyles} from "./core/settings.js"; import {registerHandlebarHelpers, preloadHandlebarsTemplates} from "./core/helpers.js"; -import BladesPushController from "./blades-push-notifications.js"; +import BladesPushController from "./BladesPushController.js"; import U from "./core/utilities.js"; -import registerDebugger from "./core/logger.js"; +import logger from "./core/logger.js"; import G, {Initialize as GsapInitialize} from "./core/gsap.js"; -import BladesActor from "./blades-actor.js"; -import BladesActorProxy from "./documents/blades-actor-proxy.js"; -import BladesItemProxy, {BladesItem, BladesClockKeeper, BladesGMTracker, BladesLocation, BladesScore} from "./documents/blades-item-proxy.js"; +import BladesActorProxy, {BladesActor} from "./documents/BladesActorProxy.js"; +import BladesItemProxy, {BladesItem, BladesClockKeeper, BladesGMTracker, BladesLocation, BladesScore} from "./documents/BladesItemProxy.js"; -import BladesItemSheet from "./sheets/item/blades-item-sheet.js"; -import BladesPCSheet from "./sheets/actor/blades-pc-sheet.js"; -import BladesCrewSheet from "./sheets/actor/blades-crew-sheet.js"; -import BladesNPCSheet from "./sheets/actor/blades-npc-sheet.js"; -import BladesFactionSheet from "./sheets/actor/blades-faction-sheet.js"; -import BladesRollCollab, {ApplyRollEffects, ApplyDescriptions} from "./blades-roll-collab.js"; +import BladesItemSheet from "./sheets/item/BladesItemSheet.js"; +import BladesPCSheet from "./sheets/actor/BladesPCSheet.js"; +import BladesCrewSheet from "./sheets/actor/BladesCrewSheet.js"; +import BladesNPCSheet from "./sheets/actor/BladesNPCSheet.js"; +import BladesFactionSheet from "./sheets/actor/BladesFactionSheet.js"; +import BladesRollCollab from "./BladesRollCollab.js"; -import BladesSelectorDialog from "./blades-dialog.js"; -import BladesActiveEffect from "./blades-active-effect.js"; -import BladesTrackerSheet from "./sheets/item/blades-tracker-sheet.js"; -import BladesClockKeeperSheet from "./sheets/item/blades-clock-keeper-sheet.js"; -import {updateClaims, updateContacts, updateOps, updateFactions} from "./data-import/data-import.js"; +import BladesSelectorDialog from "./BladesDialog.js"; +import BladesActiveEffect from "./BladesActiveEffect.js"; +import BladesGMTrackerSheet from "./sheets/item/BladesGMTrackerSheet.js"; +import BladesClockKeeperSheet from "./sheets/item/BladesClockKeeperSheet.js"; +import {updateClaims, updateContacts, updateOps, updateFactions, updateDescriptions, updateRollMods} from "./data-import/data-import.js"; CONFIG.debug.logging = false; -/*DEVCODE*/CONFIG.debug.logging = true; /*!DEVCODE*/ +/*DEVCODE*/CONFIG.debug.logging = true; +Object.assign(globalThis, {eLog: logger}); +Handlebars.registerHelper("eLog", logger.hbsLog); /*!DEVCODE*/ let socket: Socket; //~ SocketLib interface -registerDebugger(); + // #endregion ▮▮▮▮[IMPORTS]▮▮▮▮ // #region Globals: Exposing Functionality to Global Scope ~ @@ -40,6 +41,8 @@ registerDebugger(); updateContacts, updateOps, updateFactions, + applyDescriptions: updateDescriptions, + applyRollEffects: updateRollMods, BladesActor, BladesPCSheet, BladesCrewSheet, @@ -48,8 +51,6 @@ registerDebugger(); BladesActiveEffect, BladesPushController, BladesRollCollab, - ApplyRollEffects, - ApplyDescriptions, G, U, C, @@ -59,7 +60,7 @@ registerDebugger(); BladesLocation, BladesItemSheet, BladesClockKeeperSheet, - BladesTrackerSheet + BladesGMTrackerSheet } );/*!DEVCODE*/ // #endregion Globals @@ -89,7 +90,7 @@ Hooks.once("init", async () => { await Promise.all([ BladesPCSheet.Initialize(), BladesActiveEffect.Initialize(), - BladesTrackerSheet.Initialize(), + BladesGMTrackerSheet.Initialize(), BladesScore.Initialize(), BladesSelectorDialog.Initialize(), BladesClockKeeperSheet.Initialize(), @@ -101,7 +102,7 @@ Hooks.once("init", async () => { registerHandlebarHelpers(); }); -Hooks.once("ready", async () => { +Hooks.once("ready", () => { initCanvasStyles(); initTinyMCEStyles(); // BladesRollCollab.NewRoll({ @@ -109,8 +110,7 @@ Hooks.once("ready", async () => { // rollType: RollType.Action, // rollTrait: U.randElem(Object.values(Action)) // }); - // @ts-expect-error Just never bothered to declare it's a global - DebugPC(); + // DebugPC(); }); // #endregion ▄▄▄▄▄ SYSTEM INITIALIZATION ▄▄▄▄▄ diff --git a/ts/core/constants.ts b/ts/core/constants.ts index 1244fef4..93712723 100644 --- a/ts/core/constants.ts +++ b/ts/core/constants.ts @@ -94,7 +94,7 @@ export enum OtherDistrict { Deathlands = "Deathlands" } -export enum Attribute { +export enum AttributeTrait { insight = "insight", prowess = "prowess", resolve = "resolve" @@ -117,7 +117,7 @@ export enum ResolveActions { consort = "consort", sway = "sway" } -export enum Action { +export enum ActionTrait { hunt = "hunt", study = "study", survey = "survey", @@ -133,11 +133,11 @@ export enum Action { } export enum DowntimeAction { - Acquire = "Acquire", + AcquireAsset = "AcquireAsset", + IndulgeVice = "IndulgeVice", + LongTermProject = "LongTermProject", Recover = "Recover", - Vice = "Vice", - Project = "Project", - Heat = "Heat", + ReduceHeat = "ReduceHeat", Train = "Train" } @@ -150,7 +150,6 @@ export enum RollType { export enum RollSubType { Incarceration = "Incarceration", - Healing = "Healing", Engagement = "Engagement", GatherInfo = "GatherInfo" } @@ -312,7 +311,7 @@ export namespace Tag { export enum GearCategory { ArcaneImplement = "ArcaneImplement", Document = "Document", - Gear = "Gear", + GearKit = "GearKit", SubterfugeSupplies = "SubterfugeSupplies", Tool = "Tool", Weapon = "Weapon" @@ -365,56 +364,56 @@ const C = { levels: ["BITD.Light", "BITD.Normal", "BITD.Heavy", "BITD.Encumbered", "BITD.OverMax"] }, AttributeTooltips: { - [Attribute.insight]: "

Resists consequences from deception or understanding

", - [Attribute.prowess]: "

Resists consequences from physical strain or injury

", - [Attribute.resolve]: "

Resists consequences from mental strain or willpower

" + [AttributeTrait.insight]: "

Resists consequences from deception or understanding

", + [AttributeTrait.prowess]: "

Resists consequences from physical strain or injury

", + [AttributeTrait.resolve]: "

Resists consequences from mental strain or willpower

" }, ShortAttributeTooltips: { - [Attribute.insight]: "vs. deception or (mis)understanding", - [Attribute.prowess]: "vs. physical strain or injury", - [Attribute.resolve]: "vs. mental strain or willpower" + [AttributeTrait.insight]: "vs. deception or (mis)understanding", + [AttributeTrait.prowess]: "vs. physical strain or injury", + [AttributeTrait.resolve]: "vs. mental strain or willpower" }, ShortActionTooltips: { - [Action.hunt]: "carefully track a target", - [Action.study]: "scrutinize details and interpret evidence", - [Action.survey]: "observe the situation and anticipate outcomes", - [Action.tinker]: "fiddle with devices and mechanisms", - [Action.finesse]: "employ dexterity or subtle misdirection", - [Action.prowl]: "traverse skillfully and quietly", - [Action.skirmish]: "entangle a target in melee so they can't escape", - [Action.wreck]: "unleash savage force", - [Action.attune]: "open your mind to the ghost field or channel nearby electroplasmic energy through your body", - [Action.command]: "compel swift obedience", - [Action.consort]: "socialize with friends and contacts", - [Action.sway]: "influence someone with guile, charm, or argument" + [ActionTrait.hunt]: "carefully track a target", + [ActionTrait.study]: "scrutinize details and interpret evidence", + [ActionTrait.survey]: "observe the situation and anticipate outcomes", + [ActionTrait.tinker]: "fiddle with devices and mechanisms", + [ActionTrait.finesse]: "employ dexterity or subtle misdirection", + [ActionTrait.prowl]: "traverse skillfully and quietly", + [ActionTrait.skirmish]: "entangle a target in melee so they can't escape", + [ActionTrait.wreck]: "unleash savage force", + [ActionTrait.attune]: "open your mind to the ghost field or channel nearby electroplasmic energy through your body", + [ActionTrait.command]: "compel swift obedience", + [ActionTrait.consort]: "socialize with friends and contacts", + [ActionTrait.sway]: "influence someone with guile, charm, or argument" }, ActionTooltips: { - [Action.hunt]: "

When you Hunt, you carefully track a target.

  • You might follow a person or discover their location.
  • You might arrange an ambush.
  • You might attack with precision shooting from a distance.
  • You could try to wield your guns in a melee (but Skirmishing might be better).
", - [Action.study]: "

When you Study, you scrutinize details and interpret evidence.

  • You might gather information from documents, newspapers, and books.
  • You might do research on an esoteric topic.
  • You might closely analyze a person to detect lies or true feelings.
  • You could try to understand a pressing situation (but Surveying might be better).
", - [Action.survey]: "

When you Survey, you observe the situation and anticipate outcomes.

  • You might spot telltale signs of trouble before it happens.
  • You might uncover opportunities or weaknesses.
  • You might detect a person's motives or intentions (but Studying might be better).
  • You could try to spot a good ambush point (but Hunting might be better).
", - [Action.tinker]: "

When you Tinker, you fiddle with devices and mechanisms.

  • You might create a new gadget or alter an existing item.
  • You might pick a lock or crack a safe.
  • You might disable an alarm or trap.
  • You might turn the sparkcraft and electroplasmic devices around the city to your advantage.
  • You could try to control a vehicle with your tech-savvy (but Finessing might be better).
", - [Action.finesse]: "

When you Finesse, you employ dexterity or subtle misdirection.

  • You might pick someone's pocket.
  • You might handle the controls of a vehicle or direct a mount.
  • You might formally duel an opponent with graceful fighting arts.
  • You could try to leverage agility in a melee (but Skirmishing might be better).
  • You could try to pick a lock (but Tinkering might be better).
", - [Action.prowl]: "

When you Prowl, you traverse skillfully and quietly.

  • You might sneak past a guard or hide in the shadows.
  • You might run and leap across the rooftops.
  • You might attack someone from hiding with a back-stab or blackjack.
  • You could try to waylay a victim during combat (but Skirmishing might be better).
", - [Action.skirmish]: "

When you Skirmish, you entangle a target in melee so they can't escape.

  • You might brawl or wrestle with them.
  • You might hack and slash.
  • You might seize or hold a position in battle.
  • You could try to fight in a formal duel (but Finessing might be better).
", - [Action.wreck]: "

When you Wreck, you unleash savage force.

  • You might smash down a door or wall with a sledgehammer.
  • You might use an explosive to do the same.
  • You might use chaos or sabotage to create distractions or overcome obstacles.
  • You could try to overwhelm an enemy with sheer force in battle (but Skirmishing might be better).
", - [Action.attune]: "

When you Attune, you open your mind to the ghost field or channel nearby electroplasmic energy through your body.

  • You might communicate with a ghost or understand aspects of spectrology.
  • You might peer into the echo of Doskvol in the ghost field.
  • You could try to perceive beyond sight in order to better understand your situation (but Surveying might be better).
", - [Action.command]: "

When you Command, you compel swift obedience.

  • You might intimidate or threaten to get what you want.
  • You might lead a gang in a group action.
  • You could try to persuade people by giving orders (but Consorting might be better).
", - [Action.consort]: "

When you Consort, you socialize with friends and contacts.

  • You might gain access to resources, information, people, or places.
  • You might make a good impression or win someone over with charm and style.
  • You might make new friends or connect with your heritage or background.
  • You could try to direct allies with social pressure (but Commanding might be better).
", - [Action.sway]: "

When you Sway, you influence someone with guile, charm, or argument.

  • You might lie convincingly.
  • You might persuade someone to do what you want.
  • You might argue a case that leaves no clear rebuttal.
  • You could try to trick people into affection or obedience (but Consorting or Commanding might be better).
" + [ActionTrait.hunt]: "

When you Hunt, you carefully track a target.

  • You might follow a person or discover their location.
  • You might arrange an ambush.
  • You might attack with precision shooting from a distance.
  • You could try to wield your guns in a melee (but Skirmishing might be better).
", + [ActionTrait.study]: "

When you Study, you scrutinize details and interpret evidence.

  • You might gather information from documents, newspapers, and books.
  • You might do research on an esoteric topic.
  • You might closely analyze a person to detect lies or true feelings.
  • You could try to understand a pressing situation (but Surveying might be better).
", + [ActionTrait.survey]: "

When you Survey, you observe the situation and anticipate outcomes.

  • You might spot telltale signs of trouble before it happens.
  • You might uncover opportunities or weaknesses.
  • You might detect a person's motives or intentions (but Studying might be better).
  • You could try to spot a good ambush point (but Hunting might be better).
", + [ActionTrait.tinker]: "

When you Tinker, you fiddle with devices and mechanisms.

  • You might create a new gadget or alter an existing item.
  • You might pick a lock or crack a safe.
  • You might disable an alarm or trap.
  • You might turn the sparkcraft and electroplasmic devices around the city to your advantage.
  • You could try to control a vehicle with your tech-savvy (but Finessing might be better).
", + [ActionTrait.finesse]: "

When you Finesse, you employ dexterity or subtle misdirection.

  • You might pick someone's pocket.
  • You might handle the controls of a vehicle or direct a mount.
  • You might formally duel an opponent with graceful fighting arts.
  • You could try to leverage agility in a melee (but Skirmishing might be better).
  • You could try to pick a lock (but Tinkering might be better).
", + [ActionTrait.prowl]: "

When you Prowl, you traverse skillfully and quietly.

  • You might sneak past a guard or hide in the shadows.
  • You might run and leap across the rooftops.
  • You might attack someone from hiding with a back-stab or blackjack.
  • You could try to waylay a victim during combat (but Skirmishing might be better).
", + [ActionTrait.skirmish]: "

When you Skirmish, you entangle a target in melee so they can't escape.

  • You might brawl or wrestle with them.
  • You might hack and slash.
  • You might seize or hold a position in battle.
  • You could try to fight in a formal duel (but Finessing might be better).
", + [ActionTrait.wreck]: "

When you Wreck, you unleash savage force.

  • You might smash down a door or wall with a sledgehammer.
  • You might use an explosive to do the same.
  • You might use chaos or sabotage to create distractions or overcome obstacles.
  • You could try to overwhelm an enemy with sheer force in battle (but Skirmishing might be better).
", + [ActionTrait.attune]: "

When you Attune, you open your mind to the ghost field or channel nearby electroplasmic energy through your body.

  • You might communicate with a ghost or understand aspects of spectrology.
  • You might peer into the echo of Doskvol in the ghost field.
  • You could try to perceive beyond sight in order to better understand your situation (but Surveying might be better).
", + [ActionTrait.command]: "

When you Command, you compel swift obedience.

  • You might intimidate or threaten to get what you want.
  • You might lead a gang in a group action.
  • You could try to persuade people by giving orders (but Consorting might be better).
", + [ActionTrait.consort]: "

When you Consort, you socialize with friends and contacts.

  • You might gain access to resources, information, people, or places.
  • You might make a good impression or win someone over with charm and style.
  • You might make new friends or connect with your heritage or background.
  • You could try to direct allies with social pressure (but Commanding might be better).
", + [ActionTrait.sway]: "

When you Sway, you influence someone with guile, charm, or argument.

  • You might lie convincingly.
  • You might persuade someone to do what you want.
  • You might argue a case that leaves no clear rebuttal.
  • You could try to trick people into affection or obedience (but Consorting or Commanding might be better).
" }, ActionTooltipsGM: { - [Action.hunt]: "

When you Hunt, you carefully track a target.

  • You might follow a person or discover their location.
  • You might arrange an ambush.
  • You might attack with precision shooting from a distance.
  • You could try to wield your guns in a melee (but Skirmishing might be better).

  • How do you hunt them down?
  • What methods do you use?
  • What do you hope to achieve?
", - [Action.study]: "

When you Study, you scrutinize details and interpret evidence.

  • You might gather information from documents, newspapers, and books.
  • You might do research on an esoteric topic.
  • You might closely analyze a person to detect lies or true feelings.
  • You could try to understand a pressing situation (but Surveying might be better).

  • What do you study?
  • What details or evidence do you scrutinize?
  • What do you hope to understand?
", - [Action.survey]: "

When you Survey, you observe the situation and anticipate outcomes.

  • You might spot telltale signs of trouble before it happens.
  • You might uncover opportunities or weaknesses.
  • You might detect a person's motives or intentions (but Studying might be better).
  • You could try to spot a good ambush point (but Hunting might be better).

  • How do you survey the situation?
  • Is there anything special you're looking out for?
  • What do you hope to understand?
", - [Action.tinker]: "

When you Tinker, you fiddle with devices and mechanisms.

  • You might create a new gadget or alter an existing item.
  • You might pick a lock or crack a safe.
  • You might disable an alarm or trap.
  • You might turn the sparkcraft and electroplasmic devices around the city to your advantage.
  • You could try to control a vehicle with your tech-savvy (but Finessing might be better).

  • What do you tinker with?
  • What do you hope to accomplish?
", - [Action.finesse]: "

When you Finesse, you employ dexterity or subtle misdirection.

  • You might pick someone's pocket.
  • You might handle the controls of a vehicle or direct a mount.
  • You might formally duel an opponent with graceful fighting arts.
  • You could try to leverage agility in a melee (but Skirmishing might be better).
  • You could try to pick a lock (but Tinkering might be better).

  • What do you finesse?
  • What's graceful or subtle about this?
  • What do you hope to achieve?
", - [Action.prowl]: "

When you Prowl, you traverse skillfully and quietly.

  • You might sneak past a guard or hide in the shadows.
  • You might run and leap across the rooftops.
  • You might attack someone from hiding with a back-stab or blackjack.
  • You could try to waylay a victim during combat (but Skirmishing might be better).

  • How do you prowl?
  • How do you use the environment around you?
  • What do you hope to achieve?
", - [Action.skirmish]: "

When you Skirmish, you entangle a target in melee so they can't escape.

  • You might brawl or wrestle with them.
  • You might hack and slash.
  • You might seize or hold a position in battle.
  • You could try to fight in a formal duel (but Finessing might be better).

  • How do you skirmish with them?
  • What combat methods do you use?
  • What do you hope to achieve?
", - [Action.wreck]: "

When you Wreck, you unleash savage force.

  • You might smash down a door or wall with a sledgehammer.
  • You might use an explosive to do the same.
  • You might use chaos or sabotage to create distractions or overcome obstacles.
  • You could try to overwhelm an enemy with sheer force in battle (but Skirmishing might be better).

  • What do you wreck?
  • What force do you bring to bear?
  • What do you hope to accomplish?
", - [Action.attune]: "

When you Attune, you open your mind to the ghost field or channel nearby electroplasmic energy through your body.

  • You might communicate with a ghost or understand aspects of spectrology.
  • You might peer into the echo of Doskvol in the ghost field.
  • You could try to perceive beyond sight in order to better understand your situation (but Surveying might be better).

  • How do you open your mind to the ghost field?
  • What does that look like?
  • What energy are you attuning to?
  • How are you channeling that energy?
  • What do you hope the energy will do?
", - [Action.command]: "

When you Command, you compel swift obedience.

  • You might intimidate or threaten to get what you want.
  • You might lead a gang in a group action.
  • You could try to persuade people by giving orders (but Consorting might be better).

  • Who do you command?
  • How do you do it—what's your leverage here?
  • What do you hope they'll do?
", - [Action.consort]: "

When you Consort, you socialize with friends and contacts.

  • You might gain access to resources, information, people, or places.
  • You might make a good impression or win someone over with charm and style.
  • You might make new friends or connect with your heritage or background.
  • You could try to direct allies with social pressure (but Commanding might be better).

  • Who do you consort with?
  • Where do you meet?
  • What do you talk about?
  • What do you hope to achieve?
", - [Action.sway]: "

When you Sway, you influence someone with guile, charm, or argument.

  • You might lie convincingly.
  • You might persuade someone to do what you want.
  • You might argue a case that leaves no clear rebuttal.
  • You could try to trick people into affection or obedience (but Consorting or Commanding might be better).

  • Who do you sway?
  • What kind of leverage do you have here?
  • What do you hope they'll do?
" + [ActionTrait.hunt]: "

When you Hunt, you carefully track a target.

  • You might follow a person or discover their location.
  • You might arrange an ambush.
  • You might attack with precision shooting from a distance.
  • You could try to wield your guns in a melee (but Skirmishing might be better).

  • How do you hunt them down?
  • What methods do you use?
  • What do you hope to achieve?
", + [ActionTrait.study]: "

When you Study, you scrutinize details and interpret evidence.

  • You might gather information from documents, newspapers, and books.
  • You might do research on an esoteric topic.
  • You might closely analyze a person to detect lies or true feelings.
  • You could try to understand a pressing situation (but Surveying might be better).

  • What do you study?
  • What details or evidence do you scrutinize?
  • What do you hope to understand?
", + [ActionTrait.survey]: "

When you Survey, you observe the situation and anticipate outcomes.

  • You might spot telltale signs of trouble before it happens.
  • You might uncover opportunities or weaknesses.
  • You might detect a person's motives or intentions (but Studying might be better).
  • You could try to spot a good ambush point (but Hunting might be better).

  • How do you survey the situation?
  • Is there anything special you're looking out for?
  • What do you hope to understand?
", + [ActionTrait.tinker]: "

When you Tinker, you fiddle with devices and mechanisms.

  • You might create a new gadget or alter an existing item.
  • You might pick a lock or crack a safe.
  • You might disable an alarm or trap.
  • You might turn the sparkcraft and electroplasmic devices around the city to your advantage.
  • You could try to control a vehicle with your tech-savvy (but Finessing might be better).

  • What do you tinker with?
  • What do you hope to accomplish?
", + [ActionTrait.finesse]: "

When you Finesse, you employ dexterity or subtle misdirection.

  • You might pick someone's pocket.
  • You might handle the controls of a vehicle or direct a mount.
  • You might formally duel an opponent with graceful fighting arts.
  • You could try to leverage agility in a melee (but Skirmishing might be better).
  • You could try to pick a lock (but Tinkering might be better).

  • What do you finesse?
  • What's graceful or subtle about this?
  • What do you hope to achieve?
", + [ActionTrait.prowl]: "

When you Prowl, you traverse skillfully and quietly.

  • You might sneak past a guard or hide in the shadows.
  • You might run and leap across the rooftops.
  • You might attack someone from hiding with a back-stab or blackjack.
  • You could try to waylay a victim during combat (but Skirmishing might be better).

  • How do you prowl?
  • How do you use the environment around you?
  • What do you hope to achieve?
", + [ActionTrait.skirmish]: "

When you Skirmish, you entangle a target in melee so they can't escape.

  • You might brawl or wrestle with them.
  • You might hack and slash.
  • You might seize or hold a position in battle.
  • You could try to fight in a formal duel (but Finessing might be better).

  • How do you skirmish with them?
  • What combat methods do you use?
  • What do you hope to achieve?
", + [ActionTrait.wreck]: "

When you Wreck, you unleash savage force.

  • You might smash down a door or wall with a sledgehammer.
  • You might use an explosive to do the same.
  • You might use chaos or sabotage to create distractions or overcome obstacles.
  • You could try to overwhelm an enemy with sheer force in battle (but Skirmishing might be better).

  • What do you wreck?
  • What force do you bring to bear?
  • What do you hope to accomplish?
", + [ActionTrait.attune]: "

When you Attune, you open your mind to the ghost field or channel nearby electroplasmic energy through your body.

  • You might communicate with a ghost or understand aspects of spectrology.
  • You might peer into the echo of Doskvol in the ghost field.
  • You could try to perceive beyond sight in order to better understand your situation (but Surveying might be better).

  • How do you open your mind to the ghost field?
  • What does that look like?
  • What energy are you attuning to?
  • How are you channeling that energy?
  • What do you hope the energy will do?
", + [ActionTrait.command]: "

When you Command, you compel swift obedience.

  • You might intimidate or threaten to get what you want.
  • You might lead a gang in a group action.
  • You could try to persuade people by giving orders (but Consorting might be better).

  • Who do you command?
  • How do you do it—what's your leverage here?
  • What do you hope they'll do?
", + [ActionTrait.consort]: "

When you Consort, you socialize with friends and contacts.

  • You might gain access to resources, information, people, or places.
  • You might make a good impression or win someone over with charm and style.
  • You might make new friends or connect with your heritage or background.
  • You could try to direct allies with social pressure (but Commanding might be better).

  • Who do you consort with?
  • Where do you meet?
  • What do you talk about?
  • What do you hope to achieve?
", + [ActionTrait.sway]: "

When you Sway, you influence someone with guile, charm, or argument.

  • You might lie convincingly.
  • You might persuade someone to do what you want.
  • You might argue a case that leaves no clear rebuttal.
  • You could try to trick people into affection or obedience (but Consorting or Commanding might be better).

  • Who do you sway?
  • What kind of leverage do you have here?
  • What do you hope they'll do?
" }, TraumaTooltips: { Cold: "You're not moved by emotional appeals or social bonds.", @@ -951,14 +950,14 @@ const C = { BladesItemType.stricture ], Attribute: [ - Attribute.insight, - Attribute.prowess, - Attribute.resolve + AttributeTrait.insight, + AttributeTrait.prowess, + AttributeTrait.resolve ], Action: { - [Attribute.insight]: [Action.hunt, Action.study, Action.survey, Action.tinker], - [Attribute.prowess]: [Action.finesse, Action.prowl, Action.skirmish, Action.wreck], - [Attribute.resolve]: [Action.attune, Action.command, Action.consort, Action.sway] + [AttributeTrait.insight]: [ActionTrait.hunt, ActionTrait.study, ActionTrait.survey, ActionTrait.tinker], + [AttributeTrait.prowess]: [ActionTrait.finesse, ActionTrait.prowl, ActionTrait.skirmish, ActionTrait.wreck], + [AttributeTrait.resolve]: [ActionTrait.attune, ActionTrait.command, ActionTrait.consort, ActionTrait.sway] }, Vices: [ Vice.Faith, Vice.Gambling, Vice.Luxury, Vice.Obligation, Vice.Pleasure, Vice.Stupor, Vice.Weird, Vice.Worship, Vice.Living_Essence, Vice.Life_Essence, Vice.Electroplasmic_Power diff --git a/ts/core/helpers.ts b/ts/core/helpers.ts index 4b1c7803..feb9a403 100644 --- a/ts/core/helpers.ts +++ b/ts/core/helpers.ts @@ -2,8 +2,8 @@ import U from "./utilities.js"; import type {ItemData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/module.mjs"; -import type BladesActor from "../blades-actor.js"; -import type BladesItem from "../blades-item.js"; +import type BladesActor from "../BladesActor.js"; +import type BladesItem from "../BladesItem.js"; import {HbsSvgData, SVGDATA} from "./constants.js"; import type {ItemDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/itemData.js"; // #endregion ▮▮▮▮[IMPORTS]▮▮▮▮ diff --git a/ts/core/logger.ts b/ts/core/logger.ts index 14879624..6ca8c7ae 100644 --- a/ts/core/logger.ts +++ b/ts/core/logger.ts @@ -1,7 +1,7 @@ import U from "./utilities.js"; import C from "./constants.js"; -const LOGGERCONFIG: Record = { +const LOGGERCONFIG = { fullName: "eLogger", aliases: ["dbLog"], stackTraceExclusions: { @@ -71,15 +71,16 @@ const STYLES = { } }; -const eLogger = (type: "checkLog"|"log"|KeyOf = "base", ...content: [string, ...any[]]) => { +const eLogger = (type: "checkLog"|"log"|KeyOf = "base", ...content: [string, ...unknown[]]) => { if (!(type === "error" || CONFIG.debug.logging)) { return } + const lastElem = U.getLast(content); - let dbLevel: 0|1|2|3|4|5 = [0,1,2,3,4,5].includes(U.getLast(content)) - ? content.pop() + let dbLevel: 0|1|2|3|4|5 = typeof lastElem === "number" && [0,1,2,3,4,5].includes(lastElem) + ? content.pop() as 0|1|2|3|4|5 : 3; let key: string|false = false; if (type === "checkLog") { - key = content.shift(); + key = content.shift() as string; type = `log${dbLevel}`; } @@ -103,7 +104,7 @@ const eLogger = (type: "checkLog"|"log"|KeyOf = "base", ...conten } const stackTrace = type === "display" ? null - : getStackTrace(LOGGERCONFIG.stackTraceExclusions[type] ?? []); + : getStackTrace(LOGGERCONFIG.stackTraceExclusions[type as KeyOf] ?? []); const styleLine = Object.entries({ ...STYLES.base, ...STYLES[type] ?? {} @@ -155,8 +156,8 @@ const eLogger = (type: "checkLog"|"log"|KeyOf = "base", ...conten } }; -type eLogParams = [string, ...any[]]; -const eLog = { +type eLogParams = [string, ...unknown[]]; +const logger = { display: (...content: eLogParams) => eLogger("display", ...content), log0: (...content: eLogParams) => eLogger("log", ...content, 0), log1: (...content: eLogParams) => eLogger("log", ...content, 1), @@ -176,9 +177,4 @@ const eLog = { hbsLog: (...content: eLogParams) => eLogger("handlebars", ...content) }; -const registerDebugger = () => { - Object.assign(globalThis, {eLog}); - Handlebars.registerHelper("eLog", eLog.hbsLog); -}; - -export default registerDebugger; \ No newline at end of file +export default logger; \ No newline at end of file diff --git a/ts/core/mixins.ts b/ts/core/mixins.ts index f0c3a1c0..6cc9f0c4 100644 --- a/ts/core/mixins.ts +++ b/ts/core/mixins.ts @@ -1,6 +1,6 @@ import {Playbook, BladesItemType} from "./constants.js"; -import BladesActor from "../blades-actor.js"; -import BladesItem from "../blades-item.js"; +import BladesActor from "../BladesActor.js"; +import BladesItem from "../BladesItem.js"; // type Constructor = new (...args: readonly any[]) => {}; diff --git a/ts/core/tags.ts b/ts/core/tags.ts index 482c65c4..ef813099 100644 --- a/ts/core/tags.ts +++ b/ts/core/tags.ts @@ -12,6 +12,18 @@ const _onTagifyChange = (event: Event, doc: BladesDoc, targetKey: keyof BladesDo } }; +interface TagData { + value: string; + [key: string]: unknown; +} + +interface TagifyFunctions { + dropdown: { + createListHTML: (optionsArray: Array<{ value: BladesTag; "data-group": string }>) => string, + getMappedValue: (tagData: TagData) => string + } +} + const Tags = { InitListeners: (html: JQuery, doc: BladesDoc) => { @@ -34,15 +46,15 @@ const Tags = { placeAbove: false, appendTarget: html[0] } - }); + }) as Tagify & TagifyFunctions; - (tagify as any).dropdown.createListHTML = (optionsArr: Array<{ value: BladesTag; "data-group": string }>) => { + tagify.dropdown.createListHTML = (optionsArr: Array<{ value: BladesTag; "data-group": string }>) => { const map: Record = {}; return structuredClone(optionsArr) .map((suggestion, idx) => { - const value = (tagify as any).dropdown.getMappedValue.call( + const value = tagify.dropdown.getMappedValue.call( tagify, suggestion ); diff --git a/ts/core/utilities.ts b/ts/core/utilities.ts index 191f1d15..c590c23d 100644 --- a/ts/core/utilities.ts +++ b/ts/core/utilities.ts @@ -8,7 +8,7 @@ import {gsap} from "gsap/all"; // _noCapWords -- Patterns matching words that should NOT be capitalized when converting to TITLE case. const _noCapWords = "a|above|after|an|and|at|below|but|by|down|for|for|from|in|nor|of|off|on|onto|or|out|so|the|to|under|up|with|yet" .split("|") - .map((word) => new RegExp(`\\b${word}\\b`, "gui")) as RegExp[]; + .map((word) => new RegExp(`\\b${word}\\b`, "gui")); // _capWords -- Patterns matching words that should ALWAYS be capitalized when converting to SENTENCE case. const _capWords = [ @@ -153,7 +153,7 @@ const isFunc = (ref: unknown): ref is typeof Function => typeof ref === "functio const isInt = (ref: unknown): ref is int => isNumber(ref) && Math.round(ref) === ref; const isFloat = (ref: unknown): ref is float => isNumber(ref) && /\./.test(`${ref}`); const isPosInt = (ref: unknown): ref is posInt => isInt(ref) && ref >= 0; -const isIndex = (ref: T): ref is T & Index> => isList(ref) || isArray(ref); +const isIndex = (ref: T): ref is T & Index> => isList(ref) || isArray(ref); const isIterable = (ref: unknown): ref is Iterable => typeof ref === "object" && ref !== null && Symbol.iterator in ref; const isHTMLCode = (ref: unknown): ref is HTMLCode => typeof ref === "string" && /^<.*>$/u.test(ref); const isHexColor = (ref: unknown): ref is HEXColor => typeof ref === "string" && /^#(([0-9a-fA-F]{2}){3,4}|[0-9a-fA-F]{3,4})$/.test(ref); @@ -163,7 +163,39 @@ const isDefined = (ref: unknown): ref is NonNullable | null => !isUndef const isEmpty = (ref: Record | unknown[]): boolean => Object.keys(ref).length === 0; const hasItems = (ref: Index): boolean => !isEmpty(ref); const isInstance = unknown>(classRef: T, ref: unknown): ref is InstanceType => ref instanceof classRef; -const isInstanceFunc = ) => InstanceType>(clazz: T) => (instance: unknown): instance is InstanceType => instance instanceof clazz; +/** + * Asserts that a given value is of a specified type. + * Throws an error if the value is not of the expected type. + * + * @template T - The expected type of the value. + * @param {unknown} val - The value to check. + * @param {(new(...args: unknown[]) => T) | string} type - The expected type of the value. + * @throws {Error} If the value is not of the expected type. + */ +function assertNonNullType(val: unknown, type: (new(...args: unknown[]) => T) | string): asserts val is NonNullable { + let valStr: string; + // Attempt to convert the value to a string for error messaging. + try { + valStr = JSON.stringify(val); + } catch { + valStr = String(val); + } + + // Check if the value is undefined + if (val === undefined) { + throw new Error(`Value ${valStr} is undefined!`); + } + + // If the type is a string, compare the typeof the value to the type string. + if (typeof type === "string") { + if (typeof val !== type) { + throw new Error(`Value ${valStr} is not a ${type}!`); + } + } else if (!(val instanceof type)) { + // If the type is a function (constructor), check if the value is an instance of the type. + throw new Error(`Value ${valStr} is not a ${type.name}!`); + } +} const areEqual = (...refs: unknown[]) => { do { const ref = refs.pop(); @@ -257,9 +289,9 @@ const sCase = (str: T): Capitalize => { } return [first, ...rest].join(" ").trim() as Capitalize; }; -const tCase = (str: T): Titlecase => String(str).split(/\s/) +const tCase = (str: T): tCase => String(str).split(/\s/) .map((word, i) => (i && testRegExp(word, _noCapWords) ? lCase(word) : sCase(word))) - .join(" ").trim() as Titlecase; + .join(" ").trim() as tCase; // #endregion ░░░░[Case Conversion]░░░░ // #region ░░░░░░░[RegExp]░░░░ Regular Expressions ░░░░░░░ ~ const testRegExp = (str: unknown, patterns: Array = [], flags = "gui", isTestingAll = false) => patterns @@ -398,7 +430,7 @@ const verbalizeNum = (num: number | string) => { }; const parseThreeDigits = (trio: string) => { if (pInt(trio) === 0) {return ""} - const digits = `${trio}`.split("").map((digit) => pInt(digit)) as number[]; + const digits = `${trio}`.split("").map((digit) => pInt(digit)); let result = ""; if (digits.length === 3) { const hundreds = digits.shift(); @@ -424,9 +456,9 @@ const verbalizeNum = (num: number | string) => { numWords.push("negative"); } const [integers, decimals] = num.replace(/[,\s-]/g, "").split("."); - const intArray = integers.split("").reverse().join("") + const intArray = [...integers.split("")].reverse().join("") .match(/.{1,3}/g) - ?.map((v) => v.split("").reverse().join("")) ?? []; + ?.map((v) => [...v.split("")].reverse().join("")) ?? []; const intStrings = []; while (intArray.length) { const thisTrio = intArray.pop(); @@ -471,8 +503,7 @@ const romanizeNum = (num: number, isUsingGroupedChars = true) => { if (num < 0) {throw new Error(`Error: Can't Romanize Negative Numbers (${num})`)} if (num === 0) {return "0"} const romanRef = _romanNumerals[isUsingGroupedChars ? "grouped" : "ungrouped"]; - const romanNum = stringifyNum(num) - .split("") + const romanNum = [...stringifyNum(num).split("")] .reverse() .map((digit, i) => romanRef[i][pInt(digit)]) .reverse() @@ -548,7 +579,7 @@ const isIn = (needle: unknown, haystack: unknown[] = [], fuzziness = 0) => { return Object.keys(haystack) as unknown[]; } try { - return Array.from(haystack) as unknown[]; + return Array.from(haystack); } catch { throw new Error(`Haystack type must be [list, array], not ${typeof haystack}: ${JSON.stringify(haystack)}`); } @@ -630,10 +661,10 @@ const unique = (array: Type[]): Type[] => { array.forEach((item) => {if (!returnArray.includes(item)) {returnArray.push(item)} }); return returnArray; }; -const group = >(array: Type[], key: KeyOf): Partial, Type[]>> => { - const returnObj: Partial, Type[]>> = {}; +const group = >(array: Type[], key: KeyOf): Partial, Type[]>> => { + const returnObj: Partial, Type[]>> = {}; array.forEach((item) => { - const returnKey = item[key] as string & ValueOf; + const returnKey = item[key] as string & ValOf; let returnVal = returnObj[returnKey]; if (!returnVal) { returnVal = []; @@ -791,7 +822,7 @@ const replace = (obj: Index, checkTest: checkTest, repVal: unknown) => */ const objClean = (data: T, remVals: UncleanValues[] = [undefined, null, "", {}, []]): T | Partial | "KILL" => { const remStrings = remVals.map((rVal) => JSON.stringify(rVal)); - if (remStrings.includes(JSON.stringify(data)) || remVals.includes(data as ValueOf)) {return "KILL"} + if (remStrings.includes(JSON.stringify(data)) || remVals.includes(data as ValOf)) {return "KILL"} if (Array.isArray(data)) { const newData = data.map((elem) => objClean(elem, remVals)) .filter((elem) => elem !== "KILL") as T; @@ -807,7 +838,7 @@ const objClean = (data: T, remVals: UncleanValues[] = [undefined, null, "", { }; -export function toDict, V extends ValueOf>(items: T[], key: K): V extends key ? Record : never { +export function toDict, V extends ValOf>(items: T[], key: K): V extends key ? Record : never { const dict = {} as Record; const mappedItems = items .map((data) => { @@ -829,9 +860,9 @@ export function toDict, V extends Va function indexString(str: string) { if (/_\d+$/.test(str)) { - const [curIndex, ...subStr] = str.split(/_/).reverse(); + const [curIndex, ...subStr] = [...str.split(/_/)].reverse(); return [ - ...subStr.reverse(), + ...[...subStr].reverse(), parseInt(curIndex, 10) + 1 ].join("_"); } @@ -847,15 +878,23 @@ const partition = (obj: Type[], predicate: testFunc = () => true) function objMap(obj: Index, keyFunc: mapFunc | mapFunc | false, valFunc?: mapFunc): Index { // An object-equivalent Array.map() function, which accepts mapping functions to transform both keys and values. // If only one function is provided, it's assumed to be mapping the values and will receive (v, k) args. - if (!valFunc) { - valFunc = keyFunc as mapFunc; - keyFunc = false; + let valFuncTyped: mapFunc | undefined = valFunc; + let keyFuncTyped: mapFunc | false = keyFunc; + + if (!valFuncTyped) { + valFuncTyped = keyFunc as mapFunc; + keyFuncTyped = false; } - if (!keyFunc) { - keyFunc = ((k: unknown) => k); + if (!keyFuncTyped) { + keyFuncTyped = ((k: unknown) => k) as mapFunc; } - if (isArray(obj)) {return obj.map(valFunc)} - return Object.fromEntries(Object.entries(obj).map(([key, val]) => [(keyFunc as mapFunc)(key, val), (valFunc as mapFunc)(val, key)])); + + if (isArray(obj)) {return obj.map(valFuncTyped)} + + return Object.fromEntries(Object.entries(obj).map(([key, val]) => { + assertNonNullType(valFuncTyped, "function"); + return [(keyFuncTyped as mapFunc)(key, val), valFuncTyped(val, key)]; + })); } const objSize = (obj: Index) => Object.values(obj).filter((val) => val !== undefined && val !== null).length; const objFindKey = >(obj: Type, keyFunc: testFunc | testFunc | false, valFunc?: testFunc): KeyOf | false => { @@ -999,7 +1038,7 @@ function objDiff, Ty extends Record(obj: T): Record, null> | null[] | T { // If the input is an array, nullify all its elements if (Array.isArray(obj)) { obj.forEach((_, i) => { - obj[i] = null as ValueOf; + obj[i] = null as ValOf; }); return obj as null[]; } @@ -1298,6 +1337,7 @@ export default { areEqual, pFloat, pInt, radToDeg, degToRad, getKey, + assertNonNullType, FILTERS, diff --git a/ts/data-import/data-import.ts b/ts/data-import/data-import.ts index 0938ead3..f63798eb 100644 --- a/ts/data-import/data-import.ts +++ b/ts/data-import/data-import.ts @@ -1,7 +1,10 @@ import {BladesActorType, BladesItemType, Playbook} from "../core/constants.js"; import U from "../core/utilities.js"; -import {BladesNPC, BladesFaction} from "../documents/blades-actor-proxy.js"; -import {BladesItem} from "../documents/blades-item-proxy.js"; +import {BladesNPC, BladesFaction} from "../documents/BladesActorProxy.js"; +import {BladesItem} from "../documents/BladesItemProxy.js"; +import BladesActiveEffect from "../BladesActiveEffect.js"; + +import type {EffectChangeData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/effectChangeData.js"; type CrewObject = { type: string; @@ -22,10 +25,27 @@ type FactionData = Partial & { alliesStrings?: string[], enemiesStrings?: string[] }; - +type EffectData = { + key: "system.roll_mods", + mode: 2, + priority: null, + value: string +}; type BLADES_IMPORT_DATA = { FACTIONS: Record, - CREW_OBJECTS: CrewObject[] + CREW_OBJECTS: CrewObject[], + ABILITIES: { + Descriptions: Record, + RollMods: Record> + }, + CREW_ABILITIES: { + Descriptions: Record, + RollMods: Record> + }, + CREW_UPGRADES: { + Descriptions: Record, + RollMods: Record> + } }; const JSONDATA: BLADES_IMPORT_DATA = { @@ -2323,7 +2343,731 @@ const JSONDATA: BLADES_IMPORT_DATA = { rules: "You get +1d to acquire asset rolls.", flavor: "You have space to hold all the various items and supplies you end up with from your smuggling runs. They can be useful on their own or for barter when you need it." } - ] + ], + ABILITIES: { + Descriptions: { + "Battleborn": "

If you 'reduce harm' that means the level of harm you're facing right now is reduced by one.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Bodyguard": "

The protect teamwork maneuver lets you face a consequence for a teammate.

If you choose to resist that consequence, this ability gives you +1d to your resistance roll.

Also, when you read a situation to gather information about hidden dangers or potential attackers, you get +1 effect—which means more detailed information.

", + "Ghost Fighter": "

When you're imbued, you can strongly interact with ghosts and spirit-stuff, rather than weakly interact.

When you imbue yourself with spirit energy, how do you do it? What does it look like when the energy manifests?

", + "Leader": "

This ability makes your cohorts more effective in battle and also allows them to resist harm by using armor.

While you lead your cohorts, they won't stop fighting until they take fatal harm (level 4) or you order them to cease.

What do you do to inspire such bravery in battle?

", + "Mule": "

This ability is great if you want to wear heavy armor and pack a heavy weapon without attracting lots of attention. Since your exact gear is determined on-the-fly during an operation, having more load also gives you more options to get creative with when dealing with problems during a score.

", + "Not to Be Trifled With": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) in addition to the special ability.

If you perform a feat that verges on the superhuman, you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.

If you engage a small gang on equal footing, you don't suffer reduced effect due to scale against a small gang (up to six people).

", + "Savage": "

You instill fear in those around you when you get violent. How they react depends on the person. Some people will flee from you, some will be impressed, some will get violent in return. The GM judges the response of a given NPC.

In addition, when you Command someone who's affected by fear (from this ability or otherwise), take +1d to your roll.

", + "Vigorous": "

Your healing clock becomes a 3-clock, and you get a bonus die when you recover.

", + "Sharpshooter": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) in addition to the special ability.

The first use of this ability allows you to attempt long-range sniper shots that would otherwise be impossible with the rudimentary firearms of Duskwall.

The second use allows you to keep up a steady rate of fire in a battle (enough to 'suppress' a small gang up to six people), rather than stopping for a slow reload or discarding a gun after each shot. When an enemy is suppressed, they're reluctant to maneuver or attack (usually calling for a fortune roll to see if they can manage it).

", + "Focused": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Ghost Hunter (Arrow-Swift)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to move extremely quickly, out-pacing any other creature or vehicle.

", + "Ghost Hunter (Ghost Form)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to transform into electroplasmic vapor as if it were a spirit.

", + "Ghost Hunter (Mind Link)": "

Your pet functions as a cohort (Expert: Hunter).

This ability gives them potency against supernatural targets and the arcane ability to share senses and thoughts telepathically with their master.

", + "Scout": "

A 'target' can be a person, a destination, a good ambush spot, an item, etc.

", + "Survivor": "

This ability gives you an additional stress box, so you have 10 instead of 9. The maximum number of stress boxes a PC can have (from any number of additional special abilities or upgrades) is 12.

", + "Tough As Nails": "

With this ability, level 3 harm doesn't incapacitate you; instead you take -1d to your rolls (as if it were level 2 harm). Level 2 harm affects you as if it were level 1 (less effect). Level 1 harm has no effect on you (but you still write it on your sheet, and must recover to heal it). Record the harm at its original level—for healing purposes, the original harm level applies.

", + "Alchemist": "

Follow the Inventing procedure with the GM (page 224) to define your first special alchemical formula.

", + "Artificer": "

Follow the Inventing procedure with the GM (page 224) to define your first spark-craft design.

", + "Fortitude": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Ghost Ward": "

If you make an area anathema to spirits, they will do everything they can to avoid it, and will suffer torment if forced inside the area.

If you make an area enticing to spirits, they will seek it out and linger in the area, and will suffer torment if forced to leave.

This effect lasts for several days over an area the size of a small room.

Particularly powerful or prepared spirits may roll their quality or arcane magnitude to see how well they're able to resist the effect.

", + "Physicker": "

Knowledge of anatomy and healing is a rare and esoteric thing in Duskwall. Without this ability, any attempts at treatment are likely to fail or make things worse.

You can use this ability to give first aid (rolling Tinker) to allow your patient to ignore a harm penalty for an hour or two.

", + "Saboteur": "

You can drill holes in things, melt stuff with acid, even use a muffled explosive, and it will all be very quiet and extremely hard to notice.

", + "Venomous": "

You choose the type of drug or poison when you get this ability. Only a single drug or poison may be chosen—you can't become immune to any essences, oils, or other alchemical substances.

You may change the drug or poison by completing a long-term project.

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

", + "Infiltrator": "

This ability lets you contend with higher-Tier enemies on equal footing. When you're cracking a safe, picking a lock, or sneaking past elite guards, your effect level is never reduced due to superior Tier or quality level of your opposition.

Are you a renowned safe cracker? Do people tell stories of how you slipped under the noses of two Chief Inspectors, or are your exceptional talents yet to be discovered?

", + "Ambush": "

This ability benefits from preparation— so don't forget you can do that in a flashback.

", + "Daredevil": "

This special ability is a bit of a gamble. The bonus die helps you, but if you suffer consequences, they'll probably be more costly to resist. But hey, you're a daredevil, so no big deal, right?

", + "The Devil's Footsteps": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

If you perform an athletic feat (running, tumbling, balance, climbing, etc.) that verges on the superhuman, you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.

If you maneuver to confuse your enemies, they attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.

", + "Expertise": "

This special ability is good for covering for your team. If they're all terrible at your favored action, you don't have to worry about suffering a lot of stress when you lead their group action.

", + "Ghost Veil": "

This ability transforms you into an intangible shadow for a few moments. If you spend additional stress, you can extend the effect for additional benefits, which may improve your position or effect for action rolls, depending on the circumstances, as usual.

", + "Reflexes": "

This ability gives you the initiative in most situations. Some specially trained NPCs (and some demons and spirits) might also have reflexes, but otherwise, you're always the first to act, and can interrupt anyone else who tries to beat you to the punch.

This ability usually doesn't negate the need to make an action roll that you would otherwise have to make, but it may improve your position or effect.

", + "Shadow": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Rook's Gambit": "

This is the 'jack-of-all-trades' ability. If you want to attempt lots of different sorts of actions and still have a good dice pool to roll, this is the special ability for you.

", + "Cloak & Dagger": "

This ability gives you the chance to more easily get out of trouble if a covert operation goes haywire. Also, don't forget your fine disguise kit gear, which boosts the effect of your covert deception methods.

", + "Ghost Voice": "

The first part of this ability gives you permission to do something that is normally impossible: when you speak to a spirit, it always listens and understands you, even if it would otherwise be too bestial or insane to do so.

The second part of the ability increases your effect when you use social actions with the supernatural.

", + "Like Looking Into a Mirror": "

This ability works in all situations without restriction. It is very powerful, but also a bit of a curse. You see though every lie, even the kind ones.

", + "A Little Something on the Side": "

Since this money comes at the end of downtime, after all downtime actions are resolved, you can't remove it from your stash and spend it on extra activities until your next downtime phase.

", + "Mesmerism": "

The victims' memory 'glosses over' the missing time, so it's not suspicious that they've forgotten something.

When you next interact with the victim, they remember everything clearly, including the strange effect of this ability.

", + "Subterfuge": "

If you 'resist a consequence' of the appropriate type, you avoid it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Trust in Me": "

This ability isn't just for social interactions. Any action can get the bonus. 'Intimate' is for you and the group to define, it need not exclusively mean romantic intimacy.

", + "Foresight": "

You can narrate an event in the past that helps your teammate now, or you might explain how you expected this situation and planned a helpful contingency that you reveal now.

", + "Calculating": "

If you forget to use this ability during downtime, you can still activate it during the score and flashback to the previous downtime when the extra activity happened.

", + "Connected": "

Your array of underworld connections can be leveraged to loan assets, pressure a vendor to give you a better deal, intimidate witnesses, etc.

", + "Functioning Vice": "

If you indulged your vice and rolled a 4, you could increase the result to 5 or 6, or you could reduce the result to 3 or 2 (perhaps to avoid overindulgence).

Allies that join you don't need to have the same vice as you, just one that could be indulged alongside yours somehow.

", + "Ghost Contract": "

The mark of the oath is obvious to anyone who sees it (perhaps a magical rune appears on the skin).

When you suffer 'Cursed' harm, you're incapacitated by withering: enfeebled muscles, hair falling out, bleeding from the eyes and ears, etc., until you either fulfill the deal or discover a way to heal the curse.

", + "Jail Bird": "

Zero is the minimum wanted level; this ability can't make your wanted level negative.

", + "Mastermind": "

If you protect a teammate, this ability negates or reduces the severity of a consequence or harm that your teammate is facing. You don't have to be present to use this ability—say how you prepared for this situation in the past.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

", + "Weaving the Web": "

Your network of underworld connections can always be leveraged to gain insight for a job—even when your contacts aren't aware that they're helping you.

", + "Compel": "

The GM will tell you if you sense any ghosts nearby. If you don't, you can gather information (maybe Attune, Survey, or Study) to attempt to locate one.

By default, a ghost wants to satisfy its need for life essence and to exact vengeance. When you compel it, you can give it a general or specific command, but the more general it is (like 'Protect me') the more the ghost will interpret it according to its own desires.

Your control over the ghost lasts until the command is fulfilled, or until a day has passed, whichever comes first.

", + "Iron Will": "

With this ability, you do not freeze up or flee when confronted by any kind of supernatural entity or strange occult event.

", + "Occultist": "

Consorting with a given entity may require special preparations or travel to a specific place. The GM will tell you about any requirements.

You get the bonus die to your Command rolls because you can demonstrate a secret knowledge of or influence over the entity when you interact with cultists.

", + "Ritual": "

Without this special ability, the study and practice of rituals leaves you utterly vulnerable to the powers you supplicate. Such endeavors are not recommended.

", + "Strange Methods": "

Follow the Inventing procedure with the GM (page 224) to define your first arcane design.

", + "Tempest": "

When you push yourself to activate this ability, you still get one of the normal benefits of pushing yourself (+1d, +1 effect, etc.) if you're making a roll, in addition to the special ability.

When you unleash lightning as a weapon, the GM will describe its effect level and significant collateral damage. If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

When you summon a storm, the GM will describe its effect level. If you're using this power as cover or distraction, it's probably a setup teamwork maneuver, using Attune.

", + "Warded": "

If you resist a consequence, this ability negates it completely.

If you use this ability to push yourself, you get one of the benefits (+1d, +1 effect, act despite severe harm) but you don't take 2 stress.

Your special armor is restored at the beginning of downtime.

" + }, + RollMods: { + "Battleborn": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Battleborn@cat:after@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Battleborn

You may expend your special armor instead of paying 2 stress to Push yourself during a fight.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Battleborn@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-HarmLevel@status:Hidden@tooltip:

Battleborn

You may expend your special armor to reduce the level of harm you are resisting by one.

" + } + ], + "Bodyguard": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Bodyguard@cat:roll@type:ability@cTypes:Resistance@status:Hidden@tooltip:

Bodyguard

When you protect a teammate, take +1d to your resistance roll.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Bodyguard@cat:effect@type:ability@status:Hidden@tooltip:

Bodyguard

When you gather information to anticipate possible threats in the current situation, you get +1 effect.

" + } + ], + "Ghost Fighter": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ghost Fighter@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Fighter

You may imbue your hands, melee weapons, or tools with spirit energy, giving you Potency in combat vs. the supernatural.

You may also grapple with spirits to restrain and capture them.

" + } + ], + "Leader": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Leader@cat:effect@type:ability@cTypes:Action@cTraits:command@status:Hidden@tooltip:

Leader

When a Leader Commands this cohort in combat, it gains +1 effect.

" + } + ], + "Not to Be Trifled With": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Superhuman Feat@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|command@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Superhuman Feat@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|command@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might break a metal weapon with your bare hands, tackle a galloping horse, lift a huge weight, etc.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Engage Gang@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:Is-Push|ForceOn-Push|Negate-ScalePenalty@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Engage Gang

You can Push yourself to engage a gang of up to six members on equal footing (negating any Scale penalties).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Engage Gang@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@val:0@eKey:Is-Push|ForceOn-Push|Negate-ScalePenalty@sourceName:Not to Be Trifled With@status:Hidden@tooltip:

Not to Be Trifled With — Engage Gang

You can Push yourself to engage a gang of up to six members on equal footing (negating any Scale penalties).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Savage": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Savage@cat:roll@type:ability@cTypes:Action@cTraits:command@status:Hidden@tooltip:

Savage

When you Command a fightened target, gain +1d to your roll.

" + } + ], + "Vigorous": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Vigorous@cat:roll@type:ability@cTypes:Downtime@aTypes:Engagement|Recover@status:Hidden@tooltip:

Vigorous

You gain +1d to healing treatment rolls.

" + } + ], + "Sharpshooter": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Extreme Range@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Extreme Range

You can Push yourself to make a ranged attack at extreme distance, one that would otherwise be impossible with the rudimentary firearms of Duskwall.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Extreme Range@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Extreme Range

You can Push yourself to make a ranged attack at extreme distance, one that would otherwise be impossible with the rudimentary firearms of Duskwall.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Suppression Fire@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Suppression Fire

You can Push yourself to maintain a steady rate of suppression fire during a battle, enough to suppress a small gang of up to six members. (When an enemy is suppressed, they're reluctant to maneuver or attack, usually calling for a fortune roll to see if they can manage it.)

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Suppression Fire@cat:effect@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Sharpshooter@status:Hidden@tooltip:

Sharpshooter — Suppression Fire

You can Push yourself to maintain a steady rate of suppression fire during a battle, enough to suppress a small gang of up to six members. When an enemy is suppressed, they're reluctant to maneuver or attack, usually calling for a fortune roll to see if they can manage it.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Focused": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Focused@cat:roll@type:ability@cTypes:Resistance@cTraits:Insight|Resolve@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Focused

You may expend your special armor to completely negate a consequence of surprise or mental harm.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Focused@cat:after@type:ability@cTypes:Action@cTraits:hunt|study|survey|finesse|prowl|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Focused

You may expend your special armor instead of paying 2 stress to Push yourself for ranged combat or tracking.

" + } + ], + "Ghost Hunter (Arrow-Swift)": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Ghost Hunter (Arrow-Swift)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Arrow-Swift)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" + } + ], + "Ghost Hunter (Ghost Form)": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Ghost Hunter (Ghost Form)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Ghost Form)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" + } + ], + "Ghost Hunter (Mind Link)": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Ghost Hunter (Mind Link)@cat:effect@type:ability@cTypes:Action@cTraits:quality@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Hunter (Mind Link)

This cohort is imbued with spirit energy, granting it Potency against the supernatural.

" + } + ], + "Scout": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Scout@cat:effect@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:hunt|study|survey|attune|consort|sway@status:Hidden@tooltip:

Scout

When you gather information to discover the location of a target (a person, a destination, a good ambush spot, etc), you gain +1 effect.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Scout@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl@status:Hidden@tooltip:

Scout

When you hide in a prepared position or use camouflage, you get +1d to rolls to avoid detection.

" + } + ], + "Alchemist": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Alchemist@cat:result@type:ability@cTypes:Downtime|LongTermProject@status:Hidden@tooltip:

Alchemist

When you invent or craft a creation with alchemical features, you gain +1 result level to your roll.

" + } + ], + "Artificer": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Artificer@cat:result@type:ability@cTypes:Downtime|LongTermProject@cTraits:study|tinker@status:Hidden@tooltip:

Artificer

When you invent or craft a creation with spark-craft features, you gain +1 result level to your roll.

" + } + ], + "Fortitude": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Fortitude@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Fortitude

You may expend your special armor to completely negate a consequence of fatigue, weakness, or chemical effects.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Fortitude@cat:after@type:ability@cTypes:Action@cTraits:study|survey|tinker|finesse|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Fortitude

You may expend your special armor instead of paying 2 stress to Push yourself when working with technical skill or handling alchemicals.

" + } + ], + "Ghost Ward": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ghost Ward@cat:after@type:ability@cTypes:Action@cTraits:wreck@val:0@status:Hidden@tooltip:

Ghost Ward

When you Wreck an area with arcane substances, ruining it for any other use, it becomes anathema or enticing to spirits (your choice).

" + } + ], + "Physicker": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Physicker@cat:roll@type:ability@cTypes:Downtime@aTypes:Engagement|Recover@status:Hidden@tooltip:

Physicker

You gain +1d to your healing treatment rolls.

" + } + ], + "Saboteur": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Saboteur@cat:after@type:ability@cTypes:Action|Downtime|LongTermProject@aTraits:wreck@val:0@status:Hidden@tooltip:

Saboteur

When you Wreck, your work is much quieter than it should be and the damage is very well-hidden from casual inspection.

" + } + ], + "Venomous": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Venomous@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@status:Hidden@tooltip:

Venomous

You can Push yourself to secrete your chosen drug or poison through your skin or saliva, or exhale it as a vapor.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Venomous@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@status:Hidden@tooltip:

Venomous

You can Push yourself to secrete your chosen drug or poison through your skin or saliva, or exhale it as a vapor.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Infiltrator": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Infiltrator@cat:effect@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:tinker|finesse|wreck|attune@val:0@eKey:Negate-QualityPenalty|Negate-TierPenalty@status:Hidden@tooltip:

Infiltrator

You are not affected by low Quality or Tier when you bypass security measures.

" + } + ], + "Ambush": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ambush@cat:roll@type:ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune@status:Hidden@tooltip:

Ambush

When you attack from hiding or spring a trap, you get +1d to your roll.

" + } + ], + "Daredevil": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Daredevil@cat:roll@type:ability@eKey:AutoRevealOn-Desperate|ForceOn-(Daredevil),after@status:ToggledOff@tooltip:

Daredevil

When you make a desperate action roll, you may gain +1d to your roll, if you also take −1d to resistance rolls against any consequences.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Daredevil@cat:roll@posNeg:negative@type:ability@cTypes:Resistance@status:Hidden@tooltip:

Daredevil

By choosing to gain +1d to your desperate action roll, you suffer −1d to resistance rolls against the consequences of that action.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:(Daredevil)@cat:after@posNeg:negative@type:ability@val:0@sourceName:Daredevil@status:Hidden@tooltip:

Daredevil

You will suffer −1d to resistance rolls against any consequences of this action roll.

" + } + ], + "The Devil's Footsteps": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Superhuman Feat@cat:roll@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Superhuman Feat@cat:effect@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Superhuman Feat

You can Push yourself to perform a feat of physical force that verges on the superhuman (you might climb a sheer surface that lacks good hand-holds, tumble safely out of a three-story fall, leap a shocking distance, etc.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Sow Confusion@cat:roll@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Sow Confusion

You can Push yourself to maneuver to confuse your enemies so they mistakenly attack each other. (They attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Sow Confusion@cat:effect@type:ability@val:0@eKey:Is-Push|ForceOn-Push@sourceName:The Devil's Footsteps@status:ToggledOff@tooltip:

The Devil's Footsteps — Sow Confusion

You can Push yourself to maneuver to confuse your enemies so they mistakenly attack each other. (They attack each other for a moment before they realize their mistake. The GM might make a fortune roll to see how badly they harm or interfere with each other.).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Shadow": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Shadow@cat:after@type:ability@cTypes:Action@cTraits:hunt|study|survey|tinker|finesse|prowl|skirmish|wreck@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Shadow

You may expend your special armor instead of paying 2 stress to Push yourself for a feat of athletics or stealth.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Shadow@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-HarmLevel@status:Hidden@tooltip:

Shadow

You may expend your special armor to completely negate a consequence of detection or security measures.

" + } + ], + "Rook's Gambit": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Rook's Gambit@cat:roll@type:ability@cTypes:Action|Downtime|AcquireAsset|LongTermProject@val:0@eKey:ForceOn-BestAction|Cost-Stress2@status:Hidden@tooltip:

Rook's Gambit

Take 2 stress to roll your best action rating while performing a different action.

(Describe how you adapt your skill to this use.)

" + } + ], + "Cloak & Dagger": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Cloak & Dagger@cat:roll@type:ability@cTypes:Action|Resistance@cTraits:finesse|prowl|attune|command|consort|sway|Insight@status:Hidden@tooltip:

Cloak & Dagger

When you use a disguise or other form of covert misdirection, you get +1d to rolls to confuse or deflect suspicion.

" + } + ], + "Ghost Voice": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ghost Voice@cat:effect@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:attune|command|consort|sway@val:0@eKey:ForceOn-Potency@status:Hidden@tooltip:

Ghost Voice

You gain Potency when communicating with the supernatural.

" + } + ], + "Mesmerism": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Mesmerism@cat:after@type:ability@cTypes:Action@cTraits:sway@val:0@status:Hidden@tooltip:

Mesmerism

When you Sway someone, you may cause them to forget that it's happened until they next interact with you.

" + } + ], + "Subterfuge": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Subterfuge@cat:roll@type:ability@cTypes:Resistance@cTraits:Insight@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Subterfuge

You may expend your special armor to completely negate a consequence of suspicion or persuasion.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Subterfuge@cat:after@type:ability@cTypes:Action@cTraits:finesse|attune|consort|sway@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Subterfuge

You may expend your special armor instead of paying 2 stress to Push yourself for subterfuge.

" + } + ], + "Trust in Me": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Trust in Me@cat:roll@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:hunt|study|survey|tinker|finesse|prowl|skirmish|wreck|attune|command|consort|sway|Insight|Prowess|Resolve|tier|quality|magnitude|number@status:Hidden@tooltip:

Trust in Me

You gain +1d to rolls opposed by a target with whom you have an intimate relationship.

" + } + ], + "Connected": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Connected@cat:result@type:ability@cTypes:Downtime@aTypes:AcquireAsset|ReduceHeat@status:Hidden@tooltip:

Connected

When you acquire an asset or reduce heat, you get +1 result level.

" + } + ], + "Jail Bird": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Jail Bird@cat:effect@type:ability@cTypes:Downtime@aTypes:Incarceration@eKey:Increase-Tier1@status:Hidden@tooltip:

Jail Bird

You gain +1 Tier while incarcerated.

" + } + ], + "Mastermind": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Mastermind@cat:after@type:ability@cTypes:Action|Downtime|LongTermProject@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Mastermind

You may expend your special armor instead of paying 2 stress to Push yourself when you gather information or work on a long-term project.

" + } + ], + "Weaving the Web": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Weaving the Web@cat:roll@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:consort@status:Hidden@tooltip:

Weaving the Web

You gain +1d to Consort when you gather information on a target for a score.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Weaving the Web@cat:roll@type:ability@cTypes:GatherInfo@status:Hidden@tooltip:

Weaving the Web

You gain +1d to the engagement roll for the targeted score.

" + } + ], + "Ghost Mind": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Ghost Mind@cat:roll@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:hunt|study|survey|tinker|prowl|attune|command|consort|sway@status:Hidden@tooltip:

Ghost Mind

You gain +1d to rolls to gather information about the supernatural by any means.

" + } + ], + "Iron Will": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Iron Will@cat:roll@type:ability@cTypes:Resistance@aTraits:Resolve@status:Hidden@tooltip:

Iron Will

You gain +1d to Resolve resistance rolls.

" + } + ], + "Occultist": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Occultist@cat:roll@type:ability@cTypes:Action|Downtime|LongTermProject@cTraits:command@status:Hidden@tooltip:

Occultist

You gain +1d to rolls to Command cultists following ancient powers, forgotten gods or demons with whom you have previously Consorted

" + } + ], + "Strange Methods": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Strange Methods@cat:result@type:ability@cTypes:Downtime|LongTermProject@status:Hidden@tooltip:

Strange Methods

When you invent or craft a creation with arcane features, you gain +1 result level to your roll.

" + } + ], + "Tempest": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Throw Lightning@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Throw Lightning

You can Push yourself to unleash a stroke of lightning as a weapon. The GM will describe its effect level and significant collateral damage.

If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Throw Lightning@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Throw Lightning

You can Push yourself to unleash a stroke of lightning as a weapon. The GM will describe its effect level and significant collateral damage.

If you unleash it in combat against an enemy who's threatening you, you'll still make an action roll in the fight (usually with Attune).

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Summon Storm@cat:roll@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Summon Storm

You can Push yourself to summon a storm in your immediate vicinity (torrential rain, roaring winds, heavy fog, chilling frost and snow, etc.). The GM will describe its effect level.

If you're using this power as cover or distraction, it's probably a Setup teamwork maneuver, using Attune.

You still gain +1d to your roll at the cost of 2 stress, as normal for a Push.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Summon Storm@cat:effect@type:ability@cTypes:Action@val:0@eKey:Is-Push|ForceOn-Push@sourceName:Tempest@status:Hidden@tooltip:

Tempest — Summon Storm

You can Push yourself to summon a storm in your immediate vicinity (torrential rain, roaring winds, heavy fog, chilling frost and snow, etc.). The GM will describe its effect level.

If you're using this power as cover or distraction, it's probably a Setup teamwork maneuver, using Attune.

You still gain +1 effect at the cost of 2 stress, as normal for a Push.

" + } + ], + "Warded": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Warded@cat:roll@type:ability@cTypes:Resistance@val:0@eKey:Cost-SpecialArmor|Negate-Consequence@status:Hidden@tooltip:

Warded

You may expend your special armor to completely negate a consequence of supernatural origin.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Warded@cat:after@type:ability@cTypes:Action@eKey:Negate-PushCost|Cost-SpecialArmor@status:Hidden@tooltip:

Warded

You may expend your special armor instead of paying 2 stress to Push yourself when you contend with or employ arcane forces.

" + } + ] + } + }, + CREW_ABILITIES: { + Descriptions: { + "Deadly": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Crow's Veil": "

The bells don't ring at the crematorium when a member of your crew kills someone. Do you have a 'membership ritual' now that conveys this talent?

", + "Emberdeath": "

This ability activates at the moment of the target's death (spend 3 stress then or lose the opportunity to use it). It can only be triggered by a killing blow. Some particularly powerful supernatural entities or specially protected targets may be resistant or immune to this ability.

", + "No Traces": "

There are many clients who value quiet operations. This ability rewards you for keeping a low profile.

", + "Patron": "

Who is your patron? Why do they help you?

", + "Predators": "

This ability applies when the goal is murder. It doesn't apply to other stealth or deception operations you attempt that happen to involve killing.

", + "Vipers": "

The poison immunity lasts for the entire score, until you next have downtime.

", + "Dangerous": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Blood Brothers": "

If you have the Elite Thugs upgrade, it stacks with this ability. So, if you had an Adepts gang cohort, and the Elite Thugs upgrade, and then took Blood Brothers, your Adepts would add the Thugs type and also get +1d to rolls when they did Thug-type actions.

This ability may result in a gang with three types, surpassing the normal limit of two.

", + "Door Kickers": "

This ability applies when the goal is to attack an enemy. It doesn't apply to other operations you attempt that happen to involve fighting.

", + "Fiends": "

The maximum wanted level is 4. Regardless of how much turf you hold (from this ability or otherwise) the minimum rep cost to advance your Tier is always 6.

", + "Forged In The Fire": "

This ability applies to PCs in the crew. It doesn't confer any special toughness to your cohorts.

", + "Chosen": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Bound in Darkness": "

By what occult means does your teamwork manifest over distance? How is it strange or disturbing? By what ritualistic method are cult members initiated into this ability?

", + "Conviction": "

What sort of sacrifice does your deity find pleasing?

", + "Silver Tongues": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Accord": "

If your status changes, you lose the turf until it becomes +3 again. Regardless of how much turf you hold (from this ability or otherwise) the minimum rep cost to advance your Tier is always 6.

", + "Ghost Market": "

They do not pay in coin. What do they pay with?

The GM will certainly have an idea about how your strange new clients pay, but jump in with your own ideas, too! This ability is usually a big shift in the game, so talk it out and come up with something that everyone is excited about. If it's a bit mysterious and uncertain, that's good. You have more to explore that way.

", + "The Good Stuff": "

The quality of your product might be used for a fortune roll to find out how impressed a potential client is, to find out how enthralled or incapacitated a user is in their indulgence of it, to discover if a strange variation has side-effects, etc.

", + "Everyone Steals": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

", + "Ghost Echoes": "

You might explore the echo of an ancient building, crumbled to dust in the real world, but still present in the ghost field; or discern the electroplasmic glow of treasures lost in the depths of the canals; or use a sorcerous ghost door from the pre-cataclysm to infiltrate an otherwise secure location; etc.

The GM will tell you what echoes persist nearby when you gather information about them. You might also undertake investigations to discover particular echoes you hope to find.

", + "Pack Rats": "

This ability might mean that you actually have the item you need in your pile of stuff, or it could mean you have extra odds and ends to barter with.

", + "Slippery": "

The GM might sometimes want to choose an entanglement instead of rolling. In that case, they'll choose two and you can pick between them.

", + "Synchronized": "

For example, Lyric leads a group action to Attune to the ghost field to overcome a magical ward on the Dimmer Sisters' door. Emily, Lyric's player, rolls and gets a 6, and so does Matt! Because the crew has Synchronized, their two separate 6s count as a critical success on the roll.

", + "Ghost Passage": "

What do you do to 'carry' a spirit? Must the spirit consent, or can you use this ability to trap an unwilling spirit within?

", + "Reavers": "

If your vehicle already has armor, this ability gives an additional armor box.

", + "Renegades": "

Each player may choose the action they prefer (you don't all have to choose the same one).

If you take this ability during initial character and crew creation, it supersedes the normal starting limit for action ratings.

" + }, + RollMods: { + "Predators": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Predators@cat:roll@type:crew_ability@cTypes:GatherInfo@status:Hidden@tooltip:

Predators

When you use a stealth or deception plan to commit murder, take +1d to the engagement roll.

" + } + ], + "Vipers": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Vipers (Crew Ability)@cat:result@type:crew_ability@cTypes:Downtime|AcquireAsset|LongTermProject@sourceName:Vipers@status:Hidden@tooltip:

Vipers (Crew Ability)

When you acquire or craft poisons, you get +1 result level to your roll.

" + } + ], + "Blood Brothers": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Blood Brothers (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck|attune|command@sourceName:Blood Brothers@status:Hidden@tooltip:

Blood Brothers (Crew Ability)

When fighting alongside crew members in combat, gain +1d for assist, setup and group teamwork actions.

" + } + ], + "Door Kickers": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Door Kickers@cat:roll@type:crew_ability@cTypes:GatherInfo@status:Hidden@tooltip:

Door Kickers

When you use an assault plan, take +1d to the engagement roll.

" + } + ], + "Anointed": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Anointed (Crew Ability)@cat:roll@type:crew_ability@cTypes:Resistance@sourceName:Anointed@status:Hidden@tooltip:

Anointed (Crew Ability)

Gain +1d to resistance rolls against supernatural threats.

" + }, + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Anointed (Crew Ability)@cat:roll@type:crew_ability@cTypes:Downtime|Engagement|Recover@sourceName:Anointed@status:Hidden@tooltip:

Anointed (Crew Ability)

Gain +1d to healing treatment rolls when you have supernatural harm.

" + } + ], + "Conviction": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Conviction (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action@sourceName:Conviction@status:Hidden@tooltip:

Conviction (Crew Ability)

You may call upon your deity to assist any one action roll you make.

You cannot use this ability again until you indulge your Worship vice.

" + } + ], + "Zealotry": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isCohort: true, + value: "name:Zealotry (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action|Downtime@sourceName:Zealotry@status:Hidden@tooltip:

Zealotry (Crew Ability)

Gain +1d when acting against enemies of the faith.

" + } + ], + "The Good Stuff": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:The Good Stuff (Crew Ability)@cat:effect@type:crew_ability@cTypes:Action|Downtime@val:0@eKey:Increase-Quality2@sourceName:The Good Stuff@status:Hidden@tooltip:

The Good Stuff (Crew Ability)

The quality of your product is equal to your Tier +2.

" + } + ], + "High Society": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:High Society (Crew Ability)@cat:roll@type:crew_ability@sourceName:High Society@status:Hidden@tooltip:

High Society (Crew Ability)

Gain +1d to gather information about the city's elite.

" + } + ], + "Pack Rats": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Pack Rats (Crew Ability)@cat:roll@type:crew_ability@aTypes:AcquireAsset@sourceName:Pack Rats@status:Hidden@tooltip:

Pack Rats (Crew Ability)

Gain +1d to acquire an asset.

" + } + ], + "Second Story": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + value: "name:Second Story@cat:roll@type:crew_ability@cTypes:GatherInfo@status:Hidden@tooltip:

Second Story

When you execute a clandestine infiltration plan, gain +1d to the engagement roll.

" + } + ], + "Slippery": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Slippery (Crew Ability)@cat:roll@type:crew_ability@cTypes:Downtime@aTypes:ReduceHeat@sourceName:Slippery@status:Hidden@tooltip:

Slippery (Crew Ability)

Gain +1d to reduce heat rolls.

" + } + ], + "Synchronized": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + isCohort: true, + value: "name:Synchronized (Crew Ability)@cat:after@type:crew_ability@cTypes:Action@sourceName:Synchronized@status:Hidden@tooltip:

Synchronized (Crew Ability)

When you perform a group teamwork action, you may count multiple 6s from different rolls as a critical success.

" + } + ], + "Just Passing Through": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Just Passing Through (Crew Ability)@cat:roll@type:crew_ability@cTypes:Action|Downtime@cTraits:finesse|prowl|consort|sway@sourceName:Just Passing Through@status:Hidden@tooltip:

Just Passing Through (Crew Ability)

When your heat is 4 or less, gain +1d to rolls to deceive people when you pass yourself off as ordinary citizens.

" + } + ], + "Reavers": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Reavers (Crew Ability)@cat:effect@type:crew_ability@cTypes:Action@cTraits:hunt|finesse|prowl|skirmish|wreck@sourceName:Reavers@status:Hidden@tooltip:

Reavers (Crew Ability)

When you go into conflict aboard a vehicle, gain +1 effect for vehicle damage and speed.

" + } + ] + } + }, + CREW_UPGRADES: { + Descriptions: {}, + RollMods: { + "Ironhook Contacts": [ + { + key: "system.roll_mods", + mode: 2, + priority: null, + isMember: true, + value: "name:Ironhook Contacts (Crew Upgrade)@cat:roll@type:crew_upgrade@cTypes:Downtime@aTypes:Incarceration@eKey:Increase-Tier1@sourceName:Ironhook Contacts@status:Hidden@tooltip:

Ironhook Contacts (Crew Upgrade)

Gain +1 Tier while in prison, including the incarceration roll.

" + } + ] + } + } }; const problemLog: string[] = []; @@ -2502,4 +3246,430 @@ export const updateFactions = async () => { updateFactionData(factionData); }); console.log(problemLog); +}; + +export const updateRollMods = async () => { + Object.entries(JSONDATA.ABILITIES.RollMods) + .forEach(async ([aName, eData]) => { + // Get ability doc + const abilityDoc = game.items.getName(aName); + if (!abilityDoc) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} Not Found.`); + return; + } + + // Get active effects on abilityDoc + const abilityEffects = Array.from(abilityDoc.effects ?? []) as BladesActiveEffect[]; + + // Separate out 'APPLYTOMEMBERS' and 'APPLYTOCOHORTS' ActiveEffects + const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); + const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); + const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); + + // Confirm eData.isMember and eData.isCohort are consistent across all changes. + const testChange = eData[0]; + if ( + (testChange.isMember && eData.some((change) => !change.isMember)) + || (!testChange.isMember && eData.some((change) => change.isMember)) + ) { eLog.error("updateRollMods", `updateRollMods: Ability ${aName} has inconsistent 'isMember' entries.`); return } + if ( + (testChange.isCohort && eData.some((change) => !change.isCohort)) + || (!testChange.isCohort && eData.some((change) => change.isCohort)) + ) { eLog.error("updateRollMods", `updateRollMods: Ability ${aName} has inconsistent 'isCohort' entries.`); return } + + // If eData.isMember or eData.isCohort, first see if there already is such an effect on the doc + if (testChange.isMember) { + if (toMemberEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: abilityDoc.img ?? "", + changes: eData.map((change) => { + delete change.isMember; + return change; + }) + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (toMemberEffects.length === 1) { + const abilityEffect = toMemberEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } else { + effectData.changes.unshift({ + key: "APPLYTOMEMBERS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Scoundrel Ability)` + }); + } + + // Create new ActiveEffect + await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } else if (testChange.isCohort) { + if (toCohortEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: abilityDoc.img ?? "", + changes: eData.map((change) => { + delete change.isCohort; + return change; + }) + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (toCohortEffects.length === 1) { + const abilityEffect = toCohortEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } else { + effectData.changes.unshift({ + key: "APPLYTOCOHORTS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Scoundrel Ability)` + }); + } + + // Create new ActiveEffect + await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } else { + if (standardEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Ability ${aName} Has Multiple Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: abilityDoc.img ?? "", + changes: eData + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (standardEffects.length === 1) { + const abilityEffect = standardEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + + // Create new ActiveEffect + await abilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + }); + Object.entries(JSONDATA.CREW_ABILITIES.RollMods) + .forEach(async ([aName, eData]) => { + // Get crew ability doc + const crewAbilityDoc = game.items.getName(aName); + if (!crewAbilityDoc) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} Not Found.`); + return; + } + + // Get active effects on crewAbilityDoc + const abilityEffects = Array.from(crewAbilityDoc.effects ?? []) as BladesActiveEffect[]; + + // Separate out 'APPLYTOMEMBERS' and 'APPLYTOCOHORTS' ActiveEffects + const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); + const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); + const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); + + // Confirm eData.isMember and eData.isCohort are consistent across all changes. + const testChange = eData[0]; + if ( + (testChange.isMember && eData.some((change) => !change.isMember)) + || (!testChange.isMember && eData.some((change) => change.isMember)) + ) { eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} has inconsistent 'isMember' entries.`); return } + if ( + (testChange.isCohort && eData.some((change) => !change.isCohort)) + || (!testChange.isCohort && eData.some((change) => change.isCohort)) + ) { eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} has inconsistent 'isCohort' entries.`); return } + + // If eData.isMember or eData.isCohort, first see if there already is such an effect on the doc + if (testChange.isMember) { + if (toMemberEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: crewAbilityDoc.img ?? "", + changes: eData.map((change) => { + delete change.isMember; + return change; + }) + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (toMemberEffects.length === 1) { + const abilityEffect = toMemberEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } else { + effectData.changes.unshift({ + key: "APPLYTOMEMBERS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Ability)` + }); + } + + // Create new ActiveEffect + await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } else if (testChange.isCohort) { + if (toCohortEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: crewAbilityDoc.img ?? "", + changes: eData.map((change) => { + delete change.isCohort; + return change; + }) + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (toCohortEffects.length === 1) { + const abilityEffect = toCohortEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } else { + effectData.changes.unshift({ + key: "APPLYTOCOHORTS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Ability)` + }); + } + + // Create new ActiveEffect + await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } else { + if (standardEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Ability ${aName} Has Multiple Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: crewAbilityDoc.img ?? "", + changes: eData + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (standardEffects.length === 1) { + const abilityEffect = standardEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + + // Create new ActiveEffect + await crewAbilityDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + }); + Object.entries(JSONDATA.CREW_UPGRADES.RollMods) + .forEach(async ([aName, eData]) => { + // Get crew upgrade doc + const crewUpgradeDoc = game.items.getName(aName); + if (!crewUpgradeDoc) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} Not Found.`); + return; + } + + // Get active effects on crewAbilityDoc + const abilityEffects = Array.from(crewUpgradeDoc.effects ?? []) as BladesActiveEffect[]; + + // Separate out 'APPLYTOMEMBERS' and 'APPLYTOCOHORTS' ActiveEffects + const toMemberEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOMEMBERS")); + const toCohortEffects = abilityEffects.filter((effect) => effect.changes.some((change) => change.key === "APPLYTOCOHORTS")); + const standardEffects = abilityEffects.filter((effect) => effect.changes.every((change) => !["APPLYTOMEMBERS", "APPLYTOCOHORTS"].includes(change.key))); + + // Confirm eData.isMember and eData.isCohort are consistent across all changes. + const testChange = eData[0]; + if ( + (testChange.isMember && eData.some((change) => !change.isMember)) + || (!testChange.isMember && eData.some((change) => change.isMember)) + ) { eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} has inconsistent 'isMember' entries.`); return } + if ( + (testChange.isCohort && eData.some((change) => !change.isCohort)) + || (!testChange.isCohort && eData.some((change) => change.isCohort)) + ) { eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} has inconsistent 'isCohort' entries.`); return } + + // If eData.isMember or eData.isCohort, first see if there already is such an effect on the doc + if (testChange.isMember) { + if (toMemberEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} Has Multiple 'APPLYTOMEMBERS' Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: crewUpgradeDoc.img ?? "", + changes: eData.map((change) => { + delete change.isMember; + return change; + }) + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (toMemberEffects.length === 1) { + const abilityEffect = toMemberEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } else { + effectData.changes.unshift({ + key: "APPLYTOMEMBERS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Upgrade)` + }); + } + + // Create new ActiveEffect + await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } else if (testChange.isCohort) { + if (toCohortEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} Has Multiple 'APPLYTOCOHORTS' Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: crewUpgradeDoc.img ?? "", + changes: eData.map((change) => { + delete change.isCohort; + return change; + }) + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (toCohortEffects.length === 1) { + const abilityEffect = toCohortEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } else { + effectData.changes.unshift({ + key: "APPLYTOCOHORTS", + mode: 0, + priority: null, + value: `${aName.replace(/\s*\([^()]*? (Ability|Upgrade)\)\s*$/, "")} (Crew Upgrade)` + }); + } + + // Create new ActiveEffect + await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } else { + if (standardEffects.length > 1) { + eLog.error("updateRollMods", `updateRollMods: Crew Upgrade ${aName} Has Multiple Active Effects`); + return; + } + + // Initialize new effect data + const effectData: { + name: string, + icon: string, + changes: Array> + } = { + name: aName, + icon: crewUpgradeDoc.img ?? "", + changes: eData + }; + + // Derive new effect data from existing effect, if any, then delete existing effect + if (standardEffects.length === 1) { + const abilityEffect = standardEffects[0] as BladesActiveEffect; + effectData.name = abilityEffect.name ?? effectData.name; + effectData.icon = abilityEffect.icon ?? effectData.icon; + effectData.changes.unshift(...abilityEffect.changes.filter((change) => change.key !== "system.roll_mods")); + await abilityEffect.delete(); + } + + // Create new ActiveEffect + await crewUpgradeDoc.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + }); +}; + +export const updateDescriptions = async () => { + Object.entries({ + ...JSONDATA.ABILITIES.Descriptions, + ...JSONDATA.CREW_ABILITIES.Descriptions, + ...JSONDATA.CREW_UPGRADES.Descriptions + }) + .forEach(async ([aName, desc]) => { + const itemDoc = game.items.getName(aName); + if (!itemDoc) { + eLog.error("applyRollEffects", `ApplyDescriptions: Item Doc ${aName} Not Found.`); + return; + } + + // Update system.notes + itemDoc.update({"system.notes": desc}); + }); }; \ No newline at end of file diff --git a/ts/documents/blades-actor-proxy.ts b/ts/documents/BladesActorProxy.ts similarity index 88% rename from ts/documents/blades-actor-proxy.ts rename to ts/documents/BladesActorProxy.ts index 1d77a3c2..09de6138 100644 --- a/ts/documents/blades-actor-proxy.ts +++ b/ts/documents/BladesActorProxy.ts @@ -1,11 +1,11 @@ import U from "../core/utilities.js"; import {BladesActorType} from "../core/constants.js"; import type {ActorDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/actorData.js"; -import BladesActor from "../blades-actor.js"; -import BladesPC from "./actors/blades-pc.js"; -import BladesNPC from "./actors/blades-npc.js"; -import BladesFaction from "./actors/blades-faction.js"; -import BladesCrew from "./actors/blades-crew.js"; +import BladesActor from "../BladesActor.js"; +import BladesPC from "./actors/BladesPC.js"; +import BladesNPC from "./actors/BladesNPC.js"; +import BladesFaction from "./actors/BladesFaction.js"; +import BladesCrew from "./actors/BladesCrew.js"; const ActorsMap: Partial> = { [BladesActorType.pc]: BladesPC, diff --git a/ts/documents/blades-item-proxy.ts b/ts/documents/BladesItemProxy.ts similarity index 87% rename from ts/documents/blades-item-proxy.ts rename to ts/documents/BladesItemProxy.ts index 55898d6e..5cb090ac 100644 --- a/ts/documents/blades-item-proxy.ts +++ b/ts/documents/BladesItemProxy.ts @@ -1,11 +1,11 @@ import U from "../core/utilities.js"; import {BladesItemType} from "../core/constants.js"; import type {ItemDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/itemData.js"; -import BladesItem from "../blades-item.js"; -import BladesLocation from "./items/blades-location.js"; -import BladesClockKeeper from "./items/blades-clock-keeper.js"; -import BladesGMTracker from "./items/blades-gm-tracker.js"; -import BladesScore from "./items/blades-score.js"; +import BladesItem from "../BladesItem.js"; +import BladesLocation from "./items/BladesLocation.js"; +import BladesClockKeeper from "./items/BladesClockKeeper.js"; +import BladesGMTracker from "./items/BladesGMTracker.js"; +import BladesScore from "./items/BladesScore.js"; const ItemsMap: Partial> = { [BladesItemType.clock_keeper]: BladesClockKeeper, diff --git a/ts/documents/actors/blades-crew.ts b/ts/documents/actors/BladesCrew.ts similarity index 95% rename from ts/documents/actors/blades-crew.ts rename to ts/documents/actors/BladesCrew.ts index bde89977..acd4807e 100644 --- a/ts/documents/actors/blades-crew.ts +++ b/ts/documents/actors/BladesCrew.ts @@ -1,7 +1,7 @@ -import BladesItem from "../../blades-item.js"; +import BladesItem from "../../BladesItem.js"; import {BladesActorType, Playbook, BladesItemType, Factor} from "../../core/constants.js"; -import BladesActor from "../../blades-actor.js"; -import BladesRollCollab, {BladesRollMod} from "../../blades-roll-collab.js"; +import BladesActor from "../../BladesActor.js"; +import BladesRollCollab, {BladesRollMod} from "../../BladesRollCollab.js"; import type {ActorDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/actorData.js"; class BladesCrew extends BladesActor implements BladesActorSubClass.Crew, diff --git a/ts/documents/actors/blades-faction.ts b/ts/documents/actors/BladesFaction.ts similarity index 97% rename from ts/documents/actors/blades-faction.ts rename to ts/documents/actors/BladesFaction.ts index 93c0c725..99706ee9 100644 --- a/ts/documents/actors/blades-faction.ts +++ b/ts/documents/actors/BladesFaction.ts @@ -1,5 +1,5 @@ import {BladesActorType, Factor} from "../../core/constants.js"; -import BladesActor from "../../blades-actor.js"; +import BladesActor from "../../BladesActor.js"; class BladesFaction extends BladesActor implements BladesActorSubClass.Faction, BladesRollCollab.OppositionDocData { diff --git a/ts/documents/actors/blades-npc.ts b/ts/documents/actors/BladesNPC.ts similarity index 96% rename from ts/documents/actors/blades-npc.ts rename to ts/documents/actors/BladesNPC.ts index a715140c..3f3221d0 100644 --- a/ts/documents/actors/blades-npc.ts +++ b/ts/documents/actors/BladesNPC.ts @@ -1,6 +1,6 @@ import {BladesActorType, Factor} from "../../core/constants.js"; -import BladesActor from "../../blades-actor.js"; -import BladesRollCollab from "../../blades-roll-collab.js"; +import BladesActor from "../../BladesActor.js"; +import BladesRollCollab from "../../BladesRollCollab.js"; class BladesNPC extends BladesActor implements BladesActorSubClass.NPC, BladesRollCollab.OppositionDocData, diff --git a/ts/documents/actors/blades-pc.ts b/ts/documents/actors/BladesPC.ts similarity index 94% rename from ts/documents/actors/blades-pc.ts rename to ts/documents/actors/BladesPC.ts index 2d1c87f8..cd2a83ac 100644 --- a/ts/documents/actors/blades-pc.ts +++ b/ts/documents/actors/BladesPC.ts @@ -1,9 +1,9 @@ -import BladesItem from "../../blades-item.js"; -import C, {Playbook, Attribute, Action, Harm, BladesActorType, BladesItemType, Tag, RollModCategory, Factor, RollModStatus} from "../../core/constants.js"; +import BladesItem from "../../BladesItem.js"; +import C, {Playbook, AttributeTrait, ActionTrait, Harm, BladesActorType, BladesItemType, Tag, RollModCategory, Factor, RollModStatus} from "../../core/constants.js"; import U from "../../core/utilities.js"; -import BladesActor from "../../blades-actor.js"; -import BladesCrew from "./blades-crew.js"; -import BladesRollCollab, {BladesRollMod} from "../../blades-roll-collab.js"; +import BladesActor from "../../BladesActor.js"; +import BladesCrew from "./BladesCrew.js"; +import BladesRollCollab, {BladesRollMod} from "../../BladesRollCollab.js"; import type {ActorDataConstructorData} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/actorData.js"; @@ -121,7 +121,7 @@ class BladesPC extends BladesActor implements BladesActorSubClass.Scoundrel, return this.activeSubItems.find((item): item is BladesItemOfType => item.type === BladesItemType.playbook); } - get attributes(): Record { + get attributes(): Record { if (!BladesActor.IsType(this, BladesActorType.pc)) { return undefined as never } return { insight: Object.values(this.system.attributes.insight).filter(({value}) => value > 0).length + this.system.resistance_bonus.insight, @@ -129,15 +129,15 @@ class BladesPC extends BladesActor implements BladesActorSubClass.Scoundrel, resolve: Object.values(this.system.attributes.resolve).filter(({value}) => value > 0).length + this.system.resistance_bonus.resolve }; } - get actions(): Record { + get actions(): Record { if (!BladesActor.IsType(this, BladesActorType.pc)) { return undefined as never } return U.objMap({ ...this.system.attributes.insight, ...this.system.attributes.prowess, ...this.system.attributes.resolve - }, ({value, max}: ValueMax) => U.gsap.utils.clamp(0, max, value)) as Record; + }, ({value, max}: ValueMax) => U.gsap.utils.clamp(0, max, value)) as Record; } - get rollable(): Record { + get rollable(): Record { if (!BladesActor.IsType(this, BladesActorType.pc)) { return undefined as never } return { ...this.attributes, @@ -286,7 +286,7 @@ class BladesPC extends BladesActor implements BladesActorSubClass.Scoundrel, get rollTraitPCTooltipActions(): string { const tooltipStrings: string[] = [""]; const actionRatings = this.actions; - Object.values(Attribute).forEach((attribute) => { + Object.values(AttributeTrait).forEach((attribute) => { C.Action[attribute].forEach((action) => { tooltipStrings.push([ "", @@ -304,7 +304,7 @@ class BladesPC extends BladesActor implements BladesActorSubClass.Scoundrel, get rollTraitPCTooltipAttributes(): string { const tooltipStrings: string[] = ["
"]; const attributeRatings = this.attributes; - Object.values(Attribute).forEach((attribute) => { + Object.values(AttributeTrait).forEach((attribute) => { tooltipStrings.push([ "", ``, diff --git a/ts/documents/items/blades-clock-keeper.ts b/ts/documents/items/BladesClockKeeper.ts similarity index 89% rename from ts/documents/items/blades-clock-keeper.ts rename to ts/documents/items/BladesClockKeeper.ts index 91a4e7f4..9fe6d251 100644 --- a/ts/documents/items/blades-clock-keeper.ts +++ b/ts/documents/items/BladesClockKeeper.ts @@ -1,7 +1,10 @@ -import BladesItem from "../../blades-item.js"; +import BladesItem from "../../BladesItem.js"; import C, {SVGDATA, BladesActorType, BladesItemType} from "../../core/constants.js"; import U from "../../core/utilities.js"; -import BladesActor from "../../blades-actor.js"; +import BladesActor from "../../BladesActor.js"; +import type {PropertiesToSource} from "@league-of-foundry-developers/foundry-vtt-types/src/types/helperTypes.js"; +import type {ItemDataBaseProperties} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/itemData.js"; +import type {DocumentModificationOptions} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/document.mjs.js"; class BladesClockKeeper extends BladesItem implements BladesItemSubClass.Clock_Keeper { @@ -115,7 +118,7 @@ class BladesClockKeeper extends BladesItem implements BladesItemSubClass.Clock_K async setKeySize(keyID: string, keySize = 1): Promise|undefined> { if (!BladesItem.IsType(game.eunoblades.ClockKeeper, BladesItemType.clock_keeper)) { return undefined } keySize = parseInt(`${keySize}`, 10); - const updateData: Record = { + const updateData: Record = { [`system.clock_keys.${keyID}.numClocks`]: keySize }; const clockKey = game.eunoblades.ClockKeeper.system.clock_keys[keyID]; @@ -160,8 +163,12 @@ class BladesClockKeeper extends BladesItem implements BladesItemSubClass.Clock_K })); } - override async _onUpdate(changed: any, options: any, userId: string) { - await super._onUpdate(changed, options, userId); + override async _onUpdate( + changed: DeepPartial>, + options: DocumentModificationOptions, + userId: string + ) { + super._onUpdate(changed, options, userId); BladesActor.GetTypeWithTags(BladesActorType.pc).forEach((actor) => actor.render()); socketlib.system.executeForEveryone("renderOverlay"); } diff --git a/ts/documents/items/blades-gm-tracker.ts b/ts/documents/items/BladesGMTracker.ts similarity index 91% rename from ts/documents/items/blades-gm-tracker.ts rename to ts/documents/items/BladesGMTracker.ts index 5b7797d1..f8e5df0a 100644 --- a/ts/documents/items/blades-gm-tracker.ts +++ b/ts/documents/items/BladesGMTracker.ts @@ -1,6 +1,6 @@ -import BladesItem from "../../blades-item.js"; +import BladesItem from "../../BladesItem.js"; import {BladesActorType, BladesItemType, BladesPhase} from "../../core/constants.js"; -import BladesActor from "../../blades-actor.js"; +import BladesActor from "../../BladesActor.js"; class BladesGMTracker extends BladesItem implements BladesItemSubClass.Gm_Tracker { diff --git a/ts/documents/items/blades-location.ts b/ts/documents/items/BladesLocation.ts similarity index 92% rename from ts/documents/items/blades-location.ts rename to ts/documents/items/BladesLocation.ts index e74ff590..3955c850 100644 --- a/ts/documents/items/blades-location.ts +++ b/ts/documents/items/BladesLocation.ts @@ -1,8 +1,8 @@ -import BladesItem from "../../blades-item.js"; +import BladesItem from "../../BladesItem.js"; import {BladesActorType, BladesItemType, Factor} from "../../core/constants.js"; import U from "../../core/utilities.js"; -import BladesActor from "../../blades-actor.js"; -import BladesRollCollab from "../../blades-roll-collab.js"; +import BladesActor from "../../BladesActor.js"; +import BladesRollCollab from "../../BladesRollCollab.js"; class BladesLocation extends BladesItem implements BladesItemSubClass.Location, BladesRollCollab.OppositionDocData { diff --git a/ts/documents/items/blades-score.ts b/ts/documents/items/BladesScore.ts similarity index 72% rename from ts/documents/items/blades-score.ts rename to ts/documents/items/BladesScore.ts index 3e53ed2a..ef318903 100644 --- a/ts/documents/items/blades-score.ts +++ b/ts/documents/items/BladesScore.ts @@ -1,9 +1,13 @@ -import BladesItem from "../../blades-item.js"; +import BladesItem from "../../BladesItem.js"; import {BladesActorType, BladesItemType, Factor} from "../../core/constants.js"; import U from "../../core/utilities.js"; -import BladesActor from "../../blades-actor.js"; -import BladesRollCollab from "../../blades-roll-collab.js"; -import BladesScoreSheet from "../../sheets/item/blades-score-sheet.js"; +import BladesActor from "../../BladesActor.js"; +import BladesRollCollab from "../../BladesRollCollab.js"; +import BladesScoreSheet from "../../sheets/item/BladesScoreSheet.js"; + +import type {PropertiesToSource} from "@league-of-foundry-developers/foundry-vtt-types/src/types/helperTypes.js"; +import type {ItemDataBaseProperties} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/itemData.js"; +import type {DocumentModificationOptions} from "@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/document.mjs.js"; class BladesScore extends BladesItem implements BladesItemSubClass.Score, @@ -57,15 +61,18 @@ class BladesScore extends BladesItem implements BladesItemSubClass.Score, case Factor.quality: return this.getFactorTotal(Factor.tier); case Factor.scale: return 0; case Factor.magnitude: return 0; - // no default + default: return 0 as never; } - return 0 as never; } // #endregion // #region OVERRIDES: _onUpdate - override async _onUpdate(changed: any, options: any, userId: string) { - await super._onUpdate(changed, options, userId); + override async _onUpdate( + changed: DeepPartial>, + options: DocumentModificationOptions, + userId: string + ) { + super._onUpdate(changed, options, userId); BladesActor.GetTypeWithTags(BladesActorType.pc).forEach((actor) => actor.render()); } // #endregion diff --git a/ts/sheets/actor/blades-sheet.ts b/ts/sheets/actor/BladesActorSheet.ts similarity index 94% rename from ts/sheets/actor/blades-sheet.ts rename to ts/sheets/actor/BladesActorSheet.ts index 7193f22e..0613b682 100644 --- a/ts/sheets/actor/blades-sheet.ts +++ b/ts/sheets/actor/BladesActorSheet.ts @@ -2,13 +2,13 @@ import U from "../../core/utilities.js"; import G, {ApplyTooltipListeners} from "../../core/gsap.js"; -import C, {BladesActorType, BladesItemType, Attribute, Tag, Action, Factor, RollType} from "../../core/constants.js"; +import C, {BladesActorType, BladesItemType, AttributeTrait, Tag, ActionTrait, Factor, RollType} from "../../core/constants.js"; import Tags from "../../core/tags.js"; -import BladesActor from "../../blades-actor.js"; -import BladesItem from "../../blades-item.js"; -import BladesSelectorDialog, {SelectionCategory} from "../../blades-dialog.js"; -import BladesActiveEffect from "../../blades-active-effect.js"; -import BladesRollCollab, {BladesRollCollabComps} from "../../blades-roll-collab.js"; +import BladesActor from "../../BladesActor.js"; +import BladesItem from "../../BladesItem.js"; +import BladesSelectorDialog, {SelectionCategory} from "../../BladesDialog.js"; +import BladesActiveEffect from "../../BladesActiveEffect.js"; +import BladesRollCollab, {BladesRollCollabComps} from "../../BladesRollCollab.js"; // #endregion // #region TYPES: BladesCompData ~ type BladesCompData = { @@ -24,7 +24,7 @@ type BladesCompData = { // #endregion -class BladesSheet extends ActorSheet { +class BladesActorSheet extends ActorSheet { /** * Override the default getData method to provide additional data for the actor sheet. @@ -37,7 +37,7 @@ class BladesSheet extends ActorSheet { const context = super.getData(); // Prepare additional data specific to this actor's sheet. - const sheetData: DeepPartial & BladesActorDataOfType & BladesActorDataOfType @@ -194,7 +194,7 @@ class BladesSheet extends ActorSheet { html.find(".dotline").each((__, elem) => { if ($(elem).hasClass("locked")) { return } - let targetDoc: BladesActor | BladesItem = this.actor as BladesActor; + let targetDoc: BladesActor | BladesItem = this.actor; let targetField = $(elem).data("target"); const comp$ = $(elem).closest("comp"); @@ -448,8 +448,8 @@ class BladesSheet extends ActorSheet { const traitName = $(event.currentTarget).data("rollTrait"); const rollType = $(event.currentTarget).data("rollType"); const rollData: Partial = {}; - if (U.lCase(traitName) in {...Action, ...Attribute, ...Factor}) { - rollData.rollTrait = U.lCase(traitName) as Action|Attribute|Factor; + if (U.lCase(traitName) in {...ActionTrait, ...AttributeTrait, ...Factor}) { + rollData.rollTrait = U.lCase(traitName) as ActionTrait|AttributeTrait|Factor; } else if (U.isInt(traitName)) { rollData.rollTrait = U.pInt(traitName); } @@ -457,18 +457,18 @@ class BladesSheet extends ActorSheet { if (U.tCase(rollType) in RollType) { rollData.rollType = U.tCase(rollType) as RollType; } else if (typeof rollData.rollTrait === "string") { - if (rollData.rollTrait in Attribute) { + if (rollData.rollTrait in AttributeTrait) { rollData.rollType = RollType.Resistance; - } else if (rollData.rollTrait in Action) { + } else if (rollData.rollTrait in ActionTrait) { rollData.rollType = RollType.Action; } } if (game.user.isGM) { if (BladesRollCollabComps.Primary.IsDoc(this.actor)) { - rollData.rollPrimary = this.actor; + rollData.rollPrimary = {rollPrimaryDoc: this.actor}; } else if (BladesRollCollabComps.Opposition.IsDoc(this.actor)) { - rollData.rollOpposition = this.actor; + rollData.rollOpp = {rollOppDoc: this.actor}; } } @@ -489,8 +489,8 @@ class BladesSheet extends ActorSheet { // #endregion } -interface BladesSheet { +interface BladesActorSheet { get actor(): BladesActor; } -export default BladesSheet; +export default BladesActorSheet; diff --git a/ts/sheets/actor/blades-crew-sheet.ts b/ts/sheets/actor/BladesCrewSheet.ts similarity index 97% rename from ts/sheets/actor/blades-crew-sheet.ts rename to ts/sheets/actor/BladesCrewSheet.ts index be49ca2b..dd759002 100644 --- a/ts/sheets/actor/blades-crew-sheet.ts +++ b/ts/sheets/actor/BladesCrewSheet.ts @@ -1,8 +1,8 @@ -import BladesSheet from "./blades-sheet.js"; -import {BladesCrew} from "../../documents/blades-actor-proxy.js"; +import BladesActorSheet from "./BladesActorSheet.js"; +import {BladesCrew} from "../../documents/BladesActorProxy.js"; import {BladesActorType, BladesItemType} from "../../core/constants.js"; -class BladesCrewSheet extends BladesSheet { +class BladesCrewSheet extends BladesActorSheet { declare actor: BladesActorOfType; diff --git a/ts/sheets/actor/blades-faction-sheet.ts b/ts/sheets/actor/BladesFactionSheet.ts similarity index 91% rename from ts/sheets/actor/blades-faction-sheet.ts rename to ts/sheets/actor/BladesFactionSheet.ts index ffdf203d..3c2864cf 100644 --- a/ts/sheets/actor/blades-faction-sheet.ts +++ b/ts/sheets/actor/BladesFactionSheet.ts @@ -1,10 +1,10 @@ -import BladesActor from "../../blades-actor.js"; -import BladesFaction from "../../documents/actors/blades-faction.js"; -import BladesSheet from "./blades-sheet.js"; +import BladesActor from "../../BladesActor.js"; +import BladesFaction from "../../documents/actors/BladesFaction.js"; +import BladesActorSheet from "./BladesActorSheet.js"; import {BladesActorType} from "../../core/constants.js"; -class BladesFactionSheet extends BladesSheet { +class BladesFactionSheet extends BladesActorSheet { static override get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { diff --git a/ts/sheets/actor/blades-npc-sheet.ts b/ts/sheets/actor/BladesNPCSheet.ts similarity index 94% rename from ts/sheets/actor/blades-npc-sheet.ts rename to ts/sheets/actor/BladesNPCSheet.ts index 85fb7150..18366b98 100644 --- a/ts/sheets/actor/blades-npc-sheet.ts +++ b/ts/sheets/actor/BladesNPCSheet.ts @@ -1,7 +1,7 @@ -import BladesSheet from "./blades-sheet.js"; +import BladesActorSheet from "./BladesActorSheet.js"; import U from "../../core/utilities.js"; -class BladesNPCSheet extends BladesSheet { +class BladesNPCSheet extends BladesActorSheet { static override get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { @@ -15,7 +15,7 @@ class BladesNPCSheet extends BladesSheet { } override getData() { - const context = super.getData() as ReturnType & { + const context = super.getData() as ReturnType & { [key: string]: any }; diff --git a/ts/sheets/actor/blades-pc-sheet.ts b/ts/sheets/actor/BladesPCSheet.ts similarity index 92% rename from ts/sheets/actor/blades-pc-sheet.ts rename to ts/sheets/actor/BladesPCSheet.ts index 658d0fd6..8bdd02b7 100644 --- a/ts/sheets/actor/blades-pc-sheet.ts +++ b/ts/sheets/actor/BladesPCSheet.ts @@ -1,11 +1,11 @@ -import C, {BladesActorType, BladesItemType, Attribute, Tag, Action, BladesPhase} from "../../core/constants.js"; +import C, {BladesActorType, BladesItemType, AttributeTrait, Tag, ActionTrait, BladesPhase} from "../../core/constants.js"; import U from "../../core/utilities.js"; -import BladesSheet from "./blades-sheet.js"; -import {BladesActor, BladesPC} from "../../documents/blades-actor-proxy.js"; -import BladesTrackerSheet from "../item/blades-tracker-sheet.js"; +import BladesActorSheet from "./BladesActorSheet.js"; +import {BladesActor, BladesPC} from "../../documents/BladesActorProxy.js"; +import BladesGMTrackerSheet from "../item/BladesGMTrackerSheet.js"; -class BladesPCSheet extends BladesSheet { +class BladesPCSheet extends BladesActorSheet { static override get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { @@ -41,7 +41,7 @@ class BladesPCSheet extends BladesSheet { override getData() { const context = super.getData(); - const {activeSubItems, activeSubActors} = this.actor as BladesPC; + const {activeSubItems, activeSubActors} = this.actor; const sheetData: Partial> = {}; @@ -202,7 +202,7 @@ class BladesPCSheet extends BladesSheet { sheetData.loadData = { curLoad: this.actor.currentLoad, - selLoadCount: this.actor.system.loadout.levels[U.lCase(game.i18n.localize(this.actor.system.loadout.selected.toString())) as "heavy"|"normal"|"light"|"encumbered"], + selLoadCount: this.actor.system.loadout.levels[U.lCase(game.i18n.localize(this.actor.system.loadout.selected.toString())) as Loadout], selections: C.Loadout.selections, selLoadLevel: this.actor.system.loadout.selected.toString() }; @@ -211,22 +211,22 @@ class BladesPCSheet extends BladesSheet { .filter(([, isActive]) => isActive) .map(([armor]) => [armor, this.actor.system.armor.checked[armor as KeyOf]])); - sheetData.attributeData = {} as Record + actions: Record }>; - const attrEntries = Object.entries(this.actor.system.attributes) as Array<[Attribute, Record]>; + const attrEntries = Object.entries(this.actor.system.attributes) as Array<[AttributeTrait, Record]>; for (const [attribute, attrData] of attrEntries) { sheetData.attributeData[attribute] = { tooltip: C.AttributeTooltips[attribute], - actions: {} as Record + actions: {} as Record }; - const actionEntries = Object.entries(attrData) as Array<[Action, ValueMax]>; + const actionEntries = Object.entries(attrData) as Array<[ActionTrait, ValueMax]>; for (const [action, actionData] of actionEntries) { sheetData.attributeData[attribute].actions[action] = { tooltip: C.ActionTooltips[action], value: actionData.value, - max: BladesTrackerSheet.Get().phase === BladesPhase.CharGen ? 2 : this.actor.system.attributes[attribute][action].max + max: BladesGMTrackerSheet.Get().phase === BladesPhase.CharGen ? 2 : this.actor.system.attributes[attribute][action].max }; } } @@ -289,7 +289,7 @@ class BladesPCSheet extends BladesSheet { event.preventDefault(); super._onAdvanceClick(event); const action = $(event.currentTarget).data("action").replace(/^advance-/, ""); - if (action in Attribute) { + if (action in AttributeTrait) { this.actor.advanceAttribute(action); } } @@ -305,42 +305,42 @@ class BladesPCSheet extends BladesSheet { //~ Armor Control html.find(".main-armor-control").on({ - click: function() { + click() { const targetArmor = self._getClickArmor(); if (!targetArmor) { return } self.actor.update({[`system.armor.checked.${targetArmor}`]: true}); }, - contextmenu: function() { + contextmenu() { const targetArmor = self._getContextMenuArmor(); if (!targetArmor) { return } self.actor.update({[`system.armor.checked.${targetArmor}`]: false}); }, - mouseenter: function() { + mouseenter() { const targetArmor = self._getHoverArmor(); eLog.log4("Mouse Enter", targetArmor, this, $(this), $(this).next()); if (!targetArmor) { return } $(this).siblings(`.svg-armor.armor-${targetArmor}`).addClass("hover-over"); }, - mouseleave: function() { + mouseleave() { const targetArmor = self._getHoverArmor(); if (!targetArmor) { return } $(this).siblings(`.svg-armor.armor-${targetArmor}`).removeClass("hover-over"); } }); html.find(".special-armor-control").on({ - click: function() { + click() { if (!self.activeArmor.includes("special")) { return } self.actor.update({["system.armor.checked.special"]: self.uncheckedArmor.includes("special")}); }, - contextmenu: function() { + contextmenu() { if (!self.activeArmor.includes("special")) { return } self.actor.update({["system.armor.checked.special"]: self.uncheckedArmor.includes("special")}); }, - mouseenter: function() { + mouseenter() { if (!self.activeArmor.includes("special") || self.activeArmor.length === 1) { return } $(this).siblings(".svg-armor.armor-special").addClass("hover-over"); }, - mouseleave: function() { + mouseleave() { if (!self.activeArmor.includes("special") || self.activeArmor.length === 1) { return } $(this).siblings(".svg-armor.armor-special").removeClass("hover-over"); } diff --git a/ts/sheets/item/blades-clock-keeper-sheet.ts b/ts/sheets/item/BladesClockKeeperSheet.ts similarity index 96% rename from ts/sheets/item/blades-clock-keeper-sheet.ts rename to ts/sheets/item/BladesClockKeeperSheet.ts index 24c45abc..737d4044 100644 --- a/ts/sheets/item/blades-clock-keeper-sheet.ts +++ b/ts/sheets/item/BladesClockKeeperSheet.ts @@ -1,6 +1,6 @@ -import BladesItemSheet from "./blades-item-sheet.js"; -import BladesClockKeeper from "../../documents/items/blades-clock-keeper.js"; +import BladesItemSheet from "./BladesItemSheet.js"; +import BladesClockKeeper from "../../documents/items/BladesClockKeeper.js"; type BladesClockKeeperSheetData = Partial & { clock_keys: Record diff --git a/ts/sheets/item/blades-tracker-sheet.ts b/ts/sheets/item/BladesGMTrackerSheet.ts similarity index 87% rename from ts/sheets/item/blades-tracker-sheet.ts rename to ts/sheets/item/BladesGMTrackerSheet.ts index 4c90d583..5444740d 100644 --- a/ts/sheets/item/blades-tracker-sheet.ts +++ b/ts/sheets/item/BladesGMTrackerSheet.ts @@ -1,10 +1,10 @@ import {BladesActorType, BladesItemType, BladesPhase} from "../../core/constants.js"; -import BladesItemSheet from "./blades-item-sheet.js"; -import BladesItem from "../../blades-item.js"; -import BladesGMTracker from "../../documents/items/blades-gm-tracker.js"; -import BladesActor from "../../blades-actor.js"; -import BladesPC from "../../documents/actors/blades-pc.js"; +import BladesItemSheet from "./BladesItemSheet.js"; +import BladesItem from "../../BladesItem.js"; +import BladesGMTracker from "../../documents/items/BladesGMTracker.js"; +import BladesActor from "../../BladesActor.js"; +import BladesPC from "../../documents/actors/BladesPC.js"; export enum BladesTipContext { @@ -57,7 +57,7 @@ class BladesTipGenerator { } -class BladesTrackerSheet extends BladesItemSheet { +class BladesGMTrackerSheet extends BladesItemSheet { static Get() { return game.eunoblades.Tracker as BladesGMTracker } @@ -72,7 +72,7 @@ class BladesTrackerSheet extends BladesItemSheet { static async Initialize() { game.eunoblades ??= {}; - Items.registerSheet("blades", BladesTrackerSheet, {types: ["gm_tracker"], makeDefault: true}); + Items.registerSheet("blades", BladesGMTrackerSheet, {types: ["gm_tracker"], makeDefault: true}); Hooks.once("ready", async () => { let tracker: BladesGMTracker|undefined = game.items.find((item): item is BladesGMTracker => BladesItem.IsType(item, BladesItemType.gm_tracker)); if (!tracker) { @@ -94,7 +94,7 @@ class BladesTrackerSheet extends BladesItemSheet { } - override async _onSubmit(event: OnSubmitEvent, params: List = {}) { + override async _onSubmit(event: OnSubmitEvent, params: List = {}) { const prevPhase = this.item.system.phase; const submitData = await super._onSubmit(event, params); const newPhase = this.item.system.phase; @@ -119,7 +119,7 @@ class BladesTrackerSheet extends BladesItemSheet { break; } - // no default + default: break; } switch (newPhase) { case BladesPhase.CharGen: { @@ -138,7 +138,7 @@ class BladesTrackerSheet extends BladesItemSheet { break; } - // no default + default: break; } } if (isForcingRender) { @@ -150,4 +150,4 @@ class BladesTrackerSheet extends BladesItemSheet { } -export default BladesTrackerSheet; \ No newline at end of file +export default BladesGMTrackerSheet; \ No newline at end of file diff --git a/ts/sheets/item/blades-item-sheet.ts b/ts/sheets/item/BladesItemSheet.ts similarity index 98% rename from ts/sheets/item/blades-item-sheet.ts rename to ts/sheets/item/BladesItemSheet.ts index 0e4e1c75..79abfdbe 100644 --- a/ts/sheets/item/blades-item-sheet.ts +++ b/ts/sheets/item/BladesItemSheet.ts @@ -1,9 +1,9 @@ import C, {BladesItemType, BladesPhase, Factor} from "../../core/constants.js"; import U from "../../core/utilities.js"; import G, {ApplyTooltipListeners} from "../../core/gsap.js"; -import BladesActor from "../../blades-actor.js"; -import BladesItem from "../../blades-item.js"; -import BladesActiveEffect from "../../blades-active-effect.js"; +import BladesActor from "../../BladesActor.js"; +import BladesItem from "../../BladesItem.js"; +import BladesActiveEffect from "../../BladesActiveEffect.js"; import Tags from "../../core/tags.js"; diff --git a/ts/sheets/item/blades-score-sheet.ts b/ts/sheets/item/BladesScoreSheet.ts similarity index 96% rename from ts/sheets/item/blades-score-sheet.ts rename to ts/sheets/item/BladesScoreSheet.ts index 68d6b75b..c524c37b 100644 --- a/ts/sheets/item/blades-score-sheet.ts +++ b/ts/sheets/item/BladesScoreSheet.ts @@ -1,10 +1,10 @@ import U from "../../core/utilities.js"; import {BladesActorType, BladesItemType, BladesPhase, Tag, Randomizers} from "../../core/constants.js"; -import BladesItemSheet from "./blades-item-sheet.js"; +import BladesItemSheet from "./BladesItemSheet.js"; -import {BladesActor, BladesPC} from "../../documents/blades-actor-proxy.js"; -import {BladesScore} from "../../documents/blades-item-proxy.js"; -import BladesRollCollab from "../../blades-roll-collab.js"; +import {BladesActor, BladesPC} from "../../documents/BladesActorProxy.js"; +import {BladesScore} from "../../documents/BladesItemProxy.js"; +import BladesRollCollab, {BladesRollCollabComps} from "../../BladesRollCollab.js"; /* #region BladesTipGenerator */ @@ -37,7 +37,7 @@ class BladesTipGenerator { }; } - private tipContext: BladesTipContext; + private readonly tipContext: BladesTipContext; constructor(tipContext: BladesTipContext) { this.tipContext = tipContext; } @@ -225,7 +225,7 @@ class BladesScoreSheet extends BladesItemSheet { const oppId = elem$.data("oppId"); this.document.update({"system.oppositionSelected": oppId}); if (BladesScore.Active?.id === this.document.id && BladesRollCollab.Active) { - BladesRollCollab.Active.rollOpposition = this.document.system.oppositions[oppId]; + BladesRollCollab.Active.rollOpposition = new BladesRollCollabComps.Opposition(BladesRollCollab.Active, this.document.system.oppositions[oppId]); } }
${U.uCase(attribute)}