Update docs, add tests for SITUATION #define flag

This commit is contained in:
mrkmntal 2026-09-24 23:40:17 -04:00
commit 94a4e6032d
3 changed files with 165 additions and 14 deletions

View file

@ -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<Vec<ScriptedGoal>>, // 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

View file

@ -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
```

View file

@ -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);
}