Add goal scripting, as well as unit testing for the app.
This commit is contained in:
parent
5b41327649
commit
a8539d0030
8 changed files with 634 additions and 27 deletions
1
src/lib.rs
Normal file
1
src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod tsrc;
|
||||
64
src/main.rs
64
src/main.rs
|
|
@ -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 {
|
||||
|
|
|
|||
126
src/tsrc.rs
126
src/tsrc.rs
|
|
@ -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('"') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue