Skip to main content

shuyi_cli/contract/
playtest.rs

1//! `playtest`: the game's named bots (its entrants) each play its stage K
2//! times from seeds `seed`, `seed + 1`, …, the game measures every try,
3//! and its checks say per bot whether it succeeded as often as wanted
4//! (`shuyi_core::bots::playtest`). The game gives the plan
5//! ([`GameTools::playtest_plan`]): its stages, its entrants and their
6//! groups, its checks and how one try is played. This plays the tries on
7//! every core ([`crate::pool`]), times them, and judges them once in seed
8//! order, so the report never depends on the thread count. The answer
9//! passes unless a rate fell under its floor.
10//!
11//! Each entrant's line carries the plan's summary items ([`Summary`]),
12//! and [`kept`] keeps every run in a history and says what the last run
13//! of the same stage, entrants and options found.
14//!
15//! Each try keeps its commands and its record for `replay`, if the game
16//! gives one ([`TryKept`]), and each entrant its outcomes counted and the
17//! spread of its turns and measures. A plan with no checks is a plain bot
18//! run: nothing is judged, and it passes when every try played.
19
20use std::collections::{BTreeMap, BTreeSet};
21use std::fmt::Write as _;
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27use shuyi_core::bots::playtest::{
28    Check, Found, PlayedTry, PlaytestReport, Tried, Tries, UNFINISHED, judge,
29};
30
31use super::codes::Code;
32use super::{Answer, Op, codes};
33use crate::game::{
34    GameArg, GameOption, GameTools, PlaytestChecks, PlaytestPlan, check_start, code_of, game_arg,
35};
36use crate::pool;
37
38mod history;
39mod summary;
40
41pub use summary::Summary;
42
43/// Where the history is kept when the input doesn't say, from the
44/// project's folder.
45pub const DEFAULT_HISTORY: &str = "target/playtest-history";
46
47/// The tries when neither the input nor the game says: 100 from seed 0,
48/// each stopped at turn 200.
49pub const DEFAULT_TRIES: Tries = Tries::new(100, 0, 200);
50
51/// The most tries of each entrant one `playtest` plays.
52pub const MAX_TRIES: u32 = 10_000;
53
54/// What `playtest` plays. A number left out is the game's default
55/// ([`GameTools::playtest_defaults`]).
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57#[serde(deny_unknown_fields, default)]
58// An input: built with `default()` and its fields assigned, it may grow
59// (ADR-0039).
60#[non_exhaustive]
61pub struct PlaytestInput {
62    /// The stage, one of the game's; needed when it has several, unless
63    /// `every_stage`.
64    pub stage: Option<String>,
65    /// Every stage of the game, in its order, a report each.
66    pub every_stage: bool,
67    /// The entrants that play, by name or by a group's name; the game's
68    /// default ones if empty.
69    pub entrants: Vec<String>,
70    /// How many tries of each entrant.
71    pub tries: Option<u32>,
72    /// The first try's seed; each next try takes the next.
73    pub seed: Option<u64>,
74    /// The turn a try stops at if it hasn't ended.
75    pub turns: Option<u32>,
76    /// The threads to play on; one a core if left out. The report never
77    /// depends on it.
78    pub threads: Option<u32>,
79    /// The game's own options, in the order given.
80    pub start: Vec<GameArg>,
81    /// The folder the history is kept in, from the project's
82    /// ([`DEFAULT_HISTORY`] if left out); only [`kept`] keeps one.
83    pub history: Option<String>,
84    /// Keep no history, and compare with none.
85    pub no_history: bool,
86}
87
88impl PlaytestInput {
89    /// The tries it asks for, `defaults` filling in what it leaves out.
90    pub fn tries(&self, defaults: &Tries) -> Tries {
91        Tries::new(
92            self.tries.unwrap_or(defaults.count),
93            self.seed.unwrap_or(defaults.seed),
94            self.turns.unwrap_or(defaults.turns),
95        )
96    }
97}
98
99/// How one entrant's tries went, besides the report: its label, its
100/// speed, its summary and the last run's headline.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
102#[serde(deny_unknown_fields)]
103#[non_exhaustive]
104pub struct Played {
105    /// Its name.
106    pub name: String,
107    /// Its label in the game's plan (a mode), if any.
108    pub label: Option<String>,
109    /// The wall time of a try, on average, in seconds: the one number
110    /// that changes with the machine and the thread count.
111    pub seconds_per_try: f64,
112    /// The plan's summary items over its tries, in order: `won 83/100`.
113    #[serde(default)]
114    pub summary: Vec<String>,
115    /// Its headline number in the last run, if it played there:
116    /// `was 47/100 succeed` from its first rate check, else its first
117    /// summary item, `was won 63/100`.
118    #[serde(default)]
119    pub was: Option<String>,
120    /// How many of its tries ended each way, by the outcome's name (its
121    /// `Debug` up to its fields: `Won` for `Won { turn: 12 }`), and
122    /// [`UNFINISHED`] for those the turn limit stopped.
123    #[serde(default)]
124    pub outcomes: BTreeMap<String, u32>,
125    /// How many turns its tries took.
126    #[serde(default)]
127    pub turns: Option<Spread>,
128    /// The game's measures over its tries, by name.
129    #[serde(default)]
130    pub measures: BTreeMap<String, Spread>,
131    /// Each try's commands and record, in seed order.
132    #[serde(default)]
133    pub tries: Vec<TryKept>,
134}
135
136/// What the answer keeps of one try besides the report's line: how many
137/// commands were applied, and its record.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(deny_unknown_fields)]
140#[non_exhaustive]
141pub struct TryKept {
142    /// Its seed.
143    pub seed: u64,
144    /// The commands applied.
145    pub commands: u32,
146    /// Its record, in the game's record format, for `replay`; left out if
147    /// the game's plan keeps none.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub record: Option<String>,
150}
151
152/// How a number spread over an entrant's tries.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields)]
155pub struct Spread {
156    /// The smallest.
157    pub min: i64,
158    /// The largest.
159    pub max: i64,
160    /// The sum.
161    pub total: i64,
162    /// The mean (the sum over the tries that have it).
163    pub mean: f64,
164}
165
166impl Spread {
167    /// The spread of `values`, if there are any.
168    pub fn of(values: &[i64]) -> Option<Self> {
169        let (&min, &max) = (values.iter().min()?, values.iter().max()?);
170        let total: i64 = values.iter().sum();
171        #[allow(clippy::cast_precision_loss)] // a report, not a simulation
172        let mean = total as f64 / values.len() as f64;
173        Some(Self {
174            min,
175            max,
176            total,
177            mean,
178        })
179    }
180}
181
182/// The last run before this one, from the history.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184#[serde(deny_unknown_fields)]
185#[non_exhaustive]
186pub struct Previous {
187    /// When it ran: `2026-10-22 14:05 UTC`.
188    pub date: String,
189    /// The short hash of the commit the project was on, or `unknown`.
190    pub commit: String,
191}
192
193/// What `playtest` found on one stage.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
195#[serde(deny_unknown_fields)]
196#[non_exhaustive]
197pub struct PlaytestOutput {
198    /// The stage, if the game names its stages.
199    pub stage: Option<String>,
200    /// The plan's title.
201    pub title: String,
202    /// The tries and the checks' verdicts.
203    pub report: PlaytestReport,
204    /// Every entrant that played, in report order.
205    pub entrants: Vec<Played>,
206    /// The last run of the same stage, entrants and game options, if the
207    /// history has one.
208    #[serde(default)]
209    pub previous: Option<Previous>,
210}
211
212/// `playtest`: `input`'s tries of `game`'s plan, played on threads and
213/// judged; a report a stage, one unless `every_stage`. It keeps no
214/// history: [`kept`] does.
215pub fn playtest(game: &dyn GameTools, input: &PlaytestInput) -> Answer<Vec<PlaytestOutput>> {
216    let failed = |code: Code, error: String| Answer::failed(Op::Playtest, code, error);
217    if let Err(e) = check_start(game.frame_options(), &input.start) {
218        return failed(codes::WRONG_ARGUMENTS, e);
219    }
220    let tries = input.tries(&game.playtest_defaults());
221    if let Err(e) = check_tries(&tries) {
222        return failed(codes::WRONG_ARGUMENTS, e);
223    }
224    let threads = match threads(input.threads) {
225        Ok(threads) => threads,
226        Err(e) => return failed(codes::WRONG_ARGUMENTS, e),
227    };
228    let stages = match stages(
229        &game.playtest_stages(),
230        input.stage.as_deref(),
231        input.every_stage,
232    ) {
233        Ok(stages) => stages,
234        Err(e) => return failed(codes::WRONG_ARGUMENTS, e),
235    };
236    let given = PlaytestInput {
237        tries: Some(tries.count),
238        seed: Some(tries.seed),
239        turns: Some(tries.turns),
240        ..input.clone()
241    };
242    let mut outputs = Vec::new();
243    let mut limits = Vec::new();
244    for stage in stages {
245        let plan = match game.playtest_plan(stage.as_deref(), &given) {
246            Ok(plan) => plan,
247            Err(e) => return failed(code_of(&e), e),
248        };
249        let chosen = match choose(
250            &plan.entrants,
251            &plan.groups,
252            &plan.default_entrants,
253            &input.entrants,
254        ) {
255            Ok(chosen) => chosen,
256            Err((code, e)) => return failed(code, e),
257        };
258        let mut output = match play(&plan, &chosen, &tries, threads) {
259            Ok(report) => report,
260            Err(e) => return failed(codes::GAME_ERROR, e),
261        };
262        summarize(&plan, &mut output);
263        let unfinished = output
264            .report
265            .entrants
266            .iter()
267            .flat_map(|e| &e.tries)
268            .filter(|t| t.outcome.is_none())
269            .count();
270        if unfinished > 0 {
271            let on = stage.as_ref().map(|s| format!("{s}: ")).unwrap_or_default();
272            limits.push(format!(
273                "{on}{unfinished} of the tries reached turn {} without an end: their outcome is unknown",
274                tries.turns
275            ));
276        }
277        outputs.push(PlaytestOutput {
278            stage,
279            title: plan.title,
280            report: output.report,
281            entrants: output.entrants,
282            previous: None,
283        });
284    }
285    let ok = outputs.iter().all(|o| !o.report.blocked);
286    Answer::new(Op::Playtest, ok, outputs, limits)
287}
288
289/// Each entrant's summary items of `plan` over its tries.
290fn summarize(plan: &PlaytestPlan, stage: &mut StagePlayed) {
291    for (played, tries) in stage.entrants.iter_mut().zip(&stage.report.entrants) {
292        let at = plan.entrants.iter().position(|(n, _)| *n == played.name);
293        let items = at.and_then(|at| plan.summary.get(at));
294        played.summary = items
295            .into_iter()
296            .flatten()
297            .map(|item| item.text(&tries.tries))
298            .collect();
299    }
300}
301
302/// [`playtest`], each stage's run kept in the history under `root`
303/// (unless `no_history`) and compared with the last one kept there: each
304/// entrant's [`was`](Played::was) and the stage's
305/// [`previous`](PlaytestOutput::previous). A history that can't be
306/// written fails the answer, naming the file.
307pub fn kept(
308    game: &dyn GameTools,
309    root: &Path,
310    input: &PlaytestInput,
311) -> Answer<Vec<PlaytestOutput>> {
312    let mut answer = playtest(game, input);
313    if input.no_history {
314        return answer;
315    }
316    let Some(outputs) = answer.result.as_mut() else {
317        return answer;
318    };
319    let dir = root.join(input.history.as_deref().unwrap_or(DEFAULT_HISTORY));
320    let date = history::utc_stamp(history::now());
321    let commit = history::git_commit(root);
322    let options: Vec<String> = input.start.iter().map(history::option).collect();
323    let made = game.made();
324    let mut unwritten = None;
325    for output in outputs.iter_mut() {
326        let names: Vec<String> = output.entrants.iter().map(|p| p.name.clone()).collect();
327        let file = dir.join(history::file_name(
328            output.stage.as_deref(),
329            &names,
330            &options,
331        ));
332        if let Some(last) = history::last(&file) {
333            compare(output, &last);
334        }
335        let record = history::Record {
336            date: date.clone(),
337            commit: commit.clone(),
338            title: output.title.clone(),
339            stage: output.stage.clone(),
340            entrants: names,
341            options: options.clone(),
342            made: made.clone(),
343            output: without_tries(output),
344        };
345        if let Err(e) = history::append(&file, &record) {
346            unwritten.get_or_insert(format!("history {}: {e}", file.display()));
347        }
348    }
349    if let Some(error) = unwritten {
350        answer.ok = false;
351        answer.error = Some(error);
352        answer = answer.with_code(codes::FILE);
353    }
354    answer
355}
356
357/// `output` as the history keeps it: without each try's commands and
358/// record, which the next run never compares.
359fn without_tries(output: &PlaytestOutput) -> PlaytestOutput {
360    let mut kept = output.clone();
361    for played in &mut kept.entrants {
362        played.tries.clear();
363    }
364    kept
365}
366
367/// `output` against the `last` run kept: its date and commit, and each
368/// entrant's headline there.
369fn compare(output: &mut PlaytestOutput, last: &history::Record) {
370    output.previous = Some(Previous {
371        date: last.date.clone(),
372        commit: last.commit.clone(),
373    });
374    let was: Vec<Option<String>> = output
375        .entrants
376        .iter()
377        .map(|p| was(output, &last.output, &p.name))
378        .collect();
379    for (played, was) in output.entrants.iter_mut().zip(was) {
380        played.was = was;
381    }
382}
383
384/// `name`'s headline in `previous`, if it played there: its first rate
385/// check's successes if it has a rate check `now`, else its first summary
386/// item.
387fn was(now: &PlaytestOutput, previous: &PlaytestOutput, name: &str) -> Option<String> {
388    let played = previous.entrants.iter().find(|p| p.name == name)?;
389    let rate = |output: &PlaytestOutput| {
390        output
391            .report
392            .checks
393            .iter()
394            .find_map(|j| match (&j.check, &j.found) {
395                (
396                    Check::Rate { entrant, .. },
397                    Found::Rate {
398                        successes, count, ..
399                    },
400                ) if entrant == name => Some((*successes, *count)),
401                _ => None,
402            })
403    };
404    if rate(now).is_some() {
405        return rate(previous).map(|(s, n)| format!("was {s}/{n} succeed"));
406    }
407    played.summary.first().map(|item| format!("was {item}"))
408}
409
410/// Whether `tries` can be played: 1 to [`MAX_TRIES`] of them, each seed
411/// a `u64`.
412pub(crate) fn check_tries(tries: &Tries) -> Result<(), String> {
413    if tries.count == 0 || tries.count > MAX_TRIES {
414        return Err(format!("tries: 1 to {MAX_TRIES}, not {}", tries.count));
415    }
416    if tries.seed.checked_add(u64::from(tries.count)).is_none() {
417        return Err("seed: too big for that many tries".to_owned());
418    }
419    Ok(())
420}
421
422/// The threads `asked` for, or one a core if `None`.
423pub(crate) fn threads(asked: Option<u32>) -> Result<usize, String> {
424    match asked {
425        Some(0) => Err("threads: at least 1".to_owned()),
426        Some(n) => Ok(usize::try_from(n).unwrap_or(usize::MAX)),
427        None => Ok(pool::cores()),
428    }
429}
430
431/// The stages to play of the game's `stages` (`None`: the one unnamed
432/// stage): the one named `stage`, or each if `every_stage`; or why it
433/// names none.
434pub(crate) fn stages(
435    stages: &[String],
436    stage: Option<&str>,
437    every_stage: bool,
438) -> Result<Vec<Option<String>>, String> {
439    let list = || stages.join(", ");
440    match (stage, every_stage) {
441        (Some(_), true) => Err("stage or every stage, not both".to_owned()),
442        (Some(stage), false) if stages.is_empty() => {
443            Err(format!("stage: the game has no stages, not {stage}"))
444        }
445        (Some(stage), false) if stages.iter().any(|s| s == stage) => {
446            Ok(vec![Some(stage.to_owned())])
447        }
448        (Some(stage), false) => Err(format!("stage: no {stage}; the stages: {}", list())),
449        (None, _) if stages.is_empty() => Ok(vec![None]),
450        (None, false) if stages.len() > 1 => Err(format!(
451            "stage: the game has {} stages, name one or play every stage: {}",
452            stages.len(),
453            list()
454        )),
455        (None, _) => Ok(stages.iter().cloned().map(Some).collect()),
456    }
457}
458
459/// The indexes of a plan's `entrants` that `asked` names (by name or by
460/// one of its `groups`, in report order), or its `defaults` if `asked` is
461/// empty, or every one if it has none. A name that is neither is the
462/// asker's error; a group or default naming no entrant is the game's.
463pub(crate) fn choose(
464    entrants: &[(String, Option<String>)],
465    groups: &BTreeMap<String, Vec<String>>,
466    defaults: &[String],
467    asked: &[String],
468) -> Result<Vec<usize>, (Code, String)> {
469    let index = |name: &str| entrants.iter().position(|(n, _)| n == name);
470    let (names, code): (&[String], Code) = if asked.is_empty() {
471        (defaults, codes::GAME_ERROR)
472    } else {
473        (asked, codes::WRONG_ARGUMENTS)
474    };
475    if names.is_empty() {
476        return Ok((0..entrants.len()).collect());
477    }
478    let mut chosen = BTreeSet::new();
479    for name in names {
480        if let Some(group) = groups.get(name) {
481            for member in group {
482                let e = index(member).ok_or_else(|| {
483                    let error = format!("the group {name} names {member}, who isn't an entrant");
484                    (codes::GAME_ERROR, error)
485                })?;
486                chosen.insert(e);
487            }
488        } else if let Some(e) = index(name) {
489            chosen.insert(e);
490        } else {
491            let mut error = format!(
492                "entrant: no {name}; the entrants: {}",
493                entrants
494                    .iter()
495                    .map(|(n, _)| n.as_str())
496                    .collect::<Vec<_>>()
497                    .join(", ")
498            );
499            if !groups.is_empty() {
500                let groups: Vec<&str> = groups.keys().map(String::as_str).collect();
501                let _ = write!(error, "; the groups: {}", groups.join(", "));
502            }
503            return Err((code, error));
504        }
505    }
506    Ok(chosen.into_iter().collect())
507}
508
509/// One stage's report, and each entrant's speed, tally and tries kept.
510pub(crate) struct StagePlayed {
511    /// The tries, judged.
512    pub(crate) report: PlaytestReport,
513    /// Each entrant's speed, tally and tries kept.
514    pub(crate) entrants: Vec<Played>,
515}
516
517/// The `chosen` entrants of `plan` play `tries` on `threads` threads,
518/// judged by the plan's checks over them.
519fn play(
520    plan: &PlaytestPlan,
521    chosen: &[usize],
522    tries: &Tries,
523    threads: usize,
524) -> Result<StagePlayed, String> {
525    let checks = checks_of(&plan.checks, &plan.entrants, chosen)?;
526    played(&plan.entrants, chosen, &checks, tries, threads, &*plan.play)
527}
528
529/// A plan's `checks` over the `chosen` of its `entrants`, or why they
530/// can't judge them: a check names an entrant who doesn't play.
531pub(crate) fn checks_of(
532    checks: &PlaytestChecks,
533    entrants: &[(String, Option<String>)],
534    chosen: &[usize],
535) -> Result<Vec<Check>, String> {
536    let names: Vec<&str> = chosen.iter().map(|&e| entrants[e].0.as_str()).collect();
537    let checks = checks(&names);
538    // Judging no tries says so, before a try is played.
539    let none = names
540        .iter()
541        .map(|n| ((*n).to_owned(), Vec::new()))
542        .collect();
543    judge(none, &checks).map_err(|e| format!("the game's checks: {e}"))?;
544    Ok(checks)
545}
546
547/// The `chosen` of a plan's `entrants` play `tries` on `threads` threads,
548/// each try by `play` (entrant index, seed, the turn it stops at), timed,
549/// and `checks` judge them once in seed order.
550pub(crate) fn played(
551    entrants: &[(String, Option<String>)],
552    chosen: &[usize],
553    checks: &[Check],
554    tries: &Tries,
555    threads: usize,
556    play: &(dyn Fn(usize, u64, u32) -> Result<PlayedTry, String> + Sync),
557) -> Result<StagePlayed, String> {
558    let work: Vec<(usize, u64)> = chosen
559        .iter()
560        .flat_map(|&e| tries.seeds().map(move |seed| (e, seed)))
561        .collect();
562    let one = |&(e, seed): &(usize, u64)| {
563        play(e, seed, tries.turns)
564            .map_err(|error| format!("{}: try {seed}: {error}", entrants[e].0))
565    };
566    let done = pool::run(&work, threads, &one)?;
567    let mut parts: Vec<(String, Vec<Tried>)> = chosen
568        .iter()
569        .map(|&e| (entrants[e].0.clone(), Vec::new()))
570        .collect();
571    let mut took = vec![Duration::ZERO; chosen.len()];
572    let mut kept: Vec<Vec<TryKept>> = vec![Vec::new(); chosen.len()];
573    for ((e, _), (one, elapsed)) in work.iter().zip(done) {
574        let at = chosen.iter().position(|c| c == e).unwrap_or_default();
575        kept[at].push(TryKept {
576            seed: one.tried.seed,
577            commands: one.commands,
578            record: one.record,
579        });
580        parts[at].1.push(one.tried);
581        took[at] += elapsed;
582    }
583    let report = judge(parts, checks).map_err(|e| format!("the game's checks: {e}"))?;
584    let entrants = chosen
585        .iter()
586        .zip(took)
587        .zip(kept)
588        .zip(&report.entrants)
589        .map(|(((&e, took), kept), judged)| {
590            let mut played = Played {
591                name: entrants[e].0.clone(),
592                label: entrants[e].1.clone(),
593                seconds_per_try: took.as_secs_f64() / f64::from(tries.count),
594                summary: Vec::new(),
595                was: None,
596                outcomes: BTreeMap::new(),
597                turns: None,
598                measures: BTreeMap::new(),
599                tries: kept,
600            };
601            tally(&mut played, &judged.tries);
602            played
603        })
604        .collect();
605    Ok(StagePlayed { report, entrants })
606}
607
608/// The name an outcome is counted by: its `Debug` up to its fields
609/// (`Won` for `Won { turn: 12 }`), or [`UNFINISHED`].
610fn outcome_name(outcome: Option<&str>) -> &str {
611    outcome.map_or(UNFINISHED, |o| {
612        o.split([' ', '{', '(']).next().unwrap_or_default()
613    })
614}
615
616/// `tries` counted by outcome into `played`, and the spread of their turns
617/// and of each measure.
618fn tally(played: &mut Played, tries: &[Tried]) {
619    let mut measures: BTreeMap<&str, Vec<i64>> = BTreeMap::new();
620    for t in tries {
621        let name = outcome_name(t.outcome.as_deref()).to_owned();
622        *played.outcomes.entry(name).or_insert(0) += 1;
623        for (name, &value) in &t.measures {
624            measures.entry(name).or_default().push(value);
625        }
626    }
627    let turns: Vec<i64> = tries.iter().map(|t| i64::from(t.turns)).collect();
628    played.turns = Spread::of(&turns);
629    played.measures = measures
630        .into_iter()
631        .filter_map(|(name, values)| Some((name.to_owned(), Spread::of(&values)?)))
632        .collect();
633}
634
635/// `playtest`'s own options, for its usage.
636const OWN: [GameOption; 11] = [
637    GameOption {
638        flag: "--stage",
639        value: Some("ID"),
640        help: "the stage; needed when the game has several",
641    },
642    GameOption {
643        flag: "--every-stage",
644        value: None,
645        help: "every stage, in order, a report each",
646    },
647    GameOption {
648        flag: "--entrant",
649        value: Some("NAME"),
650        help: "an entrant or a group, again for more (the game's choice if none)",
651    },
652    GameOption {
653        flag: "--tries",
654        value: Some("N"),
655        help: "the tries of each entrant, 1 to 10000",
656    },
657    GameOption {
658        flag: "--seed",
659        value: Some("N"),
660        help: "the first try's seed; each next try takes the next",
661    },
662    GameOption {
663        flag: "--turns",
664        value: Some("N"),
665        help: "the turn a try stops at if it hasn't ended",
666    },
667    GameOption {
668        flag: "--threads",
669        value: Some("N"),
670        help: "the threads to play on (one a core; the report never changes)",
671    },
672    GameOption {
673        flag: "--report",
674        value: Some("FILE"),
675        help: "also write the JSON answer to FILE",
676    },
677    GameOption {
678        flag: "--history",
679        value: Some("DIR"),
680        help: "keep the history in DIR (target/playtest-history)",
681    },
682    GameOption {
683        flag: "--no-history",
684        value: None,
685        help: "keep no history, and compare with none",
686    },
687    GameOption {
688        flag: "--brief",
689        value: None,
690        help: "leave out the line per try",
691    },
692];
693
694/// The game's start `options`, a line each, for a usage.
695pub fn options_text(options: &[GameOption]) -> String {
696    let mut out = String::new();
697    for option in options {
698        let name = match option.value {
699            Some(value) => format!("{} {value}", option.flag),
700            None => option.flag.to_owned(),
701        };
702        // Writing to a `String` can't fail.
703        let _ = writeln!(
704            out,
705            "  {name:<20} {}",
706            option.help.lines().next().unwrap_or_default()
707        );
708    }
709    out
710}
711
712/// `playtest`'s usage, with the game's start `options`.
713pub fn usage(command: &str, options: &[GameOption]) -> String {
714    let mut out = format!(
715        "usage: {command} playtest [--stage ID | --every-stage] [--entrant NAME]... [--tries N]\n\
716         \x20      [--seed N] [--turns N] [--threads N] [--report FILE]\n\
717         \x20      [--history DIR | --no-history] [--brief] [options] [--json]\n\n\
718         Each of the game's bots (its entrants) plays --tries tries, seeded N, N + 1, …,\n\
719         each stopped at turn --turns, and the game's checks judge them. A number not\n\
720         given is the game's: {} tries from seed {} to turn {} unless it says. It fails\n\
721         only when a bot succeeds less often than a check's floor; with no checks, a\n\
722         plain bot run, it judges nothing and passes when every try played. Each run is\n\
723         kept in the history, a file per stage, entrants and options, and each bot's line\n\
724         says what the last run there found (was …). The JSON answer has each try's\n\
725         commands and record for replay, if the game keeps one, and each bot's outcomes\n\
726         counted and the spread of its turns and measures.\n\n",
727        DEFAULT_TRIES.count, DEFAULT_TRIES.seed, DEFAULT_TRIES.turns
728    );
729    out.push_str(&options_text(&OWN));
730    out.push_str(&options_text(options));
731    out
732}
733
734/// What `playtest`'s arguments ask for: the input, the file to write the
735/// JSON answer to, if any, and whether the text is brief.
736#[derive(Debug, Clone, Default, PartialEq, Eq)]
737#[non_exhaustive]
738pub struct PlaytestArgs {
739    /// The input.
740    pub input: PlaytestInput,
741    /// `--report`'s file, from the project's folder.
742    pub report: Option<PathBuf>,
743    /// `--brief`: the text without the line per try ([`brief`]).
744    pub brief: bool,
745}
746
747/// Reads `playtest`'s arguments (not `--json`).
748pub fn parse_args(args: &[String], options: &[GameOption]) -> Result<PlaytestArgs, String> {
749    let mut out = PlaytestArgs::default();
750    let input = &mut out.input;
751    let mut iter = args.iter();
752    while let Some(arg) = iter.next() {
753        let mut value = || {
754            iter.next()
755                .ok_or_else(|| format!("{arg} needs a value"))
756                .cloned()
757        };
758        let number = |text: &str| {
759            text.parse::<u64>()
760                .map_err(|_| format!("{arg}: not a whole number: {text}"))
761        };
762        let small = |n: u64| u32::try_from(n).map_err(|_| format!("{arg}: too big"));
763        match arg.as_str() {
764            "--stage" => input.stage = Some(value()?),
765            "--every-stage" => input.every_stage = true,
766            "--entrant" => input.entrants.push(value()?),
767            "--seed" => input.seed = Some(number(&value()?)?),
768            "--tries" => input.tries = Some(small(number(&value()?)?)?),
769            "--turns" => input.turns = Some(small(number(&value()?)?)?),
770            "--threads" => input.threads = Some(small(number(&value()?)?)?),
771            "--report" => out.report = Some(PathBuf::from(value()?)),
772            "--history" => input.history = Some(value()?),
773            "--no-history" => input.no_history = true,
774            "--brief" => out.brief = true,
775            flag if flag.starts_with("--") => input.start.push(game_arg(flag, options, value)?),
776            extra => return Err(format!("no <games> here, only flags: {extra}")),
777        }
778    }
779    Ok(out)
780}
781
782/// A try's wall time for people: two decimals, or under a hundredth.
783pub(crate) fn seconds(s: f64) -> String {
784    if s < 0.01 {
785        "< 0.01".to_owned()
786    } else {
787        format!("{s:.2}")
788    }
789}
790
791/// The width an entrant's line wraps past.
792const LINE: usize = 100;
793
794/// What `playtest` found, for people: for each stage, the plan's title,
795/// the report's table, then a line per entrant with its label, its
796/// summary, its speed and what it was in the last run, and that run's
797/// date and commit. A stage with no checks, a plain bot run, then has
798/// each entrant's outcomes counted and the spread of its turns and
799/// measures.
800pub fn text(outputs: &[PlaytestOutput]) -> String {
801    render(outputs, false)
802}
803
804/// [`text`] without the table's line per try (`--brief`).
805pub fn brief(outputs: &[PlaytestOutput]) -> String {
806    render(outputs, true)
807}
808
809/// [`text`], or [`brief`] if `brief`.
810fn render(outputs: &[PlaytestOutput], brief: bool) -> String {
811    let stages: Vec<String> = outputs
812        .iter()
813        .map(|output| {
814            let mut out = String::new();
815            if !output.title.is_empty() {
816                // Writing to a `String` can't fail.
817                let _ = writeln!(out, "{}\n", output.title);
818            }
819            if brief {
820                out.push_str(&output.report.brief());
821            } else {
822                out.push_str(&output.report.to_string());
823            }
824            out.push('\n');
825            let width = output
826                .entrants
827                .iter()
828                .map(|p| p.name.chars().count())
829                .max()
830                .unwrap_or(0);
831            for played in &output.entrants {
832                out.push_str(&entrant_line(played, width));
833            }
834            if output.report.checks.is_empty() {
835                let several = output.entrants.len() > 1;
836                for played in &output.entrants {
837                    let name = several.then_some((played.name.as_str(), width));
838                    out.push_str(&tally_lines(played, name));
839                }
840            }
841            if let Some(previous) = &output.previous {
842                let _ = writeln!(
843                    out,
844                    "previous {} · commit {}",
845                    previous.date, previous.commit
846                );
847            }
848            out
849        })
850        .collect();
851    stages.join("\n")
852}
853
854/// `played`'s outcomes counted, then the spread of its turns and of each
855/// measure, a line each; each line starts with its name padded to the
856/// width given, if one is.
857fn tally_lines(played: &Played, name: Option<(&str, usize)>) -> String {
858    let spread = |s: &Spread| format!("{}–{}, mean {:.2}", s.min, s.max, s.mean);
859    let outcomes: Vec<String> = played
860        .outcomes
861        .iter()
862        .map(|(outcome, n)| format!("{outcome} {n}"))
863        .collect();
864    let mut lines = vec![format!("outcomes: {}", outcomes.join(", "))];
865    if let Some(turns) = &played.turns {
866        lines.push(format!("turns: {}", spread(turns)));
867    }
868    for (measure, s) in &played.measures {
869        lines.push(format!("{measure}: {}, total {}", spread(s), s.total));
870    }
871    let head = name.map(|(name, width)| format!("{name:<width$} · "));
872    let mut out = String::new();
873    for line in &lines {
874        // Writing to a `String` can't fail.
875        let _ = writeln!(out, "{}{line}", head.as_deref().unwrap_or_default());
876    }
877    out
878}
879
880/// `played`'s line, its name padded to `width`: its label, its summary
881/// items, its speed and what it was, ` · ` between them, wrapped past
882/// [`LINE`] characters onto lines indented under its first item.
883fn entrant_line(played: &Played, width: usize) -> String {
884    let mut head = format!("{:<width$} · ", played.name);
885    if let Some(label) = &played.label {
886        let _ = write!(head, "{label} · ");
887    }
888    let speed = format!("{} s per try", seconds(played.seconds_per_try));
889    let items = played
890        .summary
891        .iter()
892        .cloned()
893        .chain([speed])
894        .chain(played.was.clone());
895    let indent = " ".repeat(head.chars().count());
896    let mut out = String::new();
897    let mut line = head;
898    let mut first = true;
899    for item in items {
900        if first {
901            line.push_str(&item);
902            first = false;
903        } else if line.chars().count() + 3 + item.chars().count() > LINE {
904            out.push_str(&line);
905            out.push('\n');
906            line = format!("{indent}{item}");
907        } else {
908            let _ = write!(line, " · {item}");
909        }
910    }
911    out.push_str(&line);
912    out.push('\n');
913    out
914}
915
916#[cfg(test)]
917mod tests;