Skip to main content

shuyi_core/bots/
playtest.rs

1//! A playtest runner: several named bots ([`Entrant`]s) each play a stage
2//! K times from the same seeds, the game measures every try
3//! ([`Measure`]), and data-file [`Check`]s say per bot whether it
4//! succeeded about as often as the designer wants: ✓, ✗ too hard or
5//! ⚠ too easy.
6//!
7//! [`run`] does it all. It is two halves, [`play_tries`] and [`judge`], so
8//! a caller with threads can play seed ranges on threads of its own and
9//! judge them once: the report never depends on how the seeds were split.
10//! [`play_try`] plays one seed and keeps what a tool shows of a try
11//! besides: how many commands were applied, and the entrant's record of it
12//! for `replay` ([`Entrant::record`]).
13//!
14//! Every number is an integer, so a report is the same on every machine:
15//! a band compares `successes * 100` with `min * count`, and the median
16//! of an even count is the lower middle value.
17//!
18//! The checks are a flat list, or come from [`Targets`]: what each
19//! entrant should achieve on a stage of each of the game's tiers, said
20//! once. Given a stage's tier and the entrants that play,
21//! [`Targets::checks`] gives that stage's checks. A game reads the file
22//! itself (the core does no I/O), over its own tier type:
23//!
24//! ```ron
25//! (
26//!     success: {
27//!         "casual": [(measure: "won", min: Some(1))],
28//!         "expert": [(measure: "won", min: Some(1)), (measure: "deaths", max: Some(0))],
29//!     },
30//!     bands: {
31//!         Easy: {"casual": (min: 70, max: 90), "expert": (min: 80, max: 100)},
32//!         Hard: {"casual": (min: 30, max: 50), "expert": (min: 50, max: 75)},
33//!     },
34//!     floors: {"casual": 20},
35//!     medians: [(entrant: "expert", measure: "turns", want: Lower, than: "casual")],
36//! )
37//! ```
38
39use alloc::boxed::Box;
40use alloc::collections::BTreeMap;
41use alloc::format;
42use alloc::string::{String, ToString};
43use alloc::vec::Vec;
44use core::fmt::{self, Write as _};
45use core::ops::Range;
46
47use serde::{Deserialize, Serialize};
48
49use super::{Bot, PlayError, Report, Sides, play_observed};
50
51mod targets;
52
53pub use targets::{MedianTarget, Targets};
54
55/// The game's measures of one try, by name. A success or a loss is a
56/// measure too (`"won"` 1 or 0), so every check reads measures only.
57pub type Measures = BTreeMap<String, i64>;
58
59/// A game's measures of a try, watching it command by command.
60pub trait Measure<R: Sides> {
61    /// Called for every applied command: the game after it, the command
62    /// and the events it gave.
63    fn observe(&mut self, rules: &R, command: &R::Command, events: &[R::Event]);
64
65    /// The measures of the try, once it has stopped at `end`.
66    fn finish(self: Box<Self>, report: &Report<R>, end: &R) -> Measures;
67}
68
69/// One named bot as the runner plays it, over the stage definition `S`
70/// (the data a stage is built from).
71pub trait Entrant<S, R: Sides> {
72    /// Its name, which checks and the report use.
73    fn name(&self) -> &str;
74
75    /// The stage as this entrant plays it (a game with modes picks this
76    /// entrant's), its luck from `seed`.
77    fn start(&self, stage: &S, seed: u64) -> R;
78
79    /// One bot per side for the try of `seed`.
80    fn bots(&self, seed: u64) -> Vec<(R::Side, Box<dyn Bot<R>>)>;
81
82    /// A fresh measure for a try.
83    fn measure(&self) -> Box<dyn Measure<R>>;
84
85    /// The try of `seed` as a record in the game's own format, for
86    /// `replay`, from the commands applied in order ([`play_try`]); `None`,
87    /// the default, if it keeps none.
88    fn record(&self, seed: u64, commands: &[R::Command]) -> Option<String> {
89        let _ = (seed, commands);
90        None
91    }
92}
93
94/// How a try the turn limit stopped is written where an outcome would
95/// be.
96pub const UNFINISHED: &str = "unfinished";
97
98/// K tries from seeds `seed` to `seed + count - 1` (those that fit in a
99/// `u64`), each stopped at turn `turns` if it hasn't ended. Every entrant
100/// plays the same seeds.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103#[non_exhaustive]
104pub struct Tries {
105    /// How many tries.
106    pub count: u32,
107    /// The first try's seed.
108    pub seed: u64,
109    /// The turn a try stops at if it hasn't ended.
110    pub turns: u32,
111}
112
113impl Tries {
114    /// `count` tries from seed `seed`, each stopped at turn `turns`.
115    #[must_use]
116    pub const fn new(count: u32, seed: u64, turns: u32) -> Self {
117        Self { count, seed, turns }
118    }
119
120    /// The seeds of the tries.
121    pub fn seeds(&self) -> Range<u64> {
122        self.seed..self.seed.saturating_add(u64::from(self.count))
123    }
124}
125
126/// One try of one entrant.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129#[serde(deny_unknown_fields)]
130pub struct Tried {
131    /// Its seed.
132    pub seed: u64,
133    /// How the game ended, as the outcome's `Debug` writes it; `None` if
134    /// the turn limit stopped it.
135    pub outcome: Option<String>,
136    /// The turn it stopped on (past the limit if the limit stopped it).
137    pub turns: u32,
138    /// The game's measures of it.
139    pub measures: Measures,
140}
141
142/// One try as [`play_try`] played it: what the checks read, how many
143/// commands were applied, and the entrant's record of it, if it keeps one
144/// ([`Entrant::record`]). Build it with [`PlayedTry::new`] (ADR-0039).
145#[derive(Debug, Clone, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct PlayedTry {
148    /// What the checks read.
149    pub tried: Tried,
150    /// The commands applied.
151    pub commands: u32,
152    /// Its record, in the game's record format, for `replay`.
153    pub record: Option<String>,
154}
155
156impl PlayedTry {
157    /// `tried`, after `commands` commands, with no record.
158    #[must_use]
159    pub fn new(tried: Tried, commands: u32) -> Self {
160        Self {
161            tried,
162            commands,
163            record: None,
164        }
165    }
166}
167
168/// A measure within bounds, both ends inclusive. A try missing the
169/// measure fails it.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[serde(deny_unknown_fields)]
173#[non_exhaustive]
174pub struct Condition {
175    /// The measure's name.
176    pub measure: String,
177    /// Its least value, if it has one.
178    #[serde(default)]
179    pub min: Option<i64>,
180    /// Its greatest value, if it has one.
181    #[serde(default)]
182    pub max: Option<i64>,
183}
184
185impl Condition {
186    /// The measure called `measure`, with no bounds
187    /// ([`with_min`](Self::with_min) and [`with_max`](Self::with_max) set
188    /// them).
189    pub fn new(measure: impl Into<String>) -> Self {
190        Self {
191            measure: measure.into(),
192            min: None,
193            max: None,
194        }
195    }
196
197    /// The condition with its least value `min`.
198    #[must_use]
199    pub fn with_min(self, min: Option<i64>) -> Self {
200        Self { min, ..self }
201    }
202
203    /// The condition with its greatest value `max`.
204    #[must_use]
205    pub fn with_max(self, max: Option<i64>) -> Self {
206        Self { max, ..self }
207    }
208
209    /// Whether `measures` meet it.
210    pub fn met_by(&self, measures: &Measures) -> bool {
211        measures.get(&self.measure).is_some_and(|&value| {
212            self.min.is_none_or(|min| value >= min) && self.max.is_none_or(|max| value <= max)
213        })
214    }
215}
216
217/// Whether `measures` meet every one of `conditions`.
218fn meets(conditions: &[Condition], measures: &Measures) -> bool {
219    conditions.iter().all(|c| c.met_by(measures))
220}
221
222/// A share of tries in percent, both ends inclusive.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
225#[serde(deny_unknown_fields)]
226pub struct Band {
227    /// The least share, in percent.
228    pub min: u32,
229    /// The greatest share, in percent.
230    pub max: u32,
231}
232
233/// Where a share of tries falls against a [`Band`].
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
236pub enum RateVerdict {
237    /// Under the band: too hard (✗).
238    Below,
239    /// In the band (✓).
240    Within,
241    /// Over the band: too easy (⚠).
242    Above,
243}
244
245impl Band {
246    /// Where `successes` of `count` tries fall, compared in integers.
247    pub fn verdict(self, successes: u32, count: u32) -> RateVerdict {
248        let share = u64::from(successes) * 100;
249        if share < u64::from(self.min) * u64::from(count) {
250            RateVerdict::Below
251        } else if share > u64::from(self.max) * u64::from(count) {
252            RateVerdict::Above
253        } else {
254            RateVerdict::Within
255        }
256    }
257}
258
259/// Which way a median should lean.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
262pub enum Want {
263    /// Strictly lower than the other entrant's.
264    Lower,
265    /// Strictly higher than the other entrant's.
266    Higher,
267}
268
269/// How a median compares.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272pub enum MedianVerdict {
273    /// It leans the way wanted (✓).
274    Pass,
275    /// It doesn't (✗).
276    Fail,
277    /// One side has no try to take a median of (–).
278    Unknown,
279}
280
281/// A check of a run, kept as data.
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
284#[non_exhaustive]
285pub enum Check {
286    /// The share of `entrant`'s tries that meet every `success` condition
287    /// is within `band`. A share under `floor` percent blocks the run.
288    #[non_exhaustive]
289    Rate {
290        /// The entrant checked.
291        entrant: String,
292        /// What a success is.
293        success: Vec<Condition>,
294        /// The share wanted.
295        band: Band,
296        /// The share under which the run is blocked, in percent.
297        #[serde(default)]
298        floor: Option<u32>,
299    },
300    /// The median of `measure` over `entrant`'s tries that meet `among`
301    /// is lower or higher than the same median of entrant `than`.
302    #[non_exhaustive]
303    Median {
304        /// The entrant checked.
305        entrant: String,
306        /// The measure.
307        measure: String,
308        /// Which tries count.
309        #[serde(default)]
310        among: Vec<Condition>,
311        /// Which way it should lean.
312        want: Want,
313        /// The entrant compared with.
314        than: String,
315    },
316}
317
318impl Check {
319    /// A [`Rate`](Check::Rate): the share of `entrant`'s tries that meet
320    /// every `success` condition is within `band`, with no floor
321    /// ([`with_floor`](Self::with_floor) sets one).
322    pub fn rate(entrant: impl Into<String>, success: Vec<Condition>, band: Band) -> Self {
323        Check::Rate {
324            entrant: entrant.into(),
325            success,
326            band,
327            floor: None,
328        }
329    }
330
331    /// A [`Median`](Check::Median): the median of `measure` over all of
332    /// `entrant`'s tries leans `want` of entrant `than`'s
333    /// ([`with_among`](Self::with_among) narrows the tries).
334    pub fn median(
335        entrant: impl Into<String>,
336        measure: impl Into<String>,
337        want: Want,
338        than: impl Into<String>,
339    ) -> Self {
340        Check::Median {
341            entrant: entrant.into(),
342            measure: measure.into(),
343            among: Vec::new(),
344            want,
345            than: than.into(),
346        }
347    }
348
349    /// The check with its floor `floor`, if it is a rate; any other check
350    /// as it was.
351    #[must_use]
352    pub fn with_floor(mut self, floor: Option<u32>) -> Self {
353        if let Check::Rate { floor: f, .. } = &mut self {
354            *f = floor;
355        }
356        self
357    }
358
359    /// The check over the tries that meet `among`, if it is a median; any
360    /// other check as it was.
361    #[must_use]
362    pub fn with_among(mut self, among: Vec<Condition>) -> Self {
363        if let Check::Median { among: a, .. } = &mut self {
364            *a = among;
365        }
366        self
367    }
368
369    /// The entrants it names: a game keeps the checks whose entrants all
370    /// play.
371    pub fn entrants(&self) -> Vec<&str> {
372        match self {
373            Check::Rate { entrant, .. } => alloc::vec![entrant.as_str()],
374            Check::Median { entrant, than, .. } => alloc::vec![entrant.as_str(), than.as_str()],
375        }
376    }
377}
378
379/// What a check found.
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
382pub enum Found {
383    /// A [`Check::Rate`]'s numbers.
384    Rate {
385        /// The tries that succeeded.
386        successes: u32,
387        /// The tries.
388        count: u32,
389        /// Where the share falls.
390        verdict: RateVerdict,
391        /// Whether it is under the floor.
392        blocked: bool,
393    },
394    /// A [`Check::Median`]'s numbers.
395    Median {
396        /// The entrant's median, if it has a try to take it of.
397        median: Option<i64>,
398        /// The other entrant's.
399        than: Option<i64>,
400        /// How they compare.
401        verdict: MedianVerdict,
402    },
403}
404
405/// A check and what it found.
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
408#[serde(deny_unknown_fields)]
409pub struct Judged {
410    /// The check.
411    pub check: Check,
412    /// What it found.
413    pub found: Found,
414}
415
416/// One entrant's tries, in seed order.
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
419#[serde(deny_unknown_fields)]
420pub struct EntrantTries {
421    /// Its name.
422    pub name: String,
423    /// Its tries.
424    pub tries: Vec<Tried>,
425}
426
427/// What a playtest found. Its `Display` is the table people read; a game
428/// adds its own lines from the data.
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
431#[serde(deny_unknown_fields)]
432pub struct PlaytestReport {
433    /// Every entrant's tries, in the order given.
434    pub entrants: Vec<EntrantTries>,
435    /// Every check, in the order given, and what it found.
436    pub checks: Vec<Judged>,
437    /// Whether a rate fell under its floor.
438    pub blocked: bool,
439}
440
441/// A check names an entrant that isn't in the run. Build it with
442/// [`UnknownEntrant::new`] (ADR-0039).
443#[derive(Debug, Clone, PartialEq, Eq)]
444#[non_exhaustive]
445pub struct UnknownEntrant {
446    /// The check's place in the list, from 0.
447    pub check: usize,
448    /// The name.
449    pub entrant: String,
450}
451
452impl UnknownEntrant {
453    /// Check `check` (from 0) names `entrant`.
454    pub fn new(check: usize, entrant: impl Into<String>) -> Self {
455        Self {
456            check,
457            entrant: entrant.into(),
458        }
459    }
460}
461
462impl fmt::Display for UnknownEntrant {
463    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464        write!(
465            f,
466            "check {} names `{}`, who isn't in the run",
467            self.check + 1,
468            self.entrant
469        )
470    }
471}
472
473/// Why a playtest stopped. More reasons may come (ADR-0039).
474#[non_exhaustive]
475pub enum PlaytestError<R: Sides> {
476    /// A check names an entrant that isn't in the run.
477    UnknownEntrant(UnknownEntrant),
478    /// A bot's command was refused: a bug in a bot, never skipped. Build
479    /// it with [`PlaytestError::play`].
480    #[non_exhaustive]
481    Play {
482        /// The entrant whose bot it was.
483        entrant: String,
484        /// The refusal.
485        error: PlayError<R>,
486    },
487}
488
489impl<R: Sides> PlaytestError<R> {
490    /// `entrant`'s bot's command was refused with `error`.
491    pub fn play(entrant: impl Into<String>, error: PlayError<R>) -> Self {
492        PlaytestError::Play {
493            entrant: entrant.into(),
494            error,
495        }
496    }
497}
498
499impl<R: Sides> From<UnknownEntrant> for PlaytestError<R> {
500    fn from(e: UnknownEntrant) -> Self {
501        PlaytestError::UnknownEntrant(e)
502    }
503}
504
505impl<R: Sides> fmt::Debug for PlaytestError<R>
506where
507    R::Command: fmt::Debug,
508    R::Error: fmt::Debug,
509{
510    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
511        match self {
512            PlaytestError::UnknownEntrant(e) => f.debug_tuple("UnknownEntrant").field(e).finish(),
513            PlaytestError::Play { entrant, error } => f
514                .debug_struct("Play")
515                .field("entrant", entrant)
516                .field("error", error)
517                .finish(),
518        }
519    }
520}
521
522impl<R: Sides> fmt::Display for PlaytestError<R>
523where
524    R::Command: fmt::Debug,
525    R::Error: fmt::Display,
526{
527    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
528        match self {
529            PlaytestError::UnknownEntrant(e) => e.fmt(f),
530            PlaytestError::Play { entrant, error } => write!(f, "{entrant}: {error}"),
531        }
532    }
533}
534
535/// The first check that names an entrant not in `names`.
536fn unknown_entrant(names: &[&str], checks: &[Check]) -> Option<UnknownEntrant> {
537    checks.iter().enumerate().find_map(|(i, check)| {
538        check
539            .entrants()
540            .into_iter()
541            .find(|name| !names.contains(name))
542            .map(|name| UnknownEntrant {
543                check: i,
544                entrant: name.to_string(),
545            })
546    })
547}
548
549/// `entrant` plays `stage` once from each of `seeds`, each try stopped at
550/// turn `turns`: its tries in seed order.
551pub fn play_tries<S, R: Sides>(
552    stage: &S,
553    entrant: &dyn Entrant<S, R>,
554    seeds: Range<u64>,
555    turns: u32,
556) -> Result<Vec<Tried>, PlayError<R>>
557where
558    R::Command: Clone,
559    R::Outcome: fmt::Debug,
560{
561    seeds
562        .map(|seed| tried(stage, entrant, seed, turns).map(|(tried, _)| tried))
563        .collect()
564}
565
566/// `entrant` plays `stage` once from `seed`, stopped at turn `turns`: the
567/// try, the commands applied, and the entrant's record of it
568/// ([`Entrant::record`]).
569pub fn play_try<S, R: Sides>(
570    stage: &S,
571    entrant: &dyn Entrant<S, R>,
572    seed: u64,
573    turns: u32,
574) -> Result<PlayedTry, PlayError<R>>
575where
576    R::Command: Clone,
577    R::Outcome: fmt::Debug,
578{
579    let (tried, report) = tried(stage, entrant, seed, turns)?;
580    Ok(PlayedTry {
581        tried,
582        commands: u32::try_from(report.commands.len()).unwrap_or(u32::MAX),
583        record: entrant.record(seed, &report.commands),
584    })
585}
586
587/// `entrant`'s try of `seed` on `stage`, and the play's report.
588fn tried<S, R: Sides>(
589    stage: &S,
590    entrant: &dyn Entrant<S, R>,
591    seed: u64,
592    turns: u32,
593) -> Result<(Tried, Report<R>), PlayError<R>>
594where
595    R::Command: Clone,
596    R::Outcome: fmt::Debug,
597{
598    let mut rules = entrant.start(stage, seed);
599    let mut bots = entrant.bots(seed);
600    let mut by_side: Vec<(R::Side, &mut dyn Bot<R>)> = bots
601        .iter_mut()
602        .map(|(side, bot)| (*side, bot.as_mut() as &mut dyn Bot<R>))
603        .collect();
604    let mut measure = entrant.measure();
605    let report = play_observed(&mut rules, &mut by_side, turns, |r, c, e| {
606        measure.observe(r, c, e);
607    })?;
608    let tried = Tried {
609        seed,
610        outcome: report.outcome.as_ref().map(|o| format!("{o:?}")),
611        turns: report.turns,
612        measures: measure.finish(&report, &rules),
613    };
614    Ok((tried, report))
615}
616
617/// The median of `values`: the lower middle one of an even count.
618fn median(mut values: Vec<i64>) -> Option<i64> {
619    values.sort_unstable();
620    let middle = values.len().checked_sub(1)? / 2;
621    values.get(middle).copied()
622}
623
624/// The median of `measure` over `tries` that meet `among` (a try missing
625/// the measure doesn't count).
626fn median_of(tries: &[Tried], measure: &str, among: &[Condition]) -> Option<i64> {
627    median(
628        tries
629            .iter()
630            .filter(|t| meets(among, &t.measures))
631            .filter_map(|t| t.measures.get(measure).copied())
632            .collect(),
633    )
634}
635
636/// The tries of every entrant (its name and its tries, in any seed
637/// order, in any number of parts) judged by `checks`.
638pub fn judge(
639    tries: Vec<(String, Vec<Tried>)>,
640    checks: &[Check],
641) -> Result<PlaytestReport, UnknownEntrant> {
642    let mut entrants: Vec<EntrantTries> = Vec::new();
643    for (name, mut part) in tries {
644        if let Some(e) = entrants.iter_mut().find(|e| e.name == name) {
645            e.tries.append(&mut part);
646        } else {
647            entrants.push(EntrantTries { name, tries: part });
648        }
649    }
650    for e in &mut entrants {
651        e.tries.sort_by_key(|t| t.seed);
652    }
653    let names: Vec<&str> = entrants.iter().map(|e| e.name.as_str()).collect();
654    if let Some(e) = unknown_entrant(&names, checks) {
655        return Err(e);
656    }
657    let of = |name: &str| -> &[Tried] {
658        entrants
659            .iter()
660            .find(|e| e.name == name)
661            .map_or(&[], |e| &e.tries)
662    };
663    let checks: Vec<Judged> = checks
664        .iter()
665        .map(|check| {
666            let found = match check {
667                Check::Rate {
668                    entrant,
669                    success,
670                    band,
671                    floor,
672                } => {
673                    let tries = of(entrant);
674                    let count = u32::try_from(tries.len()).unwrap_or(u32::MAX);
675                    let successes = tries.iter().filter(|t| meets(success, &t.measures)).count();
676                    let successes = u32::try_from(successes).unwrap_or(u32::MAX);
677                    let blocked = floor.is_some_and(|floor| {
678                        u64::from(successes) * 100 < u64::from(floor) * u64::from(count)
679                    });
680                    Found::Rate {
681                        successes,
682                        count,
683                        verdict: band.verdict(successes, count),
684                        blocked,
685                    }
686                }
687                Check::Median {
688                    entrant,
689                    measure,
690                    among,
691                    want,
692                    than,
693                } => {
694                    let median = median_of(of(entrant), measure, among);
695                    let other = median_of(of(than), measure, among);
696                    let verdict = match (median, other, want) {
697                        (Some(m), Some(o), Want::Lower) if m < o => MedianVerdict::Pass,
698                        (Some(m), Some(o), Want::Higher) if m > o => MedianVerdict::Pass,
699                        (Some(_), Some(_), _) => MedianVerdict::Fail,
700                        _ => MedianVerdict::Unknown,
701                    };
702                    Found::Median {
703                        median,
704                        than: other,
705                        verdict,
706                    }
707                }
708            };
709            Judged {
710                check: check.clone(),
711                found,
712            }
713        })
714        .collect();
715    let blocked = checks
716        .iter()
717        .any(|j| matches!(j.found, Found::Rate { blocked: true, .. }));
718    Ok(PlaytestReport {
719        entrants,
720        checks,
721        blocked,
722    })
723}
724
725/// Every one of `entrants` plays `stage` as `tries` says, and `checks`
726/// judge their tries. A check naming an entrant not in the run fails
727/// before any try is played; a refused command stops the run.
728pub fn run<S, R: Sides>(
729    stage: &S,
730    entrants: &[&dyn Entrant<S, R>],
731    tries: &Tries,
732    checks: &[Check],
733) -> Result<PlaytestReport, PlaytestError<R>>
734where
735    R::Command: Clone,
736    R::Outcome: fmt::Debug,
737{
738    let names: Vec<&str> = entrants.iter().map(|e| e.name()).collect();
739    if let Some(e) = unknown_entrant(&names, checks) {
740        return Err(e.into());
741    }
742    let mut played = Vec::new();
743    for entrant in entrants {
744        let name = entrant.name().to_string();
745        match play_tries(stage, *entrant, tries.seeds(), tries.turns) {
746            Ok(t) => played.push((name, t)),
747            Err(error) => {
748                return Err(PlaytestError::Play {
749                    entrant: name,
750                    error,
751                });
752            }
753        }
754    }
755    Ok(judge(played, checks)?)
756}
757
758/// `n` or a dash.
759fn or_dash(n: Option<i64>) -> String {
760    n.map_or_else(|| "–".to_string(), |n| n.to_string())
761}
762
763impl PlaytestReport {
764    /// A check's line: what it measured, and how it went.
765    fn line(judged: &Judged, width: usize) -> (String, String) {
766        match (&judged.check, &judged.found) {
767            (
768                Check::Rate {
769                    entrant,
770                    band,
771                    floor,
772                    ..
773                },
774                Found::Rate {
775                    successes,
776                    count,
777                    verdict,
778                    blocked,
779                },
780            ) => {
781                let mark = match verdict {
782                    RateVerdict::Within => "✓",
783                    RateVerdict::Below => "✗ too hard",
784                    RateVerdict::Above => "⚠ too easy",
785                };
786                let mut how = format!("band {}–{}%   {mark}", band.min, band.max);
787                if *blocked {
788                    // Writing to a `String` can't fail.
789                    let _ = write!(how, " · blocked: under the {}% floor", floor.unwrap_or(0));
790                }
791                (
792                    format!("{entrant:<width$} · {successes}/{count} succeed"),
793                    how,
794                )
795            }
796            (
797                Check::Median {
798                    entrant,
799                    measure,
800                    want,
801                    than: other,
802                    ..
803                },
804                Found::Median {
805                    median,
806                    than,
807                    verdict,
808                },
809            ) => {
810                let mark = match verdict {
811                    MedianVerdict::Pass => "✓",
812                    MedianVerdict::Fail => "✗",
813                    MedianVerdict::Unknown => "–",
814                };
815                let want = match want {
816                    Want::Lower => "lower",
817                    Want::Higher => "higher",
818                };
819                (
820                    format!(
821                        "{entrant:<width$} · {measure} median {} ({other} {})",
822                        or_dash(*median),
823                        or_dash(*than)
824                    ),
825                    format!("{want} {mark}"),
826                )
827            }
828            // A check with another check's numbers: only a hand-made report.
829            (check, found) => (format!("{check:?}"), format!("{found:?}")),
830        }
831    }
832
833    /// The table without its line per try: how many tries from which
834    /// seeds, and a line per check.
835    pub fn brief(&self) -> String {
836        let mut out = String::new();
837        // Writing to a `String` can't fail.
838        let _ = self.write_table(&mut out, false);
839        out
840    }
841
842    /// The table into `f`, with every try's line if `tries`.
843    fn write_table(&self, f: &mut impl fmt::Write, tries: bool) -> fmt::Result {
844        let first = self.entrants.first().map_or(&[][..], |e| &e.tries);
845        match (first.first(), first.last()) {
846            (Some(a), Some(b)) => {
847                writeln!(f, "{} tries (seeds {}–{})", first.len(), a.seed, b.seed)?;
848            }
849            _ => writeln!(f, "0 tries")?,
850        }
851        let width = self
852            .checks
853            .iter()
854            .map(|j| match &j.check {
855                Check::Rate { entrant, .. } | Check::Median { entrant, .. } => {
856                    entrant.chars().count()
857                }
858            })
859            .max()
860            .unwrap_or(0);
861        let lines: Vec<(String, String)> =
862            self.checks.iter().map(|j| Self::line(j, width)).collect();
863        let left = lines
864            .iter()
865            .map(|(l, _)| l.chars().count())
866            .max()
867            .unwrap_or(0);
868        if !lines.is_empty() {
869            writeln!(f)?;
870        }
871        for (what, how) in &lines {
872            let pad = left - what.chars().count() + 4;
873            writeln!(f, "{what}{:pad$}{how}", "")?;
874        }
875        if !tries {
876            return Ok(());
877        }
878        if self.entrants.iter().any(|e| !e.tries.is_empty()) {
879            writeln!(f)?;
880        }
881        for entrant in &self.entrants {
882            for t in &entrant.tries {
883                write!(
884                    f,
885                    "try {} · {} · {} · turns {}",
886                    t.seed,
887                    entrant.name,
888                    t.outcome.as_deref().unwrap_or(UNFINISHED),
889                    t.turns
890                )?;
891                for (name, value) in &t.measures {
892                    write!(f, " · {name} {value}")?;
893                }
894                writeln!(f)?;
895            }
896        }
897        Ok(())
898    }
899}
900
901/// The table: how many tries from which seeds, a line per check, then
902/// every try of every entrant.
903impl fmt::Display for PlaytestReport {
904    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
905        self.write_table(f, true)
906    }
907}
908
909#[cfg(test)]
910mod tests;