Wplace charge regen ETA bubble

ETA bubble with draggable UI

// ==UserScript==
// @name         Wplace charge regen ETA bubble
// @namespace    Zex2
// @version      3.0.7
// @description  ETA bubble with draggable UI
// @match        https://wplace.live/*
// @author       Zex2
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue

// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  // --- Storage keys
  const K = {
    cur: 'eta_cur',
    max: 'eta_max',
    debugOn: 'eta_debug_on',
    bubblePos: 'eta_bubble_pos',
    panelPos: 'eta_panel_pos'
  };

  // --- State
  let current = GM_getValue(K.cur, 148);
  let max = GM_getValue(K.max, 208);
  let lastTimer = null;           // seconds until next tick (parsed from (m:ss))
  let lastTickCheck = Date.now(); // ms clock for AFK recovery
  let lastAutoIncAt = 0;          // ms guard to avoid double-increment on regen detection
  let debugEnabled = GM_getValue(K.debugOn, true);

  // --- UI elements
  let bubble, panel, panelBody;

  // --- Menu
  GM_registerMenuCommand('Set current charges', () => {
    const val = prompt('Current charges:', String(current));
    if (val === null) return;
    current = clampInt(parseInt(val, 10), 0, 9999);
    GM_setValue(K.cur, current);
    log(`Manual set: current=${current}`);
    render();
  });

  GM_registerMenuCommand('Set max charges', () => {
    const val = prompt('Max charges:', String(max));
    if (val === null) return;
    max = clampInt(parseInt(val, 10), 1, 9999);
    GM_setValue(K.max, max);
    log(`Manual set: max=${max}`);
    render();
  });

  GM_registerMenuCommand('Toggle debug overlay', () => {
    debugEnabled = !debugEnabled;
    GM_setValue(K.debugOn, debugEnabled);
    if (panel) panel.style.display = debugEnabled ? 'block' : 'none';
    log(`Debug overlay ${debugEnabled ? 'ENABLED' : 'DISABLED'}`);
  });

  GM_registerMenuCommand('Reset positions', () => {
    GM_setValue(K.bubblePos, null);
    GM_setValue(K.panelPos, null);
    place(bubble, { x: null, y: null }, { bottom: 20, right: 20 });
    place(panel, { x: null, y: null }, { top: 20, left: 20 });
    log('Positions reset');
  });

  // --- Ensure body exists then init
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

  function init() {
    createBubble();
    createPanel();
    makeDraggable(bubble, K.bubblePos, { bottom: 20, right: 20 });
    makeDraggable(panel, K.panelPos, { top: 20, left: 20 });

    // Initial render
    render();

    // Ticker
    setInterval(tick, 500);
  }

  // --- Core loop
  function tick() {
    const timerEl = findTimerEl();
    if (!timerEl) {
      setBubble('Waiting for timer…', `${current}/${max}`);
      return;
    }
    const untilNext = parseTimer(timerEl.textContent);
    if (untilNext == null) {
      setBubble('Invalid timer', `${current}/${max}`);
      return;
    }

    // Auto-increment on cycle reset (e.g., 0:01 -> 0:30)
    if (lastTimer != null) {
      const jumpedUp = untilNext > lastTimer + 10; // robust jump threshold
      const enoughSinceLastInc = (Date.now() - lastAutoIncAt) > 15000; // guard
      if (jumpedUp && current < max && enoughSinceLastInc) {
        current += 1;
        lastAutoIncAt = Date.now();
        GM_setValue(K.cur, current);
        log(`Regen: timer reset detected (+1) → current=${current}/${max}`);
      }
    }
    lastTimer = untilNext;

    // AFK recovery (award charges for elapsed time)
    const now = Date.now();
    const elapsed = Math.floor((now - lastTickCheck) / 1000);
    if (elapsed > 40) {
      const gained = Math.floor(elapsed / 30);
      if (gained > 0 && current < max) {
        const before = current;
        current = Math.min(max, current + gained);
        GM_setValue(K.cur, current);
        log(`AFK recovery: +${current - before} (elapsed ${elapsed}s) → current=${current}/${max}`);
      }
    }
    lastTickCheck = now;

    // Clamp and render
    if (current >= max) {
      current = max;
      setBubble('Fully charged', `${current}/${max}`);
      return;
    }

    const etaSec = Math.max(0, (max - current - 1) * 30 + untilNext);
    setBubble(formatHMS(etaSec) + ' to full', `${current}/${max}`);
  }

  // --- Helpers
  function findTimerEl() {
    // Scan common inline text containers for a string like "(m:ss)"
    const candidates = document.querySelectorAll('time, span, div, p');
    for (const el of candidates) {
      const t = el.textContent || '';
      if (/\(\d+:\d{2}\)/.test(t)) return el;
    }
    return null;
  }

  function parseTimer(s) {
    const m = /\((\d+):(\d{2})\)/.exec(s || '');
    if (!m) return null;
    const mins = parseInt(m[1], 10);
    const secs = parseInt(m[2], 10);
    if (isNaN(mins) || isNaN(secs)) return null;
    return mins * 60 + secs;
  }

  function formatHMS(totalSec) {
    const s = Math.max(0, Math.ceil(totalSec));
    const h = Math.floor(s / 3600);
    const m = Math.floor((s % 3600) / 60);
    const sec = s % 60;
    if (h > 0) return `${h}h ${String(m).padStart(2,'0')}m ${String(sec).padStart(2,'0')}s`;
    return `${m}m ${String(sec).padStart(2,'0')}s`;
  }

  function clampInt(n, lo, hi) {
    if (!Number.isFinite(n)) return lo;
    return Math.min(hi, Math.max(lo, n | 0));
  }

  // --- Bubble renderer (adds edit button; listeners are delegated)
  function setBubble(line1, counts) {
    if (!bubble) return;
    bubble.innerHTML = `
      <div style="font-weight:600">${line1}</div>
      <div style="display:flex; justify-content:center; align-items:center; gap:6px; opacity:.9">
        <span class="charge-counts">${counts}</span>
        <button class="bubble-edit-btn" title="Edit current/max" style="
          background:none; border:1px solid #8a8f98; border-radius:4px; color:#cbd5e1;
          font-size:11px; padding:0 6px; cursor:pointer; line-height:1.6;">⚙️</button>
      </div>
    `;
  }

  function log(msg) {
    if (!panelBody) return;
    const ts = new Date();
    const t = ts.toLocaleTimeString([], { hour12: false });
    const row = document.createElement('div');
    row.textContent = `[${t}] ${msg}`;
    panelBody.appendChild(row);
    // cap to last 120 lines
    while (panelBody.childNodes.length > 120) {
      panelBody.removeChild(panelBody.firstChild);
    }
    if (debugEnabled && panel) panel.style.display = 'block';
  }

  // --- UI builders
  function createBubble() {
    bubble = document.createElement('div');
    Object.assign(bubble.style, {
      position: 'fixed',
      bottom: '20px',
      right: '20px',
      padding: '8px 10px',
      background: 'rgba(16,18,22,0.9)',
      color: '#e6edf3',
      border: '1px solid #2b3138',
      borderRadius: '10px',
      font: '12px/1.4 system-ui, Segoe UI, Roboto, Arial, sans-serif',
      zIndex: 2147483647,
      boxShadow: '0 6px 18px rgba(0,0,0,0.25)',
      cursor: 'move',
      minWidth: '160px'
    });

    // Delegate edit button click (no duplicate listeners on re-render)
    bubble.addEventListener('click', (e) => {
      const editBtn = e.target.closest('.bubble-edit-btn');
      if (!editBtn) return;
      e.stopPropagation();
      const inVal = prompt('Enter current/max charges:', `${current}/${max}`);
      if (!inVal) return;
      const m = inVal.match(/^\s*(\d+)\s*\/\s*(\d+)\s*$/);
      if (!m) {
        alert('Format must be: number/number (e.g., 7/10)');
        return;
      }
      const newCur = clampInt(parseInt(m[1], 10), 0, 9999);
      const newMax = clampInt(parseInt(m[2], 10), 1, 9999);
      current = Math.min(newCur, newMax);
      max = newMax;
      GM_setValue(K.cur, current);
      GM_setValue(K.max, max);
      log(`Inline edit via button: current=${current}, max=${max}`);
      render();
    });

    // Prevent drag start when pressing the edit button
    bubble.addEventListener('mousedown', (e) => {
      if (e.target.closest('.bubble-edit-btn')) e.stopPropagation();
    }, true);

    document.body.appendChild(bubble);
    place(bubble, GM_getValue(K.bubblePos, null), { bottom: 20, right: 20 });
  }

  function createPanel() {
    panel = document.createElement('div');
    Object.assign(panel.style, {
      position: 'fixed',
      top: '20px',
      left: '20px',
      width: '360px',
      maxHeight: '40vh',
      overflow: 'auto',
      background: 'rgba(10,12,16,0.95)',
      color: '#cbd5e1',
      border: '1px solid #2b3138',
      borderRadius: '8px',
      font: '12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
      zIndex: 2147483647,
      boxShadow: '0 8px 22px rgba(0,0,0,0.35)',
      display: debugEnabled ? 'block' : 'none',
      cursor: 'move'
    });

    const header = document.createElement('div');
    header.textContent = 'Charge ETA — Debug';
    Object.assign(header.style, {
      padding: '6px 8px',
      fontWeight: '700',
      borderBottom: '1px solid #2b3138',
      background: 'rgba(255,255,255,0.03)'
    });

    const controls = document.createElement('div');
    Object.assign(controls.style, {
      display: 'flex',
      gap: '8px',
      padding: '6px 8px',
      borderBottom: '1px solid #2b3138',
      flexWrap: 'wrap'
    });

    const btnHide = document.createElement('button');
    btnHide.textContent = 'Do not show again';
    styleBtn(btnHide);
    btnHide.onclick = () => {
      debugEnabled = false;
      GM_setValue(K.debugOn, false);
      panel.style.display = 'none';
    };

    const btnClear = document.createElement('button');
    btnClear.textContent = 'Clear';
    styleBtn(btnClear);
    btnClear.onclick = () => {
      panelBody.innerHTML = '';
      log('Log cleared');
    };

    const btnSync = document.createElement('button');
    btnSync.textContent = 'Sync +1';
    styleBtn(btnSync);
    btnSync.onclick = () => {
      if (current < max) {
        current += 1;
        GM_setValue(K.cur, current);
        log(`Manual sync: +1 → current=${current}/${max}`);
        render();
      }
    };

    controls.append(btnHide, btnClear, btnSync);

    panelBody = document.createElement('div');
    Object.assign(panelBody.style, {
      padding: '6px 8px',
      whiteSpace: 'pre-wrap'
    });

    panel.append(header, controls, panelBody);
    document.body.appendChild(panel);
    place(panel, GM_getValue(K.panelPos, null), { top: 20, left: 20 });

    // initial line shows state
    log(`Startup: current=${current}/${max}`);
  }

  function styleBtn(b) {
    Object.assign(b.style, {
      background: '#0b63ff',
      color: 'white',
      border: '1px solid #1547b0',
      padding: '2px 6px',
      borderRadius: '6px',
      cursor: 'pointer',
      font: '600 11px system-ui, sans-serif'
    });
  }

  // --- Positioning + drag with persistence
  function makeDraggable(el, storageKey, fallback) {
    if (!el) return;
    let start = null;
    el.addEventListener('mousedown', (e) => {
      if (e.target.closest('button, input, textarea, select, a')) return;
      start = { x: e.clientX, y: e.clientY, left: el.offsetLeft, top: el.offsetTop };
      e.preventDefault();
    });
    window.addEventListener('mousemove', (e) => {
      if (!start) return;
      const dx = e.clientX - start.x;
      const dy = e.clientY - start.y;
      el.style.left = (start.left + dx) + 'px';
      el.style.top = (start.top + dy) + 'px';
      el.style.right = 'auto';
      el.style.bottom = 'auto';
    });
    window.addEventListener('mouseup', () => {
      if (!start) return;
      start = null;
      const rect = el.getBoundingClientRect();
      GM_setValue(storageKey, { x: rect.left + window.scrollX, y: rect.top + window.scrollY });
    });

    // Touch
    el.addEventListener('touchstart', (e) => {
      const t = e.touches[0];
      start = { x: t.clientX, y: t.clientY, left: el.offsetLeft, top: el.offsetTop };
    }, { passive: true });
    window.addEventListener('touchmove', (e) => {
      if (!start) return;
      const t = e.touches[0];
      const dx = t.clientX - start.x;
      const dy = t.clientY - start.y;
      el.style.left = (start.left + dx) + 'px';
      el.style.top = (start.top + dy) + 'px';
      el.style.right = 'auto';
      el.style.bottom = 'auto';
    }, { passive: true });
    window.addEventListener('touchend', () => {
      if (!start) return;
      start = null;
      const rect = el.getBoundingClientRect();
      GM_setValue(storageKey, { x: rect.left + window.scrollX, y: rect.top + window.scrollY });
    });

    // Initial placement
    place(el, GM_getValue(storageKey, null), fallback);
  }

  function place(el, saved, fallback) {
    if (!el) return;
    if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') {
      el.style.left = saved.x + 'px';
      el.style.top = saved.y + 'px';
      el.style.right = 'auto';
      el.style.bottom = 'auto';
    } else {
      if (fallback.left != null) el.style.left = fallback.left + 'px';
      if (fallback.top != null) el.style.top = fallback.top + 'px';
      if (fallback.right != null) el.style.right = fallback.right + 'px';
      if (fallback.bottom != null) el.style.bottom = fallback.bottom + 'px';
    }
  }

  function render() {
    const timerEl = findTimerEl();
    const untilNext = timerEl ? parseTimer(timerEl.textContent) : null;
    if (current >= max) {
      current = max;
      setBubble('Fully charged', `${current}/${max}`);
      return;
    }
    if (untilNext == null) {
      setBubble('Waiting for timer…', `${current}/${max}`);
      return;
    }
    const etaSec = Math.max(0, (max - current - 1) * 30 + untilNext);
    setBubble(formatHMS(etaSec) + ' to full', `${current}/${max}`);
  }
})();