// ==UserScript== // @name MZ - Training History // @namespace douglaskampl // @version 4.1 // @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/trainingHistory_v4.css // @run-at document-idle // @license MIT // @downloadURL none // ==/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']; const MAXED_KEYWORDS = [ 'maximizado', 'maxed', 'max', 'max.', 'maks.', 'maksymalny', 'maksimum', 'maximum', 'maxad', 'máximo', 'maxeado', 'maximal', 'maksimal', 'maksimalt', 'maximerad', 'maksimoi', 'maksimums', 'maksimalus', 'maximális', 'massimo', 'maxat', 'максимум', 'μέγιστο', '最大', '최대', 'अधिकतम', 'maksimalan', 'maxim', 'максимален', 'maksimaalne', 'máx.', 'maxed out', 'fullt utvecklad', 'täysin kehittynyt', 'полностью развит', 'completamente desarrollado', 'voll entwickelt', 'zablokowany', 'tam gelişmiş', 'gelişim tamamlandı', 'maximised', 'maximized', 'Tıkalı', '已达上限', '已满', '만렙', 'limite tercapai', 'đã đạt tối đa', 'הגיע למקסימום', 'وصل للحد الأقصى', 'สุดขีด', 'rozvoj dokončen', 'maxat', 'maxad', 'maxat.', 'maxad.', 'maksimert', 'maximert', 'maksimoitu', 'täynnä', 'pilnībā attīstīts', 'maksimaliai išvystytas', 'maxim dezvoltat', 'tope', 'limite', 'τελείωσε', 'ביותר', 'gemaxt', ]; let myTeamId = null; function isClubMember() { const headerUsernameStyle = document.querySelector('#header-username')?.getAttribute('style'); return headerUsernameStyle && headerUsernameStyle.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); return { currentDate, season, day }; } 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 => { if (!(d instanceof Date)) return 0; 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 = (\[.*?\]);/); return m ? JSON.parse(m[1]) : null; } function extractChipsInfo(series) { const chips = []; if (!series) return chips; series.forEach(s => { s.data.forEach(pt => { if (pt.marker?.symbol.includes('training_camp_chip.png') && pt.name) { const chipName = pt.name.replace(/<\/b>\t\t\t\n/g, ' ').trim(); const date = new Date(pt.x); chips.push({ name: chipName, date: date, dateString: date.toLocaleDateString() }); } }); }); return chips; } function extractMaxingInfo(series, getSeasonFn) { const maxedSkills = {}; if (!series) return maxedSkills; const maxedKeywordsLower = MAXED_KEYWORDS.map(k => k.toLowerCase()); series.forEach(s => { s.data.forEach(pt => { if (pt.name && typeof pt.name === 'string') { const nameLower = pt.name.toLowerCase(); if (maxedKeywordsLower.some(keyword => nameLower.includes(keyword))) { const skillId = pt.y?.toString(); const skillName = SKILL_MAP[skillId]; if (skillName) { const date = new Date(pt.x); const season = getSeasonFn(date); if (!maxedSkills[skillName] || season < maxedSkills[skillName]) { maxedSkills[skillName] = season; } } } } }); }); return maxedSkills; } function processTrainingHistory(series, getSeasonFn) { const bySeason = {}; const skillTotals = {}; const chips = extractChipsInfo(series); const maxedSkills = extractMaxingInfo(series, getSeasonFn); const chipsBySeason = {}; let total = 0; let earliest = 9999; chips.forEach(chip => { const season = getSeasonFn(chip.date); if (!chipsBySeason[season]) chipsBySeason[season] = []; chipsBySeason[season].push(chip); }); if (series) { 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.toLocaleDateString(), skillName: sk }); if (!skillTotals[sk]) skillTotals[sk] = 0; skillTotals[sk]++; total++; if (sea < earliest) earliest = sea; } }); }); } return { bySeason, skillTotals, total, earliestSeason: earliest, chips, chipsBySeason, maxedSkills }; } 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) { return Object.values(map).reduce((a, b) => a + b, 0); } function fillSeasonGains(bySeason, earliestSeason, currentSeason, skillTotals) { const out = {}; for (let s = earliestSeason; s <= currentSeason; s++) { out[s] = {}; ORDERED_SKILLS.forEach(sk => { out[s][sk] = 0; }); if (bySeason[s]) { bySeason[s].forEach(ev => { if (skillTotals[ev.skillName]) { 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; return currentAge - seasonDiff; } function buildSeasonCheckpointData(earliestSeason, currentSeason, finalMap, seasonGains, container) { const out = []; const currentMap = {}; const currentAge = parsePlayerAge(container); ORDERED_SKILLS.forEach(sk => { currentMap[sk] = finalMap[sk] || 0; }); out.push({ season: currentSeason, label: 'Current', distribution: { ...finalMap } }); for (let s = currentSeason; s >= earliestSeason; s--) { if (seasonGains[s]) { Object.keys(seasonGains[s]).forEach(k => { if (currentMap.hasOwnProperty(k)) { currentMap[k] -= seasonGains[s][k]; if (currentMap[k] < 0) currentMap[k] = 0; } }); } const age = calculateHistoricalAge({ currentAge, currentSeason, targetSeason: s }); const label = age !== null ? `${s} (${age})` : s.toString(); const snapshot = { ...currentMap }; out.unshift({ season: s, label, distribution: snapshot }); } return out; } function makeSkillRows(params) { const { map, prevMap, arrivalMap, maxedSkills, currentSeasonForState, isCurrentState, scoutData } = params; let comparisonHtml = ''; let arrivalGainHtml = ''; let totalIncreaseFromPrev = 0; let totalGainSinceArrival = 0; ORDERED_SKILLS.forEach(k => { let v = map[k] || 0; if (v < 0) v = 0; if (v > 10) v = 10; let changeHTML = ''; let gainSinceArrivalHTML = ''; let maxedClass = ''; let potentialClass = ''; let potentialIcon = ''; if (maxedSkills && maxedSkills[k] && currentSeasonForState >= maxedSkills[k]) { maxedClass = 'mz-skill-maxed'; } if (scoutData) { if (k === scoutData.firstHpSkill || k === scoutData.secondHpSkill) { potentialClass = `mz-skill-potential-hp${scoutData.hp}`; potentialIcon = ``; } else if (k === scoutData.firstLpSkill || k === scoutData.secondLpSkill) { potentialClass = `mz-skill-potential-lp${scoutData.lp}`; potentialIcon = ``; } } if (prevMap) { const prevVal = prevMap[k] || 0; const change = v - prevVal; if (change > 0) { changeHTML = `(+${change})`; totalIncreaseFromPrev += change; } } if (arrivalMap) { const arrivalVal = arrivalMap[k] || 0; const gainSinceArrival = v - arrivalVal; if (gainSinceArrival > 0) { gainSinceArrivalHTML = `(+${gainSinceArrival})`; totalGainSinceArrival += gainSinceArrival; } } const baseSkillRowHtml = `
Failed to process player data. (${error.message || 'Unknown error'})
(Are you a club member? Is the player still active? Check console for details.)
`; } } } function initPaginationState(modal) { const statesContent = modal.querySelector('.mz-training-tab-content[data-content="states"]'); if (!statesContent) return; const paginatedView = statesContent.querySelector('.mz-paginated-view'); const allView = statesContent.querySelector('.mz-all-view'); const paginationToggle = modal.querySelector('.mz-pagination-toggle'); const paginationControls = modal.querySelector('.mz-pagination-controls'); if (!paginatedView || !allView || !paginationToggle || !paginationControls) { if (paginationControls) paginationControls.style.display = 'none'; return; } const stateColumns = paginatedView.querySelectorAll('.mz-state-col'); if (!stateColumns.length) { if (paginationControls) paginationControls.style.display = 'none'; return; } else { paginationControls.style.display = ''; } paginatedView.style.display = ''; allView.style.display = 'none'; paginationToggle.textContent = 'Show All'; let initialIndex = 0; stateColumns.forEach((col, index) => { col.style.display = index === initialIndex ? '' : '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 = `${initialIndex + 1} / ${stateColumns.length}`; } if (prevBtn) prevBtn.disabled = initialIndex === 0; if (nextBtn) nextBtn.disabled = initialIndex >= stateColumns.length - 1; detachPaginationEvents(modal); attachPaginationEvents(modal, initialIndex); } function detachPaginationEvents(modal) { const prevBtn = modal.querySelector('.mz-prev-btn'); const nextBtn = modal.querySelector('.mz-next-btn'); const toggleBtn = modal.querySelector('.mz-pagination-toggle'); if (prevBtn) { const newPrevBtn = prevBtn.cloneNode(true); prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); } if (nextBtn) { const newNextBtn = nextBtn.cloneNode(true); nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); } if (toggleBtn) { const newToggleBtn = toggleBtn.cloneNode(true); toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn); } } function attachPaginationEvents(modal, initialIndex) { const statesContent = modal.querySelector('.mz-training-tab-content[data-content="states"]'); if (!statesContent) return; 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 = statesContent.querySelector('.mz-paginated-view'); const allView = statesContent.querySelector('.mz-all-view'); if (!paginatedView || !allView || !prevBtn || !nextBtn || !paginationIndicator || !toggleBtn) return; const stateColumns = Array.from(paginatedView.querySelectorAll('.mz-state-col')); const totalPages = stateColumns.length; let currentIndex = initialIndex; function updatePaginationUI() { if (totalPages === 0) { prevBtn.disabled = true; nextBtn.disabled = true; paginationIndicator.textContent = '0 / 0'; toggleBtn.style.display = 'none'; return; } toggleBtn.style.display = ''; 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 (paginatedView.style.display === 'none') return; if (currentIndex > 0) { currentIndex--; updatePaginationUI(); } }); nextBtn.addEventListener('click', () => { if (paginatedView.style.display === 'none') return; if (currentIndex < totalPages - 1) { currentIndex++; updatePaginationUI(); } }); toggleBtn.addEventListener('click', () => { const isPaginated = paginatedView.style.display !== 'none'; paginatedView.style.display = isPaginated ? 'none' : ''; allView.style.display = isPaginated ? '' : 'none'; toggleBtn.textContent = isPaginated ? 'Show Paginated' : 'Show All'; prevBtn.disabled = !isPaginated || currentIndex === 0; nextBtn.disabled = !isPaginated || currentIndex === totalPages - 1; if (!isPaginated) { updatePaginationUI(); } }); updatePaginationUI(); } 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); 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 (!pid) return; const existingBtn = ff.querySelector('.my-training-btn'); if (existingBtn) return; const b = document.createElement('button'); b.className = 'my-training-btn'; b.innerHTML = ''; b.title = 'View Training History & Details'; b.onclick = (e) => { e.preventDefault(); fetchCombinedPlayerData(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(response => { if (!response.ok) throw new Error(`HTTP error ${response.status}`); return response.text(); }) .then(text => { const parser = new DOMParser(); const doc = parser.parseFromString(text, '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((error) => {console.error("Failed to init team ID:", error)}); } function canRunUserscript() { return isClubMember(); } function go() { initTeamId(); if (!canRunUserscript()) { console.log("MZ Training History: Not a club member, script disabled."); return; } const csi = getCurrentSeasonInfo(); if (!csi) { console.error("MZ Training History: Could not determine current season info."); return; } const getSeasonFn = getSeasonCalculator(csi); insertButtons(getSeasonFn, csi); const obs = new MutationObserver((mutations) => { let playerContainerChanged = false; for (const mutation of mutations) { if (mutation.type === 'childList') { if (mutation.target.matches && (mutation.target.matches('.playerContainer') || mutation.target.querySelector('.playerContainer'))) { playerContainerChanged = true; break; } for (const node of mutation.addedNodes) { if (node.nodeType === 1 && (node.matches('.playerContainer') || node.querySelector('.playerContainer'))) { playerContainerChanged = true; break; } } if (playerContainerChanged) break; } } if (playerContainerChanged) { insertButtons(getSeasonFn, csi); } }); obs.observe(document.body, { childList: true, subtree: true }); } go(); })();