Chemistry
@@ -1161,34 +1204,93 @@ async function loadDivision() {
try {
const d = await api('GET', '/division');
const rec = d.record || {};
- const ptsMax = 30; // 10 matches × 3 pts
- const ptsPct = Math.min(100, Math.round((d.season_points / ptsMax) * 100));
- const promoPct = d.pts_for_promotion > 0
- ? Math.min(100, Math.round((d.season_points / d.pts_for_promotion) * 100)) : 100;
+ const promoThresh = d.promotion_pts ?? 21;
+ const relThresh = d.relegation_pts ?? 3;
+ const seasonLen = d.season_length ?? 10;
+ const ptsMax = seasonLen * 3;
+ const pts = d.season_points ?? 0;
+ const divLabel = d.division === 1 ? 'Top Flight' : d.division === 10 ? 'Amateur' : `Division ${d.division}`;
+
+ // Zone bar: total bar spans 0–ptsMax; rel zone 0–relThresh, promo zone promoThresh–ptsMax
+ const relPct = Math.round((relThresh / ptsMax) * 100);
+ const promoPct = Math.round((promoThresh / ptsMax) * 100);
+ const safePct = promoPct - relPct;
+ const markerPct = Math.min(100, Math.round((pts / ptsMax) * 100));
+
+ const zoneBar = `
+
+ Relegation zone ≤${relThresh}pts
+ ${pts} pts
+ Promotion ≥${promoThresh}pts
+
+
+
+
+
+
+
${pts} / ${ptsMax}
+
+
+ ● Rel
+ ● Safe
+ ● Prom
+ ▲ You
+
`;
+
+ const zoneTxt = pts >= promoThresh && d.can_be_relegated !== false
+ ? '
Promotion zone ↑'
+ : pts <= relThresh && d.can_be_relegated
+ ? '
Relegation zone ↓'
+ : '
Safe zone';
el('division-card').innerHTML = `
${d.division}
-
Division · Season ${d.season_number}
+
${divLabel} · Season ${d.season_number}
+
${zoneTxt}
`;
el('season-progress').innerHTML = `
-
Points${d.season_points}
-
For promotion${d.pts_for_promotion} pts needed
-
-
Matches played${d.matches_played} / 10
-
Remaining${d.matches_remaining}
+ ${zoneBar}
+
Matches played${d.matches_played} / ${seasonLen}
+
Remaining${d.matches_remaining} matches
Started${d.started_at ? new Date(d.started_at).toLocaleDateString() : '—'}
+ ${d.promotion_achievable
+ ? `
Promotion still achievable!
`
+ : d.can_be_relegated && pts > relThresh
+ ? `
Promotion out of reach — finish strong.
`
+ : ''}
`;
} catch (e) {
el('division-card').innerHTML = `
${e.message}
`;
el('season-progress').innerHTML = '';
}
+
+ try {
+ const h = await api('GET', '/division/history');
+ const history = h.history || [];
+ if (!history.length) {
+ el('season-history').innerHTML = '
No completed seasons yet.';
+ } else {
+ el('season-history').innerHTML = history.map(s => {
+ const badgeCls = `hist-result-${s.result}`;
+ const letter = s.result === 'promoted' ? '↑' : s.result === 'relegated' ? '↓' : '=';
+ const divChg = s.result !== 'maintained'
+ ? ` → Div ${s.new_division}` : '';
+ return `
+
${letter}
+
+
Season ${s.season_number} · Div ${s.division}${divChg}
+
${s.wins}W ${s.draws}D ${s.losses}L · ${s.season_points}pts · +${s.coins_awarded.toLocaleString()}c
+
+
${new Date(s.ended_at).toLocaleDateString()}
+
`;
+ }).join('');
+ }
+ } catch (_) { el('season-history').innerHTML = '
History unavailable.'; }
}
async function claimRivalsReward() {
@@ -1744,6 +1846,107 @@ async function saveSettings() {
// ── Squad ─────────────────────────────────────────────────────────────────────
+// ── Formation Pitch View ─────────────────────────────────────────────────────
+
+let _squadPitchData = null; // cache last squad for pitch toggle
+
+const POS_GROUP = {
+ GK: 0,
+ SW: 1, CB: 1, RB: 1, LB: 1, RWB: 1, LWB: 1, WB: 1,
+ DM: 2, CDM: 2, CM: 2, RM: 2, LM: 2,
+ AM: 3, CAM: 3, RAM: 3, LAM: 3, CF: 3,
+ RW: 4, LW: 4, RF: 4, LF: 4, ST: 4, SS: 4, FW: 4,
+};
+
+function pitchPosGroup(posLabel) {
+ if (!posLabel) return 2;
+ const up = posLabel.toUpperCase().trim();
+ return POS_GROUP[up] ?? (up.startsWith('G') ? 0 : up.includes('B') || up === 'SW' ? 1 : up.startsWith('S') || up.startsWith('F') ? 4 : 2);
+}
+
+function renderPitch(players, formation, playerChems) {
+ const starters = players.filter(p => !p.is_on_bench);
+ const bench = players.filter(p => p.is_on_bench);
+ const frmParts = (formation || '4-4-2').split('-').map(Number);
+
+ // Build lines: line 0 = GK, then one line per formation group
+ const lines = [[], ...frmParts.map(() => [])];
+ starters.forEach(p => {
+ const g = pitchPosGroup(p.position_label || p.card?.position);
+ // Map role group (0=GK,1=DEF,2=MID,3=CAM,4=ATT) → line index
+ // GK → 0; otherwise distribute across lines 1..frmParts.length
+ if (g === 0) { lines[0].push(p); return; }
+ // For 2-group formations map to nearest line; for 3+ map linearly
+ const mapped = Math.min(Math.round((g / 4) * (frmParts.length - 1)) + 1, frmParts.length);
+ lines[mapped].push(p);
+ });
+
+ const totalLines = lines.length;
+ const pitchMarkings = `
+
`;
+
+ const chips = [];
+ lines.forEach((linePlayers, lineIdx) => {
+ if (!linePlayers.length) return;
+ // y: GK at 90%, top line at 10% from top
+ const yPct = 90 - (lineIdx / (totalLines - 1)) * 80;
+ linePlayers.forEach((p, i) => {
+ const xPct = linePlayers.length === 1 ? 50 : 8 + (i / (linePlayers.length - 1)) * 84;
+ const card = p.card;
+ const ovr = card?.overall ?? '?';
+ const name = card?.name ? card.name.split(' ').pop() : '?';
+ const pos = p.position_label || card?.position || '?';
+ const pChem = (playerChems || {})[p.owned_card_id] ?? 0;
+ const chemDot = pChem >= 7 ? '#3fb950' : pChem >= 4 ? '#d29922' : '#f85149';
+ chips.push(`
+
+
+
${ovr}
+
${name}
+
${pos}
+
+
`);
+ });
+ });
+
+ let benchHtml = '';
+ if (bench.length) {
+ benchHtml = `
+
Bench
+
+ ${bench.map(p => {
+ const card = p.card;
+ return `
+
${card?.overall ?? '?'}
+
${p.position_label || card?.position || '?'}
+
`;
+ }).join('')}
+
+
`;
+ }
+
+ return `
${pitchMarkings}${chips.join('')}
${benchHtml}`;
+}
+
+let _sqDisplayMode = 'grid';
+function setSquadDisplayMode(mode) {
+ _sqDisplayMode = mode;
+ el('sq-grid-btn').className = mode === 'grid' ? 'active' : '';
+ el('sq-pitch-btn').className = mode === 'pitch' ? 'active' : '';
+ el('squad-grid').style.display = mode === 'grid' ? '' : 'none';
+ el('squad-pitch').style.display = mode === 'pitch' ? '' : 'none';
+ if (mode === 'pitch' && _squadPitchData) {
+ const {players, formation, chems} = _squadPitchData;
+ el('squad-pitch').innerHTML = renderPitch(players, formation, chems);
+ }
+}
+
async function loadSquad() {
try {
const data = await api('GET', '/squad');
@@ -1757,8 +1960,15 @@ async function loadSquad() {
${squad?.formation || '—'}
`;
+ // Cache for pitch toggle
+ _squadPitchData = { players, formation: squad?.formation || '4-4-2', chems: chem.players || {} };
+ if (_sqDisplayMode === 'pitch') {
+ el('squad-pitch').innerHTML = renderPitch(players, squad?.formation, chem.players || {});
+ }
+
if (!players.length) {
el('squad-grid').innerHTML = '
';
el('squad-chemistry').innerHTML = '';
return;
}
diff --git a/src/mapper.rs b/src/mapper.rs
index 51298d2..677ada3 100644
--- a/src/mapper.rs
+++ b/src/mapper.rs
@@ -237,6 +237,13 @@ const EXACT: &[ExactRoute] = &[
notes: "FUT notifications (plural form) → Core notifications",
},
// ── Squad list ────────────────────────────────────────────────────────────
+ // ── Division / Season history ─────────────────────────────────────────────
+ ExactRoute {
+ ea_method: "GET", ea_path: "/ut/game/fut/division/history",
+ core_method: "GET", core_path: "/division/history",
+ notes: "FUT division history → Core season history",
+ },
+ // ── Squad list ────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/list",
core_method: "GET", core_path: "/squads",
@@ -697,7 +704,7 @@ mod tests {
#[test]
fn test_total_exact_routes_count() {
- assert!(EXACT.len() >= 55, "expected at least 55 exact mappings, got {}", EXACT.len());
+ assert!(EXACT.len() >= 56, "expected at least 56 exact mappings, got {}", EXACT.len());
}
// ── Phase 22 new mappings ─────────────────────────────────────────────────