1use 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
43pub const DEFAULT_HISTORY: &str = "target/playtest-history";
46
47pub const DEFAULT_TRIES: Tries = Tries::new(100, 0, 200);
50
51pub const MAX_TRIES: u32 = 10_000;
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57#[serde(deny_unknown_fields, default)]
58#[non_exhaustive]
61pub struct PlaytestInput {
62 pub stage: Option<String>,
65 pub every_stage: bool,
67 pub entrants: Vec<String>,
70 pub tries: Option<u32>,
72 pub seed: Option<u64>,
74 pub turns: Option<u32>,
76 pub threads: Option<u32>,
79 pub start: Vec<GameArg>,
81 pub history: Option<String>,
84 pub no_history: bool,
86}
87
88impl PlaytestInput {
89 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
102#[serde(deny_unknown_fields)]
103#[non_exhaustive]
104pub struct Played {
105 pub name: String,
107 pub label: Option<String>,
109 pub seconds_per_try: f64,
112 #[serde(default)]
114 pub summary: Vec<String>,
115 #[serde(default)]
119 pub was: Option<String>,
120 #[serde(default)]
124 pub outcomes: BTreeMap<String, u32>,
125 #[serde(default)]
127 pub turns: Option<Spread>,
128 #[serde(default)]
130 pub measures: BTreeMap<String, Spread>,
131 #[serde(default)]
133 pub tries: Vec<TryKept>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(deny_unknown_fields)]
140#[non_exhaustive]
141pub struct TryKept {
142 pub seed: u64,
144 pub commands: u32,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub record: Option<String>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields)]
155pub struct Spread {
156 pub min: i64,
158 pub max: i64,
160 pub total: i64,
162 pub mean: f64,
164}
165
166impl Spread {
167 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)] 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184#[serde(deny_unknown_fields)]
185#[non_exhaustive]
186pub struct Previous {
187 pub date: String,
189 pub commit: String,
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
195#[serde(deny_unknown_fields)]
196#[non_exhaustive]
197pub struct PlaytestOutput {
198 pub stage: Option<String>,
200 pub title: String,
202 pub report: PlaytestReport,
204 pub entrants: Vec<Played>,
206 #[serde(default)]
209 pub previous: Option<Previous>,
210}
211
212pub 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
289fn 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
302pub 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
357fn 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
367fn 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
384fn 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
410pub(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
422pub(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
431pub(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
459pub(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
509pub(crate) struct StagePlayed {
511 pub(crate) report: PlaytestReport,
513 pub(crate) entrants: Vec<Played>,
515}
516
517fn 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
529pub(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 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
547pub(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
608fn outcome_name(outcome: Option<&str>) -> &str {
611 outcome.map_or(UNFINISHED, |o| {
612 o.split([' ', '{', '(']).next().unwrap_or_default()
613 })
614}
615
616fn 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
635const 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
694pub 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 let _ = writeln!(
704 out,
705 " {name:<20} {}",
706 option.help.lines().next().unwrap_or_default()
707 );
708 }
709 out
710}
711
712pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
737#[non_exhaustive]
738pub struct PlaytestArgs {
739 pub input: PlaytestInput,
741 pub report: Option<PathBuf>,
743 pub brief: bool,
745}
746
747pub 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
782pub(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
791const LINE: usize = 100;
793
794pub fn text(outputs: &[PlaytestOutput]) -> String {
801 render(outputs, false)
802}
803
804pub fn brief(outputs: &[PlaytestOutput]) -> String {
806 render(outputs, true)
807}
808
809fn 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 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
854fn 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 let _ = writeln!(out, "{}{line}", head.as_deref().unwrap_or_default());
876 }
877 out
878}
879
880fn 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;