Live standings/bracket updates + possession stats

This commit is contained in:
markmental 2026-02-10 17:40:33 -05:00
commit 39ea6c0c0a
6 changed files with 249 additions and 85 deletions

View file

@ -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.

View file

@ -64,6 +64,14 @@ pub fn simulation_to_csv_bytes(sim: &PreparedSimulation) -> io::Result<Vec<u8>>
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,
&[

View file

@ -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;
}

View file

@ -100,6 +100,7 @@ fn quick_mode(home: Option<String>, away: Option<String>, 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 --");

View file

@ -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<String>,
pub stats_lines: Option<Vec<String>>,
pub competition_lines: Option<Vec<String>>,
pub history_append: Vec<String>,
}
#[derive(Debug, Clone)]
@ -86,8 +91,6 @@ pub enum SimOutcome {
pub struct PreparedSimulation {
pub frames: Vec<SimFrame>,
pub outcome: SimOutcome,
pub stats_lines: Vec<String>,
pub competition_lines: Vec<String>,
pub history_lines: Vec<String>,
}
@ -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<String>) -> 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<String> {
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<String> {
"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<String> {
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<String> {
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<String, StandingsRow> {
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<StandingsRow> = 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<StandingsRow> = 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<StandingsRow> = 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<String> = None;
let mut semi2_line: Option<String> = 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,
}
}

View file

@ -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);