All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

702 lines
22 KiB

1 year ago
  1. /** MIT (c) 2021 DnD5e Helpers */
  2. /** @typedef {import('./api.js').NoticeConfig} NoticeConfig */
  3. import {
  4. logger
  5. } from './logger.js';
  6. const NAME = "warpgate";
  7. const PATH = `/modules/${NAME}`;
  8. export class MODULE {
  9. static data = {
  10. name: NAME,
  11. path: PATH,
  12. title: "Warp Gate"
  13. };
  14. static get isV10() {
  15. // @ts-ignore
  16. return game.release?.generation >= 10;
  17. }
  18. static async register() {
  19. logger.info("Initializing Module");
  20. MODULE.settings();
  21. }
  22. static async build() {
  23. logger.info("Module Data Built");
  24. }
  25. static setting(key) {
  26. return game.settings.get(MODULE.data.name, key);
  27. }
  28. /**
  29. * Returns the localized string for a given warpgate scoped i18n key
  30. *
  31. * @ignore
  32. * @static
  33. * @param {*} key
  34. * @returns {string}
  35. * @memberof MODULE
  36. */
  37. static localize(key) {
  38. return game.i18n.localize(`warpgate.${key}`);
  39. }
  40. static format(key, data) {
  41. return game.i18n.format(`warpgate.${key}`, data);
  42. }
  43. static canSpawn(user) {
  44. const reqs = [
  45. 'TOKEN_CREATE',
  46. 'TOKEN_CONFIGURE',
  47. 'FILES_BROWSE',
  48. ]
  49. return MODULE.canUser(user, reqs);
  50. }
  51. static canMutate(user) {
  52. const reqs = [
  53. 'TOKEN_CONFIGURE',
  54. 'FILES_BROWSE',
  55. ]
  56. return MODULE.canUser(user, reqs);
  57. }
  58. /**
  59. * Handles notice request from spawns and mutations
  60. *
  61. * @static
  62. * @param {{x: Number, y: Number}} location
  63. * @param {string} sceneId
  64. * @param {NoticeConfig} config
  65. * @memberof MODULE
  66. */
  67. static async handleNotice({x, y}, sceneId, config) {
  68. /* can only operate if the user is on the scene requesting notice */
  69. if( canvas.ready &&
  70. !!sceneId && !!config &&
  71. config.receivers.includes(game.userId) &&
  72. canvas.scene?.id === sceneId ) {
  73. const panSettings = {};
  74. const hasLoc = x !== undefined && y !== undefined;
  75. const doPan = !!config.pan;
  76. const doZoom = !!config.zoom;
  77. const doPing = !!config.ping;
  78. if(hasLoc) {
  79. panSettings.x = x;
  80. panSettings.y = y;
  81. }
  82. if(doPan) {
  83. panSettings.duration = Number.isNumeric(config.pan) && config.pan !== true ? Number(config.pan) : CONFIG.Canvas.pings.pullSpeed;
  84. }
  85. if (doZoom) {
  86. panSettings.scale = Math.min(CONFIG.Canvas.maxZoom, config.zoom);
  87. }
  88. if (doPan) {
  89. await canvas.animatePan(panSettings);
  90. }
  91. if (doPing && hasLoc) {
  92. const user = game.users.get(config.sender);
  93. const location = {x: panSettings.x, y: panSettings.y};
  94. /* draw the ping, either onscreen or offscreen */
  95. canvas.isOffscreen(location) ?
  96. canvas.controls.drawOffscreenPing(location, {scene: sceneId, style: CONFIG.Canvas.pings.types.ARROW, user}) :
  97. canvas.controls.drawPing(location, {scene: sceneId, style: config.ping, user});
  98. }
  99. }
  100. }
  101. /**
  102. * @return {Array<String>} missing permissions for this operation
  103. */
  104. static canUser(user, requiredPermissions) {
  105. if(MODULE.setting('disablePermCheck')) return [];
  106. const {role} = user;
  107. const permissions = game.settings.get('core','permissions');
  108. return requiredPermissions.filter( req => !permissions[req].includes(role) ).map(missing => game.i18n.localize(CONST.USER_PERMISSIONS[missing].label));
  109. }
  110. /**
  111. * A helper functions that returns the first active GM level user.
  112. * @returns {User|undefined} First active GM User
  113. */
  114. static firstGM() {
  115. return game.users?.find(u => u.isGM && u.active);
  116. }
  117. /**
  118. * Checks whether the user calling this function is the user returned
  119. * by {@link warpgate.util.firstGM}. Returns true if they are, false if they are not.
  120. * @returns {boolean} Is the current user the first active GM user?
  121. */
  122. static isFirstGM() {
  123. return game.user?.id === MODULE.firstGM()?.id;
  124. }
  125. static emptyObject(obj){
  126. // @ts-ignore
  127. return foundry.utils.isEmpty(obj);
  128. }
  129. static removeEmptyObjects(obj) {
  130. let result = foundry.utils.flattenObject(obj);
  131. Object.keys(result).forEach( key => {
  132. if(typeof result[key] == 'object' && MODULE.emptyObject(result[key])) {
  133. delete result[key];
  134. }
  135. });
  136. return foundry.utils.expandObject(result);
  137. }
  138. /**
  139. * Duplicates a compatible object (non-complex).
  140. *
  141. * @returns {Object}
  142. */
  143. static copy(source, errorString = 'error.unknown') {
  144. try {
  145. return foundry.utils.deepClone(source, {strict:true});
  146. } catch (err) {
  147. logger.catchThrow(err, MODULE.localize(errorString));
  148. }
  149. return;
  150. }
  151. /**
  152. * Removes top level empty objects from the provided object.
  153. *
  154. * @static
  155. * @param {object} obj
  156. * @memberof MODULE
  157. */
  158. static stripEmpty(obj, inplace = true) {
  159. const result = inplace ? obj : MODULE.copy(obj);
  160. Object.keys(result).forEach( key => {
  161. if(typeof result[key] == 'object' && MODULE.emptyObject(result[key])) {
  162. delete result[key];
  163. }
  164. });
  165. return result;
  166. }
  167. static ownerSublist(docList) {
  168. /* break token list into sublists by first owner */
  169. const subLists = docList.reduce( (lists, doc) => {
  170. if(!doc) return lists;
  171. const owner = MODULE.firstOwner(doc)?.id ?? 'none';
  172. lists[owner] ??= [];
  173. lists[owner].push(doc);
  174. return lists;
  175. },{});
  176. return subLists;
  177. }
  178. /**
  179. * Returns the first active user with owner permissions for the given document,
  180. * falling back to the firstGM should there not be any. Returns false if the
  181. * document is falsey. In the case of token documents it checks the permissions
  182. * for the token's actor as tokens themselves do not have a permission object.
  183. *
  184. * @param {{ actor: Actor } | { document: { actor: Actor } } | Actor} doc
  185. *
  186. * @returns {User|undefined}
  187. */
  188. static firstOwner(doc) {
  189. /* null docs could mean an empty lookup, null docs are not owned by anyone */
  190. if (!doc) return undefined;
  191. /* while conceptually correct, tokens derive permissions from their
  192. * (synthetic) actor data.
  193. */
  194. const corrected = doc instanceof TokenDocument ? doc.actor :
  195. // @ts-ignore 2589
  196. doc instanceof Token ? doc.document.actor : doc;
  197. const ownershipPath = MODULE.isV10 ? 'ownership' : 'data.permission';
  198. const permissionObject = getProperty(corrected ?? {}, ownershipPath) ?? {};
  199. const playerOwners = Object.entries(permissionObject)
  200. .filter(([id, level]) => (!game.users.get(id)?.isGM && game.users.get(id)?.active) && level === 3)
  201. .map(([id, ]) => id);
  202. if (playerOwners.length > 0) {
  203. return game.users.get(playerOwners[0]);
  204. }
  205. /* if no online player owns this actor, fall back to first GM */
  206. return MODULE.firstGM();
  207. }
  208. /**
  209. * Checks whether the user calling this function is the user returned by
  210. * {@link warpgate.util.firstOwner} when the function is passed the
  211. * given document. Returns true if they are the same, false if they are not.
  212. *
  213. * As `firstOwner`, biases towards players first.
  214. *
  215. * @returns {boolean} the current user is the first player owner. If no owning player, first GM.
  216. */
  217. static isFirstOwner(doc) {
  218. return game.user.id === MODULE.firstOwner(doc).id;
  219. }
  220. /**
  221. * Helper function. Waits for a specified amount of time in milliseconds (be sure to await!).
  222. * Useful for timings with animations in the pre/post callbacks.
  223. *
  224. * @param {Number} ms Time to delay, in milliseconds
  225. * @returns Promise
  226. */
  227. static async wait(ms) {
  228. return new Promise((resolve) => setTimeout(resolve, ms))
  229. }
  230. static async waitFor(fn, maxIter = 600, iterWaitTime = 100, i = 0) {
  231. const continueWait = (current, max) => {
  232. /* negative max iter means wait forever */
  233. if (maxIter < 0) return true;
  234. return current < max;
  235. }
  236. while (!fn(i, ((i * iterWaitTime) / 100)) && continueWait(i, maxIter)) {
  237. i++;
  238. await MODULE.wait(iterWaitTime);
  239. }
  240. return i === maxIter ? false : true;
  241. }
  242. static settings() {
  243. const data = {
  244. disablePermCheck: {
  245. config: true, scope: 'world', type: Boolean, default: false,
  246. }
  247. }
  248. MODULE.applySettings(data);
  249. }
  250. static applySettings(settingsData) {
  251. Object.entries(settingsData).forEach(([key, data]) => {
  252. game.settings.register(
  253. MODULE.data.name, key, {
  254. name: MODULE.localize(`setting.${key}.name`),
  255. hint: MODULE.localize(`setting.${key}.hint`),
  256. ...data
  257. }
  258. );
  259. });
  260. }
  261. /**
  262. * @param {string|Actor} actorNameDoc
  263. * @param {object} tokenUpdates
  264. *
  265. * @returns {Promise<TokenDocument|false>}
  266. */
  267. static async getTokenData(actorNameDoc, tokenUpdates) {
  268. let sourceActor = actorNameDoc;
  269. if(typeof actorNameDoc == 'string') {
  270. /* lookup by actor name */
  271. sourceActor = game.actors.getName(actorNameDoc);
  272. }
  273. //get source actor
  274. if (!sourceActor) {
  275. logger.error(`Could not find world actor named "${actorNameDoc}" or no souce actor document provided.`);
  276. return false;
  277. }
  278. //get prototoken data -- need to prepare potential wild cards for the template preview
  279. let protoData = await sourceActor.getTokenDocument(tokenUpdates);
  280. if (!protoData) {
  281. logger.error(`Could not find proto token data for ${sourceActor.name}`);
  282. return false;
  283. }
  284. await loadTexture(protoData.texture.src);
  285. return protoData;
  286. }
  287. static async updateProtoToken(protoToken, changes) {
  288. protoToken.updateSource(changes);
  289. const img = getProperty(changes, 'texture.src');
  290. if (img) await loadTexture(img);
  291. }
  292. static getMouseStagePos() {
  293. const mouse = canvas.app.renderer.plugins.interaction.mouse;
  294. return mouse.getLocalPosition(canvas.app.stage);
  295. }
  296. /**
  297. * @returns {undefined} provided updates object modified in-place
  298. */
  299. static shimUpdate(updates) {
  300. updates.token = MODULE.shimClassData(TokenDocument.implementation, updates.token);
  301. updates.actor = MODULE.shimClassData(Actor.implementation, updates.actor);
  302. Object.keys(updates.embedded ?? {}).forEach( (embeddedName) => {
  303. const cls = CONFIG[embeddedName].documentClass;
  304. Object.entries(updates.embedded[embeddedName]).forEach( ([shortId, data]) => {
  305. updates.embedded[embeddedName][shortId] = (typeof data == 'string') ? data : MODULE.shimClassData(cls, data);
  306. });
  307. });
  308. }
  309. static shimClassData(cls, change) {
  310. if(!change) return change;
  311. if(!!change && !foundry.utils.isEmpty(change)) {
  312. /* shim data if needed */
  313. return cls.migrateData(foundry.utils.expandObject(change));
  314. }
  315. return foundry.utils.expandObject(change);
  316. }
  317. static getFeedbackSettings({alwaysAccept = false, suppressToast = false} = {}) {
  318. const acceptSetting = MODULE.setting('alwaysAcceptLocal') == 0 ?
  319. MODULE.setting('alwaysAccept') :
  320. {1: true, 2: false}[MODULE.setting('alwaysAcceptLocal')];
  321. const accepted = !!alwaysAccept ? true : acceptSetting;
  322. const suppressSetting = MODULE.setting('suppressToastLocal') == 0 ?
  323. MODULE.setting('suppressToast') :
  324. {1: true, 2: false}[MODULE.setting('suppressToastLocal')];
  325. const suppress = !!suppressToast ? true : suppressSetting;
  326. return {alwaysAccept: accepted, suppressToast: suppress};
  327. }
  328. /**
  329. * Collects the changes in 'other' compared to 'base'.
  330. * Also includes "delete update" keys for elements in 'base' that do NOT
  331. * exist in 'other'.
  332. */
  333. static strictUpdateDiff(base, other) {
  334. /* get the changed fields */
  335. const diff = foundry.utils.flattenObject(foundry.utils.diffObject(base, other, {inner: true}));
  336. /* get any newly added fields */
  337. const additions = MODULE.unique(flattenObject(base), flattenObject(other))
  338. /* set their data to null */
  339. Object.keys(additions).forEach( key => {
  340. if( typeof additions[key] != 'object' ) diff[key] = null
  341. });
  342. return foundry.utils.expandObject(diff);
  343. }
  344. static unique(object, remove) {
  345. // Validate input
  346. const ts = getType(object);
  347. const tt = getType(remove);
  348. if ((ts !== "Object") || (tt !== "Object")) throw new Error("One of source or template are not Objects!");
  349. // Define recursive filtering function
  350. const _filter = function (s, t, filtered) {
  351. for (let [k, v] of Object.entries(s)) {
  352. let has = t.hasOwnProperty(k);
  353. let x = t[k];
  354. // Case 1 - inner object
  355. if (has && (getType(v) === "Object") && (getType(x) === "Object")) {
  356. filtered[k] = _filter(v, x, {});
  357. }
  358. // Case 2 - inner key
  359. else if (!has) {
  360. filtered[k] = v;
  361. }
  362. }
  363. return filtered;
  364. };
  365. // Begin filtering at the outer-most layer
  366. return _filter(object, remove, {});
  367. }
  368. /**
  369. * Helper function for quickly creating a simple dialog with labeled buttons and associated data.
  370. * Useful for allowing a choice of actors to spawn prior to `warpgate.spawn`.
  371. *
  372. * @param {Object} data
  373. * @param {Array<{label: string, value:*}>} data.buttons
  374. * @param {string} [data.title]
  375. * @param {string} [data.content]
  376. * @param {Object} [data.options]
  377. *
  378. * @param {string} [direction = 'row'] 'column' or 'row' accepted. Controls layout direction of dialog.
  379. */
  380. static async buttonDialog(data, direction = 'row') {
  381. return await new Promise(async (resolve) => {
  382. /** @type Object<string, object> */
  383. let buttons = {},
  384. dialog;
  385. data.buttons.forEach((button) => {
  386. buttons[button.label] = {
  387. label: button.label,
  388. callback: () => resolve(button.value)
  389. }
  390. });
  391. dialog = new Dialog({
  392. title: data.title ?? '',
  393. content: data.content ?? '',
  394. buttons,
  395. close: () => resolve(false)
  396. }, {
  397. /*width: '100%',*/
  398. height: '100%',
  399. ...data.options
  400. });
  401. await dialog._render(true);
  402. dialog.element.find('.dialog-buttons').css({
  403. 'flex-direction': direction
  404. });
  405. });
  406. }
  407. static dialogInputs = (data) => {
  408. /* correct legacy input data */
  409. data.forEach(inputData => {
  410. if (inputData.type === 'select') {
  411. inputData.options.forEach((e, i) => {
  412. switch (typeof e) {
  413. case 'string':
  414. /* if we are handed legacy string values, convert them to objects */
  415. inputData.options[i] = {value: e, html: e};
  416. /* fallthrough to tweak missing values from object */
  417. case 'object':
  418. /* if no HMTL provided, use value */
  419. inputData.options[i].html ??= inputData.options[i].value;
  420. /* sanity check */
  421. if(!!inputData.options[i].html && inputData.options[i].value != undefined) {
  422. break;
  423. }
  424. /* fallthrough to throw error if all else fails */
  425. default: {
  426. const emsg = MODULE.format('error.badSelectOpts', {fnName: 'menu'});
  427. logger.error(emsg);
  428. throw new Error(emsg);
  429. }
  430. }
  431. });
  432. }
  433. });
  434. const mapped = data.map(({type, label, value, options}, i) => {
  435. type = type.toLowerCase();
  436. switch (type) {
  437. case 'header': return `<tr><td colspan = "2"><h2>${label}</h2></td></tr>`;
  438. case 'button': return '';
  439. case 'info': return `<tr><td colspan="2">${label}</td></tr>`;
  440. case 'select': {
  441. const optionString = options.map((e, i) => {
  442. return `<option value="${i}">${e.html}</option>`
  443. }).join('');
  444. return `<tr><th style="width:50%"><label>${label}</label></th><td style="width:50%"><select id="${i}qd">${optionString}</select></td></tr>`;
  445. }
  446. case 'radio': return `<tr><th style="width:50%"><label>${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" ${(options instanceof Array ? options[1] : false ?? false) ? 'checked' : ''} value="${value ?? label}" name="${options instanceof Array ? options[0] : options ?? 'radio'}"/></td></tr>`;
  447. case 'checkbox': return `<tr><th style="width:50%"><label>${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" ${(options instanceof Array ? options[0] : options ?? false) ? 'checked' : ''} value="${value ?? label}"/></td></tr>`;
  448. default: return `<tr><th style="width:50%"><label>${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" value="${options instanceof Array ? options[0] : options}"/></td></tr>`;
  449. }
  450. }).join(``)
  451. const content = `
  452. <table style="width:100%">
  453. ${mapped}
  454. </table>`;
  455. return content;
  456. }
  457. static async dialog(data = {}, title = 'Prompt', submitLabel = 'Ok') {
  458. logger.warn(`'warpgate.dialog' is deprecated and will be removed in version 1.17.0. See 'warpgate.menu' as a replacement.`);
  459. data = data instanceof Array ? data : [data];
  460. const results = await warpgate.menu({inputs: data}, {title, defaultButton: submitLabel});
  461. if(results.buttons === false) return false;
  462. return results.inputs;
  463. }
  464. /**
  465. * Advanced dialog helper providing multiple input type options as well as user defined buttons.
  466. *
  467. * | `type` | `options` | Return Value | Notes |
  468. * |--|--|--|--|
  469. * | header | none | undefined | Shortcut for `info | <h2>text</h2>`. |
  470. * | info | none | undefined | Inserts a line of text for display/informational purposes. |
  471. * | text | default value | {String} final value of text field | |
  472. * | password | (as `text`) | (as `text`) | Characters are obscured for security. |
  473. * | radio | [group name, default state (`false`)] {Array of String/Bool} | selected: {Class<Primitive>} `value`. un-selected: {Boolean} `false` | For a given group name, only one radio button can be selected. |
  474. * | checkbox | default state (`false`) {Boolean} | {Boolean} `value`/`false` checked/unchecked | `label` is used for the HTML element's `name` property |
  475. * | number | (as `text`) | {Number} final value of text field converted to a number |
  476. * | select | array of option labels or objects {value, html} | `value` property of selected option. If values not provided, numeric index of option in original list | |
  477. * @static
  478. * @param {object} [prompts]
  479. * @param {Array<{label: string, type: string, options: any|Array<any>} >} [prompts.inputs=[]] follow the same structure as dialog
  480. * @param {Array<{label: string, value: any, callback: Function }>} [prompts.buttons=[]] as buttonDialog
  481. * @param {object} [config]
  482. * @param {string} [config.title='Prompt'] Title of dialog
  483. * @param {string} [config.defaultButton='Ok'] default button label if no buttons provided
  484. * @param {function(HTMLElement) : void} [config.render=undefined]
  485. * @param {Function} [config.close = (resolve) => resolve({buttons: false})]
  486. * @param {object} [config.options = {}] Options passed to the Dialog constructor
  487. *
  488. * @return {Promise<{ inputs: Array<any>, buttons: any}>} Object with `inputs` containing the chosen values for each provided input,
  489. * in order, and the provided `value` of the pressed button, or `false` if closed.
  490. *
  491. * @example
  492. * await warpgate.menu({
  493. * inputs: [{
  494. * label: 'My Way',
  495. * type: 'radio',
  496. * options: 'group1'
  497. * }, {
  498. * label: 'The Highway',
  499. * type: 'radio',
  500. * options: 'group1'
  501. * }],
  502. * buttons: [{
  503. * label: 'Yes',
  504. * value: 1
  505. * }, {
  506. * label: 'No',
  507. * value: 2
  508. * }, {
  509. * label: 'Maybe',
  510. * value: 3
  511. * }, {
  512. * label: 'Eventually',
  513. * value: 4
  514. * }]
  515. * }, {
  516. * options: {
  517. * width: '100px',
  518. * height: '100%'
  519. * }
  520. * })
  521. *
  522. */
  523. static async menu(prompts = {}, config = {}) {
  524. /* apply defaults to optional params */
  525. const configDefaults = {
  526. title : 'Prompt',
  527. defaultButton : 'Ok',
  528. render:null,
  529. close : (resolve) => resolve({buttons: false}),
  530. options : {}
  531. }
  532. const {title, defaultButton, render, close, options} = foundry.utils.mergeObject(configDefaults, config);
  533. const {inputs, buttons} = foundry.utils.mergeObject({inputs: [], buttons: []}, prompts);
  534. return await new Promise((resolve) => {
  535. let content = MODULE.dialogInputs(inputs);
  536. /** @type Object<string, object> */
  537. let buttonData = {}
  538. buttons.forEach((button) => {
  539. buttonData[button.label] = {
  540. label: button.label,
  541. callback: async (html) => {
  542. const results = {
  543. inputs: MODULE._innerValueParse(inputs, html),
  544. buttons: button.value
  545. }
  546. if(button.callback instanceof Function) await button.callback(results, button, html);
  547. resolve(results);
  548. }
  549. }
  550. });
  551. /* insert standard submit button if none provided */
  552. if (buttons.length < 1) {
  553. buttonData = {
  554. Ok: {
  555. label: defaultButton,
  556. callback: (html) => resolve({inputs: MODULE._innerValueParse(inputs, html), buttons: true})
  557. }
  558. }
  559. }
  560. new Dialog({
  561. title,
  562. content,
  563. close: (...args) => close(resolve, ...args),
  564. buttons: buttonData,
  565. render,
  566. }, {focus: true, ...options}).render(true);
  567. });
  568. }
  569. static _innerValueParse(data, html) {
  570. return Array(data.length).fill().map((e, i) => {
  571. let {
  572. type
  573. } = data[i];
  574. if (type.toLowerCase() === `select`) {
  575. return data[i].options[html.find(`select#${i}qd`).val()].value;
  576. } else {
  577. switch (type.toLowerCase()) {
  578. case `text`:
  579. case `password`:
  580. return html.find(`input#${i}qd`)[0].value;
  581. case `radio`:
  582. case `checkbox`:
  583. return html.find(`input#${i}qd`)[0].checked ? html.find(`input#${i}qd`)[0].value : false;
  584. case `number`:
  585. return html.find(`input#${i}qd`)[0].valueAsNumber;
  586. }
  587. }
  588. })
  589. }
  590. }