MZ - Training History

Displays skill gains across previous seasons

当前为 2024-12-30 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MZ - Training History
// @namespace    douglaskampl
// @version      3.0
// @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*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=managerzone.com
// @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'];

    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 table = container.querySelector('table td:first-child + td table');
        if (table) {
            const ageRow = Array.from(table.querySelectorAll('tr')).find(row => {
                const cells = row.querySelectorAll('td');
                return cells.length === 2 && cells[1].querySelector('strong');
            });

            if (ageRow) {
                const ageText = ageRow.querySelector('strong').textContent.trim();
                const age = parseInt(ageText, 10);
                return !isNaN(age) && age >= 15 && age <= 45 ? age : null;
            }
        }

        const ageCell = container.querySelector('td, .pp-1-1 td');
        if (ageCell) {
            const ageMatch = ageCell.textContent.match(/Age:\s*(\d+)/);
            if (ageMatch) {
                const age = parseInt(ageMatch[1], 10);
                return !isNaN(age) && age >= 15 && age <= 45 ? age : null;
            }
        }

        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 = `Season ${season} (Age ${age.replace('(', '').replace(')', '')})`;
            }
            html += `<div class="mz-state-col">
                        <h4>${headerText}</h4>
                        <div class="mz-state-info">Total Skill Balls: ${sum}</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');
                });
            });
        });
    }

    const insertedForPids = new Set();

    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((err) => {
                if (spinnerInstance) spinnerInstance.stop();
                spinnerEl.style.display = 'none';
                modal.querySelector('.mz-training-modal-content').innerText = 'Failed to process training data.';
            });
    }

    function insertButtons(getSeasonFn, csi) {
        const containers = document.querySelectorAll('.playerContainer');
        containers.forEach(cc => {
            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();
                if (insertedForPids.has(pid)) return;
                insertedForPids.add(pid);
                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 init() {
        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();
})();