MZ - Training History

Displays skill gains across MZ seasons

当前为 2025-03-23 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MZ - Training History
// @namespace    douglaskampl
// @version      3.6
// @description  Displays skill gains across MZ seasons
// @author       Douglas
// @match        https://www.managerzone.com/?p=players
// @match        https://www.managerzone.com/?p=players&pid*
// @match        https://www.managerzone.com/?p=players&tid=*
// @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/trainingHistoryNew.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 isClubMember() {
        const headerUsernameStyleAttr = document.querySelector('#header-username')?.getAttribute('style');
        return headerUsernameStyleAttr && headerUsernameStyleAttr.includes('background-image');
    }

    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];
        const m = dm[2];
        const 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: '#5555aa', 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 makeSkillRows(map, prevMap) {
        let out = '';
        let totalIncrease = 0;

        ORDERED_SKILLS.forEach(k => {
            let v = map[k] || 0;
            if (v < 0) v = 0;
            if (v > 10) v = 10;

            let changeHTML = '';
            if (prevMap) {
                const prevVal = prevMap[k] || 0;
                const change = v - prevVal;
                if (change > 0) {
                    changeHTML = `<span class="mz-skill-increase">(+${change})</span>`;
                    totalIncrease += change;
                }
            }

            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 class="mz-skill-change">${changeHTML}</div>
                    </div>`;
        });

        if (totalIncrease > 0 && prevMap) {
            out += `<div class="mz-skill-total-increase">
                      <span>(+${totalIncrease} total)</span>
                    </div>`;
        }

        return { html: out, totalIncrease: totalIncrease };
    }

    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 paginatedHtml = '<div class="mz-state-wrapper mz-paginated-view">';
        let allViewHtml = '<div class="mz-state-wrapper mz-all-view" style="display:none;">';

        const currentAge = parsePlayerAge(container);

        arr.forEach((o, index) => {
            const sum = getTotalBallsFromSkillMap(o.distribution);
            let headerText;

            if (o.label === 'Current') {
                headerText = `Current State - Season ${currentSeason} (Age: ${currentAge})`;
            } else {
                const [season, age] = o.label.split(' ');
                if (index === 0) {
                    headerText = `Arrival at the Club - Season ${season} (Age: ${age.replace('(', '').replace(')', '')})`;
                } else {
                    headerText = `Beginning of Season ${season} (Age: ${age.replace('(', '').replace(')', '')})`;
                }
            }

            const prevDistribution = index > 0 ? arr[index - 1].distribution : null;
            const skillRowsResult = makeSkillRows(o.distribution, prevDistribution);

            paginatedHtml += `<div class="mz-state-col" data-page="${index}">
                    <h4>${headerText}</h4>
                    <div class="mz-state-info">Total Skill Balls: <strong>${sum}</strong></div>
                    <div class="mz-state-skills">${skillRowsResult.html}</div>
                 </div>`;

            allViewHtml += `<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">${skillRowsResult.html}</div>
                 </div>`;
        });

        paginatedHtml += '</div>';
        allViewHtml += '</div>';

        return paginatedHtml + allViewHtml;
    }

    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">${name}</h2>
        <div class="mz-training-tabs">
            <div class="mz-training-tab-row">
                <div class="mz-training-tab-buttons">
                    <button class="mz-training-tab-btn active" data-tab="states">Player Development</button>
                    <button class="mz-training-tab-btn" data-tab="evolution">Gains</button>
                </div>
                <div class="mz-pagination-controls">
                    <button class="mz-pagination-btn mz-prev-btn" disabled>&larr;</button>
                    <span class="mz-pagination-indicator">1 / 1</span>
                    <button class="mz-pagination-btn mz-next-btn" disabled>&rarr;</button>
                    <button class="mz-pagination-toggle">Show All</button>
                </div>
            </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');
        const paginationControls = modal.querySelector('.mz-pagination-controls');

        if (modal.querySelector('.mz-training-tab-btn.active').getAttribute('data-tab') !== 'states') {
            paginationControls.style.display = 'none';
        }

        tbs.forEach(btn => {
            btn.addEventListener('click', () => {
                tbs.forEach(x => x.classList.remove('active'));
                btn.classList.add('active');
                const t = btn.getAttribute('data-tab');

                if (t === 'states') {
                    paginationControls.style.display = '';
                } else {
                    paginationControls.style.display = 'none';
                }

                cs.forEach(cc => {
                    if (cc.getAttribute('data-content') === t) cc.classList.add('active');
                    else cc.classList.remove('active');
                });
            });
        });

        const prevBtn = modal.querySelector('.mz-prev-btn');
        const nextBtn = modal.querySelector('.mz-next-btn');
        const paginationIndicator = modal.querySelector('.mz-pagination-indicator');
        const toggleBtn = modal.querySelector('.mz-pagination-toggle');
        const paginatedView = modal.querySelector('.mz-paginated-view');
        const allView = modal.querySelector('.mz-all-view');
        const stateColumns = Array.from(modal.querySelectorAll('.mz-paginated-view .mz-state-col'));

        if (!stateColumns.length) return;

        let currentIndex = 0;
        const totalPages = stateColumns.length;

        paginationIndicator.textContent = `1 / ${totalPages}`;

        prevBtn.disabled = true;
        nextBtn.disabled = totalPages <= 1;

        function updatePaginationUI() {
            prevBtn.disabled = currentIndex === 0;
            nextBtn.disabled = currentIndex === totalPages - 1;
            paginationIndicator.textContent = `${currentIndex + 1} / ${totalPages}`;

            stateColumns.forEach((col, index) => {
                col.style.display = index === currentIndex ? '' : 'none';
            });
        }

        prevBtn.addEventListener('click', () => {
            if (currentIndex > 0) {
                currentIndex--;
                updatePaginationUI();
            }
        });

        nextBtn.addEventListener('click', () => {
            if (currentIndex < totalPages - 1) {
                currentIndex++;
                updatePaginationUI();
            }
        });

        toggleBtn.addEventListener('click', () => {
            const isPaginated = paginatedView.style.display !== 'none';

            if (isPaginated) {
                paginatedView.style.display = 'none';
                allView.style.display = '';
                toggleBtn.textContent = 'Show Paginated';
            } else {
                paginatedView.style.display = '';
                allView.style.display = 'none';
                toggleBtn.textContent = 'Show All';
                updatePaginationUI();
            }
        });
    }

    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;

                setTimeout(() => {
                    attachTabEvents(modal);
                    initPaginationState(modal);
                }, 50);
            })
            .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 initPaginationState(modal) {
        const paginatedView = modal.querySelector('.mz-paginated-view');
        const allView = modal.querySelector('.mz-all-view');
        const paginationToggle = modal.querySelector('.mz-pagination-toggle');
        const stateColumns = modal.querySelectorAll('.mz-paginated-view .mz-state-col');

        if (!stateColumns.length) return;

        if (paginatedView) paginatedView.style.display = '';
        if (allView) allView.style.display = 'none';
        if (paginationToggle) paginationToggle.textContent = 'Show All';

        stateColumns.forEach((col, index) => {
            col.style.display = index === 0 ? '' : 'none';
        });

        const paginationIndicator = modal.querySelector('.mz-pagination-indicator');
        const prevBtn = modal.querySelector('.mz-prev-btn');
        const nextBtn = modal.querySelector('.mz-next-btn');

        if (paginationIndicator) {
            paginationIndicator.textContent = `1 / ${stateColumns.length}`;
        }

        if (prevBtn) prevBtn.disabled = true;
        if (nextBtn) nextBtn.disabled = stateColumns.length <= 1;
    }

    function hasVisibleSkills(container) {
        return container.querySelector('table.player_skills') !== null;
    }

    function insertButtons(getSeasonFn, csi) {
        const containers = document.querySelectorAll('.playerContainer');
        containers.forEach(cc => {
            if (!hasVisibleSkills(cc)) {
                return;
            }

            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() {
        if (!isClubMember()) {
            return false;
        }

        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;
        if (p === 'players' && url.searchParams.get('tid')) return true;

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

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

        return false;
    }

    function go() {
        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 });
    }

    go();
})();