diff --git a/DEVLOG.md b/DEVLOG.md index 9b0e3e2..c370e3a 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -54,3 +54,16 @@ - Updated `quick` command to support optional `--home` / `--away`: - If missing, CPU auto-fills from remaining teams. - Same seed yields the same auto-filled teams and outcomes. + +## 2026-02-10 - Live standings/bracket updates + possession surfacing + +### Scope completed +- Added explicit possession fields to match results and surfaced them in quick mode output. +- Added possession row to single-match CSV export output. +- Implemented live detail updates driven by simulation frames: + - per-frame stats updates + - per-frame competition panel updates + - incremental history updates +- Added live league standings snapshots after each fixture. +- Added ASCII knockout tree bracket snapshots and updates after each semifinal/final. +- Tuned detail view layout to allocate more space to stats/competition/history panels. diff --git a/src/export.rs b/src/export.rs index d8716c0..19bd600 100644 --- a/src/export.rs +++ b/src/export.rs @@ -64,6 +64,14 @@ pub fn simulation_to_csv_bytes(sim: &PreparedSimulation) -> io::Result> format!("{:.2}", m.stats.away.xg), ], )?; + write_row( + &mut out, + &[ + "Possession".to_string(), + format!("{}%", m.home_possession), + format!("{}%", m.away_possession), + ], + )?; write_row( &mut out, &[ diff --git a/src/instance.rs b/src/instance.rs index ed7a37e..daf434b 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -56,9 +56,9 @@ impl SimulationInstance { let mut rng = Rng::new(self.seed); let prepared = run_simulation(self.sim_type, &self.teams, &mut rng); let total_frames = prepared.frames.len(); - self.stats_lines = prepared.stats_lines.clone(); - self.competition_lines = prepared.competition_lines.clone(); - self.history_lines = prepared.history_lines.clone(); + self.stats_lines.clear(); + self.competition_lines.clear(); + self.history_lines.clear(); self.status = SimStatus::Running { frame_index: 0, total_frames, @@ -101,6 +101,15 @@ impl SimulationInstance { for line in frame.logs { self.push_log(line); } + if let Some(stats) = frame.stats_lines { + self.stats_lines = stats; + } + if let Some(comp) = frame.competition_lines { + self.competition_lines = comp; + } + for item in frame.history_append { + self.history_lines.push(item); + } frame_index += 1; } diff --git a/src/main.rs b/src/main.rs index d95fe1c..add4d3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,6 +100,7 @@ fn quick_mode(home: Option, away: Option, base_seed: u64) -> io: if let sim::SimOutcome::Single(m) = prepared.outcome { println!("{} {}-{} {}", m.home, m.home_goals, m.away_goals, m.away); println!("xG {:.2} - {:.2}", m.stats.home.xg, m.stats.away.xg); + println!("Possession {}% - {}%", m.home_possession, m.away_possession); } println!("-- log --"); diff --git a/src/sim.rs b/src/sim.rs index d4bd0da..5093799 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -49,6 +49,8 @@ pub struct MatchResult { pub home_profile: TeamProfile, pub away_profile: TeamProfile, pub stats: MatchStats, + pub home_possession: u8, + pub away_possession: u8, } #[derive(Debug, Clone)] @@ -68,6 +70,9 @@ pub struct StandingsRow { pub struct SimFrame { pub scoreboard: String, pub logs: Vec, + pub stats_lines: Option>, + pub competition_lines: Option>, + pub history_append: Vec, } #[derive(Debug, Clone)] @@ -86,8 +91,6 @@ pub enum SimOutcome { pub struct PreparedSimulation { pub frames: Vec, pub outcome: SimOutcome, - pub stats_lines: Vec, - pub competition_lines: Vec, pub history_lines: Vec, } @@ -95,6 +98,38 @@ fn chance(rng: &mut Rng, p: f64) -> bool { rng.chance(p) } +fn possession_pct(result: &MatchResult) -> (u8, u8) { + let home_poss_base = (result.stats.home.attacks as f64) + * if result.home_profile.tactic == "possession" { + 1.15 + } else { + 1.0 + }; + let away_poss_base = (result.stats.away.attacks as f64) + * if result.away_profile.tactic == "possession" { + 1.15 + } else { + 1.0 + }; + let home_poss = if (home_poss_base + away_poss_base) > 0.0 { + ((home_poss_base / (home_poss_base + away_poss_base)) * 100.0).round() as u8 + } else { + 50 + }; + let away_poss = 100 - home_poss; + (home_poss, away_poss) +} + +fn empty_frame(scoreboard: String, logs: Vec) -> SimFrame { + SimFrame { + scoreboard, + logs, + stats_lines: None, + competition_lines: None, + history_append: Vec::new(), + } +} + pub fn penalties(rng: &mut Rng) -> (u8, u8, bool) { let mut h = 0u8; let mut a = 0u8; @@ -145,8 +180,8 @@ pub fn simulate_match(home: &str, away: &str, rng: &mut Rng) -> (MatchResult, Ve away_tactic.label ); - let mut frames = vec![SimFrame { - scoreboard: format!( + let mut frames = vec![empty_frame( + format!( "{} ({}) {} - {} {} ({}) | {}'", display_name(home), home_profile.formation, @@ -156,8 +191,8 @@ pub fn simulate_match(home: &str, away: &str, rng: &mut Rng) -> (MatchResult, Ve away_profile.formation, pad2(minute) ), - logs: vec![kickoff], - }]; + vec![kickoff], + )]; while minute < 90 { minute += 1; @@ -296,8 +331,8 @@ pub fn simulate_match(home: &str, away: &str, rng: &mut Rng) -> (MatchResult, Ve )); } - frames.push(SimFrame { - scoreboard: format!( + frames.push(empty_frame( + format!( "{} ({}) {} - {} {} ({}) | {}'", display_name(home), home_profile.formation, @@ -308,9 +343,22 @@ pub fn simulate_match(home: &str, away: &str, rng: &mut Rng) -> (MatchResult, Ve pad2(minute) ), logs, - }); + )); } + let preview_result = MatchResult { + home: home.to_string(), + away: away.to_string(), + home_goals, + away_goals, + home_profile, + away_profile, + stats: stats.clone(), + home_possession: 50, + away_possession: 50, + }; + let (home_poss, away_poss) = possession_pct(&preview_result); + ( MatchResult { home: home.to_string(), @@ -320,6 +368,8 @@ pub fn simulate_match(home: &str, away: &str, rng: &mut Rng) -> (MatchResult, Ve home_profile, away_profile, stats, + home_possession: home_poss, + away_possession: away_poss, }, frames, ) @@ -328,24 +378,6 @@ pub fn simulate_match(home: &str, away: &str, rng: &mut Rng) -> (MatchResult, Ve pub fn match_stats_lines(result: &MatchResult) -> Vec { let home_tactic = tactic_by_key(result.home_profile.tactic); let away_tactic = tactic_by_key(result.away_profile.tactic); - let home_poss_base = (result.stats.home.attacks as f64) - * if result.home_profile.tactic == "possession" { - 1.15 - } else { - 1.0 - }; - let away_poss_base = (result.stats.away.attacks as f64) - * if result.away_profile.tactic == "possession" { - 1.15 - } else { - 1.0 - }; - let home_poss = if (home_poss_base + away_poss_base) > 0.0 { - ((home_poss_base / (home_poss_base + away_poss_base)) * 100.0).round() as u8 - } else { - 50 - }; - let away_poss = 100 - home_poss; vec![ format!( @@ -385,7 +417,10 @@ pub fn match_stats_lines(result: &MatchResult) -> Vec { "Saves: {} vs {}", result.stats.home.saves, result.stats.away.saves ), - format!("Possession: {}% vs {}%", home_poss, away_poss), + format!( + "Possession: {}% vs {}%", + result.home_possession, result.away_possession + ), ] } @@ -397,6 +432,36 @@ fn standings_cmp(a: &StandingsRow, b: &StandingsRow) -> Ordering { .then(a.team.cmp(&b.team)) } +fn league_table_lines(rows: &[StandingsRow]) -> Vec { + let mut out = Vec::with_capacity(rows.len() + 2); + out.push("TEAM P W D L GF GA GD PTS".to_string()); + out.push("--------------------------------------------------------".to_string()); + for r in rows { + out.push(format!( + "{:<28} {:>2} {:>2} {:>2} {:>2} {:>3} {:>2} {:>3} {:>3}", + r.team, r.p, r.w, r.d, r.l, r.gf, r.ga, r.gd, r.pts + )); + } + out +} + +fn knockout_bracket_lines( + semi1: Option<&str>, + semi2: Option<&str>, + final_line: Option<&str>, + champion: Option<&str>, +) -> Vec { + vec![ + "Knockout Bracket".to_string(), + format!("Semi 1: {}", semi1.unwrap_or("TBD")), + " \\".to_string(), + format!(" +-- Final: {}", final_line.unwrap_or("TBD")), + " /".to_string(), + format!("Semi 2: {}", semi2.unwrap_or("TBD")), + format!("Champion: {}", champion.unwrap_or("TBD")), + ] +} + fn init_table(teams: &[String]) -> BTreeMap { let mut map = BTreeMap::new(); for team in teams { @@ -423,11 +488,23 @@ pub fn run_single(teams: &[String], rng: &mut Rng) -> PreparedSimulation { let away = teams[1].clone(); let (result, frames) = simulate_match(&home, &away, rng); let stats_lines = match_stats_lines(&result); + let mut frames = frames; + frames.push(SimFrame { + scoreboard: format!( + "{} {}-{} {} | FT", + display_name(&result.home), + result.home_goals, + result.away_goals, + display_name(&result.away) + ), + logs: Vec::new(), + stats_lines: Some(stats_lines.clone()), + competition_lines: None, + history_append: Vec::new(), + }); PreparedSimulation { frames, outcome: SimOutcome::Single(result), - stats_lines, - competition_lines: vec![], history_lines: vec![], } } @@ -447,17 +524,27 @@ pub fn run_league4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { let mut history = Vec::new(); let mut last_stats = Vec::new(); + let mut initial_table: Vec = table.values().cloned().collect(); + initial_table.sort_by(standings_cmp); + frames.push(SimFrame { + scoreboard: "League created - waiting for Matchday 1".to_string(), + logs: vec!["League table initialized".to_string()], + stats_lines: None, + competition_lines: Some(league_table_lines(&initial_table)), + history_append: Vec::new(), + }); + for (idx, (home, away)) in fixtures.iter().enumerate() { - frames.push(SimFrame { - scoreboard: format!("Running League Match {}/{}", idx + 1, fixtures.len()), - logs: vec![format!( + frames.push(empty_frame( + format!("Running League Match {}/{}", idx + 1, fixtures.len()), + vec![format!( "League fixture {}/{}: {} vs {}", idx + 1, fixtures.len(), display_name(home), display_name(away) )], - }); + )); let (res, mut match_frames) = simulate_match(home, away, rng); frames.append(&mut match_frames); @@ -504,6 +591,16 @@ pub fn run_league4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { res.away_goals, display_name(away) )); + + let mut snapshot: Vec = table.values().cloned().collect(); + snapshot.sort_by(standings_cmp); + frames.push(SimFrame { + scoreboard: format!("League table updated after Match {}", idx + 1), + logs: vec!["Standings updated".to_string()], + stats_lines: Some(last_stats.clone()), + competition_lines: Some(league_table_lines(&snapshot)), + history_append: vec![history.last().cloned().unwrap_or_default()], + }); } let mut final_table: Vec = table.into_values().collect(); @@ -515,23 +612,15 @@ pub fn run_league4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { final_table[0].pts )); - let competition = final_table - .iter() - .map(|r| { - format!( - "{} | P:{} W:{} D:{} L:{} GF:{} GA:{} GD:{} Pts:{}", - display_name(&r.team), - r.p, - r.w, - r.d, - r.l, - r.gf, - r.ga, - r.gd, - r.pts - ) - }) - .collect(); + let competition = league_table_lines(&final_table); + + frames.push(SimFrame { + scoreboard: format!("League complete - Champion {}", display_name(&champion)), + logs: vec!["League finished".to_string()], + stats_lines: Some(last_stats.clone()), + competition_lines: Some(competition.clone()), + history_append: vec![history.last().cloned().unwrap_or_default()], + }); PreparedSimulation { frames, @@ -539,8 +628,6 @@ pub fn run_league4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { champion, final_table, }, - stats_lines: last_stats, - competition_lines: competition, history_lines: history, } } @@ -553,24 +640,36 @@ pub fn run_knockout4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { let mut winners = Vec::new(); let mut history = Vec::new(); let mut frames = Vec::new(); + let mut semi1_line: Option = None; + let mut semi2_line: Option = None; + + frames.push(SimFrame { + scoreboard: "Knockout bracket initialized".to_string(), + logs: vec!["Semi-finals ready".to_string()], + stats_lines: None, + competition_lines: Some(knockout_bracket_lines(None, None, None, None)), + history_append: Vec::new(), + }); for (idx, (home, away)) in semis.iter().enumerate() { - frames.push(SimFrame { - scoreboard: format!("Running Semi-final {}/2", idx + 1), - logs: vec![format!( + frames.push(empty_frame( + format!("Running Semi-final {}/2", idx + 1), + vec![format!( "Semi {}: {} vs {}", idx + 1, display_name(home), display_name(away) )], - }); + )); let (res, mut semi_frames) = simulate_match(home, away, rng); frames.append(&mut semi_frames); + let line_text; + let winner = if res.home_goals == res.away_goals { let (ph, pa, home_wins) = penalties(rng); - history.push(format!( + line_text = format!( "Semi {}: {} {}-{} {} (pens {}-{})", idx + 1, display_name(home), @@ -579,52 +678,75 @@ pub fn run_knockout4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { display_name(away), ph, pa - )); + ); + history.push(line_text.clone()); if home_wins { home.clone() } else { away.clone() } } else if res.home_goals > res.away_goals { - history.push(format!( + line_text = format!( "Semi {}: {} {}-{} {}", idx + 1, display_name(home), res.home_goals, res.away_goals, display_name(away) - )); + ); + history.push(line_text.clone()); home.clone() } else { - history.push(format!( + line_text = format!( "Semi {}: {} {}-{} {}", idx + 1, display_name(home), res.home_goals, res.away_goals, display_name(away) - )); + ); + history.push(line_text.clone()); away.clone() }; + + if idx == 0 { + semi1_line = Some(line_text.clone()); + } else { + semi2_line = Some(line_text.clone()); + } + + frames.push(SimFrame { + scoreboard: format!("Semi-final {} complete", idx + 1), + logs: vec!["Bracket updated".to_string()], + stats_lines: Some(match_stats_lines(&res)), + competition_lines: Some(knockout_bracket_lines( + semi1_line.as_deref(), + semi2_line.as_deref(), + None, + None, + )), + history_append: vec![line_text], + }); winners.push(winner); } - frames.push(SimFrame { - scoreboard: "Running Final".to_string(), - logs: vec![format!( + frames.push(empty_frame( + "Running Final".to_string(), + vec![format!( "Final: {} vs {}", display_name(&winners[0]), display_name(&winners[1]) )], - }); + )); let (final_res, mut final_frames) = simulate_match(&winners[0], &winners[1], rng); frames.append(&mut final_frames); let last_stats = match_stats_lines(&final_res); + let final_line; let champion = if final_res.home_goals == final_res.away_goals { let (ph, pa, home_wins) = penalties(rng); - history.push(format!( + final_line = format!( "Final: {} {}-{} {} (pens {}-{})", display_name(&winners[0]), final_res.home_goals, @@ -632,45 +754,56 @@ pub fn run_knockout4(teams: &[String], rng: &mut Rng) -> PreparedSimulation { display_name(&winners[1]), ph, pa - )); + ); + history.push(final_line.clone()); if home_wins { winners[0].clone() } else { winners[1].clone() } } else if final_res.home_goals > final_res.away_goals { - history.push(format!( + final_line = format!( "Final: {} {}-{} {}", display_name(&winners[0]), final_res.home_goals, final_res.away_goals, display_name(&winners[1]) - )); + ); + history.push(final_line.clone()); winners[0].clone() } else { - history.push(format!( + final_line = format!( "Final: {} {}-{} {}", display_name(&winners[0]), final_res.home_goals, final_res.away_goals, display_name(&winners[1]) - )); + ); + history.push(final_line.clone()); winners[1].clone() }; - history.push(format!("Champion: {} 🏆", display_name(&champion))); + let champion_line = format!("Champion: {} 🏆", display_name(&champion)); + history.push(champion_line.clone()); + + frames.push(SimFrame { + scoreboard: format!("Knockout complete - {}", display_name(&champion)), + logs: vec!["Final complete".to_string()], + stats_lines: Some(last_stats.clone()), + competition_lines: Some(knockout_bracket_lines( + semi1_line.as_deref(), + semi2_line.as_deref(), + Some(&final_line), + Some(&champion_line), + )), + history_append: vec![final_line.clone(), champion_line.clone()], + }); PreparedSimulation { frames, outcome: SimOutcome::Knockout { champion: champion.clone(), }, - stats_lines: last_stats, - competition_lines: vec![ - "Bracket: Semi 1 = Team1 vs Team4".to_string(), - "Bracket: Semi 2 = Team2 vs Team3".to_string(), - format!("Champion: {}", display_name(&champion)), - ], history_lines: history, } } diff --git a/src/ui/detail.rs b/src/ui/detail.rs index 318529a..21bd9d1 100644 --- a/src/ui/detail.rs +++ b/src/ui/detail.rs @@ -17,8 +17,8 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(8), - Constraint::Length(8), + Constraint::Min(6), + Constraint::Length(12), ]) .split(area);