Repository: LiveSplit/livesplit-core
Pull request #209 (2019-06-30 16:33:31)

Allow generating Goal Comparisons
This separates out the logic from the Balanced PB comparison. The Balanced PB algorithm balances the splits based on the history such that the final time matches the Personal Best. However the algorithm is
flexible enough to be used to generate splits for other times as well, as long as they are within the sum of the best segments and the sum of worst segments. By separating out the goal generating algorithm, we provide new APIs for generating custom comparisons for arbitrary goal times. Furthermore this is now also exposed in the Run Editor for the runner to use. In the Run Editor you only provide the goal time for the currently selected timing method. If you want to provide both, you use the feature twice.


Patch:
diff --git a/capi/src/run_editor.rs b/capi/src/run_editor.rs
index 49e05d4..e70bb89 100644
--- a/capi/src/run_editor.rs
+++ b/capi/src/run_editor.rs
@@ -354,6 +354,24 @@ pub extern "C" fn RunEditor_move_comparison(
     this.move_comparison(src_index, dst_index).is_ok()
 }
 
+/// Parses a goal time and generates a custom goal comparison based on the
+/// parsed value. The comparison's times are automatically balanced based on the
+/// runner's history such that it roughly represents what split times for the
+/// goal time would roughly look like. Since it is populated by the runner's
+/// history, only goal times within the sum of the best segments and the sum of
+/// the worst segments are supported. Everything else is automatically capped by
+/// that range. The comparison is only populated for the selected timing method.
+/// The other timing method's comparison times are not modified by this, so you
+/// can call this again with the other timing method to generate the comparison
+/// times for both timing methods.
+#[no_mangle]
+pub unsafe extern "C" fn RunEditor_parse_and_generate_goal_comparison(
+    this: &mut RunEditor,
+    time: *const c_char,
+) -> bool {
+    this.parse_and_generate_goal_comparison(str(time)).is_ok()
+}
+
 /// Clears out the Attempt History and the Segment Histories of all the
 /// segments.
 #[no_mangle]
diff --git a/capi/src/setting_value.rs b/capi/src/setting_value.rs
index 6ab2ef7..e12142a 100644
--- a/capi/src/setting_value.rs
+++ b/capi/src/setting_value.rs
@@ -5,7 +5,7 @@ use crate::str;
 use livesplit_core::component::splits::{ColumnStartWith, ColumnUpdateTrigger, ColumnUpdateWith};
 use livesplit_core::settings::{Alignment, Color, Gradient, ListGradient, Value as SettingValue};
 use livesplit_core::timing::formatter::{Accuracy, DigitsFormat};
-use livesplit_core::TimingMethod;
+use livesplit_core::{layout::LayoutDirection, TimingMethod};
 use std::os::raw::c_char;
 
 /// type
@@ -280,3 +280,18 @@ pub unsafe extern "C" fn SettingValue_from_column_update_trigger(
     };
     Some(Box::new(value.into()))
 }
+
+/// Creates a new setting value from the layout direction. If it doesn't
+/// match a known layout direction, <NULL> is returned.
+#[no_mangle]
+pub unsafe extern "C" fn SettingValue_from_layout_direction(
+    value: *const c_char,
+) -> NullableOwnedSettingValue {
+    let value = str(value);
+    let value = match value {
+        "Vertical" => LayoutDirection::Vertical,
+        "Horizontal" => LayoutDirection::Horizontal,
+        _ => return None,
+    };
+    Some(Box::new(value.into()))
+}
diff --git a/src/comparison/balanced_pb.rs b/src/comparison/balanced_pb.rs
index 9bd95b4..8164c52 100644
--- a/src/comparison/balanced_pb.rs
+++ b/src/comparison/balanced_pb.rs
@@ -11,9 +11,8 @@
 //! instead, as all of the mistakes of the early game in such a situation would
 //! be smoothed out throughout the whole comparison.
 
-use super::ComparisonGenerator;
-use crate::{Attempt, Segment, TimeSpan, TimingMethod};
-use ordered_float::OrderedFloat;
+use super::{goal, ComparisonGenerator};
+use crate::{Attempt, Segment, TimingMethod};
 
 /// The Comparison Generator for calculating a comparison which has the same
 /// final time as the runner's Personal Best. Unlike the Personal Best however,
