tf_demo_parser/demo/parser/
player_summary_analyzer.rs

1use crate::demo::data::DemoTick;
2use crate::demo::message::packetentities::EntityId;
3use crate::demo::message::packetentities::PacketEntity;
4use crate::demo::message::{Message, MessageType};
5use crate::demo::packet::datatable::ClassId;
6use crate::demo::packet::stringtable::StringTableEntry;
7use crate::demo::parser::analyser::UserInfo;
8use crate::demo::parser::gamestateanalyser::UserId;
9use crate::demo::parser::handler::{BorrowMessageHandler, MessageHandler};
10use crate::demo::sendprop::SendProp;
11use crate::{ParserState, ReadResult, Stream};
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, HashMap};
14
15/**
16 * An analyzer that extracts player scoreboard information to get the stats for every player by the
17 * end of the demo.  Essentially, this will capture all the information that would appear on the
18 * scoreboard for every player if they took a snapshot at the time the demo finishes (such as the end
19 * of a match or round).
20 */
21#[derive(Default, Debug, Serialize, Deserialize, PartialEq)]
22pub struct PlayerSummaryAnalyzer {
23    state: PlayerSummaryState,
24    user_id_map: HashMap<EntityId, UserId>,
25}
26
27#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
28pub struct PlayerSummary {
29    pub points: u32,
30    pub kills: u32,
31    pub assists: u32,
32    pub deaths: u32,
33    pub buildings_destroyed: u32,
34    pub captures: u32,
35    pub defenses: u32,
36    pub dominations: u32,
37    pub revenges: u32,
38    pub ubercharges: u32,
39    pub headshots: u32,
40    pub teleports: u32,
41    pub healing: u32,
42    pub backstabs: u32,
43    pub bonus_points: u32,
44    pub support: u32,
45    pub damage_dealt: u32,
46}
47
48#[derive(Default, Debug, Serialize, Deserialize, PartialEq)]
49pub struct PlayerSummaryState {
50    pub player_summaries: HashMap<UserId, PlayerSummary>,
51    pub users: BTreeMap<UserId, UserInfo>,
52}
53
54impl MessageHandler for PlayerSummaryAnalyzer {
55    type Output = PlayerSummaryState;
56
57    fn does_handle(message_type: MessageType) -> bool {
58        matches!(message_type, MessageType::PacketEntities)
59    }
60
61    fn handle_message(&mut self, message: &Message, _tick: DemoTick, parser_state: &ParserState) {
62        if let Message::PacketEntities(message) = message {
63            for entity in message.entities.iter() {
64                self.handle_packet_entity(entity, parser_state);
65            }
66        }
67    }
68
69    fn into_output(self, _parser_state: &ParserState) -> <Self as MessageHandler>::Output {
70        self.state
71    }
72
73    fn handle_string_entry(
74        &mut self,
75        table: &str,
76        index: usize,
77        entry: &StringTableEntry,
78        _parser_state: &ParserState,
79    ) {
80        if table == "userinfo" {
81            let _ = self.parse_user_info(
82                index,
83                entry.text.as_ref().map(|s| s.as_ref()),
84                entry.extra_data.as_ref().map(|data| data.data.clone()),
85            );
86        }
87    }
88}
89
90impl BorrowMessageHandler for PlayerSummaryAnalyzer {
91    fn borrow_output(&self, _state: &ParserState) -> &Self::Output {
92        &self.state
93    }
94}
95
96/**
97 * Helper function to make processing integer properties easier.
98 *
99 * parse_integer_prop(packet, "DT_TFPlayerScoringDataExclusive", "m_iPoints", |points| { println!("Scored {} points", points) });
100 */
101fn parse_integer_prop<F>(
102    packet: &PacketEntity,
103    table: &str,
104    name: &str,
105    parser_state: &ParserState,
106    handler: F,
107) where
108    F: FnOnce(u32),
109{
110    use crate::demo::sendprop::SendPropValue;
111
112    if let Some(SendProp {
113        value: SendPropValue::Integer(val),
114        ..
115    }) = packet.get_prop_by_name(table, name, parser_state)
116    {
117        handler(val as u32);
118    }
119}
120
121impl PlayerSummaryAnalyzer {
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    fn handle_packet_entity(&mut self, packet: &PacketEntity, parser_state: &ParserState) {
127        use crate::demo::sendprop::SendPropValue;
128
129        // println!("Known server classes: {:?}", parser_state.server_classes);
130
131        if let Some(class) = parser_state
132            .server_classes
133            .get(<ClassId as Into<usize>>::into(packet.server_class))
134        {
135            // println!("Got a {} data packet: {:?}", class.name, packet);
136            match class.name.as_str() {
137                "CTFPlayer" => {
138                    if let Some(user_id) = self.user_id_map.get(&packet.entity_index) {
139                        let summaries = &mut self.state.player_summaries;
140                        let player_summary = summaries.entry(*user_id).or_default();
141
142                        // Extract scoreboard information, if present, and update the player's summary accordingly
143                        // NOTE: Multiple DT_TFPlayerScoringDataExclusive structures may be present - one for the entire match,
144                        //       and one for just the current round.  Since we're only interested in the overall match scores,
145                        //       we need to ignore the round-specific values.  Fortunately, this is easy - just ignore the
146                        //       lesser value (if multiple values are present), since none of these scores are able to decrement.
147
148                        /*
149                         * Member: m_iCaptures (offset 4) (type integer) (bits 10) (Unsigned)
150                         * Member: m_iDefenses (offset 8) (type integer) (bits 10) (Unsigned)
151                         * Member: m_iKills (offset 12) (type integer) (bits 10) (Unsigned)
152                         * Member: m_iDeaths (offset 16) (type integer) (bits 10) (Unsigned)
153                         * Member: m_iSuicides (offset 20) (type integer) (bits 10) (Unsigned)
154                         * Member: m_iDominations (offset 24) (type integer) (bits 10) (Unsigned)
155                         * Member: m_iRevenge (offset 28) (type integer) (bits 10) (Unsigned)
156                         * Member: m_iBuildingsBuilt (offset 32) (type integer) (bits 10) (Unsigned)
157                         * Member: m_iBuildingsDestroyed (offset 36) (type integer) (bits 10) (Unsigned)
158                         * Member: m_iHeadshots (offset 40) (type integer) (bits 10) (Unsigned)
159                         * Member: m_iBackstabs (offset 44) (type integer) (bits 10) (Unsigned)
160                         * Member: m_iHealPoints (offset 48) (type integer) (bits 20) (Unsigned)
161                         * Member: m_iInvulns (offset 52) (type integer) (bits 10) (Unsigned)
162                         * Member: m_iTeleports (offset 56) (type integer) (bits 10) (Unsigned)
163                         * Member: m_iDamageDone (offset 60) (type integer) (bits 20) (Unsigned)
164                         * Member: m_iCrits (offset 64) (type integer) (bits 10) (Unsigned)
165                         * Member: m_iResupplyPoints (offset 68) (type integer) (bits 10) (Unsigned)
166                         * Member: m_iKillAssists (offset 72) (type integer) (bits 12) (Unsigned)
167                         * Member: m_iBonusPoints (offset 76) (type integer) (bits 10) (Unsigned)
168                         * Member: m_iPoints (offset 80) (type integer) (bits 10) (Unsigned)
169                         *
170                         * NOTE: support points aren't included here, but is equal to the sum of m_iHealingAssist and m_iDamageAssist
171                         * TODO: pull data for support points
172                         */
173                        parse_integer_prop(
174                            packet,
175                            "DT_TFPlayerScoringDataExclusive",
176                            "m_iCaptures",
177                            parser_state,
178                            |captures| {
179                                if captures > player_summary.captures {
180                                    player_summary.captures = captures;
181                                }
182                            },
183                        );
184                        parse_integer_prop(
185                            packet,
186                            "DT_TFPlayerScoringDataExclusive",
187                            "m_iDefenses",
188                            parser_state,
189                            |defenses| {
190                                if defenses > player_summary.defenses {
191                                    player_summary.defenses = defenses;
192                                }
193                            },
194                        );
195                        parse_integer_prop(
196                            packet,
197                            "DT_TFPlayerScoringDataExclusive",
198                            "m_iKills",
199                            parser_state,
200                            |kills| {
201                                if kills > player_summary.kills {
202                                    // TODO: This might not be accruate.  Tested with a demo file with 89 kills (88 on the scoreboard),
203                                    // but only a 83 were reported in the scoring data.
204                                    player_summary.kills = kills;
205                                }
206                            },
207                        );
208                        parse_integer_prop(
209                            packet,
210                            "DT_TFPlayerScoringDataExclusive",
211                            "m_iDeaths",
212                            parser_state,
213                            |deaths| {
214                                if deaths > player_summary.deaths {
215                                    player_summary.deaths = deaths;
216                                }
217                            },
218                        );
219                        // ignore m_iSuicides
220                        parse_integer_prop(
221                            packet,
222                            "DT_TFPlayerScoringDataExclusive",
223                            "m_iDominations",
224                            parser_state,
225                            |dominations| {
226                                if dominations > player_summary.dominations {
227                                    player_summary.dominations = dominations;
228                                }
229                            },
230                        );
231                        parse_integer_prop(
232                            packet,
233                            "DT_TFPlayerScoringDataExclusive",
234                            "m_iRevenge",
235                            parser_state,
236                            |revenges| {
237                                if revenges > player_summary.revenges {
238                                    player_summary.revenges = revenges;
239                                }
240                            },
241                        );
242                        // ignore m_iBuildingsBuilt
243                        parse_integer_prop(
244                            packet,
245                            "DT_TFPlayerScoringDataExclusive",
246                            "m_iBuildingsDestroyed",
247                            parser_state,
248                            |buildings_destroyed| {
249                                if buildings_destroyed > player_summary.buildings_destroyed {
250                                    player_summary.buildings_destroyed = buildings_destroyed;
251                                }
252                            },
253                        );
254                        parse_integer_prop(
255                            packet,
256                            "DT_TFPlayerScoringDataExclusive",
257                            "m_iHeadshots",
258                            parser_state,
259                            |headshots| {
260                                if headshots > player_summary.headshots {
261                                    player_summary.headshots = headshots;
262                                }
263                            },
264                        );
265                        parse_integer_prop(
266                            packet,
267                            "DT_TFPlayerScoringDataExclusive",
268                            "m_iBackstabs",
269                            parser_state,
270                            |backstabs| {
271                                if backstabs > player_summary.backstabs {
272                                    player_summary.backstabs = backstabs;
273                                }
274                            },
275                        );
276                        parse_integer_prop(
277                            packet,
278                            "DT_TFPlayerScoringDataExclusive",
279                            "m_iHealPoints",
280                            parser_state,
281                            |healing| {
282                                if healing > player_summary.healing {
283                                    player_summary.healing = healing;
284                                }
285                            },
286                        );
287                        parse_integer_prop(
288                            packet,
289                            "DT_TFPlayerScoringDataExclusive",
290                            "m_iInvulns",
291                            parser_state,
292                            |ubercharges| {
293                                if ubercharges > player_summary.ubercharges {
294                                    player_summary.ubercharges = ubercharges;
295                                }
296                            },
297                        );
298                        parse_integer_prop(
299                            packet,
300                            "DT_TFPlayerScoringDataExclusive",
301                            "m_iTeleports",
302                            parser_state,
303                            |teleports| {
304                                if teleports > player_summary.teleports {
305                                    player_summary.teleports = teleports;
306                                }
307                            },
308                        );
309                        parse_integer_prop(
310                            packet,
311                            "DT_TFPlayerScoringDataExclusive",
312                            "m_iDamageDone",
313                            parser_state,
314                            |damage_dealt| {
315                                if damage_dealt > player_summary.damage_dealt {
316                                    player_summary.damage_dealt = damage_dealt;
317                                }
318                            },
319                        );
320                        // ignore m_iCrits
321                        // ignore m_iResupplyPoints
322                        parse_integer_prop(
323                            packet,
324                            "DT_TFPlayerScoringDataExclusive",
325                            "m_iKillAssists",
326                            parser_state,
327                            |assists| {
328                                if assists > player_summary.assists {
329                                    player_summary.assists = assists;
330                                }
331                            },
332                        );
333                        parse_integer_prop(
334                            packet,
335                            "DT_TFPlayerScoringDataExclusive",
336                            "m_iBonusPoints",
337                            parser_state,
338                            |bonus_points| {
339                                if bonus_points > player_summary.bonus_points {
340                                    player_summary.bonus_points = bonus_points;
341                                }
342                            },
343                        );
344                        parse_integer_prop(
345                            packet,
346                            "DT_TFPlayerScoringDataExclusive",
347                            "m_iPoints",
348                            parser_state,
349                            |points| {
350                                if points > player_summary.points {
351                                    player_summary.points = points;
352                                }
353                            },
354                        );
355                    }
356                }
357                "CTFPlayerResource" => {
358                    // Player summaries - including entity IDs!
359                    // look for props like m_iUserID.<entity_id> = <user_id>
360                    // for example, `m_iUserID.024 = 2523` means entity 24 is user 2523
361                    for i in 0..33 {
362                        // 0 to 32, inclusive (1..33 might also work, not sure if there's a user 0 or not).  Not exhaustive and doesn't work for servers with > 32 players
363                        if let Some(SendProp {
364                            value: SendPropValue::Integer(x),
365                            ..
366                        }) = packet.get_prop_by_name(
367                            "m_iUserID",
368                            format!("{:0>3}", i).as_str(),
369                            parser_state,
370                        ) {
371                            let entity_id = EntityId::from(i as u32);
372                            let user_id = UserId::from(x as u32);
373                            self.user_id_map.insert(entity_id, user_id);
374                        }
375                    }
376                }
377                _other => {
378                    // Don't care
379                }
380            }
381        }
382    }
383
384    fn parse_user_info(
385        &mut self,
386        index: usize,
387        text: Option<&str>,
388        data: Option<Stream>,
389    ) -> ReadResult<()> {
390        if let Some(user_info) =
391            crate::demo::data::UserInfo::parse_from_string_table(index as u16, text, data)?
392        {
393            self.state
394                .users
395                .entry(user_info.player_info.user_id)
396                .and_modify(|info| {
397                    info.entity_id = user_info.entity_id;
398                })
399                .or_insert_with(|| user_info.into());
400        }
401
402        Ok(())
403    }
404}