Skip to main content

fforge_core/match_engine/
stream.rs

1//! The match event stream (`MATCH_MODEL.md` §9): the action alphabet **is**
2//! the stream's event-kind alphabet, designed for narratability (the humble
3//! text match view, stats aggregation, the future journalist agent and
4//! graphical viewer) — not merely final outcomes.
5//!
6//! This is a Trace, not a fold input (§7): `play_match` returns it, callers
7//! are free to discard it, and nothing here is ever persisted through the
8//! event-sourced `GameState` fold.
9
10use super::zone::Zone;
11
12/// Which side an event belongs to. Distinct from `fforge_domain::ClubId` —
13/// the stream is home/away-relative; a caller maps it to real clubs via the
14/// `Fixture` it came from.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Side {
17    Home,
18    Away,
19}
20
21/// How the ball arrived at the shot (`MATCH_MODEL.md` §5) — the discriminant
22/// that makes headed vs long-range goals countable for the goal-source-mix
23/// metric. Through-ball, dribbled, and cutback finishes share `Finish`; only
24/// the attacker-attribute selection and chance-quality knob differ between
25/// them internally (§9's stream schema pins exactly these three variants).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum ShotKind {
28    Finish,
29    Header,
30    LongShot,
31}
32
33/// How the possession *reached* the shot (`MATCH_MODEL.md` §5's arrival
34/// table) — finer-grained than `ShotKind`, which collapses through-ball,
35/// dribble, and cutback finishes into `Finish`. This is what makes the
36/// wide-origin-goal-share calibration target (cross + cutback,
37/// `MATCH_MODEL.md` §8) actually computable from the stream, not just
38/// headed-goal share. A rebound follow-up shot keeps the source of the shot
39/// that created it — the rebound is a continuation of the same attack, not
40/// a new arrival route.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum ShotSource {
43    Through,
44    Dribble,
45    Cutback,
46    Cross,
47    Long,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ShotOutcome {
52    Goal,
53    Saved,
54    Off,
55    Blocked,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum MatchEventKind {
60    Pass {
61        success: bool,
62    },
63    TakeOn {
64        success: bool,
65    },
66    /// The delivery half of a cross (§5); a successful delivery is followed
67    /// by a `Shot { kind: Header, .. }` event for the contested header — the
68    /// aerial duel is that shot's defensive side, not a separately resolved
69    /// step (§5's own text: "the aerial duel is the defensive half of stage
70    /// two rather than a separate resolved step").
71    Cross {
72        success: bool,
73    },
74    /// A failed cross delivery, cleared by the defense (§3).
75    Clearance,
76    /// Possession changed hands (any failure other than a cleared cross).
77    Turnover,
78    Shot {
79        kind: ShotKind,
80        source: ShotSource,
81        outcome: ShotOutcome,
82    },
83    /// A save that was parried into a scrappy rebound (a `Shot` follow-up
84    /// event immediately follows in the stream) vs cleanly collected.
85    Save {
86        parried: bool,
87    },
88}
89
90/// One beat in the minute-by-minute stream. `zone` is the zone-entry context
91/// (`MATCH_MODEL.md` §9) so a beat can say *where* on the pitch it happened.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct MatchEvent {
94    pub minute: u8,
95    pub side: Side,
96    pub zone: Zone,
97    pub kind: MatchEventKind,
98}
99
100impl MatchEvent {
101    /// A pure, presentation-agnostic rendering — the humble text match view
102    /// (`DESIGN.md` §9) is just this, printed in order. No I/O here; callers
103    /// (`fforge-game`, the only crate allowed to touch stdout) print it.
104    pub fn commentary(&self, side_name: &str) -> String {
105        let m = self.minute;
106        let z = self.zone.label();
107        match self.kind {
108            MatchEventKind::Pass { success: true } => format!("{m}' {side_name} pick a pass {z}."),
109            MatchEventKind::Pass { success: false } => {
110                format!("{m}' {side_name} pass cut out {z}.")
111            }
112            MatchEventKind::TakeOn { success: true } => {
113                format!("{m}' {side_name} beat their marker {z}.")
114            }
115            MatchEventKind::TakeOn { success: false } => {
116                format!("{m}' {side_name} dispossessed {z}.")
117            }
118            MatchEventKind::Cross { success: true } => format!("{m}' {side_name} whip in a cross."),
119            MatchEventKind::Cross { success: false } => {
120                format!("{m}' {side_name} cross doesn't find a man.")
121            }
122            MatchEventKind::Clearance => format!("{m}' Cleared."),
123            MatchEventKind::Turnover => format!("{m}' Turned over {z}."),
124            MatchEventKind::Shot { kind, outcome, .. } => {
125                let k = match kind {
126                    ShotKind::Finish => "shot",
127                    ShotKind::Header => "header",
128                    ShotKind::LongShot => "effort from distance",
129                };
130                match outcome {
131                    ShotOutcome::Goal => format!("{m}' GOAL! {side_name} score with a {k}!"),
132                    ShotOutcome::Saved => format!("{m}' {side_name} {k} — saved!"),
133                    ShotOutcome::Off => format!("{m}' {side_name} {k} — off target."),
134                    ShotOutcome::Blocked => format!("{m}' {side_name} {k} — blocked."),
135                }
136            }
137            MatchEventKind::Save { parried: true } => {
138                format!("{m}' Parried! Loose ball in the box.")
139            }
140            MatchEventKind::Save { parried: false } => format!("{m}' Keeper collects."),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn commentary_never_panics_across_the_whole_alphabet() {
151        let kinds = [
152            MatchEventKind::Pass { success: true },
153            MatchEventKind::Pass { success: false },
154            MatchEventKind::TakeOn { success: true },
155            MatchEventKind::TakeOn { success: false },
156            MatchEventKind::Cross { success: true },
157            MatchEventKind::Cross { success: false },
158            MatchEventKind::Clearance,
159            MatchEventKind::Turnover,
160            MatchEventKind::Shot {
161                kind: ShotKind::Finish,
162                source: ShotSource::Through,
163                outcome: ShotOutcome::Goal,
164            },
165            MatchEventKind::Shot {
166                kind: ShotKind::Header,
167                source: ShotSource::Cross,
168                outcome: ShotOutcome::Saved,
169            },
170            MatchEventKind::Shot {
171                kind: ShotKind::LongShot,
172                source: ShotSource::Long,
173                outcome: ShotOutcome::Off,
174            },
175            MatchEventKind::Shot {
176                kind: ShotKind::Finish,
177                source: ShotSource::Cutback,
178                outcome: ShotOutcome::Blocked,
179            },
180            MatchEventKind::Save { parried: true },
181            MatchEventKind::Save { parried: false },
182        ];
183        for kind in kinds {
184            let event = MatchEvent {
185                minute: 42,
186                side: Side::Home,
187                zone: Zone::AttW,
188                kind,
189            };
190            assert!(!event.commentary("Home").is_empty());
191        }
192    }
193}