Skip to main content

shuyi_cli/contract/playtest/
summary.rs

1//! A plan's summary items ([`Summary`]): the few numbers of an entrant's
2//! tries a designer reads first, each over the game's named measures, as
3//! data a game may keep in a file.
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8use shuyi_core::bots::playtest::Tried;
9
10/// One number of an entrant's tries, as `playtest` prints it after the
11/// entrant's name. A try missing the measure counts it as 0. `label`
12/// defaults to the measure's name (`"most"` for [`Most`](Summary::Most)).
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15#[non_exhaustive]
16pub enum Summary {
17    /// "won 83/100": the sum of `measure` over the tries, of how many.
18    #[non_exhaustive]
19    Sum {
20        /// The measure.
21        measure: String,
22        /// The name it is printed with.
23        #[serde(default)]
24        label: Option<String>,
25    },
26    /// "deaths per try 0: 30  1: 21  2: 25  3+: 24": tries by value, the
27    /// last bucket open.
28    #[non_exhaustive]
29    Spread {
30        /// The measure.
31        measure: String,
32        /// The value of the last bucket, which takes it and more.
33        up_to: u32,
34        /// The name it is printed with.
35        #[serde(default)]
36        label: Option<String>,
37    },
38    /// "most: scout 41×": among keys `"<prefix><key>"` set to 1 or more
39    /// (keys with a further `skip_suffix` left out), the key set in the
40    /// most tries, then the first by name; "nobody" if none.
41    #[non_exhaustive]
42    Most {
43        /// What the keys start with.
44        prefix: String,
45        /// What a key left out ends with.
46        #[serde(default)]
47        skip_suffix: Option<String>,
48        /// The name it is printed with.
49        #[serde(default)]
50        label: Option<String>,
51    },
52    /// "items median 1": the lower middle value, 0 for no tries.
53    #[non_exhaustive]
54    Median {
55        /// The measure.
56        measure: String,
57        /// The name it is printed with.
58        #[serde(default)]
59        label: Option<String>,
60    },
61    /// "items max 3": the greatest value, 0 for no tries.
62    #[non_exhaustive]
63    Max {
64        /// The measure.
65        measure: String,
66        /// The name it is printed with.
67        #[serde(default)]
68        label: Option<String>,
69    },
70}
71
72impl Summary {
73    /// A [`Sum`](Summary::Sum) of `measure`.
74    pub fn sum(measure: impl Into<String>) -> Self {
75        Summary::Sum {
76            measure: measure.into(),
77            label: None,
78        }
79    }
80
81    /// A [`Spread`](Summary::Spread) of `measure`, its last bucket
82    /// `up_to` and more.
83    pub fn spread(measure: impl Into<String>, up_to: u32) -> Self {
84        Summary::Spread {
85            measure: measure.into(),
86            up_to,
87            label: None,
88        }
89    }
90
91    /// A [`Most`](Summary::Most) of the keys starting with `prefix`, none
92    /// left out ([`with_skip_suffix`](Self::with_skip_suffix) leaves some
93    /// out).
94    pub fn most(prefix: impl Into<String>) -> Self {
95        Summary::Most {
96            prefix: prefix.into(),
97            skip_suffix: None,
98            label: None,
99        }
100    }
101
102    /// A [`Median`](Summary::Median) of `measure`.
103    pub fn median(measure: impl Into<String>) -> Self {
104        Summary::Median {
105            measure: measure.into(),
106            label: None,
107        }
108    }
109
110    /// A [`Max`](Summary::Max) of `measure`.
111    pub fn max(measure: impl Into<String>) -> Self {
112        Summary::Max {
113            measure: measure.into(),
114            label: None,
115        }
116    }
117
118    /// The item printed with `label` instead of its default.
119    #[must_use]
120    pub fn with_label(mut self, label: Option<String>) -> Self {
121        match &mut self {
122            Summary::Sum { label: l, .. }
123            | Summary::Spread { label: l, .. }
124            | Summary::Most { label: l, .. }
125            | Summary::Median { label: l, .. }
126            | Summary::Max { label: l, .. } => *l = label,
127        }
128        self
129    }
130
131    /// The item leaving out keys ending with `skip_suffix`, if it is a
132    /// [`Most`](Summary::Most); any other item as it was.
133    #[must_use]
134    pub fn with_skip_suffix(mut self, skip_suffix: Option<String>) -> Self {
135        if let Summary::Most { skip_suffix: s, .. } = &mut self {
136            *s = skip_suffix;
137        }
138        self
139    }
140
141    /// The item over `tries`, as its doc comment shows it.
142    pub fn text(&self, tries: &[Tried]) -> String {
143        let named = |label: &Option<String>, measure: &str| -> String {
144            label.clone().unwrap_or_else(|| measure.to_owned())
145        };
146        match self {
147            Summary::Sum { measure, label } => {
148                let sum = values(tries, measure).fold(0i64, i64::saturating_add);
149                format!("{} {sum}/{}", named(label, measure), tries.len())
150            }
151            Summary::Spread {
152                measure,
153                up_to,
154                label,
155            } => {
156                let mut buckets: BTreeMap<u32, usize> = (0..=*up_to).map(|b| (b, 0)).collect();
157                for value in values(tries, measure) {
158                    let bucket = u32::try_from(value.max(0)).unwrap_or(u32::MAX).min(*up_to);
159                    *buckets.entry(bucket).or_default() += 1;
160                }
161                let spread: Vec<String> = buckets
162                    .iter()
163                    .map(|(&b, n)| {
164                        let open = if b == *up_to { "+" } else { "" };
165                        format!("{b}{open}: {n}")
166                    })
167                    .collect();
168                format!("{} per try {}", named(label, measure), spread.join("  "))
169            }
170            Summary::Most {
171                prefix,
172                skip_suffix,
173                label,
174            } => {
175                let mut set: BTreeMap<&str, u32> = BTreeMap::new();
176                for t in tries {
177                    let keys = t
178                        .measures
179                        .iter()
180                        .filter(|&(_, &value)| value >= 1)
181                        .filter_map(|(key, _)| key.strip_prefix(prefix.as_str()))
182                        .filter(|key| skip_suffix.as_ref().is_none_or(|s| !key.ends_with(s)));
183                    for key in keys {
184                        *set.entry(key).or_default() += 1;
185                    }
186                }
187                // The most tries, then the first by name.
188                let most = set
189                    .iter()
190                    .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
191                    .map_or_else(|| "nobody".to_owned(), |(key, n)| format!("{key} {n}×"));
192                format!("{}: {most}", named(label, "most"))
193            }
194            Summary::Median { measure, label } => {
195                let mut sorted: Vec<i64> = values(tries, measure).collect();
196                sorted.sort_unstable();
197                let median = sorted
198                    .get(sorted.len().saturating_sub(1) / 2)
199                    .copied()
200                    .unwrap_or(0);
201                format!("{} median {median}", named(label, measure))
202            }
203            Summary::Max { measure, label } => {
204                let max = values(tries, measure).max().unwrap_or(0);
205                format!("{} max {max}", named(label, measure))
206            }
207        }
208    }
209}
210
211/// `measure` in each of `tries`, 0 where it is missing.
212fn values<'a>(tries: &'a [Tried], measure: &'a str) -> impl Iterator<Item = i64> + 'a {
213    tries
214        .iter()
215        .map(move |t| t.measures.get(measure).copied().unwrap_or(0))
216}