Player page overhaul — redesigned card, live transfer tracker, R5/REC/TI charts, skill graphs, compare tool, squad scout & more
// ==UserScript==
// @name TM Player Enhanced
// @namespace https://trophymanager.com
// @version 1.5.0
// @description Player page overhaul — redesigned card, live transfer tracker, R5/REC/TI charts, skill graphs, compare tool, squad scout & more
// @match https://trophymanager.com/players/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const $ = window.jQuery;
if (!$) return;
const urlMatch = location.pathname.match(/\/players\/(\d+)/);
const IS_SQUAD_PAGE = !urlMatch && /\/players\/?$/.test(location.pathname);
if (!urlMatch && !IS_SQUAD_PAGE) return;
const PLAYER_ID = urlMatch ? urlMatch[1] : null;
/* ═══════════════════════════════════════════════════════════
IndexedDB Storage — replaces localStorage for player data
(localStorage has 5 MB limit; IndexedDB has hundreds of MB)
Provides sync reads via in-memory cache + async writes.
═══════════════════════════════════════════════════════════ */
const PlayerDB = (() => {
const DB_NAME = 'TMPlayerData';
const STORE_NAME = 'players';
const DB_VERSION = 1;
let db = null;
const cache = {};
const open = () => new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const d = e.target.result;
if (!d.objectStoreNames.contains(STORE_NAME))
d.createObjectStore(STORE_NAME);
};
req.onsuccess = (e) => { db = e.target.result; resolve(db); };
req.onerror = (e) => reject(e.target.error);
});
/** Sync read from cache (call after init) */
const get = (pid) => cache[pid] || null;
/** Async write: updates cache immediately + persists to IndexedDB */
const set = (pid, value) => {
cache[pid] = value;
if (!db) return Promise.resolve();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(value, pid);
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
}).catch(e => console.warn('[DB] write failed:', e));
};
/** Async delete: removes from cache + IndexedDB */
const remove = (pid) => {
delete cache[pid];
if (!db) return Promise.resolve();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(pid);
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
}).catch(e => console.warn('[DB] delete failed:', e));
};
/** Get all pids (from cache, sync) */
const allPids = () => Object.keys(cache);
/** Init: open DB → migrate localStorage → preload cache */
const init = async () => {
await open();
/* Migrate existing localStorage _data keys to IndexedDB */
const toMigrate = [];
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (!k || !k.endsWith('_data')) continue;
const pid = k.replace('_data', '');
if (!/^\d+$/.test(pid)) continue;
try {
const data = JSON.parse(localStorage.getItem(k));
if (data) toMigrate.push({ pid, data });
keysToRemove.push(k);
} catch (e) { keysToRemove.push(k); }
}
if (toMigrate.length > 0) {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const item of toMigrate) store.put(item.data, item.pid);
await new Promise((res, rej) => { tx.oncomplete = res; tx.onerror = rej; });
for (const k of keysToRemove) localStorage.removeItem(k);
console.log(`%c[DB] Migrated ${toMigrate.length} player(s) from localStorage → IndexedDB`,
'font-weight:bold;color:#6cc040');
}
/* Preload ALL records into sync cache */
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const reqAll = store.getAll();
const reqKeys = store.getAllKeys();
await new Promise((res, rej) => { tx.oncomplete = res; tx.onerror = rej; });
for (let i = 0; i < reqKeys.result.length; i++)
cache[reqKeys.result[i]] = reqAll.result[i];
console.log(`[DB] Loaded ${Object.keys(cache).length} player(s) from IndexedDB`);
/* Request persistent storage so Chrome won't auto-evict */
if (navigator.storage && navigator.storage.persist) {
navigator.storage.persist().then(granted => {
console.log(`[DB] Persistent storage: ${granted ? '✓ granted' : '✗ denied'}`);
});
}
};
return { init, get, set, remove, allPids };
})();
/* ── Migrate R6 old format + scan unmigrated ── */
const scanAndMigrateR6 = () => {
/* Phase 1: Convert R6 4-key format ({pid}_SI etc.) → PlayerDB */
const seenPids = new Set();
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (!k) continue;
const m = k.match(/^(\d+)_SI$/);
if (!m) continue;
const pid = m[1];
if (seenPids.has(pid)) continue;
seenPids.add(pid);
const existing = PlayerDB.get(pid);
if (existing && (existing._v === 1 || existing._v === 2 || existing._v === 3)) continue;
try {
const siObj = JSON.parse(localStorage.getItem(`${pid}_SI`) || '{}');
const rerecObj = JSON.parse(localStorage.getItem(`${pid}_REREC`) || '{}');
const r5Obj = JSON.parse(localStorage.getItem(`${pid}_R5`) || '{}');
const skillsObj = JSON.parse(localStorage.getItem(`${pid}_skills`) || '{}');
const ages = Object.keys(siObj);
if (ages.length === 0) continue;
const store = { _v: 1, lastSeen: Date.now(), records: {} };
for (const ageKey of ages) {
store.records[ageKey] = {
SI: parseInt(siObj[ageKey]) || 0,
REREC: rerecObj[ageKey] ?? null,
R5: r5Obj[ageKey] ?? null,
skills: skillsObj[ageKey] || []
};
}
PlayerDB.set(pid, store);
localStorage.removeItem(`${pid}_SI`);
localStorage.removeItem(`${pid}_REREC`);
localStorage.removeItem(`${pid}_R5`);
localStorage.removeItem(`${pid}_skills`);
console.log(`%c[Migration] Converted R6 player ${pid} (${ages.length} records) → PlayerDB v1`,
'color:#6cc040');
} catch (e) {
console.warn(`[Migration] Failed R6→PlayerDB for ${pid}:`, e.message);
}
}
/* Phase 2: Log players still needing v3 migration */
const unmigrated = [];
for (const pid of PlayerDB.allPids()) {
const s = PlayerDB.get(pid);
if (s && s._v < 3 && s.records && Object.keys(s.records).length > 3) {
const firstKey = Object.keys(s.records)[0];
const sk = s.records[firstKey]?.skills;
const type = Array.isArray(sk) && sk.length === 11 ? 'GK' : 'OUT';
unmigrated.push({
Player: pid, Type: type, Records: Object.keys(s.records).length, Version: s._v,
Link: `https://trophymanager.com/players/${pid}/`
});
}
}
if (unmigrated.length > 0) {
console.log(`%c[Migration] ${unmigrated.length} player(s) need v3 sync (visit each):`,
'font-weight:bold;color:#fbbf24');
unmigrated.sort((a, b) => b.Records - a.Records);
console.table(unmigrated);
} else {
console.log('[Migration] All players migrated ✓');
}
};
/* ═══════════════════════════════════════════════════════════
SQUAD PAGE — Parse players table from /players/ list page
Extracts skill values, training progress (part_up, one_up),
TI and TI change for each player in the squad table.
═══════════════════════════════════════════════════════════ */
const SKILL_NAMES_OUT_SHORT = ['Str','Sta','Pac','Mar','Tac','Wor','Pos','Pas','Cro','Tec','Hea','Fin','Lon','Set'];
const SKILL_NAMES_GK_SHORT = ['Str','Sta','Pac','Han','One','Ref','Aer','Jum','Com','Kic','Thr'];
const SKILL_NAMES_OUT_FULL = ['Strength','Stamina','Pace','Marking','Tackling','Workrate','Positioning','Passing','Crossing','Technique','Heading','Finishing','Longshots','Set Pieces'];
const SKILL_NAMES_GK_FULL = ['Strength','Stamina','Pace','Handling','One on ones','Reflexes','Aerial Ability','Jumping','Communication','Kicking','Throwing'];
/* ═══════════════════════════════════════════════════════════
SQUAD PAGE — Ensure all players (main + reserves) are visible
Hash format: #/a/{true|false}/b/{true|false}/
═══════════════════════════════════════════════════════════ */
const parseSquadHash = () => {
const h = location.hash || '';
const aMatch = h.match(/\/a\/(true|false)/i);
const bMatch = h.match(/\/b\/(true|false)/i);
return {
a: aMatch ? aMatch[1] === 'true' : true, /* default: main squad visible */
b: bMatch ? bMatch[1] === 'true' : false /* default: reserves hidden */
};
};
const ensureAllPlayersVisible = () => new Promise((resolve) => {
const sqDiv = document.getElementById('sq');
if (!sqDiv) { resolve(); return; }
const vis = parseSquadHash();
const needA = !vis.a;
const needB = !vis.b;
if (!needA && !needB) { resolve(); return; } /* Both already visible */
/* Set hash to show both squads */
const newHash = '#/a/true/b/true/';
const onHashChange = () => {
window.removeEventListener('hashchange', onHashChange);
/* Wait for DOM to update after hash-triggered toggle */
setTimeout(resolve, 500);
};
window.addEventListener('hashchange', onHashChange);
location.hash = newHash;
/* If hash was already the same (no event fires), or toggles need clicking */
setTimeout(() => {
window.removeEventListener('hashchange', onHashChange);
/* Fallback: click toggles directly if hash didn't work */
if (needA) { const aBtn = document.getElementById('toggle_a_team'); if (aBtn) aBtn.click(); }
if (needB) { const bBtn = document.getElementById('toggle_b_team'); if (bBtn) bBtn.click(); }
setTimeout(resolve, 500);
}, 1500);
});
/* ═══════════════════════════════════════════════════════════
SQUAD PAGE — Loader/Progress overlay
═══════════════════════════════════════════════════════════ */
const createSquadLoader = () => {
const overlay = document.createElement('div');
overlay.id = 'tmrc-squad-loader';
overlay.innerHTML = `
<div style="position:fixed;top:0;left:0;right:0;z-index:99999;
background:rgba(20,30,15,0.95);border-bottom:2px solid #6cc040;
padding:10px 20px;font-family:Arial,sans-serif;color:#e8f5d8;">
<div style="display:flex;align-items:center;gap:12px;max-width:900px;margin:0 auto;">
<div style="font-size:14px;font-weight:700;color:#6cc040;">⚽ Squad Sync</div>
<div style="flex:1;background:rgba(108,192,64,0.15);border-radius:8px;height:18px;
overflow:hidden;border:1px solid rgba(108,192,64,0.3);">
<div id="tmrc-loader-bar" style="height:100%;width:0%;background:linear-gradient(90deg,#3d6828,#6cc040);
border-radius:8px;transition:width 0.3s;"></div>
</div>
<div id="tmrc-loader-text" style="font-size:12px;min-width:180px;text-align:right;">Initializing...</div>
</div>
</div>`;
document.body.appendChild(overlay);
return {
update: (current, total, name) => {
const pct = Math.round((current / total) * 100);
const bar = document.getElementById('tmrc-loader-bar');
const txt = document.getElementById('tmrc-loader-text');
if (bar) bar.style.width = pct + '%';
if (txt) txt.textContent = `${current}/${total} — ${name}`;
},
done: (count) => {
const bar = document.getElementById('tmrc-loader-bar');
const txt = document.getElementById('tmrc-loader-text');
if (bar) bar.style.width = '100%';
if (txt) { txt.style.color = '#6cc040'; txt.textContent = `✓ ${count} players processed`; }
setTimeout(() => {
const el = document.getElementById('tmrc-squad-loader');
if (el) { el.style.transition = 'opacity 0.5s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 600); }
}, 2500);
},
error: (msg) => {
const txt = document.getElementById('tmrc-loader-text');
if (txt) { txt.style.color = '#f87171'; txt.textContent = msg; }
setTimeout(() => { const el = document.getElementById('tmrc-squad-loader'); if (el) el.remove(); }, 4000);
}
};
};
const parseSquadPage = () => {
const sqDiv = document.getElementById('sq');
if (!sqDiv) { console.warn('[Squad] No #sq div found'); return; }
const rows = sqDiv.querySelectorAll('table tbody tr:not(.header):not(.splitter)');
if (!rows.length) { console.warn('[Squad] No player rows found'); return; }
/* Detect if we've hit the GK section */
let isGKSection = false;
const allRows = sqDiv.querySelectorAll('table tbody tr');
const splitterIndices = new Set();
allRows.forEach((r, i) => { if (r.classList.contains('splitter')) splitterIndices.add(i); });
const players = [];
allRows.forEach((row, rowIdx) => {
if (row.classList.contains('splitter')) {
isGKSection = true; // After "Goalkeepers" splitter
return;
}
if (row.classList.contains('header')) return;
const cells = row.querySelectorAll('td');
if (cells.length < 10) return; // Skip malformed rows
/* ── Player ID from link ── */
const link = row.querySelector('a[player_link]');
if (!link) return;
const pid = link.getAttribute('player_link');
const name = link.textContent.trim();
/* ── Squad number ── */
const numEl = cells[0]?.querySelector('span.faux_link');
const number = numEl ? parseInt(numEl.textContent) || 0 : 0;
/* ── Age ── */
const ageText = cells[2]?.textContent?.trim() || '0.0';
const [ageYears, ageMonths] = ageText.split('.').map(s => parseInt(s) || 0);
/* ── Position ── */
const posEl = cells[3]?.querySelector('.favposition');
const posText = posEl ? posEl.textContent.trim() : '';
const isGK = isGKSection;
/* ── Skills: parse values + training status ── */
const skillCount = isGK ? 11 : 14;
const skillStartIdx = 4; // Skills start at column index 4
const skills = [];
const improved = []; // Array of { index, type: 'part_up'|'one_up' }
for (let i = 0; i < skillCount; i++) {
const cell = cells[skillStartIdx + i];
if (!cell) { skills.push(0); continue; }
const innerDiv = cell.querySelector('div.skill');
if (!innerDiv) { skills.push(0); continue; }
/* Check training status */
const hasPartUp = innerDiv.classList.contains('part_up');
const hasOneUp = innerDiv.classList.contains('one_up');
/* Parse skill value: could be a number or a star image */
const starImg = innerDiv.querySelector('img');
let skillVal = 0;
if (starImg) {
const src = starImg.getAttribute('src') || '';
if (src.includes('star_silver')) skillVal = 19;
else if (src.includes('star')) skillVal = 20;
} else {
skillVal = parseInt(innerDiv.textContent.trim()) || 0;
}
skills.push(skillVal);
if (hasPartUp) {
improved.push({ index: i, type: 'part_up', skillName: isGK ? SKILL_NAMES_GK_SHORT[i] : SKILL_NAMES_OUT_SHORT[i] });
} else if (hasOneUp) {
improved.push({ index: i, type: 'one_up', skillName: isGK ? SKILL_NAMES_GK_SHORT[i] : SKILL_NAMES_OUT_SHORT[i] });
}
}
/* ── TI and TI change: last 2 cells (before any dashes for GK) ── */
// For outfield: columns 4..17 = 14 skills, then col 18 = TI, col 19 = +/-
// For GK: columns 4..14 = 11 skills, then 3 dash columns (15,16,17), then col 18 = TI, col 19 = +/-
const tiIdx = skillStartIdx + skillCount + (isGK ? 3 : 0); // Skip 3 dash cols for GK
const tiCell = cells[tiIdx];
const tiChangeCell = cells[tiIdx + 1];
const TI = tiCell ? parseInt(tiCell.textContent.trim()) || 0 : 0;
const tiChangeText = tiChangeCell ? tiChangeCell.textContent.trim() : '0';
const TI_change = parseInt(tiChangeText.replace('+', '')) || 0;
/* ── Build player summary ── */
const totalSkill = skills.reduce((s, v) => s + v, 0);
const partUpCount = improved.filter(x => x.type === 'part_up').length;
const oneUpCount = improved.filter(x => x.type === 'one_up').length;
players.push({
pid,
name,
number,
ageYears,
ageMonths,
position: posText,
isGK,
skills,
improved,
partUpCount,
oneUpCount,
totalImproved: partUpCount + oneUpCount,
TI,
TI_change,
totalSkill
});
});
return players;
};
/* ═══════════════════════════════════════════════════════════
SQUAD PAGE — Process: fetch tooltips, distribute decimals,
log results with +/- compared to previous week.
═══════════════════════════════════════════════════════════ */
const processSquadPage = async (players) => {
if (!players || !players.length) return;
const NAMES_OUT = ['Strength','Stamina','Pace','Marking','Tackling','Workrate','Positioning','Passing','Crossing','Technique','Heading','Finishing','Longshots','Set Pieces'];
const NAMES_GK = ['Strength','Stamina','Pace','Handling','One on ones','Reflexes','Aerial Ability','Jumping','Communication','Kicking','Throwing'];
/* TI efficiency by skill level — same as analyzeGrowth */
const eff = (lvl) => {
if (lvl >= 20) return 0;
if (lvl >= 18) return 0.04;
if (lvl >= 15) return 0.05;
if (lvl >= 5) return 0.10;
return 0.15;
};
/* ASI → total skill points */
const totalPts = (asi, isGK) => {
const w = isGK ? 48717927500 : 263533760000;
return Math.pow(2, Math.log(w * asi) / Math.log(Math.pow(2, 7)));
};
/* Extract integer skills from tooltip [{name, value}] */
const extractSkills = (skillsArr, isGK) => {
const names = isGK ? NAMES_GK : NAMES_OUT;
const sv = (name) => {
const sk = skillsArr.find(s => s.name === name);
if (!sk) return 0;
const v = sk.value;
if (typeof v === 'string') {
if (v.includes('star_silver')) return 19;
if (v.includes('star')) return 20;
return parseInt(v) || 0;
}
return parseInt(v) || 0;
};
return names.map(sv);
};
/* Fetch tooltip for a single player */
const fetchTip = (pid) => new Promise((resolve) => {
$.post('/ajax/tooltip.ajax.php', { player_id: pid }, (res) => {
try {
const data = typeof res === 'object' ? res : JSON.parse(res);
resolve(data && data.player ? data.player : null);
} catch (e) { resolve(null); }
}).fail(() => resolve(null));
});
/* Delay helper */
const delay = (ms) => new Promise(r => setTimeout(r, ms));
console.log(`%c[Squad] Fetching tooltips for ${players.length} players...`, 'font-weight:bold;color:#38bdf8');
/* ── Loader UI ── */
const loader = createSquadLoader();
const results = [];
for (let pi = 0; pi < players.length; pi++) {
const p = players[pi];
loader.update(pi + 1, players.length, p.name);
/* Skip players whose current-week record is already locked */
const curAgeKeyCheck = `${p.ageYears}.${p.ageMonths}`;
const existingStore = PlayerDB.get(p.pid);
if (existingStore && existingStore.records && existingStore.records[curAgeKeyCheck]?.locked) {
console.log(`[Squad] ${p.name} — already locked for ${curAgeKeyCheck}, skipping`);
continue;
}
const tip = await fetchTip(p.pid);
await delay(100); // avoid hammering server
if (!tip) {
console.warn(`[Squad] Could not fetch tooltip for ${p.name} (${p.pid})`);
continue;
}
/* ── Extract data from tooltip ── */
const asi = parseInt((tip.asi || tip.skill_index || '').toString().replace(/[^0-9]/g, '')) || 0;
const routine = parseFloat(tip.routine) || 0;
const wage = parseInt((tip.wage || '').toString().replace(/[^0-9]/g, '')) || 0;
const favpos = tip.favposition || '';
const isGK = favpos.split(',')[0].toLowerCase() === 'gk';
const N = isGK ? 11 : 14;
const NAMES = isGK ? NAMES_GK : NAMES_OUT;
const SHORT = isGK ? SKILL_NAMES_GK_SHORT : SKILL_NAMES_OUT_SHORT;
/* Integer skills from tooltip (ground truth) */
const intSkills = tip.skills ? extractSkills(tip.skills, isGK) : p.skills;
/* ── Get previous decimals from IndexedDB ── */
const dbRecord = PlayerDB.get(p.pid);
let prevDecimals = null;
let prevAgeKey = null;
let prevSkillsFull = null;
let curDbSkillsFull = null;
if (dbRecord && dbRecord.records) {
/* Find records sorted chronologically */
const keys = Object.keys(dbRecord.records).sort((a, b) => {
const [ay, am] = a.split('.').map(Number);
const [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
});
/* Current age key from squad page */
const curAgeKey = `${p.ageYears}.${p.ageMonths}`;
/* Capture current week's existing DB record (if player page was visited this week) */
const curDbRec = dbRecord.records[curAgeKey];
if (curDbRec && curDbRec.skills && curDbRec.skills.length === N) {
curDbSkillsFull = curDbRec.skills.map(v => {
const n = typeof v === 'string' ? parseFloat(v) : v;
return n >= 20 ? 20 : n;
});
}
/* Use PREVIOUS week for comparison:
- If latest key == current age → that's THIS week, use second-to-last
- If latest key != current age → latest IS the previous week */
let prevIdx = keys.length - 1;
if (keys.length > 1 && keys[prevIdx] === curAgeKey) {
prevIdx = keys.length - 2;
}
if (prevIdx >= 0) {
prevAgeKey = keys[prevIdx];
const prevRec = dbRecord.records[prevAgeKey];
if (prevRec && prevRec.skills && prevRec.skills.length === N) {
prevSkillsFull = prevRec.skills.map(v => {
const n = typeof v === 'string' ? parseFloat(v) : v;
return n >= 20 ? 20 : n;
});
prevDecimals = prevSkillsFull.map(v => v >= 20 ? 0 : v - Math.floor(v));
}
}
}
/* ── Compute ASI remainder ── */
const asiTotalPts = asi > 0 ? totalPts(asi, isGK) : 0;
const intSum = intSkills.reduce((s, v) => s + v, 0);
const asiRemainder = asi > 0 ? Math.round((asiTotalPts - intSum) * 100) / 100 : 0;
/* ── Build improvement map (index → type) ── */
const improvementMap = {};
p.improved.forEach(imp => {
improvementMap[imp.index] = imp.type;
});
/* ── Distribute TI gain across improved skills ── */
const totalGain = p.TI / 10; // total skill points this week
let newDecimals;
if (prevDecimals && asi > 0) {
/* === We have previous data — smart distribution === */
newDecimals = [...prevDecimals];
/* Step 1: Calculate raw gains per improved skill using eff() weights */
const improvedIndices = p.improved.map(imp => imp.index);
if (improvedIndices.length > 0 && totalGain > 0) {
/* Compute eff-weighted shares for improved skills only */
const effWeights = improvedIndices.map(i => eff(intSkills[i]));
const effTotal = effWeights.reduce((a, b) => a + b, 0);
const shares = effTotal > 0
? effWeights.map(w => w / effTotal)
: effWeights.map(() => 1 / improvedIndices.length);
/* Distribute gain */
improvedIndices.forEach((idx, j) => {
newDecimals[idx] += totalGain * shares[j];
});
}
/* Step 2: Handle one_up → snap to .00 */
for (const imp of p.improved) {
if (imp.type === 'one_up') {
newDecimals[imp.index] = 0.00;
}
}
/* Step 3: Handle part_up cap → if decimal ≥ 1.0, cap at .99, send overflow to pool */
let overflow = 0;
let passes = 0;
do {
overflow = 0;
let freeCount = 0;
for (let i = 0; i < N; i++) {
if (intSkills[i] >= 20) { newDecimals[i] = 0; continue; }
if (newDecimals[i] >= 1.0) {
overflow += newDecimals[i] - 0.99;
newDecimals[i] = 0.99;
} else if (newDecimals[i] < 0.99) {
freeCount++;
}
}
if (overflow > 0.0001 && freeCount > 0) {
const add = overflow / freeCount;
for (let i = 0; i < N; i++) {
if (intSkills[i] < 20 && newDecimals[i] < 0.99) {
newDecimals[i] += add;
}
}
}
} while (overflow > 0.0001 && ++passes < 20);
/* Step 4: Also check subtle skills — they must NOT have crossed an integer boundary.
A subtle skill at previous 16.98 that got redistributed overflow to 17.01 is impossible
(it would show as one_up if it crossed). Cap and re-distribute. */
let subtleOverflow = 0;
passes = 0;
do {
subtleOverflow = 0;
let freeCount2 = 0;
for (let i = 0; i < N; i++) {
if (intSkills[i] >= 20) continue;
const prevInt = prevSkillsFull ? Math.floor(prevSkillsFull[i]) : intSkills[i];
const curInt = intSkills[i];
if (!improvementMap[i] && curInt === prevInt) {
/* Subtle: decimal must stay < 1.0 (same integer) */
if (newDecimals[i] >= 1.0) {
subtleOverflow += newDecimals[i] - 0.99;
newDecimals[i] = 0.99;
}
}
}
if (subtleOverflow > 0.0001) {
let freeSlots = 0;
for (let i = 0; i < N; i++) {
if (intSkills[i] < 20 && newDecimals[i] < 0.99) freeSlots++;
}
if (freeSlots > 0) {
const add2 = subtleOverflow / freeSlots;
for (let i = 0; i < N; i++) {
if (intSkills[i] < 20 && newDecimals[i] < 0.99) {
newDecimals[i] += add2;
}
}
}
}
} while (subtleOverflow > 0.0001 && ++passes < 20);
/* Step 5: Normalize so Σ decimals = ASI remainder */
const decSum = newDecimals.reduce((a, b) => a + b, 0);
if (decSum > 0.001 && asiRemainder > 0) {
const scale = asiRemainder / decSum;
newDecimals = newDecimals.map((d, i) => intSkills[i] >= 20 ? 0 : d * scale);
} else if (asiRemainder > 0) {
/* All decimals near zero — seed evenly */
const nonMax = intSkills.filter(v => v < 20).length;
newDecimals = intSkills.map(v => v >= 20 ? 0 : asiRemainder / nonMax);
}
/* Step 6: Final cap pass (normalization could push above .99 again) */
passes = 0;
do {
overflow = 0;
let freeCount = 0;
for (let i = 0; i < N; i++) {
if (intSkills[i] >= 20) { newDecimals[i] = 0; continue; }
if (newDecimals[i] > 0.99) { overflow += newDecimals[i] - 0.99; newDecimals[i] = 0.99; }
else if (newDecimals[i] < 0) { newDecimals[i] = 0; }
else if (newDecimals[i] < 0.99) freeCount++;
}
if (overflow > 0.0001 && freeCount > 0) {
const add = overflow / freeCount;
for (let i = 0; i < N; i++) {
if (intSkills[i] < 20 && newDecimals[i] < 0.99) newDecimals[i] += add;
}
}
} while (overflow > 0.0001 && ++passes < 20);
} else if (asi > 0) {
/* === No previous data — seed from ASI remainder evenly === */
const nonMax = intSkills.filter(v => v < 20).length;
newDecimals = intSkills.map(v => v >= 20 ? 0 : (nonMax > 0 ? asiRemainder / nonMax : 0));
} else {
/* No ASI available — can't compute */
newDecimals = new Array(N).fill(0);
}
/* ── Build full skill values ── */
const newSkillsFull = intSkills.map((v, i) => v >= 20 ? 20 : v + (newDecimals[i] || 0));
/* ── Compute diff: New vs DB (current week record) ── */
const diffSkills = curDbSkillsFull
? newSkillsFull.map((v, i) => v - curDbSkillsFull[i])
: null;
/* ── Compute R5 and REC for ALL positions, keep best ── */
const allPositions = favpos.split(',').map(s => s.trim()).filter(Boolean);
let R5 = null, REC = null, R5_DB = null;
let bestPos = allPositions[0] || '';
const r5ByPos = {};
if (asi > 0) {
let bestR5 = -Infinity;
for (const pos of allPositions) {
const pIdx = pos.toLowerCase() === 'gk' ? 9 : getPositionIndex(pos);
const r5val = Number(calculateR5F(pIdx, newSkillsFull, asi, routine));
const recval = Number(calculateRemaindersF(pIdx, newSkillsFull, asi).rec);
r5ByPos[pos] = { R5: r5val, REC: recval };
if (r5val > bestR5) {
bestR5 = r5val;
R5 = r5val;
REC = recval;
bestPos = pos;
}
}
if (curDbSkillsFull) {
const bestPosIdx = bestPos.toLowerCase() === 'gk' ? 9 : getPositionIndex(bestPos);
R5_DB = Number(calculateR5F(bestPosIdx, curDbSkillsFull, asi, routine));
}
}
/* ── Store result ── */
results.push({
pid: p.pid,
name: p.name,
number: p.number,
ageYears: p.ageYears,
ageMonths: p.ageMonths,
position: favpos,
isGK,
asi,
routine,
TI: p.TI,
TI_change: p.TI_change,
intSkills,
newDecimals,
newSkillsFull,
prevSkillsFull,
curDbSkillsFull,
diffSkills,
improved: p.improved,
R5,
R5_DB,
REC,
r5ByPos,
bestPos,
asiRemainder,
hadPrevData: !!prevDecimals
});
}
/* ═══ Console output — one table per player ═══ */
console.log(`%c[Squad] ═══ Processed ${results.length} players ═══`, 'font-weight:bold;color:#6cc040');
const fv = (v) => v >= 20 ? '★' : v.toFixed(2);
for (const r of results) {
const SHORT = r.isGK ? SKILL_NAMES_GK_SHORT : SKILL_NAMES_OUT_SHORT;
const FULL = r.isGK ? SKILL_NAMES_GK_FULL : SKILL_NAMES_OUT_FULL;
const N = r.isGK ? 11 : 14;
const rows = [];
for (let i = 0; i < N; i++) {
const imp = r.improved.find(x => x.index === i);
const marker = imp ? (imp.type === 'one_up' ? '⬆+1' : '↑') : '';
const db = r.curDbSkillsFull ? fv(r.curDbSkillsFull[i]) : '-';
const curr = fv(r.newSkillsFull[i]);
const diff = r.diffSkills
? (Math.abs(r.diffSkills[i]) < 0.005 ? '' : (r.diffSkills[i] >= 0 ? '+' : '') + r.diffSkills[i].toFixed(2))
: '';
rows.push({
Skill: SHORT[i],
DB: db,
New: curr,
Diff: diff,
Train: marker
});
}
/* Totals row */
const totalNew = r.newSkillsFull.reduce((s, v) => s + v, 0);
const totalDb = r.curDbSkillsFull ? r.curDbSkillsFull.reduce((s, v) => s + v, 0) : null;
rows.push({
Skill: 'TOTAL',
DB: totalDb != null ? totalDb.toFixed(2) : '-',
New: totalNew.toFixed(2),
Diff: totalDb != null ? ((totalNew - totalDb) >= 0 ? '+' : '') + (totalNew - totalDb).toFixed(2) : '',
Train: ''
});
const posR5Str = Object.entries(r.r5ByPos).map(([pos, v]) =>
`${pos}:${v.R5.toFixed(1)}`).join(' ');
console.log(
`%c── ${r.name} (#${r.number}) ── Age:${r.ageYears}.${String(r.ageMonths).padStart(2,'0')} | ${r.position} | ASI:${r.asi} | Rtn:${r.routine} | TI:${r.TI}(${r.TI_change>=0?'+':''}${r.TI_change}) | Best:${r.bestPos} R5_DB:${r.R5_DB!=null?r.R5_DB.toFixed(2):'?'} R5_New:${r.R5!=null?r.R5.toFixed(2):'?'} | R5[${posR5Str}] | REC:${r.REC!=null?r.REC.toFixed(2):'?'} | Rem:${r.asiRemainder.toFixed(2)} | DB:${r.curDbSkillsFull?'✓':'✗'}`,
'font-weight:bold;color:#fbbf24'
);
console.table(rows);
}
/* ═══ Sync results to IndexedDB ═══ */
loader.update(0, results.length, 'Syncing to DB...');
const bar = document.getElementById('tmrc-loader-bar');
if (bar) bar.style.width = '0%';
let syncCount = 0;
for (const r of results) {
if (r.asi <= 0) { syncCount++; continue; }
const ageKey = `${r.ageYears}.${r.ageMonths}`;
let store = PlayerDB.get(r.pid);
if (!store || !store._v) store = { _v: 1, lastSeen: Date.now(), records: {} };
store.records[ageKey] = {
SI: r.asi,
REREC: r.REC,
R5: r.R5,
skills: r.newSkillsFull.map(v => Math.round(v * 100) / 100),
routine: r.routine,
locked: true
};
store.lastSeen = Date.now();
if (!store.meta) {
store.meta = { name: r.name, pos: r.position, isGK: r.isGK };
}
await PlayerDB.set(r.pid, store);
syncCount++;
const pct = Math.round((syncCount / results.length) * 100);
if (bar) bar.style.width = pct + '%';
const txt = document.getElementById('tmrc-loader-text');
if (txt) txt.textContent = `Syncing ${syncCount}/${results.length} — ${r.name}`;
}
console.log(`%c[Squad] ✓ Synced ${syncCount} players to IndexedDB`, 'font-weight:bold;color:#6cc040');
loader.done(syncCount);
return results;
};
/* ═══════════════════════════════════════════════════════════
SHARED STATE (tooltip-derived)
═══════════════════════════════════════════════════════════ */
let isGoalkeeper = false;
let playerRecSort = null;
let playerRoutine = null;
let playerAge = null;
let playerASI = null;
let playerMonths = null;
let playerTI = null;
let playerPosition = null;
let playerSkillSums = null;
let tooltipSkills = null;
let tooltipPlayer = null;
/* Check if player belongs to the logged-in user's club */
const getOwnClubIds = () => {
const s = window.SESSION;
if (!s) return [];
const ids = [];
if (s.main_id) ids.push(String(s.main_id));
if (s.b_team) ids.push(String(s.b_team));
return ids;
};
/* ── per-tab cache ── */
const dataLoaded = {}; // key → bool
let activeMainTab = null;
let cssInjected = false;
/* ═══════════════════════════════════════════════════════════
CSS — Tab bar + History + Scout + Graphs
(Training CSS lives inside Shadow DOM)
═══════════════════════════════════════════════════════════ */
const CSS = `
/* ── Layout widths ── */
.main_center { width: 1200px !important; }
.column1 { width: 300px !important; margin-right: 8px !important; margin-left: 4px !important; }
.column2_a { width: 550px !important; margin-left: 0 !important; margin-right: 8px !important; }
.column3_a { margin-left: 0 !important; margin-right: 4px !important; }
/* ── Hide native TM tabs ── */
.tabs_outer { display: none !important; }
.tabs_content { display: none !important; }
/* ═══════════════════════════════════════
SKILLS GRID (tmps-*)
═══════════════════════════════════════ */
.tmps-wrap {
background: #1c3410; border: 1px solid #3d6828; border-radius: 8px;
overflow: hidden; padding: 0; margin-bottom: 4px;
}
.tmps-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0;
}
.tmps-row {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 14px; border-bottom: 1px solid rgba(42,74,28,.35);
}
.tmps-row:last-child { border-bottom: none; }
.tmps-row:hover { background: rgba(255,255,255,.03); }
.tmps-name {
color: #6a9a58; font-size: 11px; font-weight: 600;
}
.tmps-val {
font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.tmps-star { font-size: 15px; line-height: 1; }
.tmps-dec { font-size: 9px; opacity: .75; vertical-align: super; letter-spacing: 0; }
.tmps-divider {
grid-column: 1 / -1; height: 1px; background: #3d6828; margin: 0;
}
.tmps-hidden {
display: grid; grid-template-columns: 1fr 1fr; gap: 0;
}
.tmps-hidden .tmps-row {
padding: 5px 14px;
}
.tmps-hidden .tmps-name { color: #5a7a48; font-size: 10px; }
.tmps-hidden .tmps-val { font-size: 11px; color: #6a9a58; }
.tmps-unlock {
grid-column: 1 / -1; text-align: center; padding: 10px 14px;
}
.tmps-unlock-btn {
display: inline-block; padding: 5px 16px;
background: rgba(42,74,28,.4); border: 1px solid #2a4a1c; border-radius: 6px;
color: #8aac72; font-size: 11px; font-weight: 600; cursor: pointer;
transition: all 0.15s; font-family: inherit;
}
.tmps-unlock-btn:hover { background: rgba(42,74,28,.7); color: #c8e0b4; }
.tmps-unlock-btn img { height: 12px; vertical-align: middle; margin-left: 4px; position: relative; top: -1px; }
/* ═══════════════════════════════════════
PLAYER CARD (tmpc-*)
═══════════════════════════════════════ */
.tmpc-card {
background: #1c3410; border: 1px solid #3d6828; border-radius: 8px;
overflow: hidden; margin-bottom: 4px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.tmpc-header {
display: flex; gap: 16px; padding: 14px; align-items: flex-start;
}
.tmpc-photo {
width: 110px; min-width: 110px; border-radius: 6px;
border: 3px solid #3d6828; display: block;
}
.tmpc-info { flex: 1; min-width: 0; }
.tmpc-top-grid {
display: grid; grid-template-columns: 1fr auto;
gap: 2px 8px; align-items: center; margin-bottom: 10px;
}
.tmpc-name {
font-size: 16px; font-weight: 800; color: #e8f5d8;
line-height: 1.2;
}
.tmpc-badge-chip {
font-size: 12px; font-weight: 800; letter-spacing: -0.3px;
line-height: 16px;
font-variant-numeric: tabular-nums;
display: inline-flex; align-items: baseline; gap: 4px;
padding: 1px 8px; border-radius: 4px;
background: rgba(232,245,216,0.08); border: 1px solid rgba(232,245,216,0.15);
justify-self: end;
}
.tmpc-badge-lbl {
color: #6a9a58; font-size: 9px; font-weight: 600;
text-transform: uppercase;
}
.tmpc-pos-row {
display: flex; align-items: center; gap: 6px;
flex-wrap: wrap;
}
.tmpc-pos {
display: inline-block; padding: 1px 6px; border-radius: 4px;
font-size: 10px; font-weight: 700; letter-spacing: 0.3px;
line-height: 16px; text-align: center; min-width: 28px;
}
.tmpc-details {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px;
}
.tmpc-detail {
display: flex; justify-content: space-between; align-items: center;
padding: 3px 0;
}
.tmpc-lbl {
color: #6a9a58; font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.3px;
}
.tmpc-val {
color: #c8e0b4; font-size: 12px; font-weight: 700;
font-variant-numeric: tabular-nums;
}
.tmpc-pos-ratings {
border-top: 1px solid #3d6828; padding: 6px 14px;
}
.tmpc-rating-row {
display: flex; align-items: center; gap: 10px;
padding: 5px 0;
}
.tmpc-rating-row + .tmpc-rating-row { border-top: 1px solid rgba(61,104,40,.2); }
.tmpc-pos-bar {
width: 4px; height: 22px; border-radius: 2px; flex-shrink: 0;
}
.tmpc-pos-name {
font-size: 11px; font-weight: 700; min-width: 32px;
letter-spacing: 0.3px;
}
.tmpc-pos-stat {
display: flex; align-items: baseline; gap: 4px; margin-left: auto;
}
.tmpc-pos-stat + .tmpc-pos-stat { margin-left: 16px; }
.tmpc-pos-stat-lbl {
color: #6a9a58; font-size: 9px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.3px;
}
.tmpc-pos-stat-val {
font-size: 14px; font-weight: 800; letter-spacing: -0.3px;
font-variant-numeric: tabular-nums;
}
.tmpc-expand-toggle {
display: flex; align-items: center; justify-content: center;
gap: 6px; padding: 4px 0; cursor: pointer;
border-top: 1px solid rgba(61,104,40,.25);
color: #6a9a58; font-size: 10px; font-weight: 600;
letter-spacing: 0.4px; text-transform: uppercase;
transition: color .15s;
}
.tmpc-expand-toggle:hover { color: #80e048; }
.tmpc-expand-chevron {
display: inline-block; font-size: 10px; transition: transform .2s;
}
.tmpc-expand-toggle.tmpc-expanded .tmpc-expand-chevron { transform: rotate(180deg); }
.tmpc-all-positions {
max-height: 0; overflow: hidden; transition: max-height .3s ease;
}
.tmpc-all-positions.tmpc-expanded {
max-height: 600px;
}
.tmpc-all-positions .tmpc-rating-row.tmpc-is-player-pos {
background: rgba(61,104,40,.15);
}
.tmpc-rec-stars { font-size: 14px; letter-spacing: 1px; margin-top: 2px; line-height: 1; }
.tmpc-star-full { color: #fbbf24; }
.tmpc-star-half {
background: linear-gradient(90deg, #fbbf24 50%, #3d6828 50%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.tmpc-star-empty { color: #3d6828; }
.tmpc-flag { vertical-align: middle; margin-left: 4px; }
.tmpc-nt {
display: inline-flex; align-items: center; gap: 3px;
font-size: 9px; font-weight: 700; color: #fbbf24;
background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.25);
padding: 1px 6px; border-radius: 4px; margin-left: 6px;
vertical-align: middle; letter-spacing: 0.3px; line-height: 14px;
}
/* ── Column1 Nav (tmcn-*) ── */
.tmcn-nav {
background: #1c3410; border: 1px solid #3d6828; border-radius: 8px;
overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin-bottom: 10px;
}
.tmcn-nav a {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; color: #90b878; font-size: 12px; font-weight: 600;
text-decoration: none; border-bottom: 1px solid rgba(42,74,28,.5);
transition: all 0.15s;
}
.tmcn-nav a:last-child { border-bottom: none; }
.tmcn-nav a:hover { background: rgba(42,74,28,.4); color: #e8f5d8; }
.tmcn-nav a .tmcn-icon { font-size: 14px; width: 20px; text-align: center; flex-shrink: 0; }
.tmcn-nav a .tmcn-lbl { flex: 1; }
.column1 > .box { display: none !important; }
/* ── Strip TM box chrome in column2_a ── */
.column2_a > .box,
.column2_a > .box > .box_body { background: none !important; border: none !important; padding: 0 !important; box-shadow: none !important; }
.column2_a > .box > .box_head,
.column2_a .box_shadow,
.column2_a .box_footer,
.column2_a > h3 { display: none !important; }
/* ── Sidebar (column3_a) ── */
.tmps-sidebar {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.tmps-section {
background: #1c3410; border: 1px solid #3d6828; border-radius: 8px;
overflow: hidden; margin-bottom: 8px;
}
.tmps-section-head {
font-size: 10px; font-weight: 700; color: #6a9a58;
text-transform: uppercase; letter-spacing: 0.5px;
padding: 8px 12px 4px; border-bottom: 1px solid rgba(61,104,40,.3);
}
.tmps-btn-list {
display: flex; flex-direction: column; gap: 2px; padding: 6px;
}
.tmps-btn {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 5px; cursor: pointer;
background: transparent; border: none; width: 100%;
font-size: 11px; font-weight: 600; color: #c8e0b4;
font-family: inherit; text-align: left;
transition: background 0.12s;
}
.tmps-btn:hover { background: rgba(255,255,255,0.06); }
.tmps-btn-icon {
width: 16px; height: 16px; display: flex; align-items: center;
justify-content: center; font-size: 13px; flex-shrink: 0;
}
.tmps-btn.yellow { color: #fbbf24; }
.tmps-btn.red { color: #f87171; }
.tmps-btn.green { color: #4ade80; }
.tmps-btn.blue { color: #60a5fa; }
.tmps-btn.muted { color: #8aac72; }
.tmps-note {
margin: 0 6px 6px; padding: 6px 10px; border-radius: 5px;
background: rgba(42,74,28,0.5); border: 1px solid rgba(61,104,40,.3);
font-size: 11px; color: #8aac72; line-height: 1.4;
}
.tmps-award-list {
display: flex; flex-direction: column; gap: 0;
}
.tmps-award {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
}
.tmps-award + .tmps-award { border-top: 1px solid rgba(61,104,40,.2); }
.tmps-award-icon {
width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
}
.tmps-award-icon.gold { background: rgba(212,175,55,0.15); }
.tmps-award-icon.silver { background: rgba(96,165,250,0.15); }
.tmps-award-body { flex: 1; min-width: 0; }
.tmps-award-title {
font-size: 11px; font-weight: 700; color: #e8f5d8; line-height: 1.2;
}
.tmps-award-sub {
font-size: 10px; color: #8aac72; line-height: 1.3; margin-top: 1px;
}
.tmps-award-sub a { color: #80e048; text-decoration: none; }
.tmps-award-sub a:hover { text-decoration: underline; }
.tmps-award-season {
font-size: 11px; font-weight: 800; color: #fbbf24;
flex-shrink: 0; font-variant-numeric: tabular-nums;
}
/* ── Transfer Live Card (tmtf-*) ── */
.tmtf-card {
background: #1c3410; border: 1px solid #3d6828; border-radius: 8px;
overflow: hidden; margin-bottom: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.tmtf-head {
font-size: 10px; font-weight: 700; color: #6a9a58;
text-transform: uppercase; letter-spacing: 0.5px;
padding: 8px 12px 4px; border-bottom: 1px solid rgba(61,104,40,.3);
display: flex; align-items: center; justify-content: space-between;
}
.tmtf-reload {
background: none; border: none; color: #6a9a58; cursor: pointer;
font-size: 13px; padding: 0 2px; line-height: 1;
transition: color .15s;
}
.tmtf-reload:hover { color: #80e048; }
.tmtf-body { padding: 10px 12px; }
.tmtf-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0; font-size: 11px; color: #c8e0b4;
}
.tmtf-row + .tmtf-row { border-top: 1px solid rgba(61,104,40,.15); }
.tmtf-lbl { color: #6a9a58; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.tmtf-val { font-weight: 700; font-variant-numeric: tabular-nums; }
.tmtf-val.expiry { color: #fbbf24; }
.tmtf-val.bid { color: #80e048; }
.tmtf-val.buyer { color: #60a5fa; }
.tmtf-val.agent { color: #c084fc; }
.tmtf-val.expired { color: #f87171; }
.tmtf-val.sold { color: #4ade80; }
.tmtf-bid-btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
width: 100%; margin-top: 8px; padding: 8px 0;
background: rgba(108,192,64,0.12); border: 1px solid rgba(108,192,64,0.3);
border-radius: 6px; color: #80e048; font-size: 11px; font-weight: 700;
cursor: pointer; transition: background .15s;
font-family: inherit;
}
.tmtf-bid-btn:hover { background: rgba(108,192,64,0.22); }
.tmtf-spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid #6a9a58; border-top-color: transparent; border-radius: 50%; animation: tmtf-spin 0.6s linear infinite; margin-left: 6px; vertical-align: middle; }
@keyframes tmtf-spin { to { transform: rotate(360deg); } }
/* ── ASI Calculator (tmac-*) ── */
.tmac-card {
background: #1c3410; border: 1px solid #3d6828; border-radius: 8px;
overflow: hidden; margin-bottom: 8px; padding: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.tmac-head {
font-size: 10px; font-weight: 700; color: #6a9a58;
text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
}
.tmac-head::before { content: '📊'; font-size: 12px; }
.tmac-form { display: flex; flex-direction: column; gap: 8px; }
.tmac-field {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.tmac-label {
font-size: 10px; font-weight: 600; color: #90b878;
text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap;
}
.tmac-input {
width: 70px; padding: 5px 8px; border-radius: 4px;
background: rgba(0,0,0,.25); border: 1px solid rgba(42,74,28,.6);
color: #e8f5d8; font-size: 12px; font-weight: 600;
font-family: inherit; text-align: right; outline: none;
transition: border-color 0.15s;
}
.tmac-input:focus { border-color: #6cc040; }
.tmac-input::placeholder { color: #5a7a48; }
.tmac-result {
margin-top: 10px; padding: 8px 10px; border-radius: 5px;
background: rgba(42,74,28,.3); border: 1px solid rgba(42,74,28,.5);
display: none;
}
.tmac-result.show { display: block; }
.tmac-result-row {
display: flex; justify-content: space-between; align-items: center;
padding: 3px 0;
}
.tmac-result-row + .tmac-result-row { border-top: 1px solid rgba(42,74,28,.3); padding-top: 5px; margin-top: 2px; }
.tmac-result-lbl { font-size: 10px; font-weight: 600; color: #6a9a58; text-transform: uppercase; letter-spacing: 0.3px; }
.tmac-result-val { font-size: 13px; font-weight: 700; color: #e8f5d8; font-variant-numeric: tabular-nums; }
.tmac-diff { font-size: 10px; font-weight: 700; color: #6cc040; margin-left: 4px; }
/* ── Main Tab Bar ── */
#tmpe-container {
margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.tmpe-tabs-bar {
display: flex; background: #274a18;
border: 1px solid #3d6828; border-bottom: none;
border-radius: 8px 8px 0 0; overflow: hidden;
}
.tmpe-main-tab {
flex: 1; padding: 8px 12px; text-align: center; font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px; color: #90b878; cursor: pointer;
border: none; border-bottom: 2px solid transparent; transition: all 0.15s;
background: transparent; font-family: inherit;
-webkit-appearance: none; appearance: none;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.tmpe-main-tab .tmpe-icon { font-size: 14px; line-height: 1; }
.tmpe-main-tab:hover { color: #c8e0b4; background: #305820; }
.tmpe-main-tab.active { color: #e8f5d8; border-bottom-color: #6cc040; background: #305820; }
.tmpe-panels {
border: 1px solid #3d6828; border-top: none;
border-radius: 0 0 8px 8px;
padding: 0; min-height: 120px;
background: #1c3410;
}
.tmpe-panel {
animation: tmpe-fadeIn 0.25s ease-out;
padding: 8px;
}
@keyframes tmpe-fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Loading spinner ── */
.tmpe-loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 50px 20px; gap: 14px;
}
.tmpe-spinner {
width: 28px; height: 28px; border: 3px solid #274a18;
border-top-color: #6cc040; border-radius: 50%;
animation: tmpe-spin 0.8s linear infinite;
}
@keyframes tmpe-spin { to { transform: rotate(360deg); } }
.tmpe-loading-text { color: #6a9a58; font-size: 12px; font-weight: 600; letter-spacing: 0.5px; }
/* ═══════════════════════════════════════
HISTORY (tmph-*)
═══════════════════════════════════════ */
#tmph-root {
display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #c8e0b4; line-height: 1.4;
}
.tmph-wrap {
background: transparent; border-radius: 0; border: none; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #c8e0b4; font-size: 13px;
}
.tmph-tabs { display: flex; gap: 6px; padding: 10px 14px 6px; flex-wrap: wrap; }
.tmph-tab {
padding: 4px 12px; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px; color: #90b878; cursor: pointer;
border-radius: 4px; background: rgba(42,74,28,.3); border: 1px solid rgba(42,74,28,.6);
transition: all 0.15s; font-family: inherit; -webkit-appearance: none; appearance: none;
}
.tmph-tab:hover { color: #c8e0b4; background: rgba(42,74,28,.5); border-color: #3d6828; }
.tmph-tab.active { color: #e8f5d8; background: #305820; border-color: #3d6828; }
.tmph-body { padding: 6px 14px 16px; font-size: 13px; min-height: 120px; }
.tmph-tbl { width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 4px; }
.tmph-tbl th {
padding: 6px; font-size: 10px; font-weight: 700; color: #6a9a58;
text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 1px solid #2a4a1c;
text-align: left; white-space: nowrap;
}
.tmph-tbl th.c { text-align: center; }
.tmph-tbl th.r { text-align: right; }
.tmph-tbl td {
padding: 5px 6px; border-bottom: 1px solid rgba(42,74,28,.4);
color: #c8e0b4; font-variant-numeric: tabular-nums; vertical-align: middle;
}
.tmph-tbl td.c { text-align: center; }
.tmph-tbl td.r { text-align: right; }
.tmph-tbl tr:hover { background: rgba(255,255,255,.03); }
.tmph-tbl a { color: #80e048; text-decoration: none; font-weight: 600; }
.tmph-tbl a:hover { color: #c8e0b4; text-decoration: underline; }
.tmph-tbl .tmph-tot td { border-top: 2px solid #3d6828; color: #e0f0cc; font-weight: 800; }
.tmph-transfer td {
background: rgba(42,74,28,.2); color: #6a9a58; font-size: 10px;
padding: 4px 6px; border-bottom: 1px solid rgba(42,74,28,.3);
}
.tmph-xfer { display: flex; align-items: center; justify-content: center; gap: 8px; }
.tmph-xfer-arrow { color: #5b9bff; font-size: 13px; line-height: 1; }
.tmph-xfer-label { color: #6a9a58; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 9px; }
.tmph-xfer-sum {
color: #fbbf24; font-weight: 700; font-size: 11px;
background: rgba(251,191,36,.08); padding: 1px 8px; border-radius: 3px;
border: 1px solid rgba(251,191,36,.2);
}
.tmph-div { white-space: nowrap; font-size: 11px; }
.tmph-club { display: flex; align-items: center; gap: 6px; white-space: nowrap; max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.tmph-r-good { color: #6cc040; }
.tmph-r-avg { color: #c8e0b4; }
.tmph-r-low { color: #f87171; }
.tmph-empty { text-align: center; color: #5a7a48; padding: 40px; font-size: 13px; font-style: italic; }
/* ═══════════════════════════════════════
SCOUT (tmsc-*)
═══════════════════════════════════════ */
#tmsc-root {
display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #c8e0b4; line-height: 1.4;
}
.tmsc-wrap {
background: transparent; border-radius: 0; border: none; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #c8e0b4; font-size: 13px;
}
.tmsc-tabs { display: flex; gap: 6px; padding: 10px 14px 6px; flex-wrap: wrap; }
.tmsc-tab {
padding: 4px 12px; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px; color: #90b878; cursor: pointer;
border-radius: 4px; background: rgba(42,74,28,.3); border: 1px solid rgba(42,74,28,.6);
transition: all 0.15s; font-family: inherit; -webkit-appearance: none; appearance: none;
}
.tmsc-tab:hover { color: #c8e0b4; background: rgba(42,74,28,.5); border-color: #3d6828; }
.tmsc-tab.active { color: #e8f5d8; background: #305820; border-color: #3d6828; }
.tmsc-body { padding: 6px 14px 16px; font-size: 13px; min-height: 120px; }
.tmsc-tbl { width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 4px; }
.tmsc-tbl th {
padding: 6px; font-size: 10px; font-weight: 700; color: #6a9a58;
text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 1px solid #2a4a1c;
text-align: left; white-space: nowrap;
}
.tmsc-tbl th.c { text-align: center; }
.tmsc-tbl td {
padding: 5px 6px; border-bottom: 1px solid rgba(42,74,28,.4);
color: #c8e0b4; font-variant-numeric: tabular-nums; vertical-align: middle;
}
.tmsc-tbl td.c { text-align: center; }
.tmsc-tbl tr:hover { background: rgba(255,255,255,.03); }
.tmsc-tbl a { color: #80e048; text-decoration: none; font-weight: 600; }
.tmsc-tbl a:hover { color: #c8e0b4; text-decoration: underline; }
.tmsc-empty { text-align: center; color: #5a7a48; padding: 40px; font-size: 13px; font-style: italic; }
.tmsc-stars { font-size: 20px; letter-spacing: 2px; line-height: 1; }
.tmsc-star-full { color: #fbbf24; }
.tmsc-star-half {
background: linear-gradient(90deg, #fbbf24 50%, #3d6828 50%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.tmsc-star-empty { color: #3d6828; }
.tmsc-report { display: flex; flex-direction: column; gap: 14px; }
.tmsc-report-header {
display: flex; justify-content: space-between; align-items: flex-start;
padding-bottom: 10px; border-bottom: 1px solid #2a4a1c;
}
.tmsc-report-scout { color: #e8f5d8; font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.tmsc-report-date {
color: #6a9a58; font-size: 11px; font-weight: 600;
background: rgba(42,74,28,.4); padding: 3px 10px; border-radius: 4px; white-space: nowrap;
}
.tmsc-report-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.tmsc-report-item {
display: flex; justify-content: space-between; align-items: center;
padding: 5px 10px; background: rgba(42,74,28,.25); border-radius: 4px;
border: 1px solid rgba(42,74,28,.4);
}
.tmsc-report-label { color: #6a9a58; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
.tmsc-report-value { color: #e8f5d8; font-weight: 700; font-size: 12px; }
.tmsc-report-item.wide { grid-column: 1 / -1; }
.tmsc-section-title {
color: #6a9a58; font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.6px; padding-bottom: 6px; border-bottom: 1px solid #2a4a1c; margin-bottom: 8px;
}
.tmsc-bar-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
.tmsc-bar-label { color: #90b878; font-size: 11px; font-weight: 600; width: 100px; flex-shrink: 0; }
.tmsc-bar-track {
flex: 1; height: 6px; background: #1a2e10; border-radius: 3px;
overflow: hidden; max-width: 120px; position: relative;
}
.tmsc-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.tmsc-bar-fill-reach {
position: absolute; top: 0; left: 0; height: 100%;
border-radius: 3px; transition: width 0.3s;
}
.tmsc-bar-text { font-size: 11px; font-weight: 600; min-width: 60px; }
.tmsc-league-cell { white-space: nowrap; font-size: 11px; }
.tmsc-league-cell a { color: #80e048; text-decoration: none; font-weight: 600; }
.tmsc-league-cell a:hover { color: #c8e0b4; text-decoration: underline; }
.tmsc-club-cell a { color: #80e048; text-decoration: none; font-weight: 600; }
.tmsc-club-cell a:hover { color: #c8e0b4; text-decoration: underline; }
.tmsc-send-btn {
background: rgba(42,74,28,.4); color: #8aac72;
border: 1px solid #2a4a1c; border-radius: 6px;
padding: 4px 14px; font-size: 10px; font-weight: 600; cursor: pointer;
text-transform: uppercase; letter-spacing: 0.4px; transition: all 0.15s; font-family: inherit;
}
.tmsc-send-btn:hover { background: rgba(42,74,28,.7); color: #c8e0b4; }
.tmsc-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.tmsc-send-btn.tmsc-away {
background: transparent; border-color: rgba(61,104,40,.4); color: #5a7a48; font-size: 9px;
}
.tmsc-online { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-left: 4px; vertical-align: middle; }
.tmsc-online.on { background: #6cc040; box-shadow: 0 0 4px rgba(108,192,64,.5); }
.tmsc-online.off { background: #3d3d3d; }
.tmsc-yd-badge {
display: inline-block; background: #274a18; color: #6cc040; font-size: 9px;
font-weight: 700; padding: 1px 6px; border-radius: 3px; border: 1px solid #3d6828;
margin-left: 6px; letter-spacing: 0.5px; vertical-align: middle;
}
.tmsc-error {
text-align: center; color: #f87171; padding: 10px; font-size: 12px; font-weight: 600;
background: rgba(248,113,113,.06); border: 1px solid rgba(248,113,113,.15);
border-radius: 4px; margin-bottom: 10px;
}
.tmsc-report-divider { border: none; border-top: 1px dashed #3d6828; margin: 16px 0; }
.tmsc-report-count {
color: #6a9a58; font-size: 10px; text-align: center; padding: 4px 0;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
}
.tmsc-star-green { color: #6cc040; }
.tmsc-star-green-half {
background: linear-gradient(90deg, #6cc040 50%, #3d6828 50%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.tmsc-star-split {
background: linear-gradient(90deg, #fbbf24 50%, #6cc040 50%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.tmsc-conf {
display: inline-block; font-size: 9px; font-weight: 700; padding: 1px 5px;
border-radius: 3px; margin-left: 6px; letter-spacing: 0.3px;
vertical-align: middle; white-space: nowrap;
}
.tmsc-best-wrap {
background: rgba(42,74,28,.3); border: 1px solid #2a4a1c;
border-radius: 6px; padding: 12px; margin-bottom: 6px;
}
.tmsc-best-title {
color: #6cc040; font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.6px; margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
}
.tmsc-best-title::before { content: '★'; font-size: 13px; }
/* ═══════════════════════════════════════
GRAPHS (tmg-*)
═══════════════════════════════════════ */
.tmg-chart-wrap {
position: relative; background: rgba(0,0,0,0.18);
border: 1px solid rgba(120,180,80,0.25); border-radius: 6px;
padding: 6px 4px 4px; margin: 6px 0 10px;
}
.tmg-chart-title { font-size: 13px; font-weight: 700; color: #e8f5d8; padding: 2px 8px 4px; letter-spacing: 0.3px; }
.tmg-canvas { display: block; cursor: crosshair; }
.tmg-tooltip {
position: absolute; background: rgba(0,0,0,0.88); color: #fff;
padding: 5px 10px; border-radius: 4px; font-size: 11px; pointer-events: none;
z-index: 1000; white-space: nowrap; display: none;
border: 1px solid rgba(255,255,255,0.15); box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.tmg-legend {
display: grid; grid-template-columns: 1fr 1fr; gap: 1px 12px;
padding: 8px 12px 4px; max-width: 450px; margin: 0 auto;
}
.tmg-legend.tmg-legend-inline {
grid-template-columns: repeat(3, auto); justify-content: center; gap: 1px 18px;
}
.tmg-legend-item {
display: flex; align-items: center; gap: 3px; font-size: 11px;
color: #ccc; cursor: pointer; user-select: none; padding: 1px 0;
}
.tmg-legend-item input[type="checkbox"] {
appearance: none; -webkit-appearance: none; width: 13px; height: 13px; min-width: 13px;
border: 1px solid rgba(255,255,255,0.25); border-radius: 2px; cursor: pointer; margin: 0;
}
.tmg-legend-dot { font-size: 9px; line-height: 1; }
.tmg-legend-toggle { display: flex; gap: 6px; justify-content: center; padding: 4px 0 6px; }
.tmg-export-btn {
background: rgba(42,74,28,.5); color: #8aac72; border: 1px solid #3d6828;
border-radius: 5px; padding: 2px 10px; font-size: 10px; cursor: pointer;
font-family: inherit; letter-spacing: 0.3px;
}
.tmg-export-btn:hover { background: #305820; color: #c8e0b4; }
.tmg-btn {
background: rgba(42,74,28,.4); color: #8aac72; border: 1px solid #2a4a1c;
border-radius: 6px; padding: 4px 14px; font-size: 11px; cursor: pointer; font-family: inherit;
font-weight: 600; transition: all 0.15s; text-transform: uppercase; letter-spacing: 0.4px;
}
.tmg-btn:hover { background: rgba(42,74,28,.7); color: #c8e0b4; }
.tmg-enable-card {
background: rgba(0,0,0,0.18); border: 1px solid rgba(120,180,80,0.25);
border-radius: 6px; padding: 14px 16px; margin: 6px 0 10px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.tmg-enable-title { font-size: 13px; font-weight: 700; color: #6a9a58; letter-spacing: 0.3px; }
.tmg-enable-desc { font-size: 11px; color: #5a7a48; margin-top: 2px; }
.tmg-enable-btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 16px; background: rgba(42,74,28,.5); border: 1px solid #3d6828;
border-radius: 6px; color: #80e048; font-size: 11px; font-weight: 700;
cursor: pointer; transition: all 0.15s; font-family: inherit;
text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap;
-webkit-appearance: none; appearance: none;
}
.tmg-enable-btn:hover { background: #305820; color: #e8f5d8; border-color: #4a8030; }
.tmg-enable-btn .pro_icon { height: 12px; vertical-align: middle; position: relative; top: -1px; }
.tmg-enable-all {
display: flex; justify-content: center; padding: 4px 0 8px;
}
.tmg-skill-arrow { font-size: 9px; margin-left: 1px; }
/* ═══════════════════════════════════════
BEST ESTIMATE CARD (tmbe-*)
═══════════════════════════════════════ */
.tmbe-card {
background: #1c3410; border: 1px solid #3d6828; border-radius: 8px;
overflow: hidden; margin-bottom: 4px; padding: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.tmbe-title {
color: #6a9a58; font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.6px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
}
.tmbe-title::before { content: '★'; font-size: 13px; color: #fbbf24; }
.tmbe-title-stars { font-size: 18px; letter-spacing: 1px; line-height: 1; margin-left: auto; }
.tmbe-grid {
display: grid; grid-template-columns: 1fr; gap: 6px; margin-bottom: 14px;
}
.tmbe-item {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 10px; background: rgba(42,74,28,.25); border-radius: 4px;
border: 1px solid rgba(42,74,28,.4);
}
.tmbe-lbl { color: #6a9a58; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
.tmbe-val { color: #e8f5d8; font-weight: 700; font-size: 12px; }
.tmbe-divider {
color: #6a9a58; font-size: 9px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.5px; padding: 8px 0 2px; margin-top: 2px;
border-top: 1px solid rgba(42,74,28,.5);
}
.tmbe-peak-item {
flex-direction: column !important; align-items: stretch !important; gap: 6px; padding: 8px 10px !important;
}
.tmbe-peak-header {
display: flex; justify-content: space-between; align-items: center;
}
.tmbe-peak-reach {
font-size: 10px; font-weight: 700; line-height: 1;
display: flex; align-items: center; gap: 12px;justify-content: space-between;
}
.tmbe-reach-item {
display: flex; align-items: center; gap: 4px;
}
.tmbe-reach-tag {
font-size: 8px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.3px; color: #5a7a48;
}
.tmbe-bar-row {
display: flex; flex-direction: column; gap: 3px; padding: 6px 0;
border-bottom: 1px solid rgba(42,74,28,.3);
}
.tmbe-bar-row:last-child { border-bottom: none; }
.tmbe-bar-header {
display: flex; align-items: center; justify-content: space-between;
}
.tmbe-bar-label { color: #90b878; font-size: 11px; font-weight: 600; }
.tmbe-bar-right { display: flex; align-items: center; gap: 8px; }
.tmbe-bar-track {
width: 100%; height: 6px; background: rgba(0,0,0,.3); border-radius: 3px;
overflow: hidden; position: relative;
}
.tmbe-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.tmbe-bar-fill-reach {
position: absolute; top: 0; left: 0; height: 100%;
border-radius: 3px; transition: width 0.3s;
}
.tmbe-bar-val { font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; }
.tmbe-conf {
display: inline-block; font-size: 9px; font-weight: 700; padding: 1px 5px;
border-radius: 3px; margin-left: 6px; letter-spacing: 0.3px;
vertical-align: middle; white-space: nowrap;
}
/* ═══════════════════════════════════════
COMPARE MODAL (tmc-*)
═══════════════════════════════════════ */
.tmc-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 99999;
display: flex; align-items: center; justify-content: center;
}
.tmc-modal {
background: #1a3311; border: 1px solid #3d6828; border-radius: 10px;
width: 500px; max-width: 96vw; max-height: 90vh; display: flex; flex-direction: column;
box-shadow: 0 8px 40px rgba(0,0,0,0.7); overflow: hidden;
}
.tmc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; background: #274a18; border-bottom: 1px solid #3d6828;
font-weight: 700; color: #e8f5d8; font-size: 14px; flex-shrink: 0;
}
.tmc-close-btn { background: none; border: none; color: #90b878; cursor: pointer; font-size: 16px; padding: 0 4px; line-height: 1; }
.tmc-close-btn:hover { color: #e8f5d8; }
.tmc-modal-body { flex: 1; overflow-y: auto; padding: 12px 14px; min-height: 0; }
.tmc-input-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.tmc-input-icon { font-size: 14px; flex-shrink: 0; }
.tmc-input {
flex: 1; background: rgba(0,0,0,0.3); border: 1px solid #3d6828; border-radius: 5px;
color: #e8f5d8; padding: 6px 10px; font-size: 12px; font-family: inherit; outline: none;
}
.tmc-input:focus { border-color: #6cc040; }
.tmc-player-list { margin-top: 8px; max-height: 340px; overflow-y: auto; border: 1px solid rgba(61,104,40,0.4); border-radius: 6px; }
.tmc-player-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid rgba(61,104,40,0.25); transition: background 0.1s; }
.tmc-player-row:last-child { border-bottom: none; }
.tmc-player-row:hover { background: rgba(108,192,64,0.12); }
.tmc-row-name { flex: 1; color: #e8f5d8; font-size: 12px; font-weight: 600; }
.tmc-row-sub { font-size: 10px; color: #8aac72; }
.tmc-row-count { font-size: 10px; color: #5a7a48; font-weight: 700; }
.tmc-list-header { padding: 5px 12px 3px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: #5a7a48; background: rgba(61,104,40,0.18); border-bottom: 1px solid rgba(61,104,40,0.25); }
.tmc-squad-badge { display: inline-block; font-size: 9px; font-weight: 700; line-height: 1; padding: 1px 4px; border-radius: 3px; background: #2d5a1a; color: #a8d888; margin-left: 4px; vertical-align: middle; }
.tmc-empty-list, .tmc-loading-msg { padding: 24px; text-align: center; color: #5a7a48; font-size: 12px; font-style: italic; }
.tmc-error-msg { padding: 24px; text-align: center; color: #f87171; font-size: 12px; }
.tmc-back-btn { background: rgba(42,74,28,.5); color: #8aac72; border: 1px solid #3d6828; border-radius: 5px; padding: 4px 12px; font-size: 11px; cursor: pointer; font-family: inherit; margin-bottom: 12px; display: block; }
.tmc-back-btn:hover { background: #305820; color: #c8e0b4; }
.tmc-compare-wrap { font-size: 12px; }
.tmc-compare-header { display: flex; align-items: center; gap: 0; margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid rgba(61,104,40,0.4); }
.tmc-compare-col { flex: 1; text-align: center; }
.tmc-compare-vs { width: 32px; height: 32px; border-radius: 50%; background: rgba(61,104,40,0.4); color: #5a7a48; font-weight: 800; font-size: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.tmc-player-name { color: #e8f5d8; font-weight: 700; font-size: 13px; }
.tmc-player-sub { color: #8aac72; font-size: 10px; margin-top: 2px; }
.tmc-section-title { font-size: 10px; font-weight: 700; color: #5a7a48; text-transform: uppercase; letter-spacing: 0.8px; margin: 12px 0 6px; padding-bottom: 3px; border-bottom: 1px solid rgba(61,104,40,0.3); }
.tmc-stat-grid { display: flex; gap: 6px; margin-bottom: 4px; }
.tmc-stat-card { flex: 1; background: rgba(42,74,28,0.35); border: 1px solid rgba(61,104,40,0.3); border-radius: 6px; padding: 8px 4px; text-align: center; }
.tmc-stat-card-label { font-size: 9px; font-weight: 700; color: #5a7a48; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; }
.tmc-stat-card-vals { display: flex; align-items: center; justify-content: center; gap: 6px; }
.tmc-stat-card-v { font-weight: 700; font-size: 14px; }
.tmc-stat-card-sep { color: #3d6828; font-size: 10px; }
.tmc-skill-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
.tmc-skill-cell { display: flex; align-items: center; padding: 5px 8px; border-bottom: 1px solid rgba(61,104,40,0.15); gap: 6px; }
.tmc-skill-cell:nth-last-child(-n+2) { border-bottom: none; }
.tmc-skill-name { color: #8aac72; font-size: 11px; white-space: nowrap; flex: 1; }
.tmc-skill-vals { display: flex; align-items: baseline; gap: 1px; font-size: 12px; white-space: nowrap; }
.tmc-skill-v { font-weight: 400; font-size: 11px; }
.tmc-skill-v.win { font-weight: 800; font-size: 13px; }
.tmc-skill-sep { color: #3d6828; font-size: 10px; margin: 0 1px; }
`;
const injectCSS = () => {
if (cssInjected) return;
const s = document.createElement('style');
s.textContent = CSS;
document.head.appendChild(s);
cssInjected = true;
};
/* ═══════════════════════════════════════════════════════════
COMPARE MODULE
═══════════════════════════════════════════════════════════ */
const CompareMod = (() => {
const NAMES_OUT = ['Strength', 'Stamina', 'Pace', 'Marking', 'Tackling', 'Workrate', 'Positioning', 'Passing', 'Crossing', 'Technique', 'Heading', 'Finishing', 'Longshots', 'Set Pieces'];
const NAMES_GK = ['Strength', 'Stamina', 'Pace', 'Handling', 'One on ones', 'Reflexes', 'Aerial Ability', 'Jumping', 'Communication', 'Kicking', 'Throwing'];
const fPos = p => p ? p.split(',').map(s => s.trim().toUpperCase()).join('/') : '–';
const fAge = (y, m) => `${y}y ${m}m`;
const fv = (v, d = 2) => v == null ? '–' : Number(v).toFixed(d);
const latestKey = store => {
const keys = Object.keys(store.records || {});
if (!keys.length) return null;
return keys.sort((a, b) => {
const [ay, am] = a.split('.').map(Number), [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
}).at(-1);
};
/* Extract integer skill array from tooltip-style skills [{name,value}] */
const extractSkills = (skillsArr, isGK) => {
if (!skillsArr || !skillsArr.length) return null;
const names = isGK ? NAMES_GK : NAMES_OUT;
const sv = (name) => {
const sk = skillsArr.find(s => s.name === name);
if (!sk) return 0;
const v = sk.value;
if (typeof v === 'string') {
if (v.includes('star_silver')) return 19;
if (v.includes('star')) return 20;
return parseInt(v) || 0;
}
return parseInt(v) || 0;
};
return names.map(sv);
};
/* Compute R5 & REC for first favposition */
const computeR5REC = (favpos, skills, asi, routine) => {
if (!skills || !skills.length || !asi) return { R5: null, REREC: null };
const firstPos = (favpos || '').split(',')[0].trim();
const posIdx = getPositionIndex(firstPos);
const rou = routine || 0;
const r5 = Number(calculateR5F(posIdx, skills, asi, rou));
const rec = Number(calculateRemaindersF(posIdx, skills, asi).rec);
return { R5: r5, REREC: rec };
};
const currentData = () => {
const store = PlayerDB.get(PLAYER_ID);
const lk = store ? latestKey(store) : null;
const rec = lk && store ? store.records[lk] : null;
let R5 = rec?.R5 ?? null;
let REREC = rec?.REREC ?? null;
let skills = rec?.skills ?? null;
const asi = rec ? parseInt(rec.SI) || 0 : (playerASI || 0);
/* Compute R5/REC from tooltip skills if store doesn't have them */
if ((R5 == null || REREC == null) && tooltipSkills && asi > 0) {
const intSkills = extractSkills(tooltipSkills, isGoalkeeper);
if (intSkills && intSkills.some(s => s > 0)) {
const computed = computeR5REC(playerPosition, intSkills, asi, playerRoutine || 0);
if (R5 == null) R5 = computed.R5;
if (REREC == null) REREC = computed.REREC;
if (!skills) skills = intSkills;
}
}
return {
id: PLAYER_ID,
name: store?.meta?.name || tooltipPlayer?.name || `Player #${PLAYER_ID}`,
pos: fPos(store?.meta?.pos || playerPosition || ''),
years: tooltipPlayer ? parseInt(tooltipPlayer.age) || 0 : null,
months: tooltipPlayer ? parseInt(tooltipPlayer.months) || 0 : null,
isGK: isGoalkeeper,
SI: asi,
R5,
REREC,
skills,
records: store ? Object.keys(store.records || {}).length : 0
};
};
const buildCompareHtml = (a, b) => {
const skillNames = (a.isGK || b.isGK) ? NAMES_GK : NAMES_OUT;
const clr = (av, bv) => {
if (av == null || bv == null) return '#8aac72';
if (av === bv) return '#ccc';
return av > bv ? '#80e048' : '#f87171';
};
let h = `<div class="tmc-compare-wrap">`;
h += `<div class="tmc-compare-header">
<div class="tmc-compare-col">
<div class="tmc-player-name">${a.name}</div>
<div class="tmc-player-sub">${a.pos}${a.years != null ? ' · ' + fAge(a.years, a.months) : ''}</div>
</div>
<div class="tmc-compare-vs">VS</div>
<div class="tmc-compare-col">
<div class="tmc-player-name">${b.name}</div>
<div class="tmc-player-sub">${b.pos}${b.years != null ? ' · ' + fAge(b.years, b.months) : ''}</div>
</div>
</div>`;
h += `<div class="tmc-stat-grid">`;
for (const [lbl, ak, bk, dec] of [['ASI', 'SI', 'SI', 0], ['R5', 'R5', 'R5', 2], ['REC', 'REREC', 'REREC', 2]]) {
const av = a[ak], bv = b[bk];
h += `<div class="tmc-stat-card">
<div class="tmc-stat-card-label">${lbl}</div>
<div class="tmc-stat-card-vals">
<span class="tmc-stat-card-v" style="color:${clr(av, bv)}">${fv(av, dec)}</span>
<span class="tmc-stat-card-sep">:</span>
<span class="tmc-stat-card-v" style="color:${clr(bv, av)}">${fv(bv, dec)}</span>
</div>
</div>`;
}
h += `</div>`;
if (a.skills || b.skills) {
const clrA = '#6cc040', clrB = '#4a9fd6';
h += `<div class="tmc-section-title">Skills <span style="font-weight:400;letter-spacing:0">(</span><span style="color:${clrA};font-weight:400;letter-spacing:0">${a.name.split(' ').pop()}</span> <span style="font-weight:400;letter-spacing:0">vs</span> <span style="color:${clrB};font-weight:400;letter-spacing:0">${b.name.split(' ').pop()}</span><span style="font-weight:400;letter-spacing:0">)</span></div><div class="tmc-skill-grid">`;
const half = Math.ceil(skillNames.length / 2);
const fmtVal = (v, isWin, clr) => {
if (v == null) return `<span class="tmc-skill-v" style="color:#555">–</span>`;
const fl = Math.floor(v);
if (fl >= 20) return `<span class="tmc-skill-v${isWin ? ' win' : ''}" style="color:gold">★</span>`;
if (fl >= 19) return `<span class="tmc-skill-v${isWin ? ' win' : ''}" style="color:silver">★</span>`;
return `<span class="tmc-skill-v${isWin ? ' win' : ''}" style="color:${clr}">${fl}</span>`;
};
for (let r = 0; r < half; r++) {
for (const ci of [r, r + half]) {
if (ci >= skillNames.length) { h += `<div class="tmc-skill-cell"></div>`; continue; }
const name = skillNames[ci];
const av = a.skills?.[ci] ?? null, bv = b.skills?.[ci] ?? null;
const aWin = av != null && bv != null && av > bv;
const bWin = av != null && bv != null && bv > av;
h += `<div class="tmc-skill-cell">
<div class="tmc-skill-name">${name}</div>
<div class="tmc-skill-vals">${fmtVal(av, aWin, clrA)}<span class="tmc-skill-sep">/</span>${fmtVal(bv, bWin, clrB)}</div>
</div>`;
}
}
h += `</div>`;
}
h += `</div>`;
return h;
};
let overlay = null;
const showView = (html, backFn) => {
const body = overlay.querySelector('.tmc-modal-body');
body.innerHTML = `<button class="tmc-back-btn">← Back</button>${html}`;
body.querySelector('.tmc-back-btn').addEventListener('click', backFn || renderSelection);
};
let squadCache = null; /* { list: [{pid, name, fp, team}], post: {pid: playerObj} } */
const fetchSquad = (cb) => {
if (squadCache) { cb(squadCache); return; }
const s = window.SESSION;
const mainId = s?.main_id ? String(s.main_id) : null;
const bId = s?.b_team ? String(s.b_team) : null;
if (!mainId) { squadCache = { list: [], post: {} }; cb(squadCache); return; }
const parse = (res, team) => {
try {
const data = typeof res === 'object' ? res : JSON.parse(res);
const list = (data?.squad || []).map(p => ({
pid: String(p.player_id),
name: p.name || `#${p.player_id}`,
fp: p.fp || '',
team
}));
const post = {};
if (data?.post) { for (const [id, p] of Object.entries(data.post)) post[String(id)] = p; }
return { list, post };
} catch(e) { return { list: [], post: {} }; }
};
$.post('/ajax/players_get_select.ajax.php', { type: 'change', club_id: mainId }, res => {
const main = parse(res, 'A');
if (!bId) { squadCache = main; cb(squadCache); return; }
$.post('/ajax/players_get_select.ajax.php', { type: 'change', club_id: bId }, res2 => {
const b = parse(res2, 'B');
squadCache = { list: [...main.list, ...b.list], post: { ...main.post, ...b.post } };
cb(squadCache);
}).fail(() => { squadCache = main; cb(squadCache); });
}).fail(() => { squadCache = { list: [], post: {} }; cb(squadCache); });
};
const renderSelection = () => {
const body = overlay.querySelector('.tmc-modal-body');
body.innerHTML = `
<div class="tmc-input-row"><span class="tmc-input-icon">🔗</span><input class="tmc-input" id="tmc-url-input" placeholder="Paste player URL (e.g. /players/12345678/)…"></div>
<div class="tmc-input-row"><span class="tmc-input-icon">🔍</span><input class="tmc-input" id="tmc-search-input" placeholder="Search players…"></div>
<div id="tmc-player-list" class="tmc-player-list"><div class="tmc-empty-list">Loading squad…</div></div>`;
const urlIn = body.querySelector('#tmc-url-input');
urlIn.addEventListener('input', () => {
const m = urlIn.value.match(/\/players\/(\d+)/);
if (m) { urlIn.style.borderColor = '#6cc040'; startCompare(m[1]); }
else urlIn.style.borderColor = '';
});
body.querySelector('#tmc-search-input').addEventListener('input', e => renderList(e.target.value.toLowerCase()));
fetchSquad(() => renderList(''));
};
const renderList = (filter) => {
const list = overlay.querySelector('#tmc-player-list');
if (!list) return;
if (!squadCache || !squadCache.list.length) { list.innerHTML = '<div class="tmc-empty-list">No players found</div>'; return; }
const rows = squadCache.list
.filter(p => String(p.pid) !== String(PLAYER_ID) && (!filter || p.name.toLowerCase().includes(filter) || p.pid.includes(filter)))
.map(p =>
`<div class="tmc-player-row" data-pid="${p.pid}">
<div><div class="tmc-row-name">${p.name}${p.team === 'B' ? ' <span class="tmc-squad-badge">B</span>' : ''}</div><div class="tmc-row-sub">${p.fp}</div></div>
</div>`
);
if (!rows.length) { list.innerHTML = '<div class="tmc-empty-list">No players found</div>'; return; }
list.innerHTML = rows.join('');
list.querySelectorAll('.tmc-player-row').forEach(row => row.addEventListener('click', () => startCompare(row.dataset.pid)));
};
const startCompare = (oppId) => {
oppId = String(oppId);
showView('<div class="tmc-loading-msg">⏳ Loading player data…</div>');
const a = currentData();
const buildOpp = (p, oppStore) => {
const lk = oppStore ? latestKey(oppStore) : null;
const rec = lk ? oppStore.records[lk] : null;
const fp = p.favposition || '';
const oppIsGK = fp.split(',')[0].trim().toLowerCase() === 'gk';
const asi = rec ? parseInt(rec.SI) || 0 : (parseInt((p.asi || p.skill_index || '').toString().replace(/[^0-9]/g, '')) || 0);
let R5 = rec?.R5 ?? null;
let REREC = rec?.REREC ?? null;
let skills = rec?.skills ?? null;
/* Compute R5/REC from skills if store doesn't have them */
if ((R5 == null || REREC == null) && p.skills && asi > 0) {
const intSkills = extractSkills(p.skills, oppIsGK);
if (intSkills && intSkills.some(s => s > 0)) {
const rou = rec?.routine || parseFloat(p.routine) || 0;
const computed = computeR5REC(fp, intSkills, asi, rou);
if (R5 == null) R5 = computed.R5;
if (REREC == null) REREC = computed.REREC;
if (!skills) skills = intSkills;
}
}
return {
id: oppId,
name: p.name || p.player_name || p.player_name_long || `Player #${oppId}`,
pos: p.fp ? p.fp : fPos(fp),
years: parseInt(p.age) || 0,
months: parseInt(p.months || p.month) || 0,
isGK: oppIsGK,
SI: asi,
R5,
REREC,
skills,
records: oppStore ? Object.keys(oppStore.records || {}).length : 0
};
};
/* Priority 1: cached post data from squad endpoint (own team) */
const cachedPost = squadCache?.post?.[oppId];
if (cachedPost) {
const oppStore = PlayerDB.get(oppId);
showView(buildCompareHtml(a, buildOpp(cachedPost, oppStore)));
return;
}
/* Priority 2: tooltip endpoint (external players) */
$.post('/ajax/tooltip.ajax.php', { player_id: oppId }, res => {
try {
const data = typeof res === 'object' ? res : JSON.parse(res);
const p = data?.player;
if (!p) { showView('<div class="tmc-error-msg">⚠ Player not found</div>'); return; }
const oppStore = PlayerDB.get(oppId);
showView(buildCompareHtml(a, buildOpp(p, oppStore)));
} catch (e) { showView('<div class="tmc-error-msg">⚠ Failed to load data</div>'); }
}).fail(() => showView('<div class="tmc-error-msg">⚠ Network error</div>'));
};
const openDialog = () => {
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'tmc-overlay';
overlay.innerHTML = `<div class="tmc-modal"><div class="tmc-modal-header"><span>⚖️ Compare Player</span><button class="tmc-close-btn">✕</button></div><div class="tmc-modal-body"></div></div>`;
document.body.appendChild(overlay);
overlay.querySelector('.tmc-close-btn').addEventListener('click', () => overlay.style.display = 'none');
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.style.display = 'none'; });
}
overlay.style.display = 'flex';
renderSelection();
};
window.tmCompareOpen = openDialog;
return { openDialog };
})();
/* ═══════════════════════════════════════════════════════════
PARSE NATIONAL TEAM STATS (before DOM is modified)
═══════════════════════════════════════════════════════════ */
let parsedNTData = null;
const parseNTData = () => {
const h3s = document.querySelectorAll('h3.dark');
for (const h3 of h3s) {
const txt = h3.textContent;
if (!txt.includes('Called up for') && !txt.includes('Previously played for')) continue;
const countryLink = h3.querySelector('a.country_link');
const countryName = countryLink ? countryLink.textContent.trim() : '';
const flagLinks = h3.querySelectorAll('.country_link');
const flagEl = flagLinks.length > 1 ? flagLinks[flagLinks.length - 1] : flagLinks[0];
const flagHtml = flagEl ? flagEl.outerHTML : '';
const nextDiv = h3.nextElementSibling;
const table = nextDiv && nextDiv.querySelector('table');
if (!table) continue;
const tds = table.querySelectorAll('tr:not(:first-child) td, tr.odd td');
if (tds.length >= 6) {
/* Hide TM's original NT section */
h3.style.display = 'none';
if (nextDiv) nextDiv.style.display = 'none';
return {
country: countryName,
flagHtml: flagHtml,
matches: parseInt(tds[0].textContent) || 0,
goals: parseInt(tds[1].textContent) || 0,
assists: parseInt(tds[2].textContent) || 0,
cards: parseInt(tds[3].textContent) || 0,
rating: parseFloat(tds[4].textContent) || 0,
mom: parseInt(tds[5].textContent) || 0
};
}
}
return null;
};
/* ═══════════════════════════════════════════════════════════
SHARED TOOLTIP FETCH
═══════════════════════════════════════════════════════════ */
const fetchTooltip = () => {
$.post('/ajax/tooltip.ajax.php', { player_id: PLAYER_ID }, (res) => {
try {
const data = typeof res === 'object' ? res : JSON.parse(res);
if (!data || !data.player) return;
const p = data.player;
/* Retired / deleted player — club is null → clean up storage */
if (p.club_id === null || data.club === null) {
if (PlayerDB.get(PLAYER_ID)) {
PlayerDB.remove(PLAYER_ID);
console.log(`%c[Cleanup] Removed retired/deleted player ${PLAYER_ID} from DB`, 'font-weight:bold;color:#f87171');
}
return;
}
const fp = p.favposition || '';
isGoalkeeper = fp.split(',')[0].toLowerCase() === 'gk';
if (p.rec_sort !== undefined) playerRecSort = parseFloat(p.rec_sort) || 0;
if (p.age !== undefined) {
playerAge = (parseInt(p.age) || 0) + (parseInt(p.months) || 0) / 12;
playerMonths = parseInt(p.months) || 0;
}
if (p.favposition !== undefined) playerPosition = p.favposition;
tooltipPlayer = p;
/* Migrate meta.pos if missing for existing store */
try {
const existingStore = PlayerDB.get(PLAYER_ID);
if (existingStore && p.favposition) {
const fp = p.favposition || '';
const pGK = fp.split(',')[0].toLowerCase() === 'gk';
if (!existingStore.meta) {
existingStore.meta = { name: p.name || '', pos: fp, isGK: pGK };
PlayerDB.set(PLAYER_ID, existingStore);
console.log(`[TmPlayer] Migrated meta for player ${PLAYER_ID}: pos=${fp}`);
} else if (!existingStore.meta.pos) {
existingStore.meta.pos = fp;
existingStore.meta.isGK = pGK;
if (!existingStore.meta.name && p.name) existingStore.meta.name = p.name;
PlayerDB.set(PLAYER_ID, existingStore);
console.log(`[TmPlayer] Migrated meta.pos for player ${PLAYER_ID}: pos=${fp}`);
}
}
} catch (e) { /* non-critical */ }
if (p.skills) {
tooltipSkills = p.skills;
const sv = (name) => {
const sk = p.skills.find(s => s.name === name);
if (!sk) return 0;
const v = sk.value;
if (typeof v === 'string' && v.includes('star')) return v.includes('silver') ? 19 : 20;
return parseInt(v) || 0;
};
if (isGoalkeeper) {
playerSkillSums = {
phy: sv('Strength') + sv('Stamina') + sv('Pace') + sv('Jumping'),
tac: sv('One on ones') + sv('Aerial Ability') + sv('Communication'),
tec: sv('Handling') + sv('Reflexes') + sv('Kicking') + sv('Throwing')
};
} else {
playerSkillSums = {
phy: sv('Strength') + sv('Stamina') + sv('Pace') + sv('Heading'),
tac: sv('Marking') + sv('Tackling') + sv('Workrate') + sv('Positioning'),
tec: sv('Passing') + sv('Crossing') + sv('Technique') + sv('Finishing') + sv('Longshots') + sv('Set Pieces')
};
}
}
/* re-render scout if already shown */
ScoutMod.reRender();
/* parse NT data before buildPlayerCard modifies the DOM */
parsedNTData = parseNTData();
/* build player card after tooltip data arrives */
buildPlayerCard();
/* build ASI calculator with defaults after TI is computed */
buildASICalculator();
/* re-render history if already loaded, so NT tab appears */
if (parsedNTData && dataLoaded['history']) HistoryMod.reRender();
/* fetch scout data for Best Estimate card */
fetchBestEstimate();
/* save current visit and then patch decimal values into already-rendered skill grid */
if (p.age !== undefined && p.months !== undefined && p.skills) {
const _yr = parseInt(p.age);
const _mo = parseInt(p.months) || 0;
const _NAMES_OUT = ['Strength', 'Stamina', 'Pace', 'Marking', 'Tackling', 'Workrate', 'Positioning', 'Passing', 'Crossing', 'Technique', 'Heading', 'Finishing', 'Longshots', 'Set Pieces'];
const _NAMES_GK = ['Strength', 'Stamina', 'Pace', 'Handling', 'One on ones', 'Reflexes', 'Aerial Ability', 'Jumping', 'Communication', 'Kicking', 'Throwing'];
const _names = isGoalkeeper ? _NAMES_GK : _NAMES_OUT;
const _skillsArr = _names.map(name => {
const sk = p.skills.find(s => s.name === name);
if (!sk) return 0;
const v = sk.value;
if (typeof v === 'string' && v.includes('star')) return v.includes('silver') ? 19 : 20;
return parseInt(v) || 0;
});
setTimeout(() => syncFromGraphs(_yr, _mo, _skillsArr, playerASI, isGoalkeeper), 500);
setTimeout(() => {
/* Patch the already-rendered skill grid with decimal values */
const ageKey = `${_yr}.${_mo}`;
let skillsC = null;
try {
const store = PlayerDB.get(PLAYER_ID);
if (store && store._v >= 1 && store.records && store.records[ageKey])
skillsC = store.records[ageKey].skills;
} catch (e) { }
if (!skillsC) {
/* Fallback: compute from ASI */
if (playerASI && playerASI > 0) {
const w = isGoalkeeper ? 48717927500 : 263533760000;
const log27 = Math.log(Math.pow(2, 7));
const allSum = _skillsArr.reduce((s, v) => s + v, 0);
const rem = Math.round((Math.pow(2, Math.log(w * playerASI) / log27) - allSum) * 10) / 10;
const gs = _skillsArr.filter(v => v === 20).length;
const ns = _skillsArr.length - gs;
skillsC = _skillsArr.map(v => v === 20 ? 20 : v + (ns > 0 ? rem / ns : 0));
}
}
if (!skillsC) return;
const _NAMES = isGoalkeeper
? ['Strength', 'Stamina', 'Pace', 'Handling', 'One on ones', 'Reflexes', 'Aerial Ability', 'Jumping', 'Communication', 'Kicking', 'Throwing']
: ['Strength', 'Stamina', 'Pace', 'Marking', 'Tackling', 'Workrate', 'Positioning', 'Passing', 'Crossing', 'Technique', 'Heading', 'Finishing', 'Longshots', 'Set Pieces'];
const decMap = {};
_NAMES.forEach((name, i) => { decMap[name] = skillsC[i]; });
const renderDec = (v) => {
const floor = Math.floor(v);
const frac = v - floor;
if (floor >= 20) return `<span class="tmps-star" style="color:gold">★</span>`;
if (floor >= 19) {
const fracStr = frac > 0.005 ? `<span class="tmps-dec">.${Math.round(frac * 100).toString().padStart(2, '0')}</span>` : '';
return `<span class="tmps-star" style="color:silver">★${fracStr}</span>`;
}
const dispVal = frac > 0.005 ? `${floor}<span class="tmps-dec">.${Math.round(frac * 100).toString().padStart(2, '0')}</span>` : floor;
return `<span style="color:${skillColor(floor)}">${dispVal}</span>`;
};
document.querySelectorAll('.tmps-grid .tmps-row').forEach(row => {
const nameEl = row.querySelector('.tmps-name');
const valEl = row.querySelector('.tmps-val');
if (!nameEl || !valEl) return;
const name = nameEl.textContent.trim();
if (decMap[name] !== undefined) valEl.innerHTML = renderDec(decMap[name]);
});
}, 600);
}
} catch (e) { /* ignore */ }
});
};
/* ═══════════════════════════════════════════════════════════
███ MODULE: HISTORY
═══════════════════════════════════════════════════════════ */
const HistoryMod = (() => {
let historyData = null;
let activeTab = 'nat';
let root = null;
const q = (sel) => root ? root.querySelector(sel) : null;
const qa = (sel) => root ? root.querySelectorAll(sel) : [];
const extractClubName = (html) => { if (!html) return '-'; const m = html.match(/>([^<]+)<\/a>/); return m ? m[1] : (html === '-' ? '-' : html.replace(/<[^>]+>/g, '').trim() || '-'); };
const extractClubLink = (html) => { if (!html) return ''; const m = html.match(/href="([^"]+)"/); return m ? m[1] : ''; };
const fixDivFlags = (s) => s ? s.replace(/class='flag-img-([^']+)'/g, "class='flag-img-$1 tmsq-flag'") : '';
const ratingClass = (r) => { const v = parseFloat(r); if (isNaN(v) || v === 0) return 'tmph-r-avg'; if (v >= 6.0) return 'tmph-r-good'; if (v < 4.5) return 'tmph-r-low'; return 'tmph-r-avg'; };
const calcRating = (rating, games) => { const r = parseFloat(rating), g = parseInt(games); if (!r || !g || g === 0) return '-'; return (r / g).toFixed(2); };
const fmtNum = (n) => (n == null || n === '' || n === 0) ? '0' : Number(n).toLocaleString();
const buildNTTable = (nt) => {
if (!nt) return '<div class="tmph-empty">Not called up for any national team</div>';
const avgR = nt.matches > 0 ? nt.rating.toFixed(1) : '-';
const rc = ratingClass(avgR);
return `<table class="tmph-tbl"><thead><tr><th>Country</th><th></th><th class="c">Gp</th><th class="c">${isGoalkeeper ? 'Con' : 'G'}</th><th class="c">A</th><th class="c">Cards</th><th class="c">Rating</th><th class="c" style="color:#e8a832">Mom</th></tr></thead>`
+ `<tbody><tr><td><div class="tmph-club">${nt.country}</div></td><td class="tmph-div">${nt.flagHtml}</td><td class="c">${nt.matches}</td><td class="c" style="color:#6cc040;font-weight:600">${nt.goals}</td><td class="c" style="color:#5b9bff">${nt.assists}</td><td class="c" style="color:#fbbf24">${nt.cards}</td><td class="c ${rc}" style="font-weight:700">${avgR}</td><td class="c" style="color:#e8a832;font-weight:700">${nt.mom}</td></tr></tbody></table>`;
};
const buildTable = (rows) => {
if (!rows || !rows.length) return '<div class="tmph-empty">No history data available</div>';
const totalRow = rows.find(r => r.season === 'total');
const dataRows = rows.filter(r => r.season !== 'total');
let tb = '';
for (const row of dataRows) {
if (row.season === 'transfer') {
tb += `<tr class="tmph-transfer"><td colspan="8"><div class="tmph-xfer"><span class="tmph-xfer-arrow">⇄</span><span class="tmph-xfer-label">Transfer</span><span class="tmph-xfer-sum">${row.transfer}</span></div></td></tr>`;
continue;
}
const cn = extractClubName(row.klubnavn), cl = extractClubLink(row.klubnavn);
const cnH = cl ? `<a href="${cl}" target="_blank">${cn}</a>` : cn;
const divH = fixDivFlags(row.division_string);
const avgR = calcRating(row.rating, row.games);
tb += `<tr><td class="c" style="font-weight:700;color:#e8f5d8">${row.season}</td><td><div class="tmph-club">${cnH}</div></td><td class="tmph-div">${divH}</td><td class="c">${row.games || 0}</td><td class="c" style="color:#6cc040;font-weight:600">${isGoalkeeper ? (row.conceded || 0) : (row.goals || 0)}</td><td class="c" style="color:#5b9bff">${row.assists || 0}</td><td class="c" style="color:#fbbf24">${row.cards || 0}</td><td class="r ${ratingClass(avgR)}" style="font-weight:700">${avgR}</td></tr>`;
}
if (totalRow) {
const tr = calcRating(totalRow.rating, totalRow.games);
tb += `<tr class="tmph-tot"><td class="c" colspan="2" style="font-weight:800">Career Total</td><td></td><td class="c">${fmtNum(totalRow.games)}</td><td class="c" style="color:#6cc040">${fmtNum(isGoalkeeper ? totalRow.conceded : totalRow.goals)}</td><td class="c" style="color:#5b9bff">${fmtNum(totalRow.assists)}</td><td class="c" style="color:#fbbf24">${fmtNum(totalRow.cards)}</td><td class="r" style="color:#e0f0cc">${tr}</td></tr>`;
}
return `<table class="tmph-tbl"><thead><tr><th class="c" style="width:36px">S</th><th>Club</th><th>Division</th><th class="c">Gp</th><th class="c">${isGoalkeeper ? 'Con' : 'G'}</th><th class="c">A</th><th class="c">Cards</th><th class="r">Rating</th></tr></thead><tbody>${tb}</tbody></table>`;
};
const render = (container, data) => {
historyData = data.table;
activeTab = 'nat';
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.id = 'tmph-root';
container.appendChild(wrapper);
root = wrapper;
const TAB_LABELS = { nat: 'League', cup: 'Cup', int: 'International', total: 'Total' };
if (parsedNTData) TAB_LABELS.nt = 'National Team';
let tabsH = '';
for (const [key, label] of Object.entries(TAB_LABELS)) {
if (key === 'nt') {
tabsH += `<button class="tmph-tab ${key === activeTab ? 'active' : ''}" data-tab="${key}">${label}</button>`;
} else {
const rows = historyData[key] || [];
tabsH += `<button class="tmph-tab ${key === activeTab ? 'active' : ''}" data-tab="${key}" ${!rows.length ? 'style="opacity:0.4"' : ''}>${label}</button>`;
}
}
root.innerHTML = `<div class="tmph-wrap"><div class="tmph-tabs">${tabsH}</div><div class="tmph-body" id="tmph-tab-content">${buildTable(historyData[activeTab])}</div></div>`;
qa('.tmph-tab').forEach(tab => {
tab.addEventListener('click', () => {
const key = tab.dataset.tab;
if (key === 'nt') {
if (!parsedNTData) return;
} else {
if (!(historyData[key] || []).length) return;
}
activeTab = key;
qa('.tmph-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const c = q('#tmph-tab-content');
if (c) c.innerHTML = key === 'nt' ? buildNTTable(parsedNTData) : buildTable(historyData[key]);
});
});
};
const reRender = () => {
if (!root || !historyData) return;
const panel = root.closest('.tmpe-panel') || root.parentNode;
if (panel) render(panel, { table: historyData });
};
return { render, reRender };
})();
/* ═══════════════════════════════════════════════════════════
███ MODULE: SCOUT
═══════════════════════════════════════════════════════════ */
const ScoutMod = (() => {
let scoutData = null;
let root = null;
let activeTab = 'report';
let containerRef = null;
const q = (sel) => root ? root.querySelector(sel) : null;
const qa = (sel) => root ? root.querySelectorAll(sel) : [];
const POT_LABELS = ['', 'Queasy', 'Despicable', 'Miserable', 'Horrible', 'Wretched', 'Inadequate', 'Unimpressive', 'Mediocre', 'Standard', 'Modest', 'Okay', 'Decent', 'Fine', 'Good', 'Great', 'Excellent', 'Superb', 'Outstanding', 'Extraordinary', 'Wonderkid'];
const SPECIALTIES = ['None', 'Strength', 'Stamina', 'Pace', 'Marking', 'Tackling', 'Workrate', 'Positioning', 'Passing', 'Crossing', 'Technique', 'Heading', 'Finishing', 'Longshots', 'Set Pieces'];
const PEAK_SUMS = {
outfield: { phy: [64, 70, 74, 80], tac: [64, 70, 74, 80], tec: [96, 105, 111, 120] },
gk: { phy: [64, 70, 74, 80], tac: [50, 55, 60], tec: [68, 74, 80] }
};
const skillColor = (v) => { v = parseInt(v); if (v >= 19) return '#6cc040'; if (v >= 16) return '#80e048'; if (v >= 13) return '#c8e0b4'; if (v >= 10) return '#fbbf24'; if (v >= 7) return '#f97316'; return '#f87171'; };
const potColor = (pot) => { pot = parseInt(pot); if (pot >= 18) return '#6cc040'; if (pot >= 15) return '#5b9bff'; if (pot >= 12) return '#c8e0b4'; if (pot >= 9) return '#fbbf24'; return '#f87171'; };
const extractTier = (txt) => { if (!txt) return null; const m = txt.match(/\((\d)\/(\d)\)/); return m ? { val: parseInt(m[1]), max: parseInt(m[2]) } : null; };
const barColor = (val, max) => { const r = val / max; if (r >= 0.75) return '#6cc040'; if (r >= 0.5) return '#80e048'; if (r >= 0.25) return '#fbbf24'; return '#f87171'; };
const reachColor = (pct) => { if (pct >= 90) return '#6cc040'; if (pct >= 80) return '#80e048'; if (pct >= 70) return '#fbbf24'; if (pct >= 60) return '#f97316'; return '#f87171'; };
const fixFlags = (html) => html ? html.replace(/class='flag-img-([^']+)'/g, "class='flag-img-$1 tmsq-flag'").replace(/class="flag-img-([^"]+)"/g, 'class="flag-img-$1 tmsq-flag"') : '';
const bloomColor = (txt) => { if (!txt) return '#c8e0b4'; const t = txt.toLowerCase(); if (t === 'bloomed') return '#6cc040'; if (t.includes('late bloom')) return '#80e048'; if (t.includes('middle')) return '#fbbf24'; if (t.includes('starting')) return '#f97316'; if (t.includes('not bloomed')) return '#f87171'; return '#c8e0b4'; };
const cashColor = (c) => { if (!c) return '#c8e0b4'; if (c.includes('Astonishingly')) return '#6cc040'; if (c.includes('Incredibly')) return '#80e048'; if (c.includes('Very rich')) return '#a0d880'; if (c.includes('Rich')) return '#c8e0b4'; if (c.includes('Terrible')) return '#f87171'; if (c.includes('Poor')) return '#f97316'; return '#c8e0b4'; };
const cleanPeakText = (txt) => txt ? txt.replace(/^\s*-\s*/, '').replace(/\s*(physique|tactical ability|technical ability)\s*$/i, '').trim() : '';
const confPct = (skill) => Math.round((parseInt(skill) || 0) / 20 * 100);
const confBadge = (pct) => { const c = pct >= 90 ? '#6cc040' : pct >= 70 ? '#80e048' : pct >= 50 ? '#fbbf24' : '#f87171'; const bg = pct >= 90 ? 'rgba(108,192,64,.12)' : pct >= 70 ? 'rgba(128,224,72,.1)' : pct >= 50 ? 'rgba(251,191,36,.1)' : 'rgba(248,113,113,.1)'; return `<span class="tmsc-conf" style="color:${c};background:${bg}">${pct}%</span>`; };
const onlineDot = (on) => `<span class="tmsc-online ${on ? 'on' : 'off'}"></span>`;
const getScoutForReport = (r) => { if (!scoutData || !scoutData.scouts || !r.scoutid) return null; return Object.values(scoutData.scouts).find(s => String(s.id) === String(r.scoutid)) || null; };
const personalityTier = (key, value) => { value = parseInt(value) || 0; if (key === 'aggression') { if (value >= 17) return 'Alarmingly (5/5)'; if (value >= 13) return 'Somewhat (4/5)'; if (value >= 9) return 'Slightly (3/5)'; if (value >= 5) return 'Not particularly (2/5)'; return 'Not (1/5)'; } if (value >= 17) return 'Superb (5/5)'; if (value >= 13) return 'Good (4/5)'; if (value >= 9) return 'OK (3/5)'; if (value >= 5) return 'No special (2/5)'; return 'Bad (1/5)'; };
const getCurrentBloomStatus = (allReports, scouts) => {
if (!allReports || !allReports.length || playerAge === null) return { text: '-', certain: false, range: null };
const getDevSkill = (r) => {
if (!scouts) return 0;
const s = Object.values(scouts).find(sc => String(sc.id) === String(r.scoutid));
return s ? (parseInt(s.development) || 0) : 0;
};
const phaseFor = (start) => {
if (playerAge < start) return 'not';
if (playerAge >= start + 3) return 'done';
const y = playerAge - start;
return y < 1 ? 'starting' : y < 2 ? 'middle' : 'late';
};
const PHASE_LABEL = { not: 'Not bloomed', starting: 'Starting', middle: 'Middle', late: 'Late bloom', done: 'Bloomed' };
const statusFrom = (start) => {
const range = `${start}.0\u2013${start + 2}.11`;
const p = phaseFor(start);
if (p === 'done') return { text: 'Bloomed', certain: true, range: null };
const notBloomedTxt = bloomType ? `Not bloomed (${bloomType})` : 'Not bloomed';
const text = p === 'not' ? notBloomedTxt : p === 'starting' ? 'Starting to bloom' : p === 'middle' ? 'In the middle of his bloom' : 'In his late bloom';
return { text, certain: true, range };
};
/* --- STEP 1: Determine bloom TYPE from the most credible "Not bloomed" scout ---
Among all "Not bloomed" reports that include a type (Early/Normal/Late), the scout
with the highest dev skill wins. Ties broken by most-recent report date.
Weaker scouts' conflicting type claims are ignored for possibleStarts. */
let seenBloomed = false;
let bloomType = null, possibleStarts = null;
let bloomTypeBestDevSk = -1, bloomTypeBestDate = '';
for (const r of allReports) {
const bt = r.bloom_status_txt || '';
if (!bt || bt === '-') continue;
if (bt === 'Bloomed') { seenBloomed = true; continue; }
if (!bt.includes('Not bloomed')) continue;
const hasType = bt.includes('Early') || bt.includes('Normal') || bt.includes('Late');
if (!hasType) continue;
const devSk = getDevSkill(r);
const rDate = r.done || '';
if (devSk > bloomTypeBestDevSk || (devSk === bloomTypeBestDevSk && rDate > bloomTypeBestDate)) {
bloomTypeBestDevSk = devSk;
bloomTypeBestDate = rDate;
if (bt.includes('Early')) { bloomType = 'Early'; possibleStarts = [16, 17]; }
else if (bt.includes('Normal')) { bloomType = 'Normal'; possibleStarts = [18, 19]; }
else { bloomType = 'Late'; possibleStarts = [20, 21, 22]; }
}
}
/* --- STEP 2: 75% threshold for phase reports within the bloom window ---
Phase reports are only trusted within the possible bloom window if the scout
has dev skill >= 15 (75%). If no scout reaches 15, the best available is used.
Outside the bloom window, phases are accepted normally (inconsistent ones rejected). */
const MIN_PHASE_DEV = 15;
const bloomWinMin = possibleStarts ? possibleStarts[0] : Infinity;
const bloomWinMax = possibleStarts ? possibleStarts[possibleStarts.length - 1] + 3 : -Infinity;
let maxPhaseDevSkInWindow = 0;
for (const r of allReports) {
const bt = r.bloom_status_txt || '';
if (!bt || bt.includes('Not bloomed') || bt === 'Bloomed' || bt === '-') continue;
const rAge = parseFloat(r.report_age) || 0;
if (rAge < bloomWinMin || rAge >= bloomWinMax) continue;
const devSk = getDevSkill(r);
if (devSk > maxPhaseDevSkInWindow) maxPhaseDevSkInWindow = devSk;
}
const phaseThreshold = maxPhaseDevSkInWindow >= MIN_PHASE_DEV ? MIN_PHASE_DEV : maxPhaseDevSkInWindow;
/* --- STEP 3: Find best phase report ---
Must satisfy the threshold (when inside bloom window) and must be consistent
with the determined bloom type (implied start must be in possibleStarts). */
let bestPhase = null;
for (const r of allReports) {
const bt = r.bloom_status_txt || '';
if (!bt || bt.includes('Not bloomed') || bt === 'Bloomed' || bt === '-') continue;
const rAge = parseFloat(r.report_age) || 0;
const rFloor = Math.floor(rAge);
const devSk = getDevSkill(r);
let candidateStart = null;
if (bt.includes('Starting') && !bt.includes('Not')) candidateStart = rFloor;
else if (bt.toLowerCase().includes('middle')) candidateStart = rFloor - 1;
else if (bt.toLowerCase().includes('late bloom')) candidateStart = rFloor - 2;
if (candidateStart === null) continue;
const inWindow = possibleStarts && rAge >= bloomWinMin && rAge < bloomWinMax;
/* Reject if inside window but below quality threshold */
if (inWindow && devSk < phaseThreshold) continue;
/* Reject if implied start conflicts with determined bloom type */
if (possibleStarts && !possibleStarts.includes(candidateStart)) continue;
if (!bestPhase || devSk > bestPhase.devSkill) {
bestPhase = { knownStart: candidateStart, devSkill: devSk };
}
}
/* --- STEP 4: Dominated check ---
If a more credible scout said "Not bloomed" at an age >= the phase's implied start,
the phase report is contradicted and discarded. */
if (bestPhase) {
let dominated = false;
for (const r of allReports) {
const bt = r.bloom_status_txt || '';
if (!bt.includes('Not bloomed')) continue;
const rAge = parseFloat(r.report_age) || 0;
const devSk = getDevSkill(r);
if (rAge >= bestPhase.knownStart && devSk > bestPhase.devSkill) {
dominated = true;
break;
}
}
if (!dominated) return statusFrom(bestPhase.knownStart);
}
if (seenBloomed) return { text: 'Bloomed', certain: true, range: null };
if (!possibleStarts) return { text: '-', certain: false, range: null };
/* --- STEP 5: Narrow possibleStarts using "Not bloomed" age observations ---
All "Not bloomed" reports contribute their age for narrowing (type claim ignored
here — we already determined the type; only the age observation matters). */
for (const r of allReports) {
const bt = r.bloom_status_txt || '';
if (!bt.includes('Not bloomed')) continue;
const rAge = parseFloat(r.report_age) || 0;
possibleStarts = possibleStarts.filter(s => s > rAge);
}
if (possibleStarts.length === 0) return { text: '-', certain: false, range: null };
if (possibleStarts.length === 1) return statusFrom(possibleStarts[0]);
/* Multiple possible starts — determine phase for each */
const rangeStr = possibleStarts.map(s => `${s}.0\u2013${s + 2}.11`).join(' or ');
const phases = possibleStarts.map(s => phaseFor(s));
const unique = [...new Set(phases)];
const notBloomedLabel = bloomType ? `Not bloomed (${bloomType})` : 'Not bloomed';
if (unique.length === 1) {
if (unique[0] === 'not') return { text: notBloomedLabel, certain: true, range: rangeStr };
if (unique[0] === 'done') return { text: 'Bloomed', certain: true, range: null };
return { text: PHASE_LABEL[unique[0]], certain: true, range: rangeStr };
}
const allBlooming = phases.every(p => p !== 'not' && p !== 'done');
if (allBlooming) {
const labels = unique.map(p => PHASE_LABEL[p]).join(' or ');
return { text: 'Blooming', certain: true, phases: labels, range: rangeStr };
}
let parts = [];
if (phases.includes('not')) parts.push(notBloomedLabel);
const bloomPhases = unique.filter(p => p !== 'not' && p !== 'done');
if (bloomPhases.length) parts.push('Blooming (' + bloomPhases.map(p => PHASE_LABEL[p]).join('/') + ')');
if (phases.includes('done')) parts.push('Bloomed');
return { text: parts.join(' or '), certain: false, range: rangeStr };
};
const greenStarsHtml = (rec) => { rec = parseFloat(rec) || 0; const full = Math.floor(rec); const half = (rec % 1) >= 0.25; let h = ''; for (let i = 0; i < full; i++)h += '<span class="tmsc-star-green">★</span>'; if (half) h += '<span class="tmsc-star-green-half">★</span>'; const e = 5 - full - (half ? 1 : 0); for (let i = 0; i < e; i++)h += '<span class="tmsc-star-empty">★</span>'; return h; };
const combinedStarsHtml = (current, potMax) => {
current = parseFloat(current) || 0; potMax = parseFloat(potMax) || 0;
if (potMax < current) potMax = current;
let h = '';
for (let i = 1; i <= 5; i++) {
if (i <= current) h += '<span class="tmsc-star-full">★</span>';
else if (i - 0.5 <= current && current < i) {
/* Half gold — check if potMax fills the other half */
if (potMax >= i) h += '<span class="tmsc-star-split">★</span>';
else h += '<span class="tmsc-star-half">★</span>';
}
else if (i <= potMax) h += '<span class="tmsc-star-green">★</span>';
else if (i - 0.5 <= potMax && potMax < i) h += '<span class="tmsc-star-green-half">★</span>';
else h += '<span class="tmsc-star-empty">★</span>';
}
return h;
};
/* ── Build Scouts Table ── */
const buildScoutsTable = (scouts) => {
if (!scouts || !Object.keys(scouts).length) return '<div class="tmsc-empty">No scouts hired</div>';
const skills = ['seniors', 'youths', 'physical', 'tactical', 'technical', 'development', 'psychology'];
let rows = '';
for (const s of Object.values(scouts)) {
let sc = ''; for (const sk of skills) { const v = parseInt(s[sk]) || 0; sc += `<td class="c" style="color:${skillColor(v)};font-weight:600">${v}</td>`; }
const bc = s.away ? 'tmsc-send-btn tmsc-away' : 'tmsc-send-btn';
const bl = s.away ? (s.returns || 'Away') : 'Send';
rows += `<tr><td style="font-weight:600;color:#e8f5d8;white-space:nowrap">${s.name} ${s.surname}</td>${sc}<td class="c"><button class="${bc}" data-scout-id="${s.id}" ${s.away ? 'disabled title="' + (s.returns || '') + '"' : ''}>${bl}</button></td></tr>`;
}
return `<table class="tmsc-tbl"><thead><tr><th>Name</th><th class="c">Sen</th><th class="c">Yth</th><th class="c">Phy</th><th class="c">Tac</th><th class="c">Tec</th><th class="c">Dev</th><th class="c">Psy</th><th class="c"></th></tr></thead><tbody>${rows}</tbody></table>`;
};
/* ── Build Report Card ── */
const buildReportCard = (r) => {
const pot = parseInt(r.old_pot) || 0;
const potStarsVal = (parseFloat(r.potential) || 0) / 2;
if (r.scout_name === 'YD' || r.scoutid === '0') {
return `<div class="tmsc-report"><div class="tmsc-report-header"><div><div class="tmsc-stars">${greenStarsHtml(potStarsVal)}</div><div class="tmsc-report-scout">Youth Development<span class="tmsc-yd-badge">YD</span></div></div><div class="tmsc-report-date">${r.done || '-'}</div></div><div class="tmsc-report-grid"><div class="tmsc-report-item wide"><span class="tmsc-report-label">Potential</span><span class="tmsc-report-value" style="color:${potColor(pot)}">${pot}</span></div><div class="tmsc-report-item wide"><span class="tmsc-report-label">Age at report</span><span class="tmsc-report-value">${r.report_age || '-'}</span></div></div></div>`;
}
const spec = parseInt(r.specialist) || 0; const specLabel = SPECIALTIES[spec] || 'None';
const scout = getScoutForReport(r);
let potConf = null, bloomConf = null, phyConf = null, tacConf = null, tecConf = null, psyConf = null, specConf = null;
if (scout) { const age = parseInt(r.report_age) || 0; const senYth = age < 20 ? (parseInt(scout.youths) || 0) : (parseInt(scout.seniors) || 0); const dev = parseInt(scout.development) || 0; potConf = confPct(Math.min(senYth, dev)); bloomConf = confPct(dev); phyConf = confPct(parseInt(scout.physical) || 0); tacConf = confPct(parseInt(scout.tactical) || 0); tecConf = confPct(parseInt(scout.technical) || 0); psyConf = confPct(parseInt(scout.psychology) || 0); if (spec > 0) { const phyS = [1, 2, 3, 11]; const tacS = [4, 5, 6, 7]; if (phyS.includes(spec)) specConf = phyConf; else if (tacS.includes(spec)) specConf = tacConf; else specConf = tecConf; } }
const peaks = [{ label: 'Physique', text: cleanPeakText(r.peak_phy_txt), conf: phyConf }, { label: 'Tactical', text: cleanPeakText(r.peak_tac_txt), conf: tacConf }, { label: 'Technical', text: cleanPeakText(r.peak_tec_txt), conf: tecConf }];
let peaksH = '';
for (const p of peaks) { const tier = extractTier(p.text); if (tier) { const pct = (tier.val / tier.max) * 100; const c = barColor(tier.val, tier.max); peaksH += `<div class="tmsc-bar-row"><span class="tmsc-bar-label">${p.label}</span><div class="tmsc-bar-track"><div class="tmsc-bar-fill" style="width:${pct}%;background:${c}"></div></div><span class="tmsc-bar-text" style="color:${c}">${tier.val}/${tier.max}</span>${p.conf !== null ? confBadge(p.conf) : ''}</div>`; } }
const charisma = parseInt(r.charisma) || 0; const professionalism = parseInt(r.professionalism) || 0; const aggression = parseInt(r.aggression) || 0;
const pers = [{ label: 'Leadership', key: 'leadership', value: charisma }, { label: 'Professionalism', key: 'professionalism', value: professionalism }, { label: 'Aggression', key: 'aggression', value: aggression }];
let persH = '';
for (const p of pers) { const pct = (p.value / 20) * 100; const c = skillColor(p.value); persH += `<div class="tmsc-bar-row"><span class="tmsc-bar-label">${p.label}</span><div class="tmsc-bar-track"><div class="tmsc-bar-fill" style="width:${pct}%;background:${c}"></div></div><span class="tmsc-bar-text" style="color:${c}">${p.value}</span>${psyConf !== null ? confBadge(psyConf) : ''}</div>`; }
return `<div class="tmsc-report"><div class="tmsc-report-header"><div><div class="tmsc-stars">${combinedStarsHtml(r.rec, potStarsVal)}</div><div class="tmsc-report-scout">${r.scout_name || 'Unknown'}</div></div><div class="tmsc-report-date">${r.done || '-'}</div></div><div class="tmsc-report-grid"><div class="tmsc-report-item"><span class="tmsc-report-label">Potential</span><span class="tmsc-report-value" style="color:${potColor(pot)}">${pot}${potConf !== null ? confBadge(potConf) : ''}</span></div><div class="tmsc-report-item"><span class="tmsc-report-label">Age</span><span class="tmsc-report-value">${r.report_age || '-'}</span></div><div class="tmsc-report-item"><span class="tmsc-report-label">Bloom</span><span class="tmsc-report-value" style="color:${bloomColor(r.bloom_status_txt)}">${r.bloom_status_txt || '-'}${bloomConf !== null ? confBadge(bloomConf) : ''}</span></div><div class="tmsc-report-item"><span class="tmsc-report-label">Development</span><span class="tmsc-report-value">${r.dev_status || '-'}${bloomConf !== null ? confBadge(bloomConf) : ''}</span></div><div class="tmsc-report-item wide"><span class="tmsc-report-label">Specialty</span><span class="tmsc-report-value" style="color:${spec > 0 ? '#fbbf24' : '#5a7a48'}">${specLabel}${specConf !== null ? confBadge(specConf) : ''}</span></div></div><div><div class="tmsc-section-title">Peak Development</div>${peaksH}</div><div><div class="tmsc-section-title">Personality</div>${persH}</div></div>`;
};
/* ── Build Report Tab ── */
const buildReport = (reports, error) => {
let h = '';
if (error) { const msg = error === 'multi_scout' ? 'This scout is already on a mission' : error === 'multi_bid' ? 'Scout already scouting this player' : error; h += `<div class="tmsc-error">${msg}</div>`; }
if (!reports || !reports.length) return h + '<div class="tmsc-empty">No scout reports available</div>';
if (reports.length > 1) h += `<div class="tmsc-report-count">${reports.length} Reports</div>`;
for (let i = 0; i < reports.length; i++) { if (i > 0) h += '<hr class="tmsc-report-divider">'; h += buildReportCard(reports[i]); }
return h;
};
/* ── Build Interested ── */
const buildInterested = (interested) => {
if (!interested || !interested.length) return '<div class="tmsc-empty">No interested clubs</div>';
let rows = '';
for (const c of interested) { const ch = fixFlags(c.club_link || ''); const lh = fixFlags(c.league_link || ''); const cc = cashColor(c.cash); rows += `<tr><td class="tmsc-club-cell">${ch} ${onlineDot(c.online)}</td><td class="tmsc-league-cell">${lh}</td><td style="color:${cc};font-weight:600;font-size:11px">${c.cash}</td></tr>`; }
return `<table class="tmsc-tbl"><thead><tr><th>Club</th><th>League</th><th>Financial</th></tr></thead><tbody>${rows}</tbody></table>`;
};
/* ── Render ── */
const render = (container, data) => {
containerRef = container;
scoutData = data;
activeTab = (data.reports && data.reports.length) ? 'report' : 'scouts';
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.id = 'tmsc-root';
container.appendChild(wrapper);
root = wrapper;
const TAB_LABELS = { report: 'Report', scouts: 'Scouts', interested: 'Interested' };
let tabsH = '';
for (const [key, label] of Object.entries(TAB_LABELS)) {
let hasData = true;
if (key === 'report') hasData = data.reports && data.reports.length > 0;
if (key === 'interested') hasData = data.interested && data.interested.length > 0;
if (key === 'scouts') hasData = data.scouts && Object.keys(data.scouts).length > 0;
tabsH += `<button class="tmsc-tab ${key === activeTab ? 'active' : ''}" data-tab="${key}" ${!hasData ? 'style="opacity:0.4"' : ''}>${label}</button>`;
}
const getContent = (tab) => { switch (tab) { case 'report': return buildReport(scoutData.reports, scoutData.error); case 'scouts': return buildScoutsTable(scoutData.scouts); case 'interested': return buildInterested(scoutData.interested); default: return ''; } };
root.innerHTML = `<div class="tmsc-wrap"><div class="tmsc-tabs">${tabsH}</div><div class="tmsc-body" id="tmsc-tab-content">${getContent(activeTab)}</div></div>`;
bindTabs();
bindSendButtons();
};
const bindTabs = () => {
qa('.tmsc-tab').forEach(tab => {
tab.addEventListener('click', () => {
const key = tab.dataset.tab; activeTab = key;
qa('.tmsc-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active');
const c = q('#tmsc-tab-content'); if (!c) return;
switch (key) { case 'report': c.innerHTML = buildReport(scoutData.reports, scoutData.error); break; case 'scouts': c.innerHTML = buildScoutsTable(scoutData.scouts); bindSendButtons(); break; case 'interested': c.innerHTML = buildInterested(scoutData.interested); break; }
});
});
};
const bindSendButtons = () => {
qa('.tmsc-send-btn').forEach(btn => {
if (btn.disabled) return;
btn.addEventListener('click', () => {
const scoutId = btn.dataset.scoutId; btn.disabled = true; btn.textContent = '...';
$.post('/ajax/players_get_info.ajax.php', { player_id: PLAYER_ID, type: 'scout', scout_id: scoutId, show_non_pro_graphs: true }, 'json')
.done((res) => { try { const d = typeof res === 'object' ? res : JSON.parse(res); if (d && (d.scouts || d.reports)) { render(containerRef, d); } else { btn.textContent = 'Sent'; btn.style.background = '#274a18'; btn.style.color = '#6cc040'; } } catch (e) { btn.textContent = 'Sent'; btn.style.background = '#274a18'; btn.style.color = '#6cc040'; } })
.fail(() => { btn.textContent = 'Error'; btn.style.color = '#f87171'; setTimeout(() => { btn.textContent = 'Send'; btn.disabled = false; btn.style.color = ''; }, 2000); });
});
});
};
const reRender = () => { if (containerRef && scoutData) render(containerRef, scoutData); };
const getEstimateHtml = (data) => {
const hasScouts = data && data.reports && data.reports.length && data.scouts;
let scouts = {}, regular = [];
let potPick = null, bloomPick = null, phyPick = null, tacPick = null, tecPick = null, psyPick = null;
if (hasScouts) {
scouts = data.scouts;
regular = data.reports.filter(r => r.scout_name !== 'YD' && r.scoutid !== '0');
if (regular.length) {
const scoutSkill = (r, field) => { const s = Object.values(scouts).find(s => String(s.id) === String(r.scoutid)); return s ? (parseInt(s[field]) || 0) : 0; };
const pickBest = (field) => { let best = null, bs = -1, bd = ''; for (const r of regular) { const sk = scoutSkill(r, field); const d = r.done || ''; if (sk > bs || (sk === bs && d > bd)) { best = r; bs = sk; bd = d; } } return best ? { report: best, conf: confPct(bs) } : null; };
const pickBestPot = () => { let best = null, bs = -1, bd = ''; for (const r of regular) { const s = Object.values(scouts).find(s => String(s.id) === String(r.scoutid)); const age = parseInt(r.report_age) || 0; let sk = 0; if (s) { const senYth = age < 20 ? (parseInt(s.youths) || 0) : (parseInt(s.seniors) || 0); const dev = parseInt(s.development) || 0; sk = Math.min(senYth, dev); } const d = r.done || ''; if (sk > bs || (sk === bs && d > bd)) { best = r; bs = sk; bd = d; } } return best ? { report: best, conf: confPct(bs) } : null; };
potPick = pickBestPot(); bloomPick = pickBest('development'); phyPick = pickBest('physical'); tacPick = pickBest('tactical'); tecPick = pickBest('technical'); psyPick = pickBest('psychology');
}
}
/* If no scouts AND no skill sums, nothing to show */
if (!regular.length && !playerSkillSums) return '';
const pot = potPick ? parseInt(potPick.report.old_pot) || 0 : 0;
const potStarsVal = potPick ? (parseFloat(potPick.report.potential) || 0) / 2 : 0;
const rec = potPick ? parseFloat(potPick.report.rec) || 0 : 0;
const bloomResult = getCurrentBloomStatus(regular, scouts);
const bloomTxt = bloomResult.text || '-';
const devTxt = bloomPick ? bloomPick.report.dev_status : '-';
let specVal = 0, specLabel = 'None', specConf = null;
for (const pick of [phyPick, tacPick, tecPick]) { if (pick) { const s = parseInt(pick.report.specialist) || 0; if (s > 0) { specVal = s; specLabel = SPECIALTIES[s] || 'None'; specConf = pick.conf; break; } } }
const cb = (conf) => {
if (conf === null) return '';
if (conf === 0) return '<span class="tmbe-conf" style="background:rgba(90,122,72,.15);color:#5a7a48">?</span>';
let bg, clr;
if (conf >= 90) { bg = 'rgba(108,192,64,.15)'; clr = '#6cc040'; }
else if (conf >= 70) { bg = 'rgba(251,191,36,.12)'; clr = '#fbbf24'; }
else { bg = 'rgba(248,113,113,.1)'; clr = '#f87171'; }
return `<span class="tmbe-conf" style="background:${bg};color:${clr}">${conf}%</span>`;
};
/* Peak bars with reach */
const peaks = [
{ label: 'Physique', text: phyPick ? cleanPeakText(phyPick.report.peak_phy_txt) : '', conf: phyPick ? phyPick.conf : null, cat: 'phy' },
{ label: 'Tactical', text: tacPick ? cleanPeakText(tacPick.report.peak_tac_txt) : '', conf: tacPick ? tacPick.conf : null, cat: 'tac' },
{ label: 'Technical', text: tecPick ? cleanPeakText(tecPick.report.peak_tec_txt) : '', conf: tecPick ? tecPick.conf : null, cat: 'tec' }
];
let peaksH = '';
for (const p of peaks) {
const isGK = isGoalkeeper;
const peakArr = (isGK ? PEAK_SUMS.gk : PEAK_SUMS.outfield)[p.cat];
if (!peakArr) continue;
const maxPeakSum = peakArr[peakArr.length - 1];
const tier = extractTier(p.text);
const curSum = playerSkillSums ? playerSkillSums[p.cat] : null;
if (tier && curSum !== null) {
/* Have both scout data and skill sums */
const peakSum = peakArr[tier.val - 1];
const peakPct = (peakSum / maxPeakSum) * 100;
const curPct = (curSum / maxPeakSum) * 100;
const c = barColor(tier.val, tier.max);
const rPct = Math.round(curSum / peakSum * 100); const rC = reachColor(rPct);
const mPct = Math.round(curSum / maxPeakSum * 100); const mC = reachColor(mPct);
const reachLbl = `<div class="tmbe-peak-reach"><span class="tmbe-reach-item"><span class="tmbe-reach-tag">Peak</span><span style="color:${rC}">${rPct}%</span><span style="color:#90b878;font-weight:400;font-size:9px">(${curSum}/${peakSum})</span></span><span class="tmbe-reach-item"><span class="tmbe-reach-tag">Max</span><span style="color:${mC}">${mPct}%</span><span style="color:#90b878;font-weight:400;font-size:9px">(${curSum}/${maxPeakSum})</span></span></div>`;
peaksH += `<div class="tmbe-item tmbe-peak-item"><div class="tmbe-peak-header"><span class="tmbe-lbl">${p.label}</span><span class="tmbe-val" style="color:${c}">${tier.val}/${tier.max}${p.conf !== null ? cb(p.conf) : ''}</span></div>${reachLbl}<div class="tmbe-bar-track"><div class="tmbe-bar-fill" style="width:${peakPct}%;background:${c};opacity:0.35"></div><div class="tmbe-bar-fill-reach" style="width:${curPct}%;background:${rC}"></div></div></div>`;
} else if (curSum !== null) {
/* No scout but have skill sums — show current only with ? */
const mPct = Math.round(curSum / maxPeakSum * 100);
const curPct = (curSum / maxPeakSum) * 100;
const mC = reachColor(mPct);
const reachLbl = `<div class="tmbe-peak-reach"><span class="tmbe-reach-item"><span class="tmbe-reach-tag">Max</span><span style="color:${mC}">${mPct}%</span><span style="color:#90b878;font-weight:400;font-size:9px">(${curSum}/${maxPeakSum})</span></span></div>`;
peaksH += `<div class="tmbe-item tmbe-peak-item"><div class="tmbe-peak-header"><span class="tmbe-lbl">${p.label}</span><span class="tmbe-val" style="color:#5a7a48">?</span></div>${reachLbl}<div class="tmbe-bar-track"><div class="tmbe-bar-fill" style="width:${curPct}%;background:${mC}"></div></div></div>`;
} else if (tier) {
/* Scout data but no skill sums */
const peakSum = peakArr[tier.val - 1];
const peakPct = (peakSum / maxPeakSum) * 100;
const c = barColor(tier.val, tier.max);
peaksH += `<div class="tmbe-item tmbe-peak-item"><div class="tmbe-peak-header"><span class="tmbe-lbl">${p.label}</span><span class="tmbe-val" style="color:${c}">${tier.val}/${tier.max}${p.conf !== null ? cb(p.conf) : ''}</span></div><div class="tmbe-bar-track"><div class="tmbe-bar-fill" style="width:${peakPct}%;background:${c}"></div></div></div>`;
}
}
/* Personality */
let persH = '';
if (psyPick) {
const pers = [{ label: 'Leadership', value: parseInt(psyPick.report.charisma) || 0 }, { label: 'Professionalism', value: parseInt(psyPick.report.professionalism) || 0 }, { label: 'Aggression', value: parseInt(psyPick.report.aggression) || 0 }];
for (const p of pers) { const pct = (p.value / 20) * 100; const c = skillColor(p.value); persH += `<div class="tmbe-bar-row"><div class="tmbe-bar-header"><span class="tmbe-bar-label">${p.label}</span><div class="tmbe-bar-right"><span class="tmbe-bar-val" style="color:${c}">${p.value}</span>${cb(psyPick.conf)}</div></div><div class="tmbe-bar-track"><div class="tmbe-bar-fill" style="width:${pct}%;background:${c}"></div></div></div>`; }
} else if (!hasScouts) {
const persLabels = ['Leadership', 'Professionalism', 'Aggression'];
for (const lbl of persLabels) { persH += `<div class="tmbe-bar-row"><div class="tmbe-bar-header"><span class="tmbe-bar-label">${lbl}</span><div class="tmbe-bar-right"><span class="tmbe-bar-val" style="color:#5a7a48">?</span></div></div></div>`; }
}
const currentRating = playerRecSort !== null ? playerRecSort : rec;
const hasData = regular.length > 0;
let h = `<div class="tmbe-card"><div class="tmbe-title">Best Estimate<span class="tmbe-title-stars">${combinedStarsHtml(currentRating, potStarsVal)}</span></div>`;
h += `<div class="tmbe-grid">`;
h += `<div class="tmbe-item"><span class="tmbe-lbl">Potential</span><span class="tmbe-val" style="color:${hasData ? potColor(pot) : '#5a7a48'}">${hasData ? pot : '?'}${potPick ? cb(potPick.conf) : ''}</span></div>`;
const beBloomClr = hasData ? (bloomResult.certain ? (bloomResult.phases ? '#80e048' : bloomColor(bloomTxt)) : '#fbbf24') : '#5a7a48';
const beBloomTxt = hasData ? (!bloomResult.certain && !bloomResult.phases ? (bloomResult.text || bloomResult.range || '-') : bloomTxt) : '?';
let beBloomSub = '';
if (hasData && bloomResult.phases) beBloomSub += `<span style="display:block;font-size:9px;color:#90b878;font-weight:600;margin-top:1px">${bloomResult.phases}</span>`;
if (hasData && bloomResult.range && bloomTxt !== 'Bloomed') beBloomSub += `<span style="display:block;font-size:9px;color:#6a9a58;font-weight:600;margin-top:1px">${bloomResult.range}</span>`;
h += `<div class="tmbe-item"><span class="tmbe-lbl">Bloom</span><span class="tmbe-val" style="color:${beBloomClr}">${beBloomTxt}${bloomPick ? cb(bloomPick.conf) : ''}${beBloomSub}</span></div>`;
h += `<div class="tmbe-item"><span class="tmbe-lbl">Development</span><span class="tmbe-val" style="color:${hasData ? '#e8f5d8' : '#5a7a48'}">${hasData ? devTxt : '?'}${bloomPick ? cb(bloomPick.conf) : ''}</span></div>`;
h += `<div class="tmbe-item"><span class="tmbe-lbl">Specialty</span><span class="tmbe-val" style="color:${hasData ? (specVal > 0 ? '#fbbf24' : '#5a7a48') : '#5a7a48'}">${hasData ? specLabel : '?'}${specConf !== null ? cb(specConf) : ''}</span></div>`;
if (peaksH) h += `<div class="tmbe-divider">Peak Development</div>${peaksH}`;
h += `</div>`;
if (persH) h += `<div class="tmbe-divider">Personality</div>${persH}`;
h += `</div>`;
return h;
};
return { render, reRender, getEstimateHtml };
})();
/* ═══════════════════════════════════════════════════════════
███ MODULE: TRAINING (Shadow DOM)
═══════════════════════════════════════════════════════════ */
const TrainingMod = (() => {
const TRAINING_TYPES = { '1': 'Technical', '2': 'Fitness', '3': 'Tactical', '4': 'Finishing', '5': 'Defending', '6': 'Wings' };
const MAX_PTS = 4;
const SKILL_NAMES = { strength: 'Strength', stamina: 'Stamina', pace: 'Pace', marking: 'Marking', tackling: 'Tackling', workrate: 'Workrate', positioning: 'Positioning', passing: 'Passing', crossing: 'Crossing', technique: 'Technique', heading: 'Heading', finishing: 'Finishing', longshots: 'Longshots', set_pieces: 'Set Pieces' };
const COLORS = ['#6cc040', '#5b9bff', '#fbbf24', '#f97316', '#a78bfa', '#f87171'];
const TMT_CSS = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:host{display:block;all:initial;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#c8e0b4;line-height:1.4}
.tmt-wrap{background:transparent;border-radius:0;border:none;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#c8e0b4;font-size:13px}
.tmt-tabs{display:flex;gap:6px;padding:10px 14px 6px;flex-wrap:wrap}
.tmt-tab{padding:4px 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.4px;color:#90b878;cursor:pointer;border-radius:4px;background:rgba(42,74,28,.3);border:1px solid rgba(42,74,28,.6);transition:all 0.15s;font-family:inherit;-webkit-appearance:none;appearance:none}
.tmt-tab:hover{color:#c8e0b4;background:rgba(42,74,28,.5);border-color:#3d6828}.tmt-tab.active{color:#e8f5d8;background:#305820;border-color:#3d6828}
.tmt-pro{display:inline-block;background:rgba(108,192,64,.2);color:#6cc040;padding:1px 5px;border-radius:3px;font-size:9px;font-weight:800;letter-spacing:0.5px;margin-left:4px;vertical-align:middle}
.tmt-body{padding:10px 14px 16px;font-size:13px}
.tmt-sbar{display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(42,74,28,.35);border:1px solid #2a4a1c;border-radius:6px;margin-bottom:10px;flex-wrap:wrap}
.tmt-sbar-label{color:#6a9a58;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.4px}
.tmt-sbar select{background:rgba(42,74,28,.4);color:#c8e0b4;border:1px solid #2a4a1c;padding:4px 8px;border-radius:6px;font-size:11px;cursor:pointer;font-weight:600;font-family:inherit}
.tmt-sbar select:focus{border-color:#6cc040;outline:none}
.tmt-cards{display:flex;gap:14px;margin-bottom:12px;padding:12px 14px;background:rgba(42,74,28,.3);border:1px solid #2a4a1c;border-radius:8px;flex-wrap:wrap}
.tmt-cards>div{min-width:80px}.tmt-cards .lbl{color:#6a9a58;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;font-weight:700}.tmt-cards .val{font-size:16px;font-weight:800;margin-top:3px}
.tmt-pool-bar{height:6px;background:rgba(0,0,0,.2);border-radius:3px;overflow:hidden;display:flex;gap:1px;margin-top:8px}
.tmt-pool-seg{height:100%;border-radius:3px;transition:width 0.3s ease;min-width:0}.tmt-pool-rem{flex:1;height:100%}
.tmt-tbl{width:100%;border-collapse:collapse;font-size:11px;margin-bottom:8px}
.tmt-tbl th{padding:6px;font-size:10px;font-weight:700;color:#6a9a58;text-transform:uppercase;letter-spacing:0.4px;border-bottom:1px solid #2a4a1c;text-align:left;white-space:nowrap}.tmt-tbl th.c{text-align:center}
.tmt-tbl td{padding:5px 6px;border-bottom:1px solid rgba(42,74,28,.4);color:#c8e0b4;font-variant-numeric:tabular-nums;vertical-align:middle}.tmt-tbl td.c{text-align:center}
.tmt-tbl tr:hover{background:rgba(255,255,255,.03)}
.tmt-clr-bar{width:3px;padding:0;border-radius:2px}
.tmt-dots{display:inline-flex;gap:3px;align-items:center}
.tmt-dot{width:18px;height:18px;border-radius:50%;transition:all 0.15s;cursor:pointer;display:inline-block}
.tmt-dot-empty{background:rgba(255,255,255,.06);border:1px solid rgba(42,74,28,.6)}.tmt-dot-empty:hover{background:rgba(255,255,255,.12);border-color:rgba(42,74,28,.9)}
.tmt-dot-filled{box-shadow:0 0 6px rgba(0,0,0,.25),inset 0 1px 0 rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.15)}
.tmt-btn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:rgba(42,74,28,.4);border:1px solid #2a4a1c;border-radius:6px;color:#8aac72;font-size:14px;font-weight:700;cursor:pointer;transition:all 0.15s;padding:0;line-height:1;font-family:inherit;-webkit-appearance:none;appearance:none}
.tmt-btn:hover:not(:disabled){background:rgba(42,74,28,.7);color:#c8e0b4}.tmt-btn:active:not(:disabled){background:rgba(74,144,48,.3)}.tmt-btn:disabled{opacity:0.2;cursor:not-allowed}
.tmt-pts{font-size:13px;font-weight:800;color:#e8f5d8;min-width:14px;text-align:center}
.tmt-footer{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:rgba(42,74,28,.3);border:1px solid #2a4a1c;border-radius:8px;gap:10px;flex-wrap:wrap}
.tmt-footer-total .lbl{color:#6a9a58;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;font-weight:700}
.tmt-footer-total .val{font-size:18px;font-weight:900;color:#e8f5d8;letter-spacing:-0.5px}.tmt-footer-total .dim{color:#6a9a58;font-weight:600}
.tmt-footer-acts{display:flex;gap:6px}
.tmt-act{display:inline-block;padding:4px 14px;background:rgba(42,74,28,.4);border:1px solid #2a4a1c;border-radius:6px;color:#8aac72;font-size:11px;font-weight:600;cursor:pointer;transition:all 0.15s;text-transform:uppercase;letter-spacing:0.4px;font-family:inherit;-webkit-appearance:none;appearance:none}
.tmt-act:hover{background:rgba(42,74,28,.7);color:#c8e0b4}
.tmt-act.dng:hover{border-color:rgba(248,113,113,.3);color:#f87171;background:rgba(248,113,113,.08)}
.tmt-saved{display:inline-block;font-size:10px;font-weight:700;color:#6cc040;background:rgba(108,192,64,.12);border:1px solid rgba(108,192,64,.25);border-radius:4px;padding:2px 8px;margin-left:8px;opacity:0;transition:opacity 0.3s;vertical-align:middle}.tmt-saved.vis{opacity:1}
.tmt-custom-off .tmt-cards{display:none}.tmt-custom-off .tmt-tbl{display:none}.tmt-custom-off .tmt-footer{display:none}
.tmt-wrap:not(.tmt-custom-off) .tmt-sbar{display:none}`;
let trainingData = null, teamPoints = [0, 0, 0, 0, 0, 0], originalPoints = [0, 0, 0, 0, 0, 0], maxPool = 0, customOn = false, currentType = '3', shadow = null, customDataRef = null;
const q = (sel) => shadow ? shadow.querySelector(sel) : null;
const qa = (sel) => shadow ? shadow.querySelectorAll(sel) : [];
const renderPoolBar = () => { const tot = teamPoints.reduce((a, b) => a + b, 0); let s = ''; for (let i = 0; i < 6; i++) { if (teamPoints[i] > 0) { s += `<div class="tmt-pool-seg" style="width:${(teamPoints[i] / maxPool * 100).toFixed(2)}%;background:${COLORS[i]};opacity:0.7"></div>`; } } const rem = ((maxPool - tot) / maxPool * 100).toFixed(2); if (rem > 0) s += `<div class="tmt-pool-rem" style="width:${rem}%"></div>`; return s; };
const renderDots = (idx) => { const pts = teamPoints[idx]; const c = COLORS[idx]; let h = ''; for (let i = 0; i < MAX_PTS; i++) { h += i < pts ? `<span class="tmt-dot tmt-dot-filled" data-team="${idx}" data-seg="${i}" style="background:${c}"></span>` : `<span class="tmt-dot tmt-dot-empty" data-team="${idx}" data-seg="${i}"></span>`; } return h; };
let saveDebounce = null, saveTimer = null;
const flashSaved = () => { const el = q('#saved'); if (!el) return; el.classList.add('vis'); clearTimeout(saveTimer); saveTimer = setTimeout(() => el.classList.remove('vis'), 1800); };
const saveCustomTraining = () => { const tot = teamPoints.reduce((a, b) => a + b, 0); if (tot !== maxPool || !customDataRef) return; clearTimeout(saveDebounce); saveDebounce = setTimeout(() => { const d = { type: 'custom', on: 1, player_id: PLAYER_ID, 'custom[points_spend]': 0, 'custom[player_id]': PLAYER_ID, 'custom[saved]': '' }; for (let i = 0; i < 6; i++) { const t = customDataRef['team' + (i + 1)]; const p = `custom[team${i + 1}]`; d[`${p}[num]`] = i + 1; d[`${p}[label]`] = t.label || `Team ${i + 1}`; d[`${p}[points]`] = teamPoints[i]; d[`${p}[skills][]`] = t.skills; } $.post('/ajax/training_post.ajax.php', d).done(() => flashSaved()); }, 300); };
const saveTrainingType = (type) => { $.post('/ajax/training_post.ajax.php', { type: 'player_pos', player_id: PLAYER_ID, team_id: type }).done(() => flashSaved()); };
const updateUI = () => {
const tot = teamPoints.reduce((a, b) => a + b, 0); const rem = maxPool - tot;
const barEl = q('#pool-bar'); if (barEl) barEl.innerHTML = renderPoolBar();
const uEl = q('#card-used'); if (uEl) uEl.textContent = tot;
const fEl = q('#card-free'); if (fEl) { fEl.textContent = rem; fEl.style.color = rem > 0 ? '#fbbf24' : '#6a9a58'; }
for (let i = 0; i < 6; i++) { const dEl = q(`#dots-${i}`); if (dEl) dEl.innerHTML = renderDots(i); const pEl = q(`#pts-${i}`); if (pEl) pEl.textContent = teamPoints[i]; }
const tEl = q('#total'); if (tEl) tEl.innerHTML = `${tot}<span class="dim">/${maxPool}</span>`;
qa('.tmt-minus').forEach(b => { b.disabled = teamPoints[parseInt(b.dataset.team)] <= 0; });
qa('.tmt-plus').forEach(b => { b.disabled = teamPoints[parseInt(b.dataset.team)] >= MAX_PTS || rem <= 0; });
bindDotClicks();
};
const bindDotClicks = () => { qa('.tmt-dot').forEach(dot => { dot.onclick = () => { const ti = parseInt(dot.dataset.team); const si = parseInt(dot.dataset.seg); const tp = si + 1; const tot = teamPoints.reduce((a, b) => a + b, 0); const cur = teamPoints[ti]; if (tp === cur) teamPoints[ti] = si; else if (tp > cur) { const need = tp - cur; const avail = maxPool - tot; teamPoints[ti] = need <= avail ? tp : cur + avail; } else teamPoints[ti] = tp; updateUI(); saveCustomTraining(); }; }); };
const bindEvents = () => {
qa('.tmt-plus').forEach(b => { b.addEventListener('click', () => { const i = parseInt(b.dataset.team); if (teamPoints[i] < MAX_PTS && teamPoints.reduce((a, b) => a + b, 0) < maxPool) { teamPoints[i]++; updateUI(); saveCustomTraining(); } }); });
qa('.tmt-minus').forEach(b => { b.addEventListener('click', () => { const i = parseInt(b.dataset.team); if (teamPoints[i] > 0) { teamPoints[i]--; updateUI(); saveCustomTraining(); } }); });
bindDotClicks();
q('#btn-clear')?.addEventListener('click', () => { teamPoints.fill(0); updateUI(); saveCustomTraining(); });
q('#btn-reset')?.addEventListener('click', () => { teamPoints = [...originalPoints]; updateUI(); saveCustomTraining(); });
const tS = q('#tab-std'), tC = q('#tab-cus'), w = q('.tmt-wrap');
tS?.addEventListener('click', () => { if (customOn) { customOn = false; tS.classList.add('active'); tC.classList.remove('active'); w.classList.add('tmt-custom-off'); saveTrainingType(currentType); } });
tC?.addEventListener('click', () => { if (!customOn) { customOn = true; tC.classList.add('active'); tS.classList.remove('active'); w.classList.remove('tmt-custom-off'); saveCustomTraining(); } });
q('#type-select')?.addEventListener('change', (e) => { const v = e.target.value; if (v !== currentType) { currentType = v; saveTrainingType(v); } });
updateUI();
};
const render = (container, data) => {
trainingData = data;
const custom = data.custom;
const customData = custom.custom;
customOn = !!custom.custom_on;
currentType = String(custom.team || '3');
customDataRef = customData;
if (data.custom?.gk) {
container.innerHTML = '';
const host = document.createElement('div');
container.appendChild(host);
shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>${TMT_CSS}</style><div class="tmt-wrap"><div class="tmt-body" style="text-align:center;padding:20px 14px"><div style="font-size:22px;margin-bottom:6px">🧤</div><div style="color:#e8f5d8;font-weight:700;font-size:14px;margin-bottom:4px">Goalkeeper Training</div><div style="color:#6a9a58;font-size:11px">Training is automatically set and cannot be changed for goalkeepers.</div></div></div>`;
return;
}
for (let i = 0; i < 6; i++) { const t = customData['team' + (i + 1)]; teamPoints[i] = parseInt(t.points) || 0; originalPoints[i] = teamPoints[i]; }
const totalAlloc = teamPoints.reduce((a, b) => a + b, 0);
maxPool = totalAlloc + (parseInt(customData.points_spend) || 0); if (maxPool < 1) maxPool = 10;
const rem = maxPool - totalAlloc;
container.innerHTML = ''; const host = document.createElement('div'); container.appendChild(host);
shadow = host.attachShadow({ mode: 'open' });
let typeOpts = customOn ? '<option value="" selected>— Select —</option>' : '';
Object.entries(TRAINING_TYPES).forEach(([id, name]) => { typeOpts += `<option value="${id}" ${!customOn && id === currentType ? 'selected' : ''}>${name}</option>`; });
let teamRows = '';
for (let i = 0; i < 6; i++) { const t = customData['team' + (i + 1)]; const skills = t.skills.map(s => SKILL_NAMES[s] || s).join(', '); teamRows += `<tr data-team="${i}"><td class="tmt-clr-bar" style="background:${COLORS[i]}"></td><td style="font-weight:700;color:#e8f5d8;white-space:nowrap">T${i + 1}</td><td style="color:#8aac72;font-size:11px">${skills}</td><td class="c"><div style="display:flex;align-items:center;gap:6px;justify-content:center"><button class="tmt-btn tmt-minus" data-team="${i}">−</button><span class="tmt-dots" id="dots-${i}">${renderDots(i)}</span><span class="tmt-pts" id="pts-${i}">${teamPoints[i]}</span><button class="tmt-btn tmt-plus" data-team="${i}">+</button></div></td></tr>`; }
shadow.innerHTML = `<style>${TMT_CSS}</style>
<div class="tmt-wrap ${customOn ? '' : 'tmt-custom-off'}">
<div class="tmt-tabs"><button class="tmt-tab ${!customOn ? 'active' : ''}" id="tab-std">Standard</button><button class="tmt-tab ${customOn ? 'active' : ''}" id="tab-cus">Custom <span class="tmt-pro">PRO</span></button></div>
<div class="tmt-body">
<div class="tmt-sbar" id="type-bar"><span class="tmt-sbar-label">Training Type</span><select id="type-select">${typeOpts}</select></div>
<div class="tmt-cards"><div><div class="lbl">Allocated</div><div class="val" style="color:#6cc040" id="card-used">${totalAlloc}</div></div><div><div class="lbl">Remaining</div><div class="val" style="color:${rem > 0 ? '#fbbf24' : '#6a9a58'}" id="card-free">${rem}</div></div><div><div class="lbl">Total Pool</div><div class="val" style="color:#e8f5d8">${maxPool}</div></div><div style="flex:1;display:flex;align-items:flex-end"><div class="tmt-pool-bar" id="pool-bar" style="width:100%">${renderPoolBar()}</div></div></div>
<table class="tmt-tbl" id="teams-tbl"><thead><tr><th style="width:3px;padding:0"></th><th style="width:30px">Team</th><th>Skills</th><th class="c">Points</th></tr></thead><tbody id="teams-body">${teamRows}</tbody></table>
<div class="tmt-footer"><div class="tmt-footer-total"><div class="lbl">Total Training</div><div class="val" id="total">${totalAlloc}<span class="dim">/${maxPool}</span></div></div><div class="tmt-footer-acts"><button class="tmt-act dng" id="btn-clear">Clear All</button><button class="tmt-act" id="btn-reset">Reset</button></div></div>
</div></div>`;
bindEvents();
};
return { render };
})();
/* ═══════════════════════════════════════════════════════════
███ MODULE: GRAPHS
═══════════════════════════════════════════════════════════ */
const GraphsMod = (() => {
let lastData = null;
let containerRef = null;
const SKILL_META = [
{ key: 'strength', label: 'Strength', color: '#22cc22' }, { key: 'stamina', label: 'Stamina', color: '#00bcd4' },
{ key: 'pace', label: 'Pace', color: '#8bc34a' }, { key: 'marking', label: 'Marking', color: '#f44336' },
{ key: 'tackling', label: 'Tackling', color: '#26a69a' }, { key: 'workrate', label: 'Workrate', color: '#3f51b5' },
{ key: 'positioning', label: 'Positioning', color: '#9c27b0' }, { key: 'passing', label: 'Passing', color: '#e91e63' },
{ key: 'crossing', label: 'Crossing', color: '#2196f3' }, { key: 'technique', label: 'Technique', color: '#ff4081' },
{ key: 'heading', label: 'Heading', color: '#757575' }, { key: 'finishing', label: 'Finishing', color: '#4caf50' },
{ key: 'longshots', label: 'Longshots', color: '#00e5ff' }, { key: 'set_pieces', label: 'Set Pieces', color: '#607d8b' }
];
const SKILL_META_GK = [
{ key: 'strength', label: 'Strength', color: '#22cc22' }, { key: 'stamina', label: 'Stamina', color: '#00bcd4' },
{ key: 'pace', label: 'Pace', color: '#8bc34a' }, { key: 'handling', label: 'Handling', color: '#f44336' },
{ key: 'one_on_ones', label: 'One on ones', color: '#26a69a' }, { key: 'reflexes', label: 'Reflexes', color: '#3f51b5' },
{ key: 'aerial_ability', label: 'Aerial Ability', color: '#9c27b0' }, { key: 'jumping', label: 'Jumping', color: '#e91e63' },
{ key: 'communication', label: 'Communication', color: '#2196f3' }, { key: 'kicking', label: 'Kicking', color: '#ff4081' },
{ key: 'throwing', label: 'Throwing', color: '#757575' }
];
const getSkillMeta = () => isGoalkeeper ? SKILL_META_GK : SKILL_META;
const PEAK_META = [
{ key: 'physical', label: 'Physical', color: '#ffeb3b' },
{ key: 'tactical', label: 'Tactical', color: '#00e5ff' },
{ key: 'technical', label: 'Technical', color: '#ff4081' }
];
const calcTicks = (min, max, n) => {
if (max === min) return [min]; const range = max - min; const raw = range / n; const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const res = raw / mag; let step; if (res <= 1.5) step = mag; else if (res <= 3) step = 2 * mag; else if (res <= 7) step = 5 * mag; else step = 10 * mag;
const ticks = []; let t = Math.ceil(min / step) * step; while (t <= max + step * 0.01) { ticks.push(Math.round(t * 10000) / 10000); t += step; } return ticks;
};
const buildAges = (n, years, months) => { const cur = years + months / 12; const ages = []; for (let i = 0; i < n; i++)ages.push(cur - (n - 1 - i) / 12); return ages; };
const drawChart = (canvas, ages, values, opts = {}) => {
const { lineColor = '#fff', fillColor = 'rgba(255,255,255,0.06)', gridColor = 'rgba(255,255,255,0.10)', axisColor = '#9ab889', dotRadius = 2.5, yMinOverride, yMaxOverride, formatY = v => String(Math.round(v)) } = opts;
const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth, cssH = canvas.clientHeight;
canvas.width = cssW * dpr; canvas.height = cssH * dpr; ctx.scale(dpr, dpr);
const pL = 50, pR = 10, pT = 12, pB = 30, cW = cssW - pL - pR, cH = cssH - pT - pB;
const minA = Math.floor(Math.min(...ages)), maxA = Math.ceil(Math.max(...ages));
const rMin = Math.min(...values), rMax = Math.max(...values), m = (rMax - rMin) * 0.06 || 1;
const yMin = yMinOverride !== undefined ? yMinOverride : Math.floor(rMin - m);
const yMax = yMaxOverride !== undefined ? yMaxOverride : Math.ceil(rMax + m);
const xS = v => pL + ((v - minA) / (maxA - minA)) * cW; const yS = v => pT + cH - ((v - yMin) / (yMax - yMin)) * cH;
ctx.clearRect(0, 0, cssW, cssH); ctx.fillStyle = 'rgba(0,0,0,0.08)'; ctx.fillRect(pL, pT, cW, cH);
const yTicks = calcTicks(yMin, yMax, 6);
ctx.font = '11px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif'; ctx.textAlign = 'right';
yTicks.forEach(tick => { const y = yS(tick); if (y < pT || y > pT + cH) return; ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pL, y); ctx.lineTo(pL + cW, y); ctx.stroke(); ctx.fillStyle = axisColor; ctx.fillText(formatY(tick), pL - 7, y + 4); });
ctx.textAlign = 'center';
for (let a = minA; a <= maxA; a++) { const x = xS(a); ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, pT); ctx.lineTo(x, pT + cH); ctx.stroke(); ctx.fillStyle = axisColor; ctx.fillText(String(a), x, pT + cH + 16); }
ctx.fillStyle = axisColor; ctx.font = '12px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Age', cssW / 2, cssH - 2);
ctx.beginPath(); ctx.moveTo(xS(ages[0]), yS(values[0])); for (let i = 1; i < values.length; i++)ctx.lineTo(xS(ages[i]), yS(values[i]));
ctx.lineTo(xS(ages[ages.length - 1]), pT + cH); ctx.lineTo(xS(ages[0]), pT + cH); ctx.closePath(); ctx.fillStyle = fillColor; ctx.fill();
ctx.beginPath(); ctx.strokeStyle = lineColor; ctx.lineWidth = 1.8; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.moveTo(xS(ages[0]), yS(values[0])); for (let i = 1; i < values.length; i++)ctx.lineTo(xS(ages[i]), yS(values[i])); ctx.stroke();
for (let i = 0; i < values.length; i++) { ctx.beginPath(); ctx.arc(xS(ages[i]), yS(values[i]), dotRadius, 0, Math.PI * 2); ctx.fillStyle = lineColor; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 0.8; ctx.stroke(); }
ctx.strokeStyle = 'rgba(120,180,80,0.3)'; ctx.lineWidth = 1; ctx.strokeRect(pL, pT, cW, cH);
return { xS, yS, ages, values, formatY };
};
const drawMultiLine = (canvas, ages, seriesData, opts = {}) => {
const { gridColor = 'rgba(255,255,255,0.10)', axisColor = '#9ab889', yMinOverride, yMaxOverride, formatY = v => String(Math.round(v)), dotRadius = 1.5, yTickCount = 6 } = opts;
const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth, cssH = canvas.clientHeight; canvas.width = cssW * dpr; canvas.height = cssH * dpr; ctx.scale(dpr, dpr);
const pL = 50, pR = 10, pT = 12, pB = 30, cW = cssW - pL - pR, cH = cssH - pT - pB;
const vis = seriesData.filter(s => s.visible); let all = []; vis.forEach(s => all.push(...s.values)); if (!all.length) all = [0, 1];
const rMin = Math.min(...all), rMax = Math.max(...all), m = (rMax - rMin) * 0.06 || 1;
const yMin = yMinOverride !== undefined ? yMinOverride : Math.floor(rMin - m);
const yMax = yMaxOverride !== undefined ? yMaxOverride : Math.ceil(rMax + m);
const minA = Math.floor(Math.min(...ages)), maxA = Math.ceil(Math.max(...ages));
const xS = v => pL + ((v - minA) / (maxA - minA)) * cW; const yS = v => pT + cH - ((v - yMin) / (yMax - yMin)) * cH;
ctx.clearRect(0, 0, cssW, cssH); ctx.fillStyle = 'rgba(0,0,0,0.08)'; ctx.fillRect(pL, pT, cW, cH);
const yTicks = calcTicks(yMin, yMax, yTickCount);
ctx.font = '11px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif'; ctx.textAlign = 'right';
yTicks.forEach(tick => { const y = yS(tick); if (y < pT || y > pT + cH) return; ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pL, y); ctx.lineTo(pL + cW, y); ctx.stroke(); ctx.fillStyle = axisColor; ctx.fillText(formatY(tick), pL - 7, y + 4); });
ctx.textAlign = 'center';
for (let a = minA; a <= maxA; a++) { const x = xS(a); ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, pT); ctx.lineTo(x, pT + cH); ctx.stroke(); ctx.fillStyle = axisColor; ctx.fillText(String(a), x, pT + cH + 16); }
ctx.fillStyle = axisColor; ctx.font = '12px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Age', cssW / 2, cssH - 2);
vis.forEach(s => { ctx.beginPath(); ctx.strokeStyle = s.color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.moveTo(xS(ages[0]), yS(s.values[0])); for (let i = 1; i < s.values.length; i++)ctx.lineTo(xS(ages[i]), yS(s.values[i])); ctx.stroke(); for (let i = 0; i < s.values.length; i++) { ctx.beginPath(); ctx.arc(xS(ages[i]), yS(s.values[i]), dotRadius, 0, Math.PI * 2); ctx.fillStyle = s.color; ctx.fill(); } });
ctx.strokeStyle = 'rgba(120,180,80,0.3)'; ctx.lineWidth = 1; ctx.strokeRect(pL, pT, cW, cH);
return { xS, yS, ages, seriesData, formatY };
};
const attachTooltip = (wrap, canvas, info) => {
const tip = wrap.querySelector('.tmg-tooltip'); if (!tip) return;
canvas.addEventListener('mousemove', e => { const r = canvas.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top; let best = -1, bd = Infinity; for (let i = 0; i < info.ages.length; i++) { const d = Math.hypot(mx - info.xS(info.ages[i]), my - info.yS(info.values[i])); if (d < bd && d < 25) { bd = d; best = i; } } if (best >= 0) { const a = info.ages[best], v = info.values[best]; const ay = Math.floor(a), am = Math.round((a - ay) * 12); tip.innerHTML = `<b>Age:</b> ${ay}y ${am}m <b>Value:</b> ${info.formatY(v)}`; tip.style.display = 'block'; const px = info.xS(a), py = info.yS(v); let tx = px - tip.offsetWidth / 2; if (tx < 0) tx = 0; if (tx + tip.offsetWidth > canvas.clientWidth) tx = canvas.clientWidth - tip.offsetWidth; tip.style.left = tx + 'px'; tip.style.top = (py - 32) + 'px'; } else tip.style.display = 'none'; });
canvas.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
};
const attachMultiTooltip = (wrap, canvas, infoGetter) => {
const tip = wrap.querySelector('.tmg-tooltip'); if (!tip) return;
canvas.addEventListener('mousemove', e => { const r = canvas.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top; const info = infoGetter(); if (!info) return; let best = null, bd = Infinity; info.seriesData.filter(s => s.visible).forEach(s => { for (let i = 0; i < s.values.length; i++) { const d = Math.hypot(mx - info.xS(info.ages[i]), my - info.yS(s.values[i])); if (d < bd && d < 25) { bd = d; best = { series: s, idx: i }; } } }); if (best) { const a = info.ages[best.idx], v = best.series.values[best.idx]; const ay = Math.floor(a), am = Math.round((a - ay) * 12); tip.innerHTML = `<span style="color:${best.series.color}">●</span> <b>${best.series.label}:</b> ${info.formatY(v)} <b>Age:</b> ${ay}y ${am}m`; tip.style.display = 'block'; const px = info.xS(a), py = info.yS(v); let tx = px - tip.offsetWidth / 2; if (tx < 0) tx = 0; if (tx + tip.offsetWidth > canvas.clientWidth) tx = canvas.clientWidth - tip.offsetWidth; tip.style.left = tx + 'px'; tip.style.top = (py - 32) + 'px'; } else tip.style.display = 'none'; });
canvas.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
};
const CHART_DEFS = [
{ key: 'ti', title: 'Training Intensity', opts: { lineColor: '#fff', fillColor: 'rgba(255,255,255,0.05)' }, prepareData: raw => { const v = []; for (let i = 0; i < raw.length; i++) { if (i === 0 && typeof raw[i] === 'number' && Number(raw[i]) === 0) continue; v.push(Number(raw[i])); } return v; } },
{ key: 'skill_index', title: 'ASI', opts: { lineColor: '#fff', fillColor: 'rgba(255,255,255,0.05)', formatY: v => v >= 1000 ? Math.round(v).toLocaleString() : String(Math.round(v)) }, prepareData: raw => raw.map(Number) },
{ key: 'recommendation', title: 'REC', opts: { lineColor: '#fff', fillColor: 'rgba(255,255,255,0.05)', yMinOverride: 0, formatY: v => v.toFixed(1) }, prepareData: raw => { const v = raw.map(Number); return v; }, yMaxFn: vals => Math.max(6, Math.ceil(Math.max(...vals) * 10) / 10) }
];
const MULTI_DEFS = [
{ title: 'Skills', get meta() { return getSkillMeta(); }, showToggle: true, enableKey: 'skills', getSeriesData: g => { const sm = getSkillMeta(); return sm.map(m => ({ key: m.key, label: m.label, color: m.color, values: (g[m.key] || []).map(Number), visible: true })); }, opts: { yMinOverride: 0, yMaxOverride: 20, dotRadius: 1.5, yTickCount: 11 } },
{
title: 'Peaks', meta: PEAK_META, showToggle: false, enableKey: 'peaks', getSeriesData: g => {
const pk = g.peaks || {};
console.log('[Graphs] Raw peaks data', { pk });
/* If TM peaks exist, use them */
// if (PEAK_META.some(m => pk[m.key] && pk[m.key].length > 0)) {
// return PEAK_META.map(m => ({ key: m.key, label: m.label, color: m.color, values: (pk[m.key] || []).map(Number), visible: true }));
// }
/* Compute peaks from skills */
if (isGoalkeeper) {
/* GK: Physical: Str+Sta+Pac+Jum (4×20=80), Tactical: 1v1+Aer+Com (3×20=60), Technical: Han+Ref+Kic+Thr (4×20=80) */
const PHYS = ['strength', 'stamina', 'pace', 'jumping'];
const TACT = ['one_on_ones', 'aerial_ability', 'communication'];
const TECH = ['handling', 'reflexes', 'kicking', 'throwing'];
const L = (g[PHYS[0]] || []).length;
if (L < 2) return PEAK_META.map(m => ({ key: m.key, label: m.label, color: m.color, values: [], visible: true }));
const sumAt = (keys, i) => keys.reduce((s, k) => s + ((g[k] || [])[i] || 0), 0);
const phys = [], tact = [], tech = [];
for (let i = 0; i < L; i++) {
phys.push(Math.round(sumAt(PHYS, i) / 80 * 1000) / 10);
tact.push(Math.round(sumAt(TACT, i) / 60 * 1000) / 10);
tech.push(Math.round(sumAt(TECH, i) / 80 * 1000) / 10);
}
return [
{ key: 'physical', label: 'Physical', color: '#ffeb3b', values: phys, visible: true },
{ key: 'tactical', label: 'Tactical', color: '#00e5ff', values: tact, visible: true },
{ key: 'technical', label: 'Technical', color: '#ff4081', values: tech, visible: true }
];
}
/* Outfield: Physical: Str+Sta+Pac+Hea (4×20=80), Tactical: Mar+Tac+Wor+Pos (4×20=80), Technical: Pas+Cro+Tec+Fin+Lon+Set (6×20=120) */
const PHYS = ['strength', 'stamina', 'pace', 'heading'];
const TACT = ['marking', 'tackling', 'workrate', 'positioning'];
const TECH = ['passing', 'crossing', 'technique', 'finishing', 'longshots', 'set_pieces'];
const L = (g[PHYS[0]] || []).length;
if (L < 2) return PEAK_META.map(m => ({ key: m.key, label: m.label, color: m.color, values: [], visible: true }));
const sumAt = (keys, i) => keys.reduce((s, k) => s + ((g[k] || [])[i] || 0), 0);
const phys = [], tact = [], tech = [];
for (let i = 0; i < L; i++) {
phys.push(Math.round(sumAt(PHYS, i) / 80 * 1000) / 10);
tact.push(Math.round(sumAt(TACT, i) / 80 * 1000) / 10);
tech.push(Math.round(sumAt(TECH, i) / 120 * 1000) / 10);
}
console.log('[Graphs] Peaks computed from skills', { g });
return [
{ key: 'physical', label: 'Physical', color: '#ffeb3b', values: phys, visible: true },
{ key: 'tactical', label: 'Tactical', color: '#00e5ff', values: tact, visible: true },
{ key: 'technical', label: 'Technical', color: '#ff4081', values: tech, visible: true }
];
}, opts: { dotRadius: 1.5, yMinOverride: 0, yMaxOverride: 100, formatY: v => v.toFixed(1) + '%' }, legendInline: true
}
];
const buildSingleChart = (el, def, graphData, player) => {
let values, ages;
let enhanced = false;
/* ASI fallback: if TM's skill_index is missing, reconstruct from TI or store */
if (def.key === 'skill_index' && (!graphData[def.key] || graphData[def.key].length < 2)) {
/* Priority 1: reconstruct ASI from TI array + current playerASI */
if (playerASI > 0 && graphData.ti && graphData.ti.length >= 2) {
try {
const tiRaw = graphData.ti;
/* TI array usually has a dummy 0 at index 0; skip it */
const tiStart = (typeof tiRaw[0] === 'number' && tiRaw[0] === 0) || tiRaw[0] === '0' || tiRaw[0] === 0 ? 1 : 0;
const tiVals = tiRaw.slice(tiStart).map(v => parseInt(v) || 0);
const L = tiVals.length;
if (L >= 2) {
const K = isGoalkeeper ? 48717927500 : (Math.pow(2, 9) * Math.pow(5, 4) * Math.pow(7, 7));
const asiArr = new Array(L);
asiArr[L - 1] = playerASI;
for (let j = L - 2; j >= 0; j--) {
const ti = tiVals[j + 1];
const base = Math.pow(asiArr[j + 1] * K, 1 / 7);
asiArr[j] = Math.max(0, Math.round(Math.pow(base - ti / 10, 7) / K));
}
values = asiArr;
ages = buildAges(L, player.years, player.months);
enhanced = true;
console.log(`[Graphs] ASI reconstructed from TI (${L} points)`);
}
} catch (e) { console.warn('[Graphs] ASI from TI failed', e); }
}
/* Priority 2: fall back to store SI records */
if (!values) {
try {
const store = PlayerDB.get(PLAYER_ID);
if (store && store.records) {
const keys = Object.keys(store.records).sort((a, b) => {
const [ay, am] = a.split('.').map(Number);
const [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
});
const tmpAges = [], tmpVals = [];
keys.forEach(k => {
const si = parseInt(store.records[k].SI) || 0;
if (si <= 0) return;
const [y, m] = k.split('.').map(Number);
tmpAges.push(y + m / 12);
tmpVals.push(si);
});
/* Extend to current age using live playerASI from page */
if (tmpVals.length > 0 && playerASI > 0) {
const curAge = player.years + player.months / 12;
const lastAge = tmpAges[tmpAges.length - 1];
if (curAge > lastAge + 0.001) {
tmpAges.push(curAge);
tmpVals.push(playerASI);
}
}
if (tmpVals.length >= 2) {
values = tmpVals;
ages = tmpAges;
enhanced = true;
}
}
} catch (e) { }
}
if (!values) return;
/* REC fallback: if TM's recommendation is missing, use our store REREC */
} else if (def.key === 'recommendation' && (!graphData[def.key] || graphData[def.key].length < 2)) {
try {
const store = PlayerDB.get(PLAYER_ID);
if (store && store._v >= 3 && store.records) {
const keys = Object.keys(store.records).sort((a, b) => {
const [ay, am] = a.split('.').map(Number);
const [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
});
const tmpAges = [], tmpVals = [];
keys.forEach(k => {
const rec = store.records[k];
if (rec.REREC == null) return;
const [y, m] = k.split('.').map(Number);
tmpAges.push(y + m / 12);
tmpVals.push(rec.REREC);
});
if (tmpVals.length >= 2) {
values = tmpVals;
ages = tmpAges;
enhanced = true;
}
}
} catch (e) { }
if (!values) return;
/* TI fallback: compute from ASI differences when TM's TI graph is missing */
} else if (def.key === 'ti' && (!graphData[def.key] || graphData[def.key].length < 2)) {
const K = isGoalkeeper ? 48717927500 : (Math.pow(2, 9) * Math.pow(5, 4) * Math.pow(7, 7));
/* Priority 1: compute TI from ASI graph data */
if (graphData.skill_index && graphData.skill_index.length >= 2) {
try {
const asiRaw = graphData.skill_index.map(Number);
const tiVals = [];
for (let i = 1; i < asiRaw.length; i++) {
const prev = Math.pow(asiRaw[i - 1] * K, 1 / 7);
const cur = Math.pow(asiRaw[i] * K, 1 / 7);
tiVals.push(Math.round((cur - prev) * 10));
}
if (tiVals.length >= 2) {
values = tiVals;
/* TI[i] corresponds to training from age[i] to age[i+1], so ages start one later */
ages = buildAges(tiVals.length, player.years, player.months);
enhanced = true;
console.log(`[Graphs] TI computed from ASI graph (${tiVals.length} points)`);
}
} catch (e) { console.warn('[Graphs] TI from ASI graph failed', e); }
}
/* Priority 2: compute TI from IndexedDB SI records */
if (!values) {
try {
const store = PlayerDB.get(PLAYER_ID);
if (store && store.records) {
const keys = Object.keys(store.records).sort((a, b) => {
const [ay, am] = a.split('.').map(Number);
const [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
});
const tmpAges = [], tmpASI = [];
keys.forEach(k => {
const si = parseInt(store.records[k].SI) || 0;
if (si <= 0) return;
const [y, m] = k.split('.').map(Number);
tmpAges.push(y + m / 12);
tmpASI.push(si);
});
if (tmpASI.length >= 2) {
const tiVals = [], tiAges = [];
for (let i = 1; i < tmpASI.length; i++) {
const prev = Math.pow(tmpASI[i - 1] * K, 1 / 7);
const cur = Math.pow(tmpASI[i] * K, 1 / 7);
tiVals.push(Math.round((cur - prev) * 10));
tiAges.push(tmpAges[i]);
}
if (tiVals.length >= 2) {
values = tiVals;
ages = tiAges;
enhanced = true;
console.log(`[Graphs] TI computed from store SI (${tiVals.length} points)`);
}
}
}
} catch (e) { }
}
if (!values) return;
} else {
const raw = graphData[def.key]; if (!raw) return;
values = def.prepareData(raw); if (!values.length) return;
ages = buildAges(values.length, player.years, player.months);
}
/* REC hybrid: splice our v3 REREC (0.01 precision) over TM's (0.10) */
let recSpliceIdx = -1;
if (def.key === 'recommendation') {
try {
const store = PlayerDB.get(PLAYER_ID);
if (store && store._v >= 3 && store.records) {
const curAgeMonths = player.years * 12 + player.months;
const L = values.length;
for (let i = 0; i < L; i++) {
const am = curAgeMonths - (L - 1 - i);
const key = `${Math.floor(am / 12)}.${am % 12}`;
const rec = store.records[key];
if (rec && rec.REREC != null) {
if (recSpliceIdx < 0) recSpliceIdx = i;
values[i] = rec.REREC;
}
}
if (recSpliceIdx >= 0) console.log(`[Graphs] REC hybrid: TM data 0..${recSpliceIdx - 1}, our data ${recSpliceIdx}..${L - 1}`);
}
} catch (e) { }
}
/* Dynamic yMax: use yMaxFn if defined (e.g. REC → min 6.0) */
const chartOpts = { ...def.opts };
if (def.yMaxFn) chartOpts.yMaxOverride = def.yMaxFn(values);
/* When we have enhanced REC data, show 2 decimals in tooltip */
if (recSpliceIdx >= 0 || (enhanced && def.key === 'recommendation')) {
chartOpts.formatY = v => v % 1 === 0 ? v.toFixed(1) : v.toFixed(2);
}
const wrap = document.createElement('div'); wrap.className = 'tmg-chart-wrap';
let enhLabel = '';
if (enhanced && def.key === 'skill_index') enhLabel = ' <span style="font-size:10px;color:#f0c040;font-weight:400">(from TI)</span>';
else if (enhanced && def.key === 'ti') enhLabel = ' <span style="font-size:10px;color:#f0c040;font-weight:400">(from ASI)</span>';
else if (enhanced && def.key === 'recommendation') enhLabel = ' <span style="font-size:10px;color:#5b9bff;font-weight:400">(computed)</span>';
else if (recSpliceIdx >= 0) enhLabel = ' <span style="font-size:10px;color:#38bdf8;font-weight:400">(enhanced)</span>';
wrap.innerHTML = `<div class="tmg-chart-title">${def.title}${enhLabel}</div><canvas class="tmg-canvas" style="width:100%;height:260px;"></canvas><div class="tmg-tooltip"></div>`;
el.appendChild(wrap);
const canvas = wrap.querySelector('canvas');
requestAnimationFrame(() => { const info = drawChart(canvas, ages, values, chartOpts); attachTooltip(wrap, canvas, info); });
};
/* Build per-skill arrays from v3 store records — fallback when TM skills unavailable */
const buildStoreSkillGraphData = (player) => {
try {
const store = PlayerDB.get(PLAYER_ID);
if (!store || !store.records) { console.log('[Skills] No store or no records'); return null; }
const sm = getSkillMeta();
const expectedLen = sm.length; /* 14 for outfield, 11 for GK */
const sortedKeys = Object.keys(store.records).sort((a, b) => {
const [ay, am] = a.split('.').map(Number);
const [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
});
console.log('[Skills] store._v:', store._v, 'total records:', sortedKeys.length, 'isGK:', isGoalkeeper);
const skillArrays = {};
sm.forEach(m => { skillArrays[m.key] = []; });
let count = 0;
sortedKeys.forEach(k => {
const rec = store.records[k];
const hasSkills = rec.skills && rec.skills.length >= expectedLen;
const nonZero = hasSkills && rec.skills.some(v => v !== 0);
if (!hasSkills || !nonZero) {
console.log(`[Skills] skip ${k}: hasSkills=${hasSkills}, nonZero=${nonZero}`, rec.skills?.slice(0,3));
return;
}
sm.forEach((m, i) => { skillArrays[m.key].push(rec.skills[i]); });
count++;
});
console.log('[Skills] usable records with skills:', count);
if (count < 2) return null;
skillArrays._ages = sortedKeys.filter(k => {
const r = store.records[k];
return r.skills && r.skills.length >= expectedLen && r.skills.some(v => v !== 0);
}).map(k => { const [y, m] = k.split('.').map(Number); return y + m / 12; });
return skillArrays;
} catch (e) { console.log('[Skills] error:', e); return null; }
};
const buildMultiChart = (el, def, graphData, player, skillpoints, isOwnPlayer) => {
let seriesData = def.getSeriesData(graphData);
let fromStore = false;
let storeAges = null;
if (!seriesData.length || !seriesData[0].values.length) {
/* Try store fallback */
const storeGD = buildStoreSkillGraphData(player);
if (storeGD) {
storeAges = storeGD._ages;
seriesData = def.getSeriesData(storeGD);
}
if (!seriesData.length || !seriesData[0].values.length) {
/* No data at all — show enable card if own player, else info msg */
if (isOwnPlayer && def.enableKey) {
buildEnableCard(el, def.enableKey);
} else if (def.enableKey) {
const msg = document.createElement('div');
msg.style.cssText = 'background:rgba(0,0,0,0.15);border:1px solid rgba(120,180,80,0.2);border-radius:6px;padding:10px 14px;margin:4px 0 8px;color:#5a7a48;font-size:11px;';
msg.textContent = `${def.title}: No data available (graph not enabled)`;
el.appendChild(msg);
}
return;
}
fromStore = true;
}
const ages = storeAges || buildAges(seriesData[0].values.length, player.years, player.months);
const wrap = document.createElement('div'); wrap.className = 'tmg-chart-wrap';
const upSet = new Set((skillpoints?.up) || []); const downSet = new Set((skillpoints?.down) || []);
const legendCls = def.legendInline ? 'tmg-legend tmg-legend-inline' : 'tmg-legend';
let legendH = `<div class="${legendCls}">`;
seriesData.forEach((s, i) => { let arr = ''; if (upSet.has(s.key)) arr = '<span class="tmg-skill-arrow" style="color:#4caf50">▲</span>'; else if (downSet.has(s.key)) arr = '<span class="tmg-skill-arrow" style="color:#f44336">▼</span>'; legendH += `<label class="tmg-legend-item"><input type="checkbox" data-idx="${i}" checked style="background:${s.color}"><span class="tmg-legend-dot" style="color:${s.color}">●</span>${s.label}${arr}</label>`; });
legendH += '</div>';
let toggleH = def.showToggle ? '<div class="tmg-legend-toggle"><button class="tmg-btn" data-action="all">All</button><button class="tmg-btn" data-action="none">None</button></div>' : '';
const computedLabel = fromStore ? ' <span style="font-size:10px;color:#5b9bff;font-weight:400">(computed)</span>' : '';
const enableBtn = (fromStore && isOwnPlayer && def.enableKey)
? `<button class="tmg-enable-btn" data-enable-key="${def.enableKey}" style="font-size:10px;padding:3px 10px;margin-left:auto;">Enable <img src="/pics/pro_icon.png" class="pro_icon"></button>`
: '';
wrap.innerHTML = `<div class="tmg-chart-title" style="display:flex;align-items:center;gap:8px;">${def.title}${computedLabel}${enableBtn}</div><canvas class="tmg-canvas" style="width:100%;height:280px;"></canvas><div class="tmg-tooltip"></div>${legendH}${toggleH}`;
el.appendChild(wrap);
if (enableBtn) {
wrap.querySelector('.tmg-enable-btn').addEventListener('click', () => {
if (typeof window.graph_enable === 'function') window.graph_enable(PLAYER_ID, def.enableKey);
});
}
const canvas = wrap.querySelector('canvas'); let curInfo = null;
const redraw = () => { curInfo = drawMultiLine(canvas, ages, seriesData, def.opts); };
wrap.querySelectorAll('.tmg-legend input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', () => { const i = parseInt(cb.dataset.idx); seriesData[i].visible = cb.checked; cb.style.background = cb.checked ? seriesData[i].color : 'rgba(255,255,255,0.08)'; redraw(); }); });
if (def.showToggle) { wrap.querySelectorAll('.tmg-btn').forEach(btn => { btn.addEventListener('click', () => { const v = btn.dataset.action === 'all'; seriesData.forEach(s => s.visible = v); wrap.querySelectorAll('.tmg-legend input[type="checkbox"]').forEach((cb, i) => { cb.checked = v; cb.style.background = v ? seriesData[i].color : 'rgba(255,255,255,0.08)'; }); redraw(); }); }); }
attachMultiTooltip(wrap, canvas, () => curInfo);
requestAnimationFrame(() => redraw());
};
/* Enable button descriptions */
const ENABLE_INFO = {
skill_index: { title: 'Skill Index', desc: 'Monitor your player\'s ASI increase each training.', enableKey: 'skill_index' },
recommendation: { title: 'Recommendation', desc: 'See when your player gained new recommendation stars.', enableKey: 'recommendation' },
skills: { title: 'Skills', desc: 'Monitor when a player gained a point in a certain skill.', enableKey: 'skills' },
peaks: { title: 'Peaks', desc: 'See what % of weekly training went into each peak area.', enableKey: 'peaks' }
};
const hasGraphData = (graphData, key) => {
if (key === 'skills') return getSkillMeta().some(m => graphData[m.key] && graphData[m.key].length > 0);
if (key === 'peaks') return graphData.peaks && PEAK_META.some(m => graphData.peaks[m.key] && graphData.peaks[m.key].length > 0);
return graphData[key] && graphData[key].length > 0;
};
/* R5 chart — reads R5 values from our v3 store (not from TM endpoint) */
const buildR5Chart = (el, player) => {
try {
const store = PlayerDB.get(PLAYER_ID);
if (!store || store._v < 3 || !store.records) return;
const keys = Object.keys(store.records).sort((a, b) => {
const [ay, am] = a.split('.').map(Number);
const [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
});
const ages = [], values = [];
keys.forEach(k => {
const rec = store.records[k];
if (rec.R5 == null) return;
const [y, m] = k.split('.').map(Number);
ages.push(y + m / 12);
values.push(rec.R5);
});
if (values.length < 2) return;
const rawMin = Math.min(...values), rawMax = Math.max(...values);
const yMin = rawMin < 30 ? Math.floor(rawMin) : 30;
const yMax = rawMax > 120 ? Math.ceil(rawMax) : 120;
const opts = {
lineColor: '#5b9bff', fillColor: 'rgba(91,155,255,0.06)',
yMinOverride: yMin, yMaxOverride: yMax,
formatY: v => v % 1 === 0 ? v.toFixed(1) : v.toFixed(2)
};
const wrap = document.createElement('div'); wrap.className = 'tmg-chart-wrap';
wrap.innerHTML = `<div class="tmg-chart-title" style="display:flex;align-items:center;justify-content:space-between">
<span>R5 <span style="font-size:10px;color:#5b9bff;font-weight:400">(computed)</span></span>
<button class="tmg-export-btn" title="Export to Excel">⬇ Excel</button>
</div><canvas class="tmg-canvas" style="width:100%;height:260px;"></canvas><div class="tmg-tooltip"></div>`;
el.appendChild(wrap);
wrap.querySelector('.tmg-export-btn').addEventListener('click', () => {
const row = values.map(v => v.toFixed(2).replace('.', ',')).join(';');
const csv = 'sep=;\r\n' + row + '\r\n';
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `R5_player_${PLAYER_ID}.csv`;
document.body.appendChild(a); a.click();
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 500);
});
const canvas = wrap.querySelector('canvas');
requestAnimationFrame(() => { const info = drawChart(canvas, ages, values, opts); attachTooltip(wrap, canvas, info); });
} catch (e) { }
};
const buildEnableCard = (container, key) => {
const info = ENABLE_INFO[key];
if (!info) return;
const card = document.createElement('div');
card.className = 'tmg-enable-card';
card.innerHTML = `<div><div class="tmg-enable-title">${info.title}</div><div class="tmg-enable-desc">${info.desc}</div></div><button class="tmg-enable-btn" data-enable-key="${info.enableKey}">Enable <img src="/pics/pro_icon.png" class="pro_icon"></button>`;
card.querySelector('.tmg-enable-btn').addEventListener('click', () => {
if (typeof window.graph_enable === 'function') window.graph_enable(PLAYER_ID, info.enableKey);
});
container.appendChild(card);
};
const render = (container, data) => {
containerRef = container;
lastData = data;
container.innerHTML = '';
const graphData = data.graphs;
const player = data.player;
const skillpoints = data.skillpoints;
console.log('[Graphs] Rendering with data:', { graphData, player, skillpoints });
if (!graphData || !player) { container.innerHTML = '<div style="text-align:center;padding:40px;color:#5a7a48;font-style:italic">No graph data available</div>'; return; }
/* Determine if this is the user's own player (for enable buttons) */
const clubAnchor = document.querySelector('a[club_link]');
const clubHrefRaw = clubAnchor ? (clubAnchor.getAttribute('href') || '') : '';
const clubLinkAttr = clubAnchor ? clubAnchor.getAttribute('club_link') : null;
const clubIdMatch = clubHrefRaw.match(/\/club\/(\d+)/i) || clubHrefRaw.match(/club_link[=\/]?(\d+)/i);
const playerClubId = clubIdMatch ? clubIdMatch[1] : (clubLinkAttr || '');
const isOwnPlayer = getOwnClubIds().includes(String(playerClubId));
/* TI chart first */
buildSingleChart(container, CHART_DEFS[0], graphData, player);
/* R5 chart — built entirely from our v3 store */
buildR5Chart(container, player);
/* Remaining charts (ASI, REC) */
for (let i = 1; i < CHART_DEFS.length; i++) buildSingleChart(container, CHART_DEFS[i], graphData, player);
MULTI_DEFS.forEach(def => buildMultiChart(container, def, graphData, player, skillpoints, isOwnPlayer));
};
const reRender = () => { if (containerRef && lastData) render(containerRef, lastData); };
return { render, reRender };
})();
/* ═══════════════════════════════════════════════════════════
MAIN UI — Tab bar + panels + data fetching
═══════════════════════════════════════════════════════════ */
const TABS = [
{ key: 'history', label: 'History', mod: HistoryMod },
{ key: 'scout', label: 'Scout', mod: ScoutMod },
{ key: 'training', label: 'Training', mod: TrainingMod },
{ key: 'graphs', label: 'Graphs', mod: GraphsMod }
];
const switchTab = (key) => {
activeMainTab = key;
/* highlight active button */
document.querySelectorAll('.tmpe-main-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === key));
/* show / hide panels */
document.querySelectorAll('.tmpe-panel').forEach(p =>
p.style.display = p.dataset.tab === key ? '' : 'none');
if (dataLoaded[key]) return; /* already rendered, keep DOM intact */
const panel = document.querySelector(`.tmpe-panel[data-tab="${key}"]`);
if (!panel) return;
panel.innerHTML = '<div class="tmpe-loading"><div class="tmpe-spinner"></div><div class="tmpe-loading-text">Loading…</div></div>';
$.post('/ajax/players_get_info.ajax.php', {
player_id: PLAYER_ID,
type: key,
show_non_pro_graphs: true
}).done(res => {
try {
const data = typeof res === 'object' ? res : JSON.parse(res);
dataLoaded[key] = true;
const tab = TABS.find(t => t.key === key);
if (tab) tab.mod.render(panel, data);
} catch (e) {
panel.innerHTML = '<div class="tmpe-loading"><div style="font-size:20px">⚠</div><div class="tmpe-loading-text" style="color:#f87171">Failed to load data</div></div>';
}
}).fail(() => {
panel.innerHTML = '<div class="tmpe-loading"><div style="font-size:20px">⚠</div><div class="tmpe-loading-text" style="color:#f87171">Failed to load data</div></div>';
});
};
let initRetries = 0;
const initUI = () => {
const tabsContent = document.querySelector('.tabs_content');
if (!tabsContent) {
if (initRetries++ < 50) setTimeout(initUI, 200);
return;
}
injectCSS();
/* Build container */
const container = document.createElement('div');
container.id = 'tmpe-container';
/* Tab bar */
const bar = document.createElement('div');
bar.className = 'tmpe-tabs-bar';
const TAB_ICONS = { history: '📋', scout: '🔍', training: '⚙', graphs: '📊' };
TABS.forEach(t => {
const btn = document.createElement('button');
btn.className = 'tmpe-main-tab';
btn.dataset.tab = t.key;
btn.innerHTML = `<span class="tmpe-icon">${TAB_ICONS[t.key] || ''}</span>${t.label}`;
btn.addEventListener('click', () => switchTab(t.key));
bar.appendChild(btn);
});
container.appendChild(bar);
/* Panels */
const panels = document.createElement('div');
panels.className = 'tmpe-panels';
TABS.forEach(t => {
const p = document.createElement('div');
p.className = 'tmpe-panel';
p.dataset.tab = t.key;
p.style.display = 'none';
panels.appendChild(p);
});
container.appendChild(panels);
/* Insert before native .tabs_content */
tabsContent.parentNode.insertBefore(container, tabsContent);
/* Load default tab */
switchTab('history');
};
/* ═══════════════════════════════════════════════════════════
WINDOW RESIZE — redraw graphs
═══════════════════════════════════════════════════════════ */
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => GraphsMod.reRender(), 300);
});
/* ═══════════════════════════════════════════════════════════
INIT — wait for IndexedDB, then start everything
═══════════════════════════════════════════════════════════ */
PlayerDB.init().then(() => {
scanAndMigrateR6();
if (IS_SQUAD_PAGE) {
/* ── Squad page: ensure reserves visible, parse, process ── */
const runSquadSync = async () => {
await ensureAllPlayersVisible();
const parsed = parseSquadPage();
if (parsed && parsed.length) {
await processSquadPage(parsed);
} else {
console.warn('[Squad] No players parsed from table');
}
/* ── Watch for hash changes (toggle clicks) to re-process newly visible players ── */
const processedPids = new Set((parsed || []).map(p => p.pid));
let hashProcessing = false;
window.addEventListener('hashchange', async () => {
if (hashProcessing) return;
hashProcessing = true;
try {
await new Promise(r => setTimeout(r, 600)); /* Wait for DOM update */
const reParsed = parseSquadPage();
if (!reParsed) { hashProcessing = false; return; }
const newPlayers = reParsed.filter(p => !processedPids.has(p.pid));
if (newPlayers.length > 0) {
console.log(`%c[Squad] Detected ${newPlayers.length} new players after toggle`, 'font-weight:bold;color:#38bdf8');
newPlayers.forEach(p => processedPids.add(p.pid));
await processSquadPage(newPlayers);
}
} catch (e) { console.error('[Squad] Re-process error:', e); }
hashProcessing = false;
});
};
runSquadSync().catch(e => console.error('[Squad] Squad sync error:', e));
return; /* Don't run player-specific code on squad page */
}
fetchTooltip();
initUI();
}).catch(e => {
console.warn('[DB] IndexedDB init failed, falling back:', e);
if (IS_SQUAD_PAGE) {
const parsed = parseSquadPage();
if (parsed && parsed.length) processSquadPage(parsed);
return;
}
fetchTooltip();
initUI();
});
/* ═══════════════════════════════════════════════════════════
R5 / TI CALCULATION
═══════════════════════════════════════════════════════════ */
const POS_MULTIPLIERS = [0.3, 0.3, 0.9, 0.6, 1.5, 0.9, 0.9, 0.6, 0.3];
const COLOR_LEVELS = [
{ color: '#ff4c4c' }, { color: '#ff8c00' }, { color: '#ffd700' },
{ color: '#90ee90' }, { color: '#00cfcf' }, { color: '#5b9bff' }, { color: '#cc88ff' }
];
const R5_THRESHOLDS = [110, 100, 90, 80, 70, 60, 0];
const TI_THRESHOLDS = [12, 9, 6, 4, 2, 1, -Infinity];
const REC_THRESHOLDS = [5.5, 5, 4, 3, 2, 1, 0];
const RTN_THRESHOLDS = [90, 60, 40, 30, 20, 10, 0];
const WEIGHT_R5 = [
[0.41029304, 0.18048062, 0.56730138, 1.06344654, 1.02312672, 0.40831256, 0.58235457, 0.12717479, 0.05454137, 0.09089830, 0.42381693, 0.04626272, 0.02199046, 0],
[0.42126371, 0.18293193, 0.60567629, 0.91904794, 0.89070915, 0.40038476, 0.56146633, 0.15053902, 0.15955429, 0.15682932, 0.42109742, 0.09460329, 0.03589655, 0],
[0.23412419, 0.32032289, 0.62194779, 0.63162534, 0.63143081, 0.45218831, 0.47370658, 0.55054737, 0.17744915, 0.39932519, 0.26915814, 0.16413124, 0.07404301, 0],
[0.27276905, 0.26814289, 0.61104798, 0.39865092, 0.42862643, 0.43582015, 0.46617076, 0.44931076, 0.25175412, 0.46446692, 0.29986350, 0.43843061, 0.21494592, 0],
[0.25219260, 0.25112993, 0.56090649, 0.18230261, 0.18376490, 0.45928749, 0.53498118, 0.59461481, 0.09851189, 0.61601950, 0.31243959, 0.65402884, 0.29982016, 0],
[0.28155678, 0.24090675, 0.60680245, 0.19068879, 0.20018012, 0.45148647, 0.48230007, 0.42982389, 0.26268609, 0.57933805, 0.31712419, 0.65824985, 0.29885649, 0],
[0.22029884, 0.29229690, 0.63248227, 0.09904394, 0.10043602, 0.47469498, 0.52919791, 0.77555880, 0.10531819, 0.71048302, 0.27667115, 0.56813972, 0.21537826, 0],
[0.21151292, 0.35804710, 0.88688492, 0.14391236, 0.13769621, 0.46586605, 0.34446036, 0.51377701, 0.59723919, 0.75126119, 0.16550722, 0.29966502, 0.12417045, 0],
[0.35479780, 0.14887553, 0.43273380, 0.00023928, 0.00021111, 0.46931131, 0.57731335, 0.41686333, 0.05607604, 0.62121195, 0.45370457, 1.03660702, 0.43205492, 0],
[0.45462811, 0.30278232, 0.45462811, 0.90925623, 0.45462811, 0.90925623, 0.45462811, 0.45462811, 0.30278232, 0.15139116, 0.15139116]
];
const WEIGHT_RB = [
[0.10493615, 0.05208547, 0.07934211, 0.14448971, 0.13159554, 0.06553072, 0.07778375, 0.06669303, 0.05158306, 0.02753168, 0.12055170, 0.01350989, 0.02549169, 0.03887550],
[0.07715535, 0.04943315, 0.11627229, 0.11638685, 0.12893778, 0.07747251, 0.06370799, 0.03830611, 0.10361093, 0.06253997, 0.09128094, 0.01314110, 0.02449199, 0.03726305],
[0.08219824, 0.08668831, 0.07434242, 0.09661001, 0.08894242, 0.08998026, 0.09281287, 0.08868309, 0.04753574, 0.06042619, 0.05396986, 0.05059984, 0.05660203, 0.03060871],
[0.06744248, 0.06641401, 0.09977251, 0.08253749, 0.09709316, 0.09241026, 0.08513703, 0.06127851, 0.10275520, 0.07985941, 0.04618960, 0.03927270, 0.05285911, 0.02697852],
[0.07304213, 0.08174111, 0.07248656, 0.08482334, 0.07078726, 0.09568392, 0.09464529, 0.09580381, 0.04746231, 0.07093008, 0.04595281, 0.05955544, 0.07161249, 0.03547345],
[0.06527363, 0.06410270, 0.09701305, 0.07406706, 0.08563595, 0.09648566, 0.08651209, 0.06357183, 0.10819222, 0.07386495, 0.03245554, 0.05430668, 0.06572005, 0.03279859],
[0.07842736, 0.07744888, 0.07201150, 0.06734457, 0.05002348, 0.08350204, 0.08207655, 0.11181914, 0.03756112, 0.07486004, 0.06533972, 0.07457344, 0.09781475, 0.02719742],
[0.06545375, 0.06145378, 0.10503536, 0.06421508, 0.07627526, 0.09232981, 0.07763931, 0.07001035, 0.11307331, 0.07298351, 0.04248486, 0.06462713, 0.07038293, 0.02403557],
[0.07738289, 0.05022488, 0.07790481, 0.01356516, 0.01038191, 0.06495444, 0.07721954, 0.07701905, 0.02680715, 0.07759692, 0.12701687, 0.15378395, 0.12808992, 0.03805251],
[0.07466384, 0.07466384, 0.07466384, 0.14932769, 0.10452938, 0.14932769, 0.10452938, 0.10344411, 0.07512610, 0.04492581, 0.04479831]
];
const WAGE_RATE = 15.8079;
const TRAINING1 = new Date('2023-01-16T23:00:00Z');
const SEASON_DAYS = 84;
const getCurrentSession = () => {
const now = new Date();
let day = (now.getTime() - TRAINING1.getTime()) / 1000 / 3600 / 24;
while (day > SEASON_DAYS - 16 / 24) day -= SEASON_DAYS;
const s = Math.floor(day / 7) + 1;
return s <= 0 ? 12 : s;
};
const currentSession = getCurrentSession();
const posGroupColor = posIdx => {
if (posIdx === 9) return '#4ade80';
if (posIdx <= 1) return '#60a5fa';
if (posIdx <= 7) return '#fbbf24';
return '#f87171';
};
const fix2 = v => (Math.round(v * 100) / 100).toFixed(2);
const getColor = (value, thresholds) => {
for (let i = 0; i < thresholds.length; i++) {
if (value >= thresholds[i]) return COLOR_LEVELS[i].color;
}
return COLOR_LEVELS[COLOR_LEVELS.length - 1].color;
};
const getPositionIndex = pos => {
switch ((pos || '').toLowerCase()) {
case 'gk': return 9;
case 'dc': case 'dcl': case 'dcr': return 0;
case 'dr': case 'dl': return 1;
case 'dmc': case 'dmcl': case 'dmcr': return 2;
case 'dmr': case 'dml': return 3;
case 'mc': case 'mcl': case 'mcr': return 4;
case 'mr': case 'ml': return 5;
case 'omc': case 'omcl': case 'omcr': return 6;
case 'omr': case 'oml': return 7;
case 'fc': case 'fcl': case 'fcr': case 'f': return 8;
default: return 0;
}
};
const calculateRemainders = (posIdx, skills, asi) => {
const weight = posIdx === 9 ? 48717927500 : 263533760000;
const skillSum = skills.reduce((sum, s) => sum + parseInt(s), 0);
const remainder = Math.round((Math.pow(2, Math.log(weight * asi) / Math.log(Math.pow(2, 7))) - skillSum) * 10) / 10;
let rec = 0, ratingR = 0, remainderW1 = 0, remainderW2 = 0, not20 = 0;
for (let i = 0; i < WEIGHT_RB[posIdx].length; i++) {
rec += skills[i] * WEIGHT_RB[posIdx][i];
ratingR += skills[i] * WEIGHT_R5[posIdx][i];
if (skills[i] != 20) { remainderW1 += WEIGHT_RB[posIdx][i]; remainderW2 += WEIGHT_R5[posIdx][i]; not20++; }
}
if (remainder / not20 > 0.9 || !not20) { not20 = posIdx === 9 ? 11 : 14; remainderW1 = 1; remainderW2 = 5; }
rec = fix2((rec + remainder * remainderW1 / not20 - 2) / 3);
return { remainder, remainderW2, not20, ratingR, rec };
};
/* Float-aware version: uses parseFloat for skillSum so remainder ≈ 0 with decimal skills */
const calculateRemaindersF = (posIdx, skills, asi) => {
const weight = posIdx === 9 ? 48717927500 : 263533760000;
const skillSum = skills.reduce((sum, s) => sum + parseFloat(s), 0);
const remainder = Math.round((Math.pow(2, Math.log(weight * asi) / Math.log(Math.pow(2, 7))) - skillSum) * 10) / 10;
let rec = 0, ratingR = 0, remainderW1 = 0, remainderW2 = 0, not20 = 0;
for (let i = 0; i < WEIGHT_RB[posIdx].length; i++) {
rec += skills[i] * WEIGHT_RB[posIdx][i];
ratingR += skills[i] * WEIGHT_R5[posIdx][i];
if (skills[i] != 20) { remainderW1 += WEIGHT_RB[posIdx][i]; remainderW2 += WEIGHT_R5[posIdx][i]; not20++; }
}
if (!not20) { not20 = posIdx === 9 ? 11 : 14; remainderW1 = 1; remainderW2 = 5; }
rec = fix2((rec + remainder * remainderW1 / not20 - 2) / 3);
return { remainder, remainderW2, not20, ratingR, rec };
};
const calculateR5 = (posIdx, skills, asi, rou) => {
const r = calculateRemainders(posIdx, skills, asi);
const routineBonus = (3 / 100) * (100 - 100 * Math.pow(Math.E, -rou * 0.035));
let rating = Number(fix2(r.ratingR + (r.remainder * r.remainderW2 / r.not20) + routineBonus * 5));
const rou2 = routineBonus;
const goldstar = skills.filter(s => s == 20).length;
const skillsB = skills.map(s => s == 20 ? 20 : s * 1 + r.remainder / (skills.length - goldstar));
const sr = skillsB.map((s, i) => i === 1 ? s : s + rou2);
if (skills.length !== 11) {
const { pow, E } = Math;
const hb = sr[10] > 12 ? fix2((pow(E, (sr[10] - 10) ** 3 / 1584.77) - 1) * 0.8 + pow(E, sr[0] ** 2 * 0.007 / 8.73021) * 0.15 + pow(E, sr[6] ** 2 * 0.007 / 8.73021) * 0.05) : 0;
const fk = fix2(pow(E, (sr[13] + sr[12] + sr[9] * 0.5) ** 2 * 0.002) / 327.92526);
const ck = fix2(pow(E, (sr[13] + sr[8] + sr[9] * 0.5) ** 2 * 0.002) / 983.65770);
const pk = fix2(pow(E, (sr[13] + sr[11] + sr[9] * 0.5) ** 2 * 0.002) / 1967.31409);
const ds = sr[0] ** 2 + sr[1] ** 2 * 0.5 + sr[2] ** 2 * 0.5 + sr[3] ** 2 + sr[4] ** 2 + sr[5] ** 2 + sr[6] ** 2;
const os = sr[0] ** 2 * 0.5 + sr[1] ** 2 * 0.5 + sr[2] ** 2 + sr[3] ** 2 + sr[4] ** 2 + sr[5] ** 2 + sr[6] ** 2;
const m = POS_MULTIPLIERS[posIdx];
return fix2(rating + hb * 1 + fk * 1 + ck * 1 + pk * 1 + fix2(ds / 6 / 22.9 ** 2) * m + fix2(os / 6 / 22.9 ** 2) * m);
}
return fix2(rating);
};
/* Float-aware R5: uses calculateRemaindersF so remainder ≈ 0 with decimal skills */
const calculateR5F = (posIdx, skills, asi, rou) => {
const r = calculateRemaindersF(posIdx, skills, asi);
const routineBonus = (3 / 100) * (100 - 100 * Math.pow(Math.E, -rou * 0.035));
let rating = Number(fix2(r.ratingR + (r.remainder * r.remainderW2 / r.not20) + routineBonus * 5));
const rou2 = routineBonus;
const goldstar = skills.filter(s => s == 20).length;
const skillsB = skills.map(s => s == 20 ? 20 : s * 1 + r.remainder / (skills.length - goldstar));
const sr = skillsB.map((s, i) => i === 1 ? s : s + rou2);
if (skills.length !== 11) {
const { pow, E } = Math;
const hb = sr[10] > 12 ? fix2((pow(E, (sr[10] - 10) ** 3 / 1584.77) - 1) * 0.8 + pow(E, sr[0] ** 2 * 0.007 / 8.73021) * 0.15 + pow(E, sr[6] ** 2 * 0.007 / 8.73021) * 0.05) : 0;
const fk = fix2(pow(E, (sr[13] + sr[12] + sr[9] * 0.5) ** 2 * 0.002) / 327.92526);
const ck = fix2(pow(E, (sr[13] + sr[8] + sr[9] * 0.5) ** 2 * 0.002) / 983.65770);
const pk = fix2(pow(E, (sr[13] + sr[11] + sr[9] * 0.5) ** 2 * 0.002) / 1967.31409);
const ds = sr[0] ** 2 + sr[1] ** 2 * 0.5 + sr[2] ** 2 * 0.5 + sr[3] ** 2 + sr[4] ** 2 + sr[5] ** 2 + sr[6] ** 2;
const os = sr[0] ** 2 * 0.5 + sr[1] ** 2 * 0.5 + sr[2] ** 2 + sr[3] ** 2 + sr[4] ** 2 + sr[5] ** 2 + sr[6] ** 2;
const m = POS_MULTIPLIERS[posIdx];
return fix2(rating + hb * 1 + fk * 1 + ck * 1 + pk * 1 + fix2(ds / 6 / 22.9 ** 2) * m + fix2(os / 6 / 22.9 ** 2) * m);
}
return fix2(rating);
};
const calculateTI = (asi, wage, isGK) => {
if (!asi || !wage || wage <= 30000) return null;
const w = isGK ? 48717927500 : 263533760000;
const { pow, log, round } = Math;
const log27 = log(pow(2, 7));
return round((pow(2, log(w * asi) / log27) - pow(2, log(w * wage / WAGE_RATE) / log27)) * 10);
};
/* ═══════════════════════════════════════════════════════════
PLAYER CARD — replace native info_table
═══════════════════════════════════════════════════════════ */
const buildPlayerCard = () => {
const infoTable = document.querySelector('table.info_table.zebra');
if (!infoTable || !tooltipPlayer) return;
/* Extract data from DOM (before any DOM changes) */
const imgEl = infoTable.querySelector('img[src*="player_pic"]');
const photoSrc = imgEl ? imgEl.getAttribute('src') : '/pics/player_pic2.php';
const infoWrapper = infoTable.closest('div.std') || infoTable.parentElement;
const rowData = {};
infoTable.querySelectorAll('tr').forEach(tr => {
const th = tr.querySelector('th');
const td = tr.querySelector('td');
if (th && td) rowData[th.textContent.trim()] = td;
});
const clubTd = rowData['Club'];
const clubLink = clubTd ? clubTd.querySelector('a[club_link]') : null;
const clubName = clubLink ? clubLink.textContent.trim() : '-';
const clubHref = clubLink ? clubLink.getAttribute('href') : '';
const clubFlag = clubTd ? (clubTd.querySelector('.country_link') || { outerHTML: '' }).outerHTML : '';
const ageTxt = rowData['Age'] ? rowData['Age'].textContent.trim() : '-';
const hwRaw = rowData['Height / Weight'] ? rowData['Height / Weight'].textContent.trim() : '';
const hwParts = hwRaw.split('/').map(s => s.trim());
const heightTxt = hwParts[0] || '-';
const weightTxt = hwParts[1] || '-';
const wageTd = rowData['Wage'];
const wageTxt = wageTd ? wageTd.textContent.trim().replace(/[^0-9]/g, '') : '0';
const wageDisplay = wageTd ? wageTd.textContent.trim() : '-';
const wageNum = parseInt(wageTxt) || 0;
const asiTd = rowData['Skill Index'];
const asiTxt = asiTd ? asiTd.textContent.trim().replace(/[^0-9]/g, '') : '0';
const asiNum = parseInt(asiTxt) || 0;
const asiDisplay = asiTd ? asiTd.textContent.trim() : '-';
if (asiNum > 0) playerASI = asiNum;
const routineTd = rowData['Routine'];
const routineVal = routineTd ? parseFloat(routineTd.textContent.trim()) || 0 : 0;
playerRoutine = routineVal;
const statusTd = rowData['Status'];
const statusHtml = statusTd ? statusTd.innerHTML : '';
/* Player name and position from page header */
const headerEl = document.querySelector('.box_sub_header .large strong');
const playerName = headerEl ? headerEl.textContent.trim() : 'Player';
const posEl = document.querySelector('.favposition.long');
const posText = posEl ? posEl.textContent.trim() : '';
const flagEl = document.querySelector('.box_sub_header .country_link');
const flagHtml = flagEl ? flagEl.outerHTML : '';
const hasNT = !!document.querySelector('.nt_icon');
/* Parse positions (comma-separated from tooltip) */
const positions = playerPosition ? playerPosition.split(',').map(s => s.trim()) : [];
positions.sort((a, b) => getPositionIndex(a) - getPositionIndex(b));
const posList = positions.map(s => ({ name: s.toUpperCase(), idx: getPositionIndex(s) }));
const posIdx = posList.length > 0 ? posList[0].idx : 0;
/* Recommendation stars from DOM */
const recTd = rowData['Recommendation'];
let recStarsHtml = '';
if (recTd) {
const halfStars = (recTd.innerHTML.match(/half_star\.png/g) || []).length;
const darkStars = (recTd.innerHTML.match(/dark_star\.png/g) || []).length;
const allStarMatches = (recTd.innerHTML.match(/star\.png/g) || []).length;
const fullStars = allStarMatches - halfStars - darkStars;
for (let i = 0; i < fullStars; i++) recStarsHtml += '<span class="tmpc-star-full">★</span>';
if (halfStars) recStarsHtml += '<span class="tmpc-star-half">★</span>';
const empty = 5 - fullStars - (halfStars ? 1 : 0);
for (let i = 0; i < empty; i++) recStarsHtml += '<span class="tmpc-star-empty">★</span>';
}
/* R5 / REC / TI calculation — per position */
const posRatings = [];
const allPosRatings = [];
const ALL_OUTFIELD_POS = [
{ name: 'DC', idx: 0 }, { name: 'DL/DR', idx: 1 },
{ name: 'DMC', idx: 2 }, { name: 'DML/DMR', idx: 3 },
{ name: 'MC', idx: 4 }, { name: 'ML/MR', idx: 5 },
{ name: 'OMC', idx: 6 }, { name: 'OML/OMR', idx: 7 },
{ name: 'FC', idx: 8 }
];
let tiVal = null;
if (tooltipSkills && posList.length > 0) {
const sv = (name) => {
const sk = tooltipSkills.find(s => s.name === name);
if (!sk) return 0;
const v = sk.value;
if (typeof v === 'string' && v.includes('star')) return v.includes('silver') ? 19 : 20;
return parseInt(v) || 0;
};
let skills;
if (posIdx === 9) {
skills = [sv('Strength'), sv('Pace'), sv('Jumping'), sv('Stamina'), sv('One on ones'), sv('Reflexes'), sv('Aerial Ability'), sv('Communication'), sv('Kicking'), sv('Throwing'), sv('Handling')];
} else {
skills = [sv('Strength'), sv('Stamina'), sv('Pace'), sv('Marking'), sv('Tackling'), sv('Workrate'), sv('Positioning'), sv('Passing'), sv('Crossing'), sv('Technique'), sv('Heading'), sv('Finishing'), sv('Longshots'), sv('Set Pieces')];
}
if (asiNum > 0 && skills.some(s => s > 0)) {
/* Try to load decimal skills from v3 store for more precise R5/REC */
let decSkills = skills; // fallback: integer skills
let decRoutine = routineVal;
try {
const v3Store = PlayerDB.get(PLAYER_ID);
if (v3Store && v3Store._v >= 3 && v3Store.records) {
const recKeys = Object.keys(v3Store.records).sort((a, b) => {
const [ay, am] = a.split('.').map(Number);
const [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
});
if (recKeys.length > 0) {
const lastRec = v3Store.records[recKeys[recKeys.length - 1]];
if (lastRec && lastRec.skills && lastRec.skills.length === skills.length) {
decSkills = lastRec.skills;
if (lastRec.routine != null) decRoutine = lastRec.routine;
console.log('[Card] Using v3 decimal skills for R5/REC');
}
}
}
} catch (e) { }
for (const pp of posList) {
const r5 = Number(calculateR5F(pp.idx, decSkills, asiNum, decRoutine));
const rec = Number(calculateRemaindersF(pp.idx, decSkills, asiNum).rec);
posRatings.push({ name: pp.name, idx: pp.idx, r5, rec });
}
/* Calculate R5/REC for ALL outfield positions */
if (posIdx !== 9) {
const playerIdxSet = new Set(posList.map(p => p.idx));
for (const ap of ALL_OUTFIELD_POS) {
const r5 = Number(calculateR5F(ap.idx, decSkills, asiNum, decRoutine));
const rec = Number(calculateRemaindersF(ap.idx, decSkills, asiNum).rec);
allPosRatings.push({ name: ap.name, idx: ap.idx, r5, rec, isPlayerPos: playerIdxSet.has(ap.idx) });
}
}
}
if (asiNum > 0 && wageNum > 0) {
const tiRaw = calculateTI(asiNum, wageNum, posIdx === 9);
tiVal = tiRaw !== null && currentSession > 0
? Number((tiRaw / currentSession).toFixed(1)) : null;
if (tiVal !== null) playerTI = tiVal;
}
}
/* Build HTML */
let html = `<div class="tmpc-card">`;
html += `<div class="tmpc-header">`;
html += `<img class="tmpc-photo" src="${photoSrc}">`;
html += `<div class="tmpc-info">`;
html += `<div class="tmpc-top-grid">`;
const ntBadge = hasNT ? `<span class="tmpc-nt">🏆 NT</span>` : '';
html += `<div class="tmpc-name">${playerName} ${flagHtml}</div>`;
html += `<span class="tmpc-badge-chip"><span class="tmpc-badge-lbl">ASI</span><span style="color:${asiNum > 0 ? '#e8f5d8' : '#5a7a48'}">${asiDisplay}</span></span>`;
const posChips = posList.map(pp => {
const clr = posGroupColor(pp.idx);
return `<span class="tmpc-pos" style="background:${clr}22;border:1px solid ${clr}44;color:${clr}">${pp.name}</span>`;
}).join('');
html += `<div class="tmpc-pos-row">${posChips || posText}${ntBadge}</div>`;
html += `<span class="tmpc-badge-chip"><span class="tmpc-badge-lbl">TI</span><span style="color:${tiVal !== null ? getColor(tiVal, TI_THRESHOLDS) : '#5a7a48'}">${tiVal !== null ? tiVal.toFixed(1) : '—'}</span></span>`;
html += `</div>`;
html += `<div class="tmpc-details">`;
html += `<div class="tmpc-detail"><span class="tmpc-lbl">Club</span><span class="tmpc-val"><a href="${clubHref}" style="color:#80e048;text-decoration:none;font-weight:600">${clubName}</a> ${clubFlag}</span></div>`;
html += `<div class="tmpc-detail"><span class="tmpc-lbl">Age</span><span class="tmpc-val">${ageTxt}</span></div>`;
html += `<div class="tmpc-detail"><span class="tmpc-lbl">Height</span><span class="tmpc-val">${heightTxt}</span></div>`;
html += `<div class="tmpc-detail"><span class="tmpc-lbl">Weight</span><span class="tmpc-val">${weightTxt}</span></div>`;
html += `<div class="tmpc-detail"><span class="tmpc-lbl">Wage</span><span class="tmpc-val" style="color:#fbbf24">${wageDisplay}</span></div>`;
html += `<div class="tmpc-detail"><span class="tmpc-lbl">Status</span><span class="tmpc-val">${statusHtml}</span></div>`;
html += `<div class="tmpc-detail"><span class="tmpc-lbl">REC</span><span class="tmpc-rec-stars">${recStarsHtml}</span></div>`;
html += `<div class="tmpc-detail"><span class="tmpc-lbl">Routine</span><span class="tmpc-val" style="color:${getColor(routineVal, RTN_THRESHOLDS)}">${routineVal.toFixed(1)}</span></div>`;
html += `</div>`; /* details */
html += `</div>`; /* info */
html += `</div>`; /* header */
/* Position ratings — R5 & REC per position */
if (posRatings.length > 0) {
html += `<div class="tmpc-pos-ratings">`;
for (const pr of posRatings) {
const clr = posGroupColor(pr.idx);
html += `<div class="tmpc-rating-row">`;
html += `<div class="tmpc-pos-bar" style="background:${clr}"></div>`;
html += `<span class="tmpc-pos-name" style="color:${clr}">${pr.name}</span>`;
html += `<span class="tmpc-pos-stat"><span class="tmpc-pos-stat-lbl">R5</span><span class="tmpc-pos-stat-val" style="color:${getColor(pr.r5, R5_THRESHOLDS)}">${pr.r5.toFixed(2)}</span></span>`;
html += `<span class="tmpc-pos-stat"><span class="tmpc-pos-stat-lbl">REC</span><span class="tmpc-pos-stat-val" style="color:${getColor(pr.rec, REC_THRESHOLDS)}">${pr.rec.toFixed(2)}</span></span>`;
html += `</div>`;
}
/* Expand chevron for all positions (non-GK only) */
if (allPosRatings.length > 0) {
html += `<div class="tmpc-expand-toggle" onclick="this.classList.toggle('tmpc-expanded');this.nextElementSibling.classList.toggle('tmpc-expanded')">`;
html += `<span>All Positions</span><span class="tmpc-expand-chevron">▼</span>`;
html += `</div>`;
html += `<div class="tmpc-all-positions">`;
for (const ap of allPosRatings) {
const clr = posGroupColor(ap.idx);
const playerCls = ap.isPlayerPos ? ' tmpc-is-player-pos' : '';
html += `<div class="tmpc-rating-row${playerCls}">`;
html += `<div class="tmpc-pos-bar" style="background:${clr}"></div>`;
html += `<span class="tmpc-pos-name" style="color:${clr}">${ap.name}</span>`;
html += `<span class="tmpc-pos-stat"><span class="tmpc-pos-stat-lbl">R5</span><span class="tmpc-pos-stat-val" style="color:${getColor(ap.r5, R5_THRESHOLDS)}">${ap.r5.toFixed(2)}</span></span>`;
html += `<span class="tmpc-pos-stat"><span class="tmpc-pos-stat-lbl">REC</span><span class="tmpc-pos-stat-val" style="color:${getColor(ap.rec, REC_THRESHOLDS)}">${ap.rec.toFixed(2)}</span></span>`;
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`; /* card */
/* ── Clean column2_a: strip TM box chrome ── */
const col = document.querySelector('.column2_a');
if (!col) return;
const box = col.querySelector(':scope > .box');
const boxBody = box ? box.querySelector(':scope > .box_body') : null;
if (box && boxBody) {
[...boxBody.children].forEach(el => {
if (!el.classList.contains('box_shadow')) col.appendChild(el);
});
box.remove();
}
/* Remove h3 headers (Skills, Player Info) */
col.querySelectorAll(':scope > h3').forEach(h => h.remove());
/* Remove box_sub_header */
const subHeader = document.querySelector('.box_sub_header.align_center');
if (subHeader) subHeader.remove();
/* Replace info_table wrapper with our card */
const cardEl = document.createElement('div');
cardEl.innerHTML = html;
if (infoWrapper && infoWrapper.parentNode === col) {
col.replaceChild(cardEl.firstChild, infoWrapper);
} else {
col.prepend(cardEl.firstChild);
}
};
/* ═══════════════════════════════════════════════════════════
SKILLS GRID — replace native skill table
═══════════════════════════════════════════════════════════ */
const skillColor = v => {
if (v >= 20) return 'gold';
if (v >= 19) return 'silver';
if (v >= 16) return '#66dd44';
if (v >= 12) return '#cccc00';
if (v >= 8) return '#ee9900';
return '#ee6633';
};
const buildSkillsGrid = () => {
const skillTable = document.querySelector('table.skill_table.zebra');
if (!skillTable) return;
/* Parse skills from native table */
const SKILL_ORDER = [
['Strength', 'Passing'],
['Stamina', 'Crossing'],
['Pace', 'Technique'],
['Marking', 'Heading'],
['Tackling', 'Finishing'],
['Workrate', 'Longshots'],
['Positioning', 'Set Pieces']
];
const GK_SKILL_ORDER = [
['Strength', 'Handling'],
['Stamina', 'One on ones'],
['Pace', 'Reflexes'],
[null, 'Aerial Ability'],
[null, 'Jumping'],
[null, 'Communication'],
[null, 'Kicking'],
[null, 'Throwing']
];
const parseSkillVal = (td) => {
if (!td) return 0;
const img = td.querySelector('img');
if (img) {
const alt = img.getAttribute('alt');
if (alt) return parseInt(alt) || 0;
const src = img.getAttribute('src') || '';
if (src.includes('star_silver')) return 19;
if (src.includes('star.png')) return 20;
}
const txt = td.textContent.trim();
return parseInt(txt) || 0;
};
/* Build skill map from existing rows */
const skillMap = {};
const rows = skillTable.querySelectorAll('tr');
rows.forEach(row => {
const ths = row.querySelectorAll('th');
const tds = row.querySelectorAll('td');
ths.forEach((th, i) => {
const name = th.textContent.trim();
if (name && tds[i]) {
skillMap[name] = parseSkillVal(tds[i]);
}
});
});
/* Detect GK from DOM skill names (fallback for async tooltip timing) */
const isGK = isGoalkeeper || 'Handling' in skillMap;
/* Parse hidden skills from DOM */
const hiddenTable = document.querySelector('#hidden_skill_table');
const hiddenSkills = [];
let hasHiddenValues = false;
if (hiddenTable) {
const hRows = hiddenTable.querySelectorAll('tr');
hRows.forEach(row => {
const ths = row.querySelectorAll('th');
const tds = row.querySelectorAll('td');
ths.forEach((th, i) => {
const name = th.textContent.trim();
const td = tds[i];
let val = '';
let numVal = 0;
if (td) {
/* Check tooltip for numeric value */
const tip = td.getAttribute('tooltip') || '';
const tipMatch = tip.match(/(\d+)\/20/);
if (tipMatch) numVal = parseInt(tipMatch[1]) || 0;
val = td.textContent.trim();
}
if (name) {
hiddenSkills.push({ name, val, numVal });
if (val) hasHiddenValues = true;
}
});
});
}
/* Compute decimal skill values — prefer stored localStorage record for current age */
const NAMES_OUT_R5 = ['Strength', 'Stamina', 'Pace', 'Marking', 'Tackling', 'Workrate', 'Positioning', 'Passing', 'Crossing', 'Technique', 'Heading', 'Finishing', 'Longshots', 'Set Pieces'];
const NAMES_GK_R5 = ['Strength', 'Stamina', 'Pace', 'Handling', 'One on ones', 'Reflexes', 'Aerial Ability', 'Jumping', 'Communication', 'Kicking', 'Throwing'];
const skillNames = isGK ? NAMES_GK_R5 : NAMES_OUT_R5;
const decimalSkillMap = { ...skillMap };
let usedStorage = false;
if (playerAge !== null && playerMonths !== null) {
const ageKey = `${parseInt(playerAge)}.${playerMonths}`;
try {
const store = PlayerDB.get(PLAYER_ID);
if (store && store._v >= 1 && store.records && store.records[ageKey] && Array.isArray(store.records[ageKey].skills)) {
const stored = store.records[ageKey].skills;
skillNames.forEach((name, i) => {
if (stored[i] !== undefined) decimalSkillMap[name] = stored[i];
});
usedStorage = true;
}
} catch (e) { }
}
if (!usedStorage && playerASI && playerASI > 0) {
const skillsArr = skillNames.map(n => skillMap[n] || 0);
const w = isGK ? 48717927500 : 263533760000;
const log27 = Math.log(Math.pow(2, 7));
const skillSum = skillsArr.reduce((a, b) => a + b, 0);
const remainder = Math.round((Math.pow(2, Math.log(w * playerASI) / log27) - skillSum) * 10) / 10;
const goldstar = skillsArr.filter(s => s === 20).length;
const nonStar = skillsArr.length - goldstar;
if (remainder > 0 && nonStar > 0) {
const dec = remainder / nonStar;
skillNames.forEach(name => {
if ((skillMap[name] || 0) !== 20) decimalSkillMap[name] = (skillMap[name] || 0) + dec;
});
}
}
/* Build display */
const renderVal = (v) => {
const floor = Math.floor(v);
const frac = v - floor;
if (floor >= 20) return `<span class="tmps-star" style="color:gold">★</span>`;
if (floor >= 19) {
const fracStr = frac > 0.005 ? `<span class="tmps-dec">.${Math.round(frac * 100).toString().padStart(2, '0')}</span>` : '';
return `<span class="tmps-star" style="color:silver">★${fracStr}</span>`;
}
const dispVal = frac > 0.005 ? `${floor}<span class="tmps-dec">.${Math.round(frac * 100).toString().padStart(2, '0')}</span>` : floor;
return `<span style="color:${skillColor(floor)}">${dispVal}</span>`;
};
let leftCol = '', rightCol = '';
const activeOrder = isGK ? GK_SKILL_ORDER : SKILL_ORDER;
activeOrder.forEach(([left, right]) => {
if (left) {
const lv = decimalSkillMap[left] || 0;
leftCol += `<div class="tmps-row"><span class="tmps-name">${left}</span><span class="tmps-val">${renderVal(lv)}</span></div>`;
} else {
leftCol += `<div class="tmps-row" style="visibility:hidden"><span class="tmps-name"> </span><span class="tmps-val"> </span></div>`;
}
if (right) {
const rv = decimalSkillMap[right] || 0;
rightCol += `<div class="tmps-row"><span class="tmps-name">${right}</span><span class="tmps-val">${renderVal(rv)}</span></div>`;
}
});
let hiddenH = '';
if (hasHiddenValues) {
let hLeft = '', hRight = '';
for (let i = 0; i < hiddenSkills.length; i++) {
const hs = hiddenSkills[i];
const color = hs.numVal ? skillColor(hs.numVal) : '#6a9a58';
const row = `<div class="tmps-row"><span class="tmps-name">${hs.name}</span><span class="tmps-val" style="color:${color}">${hs.val || '-'}</span></div>`;
if (i % 2 === 0) hLeft += row; else hRight += row;
}
hiddenH = `<div class="tmps-divider"></div><div class="tmps-hidden"><div>${hLeft}</div><div>${hRight}</div></div>`;
} else {
/* Get unlock button onclick from original */
const unlockBtn = document.querySelector('.hidden_skills_text .button');
const onclick = unlockBtn ? unlockBtn.getAttribute('onclick') || '' : '';
hiddenH = `<div class="tmps-divider"></div><div class="tmps-unlock"><span class="tmps-unlock-btn" onclick="${onclick}">Assess Hidden Skills <img src="/pics/pro_icon.png" class="pro_icon"></span></div>`;
}
const html = `<div class="tmps-wrap"><div class="tmps-grid"><div>${leftCol}</div><div>${rightCol}</div></div>${hiddenH}</div>`;
/* Replace the native div.std that contains skill_table */
const parentDiv = skillTable.closest('div.std');
if (parentDiv) {
const newDiv = document.createElement('div');
newDiv.innerHTML = html;
parentDiv.parentNode.replaceChild(newDiv, parentDiv);
}
};
/* Wait for DOM then replace */
let skillRetries = 0;
const tryBuildSkills = () => {
if (document.querySelector('table.skill_table.zebra')) {
buildSkillsGrid();
} else if (skillRetries++ < 30) {
setTimeout(tryBuildSkills, 200);
}
};
tryBuildSkills();
/* ═══════════════════════════════════════════════════════════
BEST ESTIMATE — fetch scout data, render card in column1
═══════════════════════════════════════════════════════════ */
const fetchBestEstimate = () => {
const renderCard = (data) => {
const html = ScoutMod.getEstimateHtml(data || {});
if (!html) return;
const col1 = document.querySelector('.column1');
if (!col1) return;
const existing = col1.querySelector('#tmbe-standalone');
if (existing) existing.remove();
const el = document.createElement('div');
el.id = 'tmbe-standalone';
el.innerHTML = html;
const nav = col1.querySelector('.tmcn-nav');
if (nav && nav.nextSibling) {
col1.insertBefore(el, nav.nextSibling);
} else {
col1.appendChild(el);
}
};
$.post('/ajax/players_get_info.ajax.php', {
player_id: PLAYER_ID, type: 'scout', show_non_pro_graphs: true
}).done(res => {
try {
const data = typeof res === 'object' ? res : JSON.parse(res);
renderCard(data);
} catch (e) { renderCard({}); }
}).fail(() => { renderCard({}); });
};
/* ═══════════════════════════════════════════════════════════
SIDEBAR — restyle column3_a
═══════════════════════════════════════════════════════════ */
const buildSidebar = () => {
const col3 = document.querySelector('.column3_a');
if (!col3) return;
/* ── Extract data before destroying DOM ── */
/* Transfer buttons */
const transferBox = col3.querySelector('.transfer_box');
const btnData = [];
let transferListed = null; /* { playerId, playerName, minBid } if external player is listed */
if (transferBox) {
/* Check if this is an external player on the transfer list */
const tbText = transferBox.textContent || '';
const bidBtn = transferBox.querySelector('[onclick*="tlpop_pop_transfer_bid"]');
if (bidBtn && tbText.includes('transferlisted')) {
const bidMatch = bidBtn.getAttribute('onclick').match(/tlpop_pop_transfer_bid\(['"]([^'"]*)['"]\s*,\s*\d+\s*,\s*(\d+)\s*,\s*['"]([^'"]*)['"]/);
if (bidMatch) {
transferListed = { minBid: bidMatch[1], playerId: bidMatch[2], playerName: bidMatch[3] };
}
}
if (!transferListed) {
transferBox.querySelectorAll('span.button').forEach(btn => {
const onclick = btn.getAttribute('onclick') || '';
const label = btn.textContent.trim();
const imgSrc = btn.querySelector('img');
let icon = '⚡', cls = 'muted';
if (/set_asking/i.test(onclick)) { icon = '💰'; cls = 'yellow'; }
else if (/reject/i.test(onclick)) { icon = '🚫'; cls = 'red'; }
else if (/transferlist/i.test(onclick)) { icon = '📋'; cls = 'green'; }
else if (/fire/i.test(onclick)) { icon = '🗑️'; cls = 'red'; }
btnData.push({ onclick, label, icon, cls });
});
}
}
/* Other options buttons */
const otherBtns = [];
const otherSection = col3.querySelectorAll('.box_body .std.align_center');
const otherDiv = otherSection.length > 1 ? otherSection[1] : (otherSection[0] && !otherSection[0].classList.contains('transfer_box') ? otherSection[0] : null);
/* Note text */
let noteText = '';
const notePar = col3.querySelector('p.dark.rounded');
if (notePar) {
noteText = notePar.innerHTML.replace(/<span[^>]*>Note:\s*<\/span>/i, '').replace(/<br\s*\/?>/gi, ' ').trim();
}
if (otherDiv) {
otherDiv.querySelectorAll('span.button').forEach(btn => {
const onclick = btn.getAttribute('onclick') || '';
const label = btn.textContent.trim();
let icon = '⚙️', cls = 'muted';
if (/note/i.test(label)) { icon = '📝'; cls = 'blue'; }
else if (/nickname/i.test(label)) { icon = '🏷️'; cls = 'muted'; }
else if (/favorite.*pos/i.test(label)) { icon = '🔄'; cls = 'muted'; }
else if (/compare/i.test(label)) { icon = '⚖️'; cls = 'blue'; }
else if (/demote/i.test(label)) { icon = '⬇️'; cls = 'red'; }
else if (/promote/i.test(label)) { icon = '⬆️'; cls = 'green'; }
otherBtns.push({ onclick, label, icon, cls });
});
}
/* Awards — parse structured data from each award_row */
const awardRows = [];
col3.querySelectorAll('.award_row').forEach(li => {
const img = li.querySelector('img');
const imgSrc = img ? img.getAttribute('src') : '';
const rawText = li.textContent.trim();
/* Determine award type from image */
let awardType = '', awardIcon = '🏆', iconCls = 'gold';
if (/award_year_u21/.test(imgSrc)) { awardType = 'U21 Player of the Year'; awardIcon = '🌟'; iconCls = 'silver'; }
else if (/award_year/.test(imgSrc)) { awardType = 'Player of the Year'; awardIcon = '🏆'; iconCls = 'gold'; }
else if (/award_goal_u21/.test(imgSrc)) { awardType = 'U21 Top Scorer'; awardIcon = '⚽'; iconCls = 'silver'; }
else if (/award_goal/.test(imgSrc)) { awardType = 'Top Scorer'; awardIcon = '⚽'; iconCls = 'gold'; }
/* Extract season number */
const seasonMatch = rawText.match(/season\s+(\d+)/i);
const season = seasonMatch ? seasonMatch[1] : '';
/* Extract league link + flag */
const leagueLink = li.querySelector('a[league_link]');
const leagueName = leagueLink ? leagueLink.textContent.trim() : '';
const leagueHref = leagueLink ? leagueLink.getAttribute('href') : '';
const flagEl = li.querySelector('.country_link');
const flagHtml = flagEl ? flagEl.outerHTML : '';
/* Extract stats: goals or rating */
let statText = '';
const goalMatch = rawText.match(/(\d+)\s+goals?\s+in\s+(\d+)\s+match/i);
const ratingMatch = rawText.match(/rating\s+of\s+([\d.]+)\s+in\s+(\d+)\s+match/i);
if (goalMatch) statText = `${goalMatch[1]} goals / ${goalMatch[2]} games`;
else if (ratingMatch) statText = `${ratingMatch[1]} avg / ${ratingMatch[2]} games`;
awardRows.push({ awardType, awardIcon, iconCls, season, leagueName, leagueHref, flagHtml, statText });
});
/* ── Build new sidebar HTML ── */
let h = '<div class="tmps-sidebar">';
/* Transfer Options (own player) */
if (btnData.length > 0) {
h += '<div class="tmps-section">';
h += '<div class="tmps-section-head">Transfer Options</div>';
h += '<div class="tmps-btn-list">';
for (const b of btnData) {
h += `<button class="tmps-btn ${b.cls}" onclick="${b.onclick.replace(/"/g, '"')}">`;
h += `<span class="tmps-btn-icon">${b.icon}</span>${b.label}`;
h += '</button>';
}
h += '</div></div>';
}
/* Transfer Listed (external player) — live card */
if (transferListed) {
h += '<div class="tmtf-card" id="tmtf-live"></div>';
}
/* Other Options */
if (noteText || otherBtns.length > 0) {
h += '<div class="tmps-section">';
h += '<div class="tmps-section-head">Options</div>';
if (noteText) {
h += `<div class="tmps-note">${noteText}</div>`;
}
if (otherBtns.length > 0) {
h += '<div class="tmps-btn-list">';
for (const b of otherBtns) {
const isCompare = /compare/i.test(b.label);
const oc = isCompare ? 'window.tmCompareOpen()' : b.onclick.replace(/"/g, '"');
h += `<button class="tmps-btn ${b.cls}" onclick="${oc}">`;
h += `<span class="tmps-btn-icon">${b.icon}</span>${b.label}`;
h += '</button>';
}
h += '</div>';
}
h += '</div>';
}
/* Awards */
if (awardRows.length > 0) {
h += '<div class="tmps-section">';
h += '<div class="tmps-section-head">Awards</div>';
h += '<div class="tmps-award-list">';
for (const a of awardRows) {
h += `<div class="tmps-award">`;
h += `<div class="tmps-award-icon ${a.iconCls}">${a.awardIcon}</div>`;
h += `<div class="tmps-award-body">`;
h += `<div class="tmps-award-title">${a.awardType}</div>`;
let sub = '';
if (a.flagHtml) sub += a.flagHtml + ' ';
if (a.leagueName) sub += a.leagueHref ? `<a href="${a.leagueHref}">${a.leagueName}</a>` : a.leagueName;
if (a.statText) sub += (sub ? ' · ' : '') + a.statText;
if (sub) h += `<div class="tmps-award-sub">${sub}</div>`;
h += `</div>`;
if (a.season) h += `<span class="tmps-award-season">S${a.season}</span>`;
h += `</div>`;
}
h += '</div></div>';
}
h += '</div>';
/* ── Replace column3_a contents ── */
col3.innerHTML = h;
/* ── Live Transfer Polling ── */
if (transferListed) {
const tfCard = document.getElementById('tmtf-live');
if (tfCard) {
let tfInterval = null;
const fmtCoin = (v) => {
const n = typeof v === 'string' ? parseInt(v.replace(/[^0-9]/g, '')) : v;
return n ? n.toLocaleString('en-US') : '0';
};
const fmtBidArg = (v) => {
const n = typeof v === 'string' ? parseInt(v.replace(/[^0-9]/g, '')) : v;
return n ? n.toLocaleString('en-US') : '0';
};
const renderTransfer = (d) => {
const isExpired = d.expiry === 'expired';
const hasBuyer = d.buyer_id && d.buyer_id !== '0' && d.buyer_name;
const isAgent = !hasBuyer && parseInt((d.current_bid || '0').toString().replace(/[^0-9]/g, '')) > 0;
let html = `<div class="tmtf-head"><span>🔄 Transfer</span>`;
html += `<button class="tmtf-reload" title="Refresh" id="tmtf-reload-btn">↻</button>`;
html += `</div><div class="tmtf-body">`;
/* Expiry */
html += `<div class="tmtf-row"><span class="tmtf-lbl">Expiry</span>`;
if (isExpired) {
html += `<span class="tmtf-val expired">Expired</span>`;
} else {
html += `<span class="tmtf-val expiry">${d.expiry}</span>`;
}
html += `</div>`;
/* Current Bid */
const curBid = parseInt((d.current_bid || '0').toString().replace(/[^0-9]/g, ''));
if (curBid > 0) {
html += `<div class="tmtf-row"><span class="tmtf-lbl">Current Bid</span>`;
html += `<span class="tmtf-val bid"><span class="coin">${fmtCoin(curBid)}</span></span></div>`;
}
/* Bidder */
if (hasBuyer) {
html += `<div class="tmtf-row"><span class="tmtf-lbl">Bidder</span>`;
html += `<span class="tmtf-val buyer"><a href="/club/${d.buyer_id}" style="color:#60a5fa;text-decoration:none">${d.buyer_name}</a></span></div>`;
} else if (isAgent && !isExpired) {
html += `<div class="tmtf-row"><span class="tmtf-lbl">Bidder</span>`;
html += `<span class="tmtf-val agent">Agent</span></div>`;
}
/* Next bid / min bid */
if (!isExpired && d.next_bid) {
const nextVal = typeof d.next_bid === 'number' ? d.next_bid : parseInt((d.next_bid || '0').toString().replace(/[^0-9]/g, ''));
html += `<div class="tmtf-row"><span class="tmtf-lbl">${curBid > 0 ? 'Next Bid' : 'Min Bid'}</span>`;
html += `<span class="tmtf-val bid"><span class="coin">${fmtCoin(nextVal)}</span></span></div>`;
}
/* Expired result */
if (isExpired) {
if (hasBuyer) {
html += `<div class="tmtf-row"><span class="tmtf-lbl">Sold To</span>`;
html += `<span class="tmtf-val sold"><a href="/club/${d.buyer_id}" style="color:#4ade80;text-decoration:none">${d.buyer_name}</a></span></div>`;
html += `<div class="tmtf-row"><span class="tmtf-lbl">Price</span>`;
html += `<span class="tmtf-val sold"><span class="coin">${fmtCoin(d.current_bid)}</span></span></div>`;
} else if (curBid > 0) {
html += `<div class="tmtf-row"><span class="tmtf-lbl">Result</span>`;
html += `<span class="tmtf-val agent">Sold to Agent</span></div>`;
html += `<div class="tmtf-row"><span class="tmtf-lbl">Price</span>`;
html += `<span class="tmtf-val sold"><span class="coin">${fmtCoin(d.current_bid)}</span></span></div>`;
} else {
html += `<div class="tmtf-row"><span class="tmtf-lbl">Result</span>`;
html += `<span class="tmtf-val expired">Not Sold</span></div>`;
}
}
/* Bid button */
if (!isExpired) {
const nb = d.next_bid ? fmtBidArg(d.next_bid) : transferListed.minBid;
html += `<button class="tmtf-bid-btn" onclick="tlpop_pop_transfer_bid('${nb}',1,${transferListed.playerId},'${transferListed.playerName.replace(/'/g, "\\'")}')">🔨 Make Bid / Agent</button>`;
}
html += `</div>`;
tfCard.innerHTML = html;
/* Attach reload */
const reloadBtn = document.getElementById('tmtf-reload-btn');
if (reloadBtn) reloadBtn.addEventListener('click', () => fetchTransfer());
/* Stop polling on expired */
if (isExpired && tfInterval) {
clearInterval(tfInterval);
tfInterval = null;
}
};
const fetchTransfer = () => {
const reloadBtn = document.getElementById('tmtf-reload-btn');
if (reloadBtn) { reloadBtn.innerHTML = '<span class="tmtf-spinner"></span>'; reloadBtn.disabled = true; }
$.post('/ajax/transfer_get.ajax.php', {
type: 'transfer_reload',
player_id: transferListed.playerId
}, res => {
try {
const d = typeof res === 'object' ? res : JSON.parse(res);
if (d.success) renderTransfer(d);
} catch (e) {}
}).fail(() => {
if (reloadBtn) { reloadBtn.innerHTML = '↻'; reloadBtn.disabled = false; }
});
};
/* Initial fetch + start interval */
fetchTransfer();
tfInterval = setInterval(fetchTransfer, 60000);
}
}
};
buildSidebar();
/* ═══════════════════════════════════════════════════════════
ASI CALCULATOR — widget in column3_a
═══════════════════════════════════════════════════════════ */
const buildASICalculator = () => {
const col3 = document.querySelector('.column3_a');
if (!col3) return;
const K = Math.pow(2, 9) * Math.pow(5, 4) * Math.pow(7, 7); // 263534560000
const calcASI = (currentASI, trainings, avgTI) => {
const base = Math.pow(currentASI * K, 1 / 7);
const added = (avgTI * trainings) / 10;
if (isGoalkeeper) {
const ss11 = base / 14 * 11;
const fs11 = ss11 + added;
return Math.round(Math.pow(fs11 / 11 * 14, 7) / K);
}
return Math.round(Math.pow(base + added, 7) / K);
};
const agentVal = (si, ageM, gk) => {
const a = ageM / 12;
if (a < 18) return 0;
let p = Math.round(si * 500 * Math.pow(25 / a, 2.5));
if (gk) p = Math.round(p * 0.75);
return p;
};
/* Defaults: trainings = months until .11, TI = player average TI */
const defaultTrainings = playerMonths !== null ? (playerMonths >= 11 ? 12 : 11 - playerMonths) : '';
const defaultTI = playerTI !== null ? playerTI : '';
let h = '<div class="tmac-card">';
h += '<div class="tmac-head">ASI Calculator</div>';
h += '<div class="tmac-form">';
h += `<div class="tmac-field"><span class="tmac-label">Trainings</span><input type="number" id="tmac-trainings" class="tmac-input" value="${defaultTrainings}" placeholder="12" min="1" max="500"></div>`;
h += `<div class="tmac-field"><span class="tmac-label">Avg TI</span><input type="number" id="tmac-ti" class="tmac-input" value="${defaultTI}" placeholder="8" min="0.1" max="10" step="0.1"></div>`;
h += '<button id="tmac-calc" class="tmsc-send-btn">Calculate</button>';
h += '</div>';
h += '<div class="tmac-result" id="tmac-result">';
h += '<div class="tmac-result-row"><span class="tmac-result-lbl">Age</span><span class="tmac-result-val" id="tmac-age">-</span></div>';
h += '<div class="tmac-result-row"><span class="tmac-result-lbl">New ASI</span><span class="tmac-result-val" id="tmac-asi">-</span></div>';
h += '<div class="tmac-result-row"><span class="tmac-result-lbl">Skill Sum</span><span class="tmac-result-val" id="tmac-skillsum">-</span></div>';
h += '<div class="tmac-result-row"><span class="tmac-result-lbl">Sell To Agent</span><span class="tmac-result-val" id="tmac-sta">-</span></div>';
h += '</div>';
h += '</div>';
const el = document.createElement('div');
el.innerHTML = h;
col3.appendChild(el);
document.getElementById('tmac-calc').addEventListener('click', () => {
const trainings = parseInt(document.getElementById('tmac-trainings').value) || 0;
const avgTI = parseFloat(document.getElementById('tmac-ti').value) || 0;
if (trainings <= 0 || avgTI <= 0 || !playerASI) return;
const newASI = calcASI(playerASI, trainings, avgTI);
const asiDiff = newASI - playerASI;
/* 1 training = 1 month */
const resultEl = document.getElementById('tmac-result');
resultEl.classList.add('show');
const ageEl = document.getElementById('tmac-age');
if (playerAge !== null && playerMonths !== null) {
const curYears = Math.floor(playerAge);
const totalMonths = curYears * 12 + playerMonths + trainings;
const newYrs = Math.floor(totalMonths / 12);
const newMos = totalMonths % 12;
ageEl.innerHTML = `${newYrs}.${newMos}`;
} else {
ageEl.textContent = '-';
}
const asiEl = document.getElementById('tmac-asi');
asiEl.innerHTML = `${newASI.toLocaleString()}<span class="tmac-diff">+${asiDiff.toLocaleString()}</span>`;
/* Skill Sum: current → future */
const rawBase = Math.pow(playerASI * K, 1 / 7);
const curSS = isGoalkeeper ? rawBase / 14 * 11 : rawBase;
const futSS = curSS + (avgTI * trainings) / 10;
document.getElementById('tmac-skillsum').innerHTML =
`${curSS.toFixed(1)} → ${futSS.toFixed(1)}`;
/* Sell To Agent: future price with diff */
if (playerAge !== null && playerMonths !== null) {
const curTotMo = Math.floor(playerAge) * 12 + playerMonths;
const futTotMo = curTotMo + trainings;
const curSTA = agentVal(playerASI, curTotMo, isGoalkeeper);
const futSTA = agentVal(newASI, futTotMo, isGoalkeeper);
const staDiff = futSTA - curSTA;
const staSign = staDiff >= 0 ? '+' : '';
document.getElementById('tmac-sta').innerHTML =
`${futSTA.toLocaleString()}<span class="tmac-diff">${staSign}${staDiff.toLocaleString()}</span>`;
} else {
document.getElementById('tmac-sta').textContent = '-';
}
});
};
// buildASICalculator called from fetchTooltip callback
/* ═══════════════════════════════════════════════════════════
COLUMN1 NAV — replace native menu
═══════════════════════════════════════════════════════════ */
const buildColumn1Nav = () => {
const col1 = document.querySelector('.column1');
if (!col1) return;
const links = col1.querySelectorAll('.content_menu a');
if (!links.length) return;
const ICONS = {
'Squad Overview': '👥',
'Statistics': '📊',
'History': '📜',
'Fixtures': '📅'
};
let navH = '<div class="tmcn-nav">';
links.forEach(a => {
const label = a.textContent.trim();
const href = a.getAttribute('href') || '#';
const icon = ICONS[label] || '📋';
navH += `<a href="${href}"><span class="tmcn-icon">${icon}</span><span class="tmcn-lbl">${label}</span></a>`;
});
navH += '</div>';
const nav = document.createElement('div');
nav.innerHTML = navH;
col1.prepend(nav.firstChild);
};
buildColumn1Nav();
/* ═══════════════════════════════════════════════════════════
GRAPH SYNC — Build full skill history from graphs endpoint
when player has unlocked skills. This gives us weekly integer
skills + ASI from the player's debut, far more data than
manual visits. Decision matrix:
No store at all → try graphs
Store + graphSync + cur → skip (already done)
Store + graphSync - cur → regular save (add current week)
Store - graphSync → try graphs (overwrite with full history)
═══════════════════════════════════════════════════════════ */
const GRAPH_KEYS_OUT = ['strength', 'stamina', 'pace', 'marking', 'tackling', 'workrate', 'positioning', 'passing', 'crossing', 'technique', 'heading', 'finishing', 'longshots', 'set_pieces'];
const GRAPH_KEYS_GK = ['strength', 'pace', 'jumping', 'stamina', 'one_on_ones', 'reflexes', 'aerial_ability', 'communication', 'kicking', 'throwing', 'handling'];
const syncFromGraphs = (year, month, skills, SI, gk) => {
const ageKey = `${year}.${month}`;
const store = PlayerDB.get(PLAYER_ID);
const hasStore = store && store.records;
const hasGraphSync = hasStore && store.graphSync === true;
const hasCurWeek = hasStore && store.records[ageKey];
/* graphSync + current week exists → nothing to do (unless still v1/v2 or has null values) */
if (hasGraphSync && hasCurWeek) {
if (store._v < 3) {
console.log('[GraphSync] graphSync present but store still v' + store._v + ' — running analyzeGrowth');
setTimeout(analyzeGrowth, 500);
} else if (!store._nullResynced &&
Object.values(store.records).some(r =>
r.REREC == null || r.R5 == null || r.routine == null)) {
console.log('[GraphSync] v3 store has null REREC/R5/routine — re-running analyzeGrowth');
setTimeout(analyzeGrowth, 500);
} else {
console.log('[GraphSync] Already synced from graphs, current week exists — skipping');
}
return;
}
/* graphSync + current week missing → just do regular save + analyzeGrowth */
if (hasGraphSync && !hasCurWeek) {
console.log('[GraphSync] Has graphSync but missing current week — regular save');
saveCurrentVisit(year, month, skills, SI, gk);
setTimeout(analyzeGrowth, 800);
return;
}
/* No graphSync → try graphs endpoint */
console.log('[GraphSync] Trying graphs endpoint...');
$.post('/ajax/players_get_info.ajax.php', {
player_id: PLAYER_ID, type: 'graphs', show_non_pro_graphs: true
}, (res) => {
try {
const data = typeof res === 'object' ? res : JSON.parse(res);
const g = data && data.graphs;
const graphKeys = gk ? GRAPH_KEYS_GK : GRAPH_KEYS_OUT;
console.log('[GraphSync] Graphs data received:', g ? Object.keys(g) : 'no graphs');
/* Check if graphs has skill data (first skill key must exist with data) */
const firstSkillArr = g && g[graphKeys[0]];
if (!g || !firstSkillArr || firstSkillArr.length < 2) {
console.log('[GraphSync] No graph skill data available — falling back to regular');
saveCurrentVisit(year, month, skills, SI, gk);
setTimeout(analyzeGrowth, 800);
return;
}
/* Build v1 store from graph data */
const L = firstSkillArr.length;
/* Reconstruct ASI array: prefer skill_index, else derive from TI backwards
using the same formula as the ASI Calculator widget:
base = (ASI * K)^(1/7), then ASI = (base ± ti/10)^7 / K
NOTE: TI (and skill_index) arrays may be 1 longer than skill arrays
because they include a pre-pro dummy at index 0. We align using an offset. */
let asiArr;
if (g.skill_index && g.skill_index.length >= L) {
/* skill_index may have extra pre-pro entry; take the last L entries */
const off = g.skill_index.length - L;
asiArr = g.skill_index.slice(off).map(v => parseInt(v) || 0);
} else if (g.ti && g.ti.length >= L) {
const K = gk ? 48717927500 : (Math.pow(2, 9) * Math.pow(5, 4) * Math.pow(7, 7));
const tiOff = g.ti.length - L; /* usually 1: TI has extra pre-pro entry */
asiArr = new Array(L);
asiArr[L - 1] = SI; /* current ASI from tooltip */
for (let j = L - 2; j >= 0; j--) {
const ti = parseInt(g.ti[j + 1 + tiOff]) || 0;
const base = Math.pow(asiArr[j + 1] * K, 1 / 7);
asiArr[j] = Math.max(0, Math.round(Math.pow(base - ti / 10, 7) / K));
}
console.log(`[GraphSync] ASI reconstructed from TI (offset=${tiOff}, skill_index unavailable)`);
} else {
asiArr = new Array(L).fill(0);
console.log('[GraphSync] No skill_index or TI — ASI set to 0');
}
const curAgeMonths = year * 12 + month;
const _prevStore = PlayerDB.get(PLAYER_ID);
const newStore = { _v: 1, graphSync: true, lastSeen: Date.now(), records: {} };
if (_prevStore?.meta) newStore.meta = _prevStore.meta;
for (let i = 0; i < L; i++) {
const ageMonths = curAgeMonths - (L - 1 - i);
const yr = Math.floor(ageMonths / 12);
const mo = ageMonths % 12;
const key = `${yr}.${mo}`;
const si = parseInt(asiArr[i]) || 0;
const sk = graphKeys.map(k => parseInt(g[k]?.[i]) || 0);
newStore.records[key] = {
SI: si,
REREC: null,
R5: null,
skills: sk,
routine: null
};
}
PlayerDB.set(PLAYER_ID, newStore);
const recCount = Object.keys(newStore.records).length;
console.log(`%c[GraphSync] ✓ Synced player ${PLAYER_ID} from graphs: ${recCount} weeks (full career)`,
'font-weight:bold;color:#38bdf8');
/* Now run analyzeGrowth to convert v1 → v3 */
setTimeout(analyzeGrowth, 500);
} catch (e) {
console.warn('[GraphSync] Parse error, falling back to regular:', e.message);
saveCurrentVisit(year, month, skills, SI, gk);
setTimeout(analyzeGrowth, 800);
}
}).fail(() => {
console.warn('[GraphSync] Request failed — falling back to regular');
saveCurrentVisit(year, month, skills, SI, gk);
setTimeout(analyzeGrowth, 800);
});
};
/* ═══════════════════════════════════════════════════════════
SAVE CURRENT VISIT TO GROWTH RECORD
Writes {pid}_data["year.month"] = { SI, skills } on every visit.
REREC and R5 are left as stored (filled in by RatingR6 script).
═══════════════════════════════════════════════════════════ */
const saveCurrentVisit = (year, month, skills, SI, gk) => {
if (!SI || SI <= 0 || !year || !skills || !skills.length) return;
const ageKey = `${year}.${month}`;
try {
/* Compute decimal skill values (skillsC) — same as RatingR6 setJSON */
const weight = gk ? 48717927500 : 263533760000;
const log27 = Math.log(Math.pow(2, 7));
const allSum = skills.reduce((s, v) => s + v, 0);
const remainder = Math.round((Math.pow(2, Math.log(weight * SI) / log27) - allSum) * 10) / 10;
const goldstar = skills.filter(v => v === 20).length;
const nonStar = skills.length - goldstar;
const skillsC = skills.map(v => v === 20 ? 20 : v + (nonStar > 0 ? remainder / nonStar : 0));
let store = PlayerDB.get(PLAYER_ID);
if (!store || !store._v) store = { _v: 1, lastSeen: Date.now(), records: {} };
const prev = store.records[ageKey] || {};
if (prev.locked) {
console.log(`[TmPlayer] Record ${ageKey} is locked (squad sync) — skipping overwrite`);
return;
}
store.records[ageKey] = {
SI,
REREC: prev.REREC ?? null,
R5: prev.R5 ?? null,
skills: store._v >= 2 && prev.skills ? prev.skills : skillsC,
routine: prev.routine ?? null
};
store.lastSeen = Date.now();
/* Save player meta (name, pos) for compare dialog */
if (tooltipPlayer) {
store.meta = { name: tooltipPlayer.name || '', pos: tooltipPlayer.favposition || '', isGK: gk };
}
PlayerDB.set(PLAYER_ID, store);
console.log(store.records);
console.log(`[TmPlayer] Saved visit: player ${PLAYER_ID}, age ${ageKey}, SI ${SI}`);
} catch (e) {
console.warn('[TmPlayer] saveCurrentVisit failed:', e.message);
}
};
/* ═══════════════════════════════════════════════════════════
GROWTH ANALYSIS — Week-by-week decimal estimation using
training weights × TI efficiency curves.
Goes through stored records chronologically, computes the
total-skill-point delta each month from the ASI formula,
distributes it across skills weighted by:
W[i] = training allocation for skill i
eff() = TI-to-skill-point efficiency at that skill level
Handles gold-star overflow (maxed skill's share goes to ALL
other non-maxed skills randomly, not to its group mates).
═══════════════════════════════════════════════════════════ */
const analyzeGrowth = () => {
let store;
try { store = PlayerDB.get(PLAYER_ID); } catch (e) { return; }
if (!store || !store.records) return;
if (store._v >= 3) {
/* Check if any records have null REREC/R5/routine needing re-sync */
if (store._nullResynced) return; /* already retried once */
const hasNulls = Object.values(store.records).some(r =>
r.REREC == null || r.R5 == null || r.routine == null);
if (!hasNulls) return;
console.log('%c[Growth] v3 store has null values — re-processing', 'color:#e8a838;font-weight:bold');
store._v = 2; /* downgrade to allow full re-processing */
store._resyncingNulls = true;
}
/* Sort records chronologically by age key (year.month) */
const rawKeys = Object.keys(store.records).sort((a, b) => {
const [ay, am] = a.split('.').map(Number);
const [by, bm] = b.split('.').map(Number);
return (ay * 12 + am) - (by * 12 + bm);
});
if (rawKeys.length < 2) {
/* Single record — can't do delta analysis but can still compute R5/REREC/routine */
const onlyKey = rawKeys[0];
const rec = store.records[onlyKey];
const skills = rec.skills || [];
const isGKs = skills.length === 11;
const posIdxS = isGKs ? 9 : (playerPosition ? getPositionIndex(playerPosition.split(',')[0].trim()) : 0);
const si = parseInt(rec.SI) || 0;
const rtn = playerRoutine ?? 0;
const skillsF = skills.map(v => {
const n = typeof v === 'string' ? parseFloat(v) : v;
return n >= 20 ? 20 : n;
});
const singleStore = {
_v: 3, lastSeen: Date.now(),
records: {
[onlyKey]: {
SI: rec.SI,
REREC: Number(calculateRemaindersF(posIdxS, skillsF, si).rec),
R5: Number(calculateR5F(posIdxS, skillsF, si, rtn)),
skills: skillsF,
routine: rtn
}
}
};
if (store.graphSync) singleStore.graphSync = true;
if (store.meta) singleStore.meta = store.meta;
if (store._nullResynced || store._resyncingNulls) singleStore._nullResynced = true;
PlayerDB.set(PLAYER_ID, singleStore);
console.log(`%c[Growth] ✓ Single-record player ${PLAYER_ID} upgraded to v3`,
'font-weight:bold;color:#6cc040');
try { GraphsMod.reRender(); } catch (e) { }
return;
}
/* ── Fill gaps: interpolate missing months between recorded ones ── */
const ageToMonths = (k) => { const [y, m] = k.split('.').map(Number); return y * 12 + m; };
const monthsToAge = (m) => `${Math.floor(m / 12)}.${m % 12}`;
const intSkills = (rec) => rec.skills.map(v => {
const n = typeof v === 'string' ? parseFloat(v) : v;
return Math.floor(n);
});
for (let idx = 0; idx < rawKeys.length - 1; idx++) {
const aM = ageToMonths(rawKeys[idx]);
const bM = ageToMonths(rawKeys[idx + 1]);
const gap = bM - aM;
if (gap <= 1) continue;
const rA = store.records[rawKeys[idx]];
const rB = store.records[rawKeys[idx + 1]];
const siA = parseInt(rA.SI) || 0, siB = parseInt(rB.SI) || 0;
const skA = intSkills(rA), skB = intSkills(rB);
for (let step = 1; step < gap; step++) {
const t = step / gap;
const interpKey = monthsToAge(aM + step);
if (store.records[interpKey]) continue; // already exists
const interpSI = Math.round(siA + (siB - siA) * t);
/* For skills: gradually increase — floored integer ramps */
const interpSk = skA.map((sa, i) => {
const sb = skB[i];
const diff = sb - sa;
return sa + Math.floor(diff * t);
});
store.records[interpKey] = {
SI: interpSI,
REREC: null,
R5: null,
skills: interpSk,
_interpolated: true
};
}
}
/* Re-sort with interpolated records included */
const ageKeys = Object.keys(store.records).sort((a, b) =>
ageToMonths(a) - ageToMonths(b)
);
/* Detect GK by skill count from first record */
const firstSkills = store.records[ageKeys[0]].skills || [];
const isGK = firstSkills.length === 11;
/* ── Constants (outfield vs GK) ── */
const SK = isGK
? ['Strength', 'Pace', 'Jumping', 'Stamina', 'One on ones', 'Reflexes', 'Aerial Ability', 'Communication', 'Kicking', 'Throwing', 'Handling']
: ['Strength', 'Stamina', 'Pace', 'Marking', 'Tackling', 'Workrate',
'Positioning', 'Passing', 'Crossing', 'Technique', 'Heading',
'Finishing', 'Longshots', 'Set Pieces'];
const N = isGK ? 11 : 14;
/* Training groups (indices into SK array)
Outfield: T1=Str/Wor/Sta T2=Mar/Tac T3=Cro/Pac T4=Pas/Tec/Set T5=Hea/Pos T6=Fin/Lon
GK: single group — all skills get equal weight (automatic training) */
const GRP = isGK
? [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]] /* GK: one group with all 11 skills */
: [[0, 5, 1], [3, 4], [8, 2], [7, 9, 13], [10, 6], [11, 12]];
const GRP_COUNT = isGK ? 1 : 6;
const GRP_NAMES = isGK
? ['GK (all)']
: ['Str/Wor/Sta', 'Mar/Tac', 'Cro/Pac', 'Pas/Tec/Set', 'Hea/Pos', 'Fin/Lon'];
const s2g = new Array(N);
GRP.forEach((g, gi) => g.forEach(si => { s2g[si] = gi; }));
/* Standard training type → focus group index (outfield only) */
const STD_FOCUS = { '1': 3, '2': 0, '3': 1, '4': 5, '5': 4, '6': 2 };
const STD_NAMES = {
'1': 'Technical', '2': 'Fitness', '3': 'Tactical',
'4': 'Finishing', '5': 'Defending', '6': 'Wings'
};
/* TI efficiency by current skill level (skill points gained per 1 TI) */
const eff = (lvl) => {
if (lvl >= 20) return 0; // gold star – maxed
if (lvl >= 18) return 0.04; // 40% chance → ~0.04
if (lvl >= 15) return 0.05; // 50% chance → ~0.05
if (lvl >= 5) return 0.10; // normal
return 0.15; // 100% + 50% bonus
};
/* ASI → total skill points: totalPts = (WEIGHT × SI)^(1/7) */
const ASI_WEIGHT = isGK ? 48717927500 : 263533760000;
const LOG128 = Math.log(Math.pow(2, 7));
const totalPts = (si) => Math.pow(2, Math.log(ASI_WEIGHT * si) / LOG128);
/* Parse integer skills from a record (handles string / float / number) */
const intOf = (rec) => rec.skills.map(v => {
const n = typeof v === 'string' ? parseFloat(v) : v;
return Math.floor(n);
});
/* ── Compute per-skill share of total delta ──
share[i] = w[i]·eff(level[i]) / Σ(w·eff)
where w[i] comes from training group allocation.
If a skill is at 20 its share goes to ALL non-maxed skills
(random overflow), NOT to its group mates. */
const calcShares = (intS, gw) => {
const base = new Array(N).fill(0);
let overflow = 0;
for (let gi = 0; gi < GRP_COUNT; gi++) {
const grp = GRP[gi];
const perSk = gw[gi] / grp.length;
for (const si of grp) {
if (intS[si] >= 20) overflow += perSk;
else base[si] = perSk;
}
}
const nonMax = intS.filter(v => v < 20).length;
const ovfEach = nonMax > 0 ? overflow / nonMax : 0;
const w = base.map((b, i) => intS[i] >= 20 ? 0 : b + ovfEach);
const wE = w.map((wi, i) => wi * eff(intS[i]));
const tot = wE.reduce((a, b) => a + b, 0);
return tot > 0 ? wE.map(x => x / tot) : new Array(N).fill(0);
};
/* Cap decimals at 0.99 per skill; redistribute overflow to uncapped non-maxed skills */
const capDecimals = (decArr, intArr) => {
const CAP = 0.99;
const d = [...decArr];
let overflow = 0, passes = 0;
do {
overflow = 0;
let freeCount = 0;
for (let i = 0; i < N; i++) {
if (intArr[i] >= 20) { d[i] = 0; continue; }
if (d[i] > CAP) { overflow += d[i] - CAP; d[i] = CAP; }
else if (d[i] < CAP) freeCount++;
}
if (overflow > 0.0001 && freeCount > 0) {
const add = overflow / freeCount;
for (let i = 0; i < N; i++) {
if (intArr[i] < 20 && d[i] < CAP) d[i] += add;
}
}
} while (overflow > 0.0001 && ++passes < 20);
return d;
};
/* ── Main analysis runner ── */
const run = (trainingInfo, historyInfo) => {
/* Determine group weights from training data */
let gw = new Array(GRP_COUNT).fill(1 / GRP_COUNT);
let desc = isGK ? 'GK (balanced)' : 'Balanced (no data)';
if (!isGK && trainingInfo && trainingInfo.custom) {
const c = trainingInfo.custom;
const cd = c.custom;
if (c.custom_on && cd) {
/* Custom training: read dots per group */
const dots = [];
let dtot = 0;
for (let i = 0; i < 6; i++) {
const d = parseInt(cd['team' + (i + 1)]?.points) || 0;
dots.push(d); dtot += d;
}
/* Laplace smoothing: 0-dot groups still get a small chance */
const sm = 0.5;
const den = dtot + 6 * sm;
gw = dots.map(d => (d + sm) / den);
desc = `Custom dots=[${dots.join(',')}]`;
} else {
/* Standard training type */
const t = String(c.team || '3');
const fg = STD_FOCUS[t] ?? 1;
/* Focus group: 25% targeted + 75%/6 random = 37.5%
Other groups: 75%/6 = 12.5% each */
gw = new Array(6).fill(0.125);
gw[fg] = 0.375;
desc = `Standard: ${STD_NAMES[t] || t} → focus ${GRP_NAMES[fg]}`;
}
}
/* ── Compute routine history from history GP data ── */
const routineMap = {};
if (playerRoutine !== null && playerAge !== null && historyInfo && historyInfo.table && historyInfo.table.total) {
const totalRows = historyInfo.table.total.filter(r => typeof r.season === 'number');
const gpBySeason = {};
totalRows.forEach(r => { gpBySeason[r.season] = (gpBySeason[r.season] || 0) + (parseInt(r.games) || 0); });
/* Current season = max season in history (it has games this season) */
const curSeason = totalRows.length > 0 ? Math.max(...totalRows.map(r => r.season)) : 0;
const curWeek = currentSession;
const curAgeMonths = Math.floor(playerAge) * 12 + playerMonths;
const curRoutine = playerRoutine;
console.log('[Growth] Routine:', { curSeason, curWeek, curRoutine, gpBySeason });
for (const ageKey of ageKeys) {
const recAgeMonths = ageToMonths(ageKey);
const weeksBack = curAgeMonths - recAgeMonths;
if (weeksBack <= 0) { routineMap[ageKey] = curRoutine; continue; }
let gamesAfter = 0;
for (let w = 0; w < weeksBack; w++) {
const absWeek = (curSeason - 65) * 12 + (curWeek - 1) - w;
const season = 65 + Math.floor(absWeek / 12);
const gp = gpBySeason[season] || 0;
gamesAfter += (season === curSeason) ? (curWeek > 0 ? gp / curWeek : 0) : gp / 12;
}
routineMap[ageKey] = Math.max(0, Math.round((curRoutine - gamesAfter * 0.1) * 10) / 10);
}
console.log(`%c[Growth] Routine history: ${Object.keys(routineMap).length} records`, 'color:#8abc78');
}
/* ── Initial record ── */
const r0 = store.records[ageKeys[0]];
const i0 = intOf(r0);
const t0 = totalPts(r0.SI);
const iSum0 = i0.reduce((a, b) => a + b, 0);
const rem0 = t0 - iSum0;
const sh0 = calcShares(i0, gw);
let dec = capDecimals(sh0.map(s => Math.max(0, rem0 * s)), i0);
/* Format helpers */
const fmtDec = (intV, decV) => {
if (intV >= 20) return '★';
const d = Math.max(0, decV);
return `${intV}.${Math.round(d * 100).toString().padStart(2, '0')}`;
};
/* Collect summary rows */
const summary = [];
const posIdx = isGK ? 9 : (playerPosition ? getPositionIndex(playerPosition.split(',')[0].trim()) : 0);
const makeRow = (key, si, intArr, decArr, interp, routine) => {
const row = { Age: interp ? `${key} ≈` : key, ASI: si };
SK.forEach((n, i) => { row[n.substring(0, 3)] = fmtDec(intArr[i], decArr[i]); });
const rtn = routine ?? routineMap[key];
row.Rtn = rtn != null ? rtn : '-';
if (si > 0) {
const skillsF = intArr.map((v, i) => v >= 20 ? 20 : v + decArr[i]);
const r = rtn != null ? rtn : 0;
/* Old: integer skills + flat remainder */
const oldRem = calculateRemainders(posIdx, intArr, si);
row.oREC = Number(oldRem.rec);
row.oR5 = Number(calculateR5(posIdx, intArr, si, r));
/* New: decimal skills (float-aware, no double-counting) */
const newRem = calculateRemaindersF(posIdx, skillsF, si);
row.nREC = Number(newRem.rec);
row.nR5 = Number(calculateR5F(posIdx, skillsF, si, r));
} else { row.oREC = '-'; row.oR5 = '-'; row.nREC = '-'; row.nR5 = '-'; }
return row;
};
summary.push(makeRow(ageKeys[0], parseInt(r0.SI) || 0, i0, dec, !!store.records[ageKeys[0]]._interpolated, routineMap[ageKeys[0]]));
/* ── Process each subsequent record ── */
for (let m = 1; m < ageKeys.length; m++) {
const prevKey = ageKeys[m - 1], currKey = ageKeys[m];
const prevRec = store.records[prevKey], currRec = store.records[currKey];
const pi = intOf(prevRec), ci = intOf(currRec);
const pt = totalPts(prevRec.SI), ct = totalPts(currRec.SI);
const delta = ct - pt;
const ciSum = ci.reduce((a, b) => a + b, 0);
const cRem = ct - ciSum;
/* Distribute delta by shares (prev skills determine weights) */
const sh = calcShares(pi, gw);
const gains = sh.map(s => delta * s);
/* Add gains to previous decimals */
let newDec = dec.map((d, i) => d + gains[i]);
/* Handle level-ups: subtract integer gain, clamp to 0 */
for (let i = 0; i < N; i++) {
const chg = ci[i] - pi[i];
if (chg > 0) {
newDec[i] -= chg;
if (newDec[i] < 0) newDec[i] = 0;
}
if (ci[i] >= 20) newDec[i] = 0;
}
/* Normalize: scale so Σdecimals = actual remainder from ASI */
const ndSum = newDec.reduce((a, b) => a + b, 0);
if (ndSum > 0.001) {
const scale = cRem / ndSum;
dec = capDecimals(newDec.map((d, i) => ci[i] >= 20 ? 0 : d * scale), ci);
} else {
/* All near-zero → re-seed from shares */
const csh = calcShares(ci, gw);
dec = capDecimals(csh.map(s => Math.max(0, cRem * s)), ci);
}
summary.push(makeRow(currKey, parseInt(currRec.SI) || 0, ci, dec, !!currRec._interpolated, routineMap[currKey]));
}
/* ── Week-by-week summary table ── */
console.log(`%c[Growth] ═══ Player ${PLAYER_ID} — ${ageKeys.length} weeks (≈ = interpolated) ═══`,
'font-weight:bold;color:#6cc040');
// console.table(summary);
console.log(summary.slice(-1)[0].nR5)
/* ── Migrate: overwrite {pid}_data with weighted decimals + routine (_v:3) ── */
const growthStore = { _v: 3, lastSeen: Date.now(), records: {} };
if (store.graphSync) growthStore.graphSync = true;
if (store.meta) growthStore.meta = store.meta;
/* Mark _nullResynced if this was a re-sync of a previously v3 store */
if (store._nullResynced || store._resyncingNulls) growthStore._nullResynced = true;
/* Re-iterate to build records with computed skillsC */
/* We already have the summary, but we need the raw dec arrays.
Re-run quickly just to collect the float arrays. */
{
const r0g = store.records[ageKeys[0]];
const i0g = intOf(r0g);
const t0g = totalPts(r0g.SI);
const iSum0g = i0g.reduce((a, b) => a + b, 0);
const rem0g = t0g - iSum0g;
const sh0g = calcShares(i0g, gw);
let decG = capDecimals(sh0g.map(s => Math.max(0, rem0g * s)), i0g);
const skillsC0 = i0g.map((v, i) => v >= 20 ? 20 : v + decG[i]);
const prev0 = store.records[ageKeys[0]];
growthStore.records[ageKeys[0]] = {
SI: prev0.SI,
REREC: Number(calculateRemaindersF(posIdx, skillsC0, parseInt(prev0.SI) || 0).rec),
R5: Number(calculateR5F(posIdx, skillsC0, parseInt(prev0.SI) || 0, routineMap[ageKeys[0]] || 0)),
skills: skillsC0,
routine: routineMap[ageKeys[0]] ?? null
};
for (let m = 1; m < ageKeys.length; m++) {
const pKey = ageKeys[m - 1], cKey = ageKeys[m];
const pRec = store.records[pKey], cRec = store.records[cKey];
const pig = intOf(pRec), cig = intOf(cRec);
const ptg = totalPts(pRec.SI), ctg = totalPts(cRec.SI);
const deltaG = ctg - ptg;
const ciSumG = cig.reduce((a, b) => a + b, 0);
const cRemG = ctg - ciSumG;
const shG = calcShares(pig, gw);
const gainsG = shG.map(s => deltaG * s);
let newDecG = decG.map((d, i) => d + gainsG[i]);
for (let i = 0; i < N; i++) {
const chg = cig[i] - pig[i];
if (chg > 0) { newDecG[i] -= chg; if (newDecG[i] < 0) newDecG[i] = 0; }
if (cig[i] >= 20) newDecG[i] = 0;
}
const ndSumG = newDecG.reduce((a, b) => a + b, 0);
if (ndSumG > 0.001) {
const scaleG = cRemG / ndSumG;
decG = capDecimals(newDecG.map((d, i) => cig[i] >= 20 ? 0 : d * scaleG), cig);
} else {
const cshG = calcShares(cig, gw);
decG = capDecimals(cshG.map(s => Math.max(0, cRemG * s)), cig);
}
const skillsCm = cig.map((v, i) => v >= 20 ? 20 : v + decG[i]);
growthStore.records[cKey] = {
SI: cRec.SI,
REREC: Number(calculateRemaindersF(posIdx, skillsCm, parseInt(cRec.SI) || 0).rec),
R5: Number(calculateR5F(posIdx, skillsCm, parseInt(cRec.SI) || 0, routineMap[cKey] || 0)),
skills: skillsCm,
routine: routineMap[cKey] ?? null
};
}
}
PlayerDB.set(PLAYER_ID, growthStore);
console.log(`%c[Growth] ✓ Migrated player ${PLAYER_ID} to v3 (weighted decimals + routine)`,
'font-weight:bold;color:#6cc040');
/* Re-render graphs if already displayed, so REC chart picks up REREC */
try { GraphsMod.reRender(); } catch (e) { }
/* Log comparison for last record */
const lastKey = ageKeys[ageKeys.length - 1];
const oldRec = store.records[lastKey];
const newRec = growthStore.records[lastKey];
if (oldRec && newRec) {
const cmpRows = SK.map((name, i) => {
const oldV = typeof oldRec.skills[i] === 'string' ? parseFloat(oldRec.skills[i]) : oldRec.skills[i];
const newV = newRec.skills[i];
const diff = newV - oldV;
return {
Skill: name,
Old: oldV >= 20 ? '★' : oldV.toFixed(2),
New: newV >= 20 ? '★' : newV.toFixed(2),
Diff: oldV >= 20 && newV >= 20 ? '-' : (diff >= 0 ? '+' : '') + diff.toFixed(2)
};
});
const totalOld = cmpRows.reduce((s, r) => s + (r.Old === '★' ? 20 : parseFloat(r.Old)), 0);
const totalNew = cmpRows.reduce((s, r) => s + (r.New === '★' ? 20 : parseFloat(r.New)), 0);
cmpRows.push({
Skill: '── TOTAL ──',
Old: totalOld.toFixed(2),
New: totalNew.toFixed(2),
Diff: ((totalNew - totalOld) >= 0 ? '+' : '') + (totalNew - totalOld).toFixed(2)
});
// console.table(cmpRows);
/* Log old vs new REC and R5 for last record */
const lastSI = parseInt(newRec.SI) || 0;
const lastRtn = newRec.routine || 0;
const oldSkills = oldRec.skills.map(v => typeof v === 'string' ? parseFloat(v) : v);
const newSkills = newRec.skills;
const oREC = Number(calculateRemainders(posIdx, oldSkills.map(Math.floor), lastSI).rec);
const nREC = Number(calculateRemaindersF(posIdx, newSkills, lastSI).rec);
const oR5 = Number(calculateR5(posIdx, oldSkills.map(Math.floor), lastSI, lastRtn));
const nR5 = Number(calculateR5F(posIdx, newSkills, lastSI, lastRtn));
console.log(`%c[Growth] REC: ${oREC} → ${nREC} (${(nREC - oREC) >= 0 ? '+' : ''}${(nREC - oREC).toFixed(2)}) | R5: ${oR5} → ${nR5} (${(nR5 - oR5) >= 0 ? '+' : ''}${(nR5 - oR5).toFixed(2)})`,
'font-weight:bold;color:#5b9bff');
}
};
/* Fetch training + history data in parallel */
const _parse = r => { try { return typeof r === 'object' ? r : JSON.parse(r); } catch (e) { return null; } };
const trainReq = $.post('/ajax/players_get_info.ajax.php', {
player_id: PLAYER_ID, type: 'training', show_non_pro_graphs: true
}).then(r => _parse(r), () => null);
const histReq = $.post('/ajax/players_get_info.ajax.php', {
player_id: PLAYER_ID, type: 'history', show_non_pro_graphs: true
}).then(r => _parse(r), () => null);
Promise.all([trainReq, histReq]).then(([t, h]) => run(t, h));
};
/* analyzeGrowth is now triggered by syncFromGraphs after data sync */
})();