import React, { useState, useEffect, useMemo, useContext, useRef } from "react"; import { createPortal } from "react-dom"; import { Search, Loader2, RotateCcw, Shield, Settings, Zap, Plus, Copy, Trash2 } from "lucide-react"; import { DndContext, DragOverlay, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { PlayerContext } from "../PlayerContext"; import { CHIP_CONFIG, getPlayerPrice, normalizeBenchGkFirst,getOptimalLayout } from "../utils/fplLogic"; import { useFplSolverApi } from "../hooks/useFplSolverApi"; import { SolverOutputPanel } from "./SolverOutputPanel"; import { PitchView } from "./PitchView"; import { PlayerEditModal, PlayerSearchModal } from "./PlayerModals"; import { PlayerCardVisual } from "./PlayerCardVisual"; import { TabsPanel } from "./TabsPanel"; import { AdvancedSettingsModal, DEFAULT_SETTINGS } from "./AdvancedSettingsModal"; import { ActiveMovesPanel } from "./ActiveMovesPanel"; import { DraftsComparisonTable } from "./DraftsComparisonTable"; export default function Solver() { const { globalPlayers, updatePlayerStat, isLoadingDB, teamId, setTeamId, teamData, setTeamData, availableGWs, setAvailableGWs, horizon, setHorizon, activeGW, setActiveGW, captainId, setCaptainId, viceId, setViceId, initialSquadIds, setInitialSquadIds, isLoggedIn, userProfile, setUserProfile, manualOverrides, setManualOverrides, highlightTransferIds, setHighlightTransferIds, transfersByGw, setTransfersByGw, chipsByGw, setChipsByGw, baselineItb, setBaselineItb, baselineFt, setBaselineFt, availableFts, setAvailableFts, itb, setItb, HIT_COST, ftAtStartOfGw, quickSettings, setQuickSettings, advancedSettings, setAdvancedSettings, numSims, setNumSims, comprehensiveSettings, setComprehensiveSettings, appliedPlanSummary, setAppliedPlanSummary, solverApplySnapshot, setSolverApplySnapshot, solverTransferPairs, setSolverTransferPairs, solveElapsedSec, setSolveElapsedSec, drafts, setDrafts, activeDraftId, setActiveDraftId, fixtureOverrides, sessionEdits } = useContext(PlayerContext); // --- THE PRISTINE VAULT --- const pristineSquadRef = useRef({}); useEffect(() => { if (teamData.length > 0 && globalPlayers.length > 0 && availableGWs?.length > 0) { setTeamData(prevTeam => { let hasChanges = false; const newTeam = prevTeam.map(p => { if (p.isBlank) return p; const fresh = globalPlayers.find(g => String(g.ID) === String(p.ID)); if (!fresh) return p; let needsUpdate = false; for (const gw of availableGWs) { if (p[`${gw}_Pts`] !== fresh[`${gw}_Pts`] || p[`${gw}_xMins`] !== fresh[`${gw}_xMins`]) { needsUpdate = true; break; } } if (needsUpdate) { hasChanges = true; // Inherits the fresh stats while fiercely protecting the locked financial data return { ...p, ...fresh, purchase_price: p.purchase_price, selling_price: p.selling_price, Price: p.Price, now_cost: p.now_cost }; } return p; }); return hasChanges ? newTeam : prevTeam; }); } }, [globalPlayers, availableGWs]); const [pendingAutoReset, setPendingAutoReset] = useState(false); const lastOverridesRef = useRef(fixtureOverrides); // Watches for fixture changes and queues an auto-reset for when the math finishes useEffect(() => { // THE FIX: Stringify the objects so React compares the actual data, not the memory address! const currentStr = JSON.stringify(fixtureOverrides || {}); const lastStr = JSON.stringify(lastOverridesRef.current || {}); if (lastStr !== currentStr) { lastOverridesRef.current = fixtureOverrides; setPendingAutoReset(true); } }, [fixtureOverrides]); // --- STRICT VAULT-BASED PLAYER FACTORY --- const hydratePlayer = (id, knownPristineData = null) => { const globalMatch = globalPlayers.find((p) => String(p.ID) === String(id)); if (!globalMatch) return null; // 1. Trust explicit overrides (like when clicking 'undo transfer') if (knownPristineData && typeof knownPristineData === "object" && knownPristineData.purchase_price !== undefined) { const hydrated = { ...globalMatch, // Absolute source of truth for stats purchase_price: knownPristineData.purchase_price, selling_price: knownPristineData.selling_price, multiplier: knownPristineData.multiplier, is_captain: knownPristineData.is_captain, is_vice_captain: knownPristineData.is_vice_captain }; hydrated.now_cost = globalMatch.now_cost !== undefined ? globalMatch.now_cost : globalMatch.Price; hydrated.Price = hydrated.selling_price !== undefined ? hydrated.selling_price : getPlayerPrice(hydrated); return hydrated; } const marketCost = globalMatch.now_cost !== undefined ? globalMatch.now_cost : globalMatch.Price; const lockedBaselinePlayer = pristineSquadRef.current[id]; // 2. CHECK THE CHAIN: Was this player sold in any previous gameweek? let isChainBroken = false; if (lockedBaselinePlayer && availableGWs && availableGWs.length > 0) { const pastGWs = availableGWs.filter(g => g < activeGW).sort((a, b) => a - b); for (const gw of pastGWs) { if (chipsByGw[gw] === "fh") continue; const mLock = manualOverrides[gw]; if (mLock?.manualTransfers && Object.values(mLock.manualTransfers).some(p => String(p?.ID) === String(id))) { isChainBroken = true; break; } const sPairs = solverTransferPairs[gw]; if (sPairs && Object.values(sPairs).some(pair => String(pair.outPlayer?.ID) === String(id))) { isChainBroken = true; break; } } } else { isChainBroken = true; } // 3. APPLY THE LOGIC let finalPurchasePrice, finalSellingPrice; if (lockedBaselinePlayer && !isChainBroken) { finalPurchasePrice = lockedBaselinePlayer.purchase_price; finalSellingPrice = lockedBaselinePlayer.selling_price !== undefined ? lockedBaselinePlayer.selling_price : getPlayerPrice(lockedBaselinePlayer); } else { finalPurchasePrice = marketCost; finalSellingPrice = marketCost; } const hydrated = { ...globalMatch, // Fresh stats unconditionally overlay everything! ...(lockedBaselinePlayer && !isChainBroken ? { purchase_price: lockedBaselinePlayer.purchase_price, selling_price: lockedBaselinePlayer.selling_price, multiplier: lockedBaselinePlayer.multiplier, is_captain: lockedBaselinePlayer.is_captain, is_vice_captain: lockedBaselinePlayer.is_vice_captain } : {}), purchase_price: finalPurchasePrice, selling_price: finalSellingPrice, Price: finalSellingPrice, now_cost: marketCost }; return hydrated; }; const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [fixtures, setFixtures] = useState([]); const [isFixturesLoaded, setIsFixturesLoaded] = useState(false); const [activeDragPlayer, setActiveDragPlayer] = useState(null); const [selectedPlayer, setSelectedPlayer] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [sortConfig, setSortConfig] = useState({ key: "ev", direction: "desc" }); const [showIdPrompt, setShowIdPrompt] = useState(false); // --- DEFAULT ID ONBOARDING STATE --- const [showInitialIdPrompt, setShowInitialIdPrompt] = useState(false); const [initialIdInput, setInitialIdInput] = useState(""); // Trigger popup if logged in but no default ID is set useEffect(() => { // THE FIX: Wait until the profile is fully hydrated (checking for .email) // and the site has finished its initial load (!isLoadingDB) before prompting. if (isLoggedIn && userProfile && userProfile.email && !userProfile.defaultTeamId && !isLoadingDB) { setShowInitialIdPrompt(true); } else { setShowInitialIdPrompt(false); } }, [isLoggedIn, userProfile, isLoadingDB]); const handleSaveInitialId = () => { const parsedId = parseInt(initialIdInput); if (!parsedId) return; const token = localStorage.getItem('fpl_token'); if (token) { fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ default_team_id: parsedId }) }); setUserProfile(prev => ({ ...prev, defaultTeamId: parsedId })); setTeamId(String(parsedId)); // Auto-load the ID for them setShowInitialIdPrompt(false); } }; const [pendingTeamId, setPendingTeamId] = useState(null); const [lastLoadedId, setLastLoadedId] = useState(teamData.length > 0 ? teamId : null); const [solverTab, setSolverTab] = useState("solver"); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [banSearch, setBanSearch] = useState(""); const [lockSearch, setLockSearch] = useState(""); const [chipSolveOptions, setChipSolveOptions] = useState({ wc: [], fh: [], bb: [], tc: [] }); const [showDraftMenu, setShowDraftMenu] = useState(false); const [sensTimer, setSensTimer] = useState(0); const [chipSolveTimer, setChipSolveTimer] = useState(0); const abortControllerRef = useRef(null); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const { isSolving, isChipSolving, isRunningSens, pendingSolutions, setPendingSolutions, chipSolveSolutions, setChipSolveSolutions, sensResults, setSensResults, sensViewGw, setSensViewGw, handleSolve: apiHandleSolve, handleChipSolve: apiHandleChipSolve, handleSensAnalysis: apiHandleSensAnalysis, loadSettingsFromCloud, saveSettingsToCloud } = useFplSolverApi(abortControllerRef); useEffect(() => { let interval; if (isRunningSens) { interval = setInterval(() => setSensTimer((t) => t + 1), 1000); } else { setSensTimer(0); } return () => clearInterval(interval); }, [isRunningSens]); useEffect(() => { let interval; if (isChipSolving) { interval = setInterval(() => setChipSolveTimer((t) => t + 1), 1000); } else { setChipSolveTimer(0); } return () => clearInterval(interval); }, [isChipSolving]); const maxAvailableHorizon = useMemo(() => (availableGWs.length ? Math.min(10, availableGWs.length) : 10), [availableGWs]); const horizonGWs = useMemo(() => (availableGWs.length ? availableGWs.slice(0, horizon) : []), [availableGWs, horizon]); const playerCardGWs = useMemo(() => { if (!horizonGWs.length || !activeGW) return []; const idx = horizonGWs.indexOf(activeGW); return idx === -1 ? [] : horizonGWs.slice(idx).slice(0, 3); }, [horizonGWs, activeGW]); const solveGWs = useMemo(() => { if (!horizonGWs.length || !activeGW) return horizonGWs; const idx = horizonGWs.indexOf(activeGW); return idx === -1 ? horizonGWs : horizonGWs.slice(idx); }, [horizonGWs, activeGW]); const solveGWLabel = useMemo(() => { if (!solveGWs.length) return ""; return solveGWs.length === 1 ? `GW${solveGWs[0]}` : `GW${solveGWs[0]}–${solveGWs[solveGWs.length - 1]}`; }, [solveGWs]); const ownedPlayerIds = useMemo(() => new Set(teamData.filter((p) => !p.isBlank).map((p) => p.ID)), [teamData]); const hitsThisGw = useMemo(() => { const T = transfersByGw[activeGW]?.count || 0; const chip = chipsByGw[activeGW]; if (chip === "wc" || chip === "fh") return 0; const startFt = ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw); return Math.max(0, T - startFt); }, [activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw]); // 1. Standard state (can default to your hardcoded defaults initially) const [isCloudLoaded, setIsCloudLoaded] = useState(false); // 1. Fetch from Cloud on Mount / Login useEffect(() => { if (teamId && !isCloudLoaded) { loadSettingsFromCloud(teamId).then((cloudData) => { if (cloudData) { if (cloudData.quick) { setQuickSettings(prev => ({ ...prev, ...cloudData.quick })); } // THE FIX: Use setComprehensiveSettings! if (cloudData.advanced) { setComprehensiveSettings(prev => ({ ...prev, ...cloudData.advanced })); } } setIsCloudLoaded(true); }); } }, [teamId]); // 2. Save to Cloud (DEBOUNCED) useEffect(() => { if (teamId && isCloudLoaded) { const timerId = setTimeout(() => { // THE FIX: Pass comprehensiveSettings instead of advancedSettings! saveSettingsToCloud(teamId, quickSettings, comprehensiveSettings); }, 500); return () => clearTimeout(timerId); } // THE FIX: Watch comprehensiveSettings in the dependency array! }, [quickSettings, comprehensiveSettings, teamId, isCloudLoaded]); const getValidLayout = (players, gw) => { if (!players || players.length !== 15) return null; const getEV = (p) => p.isBlank ? -1000 : (Number(p[`${gw}_Pts`]) || 0); let gks = players.filter((p) => p.Pos === "G").sort((a, b) => getEV(b) - getEV(a)); let defs = players.filter((p) => p.Pos === "D").sort((a, b) => getEV(b) - getEV(a)); let mids = players.filter((p) => p.Pos === "M").sort((a, b) => getEV(b) - getEV(a)); let fwds = players.filter((p) => p.Pos === "F").sort((a, b) => getEV(b) - getEV(a)); const starters = []; if (gks.length) starters.push(gks.shift()); starters.push(...defs.splice(0, 3), ...mids.splice(0, 2), ...fwds.splice(0, 1)); const remaining = [...defs, ...mids, ...fwds].sort((a, b) => getEV(b) - getEV(a)); starters.push(...remaining.splice(0, 11 - starters.length)); const finalStarters = [ ...starters.filter((p) => p.Pos === "G"), ...starters.filter((p) => p.Pos === "D"), ...starters.filter((p) => p.Pos === "M"), ...starters.filter((p) => p.Pos === "F"), ]; const benchGk = gks.length ? gks[0] : null; const benchRest = remaining.sort((a, b) => getEV(b) - getEV(a)); const bench = benchGk ? [benchGk, ...benchRest] : benchRest; const topStarters = [...finalStarters].sort((a, b) => getEV(b) - getEV(a)); return { optimalArray: [...finalStarters, ...bench], cap: topStarters[0]?.ID, vice: topStarters[1]?.ID }; }; // Like getValidLayout but penalises players whose EV should be discounted due to // opposing-play clashes (two squad members facing each other in a fixture). // Falls back to getValidLayout when fixtures/settings are unavailable. const getValidLayoutWithPenalty = (players, gw, fixturesArr = fixtures, settings = comprehensiveSettings) => { if (!players || players.length !== 15) return null; const penalty = Number(settings?.opposing_play_penalty ?? DEFAULT_SETTINGS.opposing_play_penalty); // Build a map: teamId -> list of player IDs from this squad playing that GW const gwFixtures = (fixturesArr || []).filter(f => Number(f.event || f.gw) === Number(gw)); // For each player collect their team identifier (numeric or string) const getTeamId = (p) => p.team || p.Team || p.team_id; // Build a set of opposing-team pairs from GW fixtures that involve squad players // Returns adjusted EV for a player = raw EV - (penalty * number of opponents in squad) const getAdjustedEV = (p) => { if (p.isBlank) return -1000; const rawEV = Number(p[`${gw}_Pts`]) || 0; if (!penalty || !gwFixtures.length) return rawEV; const pTeam = getTeamId(p); if (!pTeam) return rawEV; // Find fixtures this player's team is in, then count squad opponents let opponentCount = 0; gwFixtures.forEach(fix => { const h = fix.team_h || fix.home_team; const a = fix.team_a || fix.away_team; // Is this player's team in this fixture? if (String(pTeam) === String(h) || String(pTeam) === String(a)) { const opponentTeam = String(pTeam) === String(h) ? String(a) : String(h); // Count squad players on the opposing team players.forEach(other => { if (other.ID !== p.ID && !other.isBlank) { const otherTeam = String(getTeamId(other)); if (otherTeam === opponentTeam) opponentCount++; } }); } }); return rawEV - penalty * opponentCount; }; let gks = players.filter((p) => p.Pos === "G").sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); let defs = players.filter((p) => p.Pos === "D").sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); let mids = players.filter((p) => p.Pos === "M").sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); let fwds = players.filter((p) => p.Pos === "F").sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); const starters = []; if (gks.length) starters.push(gks.shift()); starters.push(...defs.splice(0, 3), ...mids.splice(0, 2), ...fwds.splice(0, 1)); const remaining = [...defs, ...mids, ...fwds].sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); starters.push(...remaining.splice(0, 11 - starters.length)); const finalStarters = [ ...starters.filter((p) => p.Pos === "G"), ...starters.filter((p) => p.Pos === "D"), ...starters.filter((p) => p.Pos === "M"), ...starters.filter((p) => p.Pos === "F"), ]; const benchGk = gks.length ? gks[0] : null; const benchRest = remaining.sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); const bench = benchGk ? [benchGk, ...benchRest] : benchRest; const topStarters = [...finalStarters].sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); return { optimalArray: [...finalStarters, ...bench], cap: topStarters[0]?.ID, vice: topStarters[1]?.ID }; }; const derivedItb = useMemo(() => { let currentBank = baselineItb; if (!availableGWs || availableGWs.length === 0) return currentBank; for (let gw = availableGWs[0]; gw <= activeGW; gw++) { if (gw < activeGW && chipsByGw[gw] === "fh") continue; if (transfersByGw[gw]) currentBank += transfersByGw[gw].netDelta || 0; } return currentBank; }, [activeGW, availableGWs, baselineItb, transfersByGw, chipsByGw]); const currentRemainingFts = useMemo(() => { if (!availableGWs || availableGWs.length === 0) return baselineFt; const startingFts = ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw); const usedThisWeek = transfersByGw[activeGW]?.count || 0; return Math.max(0, startingFts - usedThisWeek); }, [activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw, ftAtStartOfGw]); useEffect(() => { fetch("https://anayshukla-fpl-solver.hf.space/api/fixtures") .then((res) => res.json()) .then(data => { setFixtures(data); setIsFixturesLoaded(true); }) .catch(() => { setIsFixturesLoaded(true); }); fetch("https://anayshukla-fpl-solver.hf.space/api/solver/default-settings").then((r) => (r.ok ? r.json() : {})).then((d) => { if (d && typeof d === "object") { setComprehensiveSettings(prev => ({ ...d, ...prev })); } }).catch(() => { }); }, []); useEffect(() => { setItb(derivedItb); setAvailableFts(currentRemainingFts); }, [derivedItb, currentRemainingFts, setItb, setAvailableFts]); useEffect(() => { if (horizon > maxAvailableHorizon && maxAvailableHorizon > 0) setHorizon(maxAvailableHorizon); }, [maxAvailableHorizon, horizon]); useEffect(() => { if (!isSolving) { setSolveElapsedSec(0); return; } const t0 = Date.now(); const id = setInterval(() => setSolveElapsedSec(Math.floor((Date.now() - t0) / 1000)), 250); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { clearInterval(id); document.body.style.overflow = prev; }; }, [isSolving]); const hasAutoLoadedAfterLogin = useRef(false); useEffect(() => { if (isLoggedIn && userProfile.defaultTeamId && String(userProfile.defaultTeamId) === String(teamId) && !isLoading) { if (!hasAutoLoadedAfterLogin.current) { hasAutoLoadedAfterLogin.current = true; fetchTeam(null, teamData.length > 0); } } if (!isLoggedIn) { hasAutoLoadedAfterLogin.current = false; // reset on logout } }, [isLoggedIn, userProfile.defaultTeamId, teamId]); const fetchTeam = async (e, preserveState = false) => { // If manually clicked by user, always wipe the slate clean if (e) { e.preventDefault(); setManualOverrides({}); preserveState = false; } if (!teamId) return; setIsLoading(true); setError(null); try { const token = localStorage.getItem('fpl_token'); const res = await fetch(`https://anayshukla-fpl-solver.hf.space/api/manager/${teamId}`, { headers: token ? { 'Authorization': `Bearer ${token}` } : {} }); if (!res.ok) throw new Error("Could not fetch team."); const data = await res.json(); if (data.picks && data.picks.length > 0) { // 1. ALWAYS populate the strict baseline vault and logic pristineSquadRef.current = {}; data.picks.forEach(p => { pristineSquadRef.current[p.ID] = { ...p }; }); setBaselineItb(data.in_the_bank || 0); setBaselineFt(typeof data.free_transfers === "number" ? data.free_transfers : 1); setInitialSquadIds(data.picks.map((p) => p.ID)); const gws = Object.keys(data.picks[0]).filter((k) => k.includes("_Pts")).map((k) => parseInt(k.split("_")[0])).sort((a, b) => a - b); setAvailableGWs(gws); // 2. ONLY overwrite the squad arrays if we are NOT loading from a saved DB Draft if (!preserveState) { setTransfersByGw({}); setHighlightTransferIds({}); setSolverTransferPairs({}); setSolverApplySnapshot(null); setChipsByGw({}); setChipSolveSolutions([]); setActiveGW(gws[0]); // THE HYDRATION FIX: Merge the raw FPL picks with the rich Python math BEFORE rendering the pitch! const hydratedPicks = data.picks.map(p => { const gMatch = globalPlayers.find(g => String(g.ID) === String(p.ID)); if (gMatch) { // Attach all EV, Fixture, and Price data instantly return { ...gMatch, ...p, Price: p.selling_price !== undefined ? p.selling_price : gMatch.Price }; } return p; }); // Now getValidLayoutWithPenalty has the EV data + opposing-play penalty to sort properly! const opt = getValidLayoutWithPenalty(hydratedPicks, gws[0], fixtures, comprehensiveSettings); if (opt) { setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice); } else { setTeamData(hydratedPicks); } } else { // If preserving state, just ensure activeGW doesn't break if the draft lacked it if (!activeGW) setActiveGW(gws[0]); } setLastLoadedId(teamId); if (isLoggedIn && userProfile.defaultTeamId !== parseInt(teamId)) { setPendingTeamId(parseInt(teamId)); setShowIdPrompt(true); } } } catch (err) { setError(err.message); } finally { setIsLoading(false); } }; useEffect(() => { if (!teamData.length || !activeGW || teamData.some((p) => p.isBlank && !String(p.ID).startsWith("blank_"))) return; const gwLock = manualOverrides[activeGW]; if (gwLock && gwLock.ids) { let reconstructed = gwLock.ids.map((id) => { if (String(id).startsWith("blank_")) { const replaced = gwLock.manualTransfers?.[id]; return { ID: id, isBlank: true, Pos: replaced?.Pos || "M", Name: "", Team: "", Price: 0, replacedPlayer: replaced }; } let found = hydratePlayer(id); if (found && gwLock.manualTransfers && gwLock.manualTransfers[id]) { found.replacedPlayer = gwLock.manualTransfers[id]; } return found; }).filter(Boolean); if (reconstructed.length !== 15) { if (globalPlayers.length > 0) { setManualOverrides((prev) => { const n = { ...prev }; delete n[activeGW]; return n; }); } return; } // THE FIX: Auto-optimize lineup safely AFTER global EVs finish recalculating! if (pendingAutoReset) { const opt = getValidLayoutWithPenalty(reconstructed, activeGW); if (opt) { setManualOverrides((prev) => ({ ...prev, [activeGW]: { ...gwLock, ids: opt.optimalArray.map((p) => p.ID), cap: opt.cap, vice: opt.vice } })); setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice); } setPendingAutoReset(false); return; } let needsSub = false; const getEV = (p) => Number(p[`${activeGW}_Pts`]) || 0; for (let i = 0; i < 11; i++) { const starter = reconstructed[i]; if (starter.isBlank || (getEV(starter) === 0 && (!gwLock.forcedZeros || !gwLock.forcedZeros.includes(starter.ID)))) { const bestBenchIdx = [11, 12, 13, 14].find((bIdx) => { const bPlayer = reconstructed[bIdx]; if (bPlayer.isBlank || getEV(bPlayer) <= 0) return false; const tempStarters = [...reconstructed.slice(0, 11)]; tempStarters[i] = bPlayer; const counts = { G: 0, D: 0, M: 0, F: 0 }; tempStarters.forEach(p => { if (p.Pos) counts[p.Pos]++; }); if (counts.G !== 1 || counts.D < 3 || counts.M < 2 || counts.F < 1) return false; return true; }); if (bestBenchIdx) { const temp = reconstructed[i]; reconstructed[i] = reconstructed[bestBenchIdx]; reconstructed[bestBenchIdx] = temp; needsSub = true; } } } if (needsSub) { setManualOverrides((prev) => ({ ...prev, [activeGW]: { ...gwLock, ids: reconstructed.map((p) => p.ID) } })); } setTeamData(reconstructed); setCaptainId(gwLock.cap); setViceId(gwLock.vice); } else { let deterministicIds = [...initialSquadIds]; if (availableGWs && availableGWs.length > 0) { for (let gw = availableGWs[0]; gw < activeGW; gw++) { if (manualOverrides[gw] && chipsByGw[gw] !== "fh") deterministicIds = manualOverrides[gw].ids; } } const deterministicSquad = deterministicIds.map(id => hydratePlayer(id)).filter(Boolean); const opt = getValidLayoutWithPenalty(deterministicSquad, activeGW); if (opt) { setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice); } } }, [globalPlayers, activeGW, teamData.length, manualOverrides, pendingAutoReset]); const activeGwEV = useMemo(() => { if (!teamData.length || !activeGW) return 0; const chip = chipsByGw[activeGW]; const capMult = chip === "tc" ? 3 : 2; let total = 0; teamData.slice(0, 11).forEach((p) => { if (!p.isBlank) total += (Number(p[`${activeGW}_Pts`]) || 0) * (p.ID === captainId ? capMult : 1); }); let ofIdx = 0; teamData.slice(11, 15).forEach((p) => { if (!p.isBlank) { if (chip === "bb") { total += (Number(p[`${activeGW}_Pts`]) || 0); } else { // THE FIX: Read dynamically from the UI settings! const rawBw = comprehensiveSettings?.bench_weights || { 0: 0.03, 1: 0.21, 2: 0.06, 3: 0.002 }; const gkWeight = Number(rawBw[0] || 0.03); const outWeights = [Number(rawBw[1] || 0.21), Number(rawBw[2] || 0.06), Number(rawBw[3] || 0.002)]; if (p.Pos === "G") { total += (Number(p[`${activeGW}_Pts`]) || 0) * gkWeight; } else { const bw = outWeights[ofIdx] || 0.02; total += (Number(p[`${activeGW}_Pts`]) || 0) * bw; ofIdx++; } } } }); return total - hitsThisGw * HIT_COST; }, [teamData, activeGW, captainId, hitsThisGw, chipsByGw]); const horizonEvData = useMemo(() => { if (!teamData.length || !horizonGWs.length) return { total: 0, breakdown: {} }; let total = 0; const breakdown = {}; // Helper to get the actual squad that existed at the start of a specific GW const getSquadForGw = (targetGw) => { // If it's the active GW or in the future, the current teamData is our baseline if (targetGw >= activeGW) return teamData; // If it's in the past, rebuild it from the permanent chain let deterministicIds = [...initialSquadIds]; if (availableGWs && availableGWs.length > 0) { for (let gw = availableGWs[0]; gw <= targetGw; gw++) { if (manualOverrides[gw] && chipsByGw[gw] !== "fh") { deterministicIds = manualOverrides[gw].ids; } } } return deterministicIds.map(id => hydratePlayer(id)).filter(Boolean); }; horizonGWs.forEach((gw) => { let gwPts = 0; const gwChip = chipsByGw[gw]; const gwCapMult = gwChip === "tc" ? 3 : 2; const applyBenchMath = (benchSlice) => { let ofIdx = 0; benchSlice.forEach((p) => { if (!p.isBlank) { if (gwChip === "bb") { gwPts += (Number(p[`${gw}_Pts`]) || 0); } else { // THE FIX: Dynamic settings for horizon EV too! const rawBw = comprehensiveSettings?.bench_weights || { 0: 0.03, 1: 0.21, 2: 0.06, 3: 0.002 }; const gkWeight = Number(rawBw[0] || 0.03); const outWeights = [Number(rawBw[1] || 0.21), Number(rawBw[2] || 0.06), Number(rawBw[3] || 0.002)]; if (p.Pos === "G") { gwPts += (Number(p[`${gw}_Pts`]) || 0) * gkWeight; } else { const bw = outWeights[ofIdx] || 0.02; gwPts += (Number(p[`${gw}_Pts`]) || 0) * bw; ofIdx++; } } } }); }; // THE FIX: Use the time-accurate squad for this specific GW const gwSpecificSquad = getSquadForGw(gw); if (gw === activeGW) { gwSpecificSquad.slice(0, 11).forEach((p) => { if (!p.isBlank) gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === captainId ? gwCapMult : 1); }); applyBenchMath(gwSpecificSquad.slice(11, 15)); } else { const gwLock = manualOverrides[gw]; if (gwLock && gwLock.ids) { const reconstructed = gwLock.ids.map((id) => gwSpecificSquad.find((p) => String(p.ID) === String(id)) || globalPlayers.find((p) => String(p.ID) === String(id))).filter(Boolean); if (reconstructed.length === 15) { reconstructed.slice(0, 11).forEach((p) => { if (!p.isBlank) gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === gwLock.cap ? gwCapMult : 1); }); applyBenchMath(reconstructed.slice(11, 15)); } else { const opt = getValidLayout(gwSpecificSquad, gw); if (opt) { opt.optimalArray.slice(0, 11).forEach((p) => { gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === opt.cap ? gwCapMult : 1); }); applyBenchMath(opt.optimalArray.slice(11, 15)); } } } else { const opt = getValidLayout(gwSpecificSquad, gw); if (opt) { opt.optimalArray.slice(0, 11).forEach((p) => { gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === opt.cap ? gwCapMult : 1); }); applyBenchMath(opt.optimalArray.slice(11, 15)); } } } const ftStart = ftAtStartOfGw(gw, availableGWs, baselineFt, transfersByGw, chipsByGw); const T = transfersByGw[gw]?.count ?? 0; const isChipFree = gwChip === "wc" || gwChip === "fh"; const hits = isChipFree ? 0 : Math.max(0, T - ftStart); const ev = gwPts - hits * HIT_COST; total += ev; breakdown[gw] = { ev, chip: gwChip, hits, ftStart, moves: T, isChipFree }; }); return { total, breakdown }; }, [teamData, horizonGWs, activeGW, captainId, manualOverrides, baselineFt, transfersByGw, chipsByGw, globalPlayers, initialSquadIds, availableGWs]); const horizonEV = horizonEvData.total; // Sync the breakdown to the active draft automatically useEffect(() => { if (Object.keys(horizonEvData.breakdown).length === 0) return; setDrafts(prev => { const activeIdx = prev.findIndex(d => d.id === activeDraftId); if (activeIdx === -1) return prev; const currentCached = prev[activeIdx].cachedEvs; if (JSON.stringify(currentCached) === JSON.stringify(horizonEvData.breakdown)) return prev; const next = [...prev]; next[activeIdx] = { ...next[activeIdx], cachedEvs: horizonEvData.breakdown }; return next; }); }, [horizonEvData.breakdown, activeDraftId, setDrafts]); // --- SOLVER API TRIGGERS & BASELINE ENGINE --- const getSolverStartingState = () => { const startGW = solveGWs[0]; const startIndex = availableGWs.indexOf(startGW); // 1. Get Starting Squad (The exact squad going INTO the solve horizon, before current manual moves) let startingIds = initialSquadIds; if (startIndex > 0) { const prevGw = availableGWs[startIndex - 1]; startingIds = manualOverrides[prevGw]?.ids || initialSquadIds; } const startingSquad = startingIds.map(id => { // Try to keep exact FPL prices from current teamData const existing = teamData.find(t => String(t.ID) === String(id)); if (existing) return existing; const g = globalPlayers.find(x => String(x.ID) === String(id)); return g ? { ...g, Price: getPlayerPrice(g) } : null; }).filter(Boolean); // 2. Get Starting ITB (Bank BEFORE the current gameweek's moves) let startingItb = baselineItb; for (let i = 0; i < startIndex; i++) { const gw = availableGWs[i]; if (chipsByGw[gw] === "fh") continue; if (transfersByGw[gw]) startingItb += transfersByGw[gw].netDelta || 0; } // 3. Get Starting FTs (FTs going INTO the horizon) const startingFts = ftAtStartOfGw(startGW, availableGWs, baselineFt, transfersByGw, chipsByGw); // 4. Extract Manual Moves as Booked Transfers const bookedTransfers = []; solveGWs.forEach(gw => { const lock = manualOverrides[gw]; if (lock && lock.manualTransfers) { Object.entries(lock.manualTransfers).forEach(([inId, outPlayer]) => { if (!String(inId).startsWith("blank_") && outPlayer) { bookedTransfers.push({ gw: Number(gw), transfer_in: Number(inId), transfer_out: Number(outPlayer.ID) }); } }); } }); return { startingSquad, startingItb, startingFts, bookedTransfers }; }; const getActiveCompSettings = (bookedTransfers) => { let payload; // If OFF: Send the absolute baseline defaults from our frontend UI + the manual moves if (!comprehensiveSettings.enabled) { payload = { ...DEFAULT_SETTINGS, booked_transfers: bookedTransfers }; } // If ON: Send the user's custom edited settings + the manual moves else { payload = { ...comprehensiveSettings, booked_transfers: bookedTransfers }; } // Safety check: If they aren't using the advanced FT list, strip it so backend uses the flat value if (!payload.use_ft_value_list) { delete payload.ft_value_list; } return payload; }; // --- THE XMINS FILTER ENGINE --- // Replicates open-fpl-solver logic BEFORE sending the payload to Python const getFilteredGlobalPlayers = (startingSquad, bookedTransfers) => { const activeSettings = getActiveCompSettings(bookedTransfers); const xminLbPerGw = activeSettings.xmin_lb || 0; // If the setting is 0 or disabled, skip filtering if (xminLbPerGw <= 0) return globalPlayers; // Multiply the input by the horizon length to get the total threshold const totalXminThreshold = xminLbPerGw * horizonGWs.length; // Build the "safe_players" array (Current squad + anyone you manually locked in) const safePlayers = new Set(startingSquad.map(p => String(p.ID))); bookedTransfers.forEach(bt => { safePlayers.add(String(bt.transfer_in)); safePlayers.add(String(bt.transfer_out)); }); // Execute the filter: (total_min >= xmin_lb) | (ID in safe_players) return globalPlayers.filter(p => { if (safePlayers.has(String(p.ID))) return true; let totalMins = 0; horizonGWs.forEach(gw => { totalMins += (Number(p[`${gw}_xMins`]) || 0); }); return totalMins >= totalXminThreshold; }); }; const runMainSolver = () => { const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState(); // THE FIX: Calculate apples-to-apples baseline EV for the active window, and the past locked EV const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0); const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0); apiHandleSolve({ teamId, solveGWs, horizonGWs, teamData: startingSquad, globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers), itb: startingItb, availableFts: startingFts, advancedSettings, quickSettings, chipsByGw, comprehensiveSettings: getActiveCompSettings(bookedTransfers), lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE }); }; const runSensAnalysis = () => { const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState(); const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0); const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0); apiHandleSensAnalysis({ teamId, solveGWs, horizonGWs, teamData: startingSquad, globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers), itb: startingItb, availableFts: startingFts, advancedSettings, quickSettings, chipsByGw, comprehensiveSettings: getActiveCompSettings(bookedTransfers), numSims, lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE }); }; const runChipSolve = () => { const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState(); const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0); const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0); apiHandleChipSolve({ teamId, horizonGWs, teamData: startingSquad, globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers), itb: startingItb, availableFts: startingFts, advancedSettings, comprehensiveSettings: getActiveCompSettings(bookedTransfers), chipSolveOptions, lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE }); }; // --- MULTIVERSE DRAFT HANDLERS --- const handleCloneDraft = () => { if (drafts.length >= 5) { alert("Maximum of 5 realities allowed."); return; } const currentDraft = drafts.find(d => d.id === activeDraftId); const newId = `draft_${Date.now()}`; // THE FIX: Deep clone ALL objects to stop timelines from sharing memory const newDraft = { ...currentDraft, id: newId, name: `${currentDraft.name} (Copy)`, fixtureOverrides: JSON.parse(JSON.stringify(currentDraft.fixtureOverrides || {})), sessionEdits: JSON.parse(JSON.stringify(currentDraft.sessionEdits || {})), manualOverrides: JSON.parse(JSON.stringify(currentDraft.manualOverrides || {})), transfersByGw: JSON.parse(JSON.stringify(currentDraft.transfersByGw || {})), highlightTransferIds: JSON.parse(JSON.stringify(currentDraft.highlightTransferIds || {})), solverTransferPairs: JSON.parse(JSON.stringify(currentDraft.solverTransferPairs || {})), chipsByGw: JSON.parse(JSON.stringify(currentDraft.chipsByGw || {})), cachedEvs: JSON.parse(JSON.stringify(currentDraft.cachedEvs || {})) }; setDrafts(prev => [...prev, newDraft]); setActiveDraftId(newId); }; const handleNewDraft = () => { if (drafts.length >= 5) { alert("Maximum of 5 realities allowed."); return; } const newId = `draft_${Date.now()}`; const startGW = availableGWs[0] || activeGW; const pristineSquad = initialSquadIds.map(id => hydratePlayer(id)).filter(Boolean); const opt = getValidLayoutWithPenalty(pristineSquad, startGW); const finalSquad = opt ? opt.optimalArray : pristineSquad; const newDraft = { id: newId, name: `Draft ${drafts.length + 1}`, teamData: finalSquad, horizon: horizon, activeGW: startGW, captainId: opt ? opt.cap : null, viceId: opt ? opt.vice : null, solverTransferPairs: {}, solverApplySnapshot: null, appliedPlanSummary: null, hitsThisGw: 0, highlightTransferIds: {}, transfersByGw: {}, chipsByGw: {}, manualOverrides: {}, fixtureOverrides: {}, sessionEdits: {}, cachedEvs: {} }; setDrafts(prev => [...prev, newDraft]); setActiveDraftId(newId); }; // --- TIMELINE WIPING HELPER --- // If you manually edit the pitch, any "future" moves planned by the solver MUST be // wiped out so that the timeline correctly cascades forward! const clearFuture = (prev) => { return prev; // 👈 FIXED: We no longer wipe out future gameweek plans! }; const applySolution = (sol) => { setSolverApplySnapshot({ teamData: [...teamData], availableFts, transfersByGw: { ...transfersByGw }, manualOverrides: { ...manualOverrides }, baselineItb, baselineFt }); const newOverrides = { ...manualOverrides }; const newTransfersByGw = { ...transfersByGw }; const newChipsByGw = { ...chipsByGw }; const newHighlights = { ...highlightTransferIds }; const newPairs = { ...solverTransferPairs }; let runningItb = baselineItb; const startIndex = availableGWs.indexOf(sol.horizon_gws[0]); if (startIndex > 0) { for (let i = 0; i < startIndex; i++) { const gw = availableGWs[i]; if (chipsByGw[gw] === "fh") continue; if (transfersByGw[gw]) runningItb += transfersByGw[gw].netDelta || 0; } } const fixedPlanForSummary = []; sol.plan.forEach(gwPlan => { const gw = gwPlan.gw; const getPts = (id) => { const p = globalPlayers.find(x => String(x.ID) === String(id)); return p ? (Number(p[`${gw}_Pts`]) || 0) : 0; }; const posOrder = { G: 1, D: 2, M: 3, F: 4 }; const getPos = (id) => { const p = globalPlayers.find(x => String(x.ID) === String(id)); return p ? posOrder[p.Pos] || 5 : 5; }; let activeLineup = [...gwPlan.lineup]; let activeBench = [...gwPlan.bench]; const isSymmetricChip = sol.chips_used && (sol.chips_used[String(gw)] === "bb" || sol.chips_used[String(gw)] === "fh"); // BUG 2 FIX: Auto-optimize the BB lineup visually so best players start, // using the opposing-play penalty for accurate EV ranking. if (isSymmetricChip) { const all15 = [...activeLineup, ...activeBench]; const pObjs = all15.map(id => { const p = globalPlayers.find(x => String(x.ID) === String(id)); return p ? { ...p } : { ID: id, Pos: 'M' }; }).filter(Boolean); const opt = getValidLayoutWithPenalty(pObjs, gw); if (opt) { const starterSet = new Set(opt.optimalArray.slice(0, 11).map(p => String(p.ID))); activeLineup = opt.optimalArray.slice(0, 11).map(p => p.ID); activeBench = opt.optimalArray.slice(11).map(p => p.ID); } } const sortedLineup = activeLineup.sort((a, b) => { const posDiff = getPos(a) - getPos(b); if (posDiff !== 0) return posDiff; return getPts(b) - getPts(a); }); newOverrides[gw] = { ids: [...sortedLineup, ...activeBench], cap: gwPlan.captain, vice: gwPlan.vice_captain, forcedZeros: [] }; if (sol.chips_used && sol.chips_used[String(gw)]) newChipsByGw[gw] = sol.chips_used[String(gw)]; let currentNetDelta = 0; // Track this GW's net delta if (gwPlan.transfers_in.length > 0 || gwPlan.transfers_out.length > 0) { // THE RE-APPLY FIX: Use hydratePlayer to pull the permanent locked squad prices, NEVER the current UI teamData! currentNetDelta = gwPlan.transfers_out.reduce((sum, id) => { const p = hydratePlayer(id); return sum + (p ? p.Price : 0); }, 0) - gwPlan.transfers_in.reduce((sum, id) => { const p = hydratePlayer(id); return sum + (p ? p.now_cost : 0); }, 0); newTransfersByGw[gw] = { count: gwPlan.transfers_in.length, hits: gwPlan.hits, netDelta: currentNetDelta, inIds: gwPlan.transfers_in, outIds: gwPlan.transfers_out }; newHighlights[gw] = [...gwPlan.transfers_in]; const newManualTransfersForGw = {}; gwPlan.transfers_in.forEach((inId, idx) => { const outId = gwPlan.transfers_out[idx]; const outP = hydratePlayer(outId); // Always fetches the accurate original player! if (outP) newManualTransfersForGw[inId] = outP; }); // THE FIX: Delete the solver memory so it doesn't double-render alongside the manual memory! delete newPairs[gw]; // CLEAN FIX: Attach the transfers directly to the master object we are building newOverrides[gw] = { ...newOverrides[gw], manualTransfers: { ...(newOverrides[gw]?.manualTransfers || {}), ...newManualTransfersForGw } }; if (gwPlan.chip) newChipsByGw[gw] = gwPlan.chip; } else { delete newTransfersByGw[gw]; delete newHighlights[gw]; delete newPairs[gw]; } let activeItbThisGw = runningItb + currentNetDelta; if (gwPlan.chip !== "fh") { runningItb += currentNetDelta; } fixedPlanForSummary.push({ ...gwPlan, itb: activeItbThisGw }); }); setManualOverrides(newOverrides); setTransfersByGw(newTransfersByGw); setChipsByGw(newChipsByGw); setHighlightTransferIds(newHighlights); setSolverTransferPairs(newPairs); setAppliedPlanSummary({ horizon: `GW${sol.horizon_gws[0]} - GW${sol.horizon_gws[sol.horizon_gws.length - 1]}`, ev: sol.ev, objectiveScore: sol.objective_score, plan: fixedPlanForSummary, lockedBaselineEv: horizonEV, transfers: sol.plan.map(p => ({ gw: p.gw, chip: p.chip, itb: p.itb, hits: p.hits, ft_at_start: p.ft_at_start, outs: p.transfers_out.map(id => globalPlayers.find(x => x.ID === id)?.Name || id), ins: p.transfers_in.map(id => globalPlayers.find(x => x.ID === id)?.Name || id) })) }); if (sol.plan.length > 0) { const activePlan = sol.plan.find(p => p.gw === activeGW) || sol.plan[0]; let nextSquad = [...teamData]; if (activePlan.transfers_in.length > 0) { activePlan.transfers_in.forEach((inId, idx) => { const pIn = globalPlayers.find(p => String(p.ID) === String(inId)); const outIndex = nextSquad.findIndex(p => String(p.ID) === String(activePlan.transfers_out[idx])); if (outIndex !== -1 && pIn) nextSquad[outIndex] = { ...pIn, Price: getPlayerPrice(pIn) }; }); } else { nextSquad = [...activePlan.lineup, ...activePlan.bench].map(id => { const existing = teamData.find(t => String(t.ID) === String(id)); const hydrated = hydratePlayer(id); if (existing && hydrated) return { ...hydrated, replacedPlayer: existing.replacedPlayer }; return hydrated; }).filter(Boolean); } const getPts = (p) => Number(p[`${activePlan.gw}_Pts`]) || 0; const finalLineup = activePlan.lineup.map(id => nextSquad.find(p => String(p.ID) === String(id))).filter(Boolean); const finalBench = activePlan.bench.map(id => nextSquad.find(p => String(p.ID) === String(id))).filter(Boolean); const sortedStarters = [ ...finalLineup.filter(p => p.Pos === "G").sort((a, b) => getPts(b) - getPts(a)), ...finalLineup.filter(p => p.Pos === "D").sort((a, b) => getPts(b) - getPts(a)), ...finalLineup.filter(p => p.Pos === "M").sort((a, b) => getPts(b) - getPts(a)), ...finalLineup.filter(p => p.Pos === "F").sort((a, b) => getPts(b) - getPts(a)), ]; const sortedBench = [ ...finalBench.filter(p => p.Pos === "G"), ...finalBench.filter(p => p.Pos !== "G").sort((a, b) => getPts(b) - getPts(a)) ]; setTeamData([...sortedStarters, ...sortedBench]); setCaptainId(activePlan.captain); setViceId(activePlan.vice_captain); } setPendingSolutions([]); }; const updateFutureTimelines = (oldSquad, newSquad, currentOverrides, currentTransfers, currentPairs, customMapping = null) => { let mapping = {}; let removedIds = []; let addedIds = []; if (customMapping) { Object.keys(customMapping).forEach(k => { mapping[String(k)] = String(customMapping[k]); removedIds.push(String(k)); addedIds.push(String(customMapping[k])); }); } else { const oldIds = oldSquad.map(p => String(p.ID)); const newIds = newSquad.map(p => String(p.ID)); removedIds = oldIds.filter(id => !newIds.includes(id) && !id.startsWith("blank_")); addedIds = newIds.filter(id => !oldIds.includes(id) && !id.startsWith("blank_")); for (let i = 0; i < Math.min(removedIds.length, addedIds.length); i++) { mapping[removedIds[i]] = addedIds[i]; } } const nextOverrides = { ...currentOverrides }; const nextTransfers = { ...currentTransfers }; const nextPairs = { ...currentPairs }; for (let gw = activeGW + 1; gw <= Math.max(...(availableGWs || [])); gw++) { // 1. SURGICAL SCRUB: Remove redundant "Buy" plans to kill the ghost button, // but DO NOT filter "outIds" so the Y->Z to X->Z cascade survives perfectly! if (nextTransfers[gw]) { nextTransfers[gw].inIds = (nextTransfers[gw].inIds || []).filter(id => !addedIds.includes(String(id))); nextTransfers[gw].count = nextTransfers[gw].inIds.length; if (nextTransfers[gw].count === 0) delete nextTransfers[gw]; } if (nextOverrides[gw]) { const lock = nextOverrides[gw]; const updatedIds = lock.ids.map(id => mapping[String(id)] || String(id)); // Anti-Time-Paradox: Only wipe the GW if the cascade creates literal duplicate players const uniqueIds = new Set(updatedIds); if (uniqueIds.size !== updatedIds.length) { delete nextOverrides[gw]; delete nextTransfers[gw]; delete nextPairs[gw]; // THE FIX: Plunge the timeline into darkness so the UI doesn't glow for a deleted GW! setHighlightTransferIds(prev => { const n = { ...prev }; delete n[gw]; return n; }); setTransfersByGw(prev => { const n = { ...prev }; delete n[gw]; return n; }); continue; } const updatedTransfers = {}; if (lock.manualTransfers) { for (const [inId, outPlayer] of Object.entries(lock.manualTransfers)) { // KILL OBSOLETE MOVES & GLOWS: Skip this move if the player is already naturally in the incoming squad if (addedIds.includes(String(inId)) || newSquad.some(p => String(p.ID) === String(inId))) { // FIX: highlightTransferIds is an object of arrays. Target the specific GW array. setHighlightTransferIds(prev => ({ ...prev, [gw]: Array.from(prev[gw] || []).filter(id => String(id) !== String(inId)) })); // FIX: transfersByGw is an object of objects. Safely reduce the count. setTransfersByGw(prev => { const currentGwTransfers = prev[gw]; if (!currentGwTransfers) return prev; const newInIds = Array.from(currentGwTransfers.inIds || []).filter(id => String(id) !== String(inId)); const newCount = Math.max(0, (currentGwTransfers.count || 1) - 1); if (newCount === 0) { const next = { ...prev }; delete next[gw]; return next; } return { ...prev, [gw]: { ...currentGwTransfers, inIds: newInIds, count: newCount } }; }); continue; } let newOutPlayer = outPlayer; const outIdStr = String(outPlayer?.ID); if (outPlayer && mapping[outIdStr]) { const mappedId = mapping[outIdStr]; let mappedP = globalPlayers.find(p => String(p.ID) === mappedId) || newSquad.find(p => String(p.ID) === mappedId); if (mappedP) newOutPlayer = { ...mappedP, Price: getPlayerPrice(mappedP) }; } updatedTransfers[mapping[String(inId)] || String(inId)] = newOutPlayer; } } // --- RE-OPTIMIZE LINEUP FOR FUTURE GAMEWEEKS --- // Instantly sub out the cascaded player if their EV is bad in this future gameweek const reconstructedSquad = updatedIds.map(id => { if (String(id).startsWith("blank_")) { const replaced = updatedTransfers[id]; return { ID: id, isBlank: true, Pos: replaced?.Pos || "M", Name: "", Team: "", Price: 0, replacedPlayer: replaced }; } return hydratePlayer(id); }).filter(Boolean); const opt = getValidLayoutWithPenalty(reconstructedSquad, gw); nextOverrides[gw] = { ...lock, ids: opt ? opt.optimalArray.map(p => p.ID) : updatedIds, manualTransfers: updatedTransfers, cap: opt ? opt.cap : mapping[String(lock.cap)] || lock.cap, vice: opt ? opt.vice : mapping[String(lock.vice)] || lock.vice }; } if (nextPairs[gw]) { const updatedGwPairs = {}; for (const [inId, pairData] of Object.entries(nextPairs[gw])) { // KILL GHOST BUTTON & GLOW: Skip this solver memory if the player naturally returns to squad if (addedIds.includes(String(inId)) || newSquad.some(p => String(p.ID) === String(inId))) { setHighlightTransferIds(prev => ({ ...prev, [gw]: Array.from(prev[gw] || []).filter(id => String(id) !== String(inId)) })); setTransfersByGw(prev => { const currentGwTransfers = prev[gw]; if (!currentGwTransfers) return prev; const newInIds = Array.from(currentGwTransfers.inIds || []).filter(id => String(id) !== String(inId)); const newCount = Math.max(0, (currentGwTransfers.count || 1) - 1); if (newCount === 0) { const next = { ...prev }; delete next[gw]; return next; } return { ...prev, [gw]: { ...currentGwTransfers, inIds: newInIds, count: newCount } }; }); continue; } let newOut = pairData.outPlayer; const outIdStr = String(newOut?.ID); if (newOut && mapping[outIdStr]) { const mappedId = mapping[outIdStr]; let mappedP = globalPlayers.find(p => String(p.ID) === mappedId) || newSquad.find(p => String(p.ID) === mappedId); if (mappedP) newOut = { ...mappedP, Price: getPlayerPrice(mappedP) }; } updatedGwPairs[mapping[String(inId)] || String(inId)] = { outPlayer: newOut }; } if (Object.keys(updatedGwPairs).length === 0) { delete nextPairs[gw]; } else { nextPairs[gw] = updatedGwPairs; } } } return { nextOverrides, nextTransfers, nextPairs }; }; const handleDragStart = (event) => setActiveDragPlayer(event.active.data.current.player); const isValidSwap = (p1, p2) => { if (!p1 || !p2 || p1.isBlank || p2.isBlank) return false; if (p1.ID === p2.ID) return true; if (p1.Pos === "G" && p2.Pos !== "G") return false; if (p1.Pos !== "G" && p2.Pos === "G") return false; const currentStarters = teamData.slice(0, 11); const isP1Starter = currentStarters.some((p) => p.ID === p1.ID); const isP2Starter = currentStarters.some((p) => p.ID === p2.ID); if (isP1Starter === isP2Starter) return true; const newStarters = currentStarters.filter((p) => p.ID !== p1.ID && p.ID !== p2.ID); newStarters.push(isP1Starter ? p2 : p1); const counts = { G: 0, D: 0, M: 0, F: 0 }; newStarters.forEach((p) => counts[p.Pos]++); return counts.G === 1 && counts.D >= 3 && counts.M >= 2 && counts.F >= 1 && newStarters.length === 11; }; const handleDragEnd = (event) => { const { active, over } = event; setActiveDragPlayer(null); if (over && active.id !== over.id) { const p1 = active.data.current.player; const p2 = over.data.current.player; if (isValidSwap(p1, p2)) { const newArr = [...teamData]; const idx1 = newArr.findIndex((p) => p.ID === p1.ID); const idx2 = newArr.findIndex((p) => p.ID === p2.ID); newArr[idx1] = p2; newArr[idx2] = p1; const normalized = idx1 >= 11 || idx2 >= 11 ? normalizeBenchGkFirst(newArr, activeGW) : newArr; const forcedZeros = manualOverrides[activeGW]?.forcedZeros || []; if ((Number(p1[`${activeGW}_Pts`]) || 0) === 0 && idx2 < 11) forcedZeros.push(p1.ID); if ((Number(p2[`${activeGW}_Pts`]) || 0) === 0 && idx1 < 11) forcedZeros.push(p2.ID); let newCap = captainId; let newVice = viceId; const getEV = (p) => p.isBlank ? -1000 : (Number(p[`${activeGW}_Pts`]) || 0); const starters = normalized.slice(0, 11); const starterIds = starters.map((p) => p.ID); if (!starterIds.includes(newCap)) { const sorted = [...starters].sort((a, b) => getEV(b) - getEV(a)); newCap = sorted[0]?.ID; if (newCap === newVice) newVice = sorted[1]?.ID; } if (!starterIds.includes(newVice)) { const sorted = [...starters].sort((a, b) => getEV(b) - getEV(a)); newVice = sorted.find((p) => p.ID !== newCap)?.ID; } setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: normalized.map((p) => p.ID), cap: newCap, vice: newVice, forcedZeros } })); setTeamData(normalized); setTransfersByGw(clearFuture); setHighlightTransferIds(clearFuture); setSolverTransferPairs(clearFuture); setChipsByGw(clearFuture); setAppliedPlanSummary(null); } } }; const handleCapChange = (id, type) => { let newCap = captainId; let newVice = viceId; if (type === "C") { newCap = id; if (viceId === id) newVice = captainId; } else { newVice = id; if (captainId === id) newCap = viceId; } setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: teamData.map((p) => p.ID), cap: newCap, vice: newVice, forcedZeros: prev[activeGW]?.forcedZeros } })); setCaptainId(newCap); setViceId(newVice); setTransfersByGw(clearFuture); setHighlightTransferIds(clearFuture); setSolverTransferPairs(clearFuture); setChipsByGw(clearFuture); setAppliedPlanSummary(null); }; const handleResetGW = () => { const opt = getValidLayoutWithPenalty(teamData, activeGW); if (!opt) return; setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: opt.optimalArray.map((p) => p.ID), cap: opt.cap, vice: opt.vice, forcedZeros: prev[activeGW]?.forcedZeros || [] } })); setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice); setTransfersByGw(clearFuture); setHighlightTransferIds(clearFuture); setSolverTransferPairs(clearFuture); setChipsByGw(clearFuture); setAppliedPlanSummary(null); }; const handleChipSelect = (gw, chipType) => { setChipsByGw((prev) => { const next = { ...prev }; if (!chipType) { delete next[gw]; } else { Object.keys(next).forEach((g) => { if (next[g] === chipType) delete next[g]; }); next[gw] = chipType; } return clearFuture(next); }); setManualOverrides(clearFuture); setTransfersByGw(clearFuture); setHighlightTransferIds(clearFuture); setSolverTransferPairs(clearFuture); setAppliedPlanSummary(null); }; const handleTransferOut = (playerToDrop) => { const sellPrice = getPlayerPrice(playerToDrop); const blankId = `blank_${Date.now()}`; const newSquad = teamData.map((p) => String(p.ID) === String(playerToDrop.ID) ? { ID: blankId, isBlank: true, Pos: p.Pos, Name: "", Team: "", Price: 0, replacedPlayer: playerToDrop } : p); const opt = getValidLayoutWithPenalty(newSquad, activeGW); const finalSquad = opt ? opt.optimalArray : newSquad; let nextTransfers = { ...transfersByGw }; nextTransfers[activeGW] = { ...(nextTransfers[activeGW] || { count: 0, netDelta: 0 }), netDelta: (nextTransfers[activeGW]?.netDelta || 0) + sellPrice }; let nextOverrides = { ...manualOverrides }; nextOverrides[activeGW] = { ...(nextOverrides[activeGW] || {}), ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, manualTransfers: { ...(nextOverrides[activeGW]?.manualTransfers || {}), [blankId]: playerToDrop } }; const mapping = { [playerToDrop.ID]: blankId }; const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping); setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP); setHighlightTransferIds(clearFuture); setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null); }; const handleAddPlayer = (newPlayer) => { const cost = getPlayerPrice(newPlayer); if (itb < cost) return alert("Insufficient funds!"); const newSquad = teamData.map((p) => String(p.ID) === String(selectedPlayer.ID) ? { ...newPlayer, Price: cost, purchase_price: newPlayer.now_cost, selling_price: newPlayer.now_cost, replacedPlayer: selectedPlayer.replacedPlayer } : p); const opt = getValidLayoutWithPenalty(newSquad, activeGW); const finalSquad = opt ? opt.optimalArray : newSquad; let nextTransfers = { ...transfersByGw }; nextTransfers[activeGW] = { ...(nextTransfers[activeGW] || { count: 0, netDelta: 0 }), count: (nextTransfers[activeGW]?.count || 0) + 1, netDelta: (nextTransfers[activeGW]?.netDelta || 0) - cost }; const newManualTransfers = { ...(manualOverrides[activeGW]?.manualTransfers || {}) }; delete newManualTransfers[selectedPlayer.ID]; if (selectedPlayer.replacedPlayer) newManualTransfers[newPlayer.ID] = selectedPlayer.replacedPlayer; let nextOverrides = { ...manualOverrides }; nextOverrides[activeGW] = { ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, forcedZeros: nextOverrides[activeGW]?.forcedZeros || [], manualTransfers: newManualTransfers }; const mapping = { [selectedPlayer.ID]: newPlayer.ID }; if (selectedPlayer.replacedPlayer) mapping[selectedPlayer.replacedPlayer.ID] = newPlayer.ID; const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping); setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP); if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); } setHighlightTransferIds((prev) => clearFuture({ ...prev, [activeGW]: [...(prev[activeGW] || []), newPlayer.ID] })); setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null); setSearchQuery(""); }; const handleUndoTransfer = (e, currentId, replacedPlayer) => { e.stopPropagation(); const buyPlayer = teamData.find((p) => String(p.ID) === String(currentId)) || globalPlayers.find((p) => String(p.ID) === String(currentId)); const buy = (!String(currentId).startsWith("blank_") && buyPlayer) ? getPlayerPrice(buyPlayer) : 0; const sell = getPlayerPrice(replacedPlayer); // FRESHEN REPLACED PLAYER: Ensure EV is up to date before optimizing the lineup // const freshReplacedPlayer = { ...(globalPlayers.find(p => String(p.ID) === String(replacedPlayer.ID)) || replacedPlayer), Price: getPlayerPrice(replacedPlayer) }; const freshReplacedPlayer = hydratePlayer(replacedPlayer.ID, replacedPlayer) || replacedPlayer; const newSquad = teamData.map((p) => (String(p.ID) === String(currentId) ? freshReplacedPlayer : p)); const opt = getValidLayoutWithPenalty(newSquad, activeGW); const finalSquad = opt ? opt.optimalArray : newSquad; let nextTransfers = { ...transfersByGw }; const row = nextTransfers[activeGW] || { count: 0, netDelta: 0 }; nextTransfers[activeGW] = { ...row, count: Math.max(0, row.count - (!String(currentId).startsWith("blank_") ? 1 : 0)), netDelta: row.netDelta - (sell - buy) }; let nextOverrides = { ...manualOverrides }; const newManualTransfers = { ...(nextOverrides[activeGW]?.manualTransfers || {}) }; delete newManualTransfers[currentId]; nextOverrides[activeGW] = { ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, forcedZeros: nextOverrides[activeGW]?.forcedZeros || [], manualTransfers: newManualTransfers }; const mapping = { [currentId]: replacedPlayer.ID }; const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping); setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP); if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); } setHighlightTransferIds((prev) => clearFuture({ ...prev, [activeGW]: Array.from(prev[activeGW] || []).filter((id) => String(id) !== String(currentId)) })); setChipsByGw(clearFuture); setAppliedPlanSummary(null); }; const resetHighlightedTransfer = (player) => { const pair = (solverTransferPairs[activeGW] || {})[player.ID]; if (pair?.outPlayer) { const idx = teamData.findIndex((p) => p.ID === player.ID); if (idx < 0) return; // FRESHEN REPLACED PLAYER: Ensure EV is up to date before optimizing the lineup // const freshOutPlayer = { ...(globalPlayers.find(p => String(p.ID) === String(pair.outPlayer.ID)) || pair.outPlayer), Price: getPlayerPrice(pair.outPlayer) }; const freshOutPlayer = hydratePlayer(pair.outPlayer.ID, pair.outPlayer) || pair.outPlayer; const newSquad = [...teamData]; newSquad[idx] = freshOutPlayer; const buyPrice = getPlayerPrice(player); const sellPrice = getPlayerPrice(pair.outPlayer); let nextTransfers = { ...transfersByGw }; const row = nextTransfers[activeGW] || { count: 0, netDelta: 0 }; nextTransfers[activeGW] = { ...row, count: Math.max(0, row.count - 1), netDelta: row.netDelta - (sellPrice - buyPrice), inIds: Array.from(row.inIds || []).filter(id => String(id) !== String(player.ID)), outIds: Array.from(row.outIds || []).filter(id => String(id) !== String(pair.outPlayer.ID)) }; const opt = getValidLayoutWithPenalty(newSquad, activeGW); const finalSquad = opt ? opt.optimalArray : newSquad; let nextOverrides = { ...manualOverrides }; nextOverrides[activeGW] = { ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, forcedZeros: nextOverrides[activeGW]?.forcedZeros || [] }; const mapping = { [player.ID]: pair.outPlayer.ID }; const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping); setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); } const nP = { ...cascadedP }; if (nP[activeGW]) { delete nP[activeGW][player.ID]; } setSolverTransferPairs(nP); setHighlightTransferIds((prev) => { const n = { ...prev }; if (n[activeGW]) { const gwSet = new Set(n[activeGW]); gwSet.delete(player.ID); n[activeGW] = Array.from(gwSet); } return clearFuture(n); }); setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null); return; } if (player.replacedPlayer) { handleUndoTransfer({ stopPropagation: () => { } }, player.ID, player.replacedPlayer); return; } handleTransferOut(player); }; const handleResetGWTransfers = () => { let previousSquadIds = []; const currentIndex = availableGWs.indexOf(activeGW); if (currentIndex > 0) { const prevGw = availableGWs[currentIndex - 1]; previousSquadIds = manualOverrides[prevGw]?.ids || initialSquadIds; } else { previousSquadIds = initialSquadIds; } //const restoredSquadUnsorted = previousSquadIds.map(id => { // const p = globalPlayers.find(x => String(x.ID) === String(id)); // const existing = teamData.find(t => String(t.ID) === String(id)); // return existing ? { ...p, Price: existing.Price } : { ...p, Price: getPlayerPrice(p) }; // }).filter(Boolean); const restoredSquadUnsorted = previousSquadIds.map(id => hydratePlayer(id)).filter(Boolean); const opt = getValidLayoutWithPenalty(restoredSquadUnsorted, activeGW); const finalSquad = opt ? opt.optimalArray : restoredSquadUnsorted; let nextTransfers = { ...transfersByGw }; delete nextTransfers[activeGW]; let nextOverrides = { ...manualOverrides }; nextOverrides[activeGW] = { ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, forcedZeros: [], manualTransfers: {} }; const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs); setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); } const nP = { ...cascadedP }; delete nP[activeGW]; setSolverTransferPairs(nP); setHighlightTransferIds(prev => { const next = { ...prev }; delete next[activeGW]; return clearFuture(next); }); setChipsByGw(prev => { const next = { ...prev }; delete next[activeGW]; return clearFuture(next); }); setSolverApplySnapshot(null); setAppliedPlanSummary(null); }; // --- UI FIREWALL --- // Forces the Pitch to instantly drop stale undo buttons during GW tab switches const renderTeamData = useMemo(() => { const lock = manualOverrides[activeGW]; return teamData.map(p => { if (p.isBlank && String(p.ID).startsWith("blank_")) return p; const cleanP = { ...p }; if (lock?.manualTransfers && lock.manualTransfers[p.ID]) { cleanP.replacedPlayer = lock.manualTransfers[p.ID]; } else { delete cleanP.replacedPlayer; } return cleanP; }); }, [teamData, activeGW, manualOverrides]); return (
Solving
Elapsed {solveElapsedSec}s · up to {quickSettings.iterations} iteration(s)
Chip Solving
Elapsed {chipSolveTimer}s
Sensitivity Analysis
Elapsed {sensTimer}s · {numSims} sims running…
Enter your FPL Team ID to set it as your default for future logins.
setInitialIdInput(e.target.value)} placeholder="e.g. 123456" className="w-full bg-slate-900 border border-slate-700 rounded-lg py-2.5 px-4 text-sm font-bold text-slate-200 focus:outline-none focus:border-emerald-500 text-center mb-4" />