hail_core 0.3.0

a library for implementing a speedrun timer
Documentation
/*
  Copyright 2024 periwinkle

  This Source Code Form is subject to the terms of the Mozilla Public
  License, v. 2.0. If a copy of the MPL was not distributed with this
  file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

use super::Run;
use crate::{Result, error::DeError, types::TimeType};

use std::{cmp::min, io::Read};

use roxmltree::{Document, Node};

impl Run {
    /// Try to deserialize a run from the LiveSplit split format.
    ///
    /// May not be completely accurate, works on a best-effort basis but usually gets the point across.
    pub fn from_reader_lss(mut r: impl Read) -> Result<Self> {
        let mut s = String::new();
        r.read_to_string(&mut s)?;
        Self::from_str_lss(&s)
    }

    fn from_str_lss(r: &str) -> Result<Self> {
        let doc = Document::parse(r).map_err(DeError::Xml)?;
        let ingame_time = r.contains("GameTime");
        let mut r = Run {
            ingame_time,
            ..Default::default()
        };
        let mut i = 0;
        for node in doc.root().descendants() {
            match node.tag_name().name() {
                "GameName" => {
                    r.game_title = node
                        .text()
                        .map(|t| t.to_owned())
                        .unwrap_or_else(String::new);
                }
                "CategoryName" => {
                    r.category = node
                        .text()
                        .map(|t| t.to_owned())
                        .unwrap_or_else(String::new);
                }
                "Offset" => r.offset = node.text().map(parse_time).unwrap_or(TimeType::None),
                "Segment" => {
                    add_segment(&node, &mut r, i);
                    i += 1;
                }
                _ => {}
            }
        }
        println!("{r:?}");
        r.verify()?;
        r.normalize();
        r.calc_avgs();
        r.calc_segments();
        Ok(r)
    }
}

// parse a time in the format HH:MM:SS.FFF
// or MM:SS.FFF
// or SS.FFF
// or SS
// if there's a negative sign get that too
fn parse_time(time: &str) -> TimeType {
    let (neg, t) = if let Some(t) = time.strip_prefix('-') {
        (true, t)
    } else {
        (false, time)
    };
    let mut s = t.split(':').collect::<Vec<_>>();
    let mut r = 0;
    // seconds and milliseconds
    let l = s.pop().unwrap();
    let (secs, msecs) = l.split_once('.').unwrap_or((l, "0"));
    // remove higher precision than millis, and then pad out to three digits
    let msecs = format!("{:0<3}", &msecs[0..min(msecs.len(), 3)]);
    r += msecs.parse().unwrap_or(0);
    r += secs.parse().unwrap_or(0) * 1000;
    // minutes
    if !s.is_empty() {
        r += s.pop().unwrap().parse().unwrap_or(0) * 60000;
        // hours
        if !s.is_empty() {
            r += s.pop().unwrap().parse().unwrap_or(0) * 3600000;
        }
    }
    if neg {
        r *= -1
    }
    TimeType::from(r)
}

fn add_segment(n: &Node, r: &mut Run, i: usize) {
    // Run::default has one entry in each vec
    // so we cannot push on the first time
    if i != 0 {
        r.segment_names.push(String::new());
        r.rta_pb_splits.push(TimeType::None);
        r.igt_pb_splits.push(TimeType::None);
        r.rta_gold_segments.push(TimeType::None);
        r.igt_gold_segments.push(TimeType::None);
        r.rta_sum_segments.push((0, TimeType::None));
        r.igt_sum_segments.push((0, TimeType::None));
        r.rta_avg_segments.push(TimeType::None);
        r.igt_avg_segments.push(TimeType::None);
    }
    for node in n.descendants() {
        match node.tag_name().name() {
            "Name" => {
                r.segment_names[i] = node
                    .text()
                    .map(|t| t.to_owned())
                    .unwrap_or_else(String::new)
            }
            "SplitTime" => {
                for d in node.descendants() {
                    if d.tag_name().name() == "RealTime" {
                        r.rta_pb_splits[i] = d.text().map(parse_time).unwrap_or(TimeType::None);
                    } else if d.tag_name().name() == "GameTime" {
                        r.igt_pb_splits[i] = d.text().map(parse_time).unwrap_or(TimeType::None);
                    }
                }
            }
            "BestSegmentTime" => {
                for d in node.descendants() {
                    if d.tag_name().name() == "RealTime" {
                        r.rta_gold_segments[i] = d.text().map(parse_time).unwrap_or(TimeType::None);
                    } else if d.tag_name().name() == "GameTime" {
                        r.igt_gold_segments[i] = d.text().map(parse_time).unwrap_or(TimeType::None);
                    }
                }
            }
            "SegmentHistory" => {
                for d in node.descendants() {
                    if d.tag_name().name() == "RealTime" {
                        r.rta_sum_segments[i].0 += 1;
                        r.rta_sum_segments[i].1 += parse_time(d.text().unwrap_or(""));
                    } else if d.tag_name().name() == "GameTime" {
                        r.igt_sum_segments[i].0 += 1;
                        r.igt_sum_segments[i].1 += parse_time(d.text().unwrap_or(""));
                    }
                }
            }
            _ => {}
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn parse_time_full() {
        assert_eq!(parse_time("10:01:10.1010000"), TimeType::from(36070101));
        assert_eq!(parse_time("1:25:36.4"), TimeType::from(5136400));
        assert_eq!(parse_time("1:25:36"), TimeType::from(5136000));
    }

    #[test]
    fn parse_time_mins() {
        assert_eq!(parse_time("00001:10.101"), TimeType::from(70101));
        assert_eq!(parse_time("1:10.101"), TimeType::from(70101));
    }

    #[test]
    fn parse_time_secs() {
        assert_eq!(parse_time("30.54321"), TimeType::from(30543));
        assert_eq!(parse_time("301"), TimeType::from(301000));
    }

    #[test]
    fn parse_time_negative() {
        assert_eq!(parse_time("-1:25:36"), TimeType::from(-5136000));
        assert_eq!(parse_time("-1:10.101"), TimeType::from(-70101));
        assert_eq!(parse_time("-301"), TimeType::from(-301000));
    }

    #[test]
    fn rtaonly_lss() {
        let r = Run::from_str_lss(include_str!("../../tests/any%.lss")).unwrap();
        use TimeType::Time;
        // Calculated this entire mess manually. Pain and suffering.
        let the_same_run = Run {
            game_title: "The Legend of Zelda: Breath of the Wild".into(),
            category: "Any%".into(),
            ingame_time: false,
            offset: Time(-2400),
            segment_names: vec![
                "yellow".into(),
                "escbad".into(),
                "magnet".into(),
                "bobomb battlefield".into(),
                "tree launch >:(".into(),
                "bananalame/weapon theft".into(),
                "ganon platoon".into(),
                "idiot".into(),
                "pig".into(),
            ],
            rta_pb_splits: vec![
                Time(295047),
                Time(515607),
                Time(727070),
                Time(942882),
                Time(1133341),
                Time(1559189),
                Time(1789243),
                Time(2007331),
                Time(2170346),
            ],
            igt_pb_splits: vec![TimeType::None; 9],
            rta_pb_segments: vec![
                Time(295047),
                Time(220560),
                Time(211463),
                Time(215812),
                Time(190459),
                Time(425848),
                Time(230054),
                Time(218088),
                Time(163015),
            ],
            igt_pb_segments: vec![TimeType::None; 9],
            rta_gold_segments: vec![
                Time(282399),
                Time(209727),
                Time(151479),
                Time(178201),
                Time(118043),
                Time(419260),
                Time(222728),
                Time(191937),
                Time(163015),
            ],
            igt_gold_segments: vec![TimeType::None; 9],
            rta_sum_segments: vec![
                (68, Time(20488293)),
                (42, Time(10606062)),
                (37, Time(7762144)),
                (32, Time(7898441)),
                (30, Time(5210998)),
                (23, Time(15227876)),
                (17, Time(5069775)),
                (10, Time(4112349)),
                (9, Time(1554249)),
            ],
            igt_sum_segments: vec![(0, TimeType::None); 9],
            rta_avg_segments: vec![
                Time(301298),
                Time(252525),
                Time(209787),
                Time(246826),
                Time(173699),
                Time(662081),
                Time(298222),
                Time(411234),
                Time(172694),
            ],
            igt_avg_segments: vec![TimeType::None; 9],
        };
        assert_eq!(r, the_same_run);
    }
}