MZ - Training History

Displays skill gains across previous seasons

질문, 리뷰하거나, 이 스크립트를 신고하세요.
// ==UserScript==
// @name         MZ - Training History
// @namespace    douglaskampl
// @version      3.3
// @description  Displays skill gains across previous seasons
// @author       Douglas
// @match        https://www.managerzone.com/?p=players
// @match        https://www.managerzone.com/?p=players&pid*
// @match        https://www.managerzone.com/?p=transfer*
// @exclude      https://www.managerzone.com/?p=transfer_history*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=managerzone.com
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @require      https://cdnjs.cloudflare.com/ajax/libs/spin.js/2.3.2/spin.min.js
// @resource     trainingHistoryStyles https://u18mz.vercel.app/mz/userscript/other/vTrainingHistory.css
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    GM_addStyle(GM_getResourceText('trainingHistoryStyles'));

    const SKILL_MAP = {
        '1': 'Speed',
        '2': 'Stamina',
        '3': 'Play Intelligence',
        '4': 'Passing',
        '5': 'Shooting',
        '6': 'Heading',
        '7': 'Keeping',
        '8': 'Ball Control',
        '9': 'Tackling',
        '10': 'Aerial Passing',
        '11': 'Set Plays'
    };

    const ORDERED_SKILLS = ['Speed', 'Stamina', 'Play Intelligence', 'Passing', 'Shooting', 'Heading', 'Keeping', 'Ball Control', 'Tackling', 'Aerial Passing', 'Set Plays'];

    let myTeamId = null;

    function getCurrentSeasonInfo() {
        const w = document.querySelector('#header-stats-wrapper');
        if (!w) {
            return null;
        }
        const dn = w.querySelector('h5.flex-grow-1.textCenter:not(.linked)');
        const ln = w.querySelector('h5.flex-grow-1.textCenter.linked');
        if (!dn || !ln) {
            return null;
        }
        const dm = dn.textContent.match(/(\d{1,2})[/-](\d{1,2})[/-](\d{4})/);
        if (!dm) {
            return null;
        }
        const d = dm[1], m = dm[2], y = dm[3];
        const currentDate = new Date([m, d, y].join('/'));
        const digits = ln.textContent.match(/\d+/g);
        if (!digits || digits.length < 3) {
            return null;
        }
        const season = parseInt(digits[0], 10);
        const day = parseInt(digits[2], 10);
        const info = { currentDate, season, day };
        return info;
    }

    function getSeasonCalculator(cs) {
        if (!cs) {
            return () => 0;
        }
        const baseSeason = cs.season;
        const baseDate = cs.currentDate;
        const dayOffset = cs.day;
        const seasonStart = new Date(baseDate);
        seasonStart.setDate(seasonStart.getDate() - (dayOffset - 1));
        return d => {
            let s = baseSeason;
            let ref = seasonStart.getTime();
            let diff = Math.floor((d.getTime() - ref) / 86400000);
            while (diff < 0) {
                s--;
                diff += 91;
            }
            while (diff >= 91) {
                s++;
                diff -= 91;
            }
            return s;
        };
    }

    function getPlayerContainerNode(n) {
        let c = n.closest('.playerContainer');
        if (!c) c = document.querySelector('.playerContainer');
        return c;
    }

    function parseSeriesData(txt) {
        const m = txt.match(/var series = (\[.*?\]);/);
        const parsed = m ? JSON.parse(m[1]) : null;
        return parsed;
    }

    function processTrainingHistory(series, getSeasonFn) {
        const bySeason = {};
        const skillTotals = {};
        let total = 0;
        let earliest = 9999;
        series.forEach(s => {
            s.data.forEach((pt, i) => {
                if (pt.marker?.symbol.includes('gained_skill.png') && s.data[i + 1]) {
                    const d = new Date(s.data[i + 1].x);
                    const sea = getSeasonFn(d);
                    if (!bySeason[sea]) bySeason[sea] = [];
                    const sid = s.data[i + 1].y.toString();
                    const sk = SKILL_MAP[sid] || 'Unknown';
                    bySeason[sea].push({ dateString: d.toDateString(), skillName: sk });
                    if (!skillTotals[sk]) skillTotals[sk] = 0;
                    skillTotals[sk]++;
                    total++;
                    if (sea < earliest) earliest = sea;
                }
            });
        });
        const result = { bySeason, skillTotals, total, earliestSeason: earliest };
        return result;
    }

    function createModal(content, spin) {
        const ov = document.createElement('div');
        ov.className = 'mz-training-overlay';
        const mo = document.createElement('div');
        mo.className = 'mz-training-modal';
        const bd = document.createElement('div');
        bd.className = 'mz-training-modal-content';
        const sp = document.createElement('div');
        sp.style.height = '60px';
        sp.style.display = spin ? 'block' : 'none';
        bd.appendChild(sp);
        if (content) bd.innerHTML += content;
        const cl = document.createElement('div');
        cl.className = 'mz-training-modal-close';
        cl.innerHTML = '×';
        cl.onclick = () => ov.remove();
        mo.appendChild(cl);
        mo.appendChild(bd);
        ov.appendChild(mo);
        document.body.appendChild(ov);
        ov.addEventListener('click', e => {
            if (e.target === ov) ov.remove();
        });
        requestAnimationFrame(() => {
            ov.classList.add('show');
            mo.classList.add('show');
        });
        let spinnerInstance = null;
        if (spin) {
            spinnerInstance = new Spinner({ color: '#ffa500', lines: 12 });
            spinnerInstance.spin(sp);
        }
        return { modal: mo, spinnerEl: sp, spinnerInstance, overlay: ov };
    }

    function gatherCurrentSkills(container) {
        const rows = container.querySelectorAll('table.player_skills tr');
        const out = {};
        let i = 1;
        rows.forEach(r => {
            const valCell = r.querySelector('.skillval span');
            if (!valCell) return;
            const name = SKILL_MAP[i.toString()];
            if (name) {
                const v = parseInt(valCell.textContent.trim(), 10);
                out[name] = isNaN(v) ? 0 : v;
            }
            i++;
        });
        return out;
    }

    function getTotalBallsFromSkillMap(map) {
        const total = Object.values(map).reduce((a, b) => a + b, 0);
        return total;
    }

    function fillSeasonGains(bySeason, earliestSeason, currentSeason, skillTotals) {
        const out = {};
        for (let s = earliestSeason; s <= currentSeason; s++) {
            out[s] = {};
            if (bySeason[s]) {
                bySeason[s].forEach(ev => {
                    if (!skillTotals[ev.skillName]) return;
                    if (!out[s][ev.skillName]) out[s][ev.skillName] = 0;
                    out[s][ev.skillName]++;
                });
            }
        }
        return out;
    }

    function parsePlayerAge(container) {
        const strongEls = container.querySelectorAll('strong');
        for (const el of strongEls) {
            const numberMatch = el.textContent.trim().match(/^(\d{1,2})$/);
            if (numberMatch) {
                const age = parseInt(numberMatch[1], 10);
                if (age >= 15 && age <= 45) {
                    return age;
                }
            }
        }
        const allNums = container.textContent.match(/\b(\d{1,2})\b/g);
        if (allNums) {
            for (const numString of allNums) {
                const age = parseInt(numString, 10);
                if (age >= 15 && age <= 45) {
                    return age;
                }
            }
        }
        return null;
    }

    function calculateHistoricalAge(params) {
        const { currentAge, currentSeason, targetSeason } = params;
        if (!currentAge) {
            return null;
        }
        const seasonDiff = currentSeason - targetSeason;
        const historicalAge = currentAge - seasonDiff;
        return historicalAge;
    }

    function buildSeasonCheckpointData(earliestSeason, currentSeason, finalMap, seasonGains, container) {
        const out = [];
        const baseMap = {};
        const currentAge = parsePlayerAge(container);
        ORDERED_SKILLS.forEach(sk => {
            baseMap[sk] = finalMap[sk] || 0;
        });
        for (let s = currentSeason; s >= earliestSeason; s--) {
            const age = calculateHistoricalAge({
                currentAge,
                currentSeason,
                targetSeason: s
            });
            const label = age !== null ? `${s} (${age})` : s.toString();
            const snapshot = {};
            ORDERED_SKILLS.forEach(k => {
                snapshot[k] = baseMap[k] || 0;
            });
            if (seasonGains[s]) {
                Object.keys(seasonGains[s]).forEach(k => {
                    snapshot[k] -= seasonGains[s][k];
                    if (snapshot[k] < 0) snapshot[k] = 0;
                });
            }
            out.unshift({ season: s, label, distribution: snapshot });
            if (seasonGains[s]) {
                Object.keys(seasonGains[s]).forEach(k => {
                    baseMap[k] -= seasonGains[s][k];
                    if (baseMap[k] < 0) baseMap[k] = 0;
                });
            }
        }
        out.push({
            season: currentSeason,
            label: 'Current',
            distribution: finalMap
        });
        return out;
    }

    function buildStatesLayout(bySeason, skillTotals, container, currentSeason, earliestSeason) {
        const finalMap = gatherCurrentSkills(container);
        const seasonGains = fillSeasonGains(bySeason, earliestSeason, currentSeason, skillTotals);
        const arr = buildSeasonCheckpointData(earliestSeason, currentSeason, finalMap, seasonGains, container);
        let html = '<div class="mz-state-wrapper">';
        arr.forEach(o => {
            const sum = getTotalBallsFromSkillMap(o.distribution);
            let headerText;
            if (o.label === 'Current') {
                headerText = o.label;
            } else {
                const [season, age] = o.label.split(' ');
                headerText = `Beginning of Season ${season} (Age ${age.replace('(', '').replace(')', '')})`;
            }
            html += `<div class="mz-state-col">
                        <h4>${headerText}</h4>
                        <div class="mz-state-info">Total Skill Balls: <strong>${sum}</strong></div>
                        <div class="mz-state-skills">${makeSkillRows(o.distribution)}</div>
                     </div>`;
        });
        html += '</div>';
        return html;
    }

    function makeSkillRows(map) {
        let out = '';
        ORDERED_SKILLS.forEach(k => {
            let v = map[k] || 0;
            if (v < 0) v = 0;
            if (v > 10) v = 10;
            out += `<div class="mz-state-skill">
                     <div class="mz-skill-name"><strong>${k}</strong></div>
                     <div class="mz-skill-val">
                       <img src="nocache-922/img/soccer/wlevel_${v}.gif" alt="">
                       (${v})
                     </div>
                    </div>`;
        });
        return out;
    }

    function generateEvolHTML(bySeason, total, skillTotals, currentSeason, container) {
        let html = '';
        const currentAge = parsePlayerAge(container);
        const sorted = Object.keys(bySeason)
            .map(x => parseInt(x, 10))
            .sort((a, b) => a - b);
        sorted.forEach(se => {
            const items = bySeason[se];
            const age = calculateHistoricalAge({
                currentAge,
                currentSeason,
                targetSeason: se
            });
            const label = age !== null ? `Season ${se} (Age ${age})` : se.toString();
            html += `<div class="mz-training-season">
                <h3>${label} — ${items.length} Balls Earned</h3>
                <ul>`;
            items.forEach(it => {
                html += `<li><strong>${it.dateString}</strong> ${it.skillName}</li>`;
            });
            html += '</ul></div>';
        });
        html += `<hr><h3 class="mz-training-final-summary">Total balls earned: ${total}</h3>`;
        const fs = Object.entries(skillTotals)
            .filter(x => x[1] > 0)
            .map(x => `${x[0]} (${x[1]})`)
            .join(', ');
        html += `<h3 class="mz-training-skilltotals">${fs}</h3>`;
        return html;
    }

    function generateTabsHTML(name, evo, st) {
        return `
        <h2 class="mz-training-title">Gains for ${name}</h2>
        <div class="mz-training-tabs">
            <div class="mz-training-tab-buttons">
                <button class="mz-training-tab-btn" data-tab="evolution">Gains</button>
                <button class="mz-training-tab-btn active" data-tab="states">Player Development</button>
            </div>
            <div class="mz-training-tab-content" data-content="evolution">${evo}</div>
            <div class="mz-training-tab-content active" data-content="states">${st}</div>
        </div>`;
    }

    function attachTabEvents(modal) {
        const tbs = modal.querySelectorAll('.mz-training-tab-btn');
        const cs = modal.querySelectorAll('.mz-training-tab-content');
        tbs.forEach(btn => {
            btn.addEventListener('click', () => {
                tbs.forEach(x => x.classList.remove('active'));
                btn.classList.add('active');
                const t = btn.getAttribute('data-tab');
                cs.forEach(cc => {
                    if (cc.getAttribute('data-content') === t) cc.classList.add('active');
                    else cc.classList.remove('active');
                });
            });
        });
    }

    function fetchTrainingData(pid, node, getSeasonFn, csi) {
        const cont = getPlayerContainerNode(node);
        if (!cont) {
            return;
        }
        const curSeason = csi.season;
        const nmEl = cont.querySelector('.player_name');
        const nm = nmEl ? nmEl.textContent.trim() : 'Unknown Player';
        const { modal, spinnerEl, spinnerInstance } = createModal('', true);
        fetch(`https://www.managerzone.com/ajax.php?p=trainingGraph&sub=getJsonTrainingHistory&sport=soccer&player_id=${pid}`)
            .then(r => r.text())
            .then(t => {
                if (spinnerInstance) spinnerInstance.stop();
                spinnerEl.style.display = 'none';
                const series = parseSeriesData(t);
                if (!series) throw new Error('No series data found.');
                return series;
            })
            .then(series => {
                const data = processTrainingHistory(series, getSeasonFn);
                const evoHTML = generateEvolHTML(data.bySeason, data.total, data.skillTotals, curSeason, cont);
                const stHTML = buildStatesLayout(data.bySeason, data.skillTotals, cont, curSeason, data.earliestSeason);
                const finalHTML = generateTabsHTML(nm, evoHTML, stHTML);
                modal.querySelector('.mz-training-modal-content').innerHTML = finalHTML;
                attachTabEvents(modal);
            })
            .catch(() => {
                if (spinnerInstance) spinnerInstance.stop();
                spinnerEl.style.display = 'none';
                modal.querySelector('.mz-training-modal-content').innerText = 'Failed to process training data. (Are you a club member?)';
            });
    }

    function insertButtons(getSeasonFn, csi) {
        const containers = document.querySelectorAll('.playerContainer');
        containers.forEach(cc => {
            const age = parsePlayerAge(cc);
            if (!age || age > 28) {
                return;
            }
            const f = cc.querySelectorAll('.floatRight[id^="player_id_"]');
            f.forEach(ff => {
                const pidSpan = ff.querySelector('.player_id_span');
                if (!pidSpan) return;
                const pid = pidSpan.textContent.trim();
                const existingBtn = ff.querySelector('.my-training-btn');
                if (existingBtn) return;
                const b = document.createElement('button');
                b.className = 'my-training-btn button_blue';
                b.innerHTML = '<i class="fa-solid fa-chart-pyramid"></i>';
                b.onclick = () => {
                    fetchTrainingData(pid, ff, getSeasonFn, csi);
                };
                ff.appendChild(b);
            });
        });
    }

    function initTeamId() {
        const stored = GM_getValue('TEAM_ID');
        if (stored) {
            myTeamId = stored;
            return;
        }
        const usernameEl = document.querySelector('#header-username');
        if (!usernameEl) {
            return;
        }
        const username = usernameEl.textContent.trim();
        if (!username) {
            return;
        }
        fetch(`https://www.managerzone.com/xml/manager_data.php?sport_id=1&username=${encodeURIComponent(username)}`)
            .then(r => r.text())
            .then(txt => {
                const parser = new DOMParser();
                const doc = parser.parseFromString(txt, 'text/xml');
                const teamNodes = doc.querySelectorAll('Team[sport="soccer"]');
                if (!teamNodes || !teamNodes.length) {
                    return;
                }
                const tid = teamNodes[0].getAttribute('teamId');
                if (tid) {
                    GM_setValue('TEAM_ID', tid);
                    myTeamId = tid;
                }
            })
            .catch(() => {});
    }

    function canRunUserscript() {
        const url = new URL(window.location.href);
        const p = url.searchParams.get('p');

        if (p === 'players' && !url.searchParams.get('pid')) return true;
        if (p === 'transfer') return true;

        const tid = url.searchParams.get('tid');

        if (p === 'players' && url.searchParams.get('pid')) {
            return tid && tid === myTeamId;
        }

        return false;
    }

    function init() {
        initTeamId();

        if (!canRunUserscript()) return;

        const csi = getCurrentSeasonInfo();
        if (!csi) return;

        const getSeasonFn = getSeasonCalculator(csi);
        insertButtons(getSeasonFn, csi);

        const obs = new MutationObserver(() => { insertButtons(getSeasonFn, csi); });
        obs.observe(document.body, { childList: true, subtree: true });
    }

    init();
})();