Last active 1778885832

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