@@ -36,157 +35,6 @@ pub const SHORT_NAME: &str = "Balanced";
 /// The name of this comparison.
 pub const NAME: &str = "Balanced PB";
 
-const WEIGHT: f64 = 0.9375;
-
-fn interpolate(
-    perc: f64,
-    (weight_left, time_left): (f64, TimeSpan),
-    (weight_right, time_right): (f64, TimeSpan),
-) -> TimeSpan {
-    let perc_down =
-        (weight_right - perc) * time_left.total_milliseconds() / (weight_right - weight_left);
-    let perc_up =
-        (perc - weight_left) * time_right.total_milliseconds() / (weight_right - weight_left);
-    TimeSpan::from_milliseconds(perc_up + perc_down)
-}
-
-fn generate(
-    segments: &mut [Segment],
-    method: TimingMethod,
-    time_span_buf: &mut Vec<TimeSpan>,
-    all_weighted_segment_times: &mut [Vec<(f64, TimeSpan)>],
-) {
-    let mut len = segments.len();
-
-    for ((i, segment), weighted_segment_times) in segments
-        .iter()
-        .enumerate()
-        .zip(all_weighted_segment_times.iter_mut())
-    {
-        weighted_segment_times.clear();
-
-        // Collect initial weighted segments
-        let mut current_weight = 1.0;
-        for &(id, time) in segment.segment_history().iter_actual_runs().rev() {
-            if let Some(time) = time[method] {
-                // Skip all the combined segments
-                let skip = catch! {
-                    segments[i.checked_sub(1)?].segment_history().get(id)?[method].is_none()
-                }
-                .unwrap_or(false);
-
-                if !skip {
-                    weighted_segment_times.push((current_weight, time));
-                    current_weight *= WEIGHT;
-                }
-            }
-        }
-
-        // End early if we don't have any segment times anymore
-        if weighted_segment_times.is_empty() {
-            len = i;
-            break;
-        }
-
-        // Sort everything by the times
-        weighted_segment_times
-            .sort_unstable_by_key(|&(_, time)| OrderedFloat(time.total_milliseconds()));
-
-        // Cumulative sum of the weights
-        let mut sum = 0.0;
-        for (weight, _) in weighted_segment_times.iter_mut() {
-            sum += *weight;
-            *weight = sum;
-        }
-
-        // Reweigh all of the weights to be in the range 0..1
-        let min = weighted_segment_times
-            .first()
-            .map(|&(w, _)| w)
-            .unwrap_or_default();
-        let max = weighted_segment_times
-            .last()
-            .map(|&(w, _)| w)
-            .unwrap_or_default();
-        let diff = max - min;
-
-        if diff != 0.0 {
-            for (weight, _) in weighted_segment_times.iter_mut() {
-                *weight = (*weight - min) / diff;
-            }
-        }
-    }
-
-    // Limit the slice to only the segments that have segment times.
-    let all_weighted_segment_times = &mut all_weighted_segment_times[..len];
-    // Limit the slice again to the last split that actually has a split time we can work with.
-    let (new_len, goal_time) = segments[..len]
-        .iter()
-        .enumerate()
-        .rev()
-        .find_map(|(i, s)| s.personal_best_split_time()[method].map(|t| (i + 1, t)))
-        .unwrap_or_default();
-    let all_weighted_segment_times = &mut all_weighted_segment_times[..new_len];
-
-    let (mut perc_min, mut perc_max) = (0.0, 1.0);
-
-    // Try to find the correct percentile
-    for _ in 0..50 {
-        let percentile = (perc_max + perc_min) / 2.0;
-        let mut sum = TimeSpan::zero();
-
-        time_span_buf.clear();
-        time_span_buf.extend(
-            all_weighted_segment_times
-                .iter()
-                .map(|weighted_segment_times| {
-                    // Binary search the percentile in the segment's segment times
-                    let percentile_segment_time = if weighted_segment_times.len() == 1 {
-                        // Shortcut for a single segment time
-                        weighted_segment_times[0].1
-                    } else {
-                        let found_index = weighted_segment_times
-                            .binary_search_by(|&(w, _)| w.partial_cmp(&percentile).unwrap());
-
-                        match found_index {
-                            // The percentile perfectly matched a segment time
-                            Ok(index) => weighted_segment_times[index].1,
-                            // The percentile didn't perfectly match, interpolate instead
-                            Err(right_index) => {
-                                let right = weighted_segment_times[right_index];
-                                let left = right_index
-                                    .checked_sub(1)
-                                    .map(|left_index| weighted_segment_times[left_index])
-                                    .unwrap_or_default();
-
-                                interpolate(percentile, left, right)
-                            }
-                        }
-                    };
-
-                    sum += percentile_segment_time;
-                    sum
-                }),
-        );
-
-        // Binary search the correct percentile
-        if sum == goal_time {
-            break;
-        } else if sum < goal_time {
-            perc_min = percentile;
-        } else {
-            perc_max = percentile;
-        }
-    }
-
-    for (segment, &val) in segments.iter_mut().zip(time_span_buf.iter()) {
-        segment.comparison_mut(NAME)[method] = Some(val);
-    }
-    for segment in &mut segments[time_span_buf.len()..] {
-        segment.comparison_mut(NAME)[method] = None;
-    }
-}
-
 impl ComparisonGenerator for BalancedPB {
     fn name(&self) -> &str {
         NAME
@@ -196,15 +44,19 @@ impl ComparisonGenerator for BalancedPB {
         let mut all_weighted_segment_times = vec![Vec::new(); segments.len()];
         let mut time_span_buf = Vec::with_capacity(segments.len());
 
-        generate(
+        goal::generate_for_timing_method_with_buf(
             segments,
             TimingMethod::RealTime,
+            None,
+            NAME,
             &mut time_span_buf,
             &mut all_weighted_segment_times,
         );
-        generate(
+        goal::generate_for_timing_method_with_buf(
             segments,
             TimingMethod::GameTime,
+            None,
+            NAME,
             &mut time_span_buf,
             &mut all_weighted_segment_times,
         );
diff --git a/src/comparison/goal.rs b/src/comparison/goal.rs
new file mode 100644
index 0000000..8965674
--- /dev/null
+++ b/src/comparison/goal.rs
@@ -0,0 +1,240 @@
+//! Defines functions for generating a goal comparison based on a goal time provided.
+//! The comparison's times are automatically balanced based on the runner's
+//! history such that it roughly represents what split times for the goal time
+//! would roughly look like. This does not define a Comparison Generator. The
+//! Balanced PB comparison however is based on this, which uses the Personal
+//! Best as a goal time to balance the mistakes that happened in the Personal Best.
+
+use crate::{Segment, Time, TimeSpan, TimingMethod};
+use ordered_float::OrderedFloat;
+
+/// The default name of the goal comparison.
+pub const NAME: &str = "Goal";
+
+const WEIGHT: f64 = 0.9375;
+
+fn interpolate(
+    perc: f64,
+    (weight_left, time_left): (f64, TimeSpan),
+    (weight_right, time_right): (f64, TimeSpan),
+) -> TimeSpan {
+    let perc_down =
+        (weight_right - perc) * time_left.total_milliseconds() / (weight_right - weight_left);
+    let perc_up =
+        (perc - weight_left) * time_right.total_milliseconds() / (weight_right - weight_left);
+    TimeSpan::from_milliseconds(perc_up + perc_down)
+}
+
+pub(super) fn generate_for_timing_method_with_buf(
+    segments: &mut [Segment],
+    method: TimingMethod,
+    goal_time: Option<TimeSpan>,
+    comparison: &str,
+    time_span_buf: &mut Vec<TimeSpan>,
+    all_weighted_segment_times: &mut [Vec<(f64, TimeSpan)>],
+) {
+    let mut len = segments.len();
+
+    for ((i, segment), weighted_segment_times) in segments
+        .iter()
+        .enumerate()
+        .zip(all_weighted_segment_times.iter_mut())
+    {
+        weighted_segment_times.clear();
+
+        // Collect initial weighted segments
+        let mut current_weight = 1.0;
+        for &(id, time) in segment.segment_history().iter_actual_runs().rev() {
+            if let Some(time) = time[method] {
+                // Skip all the combined segments
+                let skip = catch! {
+                    segments[i.checked_sub(1)?].segment_history().get(id)?[method].is_none()
+                }
+                .unwrap_or(false);
+
+                if !skip {
+                    weighted_segment_times.push((current_weight, time));
+                    current_weight *= WEIGHT;
+                }
+            }
+        }
+
+        // End early if we don't have any segment times anymore
+        if weighted_segment_times.is_empty() {
+            len = i;
+            break;
+        }
+
+        // Sort everything by the times
+        weighted_segment_times
+            .sort_unstable_by_key(|&(_, time)| OrderedFloat(time.total_milliseconds()));
+
+        // Cumulative sum of the weights
+        let mut sum = 0.0;
+        for (weight, _) in weighted_segment_times.iter_mut() {
+            sum += *weight;
+            *weight = sum;
+        }
+
+        // Reweigh all of the weights to be in the range 0..1
+        let min = weighted_segment_times
+            .first()
+            .map(|&(w, _)| w)
+            .unwrap_or_default();
+        let max = weighted_segment_times
+            .last()
+            .map(|&(w, _)| w)
+            .unwrap_or_default();
+        let diff = max - min;
+
+        if diff != 0.0 {
+            for (weight, _) in weighted_segment_times.iter_mut() {
+                *weight = (*weight - min) / diff;
+            }
+        }
+    }
+
+    // Limit the slice to only the segments that have segment times.
+    let mut all_weighted_segment_times = &mut all_weighted_segment_times[..len];
+
+    // Depending on whether we have a goal time or not, we used that goal time
+    // or try to determine a personal best split time that we use for the goal
+    // time. In that case we may need to limit the slice again to the last split
+    // that actually has a split time we can work with.
+    let goal_time = if let Some(goal_time) = goal_time {
+        goal_time
+    } else {
+        let (new_len, goal_time) = segments[..len]
+            .iter()
+            .enumerate()
+            .rev()
+            .find_map(|(i, s)| s.personal_best_split_time()[method].map(|t| (i + 1, t)))
+            .unwrap_or_default();
+        all_weighted_segment_times = &mut all_weighted_segment_times[..new_len];
+        goal_time
+    };
+
+    let (mut perc_min, mut perc_max) = (0.0, 1.0);
+
+    // Try to find the correct percentile
+    for _ in 0..50 {
+        let percentile = (perc_max + perc_min) / 2.0;
+        let mut sum = TimeSpan::zero();
+
+        time_span_buf.clear();
+        time_span_buf.extend(
+            all_weighted_segment_times
+                .iter()
+                .map(|weighted_segment_times| {
+                    // Binary search the percentile in the segment's segment times
+                    let percentile_segment_time = if weighted_segment_times.len() == 1 {
+                        // Shortcut for a single segment time
+                        weighted_segment_times[0].1
+                    } else {
+                        let found_index = weighted_segment_times
+                            .binary_search_by(|&(w, _)| w.partial_cmp(&percentile).unwrap());
+
+                        match found_index {
+                            // The percentile perfectly matched a segment time
+                            Ok(index) => weighted_segment_times[index].1,
+                            // The percentile didn't perfectly match, interpolate instead
+                            Err(right_index) => {
+                                let right = weighted_segment_times[right_index];
+                                let left = right_index
+                                    .checked_sub(1)
+                                    .map(|left_index| weighted_segment_times[left_index])
+                                    .unwrap_or_default();
+
+                                interpolate(percentile, left, right)
+                            }
+                        }
+                    };
+
+                    sum += percentile_segment_time;
+                    sum
+                }),
+        );
+
+        // Binary search the correct percentile
+        if sum == goal_time {
+            break;
+        } else if sum < goal_time {
+            perc_min = percentile;
+        } else {
+            perc_max = percentile;
+        }
+    }
+
+    for (segment, &val) in segments.iter_mut().zip(time_span_buf.iter()) {
+        segment.comparison_mut(comparison)[method] = Some(val);
+    }
+    for segment in &mut segments[time_span_buf.len()..] {
+        segment.comparison_mut(comparison)[method] = None;
+    }
+}
+
+/// Populates the segments with a goal comparison for the timing method
+/// specified. Every other timing method is left untouched. The segment history
+/// is used to generate comparison times such that they end up with the goal
+/// time specified. The values are stored in the comparison with the name
+/// provided. Only the range between the sum of the best segments and the sum of
+/// the worst segments is supported. Every other goal time is capped within that
+/// range.
+pub fn generate_for_timing_method(
+    segments: &mut [Segment],
+    method: TimingMethod,
+    goal_time: TimeSpan,
+    comparison: &str,
+) {
+    let mut all_weighted_segment_times = vec![Vec::new(); segments.len()];
+    let mut time_span_buf = Vec::with_capacity(segments.len());
+
+    generate_for_timing_method_with_buf(
+        segments,
+        method,
+        Some(goal_time),
+        comparison,
+        &mut time_span_buf,
+        &mut all_weighted_segment_times,
+    );
+}
+
+/// Populates the segments with a goal comparison. The segment history is used
+/// to generate comparison times such that they end up with the goal time
+/// specified. The values are stored in the comparison with the name provided.
+/// Only the range between the sum of the best segments and the sum of the worst
+/// segments is supported. Every other goal time is capped within that range.
+pub fn generate(segments: &mut [Segment], goal_time: Time, comparison: &str) {
+    let mut all_weighted_segment_times = vec![Vec::new(); segments.len()];
+    let mut time_span_buf = Vec::with_capacity(segments.len());
+
+    if let Some(real_time) = goal_time.real_time {
+        generate_for_timing_method_with_buf(
+            segments,
+            TimingMethod::RealTime,
+            Some(real_time),
+            comparison,
+            &mut time_span_buf,
+            &mut all_weighted_segment_times,
+        );
+    } else {
+        for segment in &mut *segments {
+            segment.comparison_mut(comparison).real_time = None;
+        }
+    }
+
+    if let Some(game_time) = goal_time.game_time {
+        generate_for_timing_method_with_buf(
+            segments,
+            TimingMethod::GameTime,
+            Some(game_time),
+            comparison,
+            &mut time_span_buf,
+            &mut all_weighted_segment_times,
+        );
+    } else {
+        for segment in &mut *segments {
+            segment.comparison_mut(comparison).game_time = None;
+        }
+    }
+}
diff --git a/src/comparison/mod.rs b/src/comparison/mod.rs
index 1c35a4b..5671182 100644
--- a/src/comparison/mod.rs
+++ b/src/comparison/mod.rs
@@ -10,6 +10,7 @@ pub mod average_segments;
 pub mod balanced_pb;
 pub mod best_segments;
 pub mod best_split_times;
+pub mod goal;
 pub mod latest_run;
 pub mod median_segments;
 pub mod none;
diff --git a/src/run/editor/mod.rs b/src/run/editor/mod.rs
index 80f26f0..3ec7878 100644
--- a/src/run/editor/mod.rs
+++ b/src/run/editor/mod.rs
@@ -33,6 +33,8 @@ quick_error! {
         }
         /// Negative times are not allowed here.
         NegativeTimeNotAllowed {}
+        /// Empty times are not allowed here.
+        EmptyTimeNotAllowed {}
     }
 }
 
@@ -792,6 +794,58 @@ impl Editor {
         Ok(())
     }
 
+    /// Generates a custom goal comparison based on the goal time provided. The
+    /// comparison's times are automatically balanced based on the runner's
+    /// history such that it roughly represents what split times for the goal
+    /// time would roughly look like. Since it is populated by the runner's
+    /// history, only goal times within the sum of the best segments and the sum
+    /// of the worst segments are supported. Everything else is automatically
+    /// capped by that range. The comparison is only populated for the selected
+    /// timing method. The other timing method's comparison times are not
+    /// modified by this, so you can call this again with the other timing
+    /// method to generate the comparison times for both timing methods.
+    pub fn generate_goal_comparison(&mut self, time: TimeSpan) {
+        if !self
+            .run
+            .custom_comparisons()
+            .iter()
+            .any(|c| c == comparison::goal::NAME)
+        {
+            self.run
+                .custom_comparisons_mut()
+                .push(String::from(comparison::goal::NAME));
+        }
+
+        comparison::goal::generate_for_timing_method(
+            &mut self.run.segments_mut(),
+            self.selected_method,
+            time,
+            comparison::goal::NAME,
+        );
+
+        self.raise_run_edited();
+    }
+
+    /// Parses a goal time and generates a custom goal comparison based on the
+    /// parsed value. The comparison's times are automatically balanced based on
+    /// the runner's history such that it roughly represents what split times
+    /// for the goal time would roughly look like. Since it is populated by the
+    /// runner's history, only goal times within the sum of the best segments
+    /// and the sum of the worst segments are supported. Everything else is
+    /// automatically capped by that range. The comparison is only populated for
+    /// the selected timing method. The other timing method's comparison times
+    /// are not modified by this, so you can call this again with the other
+    /// timing method to generate the comparison times for both timing methods.
+    pub fn parse_and_generate_goal_comparison<S>(&mut self, time: S) -> Result<(), ParseError>
+    where
+        S: AsRef<str>,
+    {
+        self.generate_goal_comparison(
+            parse_positive(time)?.ok_or(ParseError::EmptyTimeNotAllowed)?,
+        );
+        Ok(())
+    }
+
     /// Clears out the Attempt History and the Segment Histories of all the
     /// segments.
     pub fn clear_history(&mut self) {
@@ -818,3 +872,15 @@ impl Editor {
         SumOfBestCleaner::new(&mut self.run)
     }
 }
+
+fn parse_positive<S>(time: S) -> Result<Option<TimeSpan>, ParseError>
+where
+    S: AsRef<str>,
+{
+    let time = TimeSpan::parse_opt(time)?;
+    if time.map_or(false, |t| t < TimeSpan::zero()) {
+        Err(ParseError::NegativeTimeNotAllowed)
+    } else {
+        Ok(time)
+    }
+}
diff --git a/src/run/editor/segment_row.rs b/src/run/editor/segment_row.rs
index dc0f11f..89ae6db 100644
--- a/src/run/editor/segment_row.rs
+++ b/src/run/editor/segment_row.rs
@@ -1,4 +1,4 @@
-use super::{Editor, ParseError};
+use super::{parse_positive, Editor, ParseError};
 use crate::{Image, TimeSpan};
 
 /// A Segment Row describes the segment in the Run Editor actively selected for
@@ -156,15 +156,3 @@ impl<'a> SegmentRow<'a> {
         Ok(())
     }
 }
-
-fn parse_positive<S>(time: S) -> Result<Option<TimeSpan>, ParseError>
-where
-    S: AsRef<str>,
-{
-    let time = TimeSpan::parse_opt(time)?;
-    if time.map_or(false, |t| t < TimeSpan::zero()) {
-        Err(ParseError::NegativeTimeNotAllowed)
-    } else {
-        Ok(time)
-    }
-}


Test patch:
diff --git a/src/run/editor/tests/dissociate_run.rs b/src/run/editor/tests/dissociate_run.rs
index 4cc3de9..90ee318 100644
--- a/src/run/editor/tests/dissociate_run.rs
+++ b/src/run/editor/tests/dissociate_run.rs
@@ -1,5 +1,5 @@
 use super::super::Editor;
-use crate::{Run, Segment};
+use crate::{Run, Segment, TimeSpan};
 
 fn base() -> Editor {
     let mut run = Run::new();
@@ -176,3 +176,10 @@ fn when_clearing_metadata() {
     editor.clear_metadata();
     assert_eq!(editor.run().metadata().run_id, "");
 }
+
+#[test]
+fn not_when_generating_goal_comparison() {
+    let mut editor = base();
+    editor.generate_goal_comparison(TimeSpan::from_seconds(30.0));
+    assert_ne!(editor.run().metadata().run_id, "");
+}
diff --git a/src/run/editor/tests/mark_as_modified.rs b/src/run/editor/tests/mark_as_modified.rs
index 326f2b1..78172e4 100644
--- a/src/run/editor/tests/mark_as_modified.rs
+++ b/src/run/editor/tests/mark_as_modified.rs
@@ -1,5 +1,5 @@
 use super::super::Editor;
-use crate::{Run, Segment, TimingMethod};
+use crate::{Run, Segment, TimeSpan, TimingMethod};
 
 fn base() -> Editor {
     let mut run = Run::new();
@@ -307,4 +307,11 @@ fn not_when_cleaning_sum_of_best_without_applying_a_fix() {
     assert!(!editor.run().has_been_modified());
 }
 
+#[test]
+fn when_generating_goal_comparison() {
+    let mut editor = base();
+    editor.generate_goal_comparison(TimeSpan::from_seconds(30.0));
+    assert!(editor.run().has_been_modified());
+}
+
 // FIXME: Cleaning Sum of Best