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

95
Cargo.lock generated
View file

@ -2,12 +2,34 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "getrandom"
version = "0.2.17"
@ -19,12 +41,35 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -52,6 +97,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
@ -79,7 +130,20 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.17",
]
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
@ -93,11 +157,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "tssr"
version = "0.1.0"
dependencies = [
"rand",
"tempfile",
]
[[package]]
@ -112,6 +190,21 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.58"

View file

@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2021"
description = "Tiny Soccer Simulator Rust - generates random soccer matches on the spot"
[lib]
name = "tssr"
path = "src/lib.rs"
[[bin]]
name = "tssr"
path = "src/main.rs"
@ -11,6 +15,9 @@ path = "src/main.rs"
[dependencies]
rand = "0.8"
[dev-dependencies]
tempfile = "3"
[profile.release]
opt-level = 3
lto = true

123
README.md
View file

@ -1,6 +1,6 @@
# tssr — Tiny Soccer Simulator Rust
A blazing-fast CLI that generates completely random, fabricated soccer matches on the spot. Run it, get a match. That's it.
A blazing-fast CLI that generates completely random soccer matches on the spot — or run scripted matches with the `.tsrc` (Tiny Soccer Sim Rust C) language.
## Build
@ -13,8 +13,28 @@ The binary will be at `target/release/tssr`.
## Usage
```bash
./tssr # Print a unicode table match report
./tssr --json # Print the same match as JSON
./tssr # random match (table output)
./tssr --json # random match (JSON output)
./tssr --script match.tsrc # scripted match (table output)
./tssr --script match.tsrc --json # scripted match (JSON output)
./tssr --help # show usage info
```
### Shebang
`.tsrc` scripts can be run directly with a shebang:
```tsrc
#!/usr/bin/env tssr
string home = "Team A";
string away = "Team B";
// ...
match(home, away);
```
```bash
chmod +x match.tsrc
./match.tsrc
```
## Output
@ -28,15 +48,15 @@ The binary will be at `target/release/tssr`.
║ Man Page City 2 - 1 Libre Pool║
╠════════════════════════════════════════╣
║ ║
║ Shots 10 8 ║
║ On Target 4 3 ║
║ Possession 55% 45% ║
║ Shots 10 8 ║
║ On Target 4 3 ║
║ Possession 55% 45% ║
║ ║
╠════════════════════════════════════════╣
║ Goals: ║
║ Man Page City: Marco Smith (23') ║
║ Man Page City: Leo Brown (67') ║
║ Libre Pool: Dan Garcia (45') ║
║ Man Page City: Marco Smith (23') ║
║ Man Page City: Leo Brown (67') ║
║ Libre Pool: Dan Garcia (45') ║
╚════════════════════════════════════════╝
```
@ -68,9 +88,66 @@ The binary will be at `target/release/tssr`.
}
```
## Team Names
## `.tsrc` Scripting Language
All team names are PES-style parodies referencing free software and open source:
`.tsrc` (Tiny Soccer Sim Rust C) is a minimal C-like DSL for defining teams, players, and match events programmatically.
### Language Reference
```tsrc
// Declare a team variable
string var_name = "Team Name";
// Assign a player to a team (by variable or literal)
players["Player Name"] = var_name;
players["Player Name"] = "Team Name";
// Script a goal: minute (1-90), team (var or literal), player (literal)
goal(45, var_name, "Player Name");
goal(45, "Team Name", "Player Name");
// Select teams for the match (required)
match(var_home, var_away);
```
### Rules
- Variables must be declared with `string` before use
- `match()` directive is required — references variable names
- `players[]` references team names (values), not variable names — but variables are resolved automatically
- `goal()` arguments: `minute, team, player` — team can be variable or literal
- All semicolons are required
- `//` for single-line comments
- Strict validation: any inconsistency produces an error with a helpful message and exits immediately
### Validation Errors
The parser is strict — it will fail with a clear message if:
- A variable is used before being declared
- A variable is defined twice
- A player is added to a team that isn't in `match()`
- A player is added to the same team twice
- A goal references a player not on that team
- A goal references a team not in the match
- A goal minute is outside 1–90
- The same player scores at the same minute twice
- A match team has no players assigned
- `match()` references the same team for both home and away
### Scripted Goals
When `goal()` is used, the match becomes deterministic:
- Score is derived from the count of scripted goals per team
- Goal scorers and minutes are exactly as specified
- Shots and shots-on-target are floored at the goal count
- Possession remains randomized
Without `goal()`, everything is fully randomized.
## Team Names (Random Mode)
When no script is provided, teams are randomly selected from PES-style parodies referencing free software and open source:
- Man Page City, Libre Pool, ArsenAL, Shellsea, TomThumb FC
- Real Madread, BarcelON, BayErR, Door Mount, Inter Mission
@ -86,9 +163,29 @@ All team names are PES-style parodies referencing free software and open source:
- Possession percentage
- Goal scorers with minute of goal
## Example Files
| File | Description |
|------|-------------|
| `scripted_match.tsrc` | GNU United vs BSD Athletic — 5 scripted goals with mixed variable/literal team references |
| `gnu-bsd-soccer.tsrc` | Variable-reference players, random goals |
| `test_match.tsrc` | Intentionally broken — demonstrates error handling |
## Testing
```bash
cargo test
```
13 integration tests cover:
- Goal validation (player on team, minute range, duplicates)
- Script validation (undefined variables, same team, missing match, no players)
- Happy paths (valid script, variable references, full scripted match)
## Performance
- Single dependency (`rand`)
- Single runtime dependency (`rand`)
- `tempfile` is a dev-dependency only (for tests)
- Release profile uses LTO, single codegen unit, panic=abort, and symbol stripping
- Designed for sub-second cold compilation and instant execution
- Binary size: ~340KB stripped

21
scripted_match.tsrc Executable file
View file

@ -0,0 +1,21 @@
#!/home/mrkmntal/tssr/target/release/tssr
// GNU United vs BSD Athletic — fully scripted match
string home = "GNU United";
string away = "BSD Athletic";
players["Richard Stallman"] = home;
players["Brian Fox"] = home;
players["Linus Torvalds"] = home;
players["Theo de Raadt"] = away;
players["Jordan Hubbard"] = away;
players["Ken Thompson"] = away;
goal(12, home, "Richard Stallman");
goal(34, away, "Theo de Raadt");
goal(56, home, "Brian Fox");
goal(78, "BSD Athletic", "Ken Thompson");
goal(89, home, "Linus Torvalds");
match(home, away);

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('"') {

224
tests/tssr.rs Normal file
View file

@ -0,0 +1,224 @@
use std::io::Write;
use tempfile::NamedTempFile;
fn write_tsrc(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
fn parse(content: &str) -> Result<tssr::tsrc::TsrcScript, String> {
let f = write_tsrc(content);
tssr::tsrc::parse_tsrc(f.path().to_str().unwrap())
}
#[test]
fn goal_player_not_on_team() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
players["P2"] = a;
goal(10, h, "Wrong Player");
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("is not on team"), "got: {}", err);
assert!(err.contains("Wrong Player"), "got: {}", err);
}
#[test]
fn goal_duplicate_same_minute_player() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
players["P2"] = a;
goal(10, h, "P1");
goal(10, a, "P1");
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("duplicate goal"), "got: {}", err);
}
#[test]
fn goal_minute_out_of_range() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
players["P2"] = a;
goal(95, h, "P1");
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("between 1 and 90"), "got: {}", err);
}
#[test]
fn goal_undefined_team() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
players["P2"] = a;
goal(10, "Ghost Team", "P1");
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("not a match team"), "got: {}", err);
}
#[test]
fn goal_undefined_variable() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
players["P2"] = a;
goal(10, ghost, "P1");
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("undefined variable"), "got: {}", err);
assert!(err.contains("ghost"), "got: {}", err);
}
#[test]
fn script_valid_full_with_goals() {
let r = parse(r#"
string home = "GNU United";
string away = "BSD Athletic";
players["Richard Stallman"] = home;
players["Brian Fox"] = home;
players["Linus Torvalds"] = home;
players["Theo de Raadt"] = away;
players["Jordan Hubbard"] = away;
players["Ken Thompson"] = away;
goal(12, home, "Richard Stallman");
goal(34, away, "Theo de Raadt");
goal(56, home, "Brian Fox");
goal(78, "BSD Athletic", "Ken Thompson");
goal(89, home, "Linus Torvalds");
match(home, away);
"#);
assert!(r.is_ok(), "got: {:?}", r.err());
let s = r.unwrap();
let goals = s.goals.unwrap();
assert_eq!(goals.len(), 5);
let home_goals: Vec<_> = goals.iter().filter(|g| g.team == "GNU United").collect();
let away_goals: Vec<_> = goals.iter().filter(|g| g.team == "BSD Athletic").collect();
assert_eq!(home_goals.len(), 3);
assert_eq!(away_goals.len(), 2);
assert_eq!(home_goals[0].player, "Richard Stallman");
assert_eq!(home_goals[0].minute, 12);
}
#[test]
fn script_variable_ref_in_players() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
players["P2"] = a;
match(h, a);
"#);
assert!(r.is_ok(), "got: {:?}", r.err());
let s = r.unwrap();
assert!(s.roster.contains_key("Team A"));
assert!(s.roster.contains_key("Team B"));
}
#[test]
fn script_mismatch_roster_team_name() {
let r = parse(r#"
string h = "Linux City";
string a = "Libre Pool";
players["P1"] = "Man Page City";
players["P2"] = a;
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("not a match team"), "got: {}", err);
assert!(err.contains("Man Page City"), "got: {}", err);
}
#[test]
fn script_duplicate_player() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
players["P1"] = h;
players["P2"] = a;
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("duplicate player"), "got: {}", err);
assert!(err.contains("P1"), "got: {}", err);
}
#[test]
fn script_duplicate_variable() {
let r = parse(r#"
string home = "Team A";
string home = "Team B";
players["P1"] = home;
players["P2"] = home;
match(home, home);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("redefined"), "got: {}", err);
assert!(err.contains("home"), "got: {}", err);
}
#[test]
fn script_no_match_directive() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
players["P2"] = a;
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("no match() directive found"), "got: {}", err);
}
#[test]
fn script_same_team_in_match() {
let r = parse(r#"
string h = "Team A";
string a = "Team A";
players["P1"] = h;
players["P2"] = a;
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("same team"), "got: {}", err);
assert!(err.contains("Team A"), "got: {}", err);
}
#[test]
fn script_no_players_for_team() {
let r = parse(r#"
string h = "Team A";
string a = "Team B";
players["P1"] = h;
match(h, a);
"#);
assert!(r.is_err());
let err = r.unwrap_err();
assert!(err.contains("no players assigned"), "got: {}", err);
}