From 6e443ac2337fdd03d7d71d428fe5def5c28627db Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Lopez Date: Tue, 12 May 2026 17:01:37 +0200 Subject: [PATCH 1/5] feat(i18n): add SoloQ ranks and staff impact translations in multiple languages --- src/components/training/TrainingTab.tsx | 13 ++++++------- src/i18n/locales/de.json | 14 ++++++++++++++ src/i18n/locales/en.json | 14 ++++++++++++++ src/i18n/locales/es.json | 14 ++++++++++++++ src/i18n/locales/fr.json | 14 ++++++++++++++ src/i18n/locales/it.json | 14 ++++++++++++++ src/i18n/locales/pt-BR.json | 14 ++++++++++++++ src/i18n/locales/pt.json | 14 ++++++++++++++ src/i18n/locales/tr.json | 14 ++++++++++++++ 9 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/components/training/TrainingTab.tsx b/src/components/training/TrainingTab.tsx index f033a4176..387155453 100644 --- a/src/components/training/TrainingTab.tsx +++ b/src/components/training/TrainingTab.tsx @@ -391,6 +391,8 @@ export default function TrainingTab({ currentIntensity, currentSchedule, ); + const soloQTierLabel = t(`training.soloQTiers.${soloQ.tier}`); + return (
@@ -406,7 +408,7 @@ export default function TrainingTab({ /> role @@ -415,7 +417,7 @@ export default function TrainingTab({ {player.match_name}

- {soloQ.tier} · {soloQ.lp} LP + {soloQTierLabel} · {soloQ.lp} LP = 0 ? "text-emerald-300" : "text-rose-300"}`}> {soloQ.delta >= 0 ? `+${soloQ.delta}` : soloQ.delta} @@ -423,7 +425,7 @@ export default function TrainingTab({

{soloQ.tier} { @@ -450,10 +452,7 @@ export default function TrainingTab({
))}

- {t( - "training.staffImpact.note", - "Staff improves learning, preparation and recovery conservatively; player attributes still drive results.", - )} + {t("training.staffImpact.note")}

diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index e120eb4b0..f78b7a324 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -923,6 +923,13 @@ "currentlyTraining": "Aktuelles Training: {{attrs}} mit {{intensity}} Intensität.", "recoveryNote": "Die Spieler priorisieren an allen Trainingstagen Reset, Ruhe und Erholung.", "effectiveFocus": "Fokus", + "soloQRanks": "SoloQ-Ränge", + "roleIconAlt": "Spielerrolle", + "soloQTiers": { + "Challenger": "Herausforderer", + "Grandmaster": "Großmeister", + "Master": "Meister" + }, "trainingAppliedNote": "Training wird an geplanten Tagen angewendet, wenn du die Zeit vorrückst. Ruhetage bieten volle Erholung.", "criticalCondition": "{{count}} Spieler in kritischem Zustand (<25%)", "exhaustedPlayers": "{{count}} Spieler erschöpft (<40%)", @@ -942,6 +949,13 @@ "warnLight": "Mental Reset / Regeneration würde die Fitness-Erholung maximieren." } }, + "staffImpact": { + "title": "Staff-Einfluss", + "learning": "Lernen", + "scrims": "Scrim-Vorbereitung", + "recovery": "Erholung", + "note": "Der Staff verbessert Lernen, Vorbereitung und Erholung konservativ; Spielerattribute bleiben weiterhin entscheidend." + }, "focuses": { "Scrims": { "label": "Scrims", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6a2591625..390d5e8e1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -960,6 +960,13 @@ "currentlyTraining": "Currently training: {{attrs}} at {{intensity}} intensity.", "recoveryNote": "Players will prioritise reset, rest, and condition recovery on all training days.", "effectiveFocus": "Focus", + "soloQRanks": "SoloQ Ranks", + "roleIconAlt": "Player role", + "soloQTiers": { + "Challenger": "Challenger", + "Grandmaster": "Grandmaster", + "Master": "Master" + }, "trainingAppliedNote": "Training is applied on scheduled training days when you advance time. Rest days provide full recovery.", "criticalCondition": "{{count}} player(s) in critical condition (<25%)", "exhaustedPlayers": "{{count}} player(s) exhausted (<40%)", @@ -979,6 +986,13 @@ "warnLight": "Mental Reset / Recovery would maximise fitness recovery." } }, + "staffImpact": { + "title": "Staff impact", + "learning": "Learning", + "scrims": "Scrim prep", + "recovery": "Recovery", + "note": "Staff improves learning, preparation and recovery conservatively; player attributes still drive results." + }, "focuses": { "Scrims": { "label": "Ranked Grind", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2670eb5af..c9ba23797 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -959,6 +959,13 @@ "currentlyTraining": "Entrenando: {{attrs}} con intensidad {{intensity}}.", "recoveryNote": "Los jugadores priorizarán reset, descanso y recuperación en todos los días de entrenamiento.", "effectiveFocus": "Enfoque", + "soloQRanks": "Rangos de SoloQ", + "roleIconAlt": "Rol del jugador", + "soloQTiers": { + "Challenger": "Retador", + "Grandmaster": "Gran Maestro", + "Master": "Maestro" + }, "trainingAppliedNote": "El entrenamiento se aplica los días programados al avanzar el tiempo. Los días de descanso proporcionan recuperación total.", "criticalCondition": "{{count}} jugador(es) en condición crítica (<25%)", "exhaustedPlayers": "{{count}} jugador(es) agotado(s) (<40%)", @@ -978,6 +985,13 @@ "warnLight": "Reset mental / Recuperación maximizaría la recuperación de la condición." } }, + "staffImpact": { + "title": "Impacto del staff", + "learning": "Aprendizaje", + "scrims": "Preparación de scrims", + "recovery": "Recuperación", + "note": "El staff mejora de forma conservadora el aprendizaje, la preparación y la recuperación; los atributos del jugador siguen marcando la diferencia." + }, "focuses": { "Scrims": { "label": "Grind de ranked", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index eea177418..ad8224884 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -923,6 +923,13 @@ "currentlyTraining": "Entraînement actuel : {{attrs}} à intensité {{intensity}}.", "recoveryNote": "Les joueurs privilégieront reset, repos et récupération à chaque jour d'entraînement.", "effectiveFocus": "Focus", + "soloQRanks": "Rangs SoloQ", + "roleIconAlt": "Rôle du joueur", + "soloQTiers": { + "Challenger": "Challenger", + "Grandmaster": "Grand maître", + "Master": "Maître" + }, "trainingAppliedNote": "L'entraînement est appliqué les jours programmés quand vous avancez le temps. Les jours de repos offrent une récupération totale.", "criticalCondition": "{{count}} joueur(s) en condition critique (<25%)", "exhaustedPlayers": "{{count}} joueur(s) épuisé(s) (<40%)", @@ -942,6 +949,13 @@ "warnLight": "Reset mental / récupération maximiserait la récupération de condition." } }, + "staffImpact": { + "title": "Impact du staff", + "learning": "Apprentissage", + "scrims": "Préparation scrim", + "recovery": "Récupération", + "note": "Le staff améliore prudemment l'apprentissage, la préparation et la récupération ; les attributs des joueurs restent déterminants." + }, "focuses": { "Scrims": { "label": "Scrims", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 291cc5743..8608c2cd5 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -610,6 +610,13 @@ "currentlyTraining": "Attualmente in allenamento: {{attrs}} con intensità {{intensity}}.", "recoveryNote": "I giocatori daranno priorità a reset, riposo e recupero della condizione in ogni giorno di allenamento.", "effectiveFocus": "Focus", + "soloQRanks": "Rank SoloQ", + "roleIconAlt": "Ruolo del giocatore", + "soloQTiers": { + "Challenger": "Challenger", + "Grandmaster": "Grandmaster", + "Master": "Master" + }, "trainingAppliedNote": "L'allenamento viene applicato nei giorni previsti quando avanzi il tempo. I giorni di riposo garantiscono recupero completo.", "criticalCondition": "{{count}} giocatore/i in condizioni critiche (<25%)", "exhaustedPlayers": "{{count}} giocatore/i esausto/i (<40%)", @@ -629,6 +636,13 @@ "warnLight": "Reset mentale / recupero massimizzerebbe il recupero della condizione." } }, + "staffImpact": { + "title": "Impatto dello staff", + "learning": "Apprendimento", + "scrims": "Preparazione scrim", + "recovery": "Recupero", + "note": "Lo staff migliora in modo conservativo apprendimento, preparazione e recupero; gli attributi dei giocatori restano decisivi." + }, "focuses": { "Scrims": { "label": "Scrims", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 91d0dae83..d93829563 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -923,6 +923,13 @@ "currentlyTraining": "Treinando atualmente: {{attrs}} com intensidade {{intensity}}.", "recoveryNote": "Os jogadores vão priorizar reset, descanso e recuperação de condição em todos os dias de treino.", "effectiveFocus": "Foco", + "soloQRanks": "Ranks de SoloQ", + "roleIconAlt": "Função do jogador", + "soloQTiers": { + "Challenger": "Desafiante", + "Grandmaster": "Grão-mestre", + "Master": "Mestre" + }, "trainingAppliedNote": "O treino é aplicado nos dias programados quando você avança o tempo. Dias de descanso proporcionam recuperação total.", "criticalCondition": "{{count}} jogador(es) em condição crítica (<25%)", "exhaustedPlayers": "{{count}} jogador(es) exausto(s) (<40%)", @@ -942,6 +949,13 @@ "warnLight": "Reset mental / recuperação maximizaria a recuperação de condição." } }, + "staffImpact": { + "title": "Impacto da comissão", + "learning": "Aprendizado", + "scrims": "Preparação de scrims", + "recovery": "Recuperação", + "note": "A comissão melhora de forma conservadora o aprendizado, a preparação e a recuperação; os atributos dos jogadores continuam determinando os resultados." + }, "focuses": { "Scrims": { "label": "Scrims", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9a8f1f6ec..cfea87012 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -879,6 +879,13 @@ "currentlyTraining": "A treinar: {{attrs}} com intensidade {{intensity}}.", "recoveryNote": "Os jogadores vão priorizar reset, descanso e recuperação em todos os dias de treino.", "effectiveFocus": "Foco", + "soloQRanks": "Ranks de SoloQ", + "roleIconAlt": "Função do jogador", + "soloQTiers": { + "Challenger": "Desafiante", + "Grandmaster": "Grão-mestre", + "Master": "Mestre" + }, "trainingAppliedNote": "O treino é aplicado nos dias programados quando avanças o tempo. Dias de descanso proporcionam recuperação total.", "criticalCondition": "{{count}} jogador(es) em condição crítica (<25%)", "exhaustedPlayers": "{{count}} jogador(es) exausto(s) (<40%)", @@ -898,6 +905,13 @@ "warnLight": "Reset mental / recuperação maximizaria a recuperação de condição." } }, + "staffImpact": { + "title": "Impacto do staff", + "learning": "Aprendizagem", + "scrims": "Preparação de scrims", + "recovery": "Recuperação", + "note": "O staff melhora de forma conservadora a aprendizagem, a preparação e a recuperação; os atributos dos jogadores continuam a pesar nos resultados." + }, "focuses": { "Scrims": { "label": "Scrims", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index be6da5026..710d0e965 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -906,6 +906,13 @@ "currentlyTraining": "Şu anki antrenman: {{intensity}} yoğunluğunda {{attrs}}.", "recoveryNote": "Oyuncular tüm antrenman günlerinde zihinsel sıfırlanma, dinlenme ve kondisyon toparlamaya öncelik verecektir.", "effectiveFocus": "Odak", + "soloQRanks": "SoloQ Sıralamaları", + "roleIconAlt": "Oyuncu rolü", + "soloQTiers": { + "Challenger": "Şampiyonluk", + "Grandmaster": "Üstat", + "Master": "Usta" + }, "trainingAppliedNote": "Antrenman, zamanı ilerlettiğinizde planlanan antrenman günlerinde uygulanır. Dinlenme günleri tam toparlanma sağlar.", "criticalCondition": "{{count}} oyuncunun durumu kritik (<%25)", "exhaustedPlayers": "{{count}} oyuncu bitkin (<%40)", @@ -925,6 +932,13 @@ "warnLight": "Zihinsel Sıfırlanma / Toparlanma, kondisyon geri kazanımını en üst düzeye çıkaracaktır." } }, + "staffImpact": { + "title": "Personel etkisi", + "learning": "Öğrenme", + "scrims": "Scrim hazırlığı", + "recovery": "Toparlanma", + "note": "Personel öğrenme, hazırlık ve toparlanmayı ölçülü şekilde iyileştirir; sonuçları hâlâ oyuncu özellikleri belirler." + }, "focuses": { "Scrims": { "label": "Antrenman Maçları (Scrims)", From 0ff3a008e39745e1bd905213d656294296e08643 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Lopez Date: Tue, 12 May 2026 17:43:18 +0200 Subject: [PATCH 2/5] feat: implement role resolution for players based on active lineup and natural position --- src-tauri/crates/ofm_core/src/champions.rs | 167 +++++++++++++++++++-- src/components/champions/ChampionsTab.tsx | 47 ++---- src/lib/lolIdentity.test.ts | 93 ++++++++++++ src/lib/lolIdentity.ts | 31 +++- 4 files changed, 292 insertions(+), 46 deletions(-) create mode 100644 src/lib/lolIdentity.test.ts diff --git a/src-tauri/crates/ofm_core/src/champions.rs b/src-tauri/crates/ofm_core/src/champions.rs index a6c19ae98..19eb4e866 100644 --- a/src-tauri/crates/ofm_core/src/champions.rs +++ b/src-tauri/crates/ofm_core/src/champions.rs @@ -161,6 +161,46 @@ fn normalize_key(value: &str) -> String { .replace(|ch: char| !ch.is_ascii_alphanumeric(), "") } +fn role_for_lineup_index(index: usize) -> Option { + match index { + 0 => Some(domain::player::LolRole::Top), + 1 => Some(domain::player::LolRole::Jungle), + 2 => Some(domain::player::LolRole::Mid), + 3 => Some(domain::player::LolRole::Adc), + 4 => Some(domain::player::LolRole::Support), + _ => None, + } +} + +fn role_label_for_position(pos: domain::player::LolRole) -> &'static str { + match pos { + domain::player::LolRole::Top => "Top", + domain::player::LolRole::Jungle => "Jungle", + domain::player::LolRole::Mid => "Mid", + domain::player::LolRole::Adc => "ADC", + domain::player::LolRole::Support => "Support", + domain::player::LolRole::Unknown => "Unknown", + } +} + +fn current_role_for_player( + game: &Game, + team_id: &str, + player_id: &str, + natural_position: domain::player::LolRole, +) -> domain::player::LolRole { + game.teams + .iter() + .find(|team| team.id == team_id) + .and_then(|team| { + team.active_lineup_ids + .iter() + .position(|id| id == player_id) + .and_then(role_for_lineup_index) + }) + .unwrap_or(natural_position) +} + fn normalize_role(value: &str) -> Option { match normalize_key(value).as_str() { "top" => Some("Top".to_string()), @@ -789,17 +829,6 @@ pub fn delegate_champion_training_to_coach(game: &mut Game) -> Result String { - match pos { - domain::player::LolRole::Top => "Top".to_string(), - domain::player::LolRole::Jungle => "Jungle".to_string(), - domain::player::LolRole::Mid => "Mid".to_string(), - domain::player::LolRole::Adc => "ADC".to_string(), - domain::player::LolRole::Support => "Support".to_string(), - domain::player::LolRole::Unknown => "Unknown".to_string(), - } - }; - // Collect all meta entries upfront let meta_entries: Vec = game.champion_patch.hidden_meta.clone(); @@ -832,7 +861,12 @@ pub fn delegate_champion_training_to_coach(game: &mut Game) -> Result = meta_entries .iter() @@ -1396,3 +1430,112 @@ pub fn process_daily_champion_system(game: &mut Game) { process_meta_discovery(game); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::clock::GameClock; + use chrono::Utc; + use domain::manager::Manager; + use domain::player::{LolRole, Player, PlayerAttributes}; + use domain::team::Team; + + fn attrs() -> PlayerAttributes { + PlayerAttributes { + pace: 60, + mental_resilience: 60, + strength: 60, + champion_pool: 60, + passing: 60, + laning: 60, + tackling: 60, + mechanics: 60, + defending: 60, + positioning: 60, + macro_play: 60, + consistency: 60, + discipline: 60, + aggression: 60, + teamfighting: 60, + shotcalling: 60, + handling: 20, + reflexes: 20, + aerial: 20, + } + } + + fn game_with_lineup(lineup: Vec<&str>) -> Game { + let mut manager = Manager::new( + "manager-1".to_string(), + "Jane".to_string(), + "Manager".to_string(), + "1980-01-01".to_string(), + "ES".to_string(), + ); + manager.hire("team-1".to_string()); + + let mut team = Team::new( + "team-1".to_string(), + "Team One".to_string(), + "ONE".to_string(), + "ES".to_string(), + "Madrid".to_string(), + "Arena".to_string(), + 10_000, + ); + team.active_lineup_ids = lineup.into_iter().map(str::to_string).collect(); + + Game::new( + GameClock::new(Utc::now()), + manager, + vec![team], + Vec::new(), + Vec::new(), + Vec::new(), + ) + } + + #[test] + fn current_role_for_player_uses_active_lineup_slot_before_natural_role() { + let mut game = game_with_lineup(vec!["new-top", "jungle", "mid", "adc", "support"]); + let mut player = Player::new( + "new-top".to_string(), + "New Top".to_string(), + "New Top".to_string(), + "2000-01-01".to_string(), + "ES".to_string(), + LolRole::Support, + attrs(), + ); + player.team_id = Some("team-1".to_string()); + game.players.push(player.clone()); + + expect_role(&game, &player, LolRole::Top); + } + + #[test] + fn current_role_for_player_keeps_bench_player_natural_role() { + let mut game = game_with_lineup(vec!["top", "jungle", "mid", "adc", "support"]); + let mut player = Player::new( + "bench-support".to_string(), + "Bench Support".to_string(), + "Bench Support".to_string(), + "2000-01-01".to_string(), + "ES".to_string(), + LolRole::Support, + attrs(), + ); + player.team_id = Some("team-1".to_string()); + game.players.push(player.clone()); + + expect_role(&game, &player, LolRole::Support); + } + + fn expect_role(game: &Game, player: &Player, expected: LolRole) { + let team_id = player.team_id.as_deref().unwrap(); + assert_eq!( + current_role_for_player(game, team_id, &player.id, player.natural_position), + expected, + ); + } +} diff --git a/src/components/champions/ChampionsTab.tsx b/src/components/champions/ChampionsTab.tsx index 51bcf7c26..d14552c4a 100644 --- a/src/components/champions/ChampionsTab.tsx +++ b/src/components/champions/ChampionsTab.tsx @@ -3,13 +3,13 @@ import { useTranslation } from "react-i18next"; import { Sparkles, Clock3, Search } from "lucide-react"; import type { GameStateData } from "../../store/gameStore"; import championsSeed from "../../../data/lec/draft/champions.json"; -import playersSeed from "../../../data/lec/draft/players.json"; import { setPlayerChampionTrainingTarget, delegateChampionTraining } from "../../services/playerService"; import { calculateLolOvr } from "../../lib/lolPlayerStats"; import { formatStaffEffectPercent, getLolStaffEffectsForTeam } from "../../lib/lolStaffEffects"; import { resolvePlayerPhoto } from "../../lib/playerPhotos"; import { ROLE_ICON_PATHS } from "../../lib/roleIcons"; import { t } from "i18next"; +import { resolvePlayerCurrentLolRole } from "../../lib/lolIdentity"; interface ChampionsTabProps { gameState: GameStateData; @@ -56,22 +56,6 @@ const CHAMPIONS_BY_ROLE = Object.entries(CHAMPION_ROLES).reduce( - PLAYER_SEEDS.map((entry) => [normalizeKey(entry.ign), entry]), -); - function normalizeKey(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]/g, ""); } @@ -85,12 +69,11 @@ function normalizeRole(role: string): UiRole { return "Support"; } -function inferLolRole(player: GameStateData["players"][number]): UiRole { - const key = normalizeKey(player.natural_position || player.position || ""); - if (key.includes("defender") && !key.includes("midfielder")) return "Top"; - if (key.includes("midfielder") && !key.includes("attacking")) return "Jungle"; - if (key.includes("attackingmidfielder")) return "Mid"; - if (key.includes("forward") || key.includes("striker")) return "ADC"; +function toUiRole(role: ReturnType): UiRole { + if (role === "TOP") return "Top"; + if (role === "JUNGLE") return "Jungle"; + if (role === "MID") return "Mid"; + if (role === "ADC") return "ADC"; return "Support"; } @@ -272,11 +255,15 @@ export default function ChampionsTab({ gameState, onGameUpdate, onViewChampion } const patch = gameState.champion_patch; const staffEffects = getLolStaffEffectsForTeam(gameState, managerTeamId); + const managerTeam = useMemo( + () => gameState.teams.find((team) => team.id === managerTeamId) ?? null, + [gameState.teams, managerTeamId], + ); + const ownPlayers = useMemo(() => { if (!managerTeamId) return []; const roleOf = (player: GameStateData["players"][number]) => { - const seedEntry = PLAYER_SEED_BY_IGN.get(normalizeKey(player.match_name)); - return normalizeRole(seedEntry?.role ?? inferLolRole(player)); + return toUiRole(resolvePlayerCurrentLolRole(player, managerTeam)); }; return gameState.players @@ -286,12 +273,7 @@ export default function ChampionsTab({ gameState, onGameUpdate, onViewChampion } if (roleDiff !== 0) return roleDiff; return a.match_name.localeCompare(b.match_name); }); - }, [gameState.players, managerTeamId]); - - const managerTeam = useMemo( - () => gameState.teams.find((team) => team.id === managerTeamId) ?? null, - [gameState.teams, managerTeamId], - ); + }, [gameState.players, managerTeam, managerTeamId]); const masteryMap = useMemo(() => { const map = new Map(); @@ -572,8 +554,7 @@ export default function ChampionsTab({ gameState, onGameUpdate, onViewChampion }
{ownPlayers.map((player) => { - const seedEntry = PLAYER_SEED_BY_IGN.get(normalizeKey(player.match_name)); - const role = normalizeRole(seedEntry?.role ?? inferLolRole(player)); + const role = toUiRole(resolvePlayerCurrentLolRole(player, managerTeam)); const roleChampions = CHAMPIONS_BY_ROLE[role] ?? []; const sortedRoleChampions = [...roleChampions].sort((a, b) => { const aKey = normalizeKey(a); diff --git a/src/lib/lolIdentity.test.ts b/src/lib/lolIdentity.test.ts new file mode 100644 index 000000000..1a4a7cb69 --- /dev/null +++ b/src/lib/lolIdentity.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { PlayerData, TeamData } from "../store/gameStore"; +import { resolvePlayerCurrentLolRole } from "./lolIdentity"; + +function createPlayer(overrides: Partial = {}): PlayerData { + return { + id: "player-1", + match_name: "Player One", + full_name: "Player One", + date_of_birth: "2000-01-01", + nationality: "ES", + position: "SUPPORT", + natural_position: "SUPPORT", + alternate_positions: [], + training_focus: null, + attributes: {}, + condition: 90, + morale: 80, + injury: null, + team_id: "team-1", + contract_end: "2027-11-01", + wage: 1000, + market_value: 100000, + stats: { assists: 0 }, + career: [], + transfer_listed: false, + loan_listed: false, + transfer_offers: [], + traits: [], + ...overrides, + }; +} + +function createTeam(overrides: Partial = {}): TeamData { + return { + id: "team-1", + name: "Team One", + short_name: "ONE", + country: "ES", + city: "Madrid", + stadium_name: "Arena", + stadium_capacity: 10000, + finance: 0, + manager_id: "manager-1", + reputation: 50, + wage_budget: 0, + transfer_budget: 0, + season_income: 0, + season_expenses: 0, + formation: "LOL", + play_style: "Balanced", + training_focus: "Scrims", + training_intensity: "Medium", + training_schedule: "Balanced", + founded_year: 2024, + colors: { primary: "#000", secondary: "#fff" }, + active_lineup_ids: [], + form: [], + history: [], + ...overrides, + }; +} + +describe("lolIdentity", () => { + it("uses the active lineup slot as the player's current roster role", () => { + const player = createPlayer({ id: "new-top", natural_position: "SUPPORT", position: "SUPPORT" }); + const team = createTeam({ + active_lineup_ids: ["new-top", "jungler", "mid", "adc", "support"], + }); + + expect(resolvePlayerCurrentLolRole(player, team)).toBe("TOP"); + }); + + it("keeps bench players on their natural role", () => { + const player = createPlayer({ id: "bench-support", natural_position: "SUPPORT", position: "SUPPORT" }); + const team = createTeam({ + active_lineup_ids: ["top", "jungler", "mid", "adc", "support"], + }); + + expect(resolvePlayerCurrentLolRole(player, team)).toBe("SUPPORT"); + }); + + it("supports legacy starting_xi_ids when active_lineup_ids is absent", () => { + const player = createPlayer({ id: "legacy-mid", natural_position: "SUPPORT", position: "SUPPORT" }); + const team = createTeam({ + active_lineup_ids: undefined, + starting_xi_ids: ["top", "jungler", "legacy-mid", "adc", "support"], + }); + + expect(resolvePlayerCurrentLolRole(player, team)).toBe("MID"); + }); +}); diff --git a/src/lib/lolIdentity.ts b/src/lib/lolIdentity.ts index 3b4611850..a60ecebb3 100644 --- a/src/lib/lolIdentity.ts +++ b/src/lib/lolIdentity.ts @@ -1,8 +1,16 @@ import championsSeed from "../../data/lec/draft/champions.json"; -import type { PlayerData } from "../store/gameStore"; +import type { PlayerData, TeamData } from "../store/gameStore"; export type LolRoleTag = "TOP" | "JUNGLE" | "MID" | "ADC" | "SUPPORT"; +const ACTIVE_LINEUP_ROLES: readonly LolRoleTag[] = [ + "TOP", + "JUNGLE", + "MID", + "ADC", + "SUPPORT", +] as const; + /** * Resolve the LoL role for a player directly from their data. * Now that the backend uses LolRole directly, this is straightforward. @@ -29,6 +37,27 @@ export function resolvePlayerLolRole(player: PlayerData): LolRoleTag { return "MID"; } +/** + * Resolve the player's current roster role. + * + * A player assigned to the active lineup adopts the role of that lineup slot. + * Bench players keep their natural player role. This matters for champion-pool + * training after lineup changes: a support moved into TOP must train TOP picks. + */ +export function resolvePlayerCurrentLolRole( + player: PlayerData, + team?: Pick | null, +): LolRoleTag { + const lineupIds = team?.active_lineup_ids ?? team?.starting_xi_ids ?? []; + const lineupIndex = lineupIds.indexOf(player.id); + + if (lineupIndex >= 0) { + return ACTIVE_LINEUP_ROLES[lineupIndex] ?? resolvePlayerLolRole(player); + } + + return resolvePlayerLolRole(player); +} + function normalizeKey(value: string): string { return value.toLowerCase().replace(/[^a-z]/g, ""); } From 1d0847bbfee84cd3f612a3534bae69dc59fc7fff Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Lopez Date: Tue, 12 May 2026 20:44:45 +0200 Subject: [PATCH 3/5] feat: improve the way transfers are handled in the squad --- src-tauri/crates/ofm_core/src/transfers.rs | 99 ++++++++++++++++--- .../crates/ofm_core/tests/transfers_tests.rs | 41 ++++++++ .../dashboard/dashboardHelpers.test.ts | 23 +++++ src/components/dashboard/dashboardHelpers.ts | 20 +--- src/components/squad/SquadTab.helpers.test.ts | 43 ++++++++ src/components/squad/SquadTab.helpers.ts | 26 +++-- 6 files changed, 214 insertions(+), 38 deletions(-) diff --git a/src-tauri/crates/ofm_core/src/transfers.rs b/src-tauri/crates/ofm_core/src/transfers.rs index 57b4e2857..c3b03ec19 100644 --- a/src-tauri/crates/ofm_core/src/transfers.rs +++ b/src-tauri/crates/ofm_core/src/transfers.rs @@ -8,6 +8,7 @@ use domain::stats::LolRole; use domain::team::TeamKind; use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; +use std::collections::HashSet; use std::hash::{Hash, Hasher}; use uuid::Uuid; @@ -1396,10 +1397,10 @@ fn execute_free_agent_signing_with_payer( team.transfer_budget -= fee as i64; } + let players_snapshot = game.players.clone(); if let Some(team) = game.teams.iter_mut().find(|team| team.id == to_team_id) { - if let Some(pos) = team.active_lineup_ids.iter().position(|id| id == player_id) { - team.active_lineup_ids.remove(pos); - } + clear_player_from_active_lineup(team, player_id); + reconcile_lol_active_lineup(team, &players_snapshot); } Ok(()) @@ -1640,7 +1641,7 @@ fn round_transfer_fee(value: u64) -> u64 { } fn remove_player_from_team_references(team: &mut domain::team::Team, player_id: &str) { - team.active_lineup_ids.retain(|id| id != player_id); + clear_player_from_active_lineup(team, player_id); for group in &mut team.training_groups { group.player_ids.retain(|id| id != player_id); @@ -1654,6 +1655,76 @@ fn remove_player_from_team_references(team: &mut domain::team::Team, player_id: } } +fn clear_player_from_active_lineup(team: &mut domain::team::Team, player_id: &str) { + for lineup_id in &mut team.active_lineup_ids { + if lineup_id == player_id { + lineup_id.clear(); + } + } +} + +fn reconcile_lol_active_lineup(team: &mut domain::team::Team, players: &[domain::player::Player]) { + const ROLES: [LolRole; 5] = [ + LolRole::Top, + LolRole::Jungle, + LolRole::Mid, + LolRole::Adc, + LolRole::Support, + ]; + + let saved_ids = team.active_lineup_ids.clone(); + let mut next_ids = vec![String::new(); ROLES.len()]; + let mut used: HashSet = HashSet::new(); + + for (index, role) in ROLES.iter().enumerate() { + if let Some(player) = saved_ids + .get(index) + .and_then(|id| current_team_player_by_id(players, &team.id, id)) + .filter(|player| !used.contains(&player.id) && player.natural_position == *role) + { + next_ids[index] = player.id.clone(); + used.insert(player.id.clone()); + continue; + } + + if let Some(player) = saved_ids + .iter() + .filter_map(|id| current_team_player_by_id(players, &team.id, id)) + .find(|player| !used.contains(&player.id) && player.natural_position == *role) + { + next_ids[index] = player.id.clone(); + used.insert(player.id.clone()); + continue; + } + + if let Some(player) = players + .iter() + .filter(|player| player.team_id.as_deref() == Some(team.id.as_str())) + .filter(|player| !used.contains(&player.id) && player.natural_position == *role) + .max_by_key(|player| player.market_value) + { + next_ids[index] = player.id.clone(); + used.insert(player.id.clone()); + } + } + + team.active_lineup_ids = next_ids; +} + +fn current_team_player_by_id<'a>( + players: &'a [domain::player::Player], + team_id: &str, + player_id: &str, +) -> Option<&'a domain::player::Player> { + if player_id.is_empty() { + return None; + } + + players + .iter() + .find(|player| player.id == player_id && player.team_id.as_deref() == Some(team_id)) +} + fn remaining_contract_salary(player: &domain::player::Player, current_date: NaiveDate) -> i64 { let Some(days_remaining) = contract_days_remaining(current_date, player.contract_end.as_deref()) @@ -2049,11 +2120,10 @@ fn execute_transfer_with_payer( t.transfer_budget -= fee as i64; } + let players_snapshot = game.players.clone(); if let Some(t) = game.teams.iter_mut().find(|t| t.id == to_team_id) { - // Remove from starting XI if player was there - if let Some(pos) = t.active_lineup_ids.iter().position(|id| id == player_id) { - t.active_lineup_ids.remove(pos); - } + clear_player_from_active_lineup(t, player_id); + reconcile_lol_active_lineup(t, &players_snapshot); } let academy_owner_id = game @@ -2069,17 +2139,16 @@ fn execute_transfer_with_payer( t.transfer_budget += (fee as i64 * TRANSFER_BUDGET_SELLING_REALLOCATION_PCT) / 100; } - // Remove sold player from selling team XI if present - if let Some(t) = game.teams.iter_mut().find(|t| t.id == from_team_id) { - if let Some(pos) = t.active_lineup_ids.iter().position(|id| id == player_id) { - t.active_lineup_ids.remove(pos); - } - } - if selling_team_is_academy { ensure_academy_roster_continuity(game, from_team_id, &player_snapshot); } + let players_snapshot = game.players.clone(); + if let Some(t) = game.teams.iter_mut().find(|t| t.id == from_team_id) { + clear_player_from_active_lineup(t, player_id); + reconcile_lol_active_lineup(t, &players_snapshot); + } + if should_generate_major_transfer_news(&player_snapshot, fee) { let article_id = format!( "transfer_news_{}_{}_{}_{}", diff --git a/src-tauri/crates/ofm_core/tests/transfers_tests.rs b/src-tauri/crates/ofm_core/tests/transfers_tests.rs index 6447ae9a1..168da0b0b 100644 --- a/src-tauri/crates/ofm_core/tests/transfers_tests.rs +++ b/src-tauri/crates/ofm_core/tests/transfers_tests.rs @@ -837,6 +837,47 @@ fn selling_key_player_can_reduce_remaining_starters_morale() { assert!(teammate.morale < 75); } +#[test] +fn accepted_sale_reconciles_lol_lineup_slots_without_shifting_roles() { + let mut sold_mid = + make_player_with_position("sold-mid", LolRole::Mid, Some("team-1"), 900_000); + sold_mid + .transfer_offers + .push(make_pending_incoming_offer("offer-sold-mid", 1_100_000)); + + let mut game = make_game_with_player(sold_mid, vec![], 5_000_000, 2_000_000); + game.players.extend([ + make_player_with_position("top", LolRole::Top, Some("team-1"), 700_000), + make_player_with_position("jungle", LolRole::Jungle, Some("team-1"), 700_000), + make_player_with_position("bench-mid", LolRole::Mid, Some("team-1"), 800_000), + make_player_with_position("deft", LolRole::Adc, Some("team-1"), 950_000), + make_player_with_position("trayton", LolRole::Support, Some("team-1"), 650_000), + ]); + game.teams[0].active_lineup_ids = vec![ + "top".to_string(), + "jungle".to_string(), + "sold-mid".to_string(), + "deft".to_string(), + "trayton".to_string(), + ]; + game.teams[1].finance = 6_000_000; + game.teams[1].transfer_budget = 3_000_000; + + respond_to_offer(&mut game, "sold-mid", "offer-sold-mid", true) + .expect("accepting the pending offer should succeed"); + + assert_eq!( + game.teams[0].active_lineup_ids, + vec![ + "top".to_string(), + "jungle".to_string(), + "bench-mid".to_string(), + "deft".to_string(), + "trayton".to_string(), + ] + ); +} + #[test] fn accepted_major_transfer_generates_news_article() { let mut player = make_player("player-news-major"); diff --git a/src/components/dashboard/dashboardHelpers.test.ts b/src/components/dashboard/dashboardHelpers.test.ts index bf525ed29..fe955a98d 100644 --- a/src/components/dashboard/dashboardHelpers.test.ts +++ b/src/components/dashboard/dashboardHelpers.test.ts @@ -370,4 +370,27 @@ describe("dashboardHelpers", function (): void { expect(alertIds).not.toContain("match_lineup"); expect(alertIds).not.toContain("injured_lineup"); }); + + it("does not warn when healthy roster candidates can repair shifted role slots after transfers", function (): void { + const roster = [ + createPlayer({ id: "maynter", position: "TOP", natural_position: "TOP" }), + createPlayer({ id: "rhilech", position: "JUNGLE", natural_position: "JUNGLE" }), + createPlayer({ id: "saken", position: "MID", natural_position: "MID" }), + createPlayer({ id: "deft", position: "ADC", natural_position: "ADC" }), + createPlayer({ id: "trayton", position: "SUPPORT", natural_position: "SUPPORT" }), + ]; + const team = createTeam({ + active_lineup_ids: ["maynter", "rhilech", "deft", "trayton"], + }); + const gameState = createGameState({ + teams: [team], + players: roster, + }); + + const alerts = getDashboardAlerts(gameState, true, translateDashboardAlert); + const alertIds = alerts.map((alert) => alert.id); + + expect(alertIds).not.toContain("incomplete_lineup"); + expect(alertIds).not.toContain("match_lineup"); + }); }); diff --git a/src/components/dashboard/dashboardHelpers.ts b/src/components/dashboard/dashboardHelpers.ts index 59bd81097..843230bc3 100644 --- a/src/components/dashboard/dashboardHelpers.ts +++ b/src/components/dashboard/dashboardHelpers.ts @@ -11,7 +11,6 @@ import { getSponsorshipContractView } from "../../lib/lolFinanceContracts"; import { buildActiveLineupIds, LOL_ACTIVE_ROLES, - type LolRole, } from "../squad/SquadTab.helpers"; export interface DashboardAlert { @@ -144,24 +143,13 @@ export function getDashboardAlerts( return !message.read && message.priority === "Urgent"; }).length; const savedLineupIds = myTeam?.active_lineup_ids ?? myTeam?.starting_xi_ids ?? []; + const availableRoster = roster.filter((player) => !player.injury); const effectiveLineupIds = myTeam - ? buildActiveLineupIds(roster, savedLineupIds) + ? buildActiveLineupIds(availableRoster, savedLineupIds) : []; - const lineupPlayersOnRoster = effectiveLineupIds.filter((playerId) => { - return roster.some((player) => player.id === playerId); - }); - const activeLineupRoleCount = new Set( - lineupPlayersOnRoster - .map((playerId) => roster.find((player) => player.id === playerId)) - .filter((player): player is PlayerData => player !== undefined && !player.injury) - .map((player) => player.natural_position as LolRole) - .filter((role) => LOL_ACTIVE_ROLES.includes(role)), - ).size; + const activeLineupRoleCount = effectiveLineupIds.filter(Boolean).length; const healthyRosterRoleCount = new Set( - roster - .filter((player) => !player.injury) - .map((player) => player.natural_position as LolRole) - .filter((role) => LOL_ACTIVE_ROLES.includes(role)), + buildActiveLineupIds(availableRoster, []).filter(Boolean), ).size; const savedLineupPlayersOnRoster = savedLineupIds.filter((playerId) => { return roster.some((player) => player.id === playerId); diff --git a/src/components/squad/SquadTab.helpers.test.ts b/src/components/squad/SquadTab.helpers.test.ts index 062bd7ff3..32301f2d0 100644 --- a/src/components/squad/SquadTab.helpers.test.ts +++ b/src/components/squad/SquadTab.helpers.test.ts @@ -122,6 +122,49 @@ describe("SquadTab helpers", () => { expect(slots.find((slot) => slot.role === "SUPPORT")?.player).toBeNull(); }); + it("keeps role slots stable when persisted lineup ids were compacted after transfers", () => { + const players = [ + makePlayer("maynter", "TOP"), + makePlayer("rhilech", "JUNGLE"), + makePlayer("saken", "MID"), + makePlayer("deft", "ADC"), + makePlayer("trayton", "SUPPORT"), + ]; + + const ids = buildActiveLineupIds(players, ["maynter", "rhilech", "deft", "trayton"]); + const slots = buildActiveLineupSlots( + LOL_ACTIVE_ROLES, + ids, + new Map(players.map((player) => [player.id, player])), + ); + + expect(ids).toEqual(["maynter", "rhilech", "saken", "deft", "trayton"]); + expect(slots.find((slot) => slot.role === "MID")?.player?.id).toBe("saken"); + expect(slots.find((slot) => slot.role === "ADC")?.player?.id).toBe("deft"); + expect(slots.find((slot) => slot.role === "SUPPORT")?.player?.id).toBe("trayton"); + }); + + it("preserves empty role placeholders so later roles do not shift left", () => { + const players = [ + makePlayer("top", "TOP"), + makePlayer("jng", "JUNGLE"), + makePlayer("adc", "ADC"), + makePlayer("sup", "SUPPORT"), + ]; + + const ids = buildActiveLineupIds(players, ["top", "jng", "missing-mid", "adc", "sup"]); + const slots = buildActiveLineupSlots( + LOL_ACTIVE_ROLES, + ids, + new Map(players.map((player) => [player.id, player])), + ); + + expect(ids).toEqual(["top", "jng", "", "adc", "sup"]); + expect(slots.find((slot) => slot.role === "MID")?.player).toBeNull(); + expect(slots.find((slot) => slot.role === "ADC")?.player?.id).toBe("adc"); + expect(slots.find((slot) => slot.role === "SUPPORT")?.player?.id).toBe("sup"); + }); + it("builds preferred positions using normalised natural and alternate roles", () => { const player = makePlayer("p1", "TOP", { natural_position: "TOP", diff --git a/src/components/squad/SquadTab.helpers.ts b/src/components/squad/SquadTab.helpers.ts index 3783bc899..cbcf5665b 100644 --- a/src/components/squad/SquadTab.helpers.ts +++ b/src/components/squad/SquadTab.helpers.ts @@ -421,9 +421,21 @@ export function buildActiveLineupIds( ): string[] { const byId = new Map(available.map((player) => [player.id, player])); const used = new Set(); - const activeIds: string[] = []; + const activeIds: string[] = Array(LOL_ACTIVE_ROLES.length).fill(""); + + LOL_ACTIVE_ROLES.forEach((role, index) => { + const savedSlotPlayer = byId.get(savedIds[index] ?? ""); + + if ( + savedSlotPlayer && + !used.has(savedSlotPlayer.id) && + getLolRoleForPlayer(savedSlotPlayer) === role + ) { + activeIds[index] = savedSlotPlayer.id; + used.add(savedSlotPlayer.id); + return; + } - for (const role of LOL_ACTIVE_ROLES) { const savedRolePlayer = savedIds .map((id) => byId.get(id)) .find( @@ -432,9 +444,9 @@ export function buildActiveLineupIds( ); if (savedRolePlayer) { - activeIds.push(savedRolePlayer.id); + activeIds[index] = savedRolePlayer.id; used.add(savedRolePlayer.id); - continue; + return; } const roleCandidates = available @@ -443,12 +455,12 @@ export function buildActiveLineupIds( const bestRolePlayer = roleCandidates[0]; if (bestRolePlayer) { - activeIds.push(bestRolePlayer.id); + activeIds[index] = bestRolePlayer.id; used.add(bestRolePlayer.id); } - } + }); - return activeIds.slice(0, LOL_ACTIVE_ROLES.length); + return activeIds; } export function buildActiveLineupSlots( From 517c2b72eb64a6a27acf8d76aa5598f9ada03ee4 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Lopez Date: Tue, 12 May 2026 22:41:41 +0200 Subject: [PATCH 4/5] feat: add profile image URL to PlayerData and update related components --- src-tauri/crates/engine/src/types.rs | 3 + .../crates/engine/tests/live_match_tests.rs | 2 + .../crates/engine/tests/simulation_tests.rs | 1 + .../src/live_match_manager/team_builder.rs | 302 +++++++++++++++--- src-tauri/crates/ofm_core/src/turn/mod.rs | 1 + src/components/match/PreMatchLineup.test.tsx | 23 ++ src/components/match/PreMatchLineup.tsx | 10 +- src/components/match/types.ts | 1 + src/components/training/TrainingTab.test.tsx | 36 +++ src/components/training/TrainingTab.tsx | 18 +- 10 files changed, 346 insertions(+), 51 deletions(-) diff --git a/src-tauri/crates/engine/src/types.rs b/src-tauri/crates/engine/src/types.rs index 3fac786c4..0c3d89c90 100644 --- a/src-tauri/crates/engine/src/types.rs +++ b/src-tauri/crates/engine/src/types.rs @@ -25,6 +25,9 @@ pub enum PlayStyle { pub struct PlayerData { pub id: String, pub name: String, + /// Optional explicit player portrait URL carried from the domain roster. + #[serde(default)] + pub profile_image_url: Option, /// Player's LoL role (Top, Jungle, Mid, Adc, Support) pub role: LolRole, pub condition: u8, // 0-100 diff --git a/src-tauri/crates/engine/tests/live_match_tests.rs b/src-tauri/crates/engine/tests/live_match_tests.rs index 74716e13d..0f61a0da0 100644 --- a/src-tauri/crates/engine/tests/live_match_tests.rs +++ b/src-tauri/crates/engine/tests/live_match_tests.rs @@ -32,6 +32,7 @@ fn make_player(id: &str, name: &str, pos: &str, skill: u8) -> PlayerData { PlayerData { id: id.to_string(), name: name.to_string(), + profile_image_url: None, role: football_position_to_lol_role(pos), condition: 90, fitness: 75, @@ -966,6 +967,7 @@ fn make_player_with_traits( PlayerData { id: id.to_string(), name: name.to_string(), + profile_image_url: None, role: football_position_to_lol_role(pos), condition: 90, fitness: 75, diff --git a/src-tauri/crates/engine/tests/simulation_tests.rs b/src-tauri/crates/engine/tests/simulation_tests.rs index a5162533f..a63e4fba2 100644 --- a/src-tauri/crates/engine/tests/simulation_tests.rs +++ b/src-tauri/crates/engine/tests/simulation_tests.rs @@ -34,6 +34,7 @@ fn make_player(id: &str, name: &str, position: &str, skill: u8) -> PlayerData { PlayerData { id: id.to_string(), name: name.to_string(), + profile_image_url: None, role: football_position_to_lol_role(position), condition: 90, fitness: 75, diff --git a/src-tauri/crates/ofm_core/src/live_match_manager/team_builder.rs b/src-tauri/crates/ofm_core/src/live_match_manager/team_builder.rs index faddc6748..41148fd70 100644 --- a/src-tauri/crates/ofm_core/src/live_match_manager/team_builder.rs +++ b/src-tauri/crates/ofm_core/src/live_match_manager/team_builder.rs @@ -2,6 +2,7 @@ use crate::game::Game; use crate::potential::calculate_lol_ovr; use domain::player::LolRole as DomainLolRole; use engine::{LolRole, PlayStyle, PlayerData, TeamData}; +use std::collections::HashSet; // --------------------------------------------------------------------------- // Domain → Engine conversion (LoL: 5 titulares + banca) @@ -45,48 +46,24 @@ pub(super) fn build_team_with_bench(game: &Game, team_id: &str) -> (TeamData, Ve .iter() .filter(|p| p.team_id.as_deref() == Some(team_id)) .collect(); - let mut ordered_players = available_players; - ordered_players.sort_by(|left, right| { + let starters = select_reconciled_lol_starters(team, available_players.as_slice()); + let starter_ids = starters + .iter() + .map(|player| player.id.as_str()) + .collect::>(); + let mut bench_domain = available_players + .into_iter() + .filter(|player| !starter_ids.contains(player.id.as_str())) + .collect::>(); + bench_domain.sort_by(|left, right| { calculate_lol_ovr(right) .cmp(&calculate_lol_ovr(left)) .then_with(|| right.condition.cmp(&left.condition)) }); - let mut starters = ordered_players; - let bench_domain = if starters.len() > 5 { - starters.split_off(5) - } else { - Vec::new() - }; - - // Ensure unique roles: if the top 5 by OVR don't cover all 5 roles, - // replace duplicates with the best available player of the missing role. - let mut seen_roles = std::collections::HashSet::new(); - let mut uniq = Vec::with_capacity(5); - let mut dup = Vec::new(); - let old_starters = std::mem::take(&mut starters); - for player in old_starters { - if seen_roles.insert(player.natural_position) { - uniq.push(player); - } else { - dup.push(player); - } - } - if uniq.len() < 5 { - for player in bench_domain.iter() { - if seen_roles.insert(player.natural_position) { - uniq.push(player.clone()); - } - if uniq.len() == 5 { - break; - } - } - } - uniq.extend(dup); - starters = uniq.into_iter().take(5).collect(); - // Keep LoL lane order stable for draft/pre-match UIs. - // Selection stays top-5 by OVR+condition; this only reorders those five. + // Selection follows the reconciled role slots; this only normalizes display order. + let mut starters = starters; starters.sort_by(|left, right| { lol_role_rank(&left.natural_position) .cmp(&lol_role_rank(&right.natural_position)) @@ -114,10 +91,112 @@ pub(super) fn build_team_with_bench(game: &Game, team_id: &str) -> (TeamData, Ve (team_data, bench) } +fn select_reconciled_lol_starters<'a>( + team: Option<&domain::team::Team>, + available_players: &[&'a domain::player::Player], +) -> Vec<&'a domain::player::Player> { + const ROLES: [DomainLolRole; 5] = [ + DomainLolRole::Top, + DomainLolRole::Jungle, + DomainLolRole::Mid, + DomainLolRole::Adc, + DomainLolRole::Support, + ]; + + let saved_ids = team + .map(|team| team.active_lineup_ids.as_slice()) + .unwrap_or(&[]); + let mut starters = Vec::with_capacity(ROLES.len()); + let mut used = HashSet::::new(); + + for (index, role) in ROLES.iter().enumerate() { + if let Some(player) = saved_ids + .get(index) + .and_then(|id| current_available_player_by_id(available_players, id)) + .filter(|player| !used.contains(&player.id) && player.natural_position == *role) + { + used.insert(player.id.clone()); + starters.push(player); + continue; + } + + if let Some(player) = saved_ids + .iter() + .filter_map(|id| current_available_player_by_id(available_players, id)) + .find(|player| !used.contains(&player.id) && player.natural_position == *role) + { + used.insert(player.id.clone()); + starters.push(player); + continue; + } + + if let Some(player) = best_available_player_for_role(available_players, *role, &used) { + used.insert(player.id.clone()); + starters.push(player); + } + } + + if starters.len() < ROLES.len() { + let mut fallback_players = available_players + .iter() + .copied() + .filter(|player| !used.contains(&player.id)) + .collect::>(); + fallback_players.sort_by(|left, right| { + calculate_lol_ovr(right) + .cmp(&calculate_lol_ovr(left)) + .then_with(|| right.condition.cmp(&left.condition)) + }); + + for player in fallback_players { + used.insert(player.id.clone()); + starters.push(player); + if starters.len() == ROLES.len() { + break; + } + } + } + + starters +} + +fn current_available_player_by_id<'a>( + available_players: &[&'a domain::player::Player], + player_id: &str, +) -> Option<&'a domain::player::Player> { + if player_id.is_empty() { + return None; + } + + available_players + .iter() + .copied() + .find(|player| player.id == player_id) +} + +fn best_available_player_for_role<'a>( + available_players: &[&'a domain::player::Player], + role: DomainLolRole, + used: &HashSet, +) -> Option<&'a domain::player::Player> { + available_players + .iter() + .copied() + .filter(|player| player.natural_position == role && !used.contains(&player.id)) + .max_by_key(|player| { + ( + calculate_lol_ovr(player), + player.condition, + player.market_value, + ) + }) +} + fn to_engine_player(p: &domain::player::Player) -> PlayerData { PlayerData { id: p.id.clone(), name: p.match_name.clone(), + profile_image_url: p.profile_image_url.clone(), role: to_engine_role(p.natural_position), condition: p.condition, fitness: p.fitness, @@ -180,3 +259,154 @@ pub fn auto_select_team_roles( (captain, shotcaller) } + +#[cfg(test)] +mod tests { + use super::build_team_with_bench; + use crate::clock::GameClock; + use crate::game::Game; + use chrono::{TimeZone, Utc}; + use domain::manager::Manager; + use domain::player::{LolRole, Player, PlayerAttributes}; + use domain::team::Team; + use engine::LolRole as EngineLolRole; + + fn attrs(value: u8) -> PlayerAttributes { + PlayerAttributes { + pace: value, + mental_resilience: value, + strength: value, + champion_pool: value, + passing: value, + laning: value, + tackling: value, + mechanics: value, + defending: value, + positioning: value, + macro_play: value, + consistency: value, + discipline: value, + aggression: value, + teamfighting: value, + shotcalling: value, + handling: value, + reflexes: value, + aerial: value, + } + } + + fn player(id: &str, role: LolRole, rating: u8) -> Player { + let mut player = Player::new( + id.to_string(), + id.to_string(), + id.to_string(), + "2000-01-01".to_string(), + "Spain".to_string(), + role, + attrs(rating), + ); + player.team_id = Some("opponent".to_string()); + player.profile_image_url = Some(format!("/images/players/{id}.webp")); + player + } + + fn game_with_opponent(active_lineup_ids: Vec<&str>, players: Vec) -> Game { + let clock = GameClock::new(Utc.with_ymd_and_hms(2026, 5, 12, 12, 0, 0).unwrap()); + let manager = Manager::new( + "manager".to_string(), + "Alex".to_string(), + "Manager".to_string(), + "1980-01-01".to_string(), + "Spain".to_string(), + ); + let mut opponent = Team::new( + "opponent".to_string(), + "Opponent".to_string(), + "OPP".to_string(), + "Spain".to_string(), + "Madrid".to_string(), + "Arena".to_string(), + 10_000, + ); + opponent.active_lineup_ids = active_lineup_ids.into_iter().map(str::to_string).collect(); + + Game::new(clock, manager, vec![opponent], players, vec![], vec![]) + } + + #[test] + fn pre_match_team_builder_prefers_current_reconciled_active_lineup_over_raw_ovr() { + let game = game_with_opponent( + vec!["top", "jungle", "oscar", "adc", "labrov"], + vec![ + player("top", LolRole::Top, 70), + player("jungle", LolRole::Jungle, 70), + player("oscar", LolRole::Mid, 55), + player("other-mid", LolRole::Mid, 95), + player("adc", LolRole::Adc, 70), + player("labrov", LolRole::Support, 70), + ], + ); + + let (team, bench) = build_team_with_bench(&game, "opponent"); + + assert_eq!( + team.players + .iter() + .map(|player| player.id.as_str()) + .collect::>(), + vec!["top", "jungle", "oscar", "adc", "labrov"] + ); + assert!(bench.iter().any(|player| player.id == "other-mid")); + } + + #[test] + fn pre_match_team_builder_replaces_duplicate_or_stale_lineup_slots_from_current_roster() { + let game = game_with_opponent( + vec!["top", "jungle", "labrov", "sold-adc", "labrov"], + vec![ + player("top", LolRole::Top, 70), + player("jungle", LolRole::Jungle, 70), + player("oscar", LolRole::Mid, 85), + player("adc", LolRole::Adc, 70), + player("labrov", LolRole::Support, 90), + ], + ); + + let (team, bench) = build_team_with_bench(&game, "opponent"); + + assert_eq!( + team.players + .iter() + .map(|player| (&player.id, player.role)) + .collect::>(), + vec![ + (&"top".to_string(), EngineLolRole::Top), + (&"jungle".to_string(), EngineLolRole::Jungle), + (&"oscar".to_string(), EngineLolRole::Mid), + (&"adc".to_string(), EngineLolRole::Adc), + (&"labrov".to_string(), EngineLolRole::Support), + ] + ); + assert_eq!( + team.players + .iter() + .map(|player| (player.id.as_str(), player.profile_image_url.as_deref())) + .collect::>(), + vec![ + ("top", Some("/images/players/top.webp")), + ("jungle", Some("/images/players/jungle.webp")), + ("oscar", Some("/images/players/oscar.webp")), + ("adc", Some("/images/players/adc.webp")), + ("labrov", Some("/images/players/labrov.webp")), + ] + ); + assert_eq!( + team.players + .iter() + .filter(|player| player.id == "labrov") + .count(), + 1 + ); + assert!(!bench.iter().any(|player| player.id == "oscar")); + } +} diff --git a/src-tauri/crates/ofm_core/src/turn/mod.rs b/src-tauri/crates/ofm_core/src/turn/mod.rs index 9d3591f74..678b8f229 100644 --- a/src-tauri/crates/ofm_core/src/turn/mod.rs +++ b/src-tauri/crates/ofm_core/src/turn/mod.rs @@ -178,6 +178,7 @@ fn build_engine_team(game: &Game, team_id: &str) -> engine::TeamData { .map(|p| engine::PlayerData { id: p.id.clone(), name: p.match_name.clone(), + profile_image_url: p.profile_image_url.clone(), role: to_engine_role(p.natural_position), condition: p.condition, fitness: p.fitness, diff --git a/src/components/match/PreMatchLineup.test.tsx b/src/components/match/PreMatchLineup.test.tsx index 60235e33d..4d52d80fc 100644 --- a/src/components/match/PreMatchLineup.test.tsx +++ b/src/components/match/PreMatchLineup.test.tsx @@ -130,6 +130,29 @@ describe("PreMatchLineup component", () => { expect(screen.getByText("Away Bench")).toBeInTheDocument(); }); + it("renders explicit profile images from match player snapshots", () => { + render( + , + ); + + expect(screen.getByRole("img", { name: "Custom Top" })).toHaveAttribute( + "src", + "/data/lec/images/players/custom-top.webp", + ); + }); + it("calls callbacks for auto-select, starter select and swap on user side", () => { const onAutoSelect = vi.fn(); const onSelectStarter = vi.fn(); diff --git a/src/components/match/PreMatchLineup.tsx b/src/components/match/PreMatchLineup.tsx index a1d7d880e..387b879aa 100644 --- a/src/components/match/PreMatchLineup.tsx +++ b/src/components/match/PreMatchLineup.tsx @@ -181,6 +181,7 @@ function TeamLineupColumn({ players.map((p) => { const ovr = getPositionOvr(p); const isSelected = isUserSide && selectedStarterId === p.id; + const photoUrl = resolvePlayerPhoto(p.id, p.name, p.profile_image_url); return (