Neopets Auto Zapper Pro V2

Auto-zapper with persistent pet memory and lab2 cooldown sync

// ==UserScript==
// @name         Neopets Auto Zapper Pro V2
// @namespace    Neopets Auto Zapper Pro
// @version      2.1.1
// @description  Auto-zapper with persistent pet memory and lab2 cooldown sync
// @author       combined: badsk8700o / thezuki10 / nadinejun0 + modifications
// @match        https://www.neopets.com/lab.phtml
// @match        https://www.neopets.com/lab2.phtml
// @match        https://www.neopets.com/process_lab2.phtml
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ========== STORAGE (Using GM for persistence; fallback/sync with localStorage) ==========
  const STORE = {
    getPet: () => {
      const gm = GM_getValue('zapPet', null);
      if (gm) return gm;
      // fallback to older script
      try { return localStorage.getItem('lastSelectedPet') || null; } catch (e) { return null; }
    },
    setPet: (v) => {
      GM_setValue('zapPet', v);
      try { localStorage.setItem('lastSelectedPet', v); } catch (e) {}
    },
    clearPet: () => {
      GM_setValue('zapPet', null);
      try { localStorage.removeItem('lastSelectedPet'); } catch (e) {}
    },

    getCount: () => parseInt(GM_getValue('zapCount', '0'), 10),
    setCount: (n) => GM_setValue('zapCount', String(n)),
    incCount: () => STORE.setCount(STORE.getCount() + 1),

    getDate: () => GM_getValue('zapDate', null),
    setDate: (d) => GM_setValue('zapDate', d),

    getRemaining: () => parseInt(GM_getValue('zapRemaining', '0'), 10),
    setRemaining: (n) => GM_setValue('zapRemaining', String(n)),

    getTodayNST: () => {
      const now = new Date();
      const nst = new Date(now.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }));
      return nst.toISOString().split('T')[0];
    },

    checkAndResetDay: () => {
      const today = STORE.getTodayNST();
      const stored = STORE.getDate();
      if (stored !== today) {
        STORE.setDate(today);
        STORE.setCount(0);
        return true;
      }
      return false;
    },
  };

  function log(...args) { console.log('[AutoZapper]', ...args); }
  function randDelay(min = 800, max = 1800) { return min + Math.floor(Math.random() * (max - min + 1)); }

  function getZapsFromPage() {
    const text = document.body.innerText || '';
    let match = text.match(/Zaps Left Today:\s*(\d+)\s*\/\s*(\d+)/i);
    if (match) return { remaining: parseInt(match[1], 10), total: parseInt(match[2], 10) };
    match = text.match(/(\d+)\s*\/\s*(\d+)\s*(?:zaps?|Zaps?)/i);
    if (match) return { remaining: parseInt(match[1], 10), total: parseInt(match[2], 10) };
    log('Could not find zap count; defaulting to 0');
    return { remaining: 0, total: 0 };
  }

  // ========== LAB.PHTML ==========
  function handleLabPage() {
    if (!window.location.pathname.includes('lab.phtml')) return false;
    log('On lab.phtml - going to lab2');
    setTimeout(() => {
      let form = document.querySelector('form[action="lab2.phtml"]') || document.querySelector('form[action*="lab2"]');
      if (!form) {
        const allForms = document.querySelectorAll('form');
        allForms.forEach((f) => { if (!form && (f.action && f.action.includes('lab2'))) form = f; });
      }
      if (form) form.submit();
      else setTimeout(() => { window.location.href = 'https://www.neopets.com/lab2.phtml'; }, 1000);
    }, 1500);
    return true;
  }

  // ========== UTIL: Wait for STOP cue and cooldown ==========
  // Looks for visible text "STOP - Choose Different Pet" and parses nearby countdown or waits until it's removed.
  function waitForStopCueThenSubmit(petName, form, userStoppedFlag) {
    // If user stopped via UI, do nothing
    if (userStoppedFlag && userStoppedFlag.value) {
      log('User cancelled auto-submit (stop flag). Aborting submit.');
      return;
    }

    // try to find node that contains the STOP phrase
    const findStopNode = () => Array.from(document.querySelectorAll('body *')).find(el => {
      try { return (el.innerText || '').match(/STOP\s*-\s*Choose Different Pet/i); } catch (e) { return false; }
    });

    const stopNode = findStopNode();

    if (!stopNode) {
      // no STOP cue on page. submit after small delay
      log('No STOP cue found. Submitting after random delay.');
      setTimeout(() => {
        if (!(userStoppedFlag && userStoppedFlag.value)) form.submit();
      }, randDelay());
      return;
    }

    log('STOP cue found. Attempting to parse countdown or observe DOM.');

    // Try to parse seconds from text near the node (parent or same container)
    const candidateText = (stopNode.parentElement && stopNode.parentElement.innerText) ? stopNode.parentElement.innerText : stopNode.innerText;
    let secs = null;

    // mm:ss or ss
    const mmss = candidateText.match(/(\d{1,2}):(\d{2})/);
    if (mmss) secs = parseInt(mmss[1], 10) * 60 + parseInt(mmss[2], 10);
    else {
      const sMatch = candidateText.match(/(\d{1,4})\s*seconds?/i);
      if (sMatch) secs = parseInt(sMatch[1], 10);
    }

    if (secs !== null && !Number.isNaN(secs)) {
      const ms = (secs + 1) * 1000; // small buffer
      log('Parsed countdown seconds:', secs, 'waiting', ms, 'ms before submit');
      setTimeout(() => {
        if (!(userStoppedFlag && userStoppedFlag.value)) form.submit();
      }, ms);
      return;
    }

    // If no numeric countdown, observe DOM for removal or change
    const observer = new MutationObserver((mutationsList) => {
      // Re-check existence of STOP text
      const still = findStopNode();
      if (!still) {
        observer.disconnect();
        log('STOP cue removed from page. Proceeding to submit.');
        setTimeout(() => {
          if (!(userStoppedFlag && userStoppedFlag.value)) form.submit();
        }, randDelay());
      } else {
        // optional: check again for digits as node updates
        const txt = still.innerText || still.parentElement && still.parentElement.innerText || '';
        const s = txt.match(/(\d{1,2}):(\d{2})/) || txt.match(/(\d{1,4})\s*seconds?/i);
        if (s) {
          observer.disconnect();
          let secs2;
          if (s.length === 3) secs2 = parseInt(s[1], 10) * 60 + parseInt(s[2], 10);
          else secs2 = parseInt(s[1], 10);
          const ms2 = (secs2 + 1) * 1000;
          log('Found countdown while observing. Waiting', ms2, 'ms then submit.');
          setTimeout(() => {
            if (!(userStoppedFlag && userStoppedFlag.value)) form.submit();
          }, ms2);
        }
      }
    });

    observer.observe(document.body, { childList: true, subtree: true, characterData: true });
    // Safety fallback: after 40s, stop observing and submit if not user stopped
    setTimeout(() => {
      try { observer.disconnect(); } catch (e) {}
      if (!(userStoppedFlag && userStoppedFlag.value)) {
        log('Fallback timeout reached. Submitting.');
        setTimeout(() => { if (!(userStoppedFlag && userStoppedFlag.value)) form.submit(); }, randDelay());
      }
    }, 40000);
  }

  // ========== LAB2.PHTML ==========
  function handleLab2Page() {
    if (!window.location.pathname.includes('lab2.phtml')) return false;
    log('On lab2.phtml - build UI and manage pet memory');

    const isNewDay = STORE.checkAndResetDay();
    const storedPet = STORE.getPet();
    const used = STORE.getCount();
    const { remaining, total } = getZapsFromPage();

    log('Day reset?', isNewDay, '| Stored pet:', storedPet, '| Used:', used, '| Remaining:', remaining);

    const petListRoot = document.querySelector('#bxlist');
    const form = document.querySelector('form[action="process_lab2.phtml"]');
    if (!petListRoot || !form) return false;

    const slider = document.querySelector('.bx-wrapper');
    if (slider) slider.style.display = 'none';

    // small flag object to allow closures to modify stopped state
    const userStoppedFlag = { value: false };

    // Counter box (kept visually similar)
    const counterBox = document.createElement('div');
    counterBox.style.cssText = 'text-align:center;margin:16px auto;padding:12px;background:#e9ecef;border-radius:8px;max-width:600px;font-weight:bold;font-size:14px;';
    counterBox.innerHTML = `<div>Total Zaps Today: <span>${total}</span></div><div>Used: <span>${used}</span> | Remaining: <span>${remaining}</span></div>`;

    // Grid container (keeps much of original structure)
    const gridContainer = document.createElement('div');
    gridContainer.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;padding:16px;max-width:900px;margin:16px auto;';

    const petLis = Array.from(petListRoot.querySelectorAll('li'));
    const petCards = [];

    petLis.forEach(li => {
      const radio = li.querySelector('input[type="radio"]');
      const img = li.querySelector('img');
      const label = li.querySelector('b');
      if (!radio || !img) return;
      const petName = radio.value || (label && label.innerText);
      if (!petName) return;

      const card = document.createElement('div');
      card.style.cssText = 'border:3px solid #ccc;border-radius:8px;padding:12px;background:white;cursor:pointer;text-align:center;transition:all .2s;box-shadow:0 2px 4px rgba(0,0,0,0.1);';
      const imgDiv = document.createElement('div');
      imgDiv.style.cssText = 'height:120px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;';
      const clonedImg = img.cloneNode(true);
      clonedImg.style.maxWidth = '100%'; clonedImg.style.maxHeight = '100%'; clonedImg.style.objectFit = 'contain';
      imgDiv.appendChild(clonedImg);
      const nameDiv = document.createElement('div');
      nameDiv.textContent = petName;
      nameDiv.style.cssText = 'font-weight:bold;font-size:12px;word-wrap:break-word;';
      card.appendChild(imgDiv); card.appendChild(nameDiv);

      card.addEventListener('mouseenter', () => { if (!radio.checked) card.style.backgroundColor = '#f0f0f0'; });
      card.addEventListener('mouseleave', () => { if (!radio.checked) card.style.backgroundColor = 'white'; });

      card.addEventListener('click', () => {
        // user clicked: clear any auto-stop and register pet
        document.querySelectorAll('#bxlist input[type="radio"]').forEach(r => r.checked = false);
        petCards.forEach(c => { c.card.style.borderColor = '#ccc'; c.card.style.backgroundColor = 'white'; });
        radio.checked = true;
        STORE.setPet(petName);
        try { localStorage.setItem('lastSelectedPet', petName); } catch (e) {}
        STORE.setRemaining(remaining);
        card.style.borderColor = '#FFD700'; card.style.backgroundColor = '#FFFACD';
        // Auto-submit after a small delay, but respect any STOP cue
        setTimeout(() => {
          waitForStopCueThenSubmit(petName, form, userStoppedFlag);
        }, randDelay());
      });

      petCards.push({ card, petName, radio });
      gridContainer.appendChild(card);

      if (storedPet === petName) {
        radio.checked = true;
        card.style.borderColor = '#FFD700';
        card.style.backgroundColor = '#FFFACD';
      }
    });

    // Insert UI
    const zapInfo = form.querySelector('p[style*="text-align"]');
    if (zapInfo) form.insertBefore(gridContainer, zapInfo);
    else form.insertBefore(gridContainer, form.firstChild);
    form.insertBefore(counterBox, form.firstChild);

    // STOP button for UI control (clears memory and sets userStoppedFlag)
    function addStopButtonUI(container) {
      const stopBtn = document.createElement('button');
      stopBtn.textContent = '⏸️ Stop Zapping This Pet';
      stopBtn.style.cssText = 'display:block;margin:10px auto;padding:8px 16px;cursor:pointer;border-radius:6px;background:#ff9800;color:white;border:none;font-weight:bold;font-size:12px;';
      stopBtn.addEventListener('click', () => {
        STORE.clearPet();
        userStoppedFlag.value = true;
        stopBtn.remove();
        log('User cleared stored pet and set stop flag');
      });
      container.appendChild(stopBtn);
    }

    // If it's a new day and a storedPet exists, show countdown notice then submit respecting STOP cue
    if (isNewDay && storedPet && remaining > 0) {
      showNewDayCountdown(form, storedPet, counterBox, userStoppedFlag);
    } else if (storedPet && remaining > 0) {
      // same-day auto-continue with stored pet but wait for in-page STOP cue timing
      log('Same day - proceeding to auto-submit stored pet (respecting STOP cue if present)');
      // choose the pet radio visually and then wait for STOP cue before final submit
      const radio = Array.from(document.querySelectorAll('#bxlist input[type="radio"]')).find(r => r.value === storedPet);
      if (radio) radio.checked = true;
      waitForStopCueThenSubmit(storedPet, form, userStoppedFlag);
    }

    if (storedPet && !isNewDay && remaining > 0) addStopButtonUI(counterBox);

    return true;
  }

  // keep original new-day UI but wire in userStoppedFlag so stop UI works
  function showNewDayCountdown(form, petName, counterBox, userStoppedFlag) {
    const notice = document.createElement('div');
    notice.style.cssText = 'background:#fff3cd;border:3px solid #ffc107;border-radius:8px;padding:16px;margin:16px auto;text-align:center;max-width:600px;font-weight:bold;';
    const title = document.createElement('div');
    title.innerHTML = '<strong style="font-size:18px;">NEW DAY! Auto-zapping starts in...</strong>'; title.style.marginBottom = '10px';
    const countdown = document.createElement('div'); countdown.style.cssText = 'font-size:24px;color:#d9534f;margin:10px 0;'; let secs = 10; countdown.textContent = secs;
    const stopBtn = document.createElement('button');
    stopBtn.textContent = '❌ STOP - Choose Different Pet';
    stopBtn.style.cssText = 'margin-top:12px;padding:10px 16px;cursor:pointer;border-radius:6px;background:#dc3545;color:white;border:none;font-weight:bold;font-size:14px;';
    notice.appendChild(title); notice.appendChild(countdown); notice.appendChild(stopBtn);
    counterBox.insertBefore(notice, counterBox.firstChild);

    stopBtn.addEventListener('click', () => {
      userStoppedFlag.value = true;
      STORE.clearPet();
      notice.remove();
      log('User clicked STOP in NEW DAY notice; auto-start cancelled.');
    });

    const interval = setInterval(() => {
      secs--;
      countdown.textContent = secs;
      if (secs <= 0) {
        clearInterval(interval);
        notice.remove();
        // After countdown, find and use the stored pet; respect in-page STOP cue that the real site may show
        waitForStopCueThenSubmit(petName, form, userStoppedFlag);
      }
    }, 1000);
  }

  // ========== PROCESS_LAB2.PHTML ==========
  function handleResultsPage() {
    if (!window.location.pathname.includes('process_lab2.phtml')) return false;
    log('On results page - incrementing count');
    STORE.incCount();
    let remaining = STORE.getRemaining() - 1;
    remaining = Math.max(0, remaining);
    STORE.setRemaining(remaining);
    log('Results - Remaining zaps (from memory):', remaining);
    if (remaining <= 0) {
      STORE.clearPet();
      log('No zaps left - cleared stored pet');
    }

    const statusBox = document.createElement('div');
    statusBox.style.cssText = 'background:#d4edda;border:3px solid #28a745;border-radius:8px;padding:20px;text-align:center;margin:20px auto;max-width:600px;font-weight:bold;';
    const msg = document.createElement('div'); msg.style.fontSize = '16px';
    msg.innerHTML = remaining > 0 ? '✅ Zap successful! Returning to Lab...' : '🎉 All zaps used for today!';
    const countdown = document.createElement('div'); countdown.style.cssText = 'font-size:24px;color:#d9534f;margin-top:12px;'; let secs = 10; countdown.textContent = secs;
    statusBox.appendChild(msg); statusBox.appendChild(countdown);
    const centerTag = document.querySelector('center');
    if (centerTag) centerTag.insertBefore(statusBox, centerTag.firstChild); else document.body.insertBefore(statusBox, document.body.firstChild);

    let interval;
    const redirectToLab = () => {
      if (interval) clearInterval(interval);
      log('Redirecting to lab.phtml');
      window.location.replace('https://www.neopets.com/lab.phtml');
      setTimeout(() => { window.location.href = 'https://www.neopets.com/lab.phtml'; }, 800);
      setTimeout(() => { window.location.assign('https://www.neopets.com/lab.phtml'); }, 1600);
      setTimeout(() => { const link = document.createElement('a'); link.href = 'https://www.neopets.com/lab.phtml'; link.style.display = 'none'; document.body.appendChild(link); link.click(); link.remove(); }, 2400);
      setTimeout(() => { history.back(); }, 3200);
    };

    interval = setInterval(() => {
      secs--;
      countdown.textContent = secs;
      if (secs <= 0) redirectToLab();
    }, 1000);

    return true;
  }

  // ========== INIT ==========
  (async function init() {
    STORE.checkAndResetDay();
    if (handleLabPage()) return;
    if (handleLab2Page()) return;
    if (handleResultsPage()) return;
  })();
})();