Add goal scripting, as well as unit testing for the app.

This commit is contained in:
mrkmntal 2026-09-24 21:05:10 -04:00
commit a8539d0030
8 changed files with 634 additions and 27 deletions

1
src/lib.rs Normal file
View file

@ -0,0 +1 @@
pub mod tsrc;

View file

@ -1,11 +1,11 @@
mod tsrc;
use rand::seq::SliceRandom;
use rand::Rng;
use std::env;
use std::path::Path;
use std::process;
use tssr::tsrc;
const TEAM_NAMES: &[&str] = &[
"Man Page City",
"Libre Pool",
@ -112,20 +112,58 @@ fn gen_match(
}
};
let home_score = rng.gen_range(0..=5u8);
let away_score = rng.gen_range(0..=5u8);
let (home_score, away_score, home_scorers, away_scorers, scripted) = match script {
Some(s) if s.goals.is_some() => {
let goals = s.goals.as_ref().unwrap();
let mut home_g: Vec<(String, u8)> = Vec::new();
let mut away_g: Vec<(String, u8)> = Vec::new();
for g in goals {
if g.team == home_name {
home_g.push((g.player.clone(), g.minute));
} else {
away_g.push((g.player.clone(), g.minute));
}
}
let hs = home_g.len() as u8;
let as_ = away_g.len() as u8;
(hs, as_, home_g, away_g, true)
}
_ => {
let hs = rng.gen_range(0..=5u8);
let as_ = rng.gen_range(0..=5u8);
let home_roster = script.and_then(|s| s.roster.get(&home_name)).map(|v| v.as_slice()).unwrap_or(&[]);
let away_roster = script.and_then(|s| s.roster.get(&away_name)).map(|v| v.as_slice()).unwrap_or(&[]);
(
hs,
as_,
pick_scorers(home_roster, hs as usize, rng),
pick_scorers(away_roster, as_ as usize, rng),
false,
)
}
};
let possession = rng.gen_range(38..=62u8);
let home_shots = rng.gen_range(4..=18u8);
let away_shots = rng.gen_range(4..=18u8);
let home_on_target = rng.gen_range(1..=home_shots.min(10));
let away_on_target = rng.gen_range(1..=away_shots.min(10));
let (home_shots, home_on_target) = if scripted {
let shots = home_score.max(rng.gen_range(4..=18u8));
let on_target = home_score.max(rng.gen_range(1..=shots.min(10)));
(shots, on_target)
} else {
let shots = rng.gen_range(4..=18u8);
let on_target = rng.gen_range(1..=shots.min(10));
(shots, on_target)
};
let home_roster = script.and_then(|s| s.roster.get(&home_name)).map(|v| v.as_slice()).unwrap_or(&[]);
let away_roster = script.and_then(|s| s.roster.get(&away_name)).map(|v| v.as_slice()).unwrap_or(&[]);
let home_scorers = pick_scorers(home_roster, home_score as usize, rng);
let away_scorers = pick_scorers(away_roster, away_score as usize, rng);
let (away_shots, away_on_target) = if scripted {
let shots = away_score.max(rng.gen_range(4..=18u8));
let on_target = away_score.max(rng.gen_range(1..=shots.min(10)));
(shots, on_target)
} else {
let shots = rng.gen_range(4..=18u8);
let on_target = rng.gen_range(1..=shots.min(10));
(shots, on_target)
};
Match {
home: Team {

View file

@ -1,11 +1,20 @@
use std::collections::HashMap;
use std::fs;
#[derive(Debug)]
pub struct ScriptedGoal {
pub minute: u8,
pub team: String,
pub player: String,
}
#[derive(Debug)]
pub struct TsrcScript {
pub teams: HashMap<String, String>,
pub roster: HashMap<String, Vec<String>>,
pub home_var: String,
pub away_var: String,
pub goals: Option<Vec<ScriptedGoal>>,
}
pub fn parse_tsrc(path: &str) -> Result<TsrcScript, String> {
@ -20,6 +29,8 @@ pub fn parse_tsrc(path: &str) -> Result<TsrcScript, String> {
let mut var_lines: HashMap<String, usize> = HashMap::new();
let mut player_entries: HashMap<(String, String), usize> = HashMap::new();
let mut goals: Vec<ScriptedGoal> = Vec::new();
let mut goal_entries: HashMap<(u8, String), usize> = HashMap::new();
for (line_num, raw_line) in content.lines().enumerate() {
let n = line_num + 1;
@ -33,6 +44,9 @@ pub fn parse_tsrc(path: &str) -> Result<TsrcScript, String> {
parse_string_decl(rest, n, &mut teams, &mut var_lines)?;
} else if let Some(rest) = line.strip_prefix("players") {
parse_player_insert(rest, n, &mut roster, &mut player_entries, &teams)?;
} else if let Some(rest) = line.strip_prefix("goal") {
let goal = parse_goal(rest, n, &teams, &roster, &mut goal_entries)?;
goals.push(goal);
} else if let Some(rest) = line.strip_prefix("match") {
let rest = rest.trim_end_matches(';');
(home_var, away_var) = parse_match(rest, n, &teams)?;
@ -48,11 +62,18 @@ pub fn parse_tsrc(path: &str) -> Result<TsrcScript, String> {
validate_script(&teams, &roster, &home_var, &away_var)?;
if !goals.is_empty() {
let home_team = teams.get(&home_var).unwrap();
let away_team = teams.get(&away_var).unwrap();
validate_goals(&goals, home_team, away_team, &roster)?;
}
Ok(TsrcScript {
teams,
roster,
home_var,
away_var,
goals: if goals.is_empty() { None } else { Some(goals) },
})
}
@ -213,6 +234,111 @@ fn parse_match(s: &str, n: usize, teams: &HashMap<String, String>) -> Result<(St
Ok((var1.to_string(), var2.to_string()))
}
fn parse_goal(
s: &str,
n: usize,
teams: &HashMap<String, String>,
roster: &HashMap<String, Vec<String>>,
goal_entries: &mut HashMap<(u8, String), usize>,
) -> Result<ScriptedGoal, String> {
let s = s.trim().trim_end_matches(';');
if !s.starts_with('(') || !s.ends_with(')') {
return Err(format!("tsrc error: goal() requires parentheses on line {}", n));
}
let inner = &s[1..s.len() - 1];
let parts: Vec<&str> = inner.splitn(3, ',').collect();
if parts.len() != 3 {
return Err(format!(
"tsrc error: goal() requires 3 arguments (minute, team, player) on line {}",
n
));
}
let minute: u8 = parts[0].trim().parse().map_err(|_| {
format!("tsrc error: goal() minute must be a number on line {}", n)
})?;
if minute < 1 || minute > 90 {
return Err(format!(
"tsrc error: goal() minute must be between 1 and 90 on line {}",
n
));
}
let team_part = parts[1].trim();
let team_name = if team_part.starts_with('"') {
parse_string_literal(team_part, n)?
} else {
if !is_valid_ident(team_part) {
return Err(format!(
"tsrc error: goal() team must be a string literal or variable on line {}",
n
));
}
match teams.get(team_part) {
Some(v) => v.clone(),
None => {
return Err(format!(
"tsrc error: undefined variable '{}' in goal() on line {} (declare it with `string {} = \"...\"` first)",
team_part, n, team_part
));
}
}
};
let player_name = parse_string_literal(parts[2].trim(), n)?;
let key = (minute, player_name.clone());
if let Some(&first_line) = goal_entries.get(&key) {
return Err(format!(
"tsrc error: duplicate goal on line {} — {} already scored at minute {} (line {})",
n, player_name, minute, first_line
));
}
goal_entries.insert(key, n);
if let Some(players) = roster.get(&team_name) {
if !players.contains(&player_name) {
return Err(format!(
"tsrc error: goal() on line {} — player \"{}\" is not on team \"{}\"",
n, player_name, team_name
));
}
}
Ok(ScriptedGoal {
minute,
team: team_name,
player: player_name,
})
}
fn validate_goals(
goals: &[ScriptedGoal],
home_team: &str,
away_team: &str,
roster: &HashMap<String, Vec<String>>,
) -> Result<(), String> {
for goal in goals {
if goal.team != home_team && goal.team != away_team {
return Err(format!(
"tsrc error: goal() for \"{}\" references team \"{}\" which is not a match team (home=\"{}\", away=\"{}\")",
goal.player, goal.team, home_team, away_team
));
}
if let Some(players) = roster.get(&goal.team) {
if !players.contains(&goal.player) {
return Err(format!(
"tsrc error: goal() — player \"{}\" is not on team \"{}\"",
goal.player, goal.team
));
}
}
}
Ok(())
}
fn parse_string_literal(s: &str, n: usize) -> Result<String, String> {
let s = s.trim();
if s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') {