// engine.jsx — puntos de la quiniela + tablas de grupo + clasificación + cuadro. → window // // Puntos por partido: marcador exacto = EXACT (5) · resultado correcto = RESULT (3) · otro = 0 const DEFAULT_SCORING = { exact: 5, result: 3 }; function matchPoints(pred, res, scoring) { const S = scoring || DEFAULT_SCORING; if (!pred || !res || res.h == null || res.a == null || pred.h == null || pred.a == null) return null; if (Number(pred.h) === Number(res.h) && Number(pred.a) === Number(res.a)) return S.exact; if (Math.sign(pred.h - pred.a) === Math.sign(res.h - res.a)) return S.result; return 0; } function matchOutcome(pred, res) { if (!pred || !res || res.h == null || res.a == null || pred.h == null || pred.a == null) return null; if (Number(pred.h) === Number(res.h) && Number(pred.a) === Number(res.a)) return "exact"; if (Math.sign(pred.h - pred.a) === Math.sign(res.h - res.a)) return "result"; return "miss"; } function resultFor(match, state) { if (match.archived) return window.SEED_RESULTS[match.id] || null; return (state.results && state.results[match.id]) || null; } function isFinal(match, state) { const r = resultFor(match, state); // Compatibilidad: versiones anteriores podían guardar marcador sin `final: true`. // Si tiene ambos goles y no fue reabierto explícitamente con final:false, debe contar en la tabla. return !!(r && r.h != null && r.a != null && r.final !== false); } // ---- Tabla de un grupo function groupTable(group, state) { const teams = window.GROUP_TEAMS[group]; const row = {}; teams.forEach((c) => (row[c] = { code: c, group, pj: 0, pg: 0, pe: 0, pp: 0, pts: 0, gf: 0, gc: 0, dg: 0 })); window.MATCHES.filter((m) => m.group === group).forEach((m) => { if (!isFinal(m, state)) return; const r = resultFor(m, state); const H = row[m.home], A = row[m.away]; H.pj++; A.pj++; H.gf += r.h; H.gc += r.a; A.gf += r.a; A.gc += r.h; if (r.h > r.a) { H.pts += 3; H.pg++; A.pp++; } else if (r.h < r.a) { A.pts += 3; A.pg++; H.pp++; } else { H.pts++; A.pts++; H.pe++; A.pe++; } }); const arr = Object.values(row); arr.forEach((t) => (t.dg = t.gf - t.gc)); arr.sort((a, b) => b.pts - a.pts || b.dg - a.dg || b.gf - a.gf || a.code.localeCompare(b.code)); return arr; } function groupComplete(group, state) { return window.MATCHES.filter((m) => m.group === group).every((m) => isFinal(m, state)); } // ---- Ranking de los 12 terceros → 8 mejores clasifican function thirdsRanking(state) { const allComplete = Object.keys(window.GROUP_TEAMS).every((g) => groupComplete(g, state)); const thirds = []; Object.keys(window.GROUP_TEAMS).forEach((g) => { const t = groupTable(g, state); const complete = groupComplete(g, state); thirds.push({ ...t[2], group: g, complete }); }); thirds.sort((a, b) => b.pts - a.pts || b.dg - a.dg || b.gf - a.gf || a.group.localeCompare(b.group)); thirds.forEach((t, i) => { t.qualifies = i < 8; t.rank = i + 1; t.provisional = !allComplete; }); return thirds; } function tournamentState(state) { const groups = {}; let allComplete = true; Object.keys(window.GROUP_TEAMS).forEach((g) => { const table = groupTable(g, state); const complete = groupComplete(g, state); if (!complete) allComplete = false; groups[g] = { table, complete, winner: complete ? table[0].code : null, runner: complete ? table[1].code : null, third: table[2].code }; }); const thirds = thirdsRanking(state); return { groups, thirds, allComplete }; } // ---- Plantilla oficial del cuadro desde 16avos. // W/R = 1º/2º de grupo. T = tercer lugar de un conjunto permitido. const R32_TEMPLATE = [ { key: "r32-1", num: 73, home: ["R", "A"], away: ["R", "B"] }, { key: "r32-2", num: 74, home: ["W", "E"], away: ["T", "ABCDF", "1E"] }, { key: "r32-3", num: 75, home: ["W", "F"], away: ["R", "C"] }, { key: "r32-4", num: 76, home: ["W", "C"], away: ["R", "F"] }, { key: "r32-5", num: 77, home: ["W", "I"], away: ["T", "CDFGH", "1I"] }, { key: "r32-6", num: 78, home: ["R", "E"], away: ["R", "I"] }, { key: "r32-7", num: 79, home: ["W", "A"], away: ["T", "CEFHI", "1A"] }, { key: "r32-8", num: 80, home: ["W", "L"], away: ["T", "EHIJK", "1L"] }, { key: "r32-9", num: 81, home: ["W", "D"], away: ["T", "BEFIJ", "1D"] }, { key: "r32-10", num: 82, home: ["W", "G"], away: ["T", "AEHIJ", "1G"] }, { key: "r32-11", num: 83, home: ["R", "K"], away: ["R", "L"] }, { key: "r32-12", num: 84, home: ["W", "H"], away: ["R", "J"] }, { key: "r32-13", num: 85, home: ["W", "B"], away: ["T", "EFGIJ", "1B"] }, { key: "r32-14", num: 86, home: ["W", "J"], away: ["R", "H"] }, { key: "r32-15", num: 87, home: ["W", "K"], away: ["T", "DEIJL", "1K"] }, { key: "r32-16", num: 88, home: ["R", "D"], away: ["R", "G"] }, ]; const THIRD_SLOT_META = { "1A": { key: "r32-7", allowed: "CEFHI" }, "1B": { key: "r32-13", allowed: "EFGIJ" }, "1D": { key: "r32-9", allowed: "BEFIJ" }, "1E": { key: "r32-2", allowed: "ABCDF" }, "1G": { key: "r32-10", allowed: "AEHIJ" }, "1I": { key: "r32-5", allowed: "CDFGH" }, "1K": { key: "r32-15", allowed: "DEIJL" }, "1L": { key: "r32-8", allowed: "EHIJK" }, }; const THIRD_SLOT_ORDER = ["1A", "1B", "1D", "1E", "1G", "1I", "1K", "1L"]; function slotLabel(spec) { if (spec[0] === "W") return "1º " + spec[1]; if (spec[0] === "R") return "2º " + spec[1]; return "3º " + spec[1].split("").join("/"); } function resolveGroupSlot(spec, T) { const g = T.groups[spec[1]]; if (spec[0] === "W") return g.winner; return g.runner; } // Asigna los terceros clasificados a sus slots permitidos de forma automática. // Para producción federativa estricta se puede reemplazar por la tabla completa de Annexe C (495 filas), // pero esta resolución respeta las restricciones de rivales de cada slot y evita revanchas inmediatas. function assignThirds(thirds, allComplete) { if (!allComplete) return {}; const qualified = thirds.filter((t) => t.qualifies).map((t) => t.group).sort(); if (qualified.length !== 8) return {}; const used = new Set(); const outBySlot = {}; function candidates(slot) { return THIRD_SLOT_META[slot].allowed.split("").filter((g) => qualified.includes(g) && !used.has(g)); } function fillForced() { let changed = true; while (changed) { changed = false; for (const slot of THIRD_SLOT_ORDER) { if (outBySlot[slot]) continue; const c = candidates(slot); if (c.length === 1) { outBySlot[slot] = c[0]; used.add(c[0]); changed = true; } } for (const g of qualified) { if (used.has(g)) continue; const possible = THIRD_SLOT_ORDER.filter((slot) => !outBySlot[slot] && THIRD_SLOT_META[slot].allowed.includes(g)); if (possible.length === 1) { outBySlot[possible[0]] = g; used.add(g); changed = true; } } } } function solve() { fillForced(); const open = THIRD_SLOT_ORDER.filter((slot) => !outBySlot[slot]); if (!open.length) return true; open.sort((a, b) => candidates(a).length - candidates(b).length || THIRD_SLOT_ORDER.indexOf(a) - THIRD_SLOT_ORDER.indexOf(b)); const slot = open[0]; for (const g of candidates(slot)) { const snapshot = { out: { ...outBySlot }, used: new Set(used) }; outBySlot[slot] = g; used.add(g); if (solve()) return true; Object.keys(outBySlot).forEach((k) => delete outBySlot[k]); Object.assign(outBySlot, snapshot.out); used.clear(); snapshot.used.forEach((x) => used.add(x)); } return false; } if (!solve()) return {}; const byMatchKey = {}; Object.keys(outBySlot).forEach((slot) => { byMatchKey[THIRD_SLOT_META[slot].key] = outBySlot[slot]; }); return byMatchKey; } function resolveSpec(spec, T, thirdMap, matchKey) { if (spec[0] === "T") { const group = thirdMap[matchKey] || null; const code = group ? (T.groups[group] && T.groups[group].third) : null; return { code, group, label: slotLabel(spec) }; } return { code: resolveGroupSlot(spec, T), group: spec[1], label: slotLabel(spec) }; } // Construye el cuadro completo y lo resuelve con los ganadores marcados (koWinners) function buildBracket(state) { const T = tournamentState(state); const thirdMap = assignThirds(T.thirds, T.allComplete); const ko = state.koWinners || {}; const matches = {}; // key -> {key, round, home:{code,label}, away:{...}, num} R32_TEMPLATE.forEach((m) => { matches[m.key] = { key: m.key, round: "r32", num: m.num, home: resolveSpec(m.home, T, thirdMap, m.key), away: resolveSpec(m.away, T, thirdMap, m.key), }; }); const winnerOf = (key) => { const m = matches[key]; if (!m) return { code: null, label: "Ganador" }; const w = ko[key]; if (w === "home" && m.home.code) return { code: m.home.code, label: m.home.code }; if (w === "away" && m.away.code) return { code: m.away.code, label: m.away.code }; return { code: null, label: "Gana P" + m.num }; }; const add = (key, round, num, fromH, fromA) => { matches[key] = { key, round, num, home: winnerOf(fromH), away: winnerOf(fromA), fromH, fromA }; }; add("r16-1", "r16", 89, "r32-2", "r32-5"); add("r16-2", "r16", 90, "r32-1", "r32-3"); add("r16-3", "r16", 91, "r32-4", "r32-6"); add("r16-4", "r16", 92, "r32-7", "r32-8"); add("r16-5", "r16", 93, "r32-11", "r32-12"); add("r16-6", "r16", 94, "r32-9", "r32-10"); add("r16-7", "r16", 95, "r32-14", "r32-16"); add("r16-8", "r16", 96, "r32-13", "r32-15"); add("qf-1", "qf", 97, "r16-1", "r16-2"); add("qf-2", "qf", 98, "r16-5", "r16-6"); add("qf-3", "qf", 99, "r16-3", "r16-4"); add("qf-4", "qf", 100, "r16-7", "r16-8"); add("sf-1", "sf", 101, "qf-1", "qf-2"); add("sf-2", "sf", 102, "qf-3", "qf-4"); add("third", "third", 103, "sf-1", "sf-2"); add("final", "final", 104, "sf-1", "sf-2"); return { matches, T, thirdMap }; } function nextKoKeys(key) { if (key.startsWith("r32-")) return ["r16-" + Math.ceil(Number(key.split("-")[1]) / 2)]; if (key.startsWith("r16-")) { const n = Number(key.split("-")[1]); if (n <= 2) return ["qf-1"]; if (n <= 4) return ["qf-3"]; if (n <= 6) return ["qf-2"]; return ["qf-4"]; } if (key.startsWith("qf-")) return [Number(key.split("-")[1]) <= 2 ? "sf-1" : "sf-2"]; if (key.startsWith("sf-")) return ["final", "third"]; return []; } function clearKoDependants(ko, key) { const out = { ...(ko || {}) }; const stack = nextKoKeys(key); while (stack.length) { const k = stack.pop(); delete out[k]; nextKoKeys(k).forEach((x) => stack.push(x)); } return out; } // ---- Clasificación de jugadores (sin bonos) const BASE_MATCHES_COUNT = 4; function inferBaseStats(base) { // Los puntos base vienen de los 4 partidos archivados. // Con 5 pts exacto y 3 pts acierto, estos acumulados permiten reconstruir el resumen visible. const exact = base === 11 ? 1 : 0; const result = Math.max(0, Math.round((base - exact * DEFAULT_SCORING.exact) / DEFAULT_SCORING.result)); const miss = Math.max(0, BASE_MATCHES_COUNT - exact - result); return { exact, result, miss, played: BASE_MATCHES_COUNT }; } function computeStandings(state, scoring) { const S = scoring || DEFAULT_SCORING; const preds = state.preds || {}; const hasSavedArchivedPred = (playerId) => window.MATCHES.some((m) => { if (!m.archived) return false; const pred = preds[m.id] && preds[m.id][playerId]; return pred && pred.h != null && pred.a != null; }); const addOutcome = (bucket, pred, res) => { const pts = matchPoints(pred, res, S); if (pts == null) return; bucket.pts += pts; bucket.played++; const oc = matchOutcome(pred, res); if (oc === "exact") bucket.exact++; else if (oc === "result") bucket.result++; else bucket.miss++; }; const rows = window.PLAYERS.map((p) => { const useSavedArchived = hasSavedArchivedPred(p.id); const fallbackBase = inferBaseStats(p.base); const baseBucket = useSavedArchived ? { pts: 0, exact: 0, result: 0, miss: 0, played: 0 } : { pts: p.base || 0, exact: p.baseExact ?? fallbackBase.exact, result: p.baseResult ?? fallbackBase.result, miss: p.baseMiss ?? fallbackBase.miss, played: p.basePlayed ?? fallbackBase.played, }; const liveBucket = { pts: 0, exact: 0, result: 0, miss: 0, played: 0 }; window.MATCHES.forEach((m) => { if (!isFinal(m, state)) return; const pred = preds[m.id] && preds[m.id][p.id]; const res = resultFor(m, state); if (m.archived) { // Si ya cargaste pronósticos de los primeros partidos en Supabase, // recalculamos esos puntos reales y dejamos de depender del acumulado escrito a mano. if (useSavedArchived) addOutcome(baseBucket, pred, res); return; } addOutcome(liveBucket, pred, res); }); const exact = baseBucket.exact + liveBucket.exact; const result = baseBucket.result + liveBucket.result; const miss = baseBucket.miss + liveBucket.miss; const played = baseBucket.played + liveBucket.played; const matchPts = liveBucket.pts; const total = baseBucket.pts + liveBucket.pts; return { ...p, base: baseBucket.pts, originalBase: p.base, matchPts, total, exact, result, miss, played, baseExact: baseBucket.exact, baseResult: baseBucket.result, baseMiss: baseBucket.miss, liveExact: liveBucket.exact, liveResult: liveBucket.result, liveMiss: liveBucket.miss, livePlayed: liveBucket.played, recalculatedBase: useSavedArchived, }; }); rows.sort((a, b) => b.total - a.total || b.exact - a.exact || a.full.localeCompare(b.full)); let lastTotal = null, lastRank = 0; rows.forEach((r, i) => { if (r.total !== lastTotal) { lastRank = i + 1; lastTotal = r.total; } r.rank = lastRank; }); return rows; } Object.assign(window, { DEFAULT_SCORING, matchPoints, matchOutcome, resultFor, isFinal, groupTable, groupComplete, thirdsRanking, tournamentState, buildBracket, clearKoDependants, computeStandings, });