// src/PlayerContext.jsx import React, { createContext, useState, useEffect, useRef, useCallback, useMemo } from 'react'; export const PlayerContext = createContext(); export const FixturesContext = createContext({}); export const PlayerProvider = ({ children }) => { const globalFixturesRef = useRef({}); const [originalPlayers, setOriginalPlayers] = useState([]); const [globalPlayers, setGlobalPlayers] = useState([]); const [globalFixtures, setGlobalFixtures] = useState({}); const [isLoadingDB, setIsLoadingDB] = useState(true); const [teamId, setTeamId] = useState(''); const [availableGWs, setAvailableGWs] = useState([]); const [itb, setItb] = useState(0); const [availableFts, setAvailableFts] = useState(1); const [initialSquadIds, setInitialSquadIds] = useState([]); const [solverResult, setSolverResult] = useState(null); const [activeChip, setActiveChip] = useState(null); const [solveElapsedSec, setSolveElapsedSec] = useState(0); const [numSims, setNumSims] = useState(100); const HIT_COST = 4; const [baselineItb, setBaselineItb] = useState(0); const [baselineFt, setBaselineFt] = useState(1); const [comprehensiveSettings, setComprehensiveSettings] = useState({}); const [globalXmins, setGlobalXmins] = useState({}); const [quickSettings, setQuickSettings] = useState({ decay: 0.85, ft_value: 1.5, iterations: 1, banned: [], locked: [], }); const [advancedSettings, setAdvancedSettings] = useState({ hit_cost: 4, itb_value: 0.08, max_per_team: 3, vice_weight: 0.05, time_limit_sec: 30, no_transfer_last_gws: 0 }); // FIXED: FPL 24/25 FT Rollover Logic const ftAtStartOfGw = (targetGw, gwsArray, baseFt, trByGw, chByGw) => { if (!gwsArray || !gwsArray.length) return baseFt; let ft = baseFt; for (let gw = gwsArray[0]; gw < targetGw; gw++) { const chip = chByGw[gw]; if (chip === 'wc' || chip === 'fh') { ft = Math.min(5, ft); } else { const used = trByGw[gw]?.count || 0; ft = Math.min(5, Math.max(0, ft - used) + 1); } } return Math.max(1, Math.min(5, ft)); }; const itbAtStartOfGw = (targetGw, gwsArray, baseItb, trByGw) => { let currentItb = baseItb; if (!gwsArray || !gwsArray.length) return currentItb; for (let gw = gwsArray[0]; gw < targetGw; gw++) { currentItb += (trByGw[gw]?.netDelta || 0); } return currentItb; }; // ========================================================= // --- MULTIVERSE DRAFTS ENGINE (Phase 1) --- // ========================================================= const [drafts, setDrafts] = useState([{ id: "main_1", name: "Main Timeline", teamData: [], horizon: 5, activeGW: null, captainId: null, viceId: null, solverTransferPairs: {}, solverApplySnapshot: null, appliedPlanSummary: null, hitsThisGw: 0, highlightTransferIds: {}, transfersByGw: {}, chipsByGw: {}, manualOverrides: {}, fixtureOverrides: {}, // <-- ADDED: Isolated Fixtures sessionEdits: {} // <-- ADDED: Isolated Minutes }]); const [activeDraftId, setActiveDraftId] = useState("main_1"); // 1. EXTRACT CURRENT REALITY const activeDraft = drafts.find(d => d.id === activeDraftId) || drafts[0]; const teamData = activeDraft.teamData; const horizon = activeDraft.horizon; const activeGW = activeDraft.activeGW; const captainId = activeDraft.captainId; const viceId = activeDraft.viceId; const solverTransferPairs = activeDraft.solverTransferPairs || {}; const solverApplySnapshot = activeDraft.solverApplySnapshot; const appliedPlanSummary = activeDraft.appliedPlanSummary; const hitsThisGw = activeDraft.hitsThisGw; const highlightTransferIds = activeDraft.highlightTransferIds || {}; const transfersByGw = activeDraft.transfersByGw || {}; const chipsByGw = activeDraft.chipsByGw || {}; // Safe extraction fallbacks for older local cache hits const manualOverrides = activeDraft.manualOverrides || {}; const fixtureOverrides = activeDraft.fixtureOverrides || {}; const sessionEdits = activeDraft.sessionEdits || {}; const effectiveFixtures = useMemo(() => { return { ...globalFixtures, ...(fixtureOverrides || {}) }; }, [globalFixtures, fixtureOverrides]); // 2. PROXY SETTERS (Intercepts state calls and routes them to the active draft) const updateDraftState = useCallback((key, newValue) => { setDrafts(prevDrafts => { const activeIndex = prevDrafts.findIndex(d => d.id === activeDraftId); if (activeIndex === -1) return prevDrafts; const draft = prevDrafts[activeIndex]; // Provide an empty object fallback for expected object states to prevent functional crashes const currentValue = draft[key] !== undefined ? draft[key] : (key === 'teamData' || key === 'availableGWs' ? [] : {}); const evaluatedValue = typeof newValue === 'function' ? newValue(currentValue) : newValue; const newDrafts = [...prevDrafts]; newDrafts[activeIndex] = { ...draft, [key]: evaluatedValue }; return newDrafts; }); }, [activeDraftId]); const setTeamData = useCallback((val) => updateDraftState("teamData", val), [updateDraftState]); const setHorizon = useCallback((val) => updateDraftState("horizon", val), [updateDraftState]); const setActiveGW = useCallback((val) => updateDraftState("activeGW", val), [updateDraftState]); const setCaptainId = useCallback((val) => updateDraftState("captainId", val), [updateDraftState]); const setViceId = useCallback((val) => updateDraftState("viceId", val), [updateDraftState]); const setSolverTransferPairs = useCallback((val) => updateDraftState("solverTransferPairs", val), [updateDraftState]); const setSolverApplySnapshot = useCallback((val) => updateDraftState("solverApplySnapshot", val), [updateDraftState]); const setAppliedPlanSummary = useCallback((val) => updateDraftState("appliedPlanSummary", val), [updateDraftState]); const setHitsThisGw = useCallback((val) => updateDraftState("hitsThisGw", val), [updateDraftState]); const setHighlightTransferIds = useCallback((val) => updateDraftState("highlightTransferIds", val), [updateDraftState]); const setTransfersByGw = useCallback((val) => updateDraftState("transfersByGw", val), [updateDraftState]); const setChipsByGw = useCallback((val) => updateDraftState("chipsByGw", val), [updateDraftState]); const setFixtureOverrides = useCallback((val) => updateDraftState("fixtureOverrides", val), [updateDraftState]); // <-- GHOST PATCH const setSessionEdits = useCallback((val) => updateDraftState("sessionEdits", val), [updateDraftState]); // <-- GHOST PATCH // ========================================================= const manualOverridesRef = useRef(manualOverrides); useEffect(() => { manualOverridesRef.current = manualOverrides; }, [manualOverrides]); const [projSearchTerm, setProjSearchTerm] = useState(''); const sessionEditsRef = useRef(sessionEdits); useEffect(() => { sessionEditsRef.current = sessionEdits; }, [sessionEdits]); const [isLoggedIn, setIsLoggedIn] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(true); const [userProfile, setUserProfile] = useState({ username: "Guest", defaultTeamId: null, isAdmin: false }); const [hasGuestMadeEdits, setHasGuestMadeEdits] = useState(false); const [pendingWorkspaceLoad, setPendingWorkspaceLoad] = useState(null); // Custom proxy setter for manualOverrides to retain your exact Auth saving logic const setManualOverrides = useCallback((updater) => { updateDraftState("manualOverrides", (prev) => { const next = typeof updater === 'function' ? updater(prev) : updater; 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({ saved_edits: { ...sessionEditsRef.current, _solver_overrides: next } }) }); } return next; }); }, [updateDraftState]); const saveSession = (overrides) => { 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({ saved_edits: { ...sessionEditsRef.current, _solver_overrides: overrides }, drafts: drafts }) }); } }; useEffect(() => { // YOUR EXACT PROJECTIONS API ENDPOINT RESTORED const cachedProjections = localStorage.getItem('fpl_projections_cache'); if (cachedProjections) { try { const parsed = JSON.parse(cachedProjections); setOriginalPlayers(JSON.parse(JSON.stringify(parsed))); setGlobalPlayers(parsed); setIsLoadingDB(false); // Instantly drop the skeleton loader! } catch (e) { console.error("Cache corrupted, waiting for server..."); } } // 2. Silently fetch the fresh data in the background fetch('https://anayshukla-fpl-solver.hf.space/api/projections') .then(res => { if (!res.ok) throw new Error("DB Error"); return res.json(); }) .then(data => { localStorage.setItem('fpl_projections_cache', JSON.stringify(data)); // Save for next time setOriginalPlayers(JSON.parse(JSON.stringify(data))); setGlobalPlayers(data); setIsLoadingDB(false); // Drop loader if cache was empty }) .catch(err => { if (!cachedProjections) setIsLoadingDB(false); }); // THE FIX: Store global fixtures in the Ref so the Auth wipe doesn't delete them! fetch('https://anayshukla-fpl-solver.hf.space/api/fixtures/overrides') .then(res => res.ok ? res.json() : {}) .then(data => { if (Object.keys(data).length > 0) setGlobalFixtures(data); }) .catch(err => console.error("Failed to load global fixtures:", err)); fetch('https://anayshukla-fpl-solver.hf.space/api/xmins/overrides') .then(res => res.ok ? res.json() : {}) .then(data => { if (Object.keys(data).length > 0) setGlobalXmins(data); }) .catch(err => console.error("Failed to load global xMins:", err)); }, []); useEffect(() => { const token = localStorage.getItem('fpl_token'); if (token) { fetch('https://anayshukla-fpl-solver.hf.space/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } }) .then(res => res.json()) .then(data => { if (data.email) { setUserProfile({ username: data.email.split('@')[0], defaultTeamId: data.default_team_id, isAdmin: data.is_admin }); // THE FIX: Inject the Multiverse realities from the database! if (data.drafts && data.drafts.length > 0) { setDrafts(data.drafts); const saved = data.saved_edits || {}; if (saved._active_draft_id && data.drafts.some(d => d.id === saved._active_draft_id)) { setActiveDraftId(saved._active_draft_id); } else { setActiveDraftId(data.drafts[0].id); } } else { // ONLY run these legacy injectors if the Multiverse doesn't exist yet! // Otherwise, they use a stale React closure and overwrite Draft A with Draft B's moves! const saved = data.saved_edits || {}; if (saved._solver_overrides) { setManualOverrides(saved._solver_overrides); delete saved._solver_overrides; } if (saved._workspace) { setPendingWorkspaceLoad(saved._workspace); delete saved._workspace; } setSessionEdits(saved); } setIsLoggedIn(true); if (data.default_team_id) setTeamId(String(data.default_team_id)); } else { localStorage.removeItem('fpl_token'); setSessionEdits({}); setManualOverrides({}); setTeamId(''); setTeamData([]); setDrafts([{ id: "main_1", name: "Main Timeline", teamData: [], horizon: 5, activeGW: null, captainId: null, viceId: null, solverTransferPairs: {}, solverApplySnapshot: null, appliedPlanSummary: null, hitsThisGw: 0, highlightTransferIds: {}, transfersByGw: {}, chipsByGw: {}, manualOverrides: {}, fixtureOverrides: {}, sessionEdits: {} }]); setActiveDraftId("main_1"); } }).catch(() => { localStorage.removeItem('fpl_token'); setSessionEdits({}); setManualOverrides({}); setTeamId(''); setTeamData([]); setDrafts([{ id: "main_1", name: "Main Timeline", teamData: [], horizon: 5, activeGW: null, captainId: null, viceId: null, solverTransferPairs: {}, solverApplySnapshot: null, appliedPlanSummary: null, hitsThisGw: 0, highlightTransferIds: {}, transfersByGw: {}, chipsByGw: {}, manualOverrides: {}, fixtureOverrides: {}, sessionEdits: {} }]); setActiveDraftId("main_1"); }).finally(() => { setIsCheckingAuth(false); }); } else { setSessionEdits({}); setManualOverrides({}); setTeamId(''); setTeamData([]); setIsCheckingAuth(false); } }, [isLoggedIn]); useEffect(() => { if (originalPlayers.length > 0) { setGlobalPlayers(prev => { const newPlayers = JSON.parse(JSON.stringify(originalPlayers)); // --- 1. THE STOCHASTIC FIXTURE ENGINE --- newPlayers.forEach(p => { if (p.match_projections) { // A. Zero out old stats + Track probability sum for averaging const gwKeys = Object.keys(p).filter(k => k.includes('_Pts')).map(k => k.split('_')[0]); gwKeys.forEach(gw => { p[`${gw}_Pts`] = 0; p[`${gw}_xMins`] = 0; p[`${gw}_xG`] = 0; p[`${gw}_xA`] = 0; p[`${gw}_CS`] = 0; p[`${gw}_probSum`] = 0; // NEW: Tracks total matches in this GW }); // B. Loop matches and apply Match-Level Minute Edits const manualBaseline = sessionEdits[p.ID]?.baseline_xMins; const origBaseline = p.baseline_xMins || 90; const baselineScale = (manualBaseline != null && origBaseline > 0) ? (Number(manualBaseline) / origBaseline) : 1.0; if (!p.match_projections || typeof p.match_projections !== 'object' || Array.isArray(p.match_projections)) return; Object.entries(p.match_projections).forEach(([matchId, mData]) => { const override = effectiveFixtures[matchId]; let manualMins = sessionEdits[p.ID]?.[`${matchId}_xMins`]; let globalMatchMins = globalXmins[p.ID]?.[matchId]; let isGwOverride = false; if (manualMins === undefined) { if (globalMatchMins !== undefined) { manualMins = globalMatchMins; } else { let activeGw = override ? Object.keys(override).find(g => override[g] > 0) : mData.default_gw; if (activeGw) { manualMins = sessionEdits[p.ID]?.[`${activeGw}_xMins`] ?? globalXmins?.[p.ID]?.[activeGw]; if (manualMins !== undefined) isGwOverride = true; } } } // THE FIX: Safely extract data so NaN never infects the app! const origMins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0); const origPts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0); let activeMins = origMins; if (manualMins != null) { if (isGwOverride) { // DISTRIBUTE LEAK FIX: If an admin edited a whole GW, distribute it proportionally across the DGW splits! let totalGwMins = 0; Object.values(p.match_projections).forEach(m => { if (m.default_gw === mData.default_gw) totalGwMins += (m.xMins !== undefined ? m.xMins : (m.mins || 0)); }); const ratio = totalGwMins > 0 ? (origMins / totalGwMins) : 1; activeMins = Math.min(Number(manualMins) * ratio, 90); } else { activeMins = Number(manualMins); } } else { activeMins = Math.min((origMins * baselineScale), 90); } // Scale the match EV safely const scaling = (activeMins > 0 && origMins > 0) ? (activeMins / origMins) : 0; const aPts = origPts * scaling; const axG = (mData.xG || 0) * scaling; const axA = (mData.xA || 0) * scaling; const aCS = (mData.CS || 0) * scaling; // C. Distribute the scaled EV if (override) { Object.entries(override).forEach(([gwStr, prob]) => { const gw = gwStr; p[`${gw}_Pts`] += (aPts * prob); p[`${gw}_xMins`] += (activeMins * prob); p[`${gw}_probSum`] += prob; // Add probability to the GW sum p[`${gw}_xG`] += (axG * prob); p[`${gw}_xA`] += (axA * prob); p[`${gw}_CS`] += (aCS * prob); }); } else { const gw = mData.default_gw; p[`${gw}_Pts`] += aPts; p[`${gw}_xMins`] += activeMins; p[`${gw}_probSum`] += 1.0; p[`${gw}_xG`] += axG; p[`${gw}_xA`] += axA; p[`${gw}_CS`] += aCS; } }); // D. Calculate FPL Average xMins gwKeys.forEach(gw => { if (p[`${gw}_probSum`] > 0) { p[`${gw}_xMins`] = Math.round(p[`${gw}_xMins`] / p[`${gw}_probSum`]); } }); } }); // --- 2. APPLY MANUAL SESSION EDITS (Overwrites everything) --- if (Object.keys(sessionEdits).length > 0) { Object.keys(sessionEdits).forEach(playerId => { if (playerId === '_solver_overrides') return; const pid = parseInt(playerId); const pIdx = newPlayers.findIndex(p => p.ID === pid); if (pIdx > -1) { const edits = sessionEdits[playerId]; Object.keys(edits).forEach(editKey => { newPlayers[pIdx][editKey] = edits[editKey]; if (editKey.includes('_xMins') && !editKey.includes('_vs_')) { const gw = editKey.split('_')[0]; if (edits[`${gw}_Pts`] === undefined) { const baseMins = originalPlayers[pIdx][`${gw}_xMins`] || 90; const basePts = originalPlayers[pIdx][`${gw}_Pts`] || 0; newPlayers[pIdx][`${gw}_Pts`] = baseMins > 0 ? (basePts / baseMins) * edits[editKey] : 0; } } }); } }); } return newPlayers; }); } }, [originalPlayers, isLoggedIn, sessionEdits, effectiveFixtures]); useEffect(() => { if (!isLoggedIn || isLoadingDB || globalPlayers.length === 0) return; const timeout = setTimeout(() => { const workspace = { teamData: teamData.map(p => ({ ID: p.ID, Price: p.Price, isBlank: p.isBlank, replacedPlayer: p.replacedPlayer })), horizon, activeGW, baselineItb, baselineFt, transfersByGw, chipsByGw, quickSettings, advancedSettings, highlightTransferIds: Object.fromEntries(Object.entries(highlightTransferIds).map(([k, v]) => [k, Array.from(v)])), solverTransferPairs }; 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({ saved_edits: { ...sessionEditsRef.current, _solver_overrides: manualOverridesRef.current, _workspace: workspace, _active_draft_id: activeDraftId }, drafts: drafts // <-- THE FIX: Package and send the entire Multiverse! }) }); } }, 500); // THE FIX: Added 'drafts' to the end of this dependency array so edits trigger the save }, [teamData, horizon, activeGW, baselineItb, baselineFt, transfersByGw, chipsByGw, quickSettings, advancedSettings, highlightTransferIds, solverTransferPairs, isLoggedIn, isLoadingDB, globalPlayers, drafts]); useEffect(() => { if (globalPlayers.length === 0 || teamData.length === 0) return; setTeamData(prevTeam => { let needsUpdate = false; const syncedTeam = prevTeam.map(tp => { const gp = globalPlayers.find(p => p.ID === tp.ID); if (!gp) return tp; // If the squad's math is out of sync with the global math, flag it for an update! // (We check a few sample gameweeks to guarantee we catch the mismatch) if ( tp['1_Pts'] !== gp['1_Pts'] || tp['19_Pts'] !== gp['19_Pts'] || tp['38_Pts'] !== gp['38_Pts'] || tp.baseline_xMins !== gp.baseline_xMins ) { needsUpdate = true; return { ...tp, ...gp, Price: tp.Price }; // Merges the math, but protects your specific Selling Price! } return tp; }); // Only triggers a re-render if it actually found stale data return needsUpdate ? syncedTeam : prevTeam; }); }, [globalPlayers, teamData, setTeamData]); const updatePlayerStat = (playerId, gw, statKey, rawValue) => { let finalValue = statKey === 'xMins' ? Math.min(Math.max(Number(rawValue), 0), 90) : rawValue; if (!isLoggedIn) setHasGuestMadeEdits(true); const pristinePlayer = originalPlayers.find(p => p.ID === playerId); let calculatedPts = 0; // 1. Determine the exact original value to see if we are reverting let isRevertingToOriginal = false; const isMatchId = String(gw).includes('_vs_'); // FIX: Look specifically inside match_projections for DGW match edits! if (pristinePlayer && pristinePlayer.match_projections && isMatchId) { const mData = pristinePlayer.match_projections[gw]; if (mData) { const mOrig = statKey === 'xMins' ? mData.xMins : mData[statKey]; isRevertingToOriginal = (finalValue === mOrig); } } else { const originalValue = pristinePlayer ? pristinePlayer[`${gw}_${statKey}`] : undefined; if (originalValue !== undefined) { isRevertingToOriginal = (finalValue === originalValue); } else if (statKey === 'xMins' && finalValue === 90) { isRevertingToOriginal = true; } } // Calculate instant EV for UI feedback if (statKey === 'xMins') { if (pristinePlayer && pristinePlayer.match_projections && isMatchId) { const mData = pristinePlayer.match_projections[gw]; const baseMins = mData ? mData.xMins : 90; const basePts = mData ? mData.Pts : 0; calculatedPts = baseMins > 0 ? (basePts / baseMins) * finalValue : 0; } else { const baseMins = pristinePlayer ? pristinePlayer[`${gw}_xMins`] || 90 : 90; const basePts = pristinePlayer ? pristinePlayer[`${gw}_Pts`] || 0 : 0; calculatedPts = baseMins > 0 ? (basePts / baseMins) * finalValue : 0; } } setGlobalPlayers(prev => prev.map(p => { if (p.ID === playerId) { let updated = { ...p, [`${gw}_${statKey}`]: finalValue }; if (statKey === 'xMins') updated[`${gw}_Pts`] = calculatedPts; return updated; } return p; })); setTeamData(prev => prev.map(p => { if (p.ID === playerId) { let updated = { ...p, [`${gw}_${statKey}`]: finalValue }; if (statKey === 'xMins') updated[`${gw}_Pts`] = calculatedPts; return updated; } return p; })); // 2. The Smart Self-Cleaning Session Edits setSessionEdits(prev => { const newEdits = { ...prev }; if (isRevertingToOriginal) { if (newEdits[playerId]) { newEdits[playerId] = { ...newEdits[playerId] }; delete newEdits[playerId][`${gw}_${statKey}`]; if (statKey === 'xMins') delete newEdits[playerId][`${gw}_Pts`]; if (Object.keys(newEdits[playerId]).length === 0) delete newEdits[playerId]; } } else { if (!newEdits[playerId]) newEdits[playerId] = {}; newEdits[playerId] = { ...newEdits[playerId], [`${gw}_${statKey}`]: finalValue }; if (statKey === 'xMins') { // Do not hardcode match points so the stochastic engine can dynamically scale it! if (!isMatchId) newEdits[playerId][`${gw}_Pts`] = calculatedPts; } } if (isLoggedIn) { fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('fpl_token')}` }, body: JSON.stringify({ saved_edits: { ...newEdits, _solver_overrides: manualOverridesRef.current } }) }); } return newEdits; }); }; // --- STOCHASTIC ENGINE INVALIDATION --- // If the user changes any fixture odds, the current solver lineup is now mathematically invalid. // This instantly clears the stale lineup and forces the UI to reset. useEffect(() => { if (solverResult) { setSolverResult(null); } if (appliedPlanSummary) { setAppliedPlanSummary(null); } }, [fixtureOverrides]); return ( {children} ); };