Batch Balance

Distribute money or change balances for multiple faction members (Not supported on mobile)

  1. // ==UserScript==
  2. // @name Batch Balance
  3. // @namespace https://github.com/tobytorn
  4. // @description Distribute money or change balances for multiple faction members (Not supported on mobile)
  5. // @author tobytorn [1617955]
  6. // @match https://www.torn.com/factions.php?step=your*
  7. // @version 2.0.2
  8. // @grant GM_getValue
  9. // @grant GM_setValue
  10. // @grant GM_addStyle
  11. // @supportURL https://github.com/tobytorn/batch-balance
  12. // @license MIT
  13. // @require https://unpkg.com/jquery@3.7.0/dist/jquery.min.js
  14. // ==/UserScript==
  15.  
  16. // Usage:
  17. // Add the following parameters to the URL of the control page (https://www.torn.com/factions.php?step=your#/tab=controls) to enable this script
  18. // batbal_uids Comma-separated user IDs
  19. // batbal_amounts Comma-separated amounts
  20. // batbal_action [Optional] "add" for adding to balance (default), or "give" for giving money
  21. // batbal_asset [Optional] "money" (default) or "points"
  22. //
  23. // Example: The following URL will add 120 to Leslie, subtract 250 from tobytorn, and add 1.5k to Duke
  24. // https://www.torn.com/factions.php?step=your#/tab=controls&batbal_uids=15,1617955,4&batbal_amounts=120,-250,1500
  25.  
  26. 'use strict';
  27.  
  28. function batchBalanceWrapper() {
  29. console.log('Batch Balance starts');
  30.  
  31. const ACTION_INTERVAL_MS = 1000;
  32. const GM_VALUE_KEY = 'batbal-action';
  33. const PROFILE_HREF_PREFIX = 'profiles.php?XID=';
  34. const ACTION_SPECS = {
  35. give: {
  36. summary: 'Give',
  37. text: 'Give',
  38. waitingText: 'Giving',
  39. bodyParam: 'giveMoney',
  40. },
  41. add: {
  42. summary: 'Add to balance',
  43. text: 'Add',
  44. waitingText: 'Adding',
  45. bodyParam: 'addToBalance',
  46. },
  47. };
  48.  
  49. const $ = window.jQuery;
  50.  
  51. const LOCAL_STORAGE_PREFIX = 'BATCH_BALANCE_';
  52.  
  53. function getLocalStorage(key, defaultValue) {
  54. const value = window.localStorage.getItem(LOCAL_STORAGE_PREFIX + key);
  55. try {
  56. return JSON.parse(value) ?? defaultValue;
  57. } catch (err) {
  58. return defaultValue;
  59. }
  60. }
  61.  
  62. function setLocalStorage(key, value) {
  63. window.localStorage.setItem(LOCAL_STORAGE_PREFIX + key, JSON.stringify(value));
  64. }
  65.  
  66. const isPda = window.GM_info?.scriptHandler?.toLowerCase().includes('tornpda');
  67. const [getValue, setValue] =
  68. isPda || typeof window.GM_getValue !== 'function' || typeof window.GM_setValue !== 'function'
  69. ? [getLocalStorage, setLocalStorage]
  70. : [window.GM_getValue, window.GM_setValue];
  71.  
  72. const STYLE = `
  73. .batbal-overlay {
  74. position: relative;
  75. }
  76. .batbal-overlay:after {
  77. content: '';
  78. position: absolute;
  79. background: repeating-linear-gradient(135deg, #2228, #2228 70px, #0008 70px, #0008 80px);
  80. top: 0;
  81. left: 0;
  82. width: 100%;
  83. height: 100%;
  84. z-index: 900000;
  85. }
  86. #batbal-ctrl {
  87. margin: 10px 0;
  88. padding: 10px;
  89. border-radius: 5px;
  90. background-color: var(--default-bg-panel-color);
  91. text-align: center;
  92. line-height: 16px;
  93. }
  94. #batbal-ctrl-detail > :not(:first-child),
  95. #batbal-ctrl > :not(:first-child) {
  96. margin-top: 10px;
  97. }
  98. #batbal-ctrl-title {
  99. font-size: large;
  100. font-weight: bold;
  101. }
  102. #batbal-ctrl-status {
  103. font-weight: bold;
  104. }
  105. #batbal-ctrl button {
  106. margin: 0 4px;
  107. }
  108. #batbal-ctrl table {
  109. margin: 0 auto;
  110. }
  111. #batbal-ctrl th {
  112. font-weight: bold;
  113. }
  114. #batbal-ctrl th,
  115. #batbal-ctrl td {
  116. color: inherit;
  117. padding: 5px;
  118. border: 1px solid #ccc;
  119. }
  120. #batbal-ctrl td:last-child {
  121. text-align: right;
  122. }
  123. #batbal-ctrl-detail tr.batbal-done:after {
  124. content: '\u2713';
  125. color: green;
  126. padding-left: 6px;
  127. }
  128. `;
  129.  
  130. const CONTROLLER_HTML = `
  131. <div id="batbal-ctrl">
  132. <div id="batbal-ctrl-title">Batch Balance</div>
  133. <div>
  134. <table>
  135. <thead>
  136. <tr>
  137. <th colspan="2">Summary</th>
  138. </tr>
  139. </thead>
  140. <tbody>
  141. <tr>
  142. <th>Action</th>
  143. <td id="batbal-ctrl-summary-action-type">-</td>
  144. </tr>
  145. <tr>
  146. <th>Asset Type</th>
  147. <td id="batbal-ctrl-summary-asset-type">-</td>
  148. </tr>
  149. <tr>
  150. <th>Player Count</th>
  151. <td id="batbal-ctrl-summary-player-count">-</td>
  152. </tr>
  153. <tr>
  154. <th>Player Not in Faction</th>
  155. <td><span id="batbal-ctrl-summary-player-not-in-faction">-</span></td>
  156. </tr>
  157. <tr>
  158. <th>Total Amount</th>
  159. <td><span id="batbal-ctrl-summary-total-amount">-</span></td>
  160. </tr>
  161. </tbody>
  162. </table>
  163. </div>
  164. <div>
  165. <button id="batbal-ctrl-start" class="torn-btn" disabled>Start</button>
  166. <button id="batbal-ctrl-show-detail" class="torn-btn">Show details</button>
  167. <button id="batbal-ctrl-hide-detail" class="torn-btn" style="display: none">Hide details</button>
  168. <button id="batbal-ctrl-clear-data" class="torn-btn" disabled>Clear data</button>
  169. </div>
  170. <button id="batbal-ctrl-submit" class="torn-btn" style="display: none" disabled></button>
  171. <div>Status: <span id="batbal-ctrl-status"></span></div>
  172. <div id="batbal-ctrl-detail" style="display: none">
  173. <table>
  174. <thead>
  175. <tr>
  176. <th>ID</th>
  177. <th>Name</th>
  178. <th>Amount</th>
  179. <th>Note</th>
  180. </tr>
  181. </thead>
  182. <tbody></tbody>
  183. </table>
  184. </div>
  185. </div>`;
  186.  
  187. function formatAmount(v) {
  188. return (v >= 0 ? '+' : '') + v.toString().replace(/\d{1,3}(?=(\d{3})+$)/g, (s) => s + ',');
  189. }
  190.  
  191. async function sleep(t) {
  192. await new Promise((r) => setTimeout(r, t));
  193. }
  194.  
  195. // Copied from https://stackoverflow.com/a/25490531
  196. function getCookie(name) {
  197. return document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
  198. }
  199.  
  200. function getParams() {
  201. const params = new URLSearchParams(location.hash.slice(1));
  202. return params.get('/tab') === 'controls' ? params : null;
  203. }
  204.  
  205. function storeAction(action) {
  206. setValue(GM_VALUE_KEY, action);
  207. }
  208.  
  209. function parseAction() {
  210. const params = getParams();
  211. if (!params) {
  212. return null;
  213. }
  214. const paramUids = params.get('batbal_uids');
  215. const paramAmounts = params.get('batbal_amounts');
  216. if (paramUids === null || paramAmounts === null) {
  217. return null;
  218. }
  219. const uids = paramUids.split(',');
  220. const amounts = paramAmounts.split(',');
  221. if (amounts.length !== uids.length) {
  222. return { error: 'Param "batbal_uids" and "batbal_amounts" have different lengths' };
  223. }
  224. if (uids.length === 0 || uids.some((x) => !x.match(/^\d+$/))) {
  225. return { error: 'Param "batbal_uids" is invalid' };
  226. }
  227. if (amounts.length === 0 || amounts.some((x) => !x.match(/^[+-]?\d{1,11}$/))) {
  228. return { error: 'Param "batbal_amounts" is invalid' };
  229. }
  230. const actionType = params.get('batbal_action') ?? 'add';
  231. if (!['give', 'add'].includes(actionType)) {
  232. return { error: 'Param "batbal_action" is invalid' };
  233. }
  234. const assetType = params.get('batbal_asset') ?? 'money';
  235. if (!['money', 'points'].includes(assetType)) {
  236. return { error: 'Param "batbal_asset" is invalid' };
  237. }
  238. return {
  239. uidAmounts: uids.map((uid, i) => [uid, parseInt(amounts[i])]).filter(([, amount]) => amount !== 0),
  240. next: 0,
  241. actionType,
  242. assetType,
  243. };
  244. }
  245.  
  246. function checkAction(parsedAction, storedAction) {
  247. if (parsedAction && storedAction) {
  248. if (
  249. JSON.stringify(parsedAction.uidAmounts) !== JSON.stringify(storedAction.uidAmounts) ||
  250. parsedAction.actionType !== storedAction.actionType ||
  251. parsedAction.assetType !== storedAction.assetType
  252. ) {
  253. throw new Error(
  254. "An unfinished Batch Balance operation was found that doesn't match the URL parameters. " +
  255. 'Click "Show details" to view the pending operation. ' +
  256. 'To resume it, clear the URL parameters and refresh the page.' +
  257. 'To discard it, click "Clear data" and refresh the page.',
  258. );
  259. }
  260. }
  261. return storedAction ?? parsedAction;
  262. }
  263.  
  264. /** @returns Promise<Record<string, { name: string, isInFaction: boolean }>> */
  265. async function getUidMap() {
  266. return new Promise((resolve) => {
  267. const interval = setInterval(function () {
  268. const $depositors = $('.money___aACfM .userListWrap___voEX8 .userInfoWrap___rjWOK');
  269. if ($depositors.length === 0) {
  270. return;
  271. }
  272. const map = {};
  273. $depositors.each(function () {
  274. const $name = $(this).find(`a[href*="${PROFILE_HREF_PREFIX}"]`).first();
  275. if ($name.length > 0) {
  276. const uid = ($name.attr('href') || '').split(PROFILE_HREF_PREFIX)[1];
  277. map[uid] = {
  278. name: $name.text().trim(),
  279. isInFaction: !$(this).hasClass('inactive___Hd0EQ'),
  280. };
  281. }
  282. });
  283. clearInterval(interval);
  284. resolve(map);
  285. }, 200);
  286. });
  287. }
  288.  
  289. function renderController() {
  290. GM_addStyle(STYLE);
  291. const $controlsWrap = $('.faction-controls-wrap');
  292. $controlsWrap.addClass('batbal-overlay');
  293. $controlsWrap.before(CONTROLLER_HTML);
  294. $('#batbal-ctrl-show-detail').on('click', function () {
  295. $('#batbal-ctrl-detail').show();
  296. $('#batbal-ctrl-hide-detail').show();
  297. $(this).hide();
  298. });
  299. $('#batbal-ctrl-hide-detail').on('click', function () {
  300. $('#batbal-ctrl-detail').hide();
  301. $('#batbal-ctrl-show-detail').show();
  302. $(this).hide();
  303. });
  304. $('#batbal-ctrl-clear-data').on('click', function () {
  305. if (
  306. confirm(
  307. 'Are you sure you want to delete the saved Batch Balance data? ' +
  308. 'This will remove any unfinished operations and cannot be undone.',
  309. )
  310. ) {
  311. storeAction(null);
  312. $('#batbal-ctrl').hide();
  313. alert('Saved data has been deleted, please refresh the page');
  314. }
  315. });
  316. }
  317.  
  318. function updateStatus(s) {
  319. $('#batbal-ctrl-status').text(String(s));
  320. if (s instanceof Error) {
  321. $('#batbal-ctrl-status').css('color', 'red');
  322. }
  323. }
  324.  
  325. function renderDetails(action, uidMap) {
  326. const $tbody = $('#batbal-ctrl-detail tbody');
  327. $tbody.empty();
  328. let outsideCount = 0;
  329. action.uidAmounts.forEach(([uid, amount], i) => {
  330. const amountClass = amount >= 0 ? 't-green' : 't-red';
  331. const trClass = i < action.next ? 'batbal-done' : '';
  332. const uidInfo = uidMap[uid] || {};
  333. const name = uidInfo.name || '';
  334. const isInFaction = uidInfo.isInFaction || false;
  335. if (!isInFaction) {
  336. outsideCount++;
  337. }
  338. $tbody.append(`<tr class="${trClass}">
  339. <td>${uid}</td>
  340. <td>${name}</td>
  341. <td><span class="${amountClass}">${formatAmount(amount)}</span></td>
  342. <td><span class="${!isInFaction ? 't-red' : ''}">${!isInFaction ? 'Not in faction' : ''}</span></td>
  343. </tr>`);
  344. });
  345. const total = action.uidAmounts.reduce((v, [, amount]) => v + amount, 0);
  346. const totalClass = total >= 0 ? 't-green' : 't-red';
  347. const actionSpec = ACTION_SPECS[action.actionType];
  348. $('#batbal-ctrl-summary-action-type').text(actionSpec.summary);
  349. $('#batbal-ctrl-summary-asset-type').text(action.assetType);
  350. $('#batbal-ctrl-summary-player-count').text(action.uidAmounts.length);
  351. $('#batbal-ctrl-summary-player-not-in-faction')
  352. .text(outsideCount)
  353. .toggleClass('t-red', outsideCount > 0);
  354. $('#batbal-ctrl-summary-total-amount').text(formatAmount(total)).addClass(totalClass);
  355. updateStatus(`Progress: ${action.next} / ${action.uidAmounts.length} done`);
  356. }
  357.  
  358. async function addMoney({ uid, name, amount, actionType, assetType }) {
  359. const $submit = $('#batbal-ctrl-submit');
  360. $submit.show();
  361. const actionSpec = ACTION_SPECS[actionType];
  362. const textSuffix = ` ${assetType}: ${name} [${uid}] ${formatAmount(amount)}`;
  363. const queryParam = {
  364. money: 'factionsGiveMoney',
  365. points: 'factionsGivePoints',
  366. }[assetType];
  367. $submit.text(`${actionSpec.text} ${textSuffix}`);
  368. $submit.prop('disabled', false);
  369. return new Promise((resolve, reject) => {
  370. $submit.on('click', async () => {
  371. try {
  372. $submit.off('click');
  373. $submit.text(`${actionSpec.waitingText} ${textSuffix}`);
  374. $submit.prop('disabled', true);
  375. const rfcv = getCookie('rfc_v');
  376. const rsp = await fetch(`/page.php?sid=${queryParam}&rfcv=${rfcv}`, {
  377. method: 'POST',
  378. headers: {
  379. 'Content-Type': 'application/json',
  380. 'x-requested-with': 'XMLHttpRequest',
  381. },
  382. body: JSON.stringify({
  383. option: actionSpec.bodyParam,
  384. receiver: parseInt(uid),
  385. amount,
  386. }),
  387. });
  388. const rawData = await rsp.text();
  389. if (!rsp.ok) {
  390. throw new Error(`Network error: ${rsp.status} ${rawData}`);
  391. }
  392. const data = JSON.parse(rawData);
  393. if (data.success === true) {
  394. resolve();
  395. } else {
  396. reject(new Error(`Unexpected server response: ${rawData}`));
  397. }
  398. } catch (err) {
  399. reject(err);
  400. }
  401. });
  402. });
  403. }
  404.  
  405. async function start(action, uidMap) {
  406. storeAction(action);
  407. $('#batbal-ctrl-start').prop('disabled', true);
  408. $('#batbal-ctrl-clear-data').prop('disabled', true);
  409.  
  410. try {
  411. while (action.next < action.uidAmounts.length) {
  412. updateStatus(`Current progress: ${action.next} / ${action.uidAmounts.length} done`);
  413. const now = Date.now();
  414. const [uid, amount] = action.uidAmounts[action.next];
  415. const uidInfo = uidMap[uid] || {};
  416. const name = uidInfo.name || 'Unknown player';
  417. await addMoney({ uid, name, amount, actionType: action.actionType, assetType: action.assetType });
  418. action.next++;
  419. storeAction(action);
  420. renderDetails(action, uidMap);
  421. const elapsed = Date.now() - now;
  422. if (elapsed < ACTION_INTERVAL_MS) {
  423. await sleep(ACTION_INTERVAL_MS - elapsed);
  424. }
  425. }
  426. storeAction(null);
  427. updateStatus('All done!');
  428. } catch (err) {
  429. updateStatus(err);
  430. }
  431. }
  432.  
  433. async function main() {
  434. try {
  435. const parsedAction = parseAction();
  436. const storedAction = getValue(GM_VALUE_KEY, null);
  437. if (storedAction === null && parsedAction === null) {
  438. return;
  439. }
  440.  
  441. const uidMap = await getUidMap();
  442. renderController();
  443. if (storedAction) {
  444. renderDetails(storedAction, uidMap);
  445. $('#batbal-ctrl-clear-data').prop('disabled', false);
  446. }
  447. if (parsedAction.error) {
  448. throw new Error(parsedAction.error);
  449. }
  450.  
  451. const action = checkAction(parsedAction, storedAction);
  452. if (!storedAction) {
  453. renderDetails(action, uidMap);
  454. }
  455. if (action.actionType === 'give') {
  456. if (action.uidAmounts.some(([uid]) => !uidMap[uid]?.isInFaction)) {
  457. throw new Error('Some players are not in the faction');
  458. }
  459. if (action.uidAmounts.some(([, amount]) => amount <= 0)) {
  460. throw new Error('Amounts to give must be positive');
  461. }
  462. }
  463.  
  464. $('#batbal-ctrl-start').prop('disabled', false);
  465. $('#batbal-ctrl-start').on('click', () => start(action, uidMap));
  466. } catch (err) {
  467. updateStatus(err);
  468. console.log('Unhandled exception from Batch Balance:', err);
  469. }
  470. }
  471.  
  472. main();
  473. console.log('Batch Balance ends');
  474. }
  475.  
  476. if (document.readyState === 'loading') {
  477. document.addEventListener('readystatechange', () => {
  478. if (document.readyState === 'interactive') {
  479. batchBalanceWrapper();
  480. }
  481. });
  482. } else {
  483. batchBalanceWrapper();
  484. }