tiny-soccersim-rust/src/tsrc.rs

390 lines
12 KiB
Rust

use std::collections::HashMap;
use std::fs;
#[derive(Debug)]
pub struct ScriptedGoal {
pub minute: u8,
pub team: String,
pub player: String,
}
#[derive(Debug, PartialEq)]
pub enum OutputMode {
UnicodeTable,
AsciiTable,
Json,
}
#[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 output_mode: OutputMode,
}
pub fn parse_tsrc(path: &str) -> Result<TsrcScript, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("tsrc error: cannot read '{}': {}", path, e))?;
let mut teams = HashMap::new();
let mut roster: HashMap<String, Vec<String>> = HashMap::new();
let mut match_found = false;
let mut home_var = String::new();
let mut away_var = String::new();
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();
let mut output_mode: OutputMode = OutputMode::UnicodeTable;
for (line_num, raw_line) in content.lines().enumerate() {
let n = line_num + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with("//") || line.starts_with("#!") {
continue;
}
if let Some(rest) = line.strip_prefix("#define ") {
if output_mode != OutputMode::UnicodeTable {
return Err(format!(
"tsrc error: output mode already defined on line {} (duplicate #define)",
n
));
}
output_mode = parse_define(rest, n)?;
}
else if let Some(rest) = line.strip_prefix("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)?;
match_found = true;
}
else {
return Err(format!("tsrc error: unrecognized statement on line {}: '{}'", n, line));
}
}
if !match_found {
return Err("tsrc error: no match() directive found".to_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) },
output_mode,
})
}
fn parse_define(s: &str, n: usize) -> Result<OutputMode, String> {
match s.trim() {
"JSON" => Ok(OutputMode::Json),
"UNICODE_TABLE" => Ok(OutputMode::UnicodeTable),
"ASCII_TABLE" => Ok(OutputMode::AsciiTable),
other => Err(format!(
"tsrc error: unknown #define '{}' on line {} (expected JSON, UNICODE_TABLE, or ASCII_TABLE)",
other, n
)),
}
}
fn validate_script(
teams: &HashMap<String, String>,
roster: &HashMap<String, Vec<String>>,
home_var: &str,
away_var: &str,
) -> Result<(), String> {
let home_team = teams.get(home_var).unwrap();
let away_team = teams.get(away_var).unwrap();
if home_team == away_team {
return Err(format!(
"tsrc error: match() has the same team for both home and away (\"{}\")",
home_team
));
}
for (team_name, players) in roster {
if team_name != home_team && team_name != away_team {
return Err(format!(
"tsrc error: {} player(s) assigned to \"{}\" which is not a match team (home=\"{}\", away=\"{}\")",
players.len(), team_name, home_team, away_team
));
}
}
let home_count = roster.get(home_team).map(|v| v.len()).unwrap_or(0);
let away_count = roster.get(away_team).map(|v| v.len()).unwrap_or(0);
if home_count == 0 {
return Err(format!(
"tsrc error: \"{}\" ({}) has no players assigned",
home_team, home_var
));
}
if away_count == 0 {
return Err(format!(
"tsrc error: \"{}\" ({}) has no players assigned",
away_team, away_var
));
}
Ok(())
}
fn parse_string_decl(
s: &str,
n: usize,
teams: &mut HashMap<String, String>,
var_lines: &mut HashMap<String, usize>,
) -> Result<(), String> {
let s = s.trim();
let eq_pos = s.find('=')
.ok_or_else(|| format!("tsrc error: missing '=' on line {}", n))?;
let var_name = s[..eq_pos].trim();
let value_part = s[eq_pos + 1..].trim().trim_end_matches(';');
if !is_valid_ident(var_name) {
return Err(format!("tsrc error: invalid variable name '{}' on line {}", var_name, n));
}
if let Some(&first_line) = var_lines.get(var_name) {
return Err(format!(
"tsrc error: variable '{}' redefined on line {} (first seen on line {})",
var_name, n, first_line
));
}
let value = parse_string_literal(value_part, n)?;
var_lines.insert(var_name.to_string(), n);
teams.insert(var_name.to_string(), value);
Ok(())
}
fn parse_player_insert(
s: &str,
n: usize,
roster: &mut HashMap<String, Vec<String>>,
player_entries: &mut HashMap<(String, String), usize>,
teams: &HashMap<String, String>,
) -> Result<(), String> {
let s = s.trim();
let bracket_end = s.find(']')
.ok_or_else(|| format!("tsrc error: missing ']' on line {}", n))?;
let key_part = &s[1..bracket_end];
let after = &s[bracket_end + 1..];
let player_name = parse_string_literal(key_part.trim(), n)?;
let eq_pos = after.find('=')
.ok_or_else(|| format!("tsrc error: missing '=' on line {}", n))?;
let value_part = after[eq_pos + 1..].trim().trim_end_matches(';');
let team_name = if value_part.starts_with('"') {
parse_string_literal(value_part, n)?
}
else {
let var_name = value_part.trim();
if !is_valid_ident(var_name) {
return Err(format!(
"tsrc error: expected string literal or variable on line {}",
n
));
}
match teams.get(var_name) {
Some(v) => v.clone(),
None => {
return Err(format!(
"tsrc error: undefined variable '{}' on line {} (declare it with `string {} = \"...\"` first)",
var_name, n, var_name
));
}
}
};
let key = (player_name.clone(), team_name.clone());
if let Some(&first_line) = player_entries.get(&key) {
return Err(format!(
"tsrc error: duplicate player \"{}\" on team \"{}\" (first seen on line {})",
player_name, team_name, first_line
));
}
player_entries.insert(key, n);
roster.entry(team_name).or_default().push(player_name);
Ok(())
}
fn parse_match(s: &str, n: usize, teams: &HashMap<String, String>) -> Result<(String, String), String> {
let s = s.trim();
if !s.starts_with('(') || !s.ends_with(')') {
return Err(format!("tsrc error: match() requires parentheses on line {}", n));
}
let inner = &s[1..s.len() - 1];
let comma_pos = inner.find(',')
.ok_or_else(|| format!("tsrc error: match() requires two arguments separated by ',' on line {}", n))?;
let var1 = inner[..comma_pos].trim();
let var2 = inner[comma_pos + 1..].trim();
if !is_valid_ident(var1) {
return Err(format!("tsrc error: invalid variable '{}' in match() on line {}", var1, n));
}
if !is_valid_ident(var2) {
return Err(format!("tsrc error: invalid variable '{}' in match() on line {}", var2, n));
}
if !teams.contains_key(var1) {
return Err(format!("tsrc error: undefined variable '{}' in match() on line {}", var1, n));
}
if !teams.contains_key(var2) {
return Err(format!("tsrc error: undefined variable '{}' in match() on line {}", var2, n));
}
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('"') {
return Err(format!("tsrc error: expected string literal on line {}", n));
}
Ok(s[1..s.len() - 1].to_string())
}
fn is_valid_ident(s: &str) -> bool {
if s.is_empty() { return false; }
s.chars().all(|c| c.is_alphanumeric() || c == '_')
}