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.

4954 lines
166 KiB

1 year ago
1 year ago
1 year ago
  1. /** @typedef {import('./api.js').NoticeConfig} NoticeConfig */
  2. /**
  3. * __`options` property details__
  4. * | Input Type | Options Type | Default Value | Description |
  5. * |--|--|--|--|
  6. * | header, info | `none` | `undefined` | Ignored
  7. * | text, password, number | `string` | `''` | Initial value of input |
  8. * | checkbox | `boolean`| `false` | Initial checked state |
  9. * | radio | `[string, boolean]` | `['radio', false]` | Group name and initial checked state, respectively |
  10. * | select | `{html: string, value: any, selected: boolean}[]` or `string[]` | `[]` | HTML string for select option element, the value to be return if selected, and initial state. If only a string is provided, it will be used as both the HTML and return value. |
  11. *
  12. * @typedef {Object} MenuInput
  13. * @prop {string} type Type of input, controlling display and return values. See "options property details," above, and {@link MenuResult MenuResult.button}.
  14. * @prop {string} label Display text for this inputs label element. Accepts HTML.
  15. * @prop {boolean|string|Array<string|boolean>} [options] See "options property details," above.
  16. */
  17. /**
  18. * @callback MenuCallback
  19. * @param {MenuResult} result User's chosen values (by reference) for this menu. Can modify or expand return value.
  20. * @param {HTMLElement} html Menu DOM element.
  21. */
  22. /**
  23. * @typedef {object} MenuButton
  24. * @prop {string} label Display text for this button, accepts HTML.
  25. * @prop {*} value Arbitrary object to return if selected.
  26. * @prop {MenuCallback} [callback] Additional callback to be executed
  27. * when this button is selected. Can be used to modify the menu's results object.
  28. * @prop {boolean} [default] Any truthy value sets this button as
  29. * default for the 'submit' or 'ENTER' dialog event. If none provided, the last button provided
  30. * will be used.
  31. */
  32. /**
  33. * @typedef {object} MenuConfig
  34. * @prop {string} title='Prompt' Title of dialog
  35. * @prop {string} defaultButton='Ok' Button label if no buttons otherwise provided
  36. * @prop {boolean} checkedText=false Return the associated label's `innerText` (no html) of `'checkbox'` or `'radio'` type inputs as opposed to its checked state.
  37. * @prop {Function} close=((resolve)=>resolve({buttons:false})) Override default behavior and return value if the menu is closed without a button selected.
  38. * @prop {function(HTMLElement):void} render=()=>{}
  39. * @prop {object} options Passed to the Dialog options argument.
  40. */
  41. /**
  42. * __`inputs` return details__
  43. * | Input Type | Return Type | Description |
  44. * |--|--|--|
  45. * | header, info | `undefined` | |
  46. * | text, password, number | `string` | Final input value
  47. * | checkbox, radio | `boolean\|string`| Final checked state. Using `checkedText` results in `""` for unchecked and `label` for checked. |
  48. * | select | `any` | `value` of the chosen select option, as provided by {@link MenuInput MenuInput.options[i].value} |
  49. *
  50. * @typedef {object} MenuResult
  51. * @prop {Array} inputs See "inputs return details," above.
  52. * @prop {*} buttons `value` of the selected menu button, as provided by {@link MenuButton MenuButton.value}
  53. */
  54. /** @ignore */
  55. const NAME$3 = "warpgate";
  56. /** @ignore */
  57. const PATH = `/modules/${NAME$3}`;
  58. class MODULE {
  59. static data = {
  60. name: NAME$3,
  61. path: PATH,
  62. title: "Warp Gate",
  63. };
  64. /**
  65. *
  66. *
  67. * @static
  68. * @param {*} shimId
  69. * @param {globalThis|*} [root=globalThis]
  70. * @returns {*|null}
  71. * @memberof MODULE
  72. */
  73. static compat(shimId, root = globalThis) {
  74. const gen = game.release?.generation;
  75. switch (shimId) {
  76. case "interaction.pointer":
  77. return gen < 11 ? root.canvas.app.renderer.plugins.interaction.mouse : canvas.app.renderer.events.pointer;
  78. case "crosshairs.computeShape":
  79. return (
  80. {
  81. 10: () => {
  82. if (root.document.t != "circle") {
  83. logger.error("Non-circular Crosshairs is unsupported!");
  84. }
  85. return root._getCircleShape(root.ray.distance);
  86. },
  87. }[gen] ?? (() => root._computeShape())
  88. )();
  89. case "token.delta":
  90. return (
  91. {
  92. 10: "actorData",
  93. }[gen] ?? "delta"
  94. );
  95. default:
  96. return null;
  97. }
  98. }
  99. static async register() {
  100. logger.info("Initializing Module");
  101. MODULE.settings();
  102. }
  103. static setting(key) {
  104. return game.settings.get(MODULE.data.name, key);
  105. }
  106. /**
  107. * Returns the localized string for a given warpgate scoped i18n key
  108. *
  109. * @ignore
  110. * @static
  111. * @param {*} key
  112. * @returns {string}
  113. * @memberof MODULE
  114. */
  115. static localize(key) {
  116. return game.i18n.localize(`warpgate.${key}`);
  117. }
  118. static format(key, data) {
  119. return game.i18n.format(`warpgate.${key}`, data);
  120. }
  121. static canSpawn(user) {
  122. const reqs = ["TOKEN_CREATE", "TOKEN_CONFIGURE", "FILES_BROWSE"];
  123. return MODULE.canUser(user, reqs);
  124. }
  125. static canMutate(user) {
  126. const reqs = ["TOKEN_CONFIGURE", "FILES_BROWSE"];
  127. return MODULE.canUser(user, reqs);
  128. }
  129. /**
  130. * Handles notice request from spawns and mutations
  131. *
  132. * @static
  133. * @param {{x: Number, y: Number}} location
  134. * @param {string} sceneId
  135. * @param {NoticeConfig} config
  136. * @memberof MODULE
  137. */
  138. static async handleNotice({ x, y }, sceneId, config) {
  139. /* can only operate if the user is on the scene requesting notice */
  140. if (
  141. canvas.ready &&
  142. !!sceneId &&
  143. !!config &&
  144. config.receivers.includes(game.userId) &&
  145. canvas.scene?.id === sceneId
  146. ) {
  147. const panSettings = {};
  148. const hasLoc = x !== undefined && y !== undefined;
  149. const doPan = !!config.pan;
  150. const doZoom = !!config.zoom;
  151. const doPing = !!config.ping;
  152. if (hasLoc) {
  153. panSettings.x = x;
  154. panSettings.y = y;
  155. }
  156. if (doPan) {
  157. panSettings.duration =
  158. Number.isNumeric(config.pan) && config.pan !== true
  159. ? Number(config.pan)
  160. : CONFIG.Canvas.pings.pullSpeed;
  161. }
  162. if (doZoom) {
  163. panSettings.scale = Math.min(CONFIG.Canvas.maxZoom, config.zoom);
  164. }
  165. if (doPan) {
  166. await canvas.animatePan(panSettings);
  167. }
  168. if (doPing && hasLoc) {
  169. const user = game.users.get(config.sender);
  170. const location = { x: panSettings.x, y: panSettings.y };
  171. /* draw the ping, either onscreen or offscreen */
  172. canvas.isOffscreen(location)
  173. ? canvas.controls.drawOffscreenPing(location, {
  174. scene: sceneId,
  175. style: CONFIG.Canvas.pings.types.ARROW,
  176. user,
  177. })
  178. : canvas.controls.drawPing(location, {
  179. scene: sceneId,
  180. style: config.ping,
  181. user,
  182. });
  183. }
  184. }
  185. }
  186. /**
  187. * @return {Array<String>} missing permissions for this operation
  188. */
  189. static canUser(user, requiredPermissions) {
  190. if (MODULE.setting("disablePermCheck")) return [];
  191. const { role } = user;
  192. const permissions = game.settings.get("core", "permissions");
  193. return requiredPermissions
  194. .filter((req) => !permissions[req].includes(role))
  195. .map((missing) =>
  196. game.i18n.localize(CONST.USER_PERMISSIONS[missing].label)
  197. );
  198. }
  199. /**
  200. * A helper functions that returns the first active GM level user.
  201. * @returns {User|undefined} First active GM User
  202. */
  203. static firstGM() {
  204. return game.users?.find((u) => u.isGM && u.active);
  205. }
  206. /**
  207. * Checks whether the user calling this function is the user returned
  208. * by {@link warpgate.util.firstGM}. Returns true if they are, false if they are not.
  209. * @returns {boolean} Is the current user the first active GM user?
  210. */
  211. static isFirstGM() {
  212. return game.user?.id === MODULE.firstGM()?.id;
  213. }
  214. static emptyObject(obj) {
  215. // @ts-ignore
  216. return foundry.utils.isEmpty(obj);
  217. }
  218. static removeEmptyObjects(obj) {
  219. let result = foundry.utils.flattenObject(obj);
  220. Object.keys(result).forEach((key) => {
  221. if (typeof result[key] == "object" && MODULE.emptyObject(result[key])) {
  222. delete result[key];
  223. }
  224. });
  225. return foundry.utils.expandObject(result);
  226. }
  227. /**
  228. * Duplicates a compatible object (non-complex).
  229. *
  230. * @returns {Object}
  231. */
  232. static copy(source, errorString = "error.unknown") {
  233. try {
  234. return foundry.utils.deepClone(source, { strict: true });
  235. } catch (err) {
  236. logger.catchThrow(err, MODULE.localize(errorString));
  237. }
  238. return;
  239. }
  240. /**
  241. * Removes top level empty objects from the provided object.
  242. *
  243. * @static
  244. * @param {object} obj
  245. * @memberof MODULE
  246. */
  247. static stripEmpty(obj, inplace = true) {
  248. const result = inplace ? obj : MODULE.copy(obj);
  249. Object.keys(result).forEach((key) => {
  250. if (typeof result[key] == "object" && MODULE.emptyObject(result[key])) {
  251. delete result[key];
  252. }
  253. });
  254. return result;
  255. }
  256. static ownerSublist(docList) {
  257. /* break token list into sublists by first owner */
  258. const subLists = docList.reduce((lists, doc) => {
  259. if (!doc) return lists;
  260. const owner = MODULE.firstOwner(doc)?.id ?? "none";
  261. lists[owner] ??= [];
  262. lists[owner].push(doc);
  263. return lists;
  264. }, {});
  265. return subLists;
  266. }
  267. /**
  268. * Returns the first active user with owner permissions for the given document,
  269. * falling back to the firstGM should there not be any. Returns false if the
  270. * document is falsey. In the case of token documents it checks the permissions
  271. * for the token's actor as tokens themselves do not have a permission object.
  272. *
  273. * @param {{ actor: Actor } | { document: { actor: Actor } } | Actor} doc
  274. *
  275. * @returns {User|undefined}
  276. */
  277. static firstOwner(doc) {
  278. /* null docs could mean an empty lookup, null docs are not owned by anyone */
  279. if (!doc) return undefined;
  280. /* while conceptually correct, tokens derive permissions from their
  281. * (synthetic) actor data.
  282. */
  283. const corrected =
  284. doc instanceof TokenDocument
  285. ? doc.actor
  286. : // @ts-ignore 2589
  287. doc instanceof Token
  288. ? doc.document.actor
  289. : doc;
  290. const permissionObject = getProperty(corrected ?? {}, "ownership") ?? {};
  291. const playerOwners = Object.entries(permissionObject)
  292. .filter(
  293. ([id, level]) =>
  294. !game.users.get(id)?.isGM && game.users.get(id)?.active && level === 3
  295. )
  296. .map(([id]) => id);
  297. if (playerOwners.length > 0) {
  298. return game.users.get(playerOwners[0]);
  299. }
  300. /* if no online player owns this actor, fall back to first GM */
  301. return MODULE.firstGM();
  302. }
  303. /**
  304. * Checks whether the user calling this function is the user returned by
  305. * {@link warpgate.util.firstOwner} when the function is passed the
  306. * given document. Returns true if they are the same, false if they are not.
  307. *
  308. * As `firstOwner`, biases towards players first.
  309. *
  310. * @returns {boolean} the current user is the first player owner. If no owning player, first GM.
  311. */
  312. static isFirstOwner(doc) {
  313. return game.user.id === MODULE.firstOwner(doc).id;
  314. }
  315. /**
  316. * Helper function. Waits for a specified amount of time in milliseconds (be sure to await!).
  317. * Useful for timings with animations in the pre/post callbacks.
  318. *
  319. * @param {Number} ms Time to delay, in milliseconds
  320. * @returns Promise
  321. */
  322. static async wait(ms) {
  323. return new Promise((resolve) => setTimeout(resolve, ms));
  324. }
  325. static async waitFor(fn, maxIter = 600, iterWaitTime = 100, i = 0) {
  326. const continueWait = (current, max) => {
  327. /* negative max iter means wait forever */
  328. if (maxIter < 0) return true;
  329. return current < max;
  330. };
  331. while (!fn(i, (i * iterWaitTime) / 100) && continueWait(i, maxIter)) {
  332. i++;
  333. await MODULE.wait(iterWaitTime);
  334. }
  335. return i === maxIter ? false : true;
  336. }
  337. static settings() {
  338. const data = {
  339. disablePermCheck: {
  340. config: true,
  341. scope: "world",
  342. type: Boolean,
  343. default: false,
  344. },
  345. };
  346. MODULE.applySettings(data);
  347. }
  348. static applySettings(settingsData) {
  349. Object.entries(settingsData).forEach(([key, data]) => {
  350. game.settings.register(MODULE.data.name, key, {
  351. name: MODULE.localize(`setting.${key}.name`),
  352. hint: MODULE.localize(`setting.${key}.hint`),
  353. ...data,
  354. });
  355. });
  356. }
  357. /**
  358. * @param {string|Actor} actorNameDoc
  359. * @param {object} tokenUpdates
  360. *
  361. * @returns {Promise<TokenDocument|false>}
  362. */
  363. static async getTokenData(actorNameDoc, tokenUpdates) {
  364. let sourceActor = actorNameDoc;
  365. if (typeof actorNameDoc == "string") {
  366. /* lookup by actor name */
  367. sourceActor = game.actors.getName(actorNameDoc);
  368. }
  369. //get source actor
  370. if (!sourceActor) {
  371. logger.error(
  372. `Could not find world actor named "${actorNameDoc}" or no souce actor document provided.`
  373. );
  374. return false;
  375. }
  376. //get prototoken data -- need to prepare potential wild cards for the template preview
  377. let protoData = await sourceActor.getTokenDocument(tokenUpdates);
  378. if (!protoData) {
  379. logger.error(`Could not find proto token data for ${sourceActor.name}`);
  380. return false;
  381. }
  382. await loadTexture(protoData.texture.src);
  383. return protoData;
  384. }
  385. static async updateProtoToken(protoToken, changes) {
  386. protoToken.updateSource(changes);
  387. const img = getProperty(changes, "texture.src");
  388. if (img) await loadTexture(img);
  389. }
  390. static getMouseStagePos() {
  391. const mouse = MODULE.compat("interaction.pointer");
  392. return mouse.getLocalPosition(canvas.app.stage);
  393. }
  394. /**
  395. * @returns {undefined} provided updates object modified in-place
  396. */
  397. static shimUpdate(updates) {
  398. updates.token = MODULE.shimClassData(
  399. TokenDocument.implementation,
  400. updates.token
  401. );
  402. updates.actor = MODULE.shimClassData(Actor.implementation, updates.actor);
  403. Object.keys(updates.embedded ?? {}).forEach((embeddedName) => {
  404. const cls = CONFIG[embeddedName].documentClass;
  405. Object.entries(updates.embedded[embeddedName]).forEach(
  406. ([shortId, data]) => {
  407. updates.embedded[embeddedName][shortId] =
  408. typeof data == "string" ? data : MODULE.shimClassData(cls, data);
  409. }
  410. );
  411. });
  412. }
  413. static shimClassData(cls, change) {
  414. if (!change) return change;
  415. if (!!change && !foundry.utils.isEmpty(change)) {
  416. /* shim data if needed */
  417. return cls.migrateData(foundry.utils.expandObject(change));
  418. }
  419. return foundry.utils.expandObject(change);
  420. }
  421. static getFeedbackSettings({
  422. alwaysAccept = false,
  423. suppressToast = false,
  424. } = {}) {
  425. const acceptSetting =
  426. MODULE.setting("alwaysAcceptLocal") == 0
  427. ? MODULE.setting("alwaysAccept")
  428. : { 1: true, 2: false }[MODULE.setting("alwaysAcceptLocal")];
  429. const accepted = !!alwaysAccept ? true : acceptSetting;
  430. const suppressSetting =
  431. MODULE.setting("suppressToastLocal") == 0
  432. ? MODULE.setting("suppressToast")
  433. : { 1: true, 2: false }[MODULE.setting("suppressToastLocal")];
  434. const suppress = !!suppressToast ? true : suppressSetting;
  435. return { alwaysAccept: accepted, suppressToast: suppress };
  436. }
  437. /**
  438. * Collects the changes in 'other' compared to 'base'.
  439. * Also includes "delete update" keys for elements in 'base' that do NOT
  440. * exist in 'other'.
  441. */
  442. static strictUpdateDiff(base, other) {
  443. /* get the changed fields */
  444. const diff = foundry.utils.flattenObject(
  445. foundry.utils.diffObject(base, other, { inner: true })
  446. );
  447. /* get any newly added fields */
  448. const additions = MODULE.unique(flattenObject(base), flattenObject(other));
  449. /* set their data to null */
  450. Object.keys(additions).forEach((key) => {
  451. if (typeof additions[key] != "object") diff[key] = null;
  452. });
  453. return foundry.utils.expandObject(diff);
  454. }
  455. static unique(object, remove) {
  456. // Validate input
  457. const ts = getType(object);
  458. const tt = getType(remove);
  459. if (ts !== "Object" || tt !== "Object")
  460. throw new Error("One of source or template are not Objects!");
  461. // Define recursive filtering function
  462. const _filter = function (s, t, filtered) {
  463. for (let [k, v] of Object.entries(s)) {
  464. let has = t.hasOwnProperty(k);
  465. let x = t[k];
  466. // Case 1 - inner object
  467. if (has && getType(v) === "Object" && getType(x) === "Object") {
  468. filtered[k] = _filter(v, x, {});
  469. }
  470. // Case 2 - inner key
  471. else if (!has) {
  472. filtered[k] = v;
  473. }
  474. }
  475. return filtered;
  476. };
  477. // Begin filtering at the outer-most layer
  478. return _filter(object, remove, {});
  479. }
  480. /**
  481. * Helper function for quickly creating a simple dialog with labeled buttons and associated data.
  482. * Useful for allowing a choice of actors to spawn prior to `warpgate.spawn`.
  483. *
  484. * @param {Object} data
  485. * @param {Array<{label: string, value:*}>} data.buttons
  486. * @param {string} [data.title]
  487. * @param {string} [data.content]
  488. * @param {Object} [data.options]
  489. *
  490. * @param {string} [direction = 'row'] 'column' or 'row' accepted. Controls layout direction of dialog.
  491. */
  492. static async buttonDialog(data, direction = "row") {
  493. return await new Promise(async (resolve) => {
  494. /** @type Object<string, object> */
  495. let buttons = {},
  496. dialog;
  497. data.buttons.forEach((button) => {
  498. buttons[button.label] = {
  499. label: button.label,
  500. callback: () => resolve(button.value),
  501. };
  502. });
  503. dialog = new Dialog(
  504. {
  505. title: data.title ?? "",
  506. content: data.content ?? "",
  507. buttons,
  508. close: () => resolve(false),
  509. },
  510. {
  511. /*width: '100%',*/
  512. height: "100%",
  513. ...data.options,
  514. }
  515. );
  516. await dialog._render(true);
  517. dialog.element.find(".dialog-buttons").css({
  518. "flex-direction": direction,
  519. });
  520. });
  521. }
  522. static dialogInputs = (data) => {
  523. /* correct legacy input data */
  524. data.forEach((inputData) => {
  525. if (inputData.type === "select") {
  526. inputData.options.forEach((e, i) => {
  527. switch (typeof e) {
  528. case "string":
  529. /* if we are handed legacy string values, convert them to objects */
  530. inputData.options[i] = { value: e, html: e };
  531. /* fallthrough to tweak missing values from object */
  532. case "object":
  533. /* if no HMTL provided, use value */
  534. inputData.options[i].html ??= inputData.options[i].value;
  535. /* sanity check */
  536. if (
  537. !!inputData.options[i].html &&
  538. inputData.options[i].value != undefined
  539. ) {
  540. break;
  541. }
  542. /* fallthrough to throw error if all else fails */
  543. default: {
  544. const emsg = MODULE.format("error.badSelectOpts", {
  545. fnName: "menu",
  546. });
  547. logger.error(emsg);
  548. throw new Error(emsg);
  549. }
  550. }
  551. });
  552. }
  553. });
  554. const mapped = data
  555. .map(({ type, label, options }, i) => {
  556. type = type.toLowerCase();
  557. switch (type) {
  558. case "header":
  559. return `<tr><td colspan = "2"><h2>${label}</h2></td></tr>`;
  560. case "button":
  561. return "";
  562. case "info":
  563. return `<tr><td colspan="2">${label}</td></tr>`;
  564. case "select": {
  565. const optionString = options
  566. .map((e, i) => {
  567. return `<option value="${i}" ${e.selected ? 'selected' : ''}>${e.html}</option>`;
  568. })
  569. .join("");
  570. return `<tr><th style="width:50%"><label for="${i}qd">${label}</label></th><td style="width:50%"><select id="${i}qd">${optionString}</select></td></tr>`;
  571. }
  572. case "radio":
  573. return `<tr><th style="width:50%"><label for="${i}qd">${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" ${
  574. (options instanceof Array ? options[1] : false )
  575. ? "checked"
  576. : ""
  577. } value="${i}" name="${
  578. options instanceof Array ? options[0] : options ?? "radio"
  579. }"/></td></tr>`;
  580. case "checkbox":
  581. return `<tr><th style="width:50%"><label for="${i}qd">${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" ${
  582. (options instanceof Array ? options[0] : options ?? false)
  583. ? "checked"
  584. : ""
  585. } value="${i}"/></td></tr>`;
  586. default:
  587. return `<tr><th style="width:50%"><label for="${i}qd">${label}</label></th><td style="width:50%"><input type="${type}" id="${i}qd" value="${
  588. options instanceof Array ? options[0] : options
  589. }"/></td></tr>`;
  590. }
  591. })
  592. .join(``);
  593. const content = `
  594. <table style="width:100%">
  595. ${mapped}
  596. </table>`;
  597. return content;
  598. };
  599. /**
  600. * Advanced dialog helper providing multiple input type options as well as user defined buttons.
  601. *
  602. * @static
  603. * @param {Object} [prompts]
  604. * @param {Array<MenuInput>} [prompts.inputs]
  605. * @param {Array<MenuButton>} [prompts.buttons] If no default button is specified, the last
  606. * button provided will be set as default
  607. * @param {MenuConfig} [config]
  608. *
  609. * @return {Promise<MenuResult>} Object with `inputs` containing the chosen values for each provided input, in order, and the provided `value` of the pressed button or `false`, if closed.
  610. *
  611. * @example
  612. * const results = await warpgate.menu({
  613. * inputs: [{
  614. * label: 'My Way',
  615. * type: 'radio',
  616. * options: 'group1',
  617. * }, {
  618. * label: 'The Highway',
  619. * type: 'radio',
  620. * options: 'group1',
  621. * },{
  622. * label: 'Agree to ToS 😈',
  623. * type: 'checkbox',
  624. * options: true,
  625. * },{
  626. * type: 'select',
  627. * label: 'Make it a combo?',
  628. * options: [
  629. * {html: 'Yes ✅', value: {combo: true, size: 'med'}},
  630. * {html: 'No ❌', value: {combo: false}, selected:true},
  631. * {html: 'Super Size Me!', value: {combo: true, size: 'lg'}}
  632. * ],
  633. * }],
  634. * buttons: [{
  635. * label: 'Yes',
  636. * value: 1,
  637. * callback: () => ui.notifications.info('Yes was clicked'),
  638. * }, {
  639. * label: 'No',
  640. * value: 2
  641. * }, {
  642. * label: '<strong>Maybe</strong>',
  643. * value: 3,
  644. * default: true,
  645. * callback: (results) => {
  646. * results.inputs[3].freebies = true;
  647. * ui.notifications.info('Let us help make your decision easier.')
  648. * },
  649. * }, {
  650. * label: 'Eventually',
  651. * value: 4
  652. * }]
  653. * },{
  654. * title: 'Choose Wisely...',
  655. * //checkedText: true, //Swap true/false output to label/empty string
  656. * render: (...args) => { console.log(...args); ui.notifications.info('render!')},
  657. * options: {
  658. * width: '100px',
  659. * height: '100%',
  660. * }
  661. * })
  662. *
  663. * console.log('results', results)
  664. *
  665. * // EXAMPLE OUTPUT
  666. *
  667. * // Ex1: Default state (Press enter when displayed)
  668. * // -------------------------------
  669. * // Foundry VTT | Rendering Dialog
  670. * // S.fn.init(3) [div.dialog-content, text, div.dialog-buttons]
  671. * // render!
  672. * // Let us help make your decision easier.
  673. * // results {
  674. * // "inputs": [
  675. * // false,
  676. * // false,
  677. * // true,
  678. * // {
  679. * // "combo": false,
  680. * // "freebies": true
  681. * // }
  682. * // ],
  683. * // "buttons": 3
  684. * // }
  685. * //
  686. * // Ex 2: Output for selecting 'My Way', super sizing
  687. * // the combo, and clicking 'Yes'
  688. * // -------------------------------
  689. * // Foundry VTT | Rendering Dialog
  690. * // S.fn.init(3) [div.dialog-content, text, div.dialog-buttons]
  691. * // render!
  692. * // Yes was clicked
  693. * // results {
  694. * // "inputs": [
  695. * // true,
  696. * // false,
  697. * // true,
  698. * // {
  699. * // "combo": true,
  700. * // "size": "lg"
  701. * // }
  702. * // ],
  703. * // "buttons": 1
  704. * // }
  705. */
  706. static async menu(prompts = {}, config = {}) {
  707. /* apply defaults to optional params */
  708. const configDefaults = {
  709. title: "Prompt",
  710. defaultButton: "Ok",
  711. render: null,
  712. close: (resolve) => resolve({ buttons: false }),
  713. options: {},
  714. };
  715. const { title, defaultButton, render, close, checkedText, options } =
  716. foundry.utils.mergeObject(configDefaults, config);
  717. const { inputs, buttons } = foundry.utils.mergeObject(
  718. { inputs: [], buttons: [] },
  719. prompts
  720. );
  721. return await new Promise((resolve) => {
  722. let content = MODULE.dialogInputs(inputs);
  723. /** @type Object<string, object> */
  724. let buttonData = {};
  725. let def = buttons.at(-1)?.label;
  726. buttons.forEach((button) => {
  727. if ("default" in button) def = button.label;
  728. buttonData[button.label] = {
  729. label: button.label,
  730. callback: (html) => {
  731. const results = {
  732. inputs: MODULE._innerValueParse(inputs, html, {checkedText}),
  733. buttons: button.value,
  734. };
  735. if (button.callback instanceof Function)
  736. button.callback(results, html);
  737. return resolve(results);
  738. },
  739. };
  740. });
  741. /* insert standard submit button if none provided */
  742. if (buttons.length < 1) {
  743. def = defaultButton;
  744. buttonData = {
  745. [defaultButton]: {
  746. label: defaultButton,
  747. callback: (html) =>
  748. resolve({
  749. inputs: MODULE._innerValueParse(inputs, html, {checkedText}),
  750. buttons: true,
  751. }),
  752. },
  753. };
  754. }
  755. new Dialog(
  756. {
  757. title,
  758. content,
  759. default: def,
  760. close: (...args) => close(resolve, ...args),
  761. buttons: buttonData,
  762. render,
  763. },
  764. { focus: true, ...options }
  765. ).render(true);
  766. });
  767. }
  768. static _innerValueParse(data, html, {checkedText = false}) {
  769. return Array(data.length)
  770. .fill()
  771. .map((e, i) => {
  772. let { type } = data[i];
  773. if (type.toLowerCase() === `select`) {
  774. return data[i].options[html.find(`select#${i}qd`).val()].value;
  775. } else {
  776. switch (type.toLowerCase()) {
  777. case `text`:
  778. case `password`:
  779. return html.find(`input#${i}qd`)[0].value;
  780. case `radio`:
  781. case `checkbox`: {
  782. const ele = html.find(`input#${i}qd`)[0];
  783. if (checkedText) {
  784. const label = html.find(`[for="${i}qd"]`)[0];
  785. return ele.checked ? label.innerText : '';
  786. }
  787. return ele.checked;
  788. }
  789. case `number`:
  790. return html.find(`input#${i}qd`)[0].valueAsNumber;
  791. }
  792. }
  793. });
  794. }
  795. }
  796. /** @ignore */
  797. class logger {
  798. static info(...args) {
  799. console.log(`${MODULE?.data?.title ?? ""} | `, ...args);
  800. }
  801. static debug(...args) {
  802. if (MODULE.setting("debug"))
  803. console.debug(`${MODULE?.data?.title ?? ""} | `, ...args);
  804. }
  805. static warn(...args) {
  806. console.warn(`${MODULE?.data?.title ?? ""} | WARNING | `, ...args);
  807. ui.notifications.warn(
  808. `${MODULE?.data?.title ?? ""} | WARNING | ${args[0]}`
  809. );
  810. }
  811. static error(...args) {
  812. console.error(`${MODULE?.data?.title ?? ""} | ERROR | `, ...args);
  813. ui.notifications.error(`${MODULE?.data?.title ?? ""} | ERROR | ${args[0]}`);
  814. }
  815. static catchThrow(thrown, toastMsg = undefined) {
  816. console.warn(thrown);
  817. if (toastMsg) logger.error(toastMsg);
  818. }
  819. static register() {
  820. this.settings();
  821. }
  822. static settings() {
  823. const config = true;
  824. const settingsData = {
  825. debug: {
  826. scope: "client",
  827. config,
  828. default: false,
  829. type: Boolean,
  830. },
  831. };
  832. MODULE.applySettings(settingsData);
  833. }
  834. }
  835. /*
  836. * This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
  837. * Copyright (c) 2021 Matthew Haentschke.
  838. *
  839. * This program is free software: you can redistribute it and/or modify
  840. * it under the terms of the GNU General Public License as published by
  841. * the Free Software Foundation, version 3.
  842. *
  843. * This program is distributed in the hope that it will be useful, but
  844. * WITHOUT ANY WARRANTY; without even the implied warranty of
  845. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  846. * General Public License for more details.
  847. *
  848. * You should have received a copy of the GNU General Public License
  849. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  850. */
  851. /** @typedef {import('@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/measuredTemplateData.js').MeasuredTemplateDataProperties} MeasuredTemplateProperties */
  852. /**
  853. * Contains all fields from `MeasuredTemplate#toObject`, plus the following.
  854. *
  855. * @typedef {Object} CrosshairsData
  856. * @borrows MeasuredTemplateProperties
  857. * @prop {boolean} cancelled Workflow cancelled via right click (true)
  858. * @prop {Scene} scene Scene on this crosshairs was last active
  859. * @prop {number} radius Final radius of template, in pixels
  860. * @prop {number} size Final diameter of template, in grid units
  861. */
  862. /**
  863. * @class
  864. */
  865. class Crosshairs extends MeasuredTemplate {
  866. //constructor(gridSize = 1, data = {}){
  867. constructor(config, callbacks = {}) {
  868. const templateData = {
  869. t: config.t ?? "circle",
  870. user: game.user.id,
  871. distance: config.size,
  872. x: config.x,
  873. y: config.y,
  874. fillColor: config.fillColor,
  875. width: 1,
  876. texture: config.texture,
  877. direction: config.direction,
  878. };
  879. const template = new CONFIG.MeasuredTemplate.documentClass(templateData, {parent: canvas.scene});
  880. super(template);
  881. /** @TODO all of these fields should be part of the source data schema for this class **/
  882. /** image path to display in the center (under mouse cursor) */
  883. this.icon = config.icon ?? Crosshairs.ERROR_TEXTURE;
  884. /** text to display below crosshairs' circle */
  885. this.label = config.label;
  886. /** Offsets the default position of the label (in pixels) */
  887. this.labelOffset = config.labelOffset;
  888. /**
  889. * Arbitrary field used to identify this instance
  890. * of a Crosshairs in the canvas.templates.preview
  891. * list
  892. */
  893. this.tag = config.tag;
  894. /** Should the center icon be shown? */
  895. this.drawIcon = config.drawIcon;
  896. /** Should the outer circle be shown? */
  897. this.drawOutline = config.drawOutline;
  898. /** Opacity of the fill color */
  899. this.fillAlpha = config.fillAlpha;
  900. /** Should the texture (if any) be tiled
  901. * or scaled and offset? */
  902. this.tileTexture = config.tileTexture;
  903. /** locks the size of crosshairs (shift+scroll) */
  904. this.lockSize = config.lockSize;
  905. /** locks the position of crosshairs */
  906. this.lockPosition = config.lockPosition;
  907. /** Number of quantization steps along
  908. * a square's edge (N+1 snap points
  909. * along each edge, conting endpoints)
  910. */
  911. this.interval = config.interval;
  912. /** Callback functions to execute
  913. * at particular times
  914. */
  915. this.callbacks = callbacks;
  916. /** Indicates if the user is actively
  917. * placing the crosshairs.
  918. * Setting this to true in the show
  919. * callback will stop execution
  920. * and report the current mouse position
  921. * as the chosen location
  922. */
  923. this.inFlight = false;
  924. /** indicates if the placement of
  925. * crosshairs was canceled (with
  926. * a right click)
  927. */
  928. this.cancelled = true;
  929. /**
  930. * Indicators on where cancel was initiated
  931. * for determining if it was a drag or a cancel
  932. */
  933. this.rightX = 0;
  934. this.rightY = 0;
  935. /** @type {number} */
  936. this.radius = this.document.distance * this.scene.grid.size / 2;
  937. }
  938. /**
  939. * @returns {CrosshairsData} Current Crosshairs class data
  940. */
  941. toObject() {
  942. /** @type {CrosshairsData} */
  943. const data = foundry.utils.mergeObject(this.document.toObject(), {
  944. cancelled: this.cancelled,
  945. scene: this.scene,
  946. radius: this.radius,
  947. size: this.document.distance,
  948. });
  949. delete data.width;
  950. return data;
  951. }
  952. static ERROR_TEXTURE = 'icons/svg/hazard.svg'
  953. /**
  954. * Will retrieve the active crosshairs instance with the defined tag identifier.
  955. * @param {string} key Crosshairs identifier. Will be compared against the Crosshairs `tag` field for strict equality.
  956. * @returns {PIXI.DisplayObject|undefined}
  957. */
  958. static getTag(key) {
  959. return canvas.templates.preview.children.find( child => child.tag === key )
  960. }
  961. static getSnappedPosition({x,y}, interval){
  962. const offset = interval < 0 ? canvas.grid.size/2 : 0;
  963. const snapped = canvas.grid.getSnappedPosition(x - offset, y - offset, interval);
  964. return {x: snapped.x + offset, y: snapped.y + offset};
  965. }
  966. /* -----------EXAMPLE CODE FROM MEASUREDTEMPLATE.JS--------- */
  967. /* Portions of the core package (MeasuredTemplate) repackaged
  968. * in accordance with the "Limited License Agreement for Module
  969. * Development, found here: https://foundryvtt.com/article/license/
  970. * Changes noted where possible
  971. */
  972. /**
  973. * Set the displayed ruler tooltip text and position
  974. * @private
  975. */
  976. //BEGIN WARPGATE
  977. _setRulerText() {
  978. this.ruler.text = this.label;
  979. /** swap the X and Y to use the default dx/dy of a ray (pointed right)
  980. //to align the text to the bottom of the template */
  981. this.ruler.position.set(-this.ruler.width / 2 + this.labelOffset.x, this.template.height / 2 + 5 + this.labelOffset.y);
  982. //END WARPGATE
  983. }
  984. /** @override */
  985. async draw() {
  986. this.clear();
  987. // Load the texture
  988. const texture = this.document.texture;
  989. if ( texture ) {
  990. this._texture = await loadTexture(texture, {fallback: 'icons/svg/hazard.svg'});
  991. } else {
  992. this._texture = null;
  993. }
  994. // Template shape
  995. this.template = this.addChild(new PIXI.Graphics());
  996. // Rotation handle
  997. //BEGIN WARPGATE
  998. //this.handle = this.addChild(new PIXI.Graphics());
  999. //END WARPGATE
  1000. // Draw the control icon
  1001. //if(this.drawIcon)
  1002. this.controlIcon = this.addChild(this._drawControlIcon());
  1003. // Draw the ruler measurement
  1004. this.ruler = this.addChild(this._drawRulerText());
  1005. // Update the shape and highlight grid squares
  1006. this.refresh();
  1007. //BEGIN WARPGATE
  1008. this._setRulerText();
  1009. //this.highlightGrid();
  1010. //END WARPGATE
  1011. // Enable interactivity, only if the Tile has a true ID
  1012. if ( this.id ) this.activateListeners();
  1013. return this;
  1014. }
  1015. /**
  1016. * Draw the Text label used for the MeasuredTemplate
  1017. * @return {PreciseText}
  1018. * @protected
  1019. */
  1020. _drawRulerText() {
  1021. const style = CONFIG.canvasTextStyle.clone();
  1022. style.fontSize = Math.max(Math.round(canvas.dimensions.size * 0.36 * 12) / 12, 36);
  1023. const text = new PreciseText(null, style);
  1024. //BEGIN WARPGATE
  1025. //text.anchor.set(0.5, 0);
  1026. text.anchor.set(0, 0);
  1027. //END WARPGATE
  1028. return text;
  1029. }
  1030. /**
  1031. * Draw the ControlIcon for the MeasuredTemplate
  1032. * @return {ControlIcon}
  1033. * @protected
  1034. */
  1035. _drawControlIcon() {
  1036. const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
  1037. //BEGIN WARPGATE
  1038. let icon = new ControlIcon({texture: this.icon, size: size});
  1039. icon.visible = this.drawIcon;
  1040. //END WARPGATE
  1041. icon.pivot.set(size*0.5, size*0.5);
  1042. //icon.x -= (size * 0.5);
  1043. //icon.y -= (size * 0.5);
  1044. icon.angle = this.document.direction;
  1045. return icon;
  1046. }
  1047. /** @override */
  1048. refresh() {
  1049. if (!this.template) return;
  1050. let d = canvas.dimensions;
  1051. const document = this.document;
  1052. this.position.set(document.x, document.y);
  1053. // Extract and prepare data
  1054. let {direction, distance} = document;
  1055. distance *= (d.size/2);
  1056. //BEGIN WARPGATE
  1057. //width *= (d.size / d.distance);
  1058. //END WARPGATE
  1059. direction = Math.toRadians(direction);
  1060. // Create ray and bounding rectangle
  1061. this.ray = Ray.fromAngle(document.x, document.y, direction, distance);
  1062. // Get the Template shape
  1063. this.shape = MODULE.compat('crosshairs.computeShape', this);
  1064. // Draw the Template outline
  1065. this.template.clear()
  1066. .lineStyle(this._borderThickness, this.borderColor, this.drawOutline ? 0.75 : 0);
  1067. // Fill Color or Texture
  1068. if (this._texture) {
  1069. /* assume 0,0 is top left of texture
  1070. * and scale/offset this texture (due to origin
  1071. * at center of template). tileTexture indicates
  1072. * that this texture is tilable and does not
  1073. * need to be scaled/offset */
  1074. const scale = this.tileTexture ? 1 : distance * 2 / this._texture.width;
  1075. const offset = this.tileTexture ? 0 : distance;
  1076. this.template.beginTextureFill({
  1077. texture: this._texture,
  1078. matrix: new PIXI.Matrix().scale(scale, scale).translate(-offset, -offset)
  1079. });
  1080. } else {
  1081. this.template.beginFill(this.fillColor, this.fillAlpha);
  1082. }
  1083. // Draw the shape
  1084. this.template.drawShape(this.shape);
  1085. // Draw origin and destination points
  1086. //BEGIN WARPGATE
  1087. //this.template.lineStyle(this._borderThickness, 0x000000, this.drawOutline ? 0.75 : 0)
  1088. // .beginFill(0x000000, 0.5)
  1089. //.drawCircle(0, 0, 6)
  1090. //.drawCircle(this.ray.dx, this.ray.dy, 6);
  1091. //END WARPGATE
  1092. // Update visibility
  1093. if (this.drawIcon) {
  1094. this.controlIcon.visible = true;
  1095. this.controlIcon.border.visible = this._hover;
  1096. this.controlIcon.angle = document.direction;
  1097. }
  1098. // Draw ruler text
  1099. //BEGIN WARPGATE
  1100. this._setRulerText();
  1101. //END WARPGATE
  1102. return this;
  1103. }
  1104. /* END MEASUREDTEMPLATE.JS USAGE */
  1105. /* -----------EXAMPLE CODE FROM ABILITY-TEMPLATE.JS--------- */
  1106. /* Foundry VTT 5th Edition
  1107. * Copyright (C) 2019 Foundry Network
  1108. *
  1109. * This program is free software: you can redistribute it and/or modify
  1110. * it under the terms of the GNU General Public License as published by
  1111. * the Free Software Foundation, either version 3 of the License, or
  1112. * (at your option) any later version.
  1113. *
  1114. * This program is distributed in the hope that it will be useful,
  1115. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  1116. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  1117. * GNU General Public License for more details.
  1118. *
  1119. * Original License:
  1120. * https://gitlab.com/foundrynet/dnd5e/-/blob/master/LICENSE.txt
  1121. */
  1122. /**
  1123. * Creates a preview of the spell template
  1124. */
  1125. async drawPreview() {
  1126. // Draw the template and switch to the template layer
  1127. this.initialLayer = canvas.activeLayer;
  1128. this.layer.activate();
  1129. await this.draw();
  1130. this.layer.preview.addChild(this);
  1131. this.layer.interactiveChildren = false;
  1132. // Hide the sheet that originated the preview
  1133. //BEGIN WARPGATE
  1134. this.inFlight = true;
  1135. // Activate interactivity
  1136. this.activatePreviewListeners();
  1137. // Callbacks
  1138. this.callbacks?.show?.(this);
  1139. /* wait _indefinitely_ for placement to be decided. */
  1140. await MODULE.waitFor(() => !this.inFlight, -1);
  1141. if (this.activeHandlers) {
  1142. this.clearHandlers();
  1143. }
  1144. //END WARPGATE
  1145. return this;
  1146. }
  1147. /* -------------------------------------------- */
  1148. _mouseMoveHandler(event) {
  1149. event.stopPropagation();
  1150. /* if our position is locked, do not update it */
  1151. if (this.lockPosition) return;
  1152. // Apply a 20ms throttle
  1153. let now = Date.now();
  1154. if (now - this.moveTime <= 20) return;
  1155. const center = event.data.getLocalPosition(this.layer);
  1156. const {x,y} = Crosshairs.getSnappedPosition(center, this.interval);
  1157. this.document.updateSource({x, y});
  1158. this.refresh();
  1159. this.moveTime = now;
  1160. if(now - this.initTime > 1000){
  1161. logger.debug(`1 sec passed (${now} - ${this.initTime}) - panning`);
  1162. canvas._onDragCanvasPan(event.data.originalEvent);
  1163. }
  1164. }
  1165. _leftClickHandler(event) {
  1166. const document = this.document;
  1167. const thisSceneSize = this.scene.grid.size;
  1168. const destination = Crosshairs.getSnappedPosition(this.document, this.interval);
  1169. this.radius = document.distance * thisSceneSize / 2;
  1170. this.cancelled = false;
  1171. this.document.updateSource({ ...destination });
  1172. this.clearHandlers(event);
  1173. }
  1174. // Rotate the template by 3 degree increments (mouse-wheel)
  1175. // none = rotate 5 degrees
  1176. // shift = scale size
  1177. // ctrl = rotate 30 or 15 degrees (square/hex)
  1178. // alt = zoom canvas
  1179. _mouseWheelHandler(event) {
  1180. if (event.ctrlKey) event.preventDefault(); // Avoid zooming the browser window
  1181. if (!event.altKey) event.stopPropagation();
  1182. const delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15;
  1183. const snap = event.ctrlKey ? delta : 5;
  1184. //BEGIN WARPGATE
  1185. const document = this.document;
  1186. const thisSceneSize = this.scene.grid.size;
  1187. if (event.shiftKey && !this.lockSize) {
  1188. let distance = document.distance + 0.25 * (Math.sign(event.deltaY));
  1189. distance = Math.max(distance, 0.25);
  1190. this.document.updateSource({ distance });
  1191. this.radius = document.distance * thisSceneSize / 2;
  1192. } else if (!event.altKey) {
  1193. const direction = document.direction + (snap * Math.sign(event.deltaY));
  1194. this.document.updateSource({ direction });
  1195. }
  1196. //END WARPGATE
  1197. this.refresh();
  1198. }
  1199. _rightDownHandler(event) {
  1200. if (event.button !== 2) return;
  1201. this.rightX = event.screenX;
  1202. this.rightY = event.screenY;
  1203. }
  1204. _rightUpHandler(event) {
  1205. if (event.button !== 2) return;
  1206. const isWithinThreshold = (current, previous) => Math.abs(current - previous) < 10;
  1207. if (isWithinThreshold(this.rightX, event.screenX)
  1208. && isWithinThreshold(this.rightY, event.screenY)
  1209. ) {
  1210. this.cancelled = true;
  1211. this.clearHandlers(event);
  1212. }
  1213. }
  1214. _clearHandlers(event) {
  1215. //WARPGATE BEGIN
  1216. /* destroy ourselves */
  1217. this.document.object.destroy();
  1218. this.template.destroy();
  1219. this.layer.preview.removeChild(this);
  1220. this._destroyed = true;
  1221. canvas.stage.off("mousemove", this.activeMoveHandler);
  1222. canvas.stage.off("mousedown", this.activeLeftClickHandler);
  1223. canvas.app.view.onmousedown = null;
  1224. canvas.app.view.onmouseup = null;
  1225. canvas.app.view.onwheel = null;
  1226. // Show the sheet that originated the preview
  1227. if (this.actorSheet) this.actorSheet.maximize();
  1228. this.activeHandlers = false;
  1229. this.inFlight = false;
  1230. //WARPGATE END
  1231. /* re-enable interactivity on this layer */
  1232. this.layer.interactiveChildren = true;
  1233. /* moving off this layer also deletes ALL active previews?
  1234. * unexpected, but manageable
  1235. */
  1236. if (this.layer.preview.children.length == 0) {
  1237. this.initialLayer.activate();
  1238. }
  1239. }
  1240. /**
  1241. * Activate listeners for the template preview
  1242. */
  1243. activatePreviewListeners() {
  1244. this.moveTime = 0;
  1245. this.initTime = Date.now();
  1246. //BEGIN WARPGATE
  1247. this.activeHandlers = true;
  1248. /* Activate listeners */
  1249. this.activeMoveHandler = this._mouseMoveHandler.bind(this);
  1250. this.activeLeftClickHandler = this._leftClickHandler.bind(this);
  1251. this.rightDownHandler = this._rightDownHandler.bind(this);
  1252. this.rightUpHandler = this._rightUpHandler.bind(this);
  1253. this.activeWheelHandler = this._mouseWheelHandler.bind(this);
  1254. this.clearHandlers = this._clearHandlers.bind(this);
  1255. // Update placement (mouse-move)
  1256. canvas.stage.on("mousemove", this.activeMoveHandler);
  1257. // Confirm the workflow (left-click)
  1258. canvas.stage.on("mousedown", this.activeLeftClickHandler);
  1259. // Mouse Wheel rotate
  1260. canvas.app.view.onwheel = this.activeWheelHandler;
  1261. // Right click cancel
  1262. canvas.app.view.onmousedown = this.rightDownHandler;
  1263. canvas.app.view.onmouseup = this.rightUpHandler;
  1264. // END WARPGATE
  1265. }
  1266. /** END ABILITY-TEMPLATE.JS USAGE */
  1267. }
  1268. const NAME$2 = 'Events';
  1269. let watches = {};
  1270. let triggers = {};
  1271. let id = 0;
  1272. const removeIf = function (array, condition) {
  1273. let i = array.length;
  1274. while (i--) {
  1275. if (condition(this[i], i)) {
  1276. this.splice(i, 1);
  1277. return true;
  1278. }
  1279. }
  1280. return false;
  1281. };
  1282. class Events {
  1283. /**
  1284. * Similar in operation to `Hooks.on`, with two exceptions. First, the provided function
  1285. * can be asynchronous and will be awaited. Second, an optional `conditionFn` parameter
  1286. * is added to help compartmentalize logic between detecting the desired event and responding to said event.
  1287. *
  1288. * @param {String} name Event name to watch for; It is recommended to use the enums found in {@link warpgate.EVENT}
  1289. * @param {function(object):Promise|void} fn Function to execute when this event has passed the condition function. Will be awaited
  1290. * @param {function(object):boolean} [condition = ()=>true] Optional. Function to determine if the event function should
  1291. * be executed. While not strictly required, as the `fn` function could simply return as a NOOP, providing this
  1292. * parameter may help compartmentalize "detection" vs "action" processing.
  1293. *
  1294. * @returns {number} Function id assigned to this event, for use with {@link warpgate.event.remove}
  1295. */
  1296. static watch(name, fn, condition = () => {
  1297. return true;
  1298. }) {
  1299. if (!watches[name]) watches[name] = [];
  1300. id++;
  1301. watches[name].push({
  1302. fn,
  1303. condition,
  1304. id
  1305. });
  1306. return id;
  1307. }
  1308. /**
  1309. * Identical to {@link warpgate.event.watch}, except that this function will only be called once, after the condition is met.
  1310. *
  1311. * @see {@link warpgate.event.watch}
  1312. */
  1313. static trigger(name, fn, condition = () => {
  1314. return true;
  1315. }) {
  1316. if (!triggers[name]) triggers[name] = [];
  1317. id++;
  1318. triggers[name].push({
  1319. fn,
  1320. condition,
  1321. id
  1322. });
  1323. return id;
  1324. }
  1325. static async run(name, data) {
  1326. for (const {
  1327. fn,
  1328. condition,
  1329. id
  1330. } of watches[name] ?? []) {
  1331. try {
  1332. if (condition(data)) {
  1333. logger.debug(`${name} | ${id} passes watch condition`);
  1334. await fn(data);
  1335. } else {
  1336. logger.debug(`${name} | ${id} fails watch condition`);
  1337. }
  1338. } catch (e) {
  1339. logger.error(`${NAME$2} | error`, e, `\n \nIn watch function (${name})\n`, fn);
  1340. }
  1341. }
  1342. let {
  1343. run,
  1344. keep
  1345. } = (triggers[name] ?? []).reduce((acum, elem) => {
  1346. try {
  1347. const passed = elem.condition(data);
  1348. if (passed) {
  1349. logger.debug(`${name} | ${elem.id} passes trigger condition`);
  1350. acum.run.push(elem);
  1351. } else {
  1352. logger.debug(`${name} | ${elem.id} fails trigger condition`);
  1353. acum.keep.push(elem);
  1354. }
  1355. } catch (e) {
  1356. logger.error(`${NAME$2} | error`, e, `\n \nIn trigger condition function (${name})\n`, elem.condition);
  1357. return acum;
  1358. } finally {
  1359. return acum;
  1360. }
  1361. }, {
  1362. run: [],
  1363. keep: []
  1364. });
  1365. for (const {
  1366. fn,
  1367. id
  1368. } of run) {
  1369. logger.debug(`${name} | calling trigger ${id}`);
  1370. try {
  1371. await fn(data);
  1372. } catch (e) {
  1373. logger.error(`${NAME$2} | error`, e, `\n \nIn trigger function (${name})\n`, fn);
  1374. }
  1375. }
  1376. triggers[name] = keep;
  1377. }
  1378. /**
  1379. * Removes a `watch` or `trigger` by its provided id -- obtained by the return value of `watch` and `trigger`.
  1380. *
  1381. * @param {number} id Numerical ID of the event function to remove.
  1382. *
  1383. * @see warpgate.event.watch
  1384. * @see warpgate.event.trigger
  1385. */
  1386. static remove(id) {
  1387. const searchFn = (elem) => {
  1388. return elem.id === id
  1389. };
  1390. const tryRemove = (page) => removeIf(page, searchFn);
  1391. const hookRemove = Object.values(watches).map(tryRemove).reduce((sum, current) => {
  1392. return sum || current
  1393. }, false);
  1394. const triggerRemove = Object.values(triggers).map(tryRemove).reduce((sum, current) => {
  1395. return sum || current
  1396. }, false);
  1397. return hookRemove || triggerRemove;
  1398. }
  1399. }
  1400. /*
  1401. * This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
  1402. * Copyright (c) 2021 Matthew Haentschke.
  1403. *
  1404. * This program is free software: you can redistribute it and/or modify
  1405. * it under the terms of the GNU General Public License as published by
  1406. * the Free Software Foundation, version 3.
  1407. *
  1408. * This program is distributed in the hope that it will be useful, but
  1409. * WITHOUT ANY WARRANTY; without even the implied warranty of
  1410. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  1411. * General Public License for more details.
  1412. *
  1413. * You should have received a copy of the GNU General Public License
  1414. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  1415. */
  1416. /** @ignore */
  1417. const NAME$1 = "Mutator";
  1418. /** @typedef {import('./api.js').ComparisonKeys} ComparisonKeys */
  1419. /** @typedef {import('./api.js').NoticeConfig} NoticeConfig */
  1420. /** @typedef {import('./mutation-stack.js').MutationData} MutationData */
  1421. /** @typedef {import('./api.js').Shorthand} Shorthand */
  1422. /** @typedef {import('./api.js').SpawningOptions} SpawningOptions */
  1423. //TODO proper objects
  1424. /** @typedef {Object} MutateInfo
  1425. * @ignore
  1426. */
  1427. /**
  1428. * Workflow options
  1429. * @typedef {Object} WorkflowOptions
  1430. * @property {Shorthand} [updateOpts] Options for the creation/deletion/updating of (embedded) documents related to this mutation
  1431. * @property {string} [description] Description of this mutation for potential display to the remote owning user.
  1432. * @property {NoticeConfig} [notice] Options for placing a ping or panning to the token after mutation
  1433. * @property {boolean} [noMoveWait = false] If true, will not wait for potential token movement animation to complete before proceeding with remaining actor/embedded updates.
  1434. * @property {Object} [overrides]
  1435. * @property {boolean} [overrides.alwaysAccept = false] Force the receiving clients "auto-accept" state,
  1436. * regardless of world/client settings
  1437. * @property {boolean} [overrides.suppressToast = false] Force the initiating and receiving clients to suppress
  1438. * the "call and response" UI toasts indicating the requests accepted/rejected response.
  1439. * @property {boolean} [overrides.includeRawData = false] Force events produced from this operation to include the
  1440. * raw data used for its operation (such as the final mutation data to be applied, or the resulting packed actor
  1441. * data from a spawn). **Caution, use judiciously** -- enabling this option can result in potentially large
  1442. * socket data transfers during warpgate operation.
  1443. * @property {boolean} [overrides.preserveData = false] If enabled, the provided updates data object will
  1444. * be modified in-place as needed for internal Warp Gate operations and will NOT be re-usable for a
  1445. * subsequent operation. Otherwise, the provided data is copied and modified internally, preserving
  1446. * the original input for subsequent re-use.
  1447. *
  1448. */
  1449. /**
  1450. *
  1451. * @typedef {Object} MutationOptions
  1452. * @property {boolean} [permanent=false] Indicates if this should be treated as a permanent change
  1453. * to the actor, which does not store the update delta information required to revert mutation.
  1454. * @property {string} [name=randomId()] User provided name, or identifier, for this particular
  1455. * mutation operation. Used for reverting mutations by name, as opposed to popping last applied.
  1456. * @property {Object} [delta]
  1457. * @property {ComparisonKeys} [comparisonKeys]
  1458. */
  1459. /**
  1460. * The post delta creation, pre mutate callback. Called after the update delta has been generated, but before
  1461. * it is stored on the actor. Can be used to modify this delta for storage (ex. Current and Max HP are
  1462. * increased by 10, but when reverted, you want to keep the extra Current HP applied. Update the delta object
  1463. * with the desired HP to return to after revert, or remove it entirely.
  1464. *
  1465. * @typedef {(function(Shorthand,TokenDocument):Promise|undefined)} PostDelta
  1466. * @param {Shorthand} delta Computed change of the actor based on `updates`. Used to "unroll" this mutation when reverted.
  1467. * @param {TokenDocument} tokenDoc Token being modified.
  1468. *
  1469. * @returns {Promise<any>|any}
  1470. */
  1471. /**
  1472. * The post mutate callback prototype. Called after the actor has been mutated and after the mutate event
  1473. * has triggered. Useful for animations or changes that should not be tracked by the mutation system.
  1474. *
  1475. * @typedef {function(TokenDocument, Object, boolean):Promise|void} PostMutate
  1476. * @param {TokenDocument} tokenDoc Token that has been modified.
  1477. * @param {Shorthand} updates Current permutation of the original shorthand updates object provided, as
  1478. * applied for this mutation
  1479. * @param {boolean} accepted Whether or not the mutation was accepted by the first owner.
  1480. *
  1481. * @returns {Promise<any>|any}
  1482. */
  1483. class Mutator {
  1484. static register() {
  1485. Mutator.defaults();
  1486. }
  1487. static defaults(){
  1488. MODULE[NAME$1] = {
  1489. comparisonKey: 'name'
  1490. };
  1491. }
  1492. static #idByQuery( list, key, comparisonPath ) {
  1493. const id = this.#findByQuery(list, key, comparisonPath)?.id ?? null;
  1494. return id;
  1495. }
  1496. static #findByQuery( list, key, comparisonPath ) {
  1497. return list.find( element => getProperty(element, comparisonPath) === key )
  1498. }
  1499. //TODO change to reduce
  1500. static _parseUpdateShorthand(collection, updates, comparisonKey) {
  1501. let parsedUpdates = Object.keys(updates).map((key) => {
  1502. if (updates[key] === warpgate.CONST.DELETE) return { _id: null };
  1503. const _id = this.#idByQuery(collection, key, comparisonKey );
  1504. return {
  1505. ...updates[key],
  1506. _id,
  1507. }
  1508. });
  1509. parsedUpdates = parsedUpdates.filter( update => !!update._id);
  1510. return parsedUpdates;
  1511. }
  1512. //TODO change to reduce
  1513. static _parseDeleteShorthand(collection, updates, comparisonKey) {
  1514. let parsedUpdates = Object.keys(updates).map((key) => {
  1515. if (updates[key] !== warpgate.CONST.DELETE) return null;
  1516. return this.#idByQuery(collection, key, comparisonKey);
  1517. });
  1518. parsedUpdates = parsedUpdates.filter( update => !!update);
  1519. return parsedUpdates;
  1520. }
  1521. static _parseAddShorthand(collection, updates, comparisonKey){
  1522. let parsedAdds = Object.keys(updates).reduce((acc, key) => {
  1523. /* ignore deletes */
  1524. if (updates[key] === warpgate.CONST.DELETE) return acc;
  1525. /* ignore item updates for items that exist */
  1526. if (this.#idByQuery(collection, key, comparisonKey)) return acc;
  1527. let data = updates[key];
  1528. setProperty(data, comparisonKey, key);
  1529. acc.push(data);
  1530. return acc;
  1531. },[]);
  1532. return parsedAdds;
  1533. }
  1534. static _invertShorthand(collection, updates, comparisonKey){
  1535. let inverted = {};
  1536. Object.keys(updates).forEach( (key) => {
  1537. /* find this item currently and copy off its data */
  1538. const currentData = this.#findByQuery(collection, key, comparisonKey);
  1539. /* this is a delete */
  1540. if (updates[key] === warpgate.CONST.DELETE) {
  1541. /* hopefully we found something */
  1542. if(currentData) setProperty(inverted, key, currentData.toObject());
  1543. else logger.debug('Delta Creation: Could not locate shorthand identified document for deletion.', collection, key, updates[key]);
  1544. return;
  1545. }
  1546. /* this is an update */
  1547. if (currentData){
  1548. /* grab the current value of any updated fields and store */
  1549. const expandedUpdate = expandObject(updates[key]);
  1550. const sourceData = currentData.toObject();
  1551. const updatedData = mergeObject(sourceData, expandedUpdate, {inplace: false});
  1552. const diff = MODULE.strictUpdateDiff(updatedData, sourceData);
  1553. setProperty(inverted, updatedData[comparisonKey], diff);
  1554. return;
  1555. }
  1556. /* must be an add, so we delete */
  1557. setProperty(inverted, key, warpgate.CONST.DELETE);
  1558. });
  1559. return inverted;
  1560. }
  1561. static _errorCheckEmbeddedUpdates( embeddedName, updates ) {
  1562. /* at the moment, the most pressing error is an Item creation without a 'type' field.
  1563. * This typically indicates a failed lookup for an update operation
  1564. */
  1565. if( embeddedName == 'Item'){
  1566. const badItemAdd = (updates.add ?? []).find( add => !add.type );
  1567. if (badItemAdd) {
  1568. logger.info(badItemAdd);
  1569. const message = MODULE.format('error.badMutate.missing.type', {embeddedName});
  1570. return {error: true, message}
  1571. }
  1572. }
  1573. return {error:false};
  1574. }
  1575. /* run the provided updates for the given embedded collection name from the owner */
  1576. static async _performEmbeddedUpdates(owner, embeddedName, updates, comparisonKey = 'name', updateOpts = {}){
  1577. const collection = owner.getEmbeddedCollection(embeddedName);
  1578. const parsedAdds = Mutator._parseAddShorthand(collection, updates, comparisonKey);
  1579. const parsedUpdates = Mutator._parseUpdateShorthand(collection, updates, comparisonKey);
  1580. const parsedDeletes = Mutator._parseDeleteShorthand(collection, updates, comparisonKey);
  1581. logger.debug(`Modify embedded ${embeddedName} of ${owner.name} from`, {adds: parsedAdds, updates: parsedUpdates, deletes: parsedDeletes});
  1582. const {error, message} = Mutator._errorCheckEmbeddedUpdates( embeddedName, {add: parsedAdds, update: parsedUpdates, delete: parsedDeletes} );
  1583. if(error) {
  1584. logger.error(message);
  1585. return false;
  1586. }
  1587. try {
  1588. if (parsedAdds.length > 0) await owner.createEmbeddedDocuments(embeddedName, parsedAdds, updateOpts);
  1589. } catch (e) {
  1590. logger.error(e);
  1591. }
  1592. try {
  1593. if (parsedUpdates.length > 0) await owner.updateEmbeddedDocuments(embeddedName, parsedUpdates, updateOpts);
  1594. } catch (e) {
  1595. logger.error(e);
  1596. }
  1597. try {
  1598. if (parsedDeletes.length > 0) await owner.deleteEmbeddedDocuments(embeddedName, parsedDeletes, updateOpts);
  1599. } catch (e) {
  1600. logger.error(e);
  1601. }
  1602. return true;
  1603. }
  1604. /* embeddedUpdates keyed by embedded name, contains shorthand */
  1605. static async _updateEmbedded(owner, embeddedUpdates, comparisonKeys, updateOpts = {}){
  1606. /* @TODO check for any recursive embeds*/
  1607. if (embeddedUpdates?.embedded) delete embeddedUpdates.embedded;
  1608. for(const embeddedName of Object.keys(embeddedUpdates ?? {})){
  1609. await Mutator._performEmbeddedUpdates(owner, embeddedName, embeddedUpdates[embeddedName],
  1610. comparisonKeys[embeddedName] ?? MODULE[NAME$1].comparisonKey,
  1611. updateOpts[embeddedName] ?? {});
  1612. }
  1613. }
  1614. /* updates the actor and any embedded documents of this actor */
  1615. /* @TODO support embedded documents within embedded documents */
  1616. static async _updateActor(actor, updates = {}, comparisonKeys = {}, updateOpts = {}) {
  1617. logger.debug('Performing update on (actor/updates)',actor, updates, comparisonKeys, updateOpts);
  1618. await warpgate.wait(MODULE.setting('updateDelay')); // @workaround for semaphore bug
  1619. /** perform the updates */
  1620. if (updates.actor) await actor.update(updates.actor, updateOpts.actor ?? {});
  1621. await Mutator._updateEmbedded(actor, updates.embedded, comparisonKeys, updateOpts.embedded);
  1622. return;
  1623. }
  1624. /**
  1625. * Given an update argument identical to `warpgate.spawn` and a token document, will apply the changes listed
  1626. * in the updates and (by default) store the change delta, which allows these updates to be reverted. Mutating
  1627. * the same token multiple times will "stack" the delta changes, allowing the user to remove them as desired,
  1628. * while preserving changes made "higher" in the stack.
  1629. *
  1630. * @param {TokenDocument} tokenDoc Token document to update, does not accept Token Placeable.
  1631. * @param {Shorthand} [updates] As {@link warpgate.spawn}
  1632. * @param {Object} [callbacks] Two provided callback locations: delta and post. Both are awaited.
  1633. * @param {PostDelta} [callbacks.delta]
  1634. * @param {PostMutate} [callbacks.post]
  1635. * @param {WorkflowOptions & MutationOptions} [options]
  1636. *
  1637. * @return {Promise<MutationData|false>} The mutation stack entry produced by this mutation, if they are tracked (i.e. not permanent).
  1638. */
  1639. static async mutate(tokenDoc, updates = {}, callbacks = {}, options = {}) {
  1640. const neededPerms = MODULE.canMutate(game.user);
  1641. if(neededPerms.length > 0) {
  1642. logger.warn(MODULE.format('error.missingPerms', {permList: neededPerms.join(', ')}));
  1643. return false;
  1644. }
  1645. /* the provided update object will be mangled for our use -- copy it to
  1646. * preserve the user's original input if requested (default).
  1647. */
  1648. if(!options.overrides?.preserveData) {
  1649. updates = MODULE.copy(updates, 'error.badUpdate.complex');
  1650. if(!updates) return false;
  1651. options = foundry.utils.mergeObject(options, {overrides: {preserveData: true}}, {inplace: false});
  1652. }
  1653. /* ensure that we are working with clean data */
  1654. await Mutator.clean(updates, options);
  1655. /* providing a delta means you are managing the
  1656. * entire data change (including mutation stack changes).
  1657. * Typically used by remote requests */
  1658. /* create a default mutation info assuming we were provided
  1659. * with the final delta already or the change is permanent
  1660. */
  1661. let mutateInfo = Mutator._createMutateInfo( options.delta ?? {}, options );
  1662. /* check that this mutation name is unique */
  1663. const present = warpgate.mutationStack(tokenDoc).getName(mutateInfo.name);
  1664. if(!!present) {
  1665. logger.warn(MODULE.format('error.badMutate.duplicate', {name: mutateInfo.name}));
  1666. return false;
  1667. }
  1668. /* ensure the options parameter has a name field if not provided */
  1669. options.name = mutateInfo.name;
  1670. /* expand the object to handle property paths correctly */
  1671. MODULE.shimUpdate(updates);
  1672. /* permanent changes are not tracked */
  1673. if(!options.permanent) {
  1674. /* if we have the delta provided, trust it */
  1675. let delta = options.delta ?? Mutator._createDelta(tokenDoc, updates, options);
  1676. /* allow user to modify delta if needed (remote updates will never have callbacks) */
  1677. if (callbacks.delta) {
  1678. const cont = await callbacks.delta(delta, tokenDoc);
  1679. if(cont === false) return false;
  1680. }
  1681. /* update the mutation info with the final updates including mutate stack info */
  1682. mutateInfo = Mutator._mergeMutateDelta(tokenDoc.actor, delta, updates, options);
  1683. options.delta = mutateInfo.delta;
  1684. } else if (callbacks.delta) {
  1685. /* call the delta callback if provided, but there is no object to modify */
  1686. const cont = await callbacks.delta({}, tokenDoc);
  1687. if(cont === false) return false;
  1688. }
  1689. if (tokenDoc.actor.isOwner) {
  1690. if(options.notice && tokenDoc.object) {
  1691. const placement = {
  1692. scene: tokenDoc.object.scene,
  1693. ...tokenDoc.object.center,
  1694. };
  1695. warpgate.plugin.notice(placement, options.notice);
  1696. }
  1697. await Mutator._update(tokenDoc, updates, options);
  1698. if(callbacks.post) await callbacks.post(tokenDoc, updates, true);
  1699. await warpgate.event.notify(warpgate.EVENT.MUTATE, {
  1700. uuid: tokenDoc.uuid,
  1701. name: options.name,
  1702. updates: (options.overrides?.includeRawData ?? false) ? updates : 'omitted',
  1703. options
  1704. });
  1705. } else {
  1706. /* this is a remote mutation request, hand it over to that system */
  1707. return remoteMutate( tokenDoc, {updates, callbacks, options} );
  1708. }
  1709. return mutateInfo;
  1710. }
  1711. /**
  1712. * Perform a managed, batch update of multple token documents. Heterogeneous ownership supported
  1713. * and routed through the Remote Mutation system as needed. The same updates, callbacks and options
  1714. * objects will be used for all mutations.
  1715. *
  1716. * Note: If a specific mutation name is not provided, a single random ID will be generated for all
  1717. * resulting individual mutations.
  1718. *
  1719. * @static
  1720. * @param {Array<TokenDocument>} tokenDocs List of tokens on which to apply the provided mutation.
  1721. * @param {Object} details The details of this batch mutation operation.
  1722. * @param {Shorthand} details.updates The updates to apply to each token; as {@link warpgate.spawn}
  1723. * @param {Object} [details.callbacks] Delta and post mutation callbacks; as {@link warpgate.mutate}
  1724. * @param {PostDelta} [details.callbacks.delta]
  1725. * @param {PostMutate} [details.callbacks.post]
  1726. * @param {WorkflowOptions & MutationOptions} [details.options]
  1727. *
  1728. * @returns {Promise<Array<MutateInfo>>} List of mutation results, which resolve
  1729. * once all local mutations have been applied and when all remote mutations have been _accepted_
  1730. * or _rejected_. Currently, local and remote mutations will contain differing object structures.
  1731. * Notably, local mutations contain a `delta` field containing the revert data for
  1732. * this mutation; whereas remote mutations will contain an `accepted` field,
  1733. * indicating if the request was accepted.
  1734. */
  1735. static async batchMutate( tokenDocs, {updates, callbacks, options} ) {
  1736. /* break token list into sublists by first owner */
  1737. const tokenLists = MODULE.ownerSublist(tokenDocs);
  1738. if((tokenLists['none'] ?? []).length > 0) {
  1739. logger.warn(MODULE.localize('error.offlineOwnerBatch'));
  1740. logger.debug('Affected UUIDs:', tokenLists['none'].map( t => t.uuid ));
  1741. delete tokenLists['none'];
  1742. }
  1743. options.name ??= randomID();
  1744. let promises = Reflect.ownKeys(tokenLists).flatMap( async (owner) => {
  1745. if(owner == game.userId) {
  1746. //self service mutate
  1747. return await tokenLists[owner].map( tokenDoc => warpgate.mutate(tokenDoc, updates, callbacks, options) );
  1748. }
  1749. /* is a remote update */
  1750. return await remoteBatchMutate( tokenLists[owner], {updates, callbacks, options} );
  1751. });
  1752. /* wait for each client batch of mutations to complete */
  1753. promises = await Promise.all(promises);
  1754. /* flatten all into a single array, and ensure all subqueries are complete */
  1755. return Promise.all(promises.flat());
  1756. }
  1757. /**
  1758. * Perform a managed, batch update of multple token documents. Heterogeneous ownership supported
  1759. * and routed through the Remote Mutation system as needed. The same updates, callbacks and options
  1760. * objects will be used for all mutations.
  1761. *
  1762. * Note: If a specific mutation name is not provided, a single random ID will be generated for all
  1763. * resulting individual mutations.
  1764. *
  1765. * @static
  1766. * @param {Array<TokenDocument>} tokenDocs List of tokens on which to perform the revert
  1767. * @param {Object} details
  1768. * @param {string} [details.mutationName] Specific mutation name to revert, or the latest mutation
  1769. * for an individual token if not provided. Tokens without mutations or without the specific
  1770. * mutation requested are not processed.
  1771. * @param {WorkflowOptions & MutationOptions} [details.options]
  1772. * @returns {Promise<Array<MutateInfo>>} List of mutation revert results, which resolve
  1773. * once all local reverts have been applied and when all remote reverts have been _accepted_
  1774. * or _rejected_. Currently, local and remote reverts will contain differing object structures.
  1775. * Notably, local revert contain a `delta` field containing the revert data for
  1776. * this mutation; whereas remote reverts will contain an `accepted` field,
  1777. * indicating if the request was accepted.
  1778. */
  1779. static async batchRevert( tokenDocs, {mutationName = null, options = {}} = {} ) {
  1780. const tokenLists = MODULE.ownerSublist(tokenDocs);
  1781. if((tokenLists['none'] ?? []).length > 0) {
  1782. logger.warn(MODULE.localize('error.offlineOwnerBatch'));
  1783. logger.debug('Affected UUIDs:', tokenLists['none'].map( t => t.uuid ));
  1784. delete tokenLists['none'];
  1785. }
  1786. let promises = Reflect.ownKeys(tokenLists).map( (owner) => {
  1787. if(owner == game.userId) {
  1788. //self service mutate
  1789. return tokenLists[owner].map( tokenDoc => warpgate.revert(tokenDoc, mutationName, options) );
  1790. }
  1791. /* is a remote update */
  1792. return remoteBatchRevert( tokenLists[owner], {mutationName, options} );
  1793. });
  1794. promises = await Promise.all(promises);
  1795. return Promise.all(promises.flat());
  1796. }
  1797. /**
  1798. * @returns {MutationData}
  1799. */
  1800. static _createMutateInfo( delta, options = {} ) {
  1801. options.name ??= randomID();
  1802. return {
  1803. delta: MODULE.stripEmpty(delta),
  1804. user: game.user.id,
  1805. comparisonKeys: MODULE.stripEmpty(options.comparisonKeys ?? {}, false),
  1806. name: options.name,
  1807. updateOpts: MODULE.stripEmpty(options.updateOpts ?? {}, false),
  1808. overrides: MODULE.stripEmpty(options.overrides ?? {}, false),
  1809. };
  1810. }
  1811. static _cleanInner(single) {
  1812. Object.keys(single).forEach( key => {
  1813. /* dont process embedded */
  1814. if(key == 'embedded') return;
  1815. /* dont process delete identifiers */
  1816. if(typeof single[key] == 'string') return;
  1817. /* convert value to plain object if possible */
  1818. if(single[key]?.toObject) single[key] = single[key].toObject();
  1819. if(single[key] == undefined) {
  1820. single[key] = {};
  1821. }
  1822. return;
  1823. });
  1824. }
  1825. /**
  1826. * Cleans and validates mutation data
  1827. * @param {Shorthand} updates
  1828. * @param {SpawningOptions & MutationOptions} [options]
  1829. */
  1830. static async clean(updates, options = undefined) {
  1831. if(!!updates) {
  1832. /* ensure we are working with raw objects */
  1833. Mutator._cleanInner(updates);
  1834. /* perform cleaning on shorthand embedded updates */
  1835. Object.values(updates.embedded ?? {}).forEach( type => Mutator._cleanInner(type));
  1836. /* if the token is getting an image update, preload it */
  1837. let source;
  1838. if('src' in (updates.token?.texture ?? {})) {
  1839. source = updates.token.texture.src;
  1840. }
  1841. else if( 'img' in (updates.token ?? {})){
  1842. source = updates.token.img;
  1843. }
  1844. /* load texture if provided */
  1845. try {
  1846. !!source ? await loadTexture(source) : null;
  1847. } catch (err) {
  1848. logger.debug(err);
  1849. }
  1850. }
  1851. if(!!options) {
  1852. /* insert the better ActiveEffect default ONLY IF
  1853. * one wasn't provided in the options object initially
  1854. */
  1855. options.comparisonKeys = foundry.utils.mergeObject(
  1856. options.comparisonKeys ?? {},
  1857. {ActiveEffect: 'label'},
  1858. {overwrite:false, inplace:false});
  1859. /* if `id` is being used as the comparison key,
  1860. * change it to `_id` and set the option to `keepId=true`
  1861. * if either are present
  1862. */
  1863. options.comparisonKeys ??= {};
  1864. options.updateOpts ??= {};
  1865. Object.keys(options.comparisonKeys).forEach( embName => {
  1866. /* switch to _id if needed */
  1867. if(options.comparisonKeys[embName] == 'id') options.comparisonKeys[embName] = '_id';
  1868. /* flag this update to preserve ids */
  1869. if(options.comparisonKeys[embName] == '_id') {
  1870. foundry.utils.mergeObject(options.updateOpts, {embedded: {[embName]: {keepId: true}}});
  1871. }
  1872. });
  1873. }
  1874. }
  1875. static _mergeMutateDelta(actorDoc, delta, updates, options) {
  1876. /* Grab the current stack (or make a new one) */
  1877. let mutateStack = actorDoc.getFlag(MODULE.data.name, 'mutate') ?? [];
  1878. /* create the information needed to revert this mutation and push
  1879. * it onto the stack
  1880. */
  1881. const mutateInfo = Mutator._createMutateInfo( delta, options );
  1882. mutateStack.push(mutateInfo);
  1883. /* Create a new mutation stack flag data and store it in the update object */
  1884. const flags = {warpgate: {mutate: mutateStack}};
  1885. updates.actor = mergeObject(updates.actor ?? {}, {flags});
  1886. return mutateInfo;
  1887. }
  1888. /* @return {Promise} */
  1889. static async _update(tokenDoc, updates, options = {}) {
  1890. /* update the token */
  1891. await tokenDoc.update(updates.token ?? {}, options.updateOpts?.token ?? {});
  1892. if(!options.noMoveWait && !!tokenDoc.object) {
  1893. await CanvasAnimation.getAnimation(tokenDoc.object.animationName)?.promise;
  1894. }
  1895. /* update the actor */
  1896. return Mutator._updateActor(tokenDoc.actor, updates, options.comparisonKeys ?? {}, options.updateOpts ?? {});
  1897. }
  1898. /**
  1899. * Will peel off the last applied mutation change from the provided token document
  1900. *
  1901. * @param {TokenDocument} tokenDoc Token document to revert the last applied mutation.
  1902. * @param {String} [mutationName]. Specific mutation name to revert. optional.
  1903. * @param {WorkflowOptions} [options]
  1904. *
  1905. * @return {Promise<MutationData|undefined>} The mutation data (updates) used for this
  1906. * revert operation or `undefined` if none occured.
  1907. */
  1908. static async revertMutation(tokenDoc, mutationName = undefined, options = {}) {
  1909. const mutateData = await Mutator._popMutation(tokenDoc?.actor, mutationName);
  1910. if(!mutateData) {
  1911. return;
  1912. }
  1913. if (tokenDoc.actor?.isOwner) {
  1914. if(options.notice && tokenDoc.object) {
  1915. const placement = {
  1916. scene: tokenDoc.object.scene,
  1917. ...tokenDoc.object.center,
  1918. };
  1919. warpgate.plugin.notice(placement, options.notice);
  1920. }
  1921. /* the provided options object will be mangled for our use -- copy it to
  1922. * preserve the user's original input if requested (default).
  1923. */
  1924. if(!options.overrides?.preserveData) {
  1925. options = MODULE.copy(options, 'error.badUpdate.complex');
  1926. if(!options) return;
  1927. options = foundry.utils.mergeObject(options, {overrides: {preserveData: true}}, {inplace: false});
  1928. }
  1929. /* perform the revert with the stored delta */
  1930. MODULE.shimUpdate(mutateData.delta);
  1931. mutateData.updateOpts ??= {};
  1932. mutateData.overrides ??= {};
  1933. foundry.utils.mergeObject(mutateData.updateOpts, options.updateOpts ?? {});
  1934. foundry.utils.mergeObject(mutateData.overrides, options.overrides ?? {});
  1935. await Mutator._update(tokenDoc, mutateData.delta, {
  1936. overrides: mutateData.overrides,
  1937. comparisonKeys: mutateData.comparisonKeys,
  1938. updateOpts: mutateData.updateOpts
  1939. });
  1940. /* notify clients */
  1941. warpgate.event.notify(warpgate.EVENT.REVERT, {
  1942. uuid: tokenDoc.uuid,
  1943. name: mutateData.name,
  1944. updates: (options.overrides?.includeRawData ?? false) ? mutateData : 'omitted',
  1945. options});
  1946. } else {
  1947. return remoteRevert(tokenDoc, {mutationId: mutateData.name, options});
  1948. }
  1949. return mutateData;
  1950. }
  1951. static async _popMutation(actor, mutationName) {
  1952. let mutateStack = actor?.getFlag(MODULE.data.name, 'mutate') ?? [];
  1953. if (mutateStack.length == 0 || !actor){
  1954. logger.debug(`Provided actor is undefined or has no mutation stack. Cannot pop.`);
  1955. return undefined;
  1956. }
  1957. let mutateData = undefined;
  1958. if (!!mutationName) {
  1959. /* find specific mutation */
  1960. const index = mutateStack.findIndex( mutation => mutation.name === mutationName );
  1961. /* check for no result and log */
  1962. if ( index < 0 ) {
  1963. logger.debug(`Could not locate mutation named ${mutationName} in actor ${actor.name}`);
  1964. return undefined;
  1965. }
  1966. /* otherwise, retrieve and remove */
  1967. mutateData = mutateStack.splice(index, 1)[0];
  1968. for( let i = index; i < mutateStack.length; i++){
  1969. /* get the values stored in our delta and push any overlapping ones to
  1970. * the mutation next in the stack
  1971. */
  1972. const stackUpdate = filterObject(mutateData.delta, mutateStack[i].delta);
  1973. mergeObject(mutateStack[i].delta, stackUpdate);
  1974. /* remove any changes that exist higher in the stack, we have
  1975. * been overriden and should not restore these values
  1976. */
  1977. mutateData.delta = MODULE.unique(mutateData.delta, mutateStack[i].delta);
  1978. }
  1979. } else {
  1980. /* pop the most recent mutation */
  1981. mutateData = mutateStack.pop();
  1982. }
  1983. const newFlags = {[`${MODULE.data.name}.mutate`]: mutateStack};
  1984. /* set the current mutation stack in the mutation data */
  1985. foundry.utils.mergeObject(mutateData.delta, {actor: {flags: newFlags}});
  1986. logger.debug(MODULE.localize('debug.finalRevertUpdate'), mutateData);
  1987. return mutateData;
  1988. }
  1989. /* given a token document and the standard update object,
  1990. * parse the changes that need to be applied to *reverse*
  1991. * the mutate operation
  1992. */
  1993. static _createDelta(tokenDoc, updates, options) {
  1994. /* get token changes */
  1995. let tokenData = tokenDoc.toObject();
  1996. //tokenData.actorData = {};
  1997. const tokenDelta = MODULE.strictUpdateDiff(updates.token ?? {}, tokenData);
  1998. /* get the actor changes (no embeds) */
  1999. const actorData = Mutator._getRootActorData(tokenDoc.actor);
  2000. const actorDelta = MODULE.strictUpdateDiff(updates.actor ?? {}, actorData);
  2001. /* get the changes from the embeds */
  2002. let embeddedDelta = {};
  2003. if(updates.embedded) {
  2004. for( const embeddedName of Object.keys(updates.embedded) ) {
  2005. const collection = tokenDoc.actor.getEmbeddedCollection(embeddedName);
  2006. const invertedShorthand = Mutator._invertShorthand(collection, updates.embedded[embeddedName], getProperty(options.comparisonKeys, embeddedName) ?? 'name');
  2007. embeddedDelta[embeddedName] = invertedShorthand;
  2008. }
  2009. }
  2010. logger.debug(MODULE.localize('debug.tokenDelta'), tokenDelta, MODULE.localize('debug.actorDelta'), actorDelta, MODULE.localize('debug.embeddedDelta'), embeddedDelta);
  2011. return {token: tokenDelta, actor: actorDelta, embedded: embeddedDelta}
  2012. }
  2013. /* returns the actor data sans ALL embedded collections */
  2014. static _getRootActorData(actorDoc) {
  2015. let actorData = actorDoc.toObject();
  2016. /* get the key NAME of the embedded document type.
  2017. * ex. not 'ActiveEffect' (the class name), 'effect' the collection's field name
  2018. */
  2019. let embeddedFields = Object.values(Actor.implementation.metadata.embedded);
  2020. /* delete any embedded fields from the actor data */
  2021. embeddedFields.forEach( field => { delete actorData[field]; } );
  2022. return actorData;
  2023. }
  2024. }
  2025. const register$3 = Mutator.register, mutate = Mutator.mutate, revertMutation = Mutator.revertMutation, batchMutate = Mutator.batchMutate, batchRevert = Mutator.batchRevert, clean = Mutator.clean, _updateActor = Mutator._updateActor;
  2026. /*
  2027. * This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
  2028. * Copyright (c) 2021 Matthew Haentschke.
  2029. *
  2030. * This program is free software: you can redistribute it and/or modify
  2031. * it under the terms of the GNU General Public License as published by
  2032. * the Free Software Foundation, version 3.
  2033. *
  2034. * This program is distributed in the hope that it will be useful, but
  2035. * WITHOUT ANY WARRANTY; without even the implied warranty of
  2036. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  2037. * General Public License for more details.
  2038. *
  2039. * You should have received a copy of the GNU General Public License
  2040. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  2041. */
  2042. class RemoteMutator {
  2043. static register() {
  2044. RemoteMutator.settings();
  2045. }
  2046. static settings() {
  2047. const config = true;
  2048. const settingsData = {
  2049. alwaysAccept: {
  2050. scope: 'world', config, default: false, type: Boolean
  2051. },
  2052. suppressToast: {
  2053. scope: 'world', config, default: false, type: Boolean
  2054. },
  2055. alwaysAcceptLocal: {
  2056. scope: 'client', config, default: 0, type: Number,
  2057. choices: {
  2058. 0: MODULE.localize('setting.option.useWorld'),
  2059. 1: MODULE.localize('setting.option.overrideTrue'),
  2060. 2: MODULE.localize('setting.option.overrideFalse'),
  2061. }
  2062. },
  2063. suppressToastLocal: {
  2064. scope: 'client', config, default: 0, type: Number,
  2065. choices: {
  2066. 0: MODULE.localize('setting.option.useWorld'),
  2067. 1: MODULE.localize('setting.option.overrideTrue'),
  2068. 2: MODULE.localize('setting.option.overrideFalse'),
  2069. }
  2070. },
  2071. };
  2072. MODULE.applySettings(settingsData);
  2073. }
  2074. //responseData:
  2075. //------
  2076. //sceneId
  2077. //userId
  2078. //-------
  2079. //accepted (bool)
  2080. //tokenId
  2081. //actorId
  2082. //mutationId
  2083. //updates (if mutate)
  2084. /* create the needed trigger functions if there is a post callback to handle */
  2085. static _createMutateTriggers( tokenDoc, {post = undefined}, options ) {
  2086. const condition = (responseData) => {
  2087. return responseData.tokenId === tokenDoc.id && responseData.mutationId === options.name;
  2088. };
  2089. /* craft the response handler
  2090. * execute the post callback */
  2091. const promise = new Promise( (resolve) => {
  2092. const handleResponse = async (responseData) => {
  2093. /* if accepted, run our post callback */
  2094. const tokenDoc = game.scenes.get(responseData.sceneId).getEmbeddedDocument('Token', responseData.tokenId);
  2095. if (responseData.accepted) {
  2096. const info = MODULE.format('display.mutationAccepted', {mName: options.name, tName: tokenDoc.name});
  2097. const {suppressToast} = MODULE.getFeedbackSettings(options.overrides);
  2098. if(!suppressToast) ui.notifications.info(info);
  2099. } else {
  2100. const warn = MODULE.format('display.mutationRejected', {mName: options.name, tName: tokenDoc.name});
  2101. if(!options.overrides?.suppressReject) ui.notifications.warn(warn);
  2102. }
  2103. /* only need to do this if we have a post callback */
  2104. if (post) await post(tokenDoc, responseData.updates, responseData.accepted);
  2105. resolve(responseData);
  2106. return;
  2107. };
  2108. warpgate.event.trigger(warpgate.EVENT.MUTATE_RESPONSE, handleResponse, condition);
  2109. });
  2110. return promise;
  2111. }
  2112. static _createRevertTriggers( tokenDoc, mutationName = undefined, {callbacks={}, options = {}} ) {
  2113. const condition = (responseData) => {
  2114. return responseData.tokenId === tokenDoc.id && (responseData.mutationId === mutationName || !mutationName);
  2115. };
  2116. /* if no name provided, we are popping the last one */
  2117. const mName = mutationName ? mutationName : warpgate.mutationStack(tokenDoc).last.name;
  2118. /* craft the response handler
  2119. * execute the post callback */
  2120. const promise = new Promise(async (resolve) => {
  2121. const handleResponse = async (responseData) => {
  2122. const tokenDoc = game.scenes.get(responseData.sceneId).getEmbeddedDocument('Token', responseData.tokenId);
  2123. /* if accepted, run our post callback */
  2124. if (responseData.accepted) {
  2125. const info = MODULE.format('display.revertAccepted', {mName , tName: tokenDoc.name});
  2126. const {suppressToast} = MODULE.getFeedbackSettings(options.overrides);
  2127. if(!suppressToast) ui.notifications.info(info);
  2128. } else {
  2129. const warn = MODULE.format('display.revertRejected', {mName , tName: tokenDoc.name});
  2130. if(!options.overrides?.suppressReject) ui.notifications.warn(warn);
  2131. }
  2132. await callbacks.post?.(tokenDoc, responseData.updates, responseData.accepted);
  2133. resolve(responseData);
  2134. return;
  2135. };
  2136. warpgate.event.trigger(warpgate.EVENT.REVERT_RESPONSE, handleResponse, condition);
  2137. });
  2138. return promise;
  2139. }
  2140. static remoteMutate( tokenDoc, {updates, callbacks = {}, options = {}} ) {
  2141. /* we need to make sure there is a user that can handle our resquest */
  2142. if (!MODULE.firstOwner(tokenDoc)) {
  2143. logger.error(MODULE.localize('error.noOwningUserMutate'));
  2144. return false;
  2145. }
  2146. /* register our trigger for monitoring remote response.
  2147. * This handles the post callback
  2148. */
  2149. const promise = RemoteMutator._createMutateTriggers( tokenDoc, callbacks, options );
  2150. /* broadcast the request to mutate the token */
  2151. requestMutate(tokenDoc.id, tokenDoc.parent.id, { updates, options });
  2152. return promise;
  2153. }
  2154. /**
  2155. *
  2156. * @returns {Promise<Array<Object>>}
  2157. */
  2158. static async remoteBatchMutate( tokenDocs, {updates, callbacks = {}, options = {}} ) {
  2159. /* follow normal protocol for initial requests.
  2160. * if accepted, force accept and force suppress remaining token mutations
  2161. * if rejected, bail on all further mutations for this owner */
  2162. const firstToken = tokenDocs.shift();
  2163. let results = [await warpgate.mutate(firstToken, updates, callbacks, options)];
  2164. if (results[0].accepted) {
  2165. const silentOptions = foundry.utils.mergeObject(options, { overrides: {alwaysAccept: true, suppressToast: true} }, {inplace: false});
  2166. results = results.concat(tokenDocs.map( tokenDoc => {
  2167. return warpgate.mutate(tokenDoc, updates, callbacks, silentOptions);
  2168. }));
  2169. } else {
  2170. results = results.concat(tokenDocs.map( tokenDoc => ({sceneId: tokenDoc.parent.id, tokenId: tokenDoc.id, accepted: false})));
  2171. }
  2172. return results;
  2173. }
  2174. static remoteRevert( tokenDoc, {mutationId = null, callbacks={}, options = {}} = {} ) {
  2175. /* we need to make sure there is a user that can handle our resquest */
  2176. if (!MODULE.firstOwner(tokenDoc)) {
  2177. logger.error(MODULE.format('error.noOwningUserRevert'));
  2178. return false;
  2179. }
  2180. /* register our trigger for monitoring remote response.
  2181. * This handles the post callback
  2182. */
  2183. const result = RemoteMutator._createRevertTriggers( tokenDoc, mutationId, {callbacks, options} );
  2184. /* broadcast the request to mutate the token */
  2185. requestRevert(tokenDoc.id, tokenDoc.parent.id, {mutationId, options});
  2186. return result;
  2187. }
  2188. /**
  2189. *
  2190. * @returns {Promise<Array<Object>>}
  2191. */
  2192. static async remoteBatchRevert( tokenDocs, {mutationName = null, options = {}} = {} ) {
  2193. /* follow normal protocol for initial requests.
  2194. * if accepted, force accept and force suppress remaining token mutations
  2195. * if rejected, bail on all further mutations for this owner */
  2196. let firstToken = tokenDocs.shift();
  2197. while( !!firstToken && warpgate.mutationStack(firstToken).stack.length == 0 ) firstToken = tokenDocs.shift();
  2198. if(!firstToken) return [];
  2199. const results = [await warpgate.revert(firstToken, mutationName, options)];
  2200. if(results[0].accepted) {
  2201. const silentOptions = foundry.utils.mergeObject(options, {
  2202. overrides: {alwaysAccept: true, suppressToast: true}
  2203. }, {inplace: false}
  2204. );
  2205. results.push(...(tokenDocs.map( tokenDoc => {
  2206. return warpgate.revert(tokenDoc, mutationName, silentOptions);
  2207. })));
  2208. } else {
  2209. results.push(...tokenDocs.map( tokenDoc => ({sceneId: tokenDoc.parent.id, tokenId: tokenDoc.id, accepted: false})));
  2210. }
  2211. return results;
  2212. }
  2213. static async handleMutationRequest(payload) {
  2214. /* First, are we the first player owner? If not, stop, they will handle it */
  2215. const tokenDoc = game.scenes.get(payload.sceneId).getEmbeddedDocument('Token', payload.tokenId);
  2216. if (MODULE.isFirstOwner(tokenDoc.actor)) {
  2217. let {alwaysAccept: accepted, suppressToast} = MODULE.getFeedbackSettings(payload.options.overrides);
  2218. if(!accepted) {
  2219. accepted = await RemoteMutator._queryRequest(tokenDoc, payload.userId, payload.options.description, payload.updates);
  2220. /* if a dialog is shown, the user knows the outcome */
  2221. suppressToast = true;
  2222. }
  2223. let responseData = {
  2224. sceneId: payload.sceneId,
  2225. userId: game.user.id,
  2226. accepted,
  2227. tokenId: payload.tokenId,
  2228. mutationId: payload.options.name,
  2229. options: payload.options,
  2230. };
  2231. await warpgate.event.notify(warpgate.EVENT.MUTATE_RESPONSE, responseData);
  2232. if (accepted) {
  2233. /* first owner accepts mutation -- apply it */
  2234. /* requests will never have callbacks */
  2235. await mutate(tokenDoc, payload.updates, {}, payload.options);
  2236. const message = MODULE.format('display.mutationRequestTitle', {userName: game.users.get(payload.userId).name, tokenName: tokenDoc.name});
  2237. if(!suppressToast) ui.notifications.info(message);
  2238. }
  2239. }
  2240. }
  2241. static async handleRevertRequest(payload) {
  2242. /* First, are we the first player owner? If not, stop, they will handle it */
  2243. const tokenDoc = game.scenes.get(payload.sceneId).getEmbeddedDocument('Token', payload.tokenId);
  2244. if (MODULE.isFirstOwner(tokenDoc.actor)) {
  2245. const stack = warpgate.mutationStack(tokenDoc);
  2246. if( (stack.stack ?? []).length == 0 ) return;
  2247. const details = payload.mutationId ? stack.getName(payload.mutationId) : stack.last;
  2248. const description = MODULE.format('display.revertRequestDescription', {mName: details.name, tName: tokenDoc.name});
  2249. let {alwaysAccept: accepted, suppressToast} = MODULE.getFeedbackSettings(payload.options.overrides);
  2250. if(!accepted) {
  2251. accepted = await RemoteMutator._queryRequest(tokenDoc, payload.userId, description, details );
  2252. suppressToast = true;
  2253. }
  2254. let responseData = {
  2255. sceneId: payload.sceneId,
  2256. userId: game.user.id,
  2257. accepted,
  2258. tokenId: payload.tokenId,
  2259. mutationId: payload.mutationId
  2260. };
  2261. await warpgate.event.notify(warpgate.EVENT.REVERT_RESPONSE, responseData);
  2262. /* if the request is accepted, do the revert */
  2263. if (accepted) {
  2264. await revertMutation(tokenDoc, payload.mutationId, payload.options);
  2265. if (!suppressToast) {
  2266. ui.notifications.info(description);
  2267. }
  2268. }
  2269. }
  2270. }
  2271. static async _queryRequest(tokenDoc, requestingUserId, description = 'warpgate.display.emptyDescription', detailsObject) {
  2272. /* if this is update data, dont include the mutate data please, its huge */
  2273. let displayObject = duplicate(detailsObject);
  2274. if (displayObject.actor?.flags?.warpgate) {
  2275. displayObject.actor.flags.warpgate = {};
  2276. }
  2277. displayObject = MODULE.removeEmptyObjects(displayObject);
  2278. const details = RemoteMutator._convertObjToHTML(displayObject);
  2279. const modeSwitch = {
  2280. description: {label: MODULE.localize('display.inspectLabel'), value: 'inspect', content: `<p>${game.i18n.localize(description)}</p>`},
  2281. inspect: {label: MODULE.localize('display.descriptionLabel'), value: 'description', content: details }
  2282. };
  2283. const title = MODULE.format('display.mutationRequestTitle', {userName: game.users.get(requestingUserId).name, tokenName: tokenDoc.name});
  2284. let userResponse = false;
  2285. let modeButton = modeSwitch.description;
  2286. do {
  2287. userResponse = await warpgate.buttonDialog({buttons: [{label: MODULE.localize('display.findTargetLabel'), value: 'select'}, {label: MODULE.localize('display.acceptLabel'), value: true}, {label: MODULE.localize('display.rejectLabel'), value: false}, modeButton], content: modeButton.content, title, options: {top: 100}});
  2288. if (userResponse === 'select') {
  2289. if (tokenDoc.object) {
  2290. tokenDoc.object.control({releaseOthers: true});
  2291. await canvas.animatePan({x: tokenDoc.object.x, y: tokenDoc.object.y});
  2292. }
  2293. } else if (userResponse !== false && userResponse !== true) {
  2294. /* swap modes and re-render */
  2295. modeButton = modeSwitch[userResponse];
  2296. }
  2297. } while (userResponse !== false && userResponse !== true)
  2298. return userResponse;
  2299. }
  2300. static _convertObjToHTML(obj) {
  2301. const stringified = JSON.stringify(obj, undefined, '$SPACING');
  2302. return stringified.replaceAll('\n', '<br>').replaceAll('$SPACING', '&nbsp;&nbsp;&nbsp;&nbsp;');
  2303. }
  2304. }
  2305. const register$2 = RemoteMutator.register, handleMutationRequest = RemoteMutator.handleMutationRequest, handleRevertRequest = RemoteMutator.handleRevertRequest, remoteMutate = RemoteMutator.remoteMutate, remoteRevert = RemoteMutator.remoteRevert, remoteBatchMutate = RemoteMutator.remoteBatchMutate, remoteBatchRevert = RemoteMutator.remoteBatchRevert;
  2306. /*
  2307. * MIT License
  2308. *
  2309. * Copyright (c) 2021 DnD5e Helpers Team
  2310. *
  2311. * Permission is hereby granted, free of charge, to any person obtaining a copy
  2312. * of this software and associated documentation files (the "Software"), to deal
  2313. * in the Software without restriction, including without limitation the rights
  2314. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  2315. * copies of the Software, and to permit persons to whom the Software is
  2316. * furnished to do so, subject to the following conditions:
  2317. *
  2318. * The above copyright notice and this permission notice shall be included in all
  2319. * copies or substantial portions of the Software.
  2320. *
  2321. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  2322. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  2323. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  2324. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  2325. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  2326. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  2327. * SOFTWARE.
  2328. */
  2329. let updateQueues = new Map();
  2330. /**
  2331. * Safely manages concurrent updates to the provided entity type
  2332. * @function warpgate.plugin.queueUpdate
  2333. * @param {Function} updateFn the function that handles the actual update (can be async)
  2334. */
  2335. function queueUpdate(updateFn) {
  2336. /** queue the update for this entity */
  2337. getQueue().queueUpdate(updateFn);
  2338. }
  2339. function getQueue(entity = "default"){
  2340. /** if this is a new entity type, create the queue object to manage it */
  2341. if(!updateQueues.has(entity)) {
  2342. updateQueues.set(entity, new UpdateQueue(entity));
  2343. }
  2344. /** queue the update for this entity */
  2345. return updateQueues.get(entity);
  2346. }
  2347. /**
  2348. * Helper class to manage database updates that occur from
  2349. * hooks that may fire back to back.
  2350. * @ignore
  2351. */
  2352. class UpdateQueue {
  2353. constructor(entityType) {
  2354. /** self identification */
  2355. this.entityType = entityType;
  2356. /** buffer of update functions to run */
  2357. this.queue = [];
  2358. /** Semaphore for 'batch update in progress' */
  2359. this.inFlight = false;
  2360. }
  2361. queueUpdate(fn) {
  2362. this.queue.push(fn);
  2363. /** only kick off a batch of updates if none are in flight */
  2364. if (!this.inFlight) {
  2365. this.runUpdate();
  2366. }
  2367. }
  2368. flush() {
  2369. return MODULE.waitFor( () => !this.inFlight )
  2370. }
  2371. async runUpdate(){
  2372. this.inFlight = true;
  2373. while(this.queue.length > 0) {
  2374. /** grab the last update in the list and hold onto its index
  2375. * in case another update pushes onto this array before we
  2376. * are finished.
  2377. */
  2378. const updateIndex = this.queue.length-1;
  2379. const updateFn = this.queue[updateIndex];
  2380. /** wait for the update to complete */
  2381. await updateFn();
  2382. /** remove this entry from the queue */
  2383. this.queue.splice(updateIndex,1);
  2384. }
  2385. this.inFlight = false;
  2386. }
  2387. }
  2388. /*
  2389. * This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
  2390. * Copyright (c) 2021 Matthew Haentschke.
  2391. *
  2392. * This program is free software: you can redistribute it and/or modify
  2393. * it under the terms of the GNU General Public License as published by
  2394. * the Free Software Foundation, version 3.
  2395. *
  2396. * This program is distributed in the hope that it will be useful, but
  2397. * WITHOUT ANY WARRANTY; without even the implied warranty of
  2398. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  2399. * General Public License for more details.
  2400. *
  2401. * You should have received a copy of the GNU General Public License
  2402. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  2403. */
  2404. const ops = {
  2405. DISMISS_SPAWN: "dismiss", //tokenId, sceneId, userId
  2406. EVENT: "event", //name, ...payload
  2407. REQUEST_MUTATE: "req-mutate", // ...payload
  2408. REQUEST_REVERT: "req-revert", // ...payload
  2409. NOTICE: "req-notice",
  2410. };
  2411. class Comms {
  2412. static register() {
  2413. Comms.hooks();
  2414. }
  2415. static hooks() {
  2416. Hooks.on("ready", Comms._ready);
  2417. }
  2418. static _ready() {
  2419. logger.info("Registering sockets");
  2420. game.socket.on(`module.${MODULE.data.name}`, Comms._receiveSocket);
  2421. }
  2422. static _receiveSocket(socketData) {
  2423. logger.debug("Received socket data => ", socketData);
  2424. /* all users should immediately respond to notices */
  2425. if (socketData.op == ops.NOTICE) {
  2426. MODULE.handleNotice(
  2427. socketData.payload.location,
  2428. socketData.payload.sceneId,
  2429. socketData.payload.options
  2430. );
  2431. return socketData;
  2432. }
  2433. queueUpdate(async () => {
  2434. logger.debug("Routing operation: ", socketData.op);
  2435. switch (socketData.op) {
  2436. case ops.DISMISS_SPAWN:
  2437. await handleDismissSpawn(socketData.payload);
  2438. break;
  2439. case ops.EVENT:
  2440. /* all users should respond to events */
  2441. await Events.run(socketData.eventName, socketData.payload);
  2442. break;
  2443. case ops.REQUEST_MUTATE:
  2444. /* First owner of this target token/actor should respond */
  2445. await handleMutationRequest(socketData.payload);
  2446. break;
  2447. case ops.REQUEST_REVERT:
  2448. /* First owner of this target token/actor should respond */
  2449. await handleRevertRequest(socketData.payload);
  2450. break;
  2451. default:
  2452. logger.error("Unrecognized socket request", socketData);
  2453. break;
  2454. }
  2455. });
  2456. return socketData;
  2457. }
  2458. static _emit(socketData) {
  2459. game.socket.emit(`module.${MODULE.data.name}`, socketData);
  2460. /* always send events to self as well */
  2461. return Comms._receiveSocket(socketData);
  2462. }
  2463. static requestDismissSpawn(tokenId, sceneId) {
  2464. /** craft the socket data */
  2465. const data = {
  2466. op: ops.DISMISS_SPAWN,
  2467. payload: { tokenId, sceneId, userId: game.user.id },
  2468. };
  2469. return Comms._emit(data);
  2470. }
  2471. /*
  2472. * payload = {userId, tokenId, sceneId, updates, options}
  2473. */
  2474. static requestMutate(
  2475. tokenId,
  2476. sceneId,
  2477. { updates = {}, options = {} } = {},
  2478. onBehalf = game.user.id
  2479. ) {
  2480. /* insert common fields */
  2481. const payload = {
  2482. userId: onBehalf,
  2483. tokenId,
  2484. sceneId,
  2485. updates,
  2486. options,
  2487. };
  2488. /* craft the socket data */
  2489. const data = {
  2490. op: ops.REQUEST_MUTATE,
  2491. payload,
  2492. };
  2493. return Comms._emit(data);
  2494. }
  2495. static requestRevert(
  2496. tokenId,
  2497. sceneId,
  2498. { mutationId = undefined, onBehalf = game.user.id, options = {} }
  2499. ) {
  2500. /* insert common fields */
  2501. const payload = {
  2502. userId: onBehalf,
  2503. tokenId,
  2504. sceneId,
  2505. mutationId,
  2506. options,
  2507. };
  2508. /* craft the socket data */
  2509. const data = {
  2510. op: ops.REQUEST_REVERT,
  2511. payload,
  2512. };
  2513. return Comms._emit(data);
  2514. }
  2515. static requestNotice(location, sceneId = canvas.scene?.id, options = {}) {
  2516. const data = {
  2517. op: ops.NOTICE,
  2518. payload: {
  2519. sceneId,
  2520. location,
  2521. options,
  2522. },
  2523. };
  2524. return Comms._emit(data);
  2525. }
  2526. static packToken(tokenDoc) {
  2527. const tokenData = tokenDoc.toObject();
  2528. delete tokenData.actorData;
  2529. delete tokenData.delta;
  2530. let actorData = tokenDoc.actor?.toObject() ?? {};
  2531. actorData.token = tokenData;
  2532. return actorData;
  2533. }
  2534. /**
  2535. * Allow custom events to be fired using the Warp Gate event system. Is broadcast to all users, including the initiator.
  2536. * Like Hooks, these functions cannot be awaited for a response, but all event functions executing on a given client
  2537. * will be evaluated in order of initial registration and the processing of the event functions will respect
  2538. * (and await) returned Promises.
  2539. *
  2540. * @param {string} name Name of this event. Watches and triggers use this name to register themselves.
  2541. * Like Hooks, any string can be used and it is dependent upon the watch or trigger registration to monitor the correct event name.
  2542. * @param {object} [payload={sceneId: canvas.scene.id, userId: game.user.id}] eventData {Object} The data that will be
  2543. * provided to watches and triggers and their condition functions.
  2544. * @param {string} [onBehalf=game.user.id] User ID that will be used in place of the current user in the
  2545. * cases of a relayed request to the GM (e.g. dismissal).
  2546. *
  2547. * @returns {Object} Data object containing the event's payload (execution details), and identifying metadata about
  2548. * this event, sent to all watching and triggering clients.
  2549. */
  2550. static notifyEvent(name, payload = {}, onBehalf = game.user?.id) {
  2551. /** insert common fields */
  2552. payload.sceneId = canvas.scene?.id;
  2553. payload.userId = onBehalf;
  2554. /* craft the socket data */
  2555. const data = {
  2556. op: ops.EVENT,
  2557. eventName: name,
  2558. payload,
  2559. };
  2560. return Comms._emit(data);
  2561. }
  2562. }
  2563. const register$1 = Comms.register,
  2564. requestMutate = Comms.requestMutate,
  2565. requestRevert = Comms.requestRevert,
  2566. packToken = Comms.packToken,
  2567. requestDismissSpawn = Comms.requestDismissSpawn,
  2568. notifyEvent = Comms.notifyEvent,
  2569. requestNotice = Comms.requestNotice;
  2570. /*
  2571. * This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
  2572. * Copyright (c) 2021 Matthew Haentschke.
  2573. *
  2574. * This program is free software: you can redistribute it and/or modify
  2575. * it under the terms of the GNU General Public License as published by
  2576. * the Free Software Foundation, version 3.
  2577. *
  2578. * This program is distributed in the hope that it will be useful, but
  2579. * WITHOUT ANY WARRANTY; without even the implied warranty of
  2580. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  2581. * General Public License for more details.
  2582. *
  2583. * You should have received a copy of the GNU General Public License
  2584. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  2585. */
  2586. const NAME = "Gateway";
  2587. /** @typedef {import('./api.js').CrosshairsConfig} CrosshairsConfig */
  2588. /** @typedef {import('./crosshairs.js').CrosshairsData} CrosshairsData */
  2589. /** @typedef {import('./lib/PlaceableFit.mjs').PlaceableFit} PlaceableFit */
  2590. /**
  2591. * Callback started just prior to the crosshairs template being drawn. Is not awaited. Used for modifying
  2592. * how the crosshairs is displayed and for responding to its displayed position
  2593. *
  2594. * All of the fields in the {@link CrosshairsConfig} object can be modified directly. Any fields owned by
  2595. * MeasuredTemplate must be changed via `update|updateSource` as other DocumentData|DataModel classes.
  2596. * Async functions will run in parallel while the user is moving the crosshairs. Serial functions will
  2597. * block detection of the left and right click operations until return.
  2598. *
  2599. * @typedef {function(Crosshairs):any} ParallelShow
  2600. * @param {Crosshairs} crosshairs The live Crosshairs instance associated with this callback
  2601. *
  2602. * @returns {any}
  2603. */
  2604. /**
  2605. * @class
  2606. * @private
  2607. */
  2608. class Gateway {
  2609. static register() {
  2610. Gateway.settings();
  2611. Gateway.defaults();
  2612. }
  2613. static settings() {
  2614. const config = true;
  2615. const settingsData = {
  2616. openDelete: {
  2617. scope: "world",
  2618. config,
  2619. default: false,
  2620. type: Boolean,
  2621. },
  2622. updateDelay: {
  2623. scope: "client",
  2624. config,
  2625. default: 0,
  2626. type: Number,
  2627. },
  2628. };
  2629. MODULE.applySettings(settingsData);
  2630. }
  2631. static defaults() {
  2632. MODULE[NAME] = {
  2633. /**
  2634. * type {CrosshairsConfig}
  2635. * @const
  2636. */
  2637. get crosshairsConfig() {
  2638. return {
  2639. size: 1,
  2640. icon: "icons/svg/dice-target.svg",
  2641. label: "",
  2642. labelOffset: {
  2643. x: 0,
  2644. y: 0,
  2645. },
  2646. tag: "crosshairs",
  2647. drawIcon: true,
  2648. drawOutline: true,
  2649. interval: 2,
  2650. fillAlpha: 0,
  2651. tileTexture: false,
  2652. lockSize: true,
  2653. lockPosition: false,
  2654. rememberControlled: false,
  2655. //Measured template defaults
  2656. texture: null,
  2657. //x: 0,
  2658. //y: 0,
  2659. direction: 0,
  2660. fillColor: game.user.color,
  2661. };
  2662. },
  2663. };
  2664. }
  2665. /**
  2666. * dnd5e helper function
  2667. * @param { Item5e } item
  2668. * @param {Object} [options={}]
  2669. * @param {Object} [config={}] V10 Only field
  2670. * @todo abstract further out of core code
  2671. */
  2672. static async _rollItemGetLevel(item, options = {}, config = {}) {
  2673. const result = await item.use(config, options);
  2674. // extract the level at which the spell was cast
  2675. if (!result) return 0;
  2676. const content = result.content;
  2677. const level = content.charAt(content.indexOf("data-spell-level") + 18);
  2678. return parseInt(level);
  2679. }
  2680. /**
  2681. * Displays a circular template attached to the mouse cursor that snaps to grid centers
  2682. * and grid intersections.
  2683. *
  2684. * Its size is in grid squares/hexes and can be scaled up and down via shift+mouse scroll.
  2685. * Resulting data indicates the final position and size of the template. Note: Shift+Scroll
  2686. * will increase/decrease the size of the crosshairs outline, which increases or decreases
  2687. * the size of the token spawned, independent of other modifications.
  2688. *
  2689. * @param {CrosshairsConfig} [config] Configuration settings for how the crosshairs template should be displayed.
  2690. * @param {Object} [callbacks] Functions executed at certain stages of the crosshair display process.
  2691. * @param {ParallelShow} [callbacks.show]
  2692. *
  2693. * @returns {Promise<CrosshairsData>} All fields contained by `MeasuredTemplateDocument#toObject`. Notably `x`, `y`,
  2694. * `width` (in pixels), and the addition of `size` (final size, in grid units, e.g. "2" for a final diameter of 2 squares).
  2695. *
  2696. */
  2697. static async showCrosshairs(config = {}, callbacks = {}) {
  2698. /* add in defaults */
  2699. mergeObject(config, MODULE[NAME].crosshairsConfig, { overwrite: false });
  2700. /* store currently controlled tokens */
  2701. let controlled = [];
  2702. if (config.rememberControlled) {
  2703. controlled = canvas.tokens.controlled;
  2704. }
  2705. /* if a specific initial location is not provided, grab the current mouse location */
  2706. if (!config.hasOwnProperty("x") && !config.hasOwnProperty("y")) {
  2707. let mouseLoc = MODULE.getMouseStagePos();
  2708. mouseLoc = Crosshairs.getSnappedPosition(mouseLoc, config.interval);
  2709. config.x = mouseLoc.x;
  2710. config.y = mouseLoc.y;
  2711. }
  2712. const template = new Crosshairs(config, callbacks);
  2713. await template.drawPreview();
  2714. const dataObj = template.toObject();
  2715. /* if we have stored any controlled tokens,
  2716. * restore that control now
  2717. */
  2718. for (const token of controlled) {
  2719. token.control({ releaseOthers: false });
  2720. }
  2721. return dataObj;
  2722. }
  2723. /* tests if a placeable's center point is within
  2724. * the radius of the crosshairs
  2725. */
  2726. static _containsCenter(placeable, crosshairsData) {
  2727. const calcDistance = (A, B) => {
  2728. return Math.hypot(A.x - B.x, A.y - B.y);
  2729. };
  2730. const distance = calcDistance(placeable.center, crosshairsData);
  2731. return distance <= crosshairsData.radius;
  2732. }
  2733. /**
  2734. * Returns desired types of placeables whose center point
  2735. * is within the crosshairs radius.
  2736. *
  2737. * @param {Object} crosshairsData Requires at least {x,y,radius,parent} (all in pixels, parent is a Scene)
  2738. * @param {String|Array<String>} [types='Token'] Collects the desired embedded placeable types.
  2739. * @param {Function} [containedFilter=Gateway._containsCenter]. Optional function for determining if a placeable
  2740. * is contained by the crosshairs. Default function tests for centerpoint containment. {@link Gateway._containsCenter}
  2741. *
  2742. * @return {Object<String,PlaceableObject>} List of collected placeables keyed by embeddedName
  2743. */
  2744. static collectPlaceables(
  2745. crosshairsData,
  2746. types = "Token",
  2747. containedFilter = Gateway._containsCenter
  2748. ) {
  2749. const isArray = types instanceof Array;
  2750. types = isArray ? types : [types];
  2751. const result = types.reduce((acc, embeddedName) => {
  2752. const collection =
  2753. crosshairsData.scene.getEmbeddedCollection(embeddedName);
  2754. let contained = collection.filter((document) => {
  2755. return containedFilter(document.object, crosshairsData);
  2756. });
  2757. acc[embeddedName] = contained;
  2758. return acc;
  2759. }, {});
  2760. /* if we are only collecting one kind of placeable, only return one kind of placeable */
  2761. return isArray ? result : result[types[0]];
  2762. }
  2763. static async handleDismissSpawn({ tokenId, sceneId, userId, ...rest }) {
  2764. /* let the first GM handle all dismissals */
  2765. if (MODULE.isFirstGM())
  2766. await Gateway.dismissSpawn(tokenId, sceneId, userId);
  2767. }
  2768. /**
  2769. * Deletes the specified token from the specified scene. This function allows anyone
  2770. * to delete any specified token unless this functionality is restricted to only
  2771. * owned tokens in Warp Gate's module settings. This is the same function called
  2772. * by the "Dismiss" header button on owned actor sheets.
  2773. *
  2774. * @param {string} tokenId
  2775. * @param {string} [sceneId = canvas.scene.id] Needed if the dismissed token does not reside
  2776. * on the currently viewed scene
  2777. * @param {string} [onBehalf = game.user.id] Impersonate another user making this request
  2778. */
  2779. static async dismissSpawn(
  2780. tokenId,
  2781. sceneId = canvas.scene?.id,
  2782. onBehalf = game.user.id
  2783. ) {
  2784. if (!tokenId || !sceneId) {
  2785. logger.debug(
  2786. "Cannot dismiss null token or from a null scene.",
  2787. tokenId,
  2788. sceneId
  2789. );
  2790. return;
  2791. }
  2792. const tokenData = game.scenes
  2793. .get(sceneId)
  2794. ?.getEmbeddedDocument("Token", tokenId);
  2795. if (!tokenData) {
  2796. logger.debug(`Token [${tokenId}] no longer exists on scene [${sceneId}]`);
  2797. return;
  2798. }
  2799. /* check for permission to delete freely */
  2800. if (!MODULE.setting("openDelete")) {
  2801. /* check permissions on token */
  2802. if (!tokenData.isOwner) {
  2803. logger.error(MODULE.localize("error.unownedDelete"));
  2804. return;
  2805. }
  2806. }
  2807. logger.debug(`Deleting ${tokenData.uuid}`);
  2808. if (!MODULE.firstGM()) {
  2809. logger.error(MODULE.localize("error.noGm"));
  2810. return;
  2811. }
  2812. /** first gm drives */
  2813. if (MODULE.isFirstGM()) {
  2814. if( tokenData.isLinked ) {
  2815. logger.debug('...and removing control flag from linked token actor');
  2816. await tokenData.actor?.unsetFlag(MODULE.data.name, 'control');
  2817. }
  2818. const tokenDocs = await game.scenes
  2819. .get(sceneId)
  2820. .deleteEmbeddedDocuments("Token", [tokenId]);
  2821. const actorData = packToken(tokenDocs[0]);
  2822. await warpgate.event.notify(
  2823. warpgate.EVENT.DISMISS,
  2824. { actorData },
  2825. onBehalf
  2826. );
  2827. } else {
  2828. /** otherwise, we need to send a request for deletion */
  2829. requestDismissSpawn(tokenId, sceneId);
  2830. }
  2831. return;
  2832. }
  2833. /**
  2834. * returns promise of token creation
  2835. * @param {TokenData} protoToken
  2836. * @param {{ x: number, y: number }} spawnPoint
  2837. * @param {boolean} collision
  2838. */
  2839. static async _spawnTokenAtLocation(protoToken, spawnPoint, collision) {
  2840. // Increase this offset for larger summons
  2841. const gridSize = canvas.scene.grid.size;
  2842. let loc = {
  2843. x: spawnPoint.x - gridSize * (protoToken.width / 2),
  2844. y: spawnPoint.y - gridSize * (protoToken.height / 2),
  2845. };
  2846. /* call ripper's placement algorithm for collision checks
  2847. * which will try to avoid tokens and walls
  2848. */
  2849. if (collision) {
  2850. /** @type PlaceableFit */
  2851. const PFit = warpgate.abstract.PlaceableFit;
  2852. const fitter = new PFit({...loc, width: gridSize * protoToken.width, height: gridSize * protoToken.height});
  2853. const openPosition = fitter.find();
  2854. if (!openPosition) {
  2855. logger.info(MODULE.localize("error.noOpenLocation"));
  2856. } else {
  2857. loc = openPosition;
  2858. }
  2859. }
  2860. protoToken.updateSource(loc);
  2861. return canvas.scene.createEmbeddedDocuments("Token", [protoToken]);
  2862. }
  2863. }
  2864. const register = Gateway.register,
  2865. dismissSpawn = Gateway.dismissSpawn,
  2866. showCrosshairs = Gateway.showCrosshairs,
  2867. collectPlaceables = Gateway.collectPlaceables,
  2868. _rollItemGetLevel = Gateway._rollItemGetLevel,
  2869. handleDismissSpawn = Gateway.handleDismissSpawn,
  2870. _spawnTokenAtLocation = Gateway._spawnTokenAtLocation;
  2871. /*
  2872. * This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
  2873. * Copyright (c) 2021 Matthew Haentschke.
  2874. *
  2875. * This program is free software: you can redistribute it and/or modify
  2876. * it under the terms of the GNU General Public License as published by
  2877. * the Free Software Foundation, version 3.
  2878. *
  2879. * This program is distributed in the hope that it will be useful, but
  2880. * WITHOUT ANY WARRANTY; without even the implied warranty of
  2881. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  2882. * General Public License for more details.
  2883. *
  2884. * You should have received a copy of the GNU General Public License
  2885. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  2886. */
  2887. /** @typedef {import('./api.js').Shorthand} Shorthand */
  2888. /** @typedef {import('./api.js').ComparisonKeys} ComparisonKeys */
  2889. /**
  2890. * @typedef {Object} MutationData
  2891. * @property {Shorthand} delta
  2892. * @property {string} user
  2893. * @property {ComparisonKeys} comparisonKeys
  2894. * @property {Shorthand} updateOpts
  2895. * @property {object} overrides
  2896. * @property {string} name
  2897. */
  2898. /**
  2899. * The following class and its utility methods allows safer and more direct modification of the mutation stack,
  2900. * which is stored on a token's actor. This mutation stack stores the information needed to _revert_ the changes
  2901. * made by a mutation. This could be used, for example, to deal with rollover damage where the hit point value
  2902. * being reverted to is lower than when the mutation was first applied.
  2903. *
  2904. * Searching and querying a given mutation is a quick, read-only process. When the mutation stack is modified
  2905. * via one of its class methods, the actor's mutation data at that point in time will be copied for fast, local updates.
  2906. *
  2907. * No changes will be made to the actor's serialized data until the changes have been commited ({@link MutationStack#commit}).
  2908. * The MutationStack object will then be locked back into a read-only state sourced with this newly updated data.
  2909. */
  2910. class MutationStack {
  2911. constructor(tokenDoc) {
  2912. this.actor = tokenDoc instanceof TokenDocument ? tokenDoc.actor :
  2913. tokenDoc instanceof Token ? tokenDoc.document.actor :
  2914. tokenDoc instanceof Actor ? tokenDoc :
  2915. null;
  2916. if(!this.actor) {
  2917. throw new Error(MODULE.localize('error.stack.noActor'));
  2918. }
  2919. }
  2920. /**
  2921. * Private copy of the working stack (mutable)
  2922. * @type {Array<MutationData>}
  2923. */
  2924. #stack = [];
  2925. /** indicates if the stack has been duplicated for modification */
  2926. #locked = true;
  2927. /**
  2928. * Current stack, according to the remote server (immutable)
  2929. * @const
  2930. * @type {Array<MutationData>}
  2931. */
  2932. get #liveStack() {
  2933. // @ts-ignore
  2934. return this.actor?.getFlag(MODULE.data.name, 'mutate') ?? []
  2935. }
  2936. /**
  2937. * Mutation stack according to its lock state.
  2938. * @type {Array<MutationData>}
  2939. */
  2940. get stack() {
  2941. return this.#locked ? this.#liveStack : this.#stack ;
  2942. }
  2943. /**
  2944. * @callback FilterFn
  2945. * @param {MutationData} mutation
  2946. * @returns {boolean} provided mutation meets criteria
  2947. * @memberof MutationStack
  2948. */
  2949. /**
  2950. * Searches for an element of the mutation stack that satisfies the provided predicate
  2951. *
  2952. * @param {FilterFn} predicate Receives the argments of `Array.prototype.find`
  2953. * and should return a boolean indicating if the current element satisfies the predicate condition
  2954. * @return {MutationData|undefined} Element of the mutation stack that matches the predicate, or undefined if none.
  2955. */
  2956. find(predicate) {
  2957. if (this.#locked) return this.#liveStack.find(predicate);
  2958. return this.#stack.find(predicate);
  2959. }
  2960. /**
  2961. * Searches for an element of the mutation stack that satisfies the provided predicate and returns its
  2962. * stack index
  2963. *
  2964. * @param {FilterFn} predicate Receives the argments of {@link Array.findIndex} and returns a Boolean indicating if the current
  2965. * element satisfies the predicate condition
  2966. * @return {Number} Index of the element of the mutation stack that matches the predicate, or undefined if none.
  2967. */
  2968. #findIndex( predicate ) {
  2969. if (this.#locked) return this.#liveStack.findIndex(predicate);
  2970. return this.#stack.findIndex(predicate);
  2971. }
  2972. /**
  2973. * Retrieves an element of the mutation stack that matches the provided name
  2974. *
  2975. * @param {String} name Name of mutation (serves as a unique identifier)
  2976. * @return {MutationData|undefined} Element of the mutation stack matching the provided name, or undefined if none
  2977. */
  2978. getName(name) {
  2979. return this.find((element) => element.name === name);
  2980. }
  2981. /**
  2982. * Retrieves that last mutation added to the mutation stack (i.e. the "newest"),
  2983. * or undefined if none present
  2984. * @type {MutationData}
  2985. */
  2986. get last() {
  2987. return this.stack[this.stack.length - 1];
  2988. }
  2989. /**
  2990. * Updates the mutation matching the provided name with the provided mutation info.
  2991. * The mutation info can be a subset of the full object if (and only if) overwrite is false.
  2992. *
  2993. * @param {string} name name of mutation to update
  2994. * @param {MutationData} data New information, can include 'name'.
  2995. * @param {object} options
  2996. * @param {boolean} [options.overwrite = false] default will merge the provided info
  2997. * with the current values. True will replace the entire entry and requires
  2998. * at least the 'name' field.
  2999. *
  3000. * @return {MutationStack} self, unlocked for writing and updates staged if update successful
  3001. */
  3002. update(name, data, {
  3003. overwrite = false
  3004. }) {
  3005. const index = this.#findIndex((element) => element.name === name);
  3006. if (index < 0) {
  3007. return this;
  3008. }
  3009. this.#unlock();
  3010. if (overwrite) {
  3011. /* we need at LEAST a name to identify by */
  3012. if (!data.name) {
  3013. logger.error(MODULE.localize('error.incompleteMutateInfo'));
  3014. this.#locked=true;
  3015. return this;
  3016. }
  3017. /* if no user is provided, input current user. */
  3018. if (!data.user) data.user = game.user.id;
  3019. this.#stack[index] = data;
  3020. } else {
  3021. /* incomplete mutations are fine with merging */
  3022. mergeObject(this.#stack[index], data);
  3023. }
  3024. return this;
  3025. }
  3026. /**
  3027. * Applies a given change or tranform function to the current buffer,
  3028. * unlocking if needed.
  3029. *
  3030. * @param {MutationData|function(MutationData) : MutationData} transform Object to merge or function to generate an object to merge from provided {@link MutationData}
  3031. * @param {FilterFn} [filterFn = () => true] Optional function returning a boolean indicating
  3032. * if this element should be modified. By default, affects all elements of the mutation stack.
  3033. * @return {MutationStack} self, unlocked for writing and updates staged.
  3034. */
  3035. updateAll(transform, filterFn = () => true) {
  3036. const innerUpdate = (transform) => {
  3037. if (typeof transform === 'function') {
  3038. /* if we are applying a transform function */
  3039. return (element) => mergeObject(element, transform(element));
  3040. } else {
  3041. /* if we are applying a constant change */
  3042. return (element) => mergeObject(element, transform);
  3043. }
  3044. };
  3045. this.#unlock();
  3046. this.#stack.forEach((element) => {
  3047. if (filterFn(element)) {
  3048. innerUpdate(transform)(element);
  3049. }
  3050. });
  3051. return this;
  3052. }
  3053. /**
  3054. * Deletes all mutations from this actor's stack, effectively making
  3055. * the current changes permanent.
  3056. *
  3057. * @param {function(MutationData):boolean} [filterFn = () => true] Optional function returning a boolean indicating if this
  3058. * element should be delete. By default, deletes all elements of the mutation stack.
  3059. * @return {MutationStack} self, unlocked for writing and updates staged.
  3060. */
  3061. deleteAll(filterFn = () => true) {
  3062. this.#unlock();
  3063. this.#stack = this.#stack.filter((element) => !filterFn(element));
  3064. return this;
  3065. }
  3066. /**
  3067. * Updates the owning actor with the mutation stack changes made. Will not commit a locked buffer.
  3068. *
  3069. * @return {Promise<MutationStack>} self, locked for writing
  3070. */
  3071. async commit() {
  3072. if(this.#locked) {
  3073. logger.error(MODULE.localize('error.stackLockedOrEmpty'));
  3074. }
  3075. await this.actor.update({
  3076. flags: {
  3077. [MODULE.data.name]: {
  3078. 'mutate': this.#stack
  3079. }
  3080. }
  3081. });
  3082. /* return to a locked read-only state */
  3083. this.#locked = true;
  3084. this.#stack.length = 0;
  3085. return this;
  3086. }
  3087. /**
  3088. * Unlocks the current buffer for writing by copying the mutation stack into this object.
  3089. *
  3090. * @return {boolean} Indicates if the unlock occured. False indicates the buffer was already unlocked.
  3091. */
  3092. #unlock() {
  3093. if (!this.#locked) {
  3094. return false;
  3095. }
  3096. this.#stack = duplicate(this.#liveStack);
  3097. this.#locked = false;
  3098. return true;
  3099. }
  3100. }
  3101. /* theripper93
  3102. * Copyright (C) 2021 dnd-randomizer
  3103. *
  3104. * This program is free software: you can redistribute it and/or modify
  3105. * it under the terms of the GNU General Public License as published by
  3106. * the Free Software Foundation, either version 3 of the License, or
  3107. * (at your option) any later version.
  3108. *
  3109. * This program is distributed in the hope that it will be useful,
  3110. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  3111. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  3112. * GNU General Public License for more details.
  3113. *
  3114. * Original License:
  3115. * https://github.com/theripper93/dnd-randomizer/blob/master/LICENSE
  3116. */
  3117. /* WARPGATE CHANGES
  3118. * exporting propagator class
  3119. * removed test function from original code
  3120. */
  3121. class Propagator {
  3122. // Find a non-occupied cell in the grid that matches the size of the token given an origin
  3123. static getFreePosition(tokenData, origin, collision = true) {
  3124. const center = canvas.grid.getCenter(origin.x, origin.y);
  3125. origin = { x: center[0], y: center[1] };
  3126. const positions = Propagator.generatePositions(origin);
  3127. for (let position of positions) {
  3128. if (Propagator.canFit(tokenData, position, positions[0], collision)) {
  3129. return position;
  3130. }
  3131. }
  3132. }
  3133. //generate positions radiantially from the origin
  3134. static generatePositions(origin) {
  3135. let positions = [
  3136. canvas.grid.getSnappedPosition(origin.x - 1, origin.y - 1),
  3137. ];
  3138. for (
  3139. let r = canvas.scene.dimensions.size;
  3140. r < canvas.scene.dimensions.size * 10;
  3141. r += canvas.scene.dimensions.size
  3142. ) {
  3143. for (
  3144. let theta = 0;
  3145. theta < 2 * Math.PI;
  3146. theta += Math.PI / ((4 * r) / canvas.scene.dimensions.size)
  3147. ) {
  3148. const newPos = canvas.grid.getTopLeft(
  3149. origin.x + r * Math.cos(theta),
  3150. origin.y + r * Math.sin(theta)
  3151. );
  3152. positions.push({ x: newPos[0], y: newPos[1] });
  3153. }
  3154. }
  3155. return positions;
  3156. }
  3157. //check if a position is free
  3158. static isFree(position) {
  3159. for (let token of canvas.tokens.placeables) {
  3160. const hitBox = new PIXI.Rectangle(token.x, token.y, token.w, token.h);
  3161. if (hitBox.contains(position.x, position.y)) {
  3162. return false;
  3163. }
  3164. }
  3165. return true;
  3166. }
  3167. //check if a token can fit in a position
  3168. static canFit(tokenData, position, origin, collision) {
  3169. for (let i = 0; i < tokenData.width; i++) {
  3170. for (let j = 0; j < tokenData.height; j++) {
  3171. const x = position.x + j;
  3172. const y = position.y + i;
  3173. if (!Propagator.isFree({ x, y })) {
  3174. return false;
  3175. }
  3176. }
  3177. }
  3178. const wallCollisions =
  3179. canvas.walls.checkCollision(
  3180. new Ray(origin, {
  3181. x: position.x + tokenData.width / 2,
  3182. y: position.y + tokenData.height / 2,
  3183. }),
  3184. { type: "move" }
  3185. )?.length ?? 0;
  3186. return !collision || !wallCollisions;
  3187. }
  3188. }
  3189. /**
  3190. * Generator function for exploring vertex-connected grid locations in an
  3191. * outward "ring" pattern.
  3192. *
  3193. * @export
  3194. * @generator
  3195. * @name warpgate.plugin.RingGenerator
  3196. * @param {{x:Number, y:Number}} origin Staring location (pixels) for search
  3197. * @param {Number} numRings
  3198. * @yields {{x: Number, y: Number}} pixel location of next grid-ring-connected origin
  3199. */
  3200. function* RingGenerator(origin, numRings) {
  3201. const gridLoc = canvas.grid.grid.getGridPositionFromPixels(
  3202. origin.x,
  3203. origin.y
  3204. );
  3205. const positions = new Set();
  3206. const seen = (position) => {
  3207. const key = position.join(".");
  3208. if (positions.has(key)) return true;
  3209. positions.add(key);
  3210. return false;
  3211. };
  3212. seen(gridLoc);
  3213. let queue = [gridLoc];
  3214. let ring = 0;
  3215. /* include seed point in iterator */
  3216. yield { x: origin.x, y: origin.y, ring: -1 };
  3217. /* if this is off-grid, also check the snap location */
  3218. const snapped = canvas.grid.getSnappedPosition(origin.x, origin.y);
  3219. const snappedIndex = canvas.grid.grid.getGridPositionFromPixels(
  3220. snapped.x,
  3221. snapped.y
  3222. );
  3223. if (!seen(snappedIndex)) {
  3224. queue = [snappedIndex];
  3225. yield {...snapped, ring: -1};
  3226. }
  3227. while (queue.length > 0 && ring < numRings) {
  3228. const next = queue.flatMap((loc) => canvas.grid.grid.getNeighbors(...loc));
  3229. queue = next.filter((loc) => !seen(loc));
  3230. for (const loc of queue) {
  3231. const [x, y] = canvas.grid.grid.getPixelsFromGridPosition(...loc);
  3232. yield { x, y, ring };
  3233. }
  3234. ring += 1;
  3235. }
  3236. return { x: null, y: null, ring: null };
  3237. }
  3238. /**
  3239. * Utility class for locating a free area on the grid from
  3240. * a given initial 'requested' position. Effectively slides
  3241. * the requested position to a nearby position free of other
  3242. * tokens (by default, but accepts arbitrary canvas layers with quad trees)
  3243. *
  3244. * @class PlaceableFit
  3245. */
  3246. class PlaceableFit {
  3247. /**
  3248. * Initialize new "fit" search from the provided
  3249. * bounds.
  3250. *
  3251. * @param {{x:Number, y:Number, width:Number, height:Number}} bounds
  3252. * @param {Object} [options]
  3253. * @constructor
  3254. */
  3255. constructor(bounds, options = {}) {
  3256. this.options = {
  3257. avoidWalls: true,
  3258. searchRange: 6,
  3259. visualize: false,
  3260. collisionLayers: [canvas.tokens],
  3261. };
  3262. foundry.utils.mergeObject(this.options, options);
  3263. this.bounds = new PIXI.Rectangle(
  3264. bounds.x,
  3265. bounds.y,
  3266. bounds.width,
  3267. bounds.height
  3268. );
  3269. if (this.options.visualize) canvas.controls?.debug?.clear?.();
  3270. }
  3271. /**
  3272. *
  3273. *
  3274. * @param {{x:Number, y:Number}} newOrigin
  3275. * @returns PIXI.Rectangle bounds for overlap testing (slightly smaller)
  3276. * @memberof PlaceableFit
  3277. */
  3278. _collisionBounds(newOrigin) {
  3279. const newBounds = new PIXI.Rectangle(
  3280. newOrigin.x,
  3281. newOrigin.y,
  3282. this.bounds.width,
  3283. this.bounds.height
  3284. );
  3285. newBounds.pad(-10);
  3286. return newBounds.normalize();
  3287. }
  3288. /**
  3289. * With the provided origin (top left), can this
  3290. * placeable fit without overlapping other placeables?
  3291. *
  3292. * @param {{x: Number, y: Number}} loc Origin of bounds
  3293. * @returns boolean Placeable bounds fit without overlap
  3294. * @memberof PlaceableFit
  3295. */
  3296. spaceClear(loc) {
  3297. const candidateBounds = this._collisionBounds(loc);
  3298. if (this.options.visualize) {
  3299. canvas.controls.debug
  3300. .lineStyle(2, 0xff0000, 0.5)
  3301. .drawShape(candidateBounds);
  3302. }
  3303. for (const layer of this.options.collisionLayers) {
  3304. const hits = layer.quadtree.getObjects(candidateBounds);
  3305. if (hits.size == 0) return true;
  3306. }
  3307. return false;
  3308. }
  3309. /**
  3310. *
  3311. *
  3312. * @param {{x:Number, y:Number}} originalCenter
  3313. * @param {{x:Number, y:Number}} shiftedCenter
  3314. * @returns Boolean resulting shifted position would collide with a move blocking wall
  3315. * @memberof PlaceableFit
  3316. */
  3317. _offsetCollidesWall(originalCenter, shiftedCenter) {
  3318. const collision = CONFIG.Canvas.polygonBackends.move.testCollision(
  3319. originalCenter,
  3320. shiftedCenter,
  3321. { mode: "any", type: "move" }
  3322. );
  3323. return collision;
  3324. }
  3325. /**
  3326. * Searches for and returns the bounds origin point at which it does
  3327. * not overlap other placeables.
  3328. *
  3329. * @returns {{x: Number, y: Number}|undefined} Identified bounds origin free of overlap
  3330. * @memberof PlaceableFit
  3331. */
  3332. find() {
  3333. if (game.release?.generation < 11) {
  3334. return Propagator.getFreePosition(this.bounds, this.bounds);
  3335. }
  3336. const locIter = RingGenerator(this.bounds, this.options.searchRange);
  3337. let testLoc = null;
  3338. const newCenter = (x, y) => ({
  3339. x: x + this.bounds.width / 2,
  3340. y: y + this.bounds.height / 2,
  3341. });
  3342. while (!(testLoc = locIter.next()).done) {
  3343. const { x, y } = testLoc.value;
  3344. let clear = this.spaceClear({ x, y });
  3345. if (clear && this.options.avoidWalls) {
  3346. clear = !this._offsetCollidesWall(this.bounds.center, newCenter(x, y));
  3347. }
  3348. if (clear) return { x, y };
  3349. }
  3350. return;
  3351. }
  3352. }
  3353. /*
  3354. * This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
  3355. * Copyright (c) 2023 Matthew Haentschke.
  3356. *
  3357. * This program is free software: you can redistribute it and/or modify
  3358. * it under the terms of the GNU General Public License as published by
  3359. * the Free Software Foundation, version 3.
  3360. *
  3361. * This program is distributed in the hope that it will be useful, but
  3362. * WITHOUT ANY WARRANTY; without even the implied warranty of
  3363. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  3364. * General Public License for more details.
  3365. *
  3366. * You should have received a copy of the GNU General Public License
  3367. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  3368. */
  3369. /**
  3370. * For gridded scenes, will highlight a corresponding number of concentric rings spreading outward from the provided starting point (which is given a negative ring index).
  3371. *
  3372. * By default, the number of rings to highlight is zero and the layer is cleared before drawing, which results in erasing any highlighted rings on the canvas.
  3373. *
  3374. * @param {object} config
  3375. * @param {Number} config.x
  3376. * @param {Number} config.y
  3377. * @param {Number} [config.rings = 0] Number of concentric rings to highlight.
  3378. * @param {String} [config.name = 'warpgate-ring'] Highlight layer name to be used for drawing/clearing
  3379. *
  3380. * @param {object} [options]
  3381. * @param {Number} [options.size = 1] Width of each ring, in grid spaces
  3382. * @param {Array<string|number|Color>} [options.colors = [game.user.color]] Circular list of colors for each ring. Will repeat if number of rings is larger than the number of provided colors. Provided value is passed through `Color.from` and converted to its integer representation.
  3383. * @param {boolean} [options.clear = true] Clear any current highlights on named layer before drawing more.
  3384. * @param {Number} [options.lifetime = 0] Time (in milliseconds) before the highlighted ring is automatically
  3385. * cleared. A negative value or zero indicates "indefinitely". Ignored if `config.rings` is less than 1.
  3386. *
  3387. * @returns {Array<{x: Number, y: Number, ring: Number}>} Highlighted grid locations (in pixels) and their corresponding ring index
  3388. *
  3389. * @example
  3390. * const name = 'rangefinder';
  3391. * const size = 2;
  3392. * const rings = 3;
  3393. * const colors = [0xFF0000, 0x00FF00, 0x0000FF];
  3394. *
  3395. * // Draw a simple ring on the default layer
  3396. * warpgate.grid.highlightRing({x: token.x, y:token.y, rings:1});
  3397. *
  3398. * // Draw a larger temporary ring on the rangerfinder layer
  3399. * const highlights = warpgate.grid.highlightRing(
  3400. * {x: token.x, y:token.y, rings, name},
  3401. * {size, colors, clear: true, lifetime:2000});
  3402. *
  3403. * ui.notifications.info(`Highlighted ${highlights.length} grid positions.`);
  3404. */
  3405. function highlightRing(
  3406. config = { x: 0, y: 0, rings: 0, name: 'warpgate-ring' },
  3407. options = { size: 1, colors: [], clear: true, lifetime: 0}
  3408. ) {
  3409. /* establish defaults */
  3410. config = foundry.utils.mergeObject({rings: 0, name: 'warpgate-ring'}, config);
  3411. options = foundry.utils.mergeObject({ size: 1, colors: [], clear: true, lifetime: 0}, options);
  3412. /* ensure we have a layer on which to draw */
  3413. canvas.grid.addHighlightLayer(config.name);
  3414. if (options.clear) canvas.grid.clearHighlightLayer(config.name);
  3415. if(config.rings < 1) {
  3416. return [];
  3417. }
  3418. /* prep color array/string */
  3419. options.colors =
  3420. options.colors instanceof Array ? options.colors : [options.colors];
  3421. if (options.colors.length == 0) {
  3422. options.colors = [game.user.color];
  3423. }
  3424. /* Convert to a Number form */
  3425. options.colors = options.colors.map((val) => Color.from(val).valueOf());
  3426. /* snap position to nearest grid square origin */
  3427. const snapped = canvas.grid.getSnappedPosition(config.x, config.y);
  3428. const locs = [...warpgate.plugin.RingGenerator(snapped, config.rings * options.size)];
  3429. locs.forEach((loc) => {
  3430. if (loc.ring < 0) return;
  3431. canvas.grid.highlightPosition(config.name, {
  3432. x: loc.x,
  3433. y: loc.y,
  3434. color:
  3435. options.colors[
  3436. Math.floor(loc.ring / options.size) % options.colors.length
  3437. ],
  3438. });
  3439. });
  3440. if (options.lifetime > 0) warpgate.wait(options.lifetime).then( () => highlightRing({name: config.name}) );
  3441. return locs;
  3442. }
  3443. /** @typedef {import('./crosshairs.js').CrosshairsData} CrosshairsData */
  3444. /** @typedef {import('./mutator.js').WorkflowOptions} WorkflowOptions */
  3445. /** @typedef {import('./gateway.js').ParallelShow} ParallelShow */
  3446. /**
  3447. * string-string key-value pairs indicating which field to use for comparisons for each needed embeddedDocument type.
  3448. * @typedef {Object<string,string>} ComparisonKeys
  3449. * @example
  3450. * const comparisonKeys = {
  3451. * ActiveEffect: 'label',
  3452. * Item: 'name'
  3453. * }
  3454. */
  3455. /*
  3456. * @private
  3457. * @ignore
  3458. * @todo Creating proper type and use in warpgate.dismiss
  3459. * @typedef {{overrides: ?{includeRawData: ?WorkflowOptions['overrides']['includeRawData']}}} DismissOptions
  3460. */
  3461. /**
  3462. * Configuration obect for pan and ping (i.e. Notice) operations
  3463. * @typedef {Object} NoticeConfig
  3464. * @prop {boolean|string} [ping=false] Creates an animated ping at designated location if a valid
  3465. * ping style from the values contained in `CONFIG.Canvas.pings.types` is provided, or `'pulse'` if `true`
  3466. * @prop {boolean|Number} [pan=false] Pans all receivers to designated location if value is `true`
  3467. * using the configured default pan duration of `CONFIG.Canvas.pings.pullSpeed`. If a Number is
  3468. * provided, it is used as the duration of the pan.
  3469. * @prop {Number} [zoom] Alters zoom level of all receivers, independent of pan/ping
  3470. * @prop {string} [sender = game.userId] The user who triggered the notice
  3471. * @prop {Array<string>} [receivers = warpgate.USERS.SELF] An array of user IDs to send the notice to. If not
  3472. * provided, the notice is only sent to the current user.
  3473. */
  3474. /**
  3475. * Common 'shorthand' notation describing arbitrary data related to a spawn/mutate/revert process.
  3476. *
  3477. * The `token` and `actor` key values are standard update or options objects as one would use in
  3478. * `Actor#update` and `TokenDocument#update`.
  3479. *
  3480. * The `embedded` key uses a shorthand notation to make creating the updates for embedded documents
  3481. * (such as items) easier. Notably, it does not require the `_id` field to be part of the update object
  3482. * for a given embedded document type.
  3483. *
  3484. * @typedef {Object} Shorthand
  3485. * @prop {object} [token] Data related to the workflow TokenDocument.
  3486. * @prop {object} [actor] Data related to the workflow Actor.
  3487. * @prop {Object<string, object|string>} [embedded] Keyed by embedded document class name (e.g. `"Item"` or `"ActiveEffect"`), there are three operations that this object controls -- adding, updating, deleting (in that order).
  3488. *
  3489. * | Operation | Value Interpretation |
  3490. * | :-- | :-- |
  3491. * | Add | Given the identifier of a **non-existing** embedded document, the value contains the data object for document creation compatible with `createEmbeddedDocuments`. This object can be constructed in-place by hand, or gotten from a template document and modified using `"Item To Add": game.items.getName("Name of Item").data`. As an example. Note: the name contained in the key will override the corresponding identifier field in the final creation data. |
  3492. * | Update | Given a key of an existing document, the value contains the data object compatible with `updateEmbeddedDocuments`|
  3493. * | Delete | A value of {@link warpgate.CONST.DELETE} will remove this document (if it exists) from the spawned actor. e.g. `{"Item Name To Delete": warpgate.CONST.DELETE}`|
  3494. *
  3495. * @see ComparisonKeys
  3496. */
  3497. /**
  3498. * Pre spawn callback. After a location is chosen or provided, but before any
  3499. * spawning for _this iteration_ occurs. Used for modifying the spawning data prior to
  3500. * each spawning iteration and for potentially skipping certain iterations.
  3501. *
  3502. * @callback PreSpawn
  3503. * @param {{x: number, y: number}} location Desired centerpoint of spawned token.
  3504. * @param {Object} updates Current working "updates" object, which is modified for every iteration
  3505. * @param {number} iteration Current iteration number (0-indexed) in the case of 'duplicates'
  3506. *
  3507. * @returns {Promise<boolean>|boolean} Indicating if the _current_ spawning iteration should continue.
  3508. */
  3509. /**
  3510. * Post spawn callback. After the spawning and updating for _this iteration_ occurs.
  3511. * Used for modifying the spawning for the next iteration, operations on the TokenDocument directly
  3512. * (such as animations or chat messages), and potentially aborting the spawning process entirely.
  3513. *
  3514. * @callback PostSpawn
  3515. * @param {{x: number, y: number}} location Actual centerpoint of spawned token (affected by collision options).
  3516. * @param {TokenDocument} spawnedToken Resulting token created for this spawning iteration
  3517. * @param {Object} updates Current working "updates" object, which is modified for every iteration
  3518. * @param {number} iteration Current iteration number (0-indexed) in the case of 'duplicates'
  3519. *
  3520. * @returns {Promise<boolean>|boolean} Indicating if this entire spawning process should be aborted (including any remaining duplicates)
  3521. */
  3522. /**
  3523. * This object controls how the crosshairs will be displayed and decorated.
  3524. * Each field is optional with its default value listed.
  3525. *
  3526. * @typedef {Object} CrosshairsConfig
  3527. * @property {number} [x=currentMousePosX] Initial x location for display
  3528. * @property {number} [y=currentMousePosY] Initial y location for display
  3529. * @property {number} [size=1] The initial diameter of the crosshairs outline in grid squares
  3530. * @property {string} [icon = 'icons/svg/dice-target.svg'] The icon displayed in the center of the crosshairs
  3531. * @property {number} [direction = 0] Initial rotation angle (in degrees) of the displayed icon (if any). 0 degrees corresponds to <0, 1> unit vector (y+ in screen space, or 'down' in "monitor space"). If this is included within a {@link WarpOptions} object, it is treated as a delta change to the token/update's current rotation value. Positive values rotate clockwise; negative values rotate counter-clockwise.
  3532. * @property {string} [label = ''] The text to display below the crosshairs outline
  3533. * @property {{x:number, y:number}} [labelOffset={x:0,y:0}] Pixel offset from the label's initial relative position below the outline
  3534. * @property {*} [tag='crosshairs'] Arbitrary value used to identify this crosshairs object
  3535. * @property {boolean} [drawIcon=true] Controls the display of the center icon of the crosshairs
  3536. * @property {boolean} [drawOutline=true] Controls the display of the outline circle of the crosshairs
  3537. * @property {number} [interval=2] Sub-grid granularity per square. Snap points will be created every 1/`interval`
  3538. * grid spaces. Positive values begin snapping at grid intersections. Negative values begin snapping at the
  3539. * center of the square. Ex. the default value of 2 produces two snap points -- one at the edge and one at the
  3540. * center; `interval` of 1 will snap to grid intersections; `interval` of -1 will snap to grid centers.
  3541. * Additionally, a value of `0` will turn off grid snapping completely for this instance of crosshairs.
  3542. * @property {number} [fillAlpha=0] Alpha (opacity) of the template's fill color (if any).
  3543. * @property {string} [fillColor=game.user.color] Color of the template's fill when no texture is used.
  3544. * @property {boolean} [rememberControlled=false] Will restore the previously selected tokens after using crosshairs.
  3545. * @property {boolean} [tileTexture=false] Indicates if the texture is tileable and does not need specific
  3546. * offset/scaling to be drawn correctly. By default, the chosen texture will be position and scaled such
  3547. * that the center of the texture image resides at the center of the crosshairs template.
  3548. * @property {boolean} [lockSize=true] Controls the ability of the user to scale the size of the crosshairs
  3549. * using shift+scroll. When locked, shift+scroll acts as a "coarse rotation" step for rotating the center icon.
  3550. * @property {boolean} [lockPosition=false] Prevents updating the position of the crosshair based on mouse movement. Typically used in combination with the `show` callback to lock position conditionally.
  3551. * @property {string} [texture] Asset path of the texture to draw inside the crosshairs border.
  3552. */
  3553. /**
  3554. * @typedef {Object} SpawningOptions
  3555. * @property {ComparisonKeys} [comparisonKeys] Data paths relative to root document data used for comparisons of embedded
  3556. * shorthand identifiers
  3557. * @property {Shorthand} [updateOpts] Options for the creation/deletion/updating of (embedded) documents related to this spawning
  3558. * @property {Actor} [controllingActor] will minimize this actor's open sheet (if any) for a clearer view of the canvas
  3559. * during placement. Also flags the created token with this actor's id. Default `null`
  3560. * @property {number} [duplicates=1] will spawn multiple tokens from a single placement. See also {@link SpawningOptions.collision}
  3561. * @property {boolean} [collision=duplicates>1] controls whether the placement of a token collides with any other token
  3562. * or wall and finds a nearby unobstructed point (via a radial search) to place the token. If `duplicates` is greater
  3563. * than 1, default is `true`; otherwise `false`.
  3564. * @property {NoticeConfig} [notice] will pan or ping the canvas to the token's position after spawning.
  3565. * @property {object} [overrides] See corresponding property descriptions in {@link WorkflowOptions}
  3566. * @property {boolean} [overrides.includeRawData = false]
  3567. * @property {boolean} [overrides.preserveData = false]
  3568. */
  3569. /**
  3570. * @typedef {Object} WarpOptions
  3571. * @prop {CrosshairsConfig} [crosshairs] A crosshairs configuration object to be used for this spawning process
  3572. */
  3573. /**
  3574. * @class
  3575. * @private
  3576. */
  3577. class api {
  3578. static register() {
  3579. api.globals();
  3580. }
  3581. static settings() {
  3582. }
  3583. static globals() {
  3584. /**
  3585. * @global
  3586. * @summary Top level (global) symbol providing access to all Warp Gate API functions
  3587. * @static
  3588. * @namespace warpgate
  3589. * @property {warpgate.CONST} CONST
  3590. * @property {warpgate.EVENT} EVENT
  3591. * @property {warpgate.USERS} USERS
  3592. * @borrows api._spawn as spawn
  3593. * @borrows api._spawnAt as spawnAt
  3594. * @borrows Gateway.dismissSpawn as dismiss
  3595. * @borrows Mutator.mutate as mutate
  3596. * @borrows Mutator.revertMutation as revert
  3597. * @borrows MODULE.wait as wait
  3598. * @borrows MODULE.buttonDialog as buttonDialog
  3599. * @borrows MODULE.menu as menu
  3600. */
  3601. window[MODULE.data.name] = {
  3602. spawn : api._spawn,
  3603. spawnAt : api._spawnAt,
  3604. dismiss : dismissSpawn,
  3605. mutate : mutate,
  3606. revert : revertMutation,
  3607. /**
  3608. * Factory method for creating a new mutation stack class from
  3609. * the provided token document
  3610. *
  3611. * @memberof warpgate
  3612. * @static
  3613. * @param {TokenDocument} tokenDoc
  3614. * @return {MutationStack} Locked instance of a token actor's mutation stack.
  3615. *
  3616. * @see {@link MutationStack}
  3617. */
  3618. mutationStack : (tokenDoc) => new MutationStack(tokenDoc),
  3619. wait : MODULE.wait,
  3620. menu: MODULE.menu,
  3621. buttonDialog : MODULE.buttonDialog,
  3622. /**
  3623. * @summary Utility functions for common queries and operations
  3624. * @namespace
  3625. * @alias warpgate.util
  3626. * @borrows MODULE.firstGM as firstGM
  3627. * @borrows MODULE.isFirstGM as isFirstGM
  3628. * @borrows MODULE.firstOwner as firstOwner
  3629. * @borrows MODULE.isFirstOwner as isFirstOwner
  3630. */
  3631. util: {
  3632. firstGM : MODULE.firstGM,
  3633. isFirstGM : MODULE.isFirstGM,
  3634. firstOwner : MODULE.firstOwner,
  3635. isFirstOwner : MODULE.isFirstOwner,
  3636. },
  3637. /**
  3638. * @summary Crosshairs methods
  3639. * @namespace
  3640. * @alias warpgate.crosshairs
  3641. * @borrows Gateway.showCrosshairs as show
  3642. * @borrows Crosshairs.getTag as getTag
  3643. * @borrows Gateway.collectPlaceables as collect
  3644. */
  3645. crosshairs: {
  3646. show: showCrosshairs,
  3647. getTag: Crosshairs.getTag,
  3648. collect: collectPlaceables,
  3649. },
  3650. /**
  3651. * @summary Methods intended for warp gate "pylons" (e.g. Warp Gate-dependent modules)
  3652. * @namespace
  3653. * @alias warpgate.plugin
  3654. * @borrows api._notice as notice
  3655. * @borrows Mutator.batchMutate as batchMutate
  3656. * @borrows Mutator.batchRevert as batchRevert
  3657. * @borrows RingGenerator as RingGenerator
  3658. */
  3659. plugin: {
  3660. queueUpdate,
  3661. notice: api._notice,
  3662. batchMutate,
  3663. batchRevert,
  3664. RingGenerator,
  3665. },
  3666. /**
  3667. * @summary Helper functions related to grid-centric canvas operations
  3668. * @namespace
  3669. * @alias warpgate.grid
  3670. * @borrows highlightRing as highlightRing
  3671. */
  3672. grid: {
  3673. highlightRing,
  3674. },
  3675. /**
  3676. * @summary System specific helpers
  3677. * @namespace
  3678. * @private
  3679. * @alias warpgate.dnd5e
  3680. * @prop {Function} rollItem
  3681. * @borrows Gateway._rollItemGetLevel as rollItem
  3682. */
  3683. get dnd5e() {
  3684. foundry.utils.logCompatibilityWarning(`[${MODULE.data.name}] System-specific namespaces and helper functions have been deprecated. Please convert to system provided functions.`, {since: 1.16, until: 2, details:`Migration details:\nrollItem(Item) to Item#use()`});
  3685. return {rollItem : _rollItemGetLevel}
  3686. },
  3687. /**
  3688. * @description Constants and enums for use in embedded shorthand fields
  3689. * @alias warpgate.CONST
  3690. * @readonly
  3691. * @enum {string}
  3692. */
  3693. CONST : {
  3694. /** Instructs warpgate to delete the identified embedded document. Used in place of the update or create data objects. */
  3695. DELETE : 'delete',
  3696. },
  3697. /**
  3698. * @description Helper enums for retrieving user IDs
  3699. * @alias warpgate.USERS
  3700. * @readonly
  3701. * @enum {Array<string>}
  3702. * @property {Array<string>} ALL All online users
  3703. * @property {Array<string>} SELF The current user
  3704. * @property {Array<string>} GM All online GMs
  3705. * @property {Array<string>} PLAYERS All online players (non-gms)
  3706. */
  3707. USERS: {
  3708. /** All online users */
  3709. get ALL() { return game.users.filter(user => user.active).map( user => user.id ) },
  3710. /** The current user */
  3711. get SELF() { return [game.userId] },
  3712. /** All online GMs */
  3713. get GM() { return game.users.filter(user => user.active && user.isGM).map( user => user.id ) },
  3714. /** All online players */
  3715. get PLAYERS() { return game.users.filter(user => user.active && !user.isGM).map( user => user.id ) }
  3716. },
  3717. /**
  3718. *
  3719. * The following table describes the stock event type payloads that are broadcast during {@link warpgate.event.notify}
  3720. *
  3721. * | Event | Payload | Notes |
  3722. * | :-- | -- | -- |
  3723. * | `<any>` | `{sceneId: string, userId: string}` | userId is the initiator |
  3724. * | {@link warpgate.EVENT.PLACEMENT} | `{templateData: {@link CrosshairsData}|Object, tokenData: TokenData|String('omitted'), options: {@link WarpOptions}} | The final Crosshairs data used to spawn the token, and the final token data that will be spawned. There is no actor data provided. In the case of omitting raw data, `template` data will be of type `{x: number, y: number, size: number, cancelled: boolean}` |
  3725. * | SPAWN | `{uuid: string, updates: {@link Shorthand}|String('omitted'), options: {@link WarpOptions}|{@link SpawningOptions}, iteration: number}` | UUID of created token, updates applied to the token, options used for spawning, and iteration this token was spawned on.|
  3726. * | DISMISS | `{actorData: {@link PackedActorData}|string}` | `actorData` is a customized version of `Actor#toObject` with its `token` field containing the actual token document data dismissed, instead of its prototype data. |
  3727. * | MUTATE | `{uuid: string, updates: {@link Shorthand}, options: {@link WorkflowOptions} & {@link MutationOptions}` | UUID of modified token, updates applied to the token, options used for mutation. When raw data is omitted, `updates` will be `String('omitted')`|
  3728. * | REVERT | `{uuid: string, updates: {@link Shorthand}, options: {@link WorkflowOptions}} | UUID is that of reverted token and updates applied to produce the final reverted state (or `String('omitted') if raw data is omitted). |
  3729. * | REVERT\_RESPONSE | `{accepted: bool, tokenId: string, mutationId: string, options: {@link WorkflowOptions}` | Indicates acceptance/rejection of the remote revert request, including target identifiers and options |
  3730. * | MUTATE\_RESPONSE | `{accepted: bool, tokenId: string, mutationId: string, options: {@link WorkflowOptions}` | `mutationId` is the name provided in `options.name` OR a randomly assigned ID if not provided. Callback functions provided for remote mutations will be internally converted to triggers for this event and do not need to be registered manually by the user. `accepted` is a bool field that indicates if the remote user accepted the mutation. |
  3731. *
  3732. * @description Event name constants for use with the {@link warpgate.event} system.
  3733. * @alias warpgate.EVENT
  3734. * @enum {string}
  3735. */
  3736. EVENT : {
  3737. /** After placement is chosen */
  3738. PLACEMENT: 'wg_placement',
  3739. /** After each token has been spawned and fully updated */
  3740. SPAWN: 'wg_spawn',
  3741. /** After a token has been dismissed via warpgate */
  3742. DISMISS: 'wg_dismiss',
  3743. /** After a token has been fully reverted */
  3744. REVERT: 'wg_revert',
  3745. /** After a token has been fully modified */
  3746. MUTATE: 'wg_mutate',
  3747. /** Feedback of mutation acceptance/rejection from the remote owning player in
  3748. * the case of an "unowned" or remote mutation operation
  3749. */
  3750. MUTATE_RESPONSE: 'wg_response_mutate',
  3751. /** Feedback of mutation revert acceptance/rejection from the remote owning player in
  3752. * the case of an "unowned" or remote mutation operation
  3753. */
  3754. REVERT_RESPONSE: 'wg_response_revert'
  3755. },
  3756. /**
  3757. * Warp Gate includes a hook-like event system that can be used to respond to stages of the
  3758. * spawning and mutation process. Additionally, the event system is exposed so that users
  3759. * and module authors can create custom events in any context.
  3760. *
  3761. * @summary Event system API functions.
  3762. * @see warpgate.event.notify
  3763. *
  3764. * @namespace
  3765. * @alias warpgate.event
  3766. * @borrows Events.watch as watch
  3767. * @borrows Events.trigger as trigger
  3768. * @borrows Events.remove as remove
  3769. * @borrows Comms.notifyEvent as notify
  3770. *
  3771. */
  3772. event : {
  3773. watch : Events.watch,
  3774. trigger : Events.trigger,
  3775. remove : Events.remove,
  3776. notify : notifyEvent,
  3777. },
  3778. /**
  3779. * @summary Warp Gate classes suitable for extension
  3780. * @namespace
  3781. * @alias warpgate.abstract
  3782. * @property {Crosshairs} Crosshairs
  3783. * @property {MutationStack} MutationStack
  3784. * @property {PlaceableFit} PlaceableFit
  3785. */
  3786. abstract : {
  3787. Crosshairs,
  3788. MutationStack,
  3789. PlaceableFit,
  3790. }
  3791. };
  3792. }
  3793. /**
  3794. *
  3795. * The primary function of Warp Gate. When executed, it will create a custom MeasuredTemplate
  3796. * that is used to place the spawned token and handle any customizations provided in the `updates`
  3797. * object. `warpgate#spawn` will return a Promise that can be awaited, which can be used in loops
  3798. * to spawn multiple tokens, one after another (or use the `duplicates` options). The player spawning
  3799. * the token will also be given Owner permissions for that specific token actor.
  3800. * This means that players can spawn any creature available in the world.
  3801. *
  3802. * @param {String|PrototypeTokenDocument} spawnName Name of actor to spawn or the actual TokenData
  3803. * that should be used for spawning.
  3804. * @param {Shorthand} [updates] - embedded document, actor, and token document updates. embedded updates use
  3805. * a "shorthand" notation.
  3806. * @param {Object} [callbacks] The callbacks object as used by spawn and spawnAt provide a way to execute custom
  3807. * code during the spawning process. If the callback function modifies updates or location, it is often best
  3808. * to do this via `mergeObject` due to pass by reference restrictions.
  3809. * @param {PreSpawn} [callbacks.pre]
  3810. * @param {PostSpawn} [callbacks.post]
  3811. * @param {ParallelShow} [callbacks.show]
  3812. * @param {WarpOptions & SpawningOptions} [options]
  3813. *
  3814. * @return {Promise<Array<String>>} list of created token ids
  3815. */
  3816. static async _spawn(spawnName, updates = {}, callbacks = {}, options = {}) {
  3817. /* check for needed spawning permissions */
  3818. const neededPerms = MODULE.canSpawn(game.user);
  3819. if(neededPerms.length > 0) {
  3820. logger.warn(MODULE.format('error.missingPerms', {permList: neededPerms.join(', ')}));
  3821. return [];
  3822. }
  3823. /* create permissions for this user */
  3824. const actorData = {
  3825. ownership: {[game.user.id]: CONST.DOCUMENT_PERMISSION_LEVELS.OWNER}
  3826. };
  3827. /* the provided update object will be mangled for our use -- copy it to
  3828. * preserve the user's original input if requested (default).
  3829. */
  3830. if(!options.overrides?.preserveData) {
  3831. updates = MODULE.copy(updates, 'error.badUpdate.complex');
  3832. if(!updates) return [];
  3833. options = foundry.utils.mergeObject(options, {overrides: {preserveData: true}}, {inplace: false});
  3834. }
  3835. /* insert token updates to modify token actor permission */
  3836. MODULE.shimUpdate(updates);
  3837. foundry.utils.mergeObject(updates, {token: mergeObject(updates.token ?? {}, {actorData}, {overwrite:false})});
  3838. /* Detect if the protoData is actually a name, and generate token data */
  3839. let protoData;
  3840. if (typeof spawnName == 'string'){
  3841. protoData = await MODULE.getTokenData(spawnName, updates.token);
  3842. } else {
  3843. protoData = spawnName;
  3844. protoData.updateSource(updates.token ?? {});
  3845. }
  3846. if (!protoData) return;
  3847. if(options.controllingActor?.sheet?.rendered) options.controllingActor.sheet.minimize();
  3848. /* gather data needed for configuring the display of the crosshairs */
  3849. const tokenImg = protoData.texture.src;
  3850. const rotation = updates.token?.rotation ?? protoData.rotation ?? 0;
  3851. const crosshairsConfig = foundry.utils.mergeObject(options.crosshairs ?? {}, {
  3852. size: protoData.width,
  3853. icon: tokenImg,
  3854. name: protoData.name,
  3855. direction: 0,
  3856. }, {inplace: true, overwrite: false});
  3857. crosshairsConfig.direction += rotation;
  3858. /** @type {CrosshairsData} */
  3859. const templateData = await showCrosshairs(crosshairsConfig, callbacks);
  3860. const eventPayload = {
  3861. templateData: (options.overrides?.includeRawData ?? false) ? templateData : {x: templateData.x, y: templateData.y, size: templateData.size, cancelled: templateData.cancelled},
  3862. tokenData: (options.overrides?.includeRawData ?? false) ? protoData.toObject() : 'omitted',
  3863. options,
  3864. };
  3865. await warpgate.event.notify(warpgate.EVENT.PLACEMENT, eventPayload);
  3866. if (templateData.cancelled) return;
  3867. let spawnLocation = {x: templateData.x, y:templateData.y};
  3868. /* calculate any scaling that may have happened */
  3869. const scale = templateData.size / protoData.width;
  3870. /* insert changes from the template into the updates data */
  3871. mergeObject(updates, {token: {rotation: templateData.direction, width: templateData.size, height: protoData.height*scale}});
  3872. return api._spawnAt(spawnLocation, protoData, updates, callbacks, options);
  3873. }
  3874. /**
  3875. * An alternate, more module friendly spawning function. Will create a token from the provided token data and updates at the designated location.
  3876. *
  3877. * @param {{x: number, y: number}} spawnLocation Centerpoint of spawned token
  3878. * @param {String|PrototypeTokenData|TokenData|PrototypeTokenDocument} protoData Any token data or the name of a world-actor. Serves as the base data for all operations.
  3879. * @param {Shorthand} [updates] As {@link warpgate.spawn}
  3880. * @param {Object} [callbacks] see {@link warpgate.spawn}
  3881. * @param {PreSpawn} [callbacks.pre]
  3882. * @param {PostSpawn} [callbacks.post]
  3883. * @param {SpawningOptions} [options] Modifies behavior of the spawning process.
  3884. *
  3885. * @return {Promise<Array<string>>} list of created token ids
  3886. *
  3887. */
  3888. static async _spawnAt(spawnLocation, protoData, updates = {}, callbacks = {}, options = {}) {
  3889. /* check for needed spawning permissions */
  3890. const neededPerms = MODULE.canSpawn(game.user);
  3891. if(neededPerms.length > 0) {
  3892. logger.warn(MODULE.format('error.missingPerms', {permList: neededPerms.join(', ')}));
  3893. return [];
  3894. }
  3895. /* the provided update object will be mangled for our use -- copy it to
  3896. * preserve the user's original input if requested (default).
  3897. */
  3898. if(!options.overrides?.preserveData) {
  3899. updates = MODULE.copy(updates, 'error.badUpdate.complex');
  3900. if(!updates) return [];
  3901. options = foundry.utils.mergeObject(options, {overrides: {preserveData: true}}, {inplace: false});
  3902. }
  3903. MODULE.shimUpdate(updates);
  3904. /* Detect if the protoData is actually a name, and generate token data */
  3905. if (typeof protoData == 'string'){
  3906. protoData = await MODULE.getTokenData(protoData, updates.token ?? {});
  3907. }
  3908. if (!protoData) return [];
  3909. let createdIds = [];
  3910. /* flag this user as the tokens's creator */
  3911. const actorFlags = {
  3912. [MODULE.data.name]: {
  3913. control: {user: game.user.id, actor: options.controllingActor?.uuid},
  3914. }
  3915. };
  3916. /* create permissions for this user */
  3917. const actorData = {
  3918. ownership: {[game.user.id]: CONST.DOCUMENT_PERMISSION_LEVELS.OWNER}
  3919. };
  3920. const deltaField = MODULE.compat('token.delta');
  3921. updates.token = mergeObject({[deltaField]: actorData}, updates.token ?? {}, {inplace: false});
  3922. updates.actor = mergeObject({flags: actorFlags}, updates.actor ?? {}, {inplace: false});
  3923. const duplicates = options.duplicates > 0 ? options.duplicates : 1;
  3924. await clean(null, options);
  3925. if(options.notice) warpgate.plugin.notice({...spawnLocation, scene: canvas.scene}, options.notice);
  3926. for (let iteration = 0; iteration < duplicates; iteration++) {
  3927. /** pre creation callback */
  3928. if (callbacks.pre) {
  3929. const response = await callbacks.pre(spawnLocation, updates, iteration);
  3930. /* pre create callbacks can skip this spawning iteration */
  3931. if(response === false) continue;
  3932. }
  3933. await clean(updates);
  3934. /* merge in changes to the prototoken */
  3935. if(iteration == 0){
  3936. /* first iteration, potentially from a spawn with a determined image,
  3937. * apply our changes to this version */
  3938. await MODULE.updateProtoToken(protoData, updates.token);
  3939. } else {
  3940. /* get a fresh copy */
  3941. protoData = await MODULE.getTokenData(game.actors.get(protoData.actorId), updates.token);
  3942. }
  3943. logger.debug(`Spawn iteration ${iteration} using`, protoData, updates);
  3944. /* pan to token if first iteration */
  3945. //TODO integrate into stock event data instead of hijacking mutate events
  3946. /** @type Object */
  3947. const spawnedTokenDoc = (await _spawnTokenAtLocation(protoData,
  3948. spawnLocation,
  3949. options.collision ?? (options.duplicates > 1)))[0];
  3950. createdIds.push(spawnedTokenDoc.id);
  3951. logger.debug('Spawned token with data: ', spawnedTokenDoc);
  3952. await _updateActor(spawnedTokenDoc.actor, updates, options.comparisonKeys ?? {}, options.updateOpts ?? {});
  3953. const eventPayload = {
  3954. uuid: spawnedTokenDoc.uuid,
  3955. updates: (options.overrides?.includeRawData ?? false) ? updates : 'omitted',
  3956. options,
  3957. iteration
  3958. };
  3959. await warpgate.event.notify(warpgate.EVENT.SPAWN, eventPayload);
  3960. /* post creation callback */
  3961. if (callbacks.post) {
  3962. const response = await callbacks.post(spawnLocation, spawnedTokenDoc, updates, iteration);
  3963. if(response === false) break;
  3964. }
  3965. }
  3966. if (options.controllingActor?.sheet?.rendered) options.controllingActor?.sheet?.maximize();
  3967. return createdIds;
  3968. }
  3969. /**
  3970. * Helper function for displaying pings for or panning the camera of specific users. If no scene is provided, the user's current
  3971. * is assumed.
  3972. *
  3973. * @param {{x: Number, y: Number, scene: Scene} | CrosshairsData} placement Information for the physical placement of the notice containing at least `{x: Number, y: Number, scene: Scene}`
  3974. * @param {NoticeConfig} [config] Configuration for the notice
  3975. */
  3976. static _notice({x, y, scene}, config = {}){
  3977. config.sender ??= game.userId;
  3978. config.receivers ??= warpgate.USERS.SELF;
  3979. scene ??= canvas.scene;
  3980. return requestNotice({x,y}, scene.id, config);
  3981. }
  3982. }
  3983. /*
  3984. * This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
  3985. * Copyright (c) 2021 Matthew Haentschke.
  3986. *
  3987. * This program is free software: you can redistribute it and/or modify
  3988. * it under the terms of the GNU General Public License as published by
  3989. * the Free Software Foundation, version 3.
  3990. *
  3991. * This program is distributed in the hope that it will be useful, but
  3992. * WITHOUT ANY WARRANTY; without even the implied warranty of
  3993. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  3994. * General Public License for more details.
  3995. *
  3996. * You should have received a copy of the GNU General Public License
  3997. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  3998. */
  3999. class UserInterface {
  4000. static register() {
  4001. this.hooks();
  4002. this.settings();
  4003. }
  4004. static hooks() {
  4005. Hooks.on("renderActorSheet", UserInterface._renderActorSheet);
  4006. }
  4007. static settings() {
  4008. const config = true;
  4009. const settingsData = {
  4010. showDismissLabel : {
  4011. scope: "client", config, default: true, type: Boolean,
  4012. },
  4013. showRevertLabel : {
  4014. scope: "client", config, default: true, type: Boolean,
  4015. },
  4016. dismissButtonScope : {
  4017. scope: "client", config, default: 'spawned', type: String, choices: {
  4018. disabled: MODULE.localize('setting.option.disabled'),
  4019. spawned: MODULE.localize('setting.option.spawnedOnly'),
  4020. all: MODULE.localize('setting.option.all')
  4021. }
  4022. },
  4023. revertButtonBehavior : {
  4024. scope: 'client', config, default: 'pop', type: String, choices: {
  4025. disabled: MODULE.localize('setting.option.disabled'),
  4026. pop: MODULE.localize('setting.option.popLatestMutation'),
  4027. menu: MODULE.localize('setting.option.showMutationList')
  4028. }
  4029. }
  4030. };
  4031. MODULE.applySettings(settingsData);
  4032. }
  4033. static _renderActorSheet(app, html) {
  4034. UserInterface.addDismissButton(app, html);
  4035. UserInterface.addRevertMutation(app, html);
  4036. }
  4037. static _shouldAddDismiss(token) {
  4038. if ( !(token instanceof TokenDocument) ) return false;
  4039. switch (MODULE.setting('dismissButtonScope')){
  4040. case 'disabled':
  4041. return false;
  4042. case 'spawned':
  4043. const controlData = token?.actor.getFlag(MODULE.data.name, 'control');
  4044. /** do not add the button if we are not the controlling actor AND we aren't the GM */
  4045. if ( controlData?.user !== game.user.id ) return false;
  4046. return !!controlData;
  4047. case 'all':
  4048. return true;
  4049. }
  4050. }
  4051. static addDismissButton(app, html) {
  4052. const token = app.token;
  4053. /** this is not a warpgate spawned actor */
  4054. if (!UserInterface._shouldAddDismiss(token)) return;
  4055. /* do not add duplicate buttons! */
  4056. if(html.closest('.app').find('.dismiss-warpgate').length !== 0) {
  4057. logger.debug(MODULE.localize('debug.dismissPresent'));
  4058. return;
  4059. }
  4060. const label = MODULE.setting('showDismissLabel') ? MODULE.localize("display.dismiss") : "";
  4061. let dismissButton = $(`<a class="dismiss-warpgate" title="${MODULE.localize('display.dismiss')}"><i class="fas fa-user-slash"></i>${label}</a>`);
  4062. dismissButton.click( (/*event*/) => {
  4063. if (!token) {
  4064. logger.error(MODULE.localize('error.sheetNoToken'));
  4065. return;
  4066. }
  4067. const {id, parent} = token;
  4068. dismissSpawn(id, parent?.id);
  4069. /** close the actor sheet if provided */
  4070. app?.close({submit: false});
  4071. });
  4072. let title = html.closest('.app').find('.window-title');
  4073. dismissButton.insertAfter(title);
  4074. }
  4075. static _shouldAddRevert(token) {
  4076. if ( !(token instanceof TokenDocument) ) return false;
  4077. const mutateStack = warpgate.mutationStack(token).stack;
  4078. /* this is not a warpgate mutated actor,
  4079. * or there are no remaining stacks to peel */
  4080. if (mutateStack.length == 0) return false;
  4081. return MODULE.setting('revertButtonBehavior') !== 'disabled';
  4082. }
  4083. static _getTokenFromApp(app) {
  4084. const {token, actor} = app;
  4085. const hasToken = token instanceof TokenDocument;
  4086. if( !hasToken ) {
  4087. /* check if linked and has an active token on scene */
  4088. const candidates = actor?.getActiveTokens() ?? [];
  4089. const linkedToken = candidates.find( t => t.document.actorLink )?.document ?? null;
  4090. return linkedToken;
  4091. }
  4092. return token;
  4093. }
  4094. static addRevertMutation(app, html) {
  4095. /* do not add duplicate buttons! */
  4096. let foundButton = html.closest('.app').find('.revert-warpgate');
  4097. /* we remove the current button on each render
  4098. * in case the render was triggered by a mutation
  4099. * event and we need to update the tool tip
  4100. * on the revert stack
  4101. */
  4102. if (foundButton) {
  4103. foundButton.remove();
  4104. }
  4105. const token = UserInterface._getTokenFromApp(app);
  4106. if(!UserInterface._shouldAddRevert(token)) return;
  4107. const mutateStack = token?.actor?.getFlag(MODULE.data.name, 'mutate');
  4108. /* construct the revert button */
  4109. const label = MODULE.setting('showRevertLabel') ? MODULE.localize("display.revert") : "";
  4110. const stackCount = mutateStack.length > 1 ? ` 1/${mutateStack.length}` : '';
  4111. let revertButton = $(`<a class="revert-warpgate" title="${MODULE.localize('display.revert')}${stackCount}"><i class="fas fa-undo-alt"></i>${label}</a>`);
  4112. revertButton.click( async (event) => {
  4113. const shouldShow = (shiftKey) => {
  4114. const mode = MODULE.setting('revertButtonBehavior');
  4115. const show = mode == 'menu' ? !shiftKey : shiftKey;
  4116. return show;
  4117. };
  4118. let name = undefined;
  4119. const showMenu = shouldShow(event.shiftKey);
  4120. if (showMenu) {
  4121. const buttons = mutateStack.map( mutation => {return {label: mutation.name, value: mutation.name}} );
  4122. name = await warpgate.buttonDialog({buttons, title: MODULE.localize('display.revertDialogTitle')}, 'column');
  4123. if (name === false) return;
  4124. }
  4125. /* need to queue this since 'click' could
  4126. * happen at any time.
  4127. * Do not need to remove the button here
  4128. * as it will be refreshed on the render call
  4129. */
  4130. queueUpdate( async () => {
  4131. await revertMutation(token, name);
  4132. app?.render(false);
  4133. });
  4134. });
  4135. let title = html.closest('.app').find('.window-title');
  4136. revertButton.insertAfter(title);
  4137. }
  4138. }
  4139. /*
  4140. * MIT License
  4141. *
  4142. * Copyright (c) 2020-2021 DnD5e Helpers Team and Contributors
  4143. *
  4144. * Permission is hereby granted, free of charge, to any person obtaining a copy
  4145. * of this software and associated documentation files (the "Software"), to deal
  4146. * in the Software without restriction, including without limitation the rights
  4147. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  4148. * copies of the Software, and to permit persons to whom the Software is
  4149. * furnished to do so, subject to the following conditions:
  4150. *
  4151. * The above copyright notice and this permission notice shall be included in all
  4152. * copies or substantial portions of the Software.
  4153. *
  4154. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  4155. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  4156. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  4157. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  4158. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  4159. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  4160. * SOFTWARE.
  4161. */
  4162. const SUB_MODULES = {
  4163. MODULE,
  4164. logger,
  4165. api,
  4166. Gateway: {register: register},
  4167. Mutator: {register: register$3},
  4168. RemoteMutator: {register: register$2},
  4169. UserInterface,
  4170. Comms: {register: register$1}
  4171. };
  4172. /*
  4173. Initialize all Sub Modules
  4174. */
  4175. Hooks.on(`setup`, () => {
  4176. Object.values(SUB_MODULES).forEach(cl => cl.register());
  4177. });