Batch Balance

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Batch Balance
// @namespace   https://github.com/tobytorn
// @description Distribute money or change balances for multiple faction members (Not supported on mobile)
// @author      tobytorn [1617955]
// @match       https://www.torn.com/factions.php?step=your*
// @version     2.0.2
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addStyle
// @supportURL  https://github.com/tobytorn/batch-balance
// @license     MIT
// @require     https://unpkg.com/[email protected]/dist/jquery.min.js
// ==/UserScript==

// Usage:
// 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
// batbal_uids    Comma-separated user IDs
// batbal_amounts Comma-separated amounts
// batbal_action  [Optional] "add" for adding to balance (default), or "give" for giving money
// batbal_asset   [Optional] "money" (default) or "points"
//
// Example: The following URL will add 120 to Leslie, subtract 250 from tobytorn, and add 1.5k to Duke
//   https://www.torn.com/factions.php?step=your#/tab=controls&batbal_uids=15,1617955,4&batbal_amounts=120,-250,1500

'use strict';

function batchBalanceWrapper() {
  console.log('Batch Balance starts');

  const ACTION_INTERVAL_MS = 1000;
  const GM_VALUE_KEY = 'batbal-action';
  const PROFILE_HREF_PREFIX = 'profiles.php?XID=';
  const ACTION_SPECS = {
    give: {
      summary: 'Give',
      text: 'Give',
      waitingText: 'Giving',
      bodyParam: 'giveMoney',
    },
    add: {
      summary: 'Add to balance',
      text: 'Add',
      waitingText: 'Adding',
      bodyParam: 'addToBalance',
    },
  };

  const $ = window.jQuery;

  const LOCAL_STORAGE_PREFIX = 'BATCH_BALANCE_';

  function getLocalStorage(key, defaultValue) {
    const value = window.localStorage.getItem(LOCAL_STORAGE_PREFIX + key);
    try {
      return JSON.parse(value) ?? defaultValue;
    } catch (err) {
      return defaultValue;
    }
  }

  function setLocalStorage(key, value) {
    window.localStorage.setItem(LOCAL_STORAGE_PREFIX + key, JSON.stringify(value));
  }

  const isPda = window.GM_info?.scriptHandler?.toLowerCase().includes('tornpda');
  const [getValue, setValue] =
    isPda || typeof window.GM_getValue !== 'function' || typeof window.GM_setValue !== 'function'
      ? [getLocalStorage, setLocalStorage]
      : [window.GM_getValue, window.GM_setValue];

  const STYLE = `
    .batbal-overlay {
      position: relative;
    }
    .batbal-overlay:after {
      content: '';
      position: absolute;
      background: repeating-linear-gradient(135deg, #2228, #2228 70px, #0008 70px, #0008 80px);
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      z-index: 900000;
    }
    #batbal-ctrl {
      margin: 10px 0;
      padding: 10px;
      border-radius: 5px;
      background-color: var(--default-bg-panel-color);
      text-align: center;
      line-height: 16px;
    }
    #batbal-ctrl-detail > :not(:first-child),
    #batbal-ctrl > :not(:first-child) {
      margin-top: 10px;
    }
    #batbal-ctrl-title {
      font-size: large;
      font-weight: bold;
    }
    #batbal-ctrl-status {
      font-weight: bold;
    }
    #batbal-ctrl button {
      margin: 0 4px;
    }
    #batbal-ctrl table {
      margin: 0 auto;
    }
    #batbal-ctrl th {
      font-weight: bold;
    }
    #batbal-ctrl th,
    #batbal-ctrl td {
      color: inherit;
      padding: 5px;
      border: 1px solid #ccc;
    }
    #batbal-ctrl td:last-child {
      text-align: right;
    }
    #batbal-ctrl-detail tr.batbal-done:after {
      content: '\u2713';
      color: green;
      padding-left: 6px;
    }
  `;

  const CONTROLLER_HTML = `
    <div id="batbal-ctrl">
      <div id="batbal-ctrl-title">Batch Balance</div>
      <div>
        <table>
          <thead>
            <tr>
              <th colspan="2">Summary</th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <th>Action</th>
              <td id="batbal-ctrl-summary-action-type">-</td>
            </tr>
            <tr>
              <th>Asset Type</th>
              <td id="batbal-ctrl-summary-asset-type">-</td>
            </tr>
            <tr>
              <th>Player Count</th>
              <td id="batbal-ctrl-summary-player-count">-</td>
            </tr>
            <tr>
              <th>Player Not in Faction</th>
              <td><span id="batbal-ctrl-summary-player-not-in-faction">-</span></td>
            </tr>
            <tr>
              <th>Total Amount</th>
              <td><span id="batbal-ctrl-summary-total-amount">-</span></td>
            </tr>
          </tbody>
        </table>
      </div>
      <div>
        <button id="batbal-ctrl-start" class="torn-btn" disabled>Start</button>
        <button id="batbal-ctrl-show-detail" class="torn-btn">Show details</button>
        <button id="batbal-ctrl-hide-detail" class="torn-btn" style="display: none">Hide details</button>
        <button id="batbal-ctrl-clear-data" class="torn-btn" disabled>Clear data</button>
      </div>
      <button id="batbal-ctrl-submit" class="torn-btn" style="display: none" disabled></button>
      <div>Status: <span id="batbal-ctrl-status"></span></div>
      <div id="batbal-ctrl-detail" style="display: none">
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>Name</th>
              <th>Amount</th>
              <th>Note</th>
            </tr>
          </thead>
          <tbody></tbody>
        </table>
      </div>
    </div>`;

  function formatAmount(v) {
    return (v >= 0 ? '+' : '') + v.toString().replace(/\d{1,3}(?=(\d{3})+$)/g, (s) => s + ',');
  }

  async function sleep(t) {
    await new Promise((r) => setTimeout(r, t));
  }

  // Copied from https://stackoverflow.com/a/25490531
  function getCookie(name) {
    return document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
  }

  function getParams() {
    const params = new URLSearchParams(location.hash.slice(1));
    return params.get('/tab') === 'controls' ? params : null;
  }

  function storeAction(action) {
    setValue(GM_VALUE_KEY, action);
  }

  function parseAction() {
    const params = getParams();
    if (!params) {
      return null;
    }
    const paramUids = params.get('batbal_uids');
    const paramAmounts = params.get('batbal_amounts');
    if (paramUids === null || paramAmounts === null) {
      return null;
    }
    const uids = paramUids.split(',');
    const amounts = paramAmounts.split(',');
    if (amounts.length !== uids.length) {
      return { error: 'Param "batbal_uids" and "batbal_amounts" have different lengths' };
    }
    if (uids.length === 0 || uids.some((x) => !x.match(/^\d+$/))) {
      return { error: 'Param "batbal_uids" is invalid' };
    }
    if (amounts.length === 0 || amounts.some((x) => !x.match(/^[+-]?\d{1,11}$/))) {
      return { error: 'Param "batbal_amounts" is invalid' };
    }
    const actionType = params.get('batbal_action') ?? 'add';
    if (!['give', 'add'].includes(actionType)) {
      return { error: 'Param "batbal_action" is invalid' };
    }
    const assetType = params.get('batbal_asset') ?? 'money';
    if (!['money', 'points'].includes(assetType)) {
      return { error: 'Param "batbal_asset" is invalid' };
    }
    return {
      uidAmounts: uids.map((uid, i) => [uid, parseInt(amounts[i])]).filter(([, amount]) => amount !== 0),
      next: 0,
      actionType,
      assetType,
    };
  }

  function checkAction(parsedAction, storedAction) {
    if (parsedAction && storedAction) {
      if (
        JSON.stringify(parsedAction.uidAmounts) !== JSON.stringify(storedAction.uidAmounts) ||
        parsedAction.actionType !== storedAction.actionType ||
        parsedAction.assetType !== storedAction.assetType
      ) {
        throw new Error(
          "An unfinished Batch Balance operation was found that doesn't match the URL parameters. " +
            'Click "Show details" to view the pending operation. ' +
            'To resume it, clear the URL parameters and refresh the page.' +
            'To discard it, click "Clear data" and refresh the page.',
        );
      }
    }
    return storedAction ?? parsedAction;
  }

  /** @returns Promise<Record<string, { name: string, isInFaction: boolean }>> */
  async function getUidMap() {
    return new Promise((resolve) => {
      const interval = setInterval(function () {
        const $depositors = $('.money___aACfM .userListWrap___voEX8 .userInfoWrap___rjWOK');
        if ($depositors.length === 0) {
          return;
        }
        const map = {};
        $depositors.each(function () {
          const $name = $(this).find(`a[href*="${PROFILE_HREF_PREFIX}"]`).first();
          if ($name.length > 0) {
            const uid = ($name.attr('href') || '').split(PROFILE_HREF_PREFIX)[1];
            map[uid] = {
              name: $name.text().trim(),
              isInFaction: !$(this).hasClass('inactive___Hd0EQ'),
            };
          }
        });
        clearInterval(interval);
        resolve(map);
      }, 200);
    });
  }

  function renderController() {
    GM_addStyle(STYLE);
    const $controlsWrap = $('.faction-controls-wrap');
    $controlsWrap.addClass('batbal-overlay');
    $controlsWrap.before(CONTROLLER_HTML);
    $('#batbal-ctrl-show-detail').on('click', function () {
      $('#batbal-ctrl-detail').show();
      $('#batbal-ctrl-hide-detail').show();
      $(this).hide();
    });
    $('#batbal-ctrl-hide-detail').on('click', function () {
      $('#batbal-ctrl-detail').hide();
      $('#batbal-ctrl-show-detail').show();
      $(this).hide();
    });
    $('#batbal-ctrl-clear-data').on('click', function () {
      if (
        confirm(
          'Are you sure you want to delete the saved Batch Balance data? ' +
            'This will remove any unfinished operations and cannot be undone.',
        )
      ) {
        storeAction(null);
        $('#batbal-ctrl').hide();
        alert('Saved data has been deleted, please refresh the page');
      }
    });
  }

  function updateStatus(s) {
    $('#batbal-ctrl-status').text(String(s));
    if (s instanceof Error) {
      $('#batbal-ctrl-status').css('color', 'red');
    }
  }

  function renderDetails(action, uidMap) {
    const $tbody = $('#batbal-ctrl-detail tbody');
    $tbody.empty();
    let outsideCount = 0;
    action.uidAmounts.forEach(([uid, amount], i) => {
      const amountClass = amount >= 0 ? 't-green' : 't-red';
      const trClass = i < action.next ? 'batbal-done' : '';
      const uidInfo = uidMap[uid] || {};
      const name = uidInfo.name || '';
      const isInFaction = uidInfo.isInFaction || false;
      if (!isInFaction) {
        outsideCount++;
      }
      $tbody.append(`<tr class="${trClass}">
      <td>${uid}</td>
      <td>${name}</td>
      <td><span class="${amountClass}">${formatAmount(amount)}</span></td>
      <td><span class="${!isInFaction ? 't-red' : ''}">${!isInFaction ? 'Not in faction' : ''}</span></td>
    </tr>`);
    });
    const total = action.uidAmounts.reduce((v, [, amount]) => v + amount, 0);
    const totalClass = total >= 0 ? 't-green' : 't-red';
    const actionSpec = ACTION_SPECS[action.actionType];
    $('#batbal-ctrl-summary-action-type').text(actionSpec.summary);
    $('#batbal-ctrl-summary-asset-type').text(action.assetType);
    $('#batbal-ctrl-summary-player-count').text(action.uidAmounts.length);
    $('#batbal-ctrl-summary-player-not-in-faction')
      .text(outsideCount)
      .toggleClass('t-red', outsideCount > 0);
    $('#batbal-ctrl-summary-total-amount').text(formatAmount(total)).addClass(totalClass);
    updateStatus(`Progress: ${action.next} / ${action.uidAmounts.length} done`);
  }

  async function addMoney({ uid, name, amount, actionType, assetType }) {
    const $submit = $('#batbal-ctrl-submit');
    $submit.show();
    const actionSpec = ACTION_SPECS[actionType];
    const textSuffix = ` ${assetType}: ${name} [${uid}] ${formatAmount(amount)}`;
    const queryParam = {
      money: 'factionsGiveMoney',
      points: 'factionsGivePoints',
    }[assetType];
    $submit.text(`${actionSpec.text} ${textSuffix}`);
    $submit.prop('disabled', false);
    return new Promise((resolve, reject) => {
      $submit.on('click', async () => {
        try {
          $submit.off('click');
          $submit.text(`${actionSpec.waitingText} ${textSuffix}`);
          $submit.prop('disabled', true);
          const rfcv = getCookie('rfc_v');
          const rsp = await fetch(`/page.php?sid=${queryParam}&rfcv=${rfcv}`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'x-requested-with': 'XMLHttpRequest',
            },
            body: JSON.stringify({
              option: actionSpec.bodyParam,
              receiver: parseInt(uid),
              amount,
            }),
          });
          const rawData = await rsp.text();
          if (!rsp.ok) {
            throw new Error(`Network error: ${rsp.status} ${rawData}`);
          }
          const data = JSON.parse(rawData);
          if (data.success === true) {
            resolve();
          } else {
            reject(new Error(`Unexpected server response: ${rawData}`));
          }
        } catch (err) {
          reject(err);
        }
      });
    });
  }

  async function start(action, uidMap) {
    storeAction(action);
    $('#batbal-ctrl-start').prop('disabled', true);
    $('#batbal-ctrl-clear-data').prop('disabled', true);

    try {
      while (action.next < action.uidAmounts.length) {
        updateStatus(`Current progress: ${action.next} / ${action.uidAmounts.length} done`);
        const now = Date.now();
        const [uid, amount] = action.uidAmounts[action.next];
        const uidInfo = uidMap[uid] || {};
        const name = uidInfo.name || 'Unknown player';
        await addMoney({ uid, name, amount, actionType: action.actionType, assetType: action.assetType });
        action.next++;
        storeAction(action);
        renderDetails(action, uidMap);
        const elapsed = Date.now() - now;
        if (elapsed < ACTION_INTERVAL_MS) {
          await sleep(ACTION_INTERVAL_MS - elapsed);
        }
      }
      storeAction(null);
      updateStatus('All done!');
    } catch (err) {
      updateStatus(err);
    }
  }

  async function main() {
    try {
      const parsedAction = parseAction();
      const storedAction = getValue(GM_VALUE_KEY, null);
      if (storedAction === null && parsedAction === null) {
        return;
      }

      const uidMap = await getUidMap();
      renderController();
      if (storedAction) {
        renderDetails(storedAction, uidMap);
        $('#batbal-ctrl-clear-data').prop('disabled', false);
      }
      if (parsedAction.error) {
        throw new Error(parsedAction.error);
      }

      const action = checkAction(parsedAction, storedAction);
      if (!storedAction) {
        renderDetails(action, uidMap);
      }
      if (action.actionType === 'give') {
        if (action.uidAmounts.some(([uid]) => !uidMap[uid]?.isInFaction)) {
          throw new Error('Some players are not in the faction');
        }
        if (action.uidAmounts.some(([, amount]) => amount <= 0)) {
          throw new Error('Amounts to give must be positive');
        }
      }

      $('#batbal-ctrl-start').prop('disabled', false);
      $('#batbal-ctrl-start').on('click', () => start(action, uidMap));
    } catch (err) {
      updateStatus(err);
      console.log('Unhandled exception from Batch Balance:', err);
    }
  }

  main();
  console.log('Batch Balance ends');
}

if (document.readyState === 'loading') {
  document.addEventListener('readystatechange', () => {
    if (document.readyState === 'interactive') {
      batchBalanceWrapper();
    }
  });
} else {
  batchBalanceWrapper();
}