diff --git a/docs/engine/README.md b/docs/engine/README.md index 0e27187..3909fb7 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -1,6 +1,6 @@ # tssr Engine Reference -The tssr engine generates soccer match results. It operates in two modes: **random** (everything fabricated on the spot) and **scripted** (driven by a `.tsr`c file). +The tssr engine generates soccer match results. It operates in three modes: **random** (everything fabricated on the spot), **scripted** (driven by `.tsrc` goals), and **situation** (hybrid — scripted goals guaranteed plus random extras). ## Overview @@ -68,9 +68,14 @@ let (home_name, away_name) = match script { ### 2. Score & Scorers -Two paths depending on whether `goal()` was scripted: +Three paths depending on script configuration: -**Scripted goals present:** +**Situation mode (SITUATION + goal()):** +- Partition scripted goals by team +- Add 0-5 random extra goals per team from roster/name pools +- Score = scripted + random + +**Scripted goals only (goal() without SITUATION):** - Partition goals by team - Score = count of goals per team - Scorers = exact `(player, minute)` from script @@ -111,19 +116,37 @@ struct TsrcScript { away_var: String, // variable name for away goals: Option>, // scripted goals (None = random) output_mode: OutputMode, // UnicodeTable, AsciiTable, Json + situation_mode: bool, // hybrid deterministic/stochastic } ``` ### Override Behavior -| Field | Source when scripted | Source when random | -|-------|---------------------|-------------------| -| Team names | `s.teams[home_var]` | Random from TEAM_NAMES | -| Player roster | `s.roster[team]` | Empty (random names) | -| Goal scorers | Exact from `s.goals` | `pick_scorers()` | -| Score | Count from `s.goals` | `rng.gen_range(0..=5)` | -| Shots | `max(score, random)` | `rng.gen_range(4..=18)` | -| Possession | Always random | Always random | +| Field | Scripted | Situation | Random | +|-------|-----------|------------|--------| +| Team names | `s.teams[home_var]` | `s.teams[home_var]` | Random from TEAM_NAMES | +| Player roster | `s.roster[team]` | `s.roster[team]` | Empty (random names) | +| Goal scorers | Exact from `s.goals` | Scripted + 0-5 random/team | `pick_scorers()` | +| Score | Count from `s.goals` | Scripted + random | `rng.gen_range(0..=5)` | +| Shots | `max(score, random)` | `max(score, random)` | `rng.gen_range(4..=18)` | +| Possession | Always random | Always random | Always random | + +## Situation Mode + +Hybrid deterministic/stochastic mode enabled by `#define SITUATION`. When combined with `goal()`: + +```rust +// Partition scripted goals +let (home_goals, away_goals) = partition_by_team(&s.goals, home_name, away_name); + +// Add 0-5 random extra goals per team +let extra_home = rng.gen_range(0..=5); +let extra_away = rng.gen_range(0..=5); +home_goals.extend(pick_scorers(home_roster, extra_home, rng)); +away_goals.extend(pick_scorers(away_roster, extra_away, rng)); +``` + +Requires at least one `goal()` — error if SITUATION is defined without goals. ## Output Formatting @@ -256,6 +279,7 @@ tssr/ │ └── README.md # Engine reference ├── scripted_match.tsrc # Example: scripted goals ├── defines-test-match.tsrc # Example: ASCII_TABLE mode +├── situation-demo.tsrc # Example: SITUATION hybrid mode ├── gnu-bsd-soccer.tsrc # Example: variable references └── test_match.tsrc # Intentionally broken (for testing errors) ``` @@ -276,5 +300,6 @@ cargo test 19 integration tests cover: - `.tsrc` language validation (13 tests) - `#define` directive handling (6 tests) +- SITUATION mode validation - All error conditions produce correct error messages - Happy paths for valid scripts diff --git a/docs/tsrc-language/README.md b/docs/tsrc-language/README.md index 88492a9..aca1e63 100644 --- a/docs/tsrc-language/README.md +++ b/docs/tsrc-language/README.md @@ -100,18 +100,32 @@ Selects which two teams play. **Required** — every `.tsrc` script must contain Sets the output format for the match. Optional — defaults to `UNICODE_TABLE`. **Rules:** -- Only one `#define` per script +- Only one output mode `#define` per script - Must be one of the three known modes - Unknown names produce an error The `--json` CLI flag always overrides the script's `#define`. +### Situation Mode Directive + +```tsrc +#define SITUATION +``` + +Enables hybrid deterministic/stochastic mode. Scripted goals are guaranteed, but additional random goals (up to 5 per team) are also generated. + +**Rules:** +- Requires at least one `goal()` call — error without one +- Can be combined with any output mode `#define` +- Scripted goals always appear; random goals vary per run +- Random goals are drawn from the team's roster (or name pools if roster is empty) + ## Complete Grammar ``` script = statement* statement = define | string_decl | player_insert | goal_stmt | match_stmt -define = "#define" identifier +define = "#define" (identifier | "SITUATION") string_decl = "string" identifier "=" string_literal ";" player_insert = "players" "[" string_literal "]" "=" (identifier | string_literal) ";" goal_stmt = "goal" "(" number "," (identifier | string_literal) "," string_literal ")" ";" @@ -128,8 +142,18 @@ shebang = "#!" .* "\n" | ASCII table | `ASCII_TABLE` | — | ASCII-only (`+`, `-`, `|`) | | JSON | `JSON` | `--json` | Machine-readable JSON | +## Match Generation Modes + +| Mode | `#define` | Behavior | +|------|-----------|----------| +| Random | (none) | Everything is randomized | +| Scripted | (none, with `goal()`) | Goals, scorers, minutes are exact | +| Situation | `SITUATION` | Scripted goals guaranteed + 0-5 random extra goals per team | + ## Scripted Goals Behavior +### Deterministic Mode (goal() without SITUATION) + When `goal()` is used, the match becomes deterministic: | Field | Behavior with `goal()` | Behavior without | @@ -140,6 +164,18 @@ When `goal()` is used, the match becomes deterministic: | Shots on target | `max(score, random(1..shots))` | Random | | Possession | Still random | Random | +### Situation Mode (goal() with SITUATION) + +Hybrid deterministic/stochastic — scripted goals are guaranteed, plus random extras: + +| Field | Behavior in SITUATION mode | +|-------|---------------------------| +| Score | Scripted goals + 0-5 random extra per team | +| Scorers | Exact scripted goals + random from roster/name pools | +| Shots | `max(total_score, random(4..=18))` | +| Shots on target | `max(total_score, random(1..shots))` | +| Possession | Still random | + ## Variable Resolution Variables are resolved at parse time by looking up their declared value: @@ -178,8 +214,9 @@ The parser is strict. Any inconsistency produces an error and exits immediately. | `player "X" is not on team "Y"` | Goal references unregistered player | | `duplicate goal` | Same player scores same minute twice | | `undefined variable 'x' in goal()` | Goal uses undeclared team variable | -| `duplicate #define` | More than one `#define` in script | +| `duplicate #define` | More than one output mode `#define` | | `unknown #define 'FOO'` | Unrecognized output mode | +| `#define SITUATION requires at least one goal() call` | SITUATION defined without any `goal()` | ## Full Example @@ -228,3 +265,35 @@ match(home, away); +BSD Athletic: Ken Thompson (78') + +------------------------------------+ ``` + +## Situation Mode Example + +```tsrc +#!/usr/bin/env tssr + +// Hybrid mode: some goals guaranteed, others random +#define SITUATION + +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"); // guaranteed +goal(67, away, "Theo de Raadt"); // guaranteed +// Up to 5 additional random goals per team will be added + +match(home, away); +``` + +Each run produces different scores — scripted goals always appear, random goals vary: +``` +Run 1: GNU United 2-3 BSD Athletic +Run 2: GNU United 1-2 BSD Athletic +Run 3: GNU United 2-2 BSD Athletic +``` diff --git a/tests/tssr.rs b/tests/tssr.rs index ac202bf..da1ad0d 100644 --- a/tests/tssr.rs +++ b/tests/tssr.rs @@ -309,3 +309,60 @@ fn define_unknown_error() { assert!(err.contains("unknown #define"), "got: {}", err); assert!(err.contains("FOO_BAR"), "got: {}", err); } + +#[test] +fn situation_valid_with_goals() { + let r = parse(r#" + #define SITUATION + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + players["P3"] = h; + players["P4"] = a; + goal(10, h, "P1"); + goal(45, a, "P2"); + match(h, a); + "#); + assert!(r.is_ok(), "got: {:?}", r.err()); + let s = r.unwrap(); + assert!(s.situation_mode); + assert!(s.goals.is_some()); + assert_eq!(s.goals.unwrap().len(), 2); +} + +#[test] +fn situation_requires_goals_error() { + let r = parse(r#" + #define SITUATION + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + match(h, a); + "#); + assert!(r.is_err()); + let err = r.unwrap_err(); + assert!(err.contains("SITUATION"), "got: {}", err); + assert!(err.contains("goal()"), "got: {}", err); +} + +#[test] +fn situation_combinable_with_output_mode() { + let r = parse(r#" + #define SITUATION + #define ASCII_TABLE + string h = "Team A"; + string a = "Team B"; + players["P1"] = h; + players["P2"] = a; + players["P3"] = h; + players["P4"] = a; + goal(10, h, "P1"); + match(h, a); + "#); + assert!(r.is_ok(), "got: {:?}", r.err()); + let s = r.unwrap(); + assert!(s.situation_mode); + assert_eq!(s.output_mode, tssr::tsrc::OutputMode::AsciiTable); +}