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.

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