Idle Artisan Tracker Ultimate Mozilla Script

Real-time gathering, refining, and XP/hour calculator for Idle Artisan.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Idle Artisan Tracker Ultimate Mozilla Script
// @namespace    https://greasyfork.org/users/Kota
// @version      1.1
// @description  Real-time gathering, refining, and XP/hour calculator for Idle Artisan.
// @author       Nightchildkota
// @match        *://*.idleartisan.com/*
// @license      MIT
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {

const SECONDS_PER_ACTION = 2;

let lastResourceSnapshot = {};
let lastGatherRates = {};
let inactivityTicks = 0;

// --- XP tracking ---
let lastXPPerHour = 0;
let lastSkill = null;
let ignoreNextXP = false;

// --- Skill/Resource Mappings ---
const GATHER_SKILL_MAP = {
    "Mining": "Iron Ore",
    "Woodcutting": "Wood",
    "Fishing": "Fish",
    "Hunting": "Carcass",
    "Battling": "Gold",
    "Scavenging": "Salvage"
};

const REFINE_INPUT_MAP = {
    "Gold": "Gold",
    "Iron": "Iron Ore",
    "Plank": "Wood",
    "Fish": "Fish",
    "Meat": "Carcass"
};

// Map input resource to output name for display
const REFINE_OUTPUT_MAP = {
    "Gold": "Gold Bars",
    "Iron": "Iron Bars",
    "Plank": "Planks",
    "Fish": "Sushi",
    "Meat": "Meat"
};

// --- Helper Functions ---
function parseNumber(text) {
    // Remove commas for thousands, parse float
    return parseFloat(text.replace(/,/g, "")) || 0;
}

function secondsToHMS(seconds) {
    let h = Math.floor(seconds / 3600);
    let m = Math.floor((seconds % 3600) / 60);
    let s = Math.floor(seconds % 60);
    return `${h}h ${m}m ${s}s`;
}

// --- Skill Functions ---
function getActiveSkillName() {
    const active = document.querySelector(".assign-btn.active");
    if (!active) return null;
    return active.innerText.split("\n")[0].trim();
}

function handleSkillSwitch() {
    const currentSkill = getActiveSkillName();
    if (currentSkill !== lastSkill) {
        ignoreNextXP = true; // skip first XP log from previous skill
        lastSkill = currentSkill;
    }
}

function getActiveGatherResource() {
    const skill = getActiveSkillName();
    if (!skill) return null;
    for (let key in GATHER_SKILL_MAP) {
        if (skill.includes(key)) return GATHER_SKILL_MAP[key];
    }
    return null;
}

function getActiveRefine() {
    const active = document.querySelector(".assign-btn.active");
    if (!active) return null;
    const info = active.querySelector(".skill-info");
    if (!info) return null;

    const text = info.innerText;
    const inMatch = text.match(/In:\s*([\d,\.]+)/);
    const outMatch = text.match(/Out:\s*([\d,\.]+)/);
    if (!inMatch || !outMatch) return null;

    const refineName = active.innerText.split("\n")[0];
    let inputResource = null;
    for (let key in REFINE_INPUT_MAP) {
        if (refineName.includes(key)) inputResource = REFINE_INPUT_MAP[key];
    }

    const xpMatch = text.match(/earning\s*([\d,\.]+)\s*XP/i);
    const xpPerAction = xpMatch ? parseNumber(xpMatch[1]) : 0;

    return { name: refineName, ratioIn: parseNumber(inMatch[1]), ratioOut: parseNumber(outMatch[1]), inputResource, xpPerAction };
}

function getResourceAmount(resourceName) {
    const grid = document.getElementById("pinned-resources-grid");
    if (!grid) return 0;

    const cards = grid.querySelectorAll(".resource-card");
    for (let card of cards) {
        if (card.title !== resourceName) continue;
        let raw = card.querySelector(".res-value").innerText.trim();
        // Remove commas only — correct thousands parsing
        raw = raw.replace(/,/g, "");
        return parseFloat(raw) || 0;
    }
    return 0;
}

// --- XP Log Observer ---
function observeXPLogs() {
    const logContainer = document.getElementById("log-messages");
    if (!logContainer) return setTimeout(observeXPLogs, 1000);

    const observer = new MutationObserver(mutations => {
        mutations.forEach(m => {
            m.addedNodes.forEach(node => {
                if (node.nodeType !== 1) return;
                const text = node.innerText;

                const xpMatch = text.match(/earning\s*([\d,\.]+)\s*\w+ XP/i);
                if (xpMatch) {
                    const xpAmount = parseFloat(xpMatch[1].replace(/,/g, ""));

                    // --- Handle skill switch lag ---
                    if (ignoreNextXP) {
                        ignoreNextXP = false;
                        return;
                    }

                    // --- Instant XP/hour calculation ---
                    lastXPPerHour = xpAmount * (3600 / SECONDS_PER_ACTION);
                }
            });
        });
    });

    observer.observe(logContainer, { childList: true, subtree: true });
}

// --- Gather/Refine Rates ---
function calculateGatherPerHour() {
    handleSkillSwitch(); // check if skill changed
    const refine = getActiveRefine();

    // Refining
    if (refine && refine.inputResource) {
        const actionsPerHour = 3600 / SECONDS_PER_ACTION;
        const outputPerHour = actionsPerHour * refine.ratioOut;

        if (refine.xpPerAction) lastXPPerHour = refine.xpPerAction * actionsPerHour;

        const outputName = REFINE_OUTPUT_MAP[refine.inputResource] || refine.name;

        return { [outputName]: outputPerHour, xpPerHour: lastXPPerHour };
    }

    // Gathering
    const grid = document.getElementById("pinned-resources-grid");
    if (!grid) return null;

    const cards = grid.querySelectorAll(".resource-card");
    let currentSnapshot = {};
    let newRates = {};
    let detectedIncrease = false;

    for (let card of cards) {
        const name = card.title;
        let raw = card.querySelector(".res-value").innerText.trim();
        // Remove commas only — correct thousands parsing
        raw = raw.replace(/,/g, "");
        const value = parseFloat(raw) || 0;

        currentSnapshot[name] = value;

        if (lastResourceSnapshot[name] !== undefined) {
            const diff = value - lastResourceSnapshot[name];
            if (diff > 0) {
                detectedIncrease = true;
                newRates[name] = diff * (3600 / SECONDS_PER_ACTION);
            }
        }
    }

    lastResourceSnapshot = currentSnapshot;

    if (detectedIncrease) {
        lastGatherRates = newRates;
        inactivityTicks = 0;
    } else {
        inactivityTicks++;
    }

    // Remove flicker wipe on inactivity, so no clearing lastGatherRates

    // Always include instant XP/hour
    lastGatherRates.xpPerHour = lastXPPerHour;

    if (Object.keys(lastGatherRates).length === 0) return null;
    return lastGatherRates;
}

// --- Update UI ---
function updateCalculator() {
    let html = "";
    const refine = getActiveRefine();

    if (refine && refine.inputResource) {
        const stock = getResourceAmount(refine.inputResource);
        const actions = Math.floor(stock / refine.ratioIn);
        const output = actions * refine.ratioOut;
        const time = actions * SECONDS_PER_ACTION;

        const outputName = REFINE_OUTPUT_MAP[refine.inputResource] || refine.name;

        // === HEADER SHOWS REFINE <INPUT> ===
        html += `<b>Refine ${refine.inputResource}</b><br>
        ${refine.inputResource} in stock: ${stock.toLocaleString('en-US')}<br>
        In: ${refine.ratioIn.toLocaleString('en-US')} → Out: ${refine.ratioOut.toLocaleString('en-US')}
        <hr>
        Actions: ${actions.toLocaleString('en-US')}<br>
        Total craft: ${output.toLocaleString('en-US')} ${outputName}<br>
        Time: ${secondsToHMS(time)}
        <hr>`;
    }

    const gather = calculateGatherPerHour();
    if (gather) {
        const label = (refine && refine.inputResource) ? "Refining Rate" : "Gather Rate";
        html += `<b>${label}</b><br>`;
        for (let resource in gather) {
            if (resource !== "xpPerHour") {
                html += `~${Math.round(gather[resource]).toLocaleString('en-US')} ${resource}/hour<br>`;
            }
        }
        if (gather.xpPerHour) {
            html += `<b>XP/hour:</b> ${Math.round(gather.xpPerHour).toLocaleString('en-US')} XP<br>`;
        }
    }

    if (!html) html = "Waiting for activity…";
    document.getElementById("calcOutput").innerHTML = html;
}

// --- Draggable Window ---
function makeDraggable(el) {
    let isDown = false, offsetX = 0, offsetY = 0;

    el.addEventListener("mousedown", e => {
        isDown = true;
        offsetX = el.offsetLeft - e.clientX;
        offsetY = el.offsetTop - e.clientY;
    });

    document.addEventListener("mouseup", () => isDown = false);
    document.addEventListener("mousemove", e => {
        if (!isDown) return;
        el.style.left = (e.clientX + offsetX) + "px";
        el.style.top = (e.clientY + offsetY) + "px";
    });
}

function createWindow() {
    const box = document.createElement("div");
    box.id = "refineCalcBox";
    box.style.position = "fixed";
    box.style.top = "200px";
    box.style.left = "200px";
    box.style.background = "#1e1e1e";
    box.style.color = "white";
    box.style.padding = "15px";
    box.style.border = "2px solid #555";
    box.style.zIndex = "999999";
    box.style.width = "460px";
    box.style.borderRadius = "8px";
    box.innerHTML = `<b>Idle Artisan Tracker</b><hr><div id="calcOutput">Loading...</div>`;
    document.body.appendChild(box);
    makeDraggable(box);
}

// --- Init ---
window.addEventListener("load", function () {
    createWindow();
    observeXPLogs();
    setInterval(updateCalculator, 1000);
});

})();