Skip to main content

shuyi_cli/contract/
run.rs

1//! `run` and `scene`: the game started headless as the game's options
2//! say, a script of keys, controller buttons, actions by name and waits
3//! played, and some frames more. `run` answers with the top screen, the frame as text, the
4//! scene, what happened, the sounds asked for, the game's log and its
5//! modules; `scene` with the scene and the log. With `--perf`, `run` also
6//! measures each frame and answers a summary of the times, and with
7//! `--perf-out` writes every kept frame as a JSON line ([`super::perf`]);
8//! where the heap is counted by memory tag, it takes a heap snapshot
9//! before the script and one after the last frame, and answers what held
10//! the heap and what grew. With `--session`, the game starts from a session
11//! record instead (`shuyi_platform::session`), played to its end or to the
12//! first frame this build plays differently, and the script goes on from
13//! there.
14
15use std::fmt::Write as _;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use shuyi_ui::log::LOG_LINES;
22use shuyi_ui::perf::{FrameRecord, HeapSnapshot, PERF_FRAMES};
23
24use super::perf::{self, PerfSummary};
25use super::session::{
26    self, AddedSound, Event, LogEntry, ModuleEntry, Scene, Session, SessionInput, SessionPlayed,
27    Sound, from_record, measured_start,
28};
29use super::{Answer, Op, codes};
30use crate::frame_png::{self, Script, Step};
31use crate::game::{GameArg, GameOption, GameTools, check_start, code_of, game_arg};
32
33/// The most frames `frames` may ask for: about 28 simulated minutes.
34pub const MAX_IDLE_FRAMES: u32 = 100_000;
35
36/// What `run` and `scene` play.
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
38#[serde(deny_unknown_fields, default)]
39// An input: built with `default()` and its fields assigned, it may grow
40// (ADR-0039).
41#[non_exhaustive]
42pub struct RunInput {
43    /// The game's own start options (`frame-png --help` lists them), in
44    /// the order given.
45    pub start: Vec<GameArg>,
46    /// The steps to play.
47    pub script: Script,
48    /// Frames to run after the script with no input.
49    pub frames: u32,
50    /// Measure each frame, and answer a summary of the times (`run` only).
51    pub perf: bool,
52    /// Also write every kept frame's record there, a JSON line each; a
53    /// relative path is from the project's folder. It implies `perf`.
54    pub perf_out: Option<String>,
55    /// Start from this session record, played to its end, in place of the
56    /// start options: the game's real start is the record's. A frame that
57    /// plays differently stops it there, and the script and `frames` don't
58    /// play.
59    pub session: Option<SessionInput>,
60    /// Answer only the value at this JSON Pointer (RFC 6901) in the
61    /// scene's data, `/hero/x` (`scene` only).
62    pub path: Option<String>,
63    /// Answer the JSON Schema of the scene's data, as the game derived it
64    /// (`scene` only).
65    pub shape: bool,
66}
67
68/// What `run` saw.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
70#[serde(deny_unknown_fields)]
71// An answer, read field by field: it may grow (ADR-0039).
72#[non_exhaustive]
73pub struct RunOutput {
74    /// The frames run in all.
75    pub frames: u32,
76    /// The top screen's name; empty once every screen has closed.
77    pub top_screen: String,
78    /// The open screens, bottom first.
79    pub screens: Vec<String>,
80    /// The frame the game asked to quit in, if it did.
81    pub quit: Option<u32>,
82    /// The scene on screen, if a stage is open.
83    pub scene: Option<Scene>,
84    /// The last frame drawn, in the snapshot text format.
85    pub frame: String,
86    /// What happened, in frame order, then the game's own events.
87    pub events: Vec<Event>,
88    /// The sound cues asked for, in order.
89    pub sounds: Vec<Sound>,
90    /// The sounds the game added at run time and hadn't removed at the
91    /// end of the run, in cue order; left out when none.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub sounds_added: Vec<AddedSound>,
94    /// The game's log, oldest first: its last lines.
95    pub log: Vec<LogEntry>,
96    /// The modules installed in the game, in install order, with the
97    /// hooks the game calls on each.
98    pub modules: Vec<ModuleEntry>,
99    /// The frames' times, with `perf`: none without it, or when nothing
100    /// was measured.
101    pub perf: Option<PerfSummary>,
102    /// How far the session record played, with `session`.
103    pub session: Option<SessionPlayed>,
104}
105
106/// What `scene` saw.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109// An answer, read field by field: it may grow (ADR-0039).
110#[non_exhaustive]
111pub struct SceneOutput {
112    /// The frames run in all.
113    pub frames: u32,
114    /// The top screen's name.
115    pub top_screen: String,
116    /// The scene, if a stage is open; left out with `path` or `shape`,
117    /// which answer what was asked instead.
118    pub scene: Option<Scene>,
119    /// The game's log, oldest first: its last lines.
120    pub log: Vec<LogEntry>,
121    /// How far the session record played, with `session`.
122    pub session: Option<SessionPlayed>,
123    /// With `path`, the value at that pointer in the scene's data.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub value: Option<serde_json::Value>,
126    /// With `shape`, the JSON Schema of the scene's data in serialize mode,
127    /// if the game gave one (`Scene::with_data`).
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub shape: Option<serde_json::Value>,
130}
131
132/// What `scene` is asked for beyond the scene: one value of its data, by
133/// pointer, and its data's shape.
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
135pub struct SceneAsk<'a> {
136    /// A JSON Pointer into the scene's data.
137    pub path: Option<&'a str>,
138    /// Whether to answer the data's shape.
139    pub shape: bool,
140}
141
142impl RunInput {
143    /// Whether the Harness can play it: every step of its script
144    /// ([`frame_png::check`]), at most [`MAX_IDLE_FRAMES`] frames after, and
145    /// no start options with a session record, whose start is the game's
146    /// real one. Its actions' names are the game's to check
147    /// ([`check_actions`]).
148    pub fn check(&self) -> Result<(), String> {
149        frame_png::check(&self.script)?;
150        if self.session.is_some() && !self.start.is_empty() {
151            return Err(
152                "session: a record starts as the game really starts, with no start options"
153                    .to_owned(),
154            );
155        }
156        if self.frames > MAX_IDLE_FRAMES {
157            return Err(format!(
158                "frames: at most {MAX_IDLE_FRAMES}, not {}",
159                self.frames
160            ));
161        }
162        Ok(())
163    }
164
165    /// Whether it measures the frames: `perf`, or `perf_out`.
166    pub fn measures(&self) -> bool {
167        self.perf || self.perf_out.is_some()
168    }
169}
170
171/// A session played: the heap snapshot taken once it started if it was
172/// measured, and how far its record played if it has one.
173struct Ran {
174    session: Box<dyn Session>,
175    first: Option<HeapSnapshot>,
176    record: Option<SessionPlayed>,
177    /// What the record's playing can't promise.
178    limits: Vec<String>,
179    /// Whether a frame of the record played differently.
180    differs: bool,
181}
182
183/// A session of `game` started and played as `input` says, or the answer
184/// of `op` that says why not; a record is read from `root`. Measured, also
185/// the heap snapshot taken once it started, before the script. A record
186/// that plays differently stands on that frame: the script and the frames
187/// after it don't play.
188fn played<T>(
189    op: Op,
190    game: &dyn GameTools,
191    root: &Path,
192    input: &RunInput,
193) -> Result<Ran, Answer<T>> {
194    input
195        .check()
196        .map_err(|e| Answer::failed(op, codes::WRONG_ARGUMENTS, e))?;
197    if op == Op::Run && (input.path.is_some() || input.shape) {
198        let error = "path and shape are `scene`'s: `run` answers the whole scene";
199        return Err(Answer::failed(op, codes::WRONG_ARGUMENTS, error));
200    }
201    check_start(game.frame_options(), &input.start)
202        .map_err(|e| Answer::failed(op, codes::WRONG_ARGUMENTS, e))?;
203    let start = || match &input.session {
204        Some(record) => {
205            from_record(game, root, record).map(|(session, played)| (session, Some(played)))
206        }
207        None => game
208            .session(&input.start)
209            .map(|session| (session, None))
210            .map_err(|e| (code_of(&e), e)),
211    };
212    let started = if input.measures() {
213        measured_start(start)
214    } else {
215        start()
216    };
217    let (mut session, played) = started.map_err(|(code, e)| Answer::failed(op, code, e))?;
218    let differs = played
219        .as_ref()
220        .is_some_and(|played| played.first_difference.is_some());
221    let mut first = None;
222    if !differs {
223        check_actions(&*session, &input.script)
224            .map_err(|e| Answer::failed(op, codes::WRONG_ARGUMENTS, e))?;
225        if input.measures() {
226            session.set_perf(true);
227            first = session.heap_snapshot();
228        }
229        session.play(&input.script);
230        session.idle(input.frames);
231    }
232    // Read after the script: its frames add lines (`Session::play_record`).
233    let after = played.and_then(|_| session.play_record());
234    let (record, limits) = match (&input.session, after) {
235        (Some(file), Some(played)) => (Some(SessionPlayed::of(&file.file, &played)), played.limits),
236        _ => (None, Vec::new()),
237    };
238    Ok(Ran {
239        session,
240        first,
241        record,
242        limits,
243        differs,
244    })
245}
246
247/// Whether `session`'s game can press every action `script` names: one
248/// with a key or a button ([`Session::actions`]). An error names the step
249/// and the game's actions, before anything plays (`run`, `scene` and MCP's
250/// `step` call it).
251pub fn check_actions(session: &dyn Session, script: &Script) -> Result<(), String> {
252    let mut actions = None;
253    for (i, step) in script.steps.iter().enumerate() {
254        let Step::Act(name) = step else {
255            continue;
256        };
257        let actions = actions.get_or_insert_with(|| session.actions());
258        if !actions.contains(name) {
259            let theirs = if actions.is_empty() {
260                "the game has none".to_owned()
261            } else {
262                format!("the game's are {}", actions.join(", "))
263            };
264            return Err(format!(
265                "step {}: act: no action `{name}` with a key or a button; {theirs}",
266                i + 1
267            ));
268        }
269    }
270    Ok(())
271}
272
273/// What a run of `frames` frames couldn't show: the frames after the game
274/// asked to quit (on frame `quit`), the frame if no screen is `open`, and
275/// the scene, if there is none because of `no_scene`.
276pub fn limits_of(
277    frames: u32,
278    quit: Option<u32>,
279    open: bool,
280    no_scene: Option<&str>,
281) -> Vec<String> {
282    let mut limits = Vec::new();
283    if let Some(quit) = quit {
284        let after = frames.saturating_sub(quit);
285        limits.push(if after == 0 {
286            format!("the game asked to quit on frame {quit}, the last")
287        } else {
288            format!(
289                "the game asked to quit on frame {quit}: the {after} frame(s) after it changed \
290                 nothing"
291            )
292        });
293    }
294    if !open {
295        limits.push("no screen is open: the frame is the last one drawn".to_owned());
296    }
297    if let Some(why) = no_scene {
298        limits.push(format!("no scene: {why}"));
299    }
300    limits
301}
302
303/// What `session` couldn't show after its run, `scene` its scene: also
304/// the lines its log dropped.
305fn limits(session: &dyn Session, scene: &Result<Scene, String>) -> Vec<String> {
306    let mut limits = limits_of(
307        session.frame_count(),
308        session.quit_frame(),
309        !session.screens().is_empty(),
310        scene.as_ref().err().map(String::as_str),
311    );
312    let dropped = session.log_dropped();
313    if dropped > 0 {
314        limits.push(format!(
315            "the log keeps the last {LOG_LINES} lines: {dropped} earlier line(s) were dropped"
316        ));
317    }
318    limits
319}
320
321/// What `session` shows after its run, as `run` answers it, with its
322/// limits.
323pub fn run_output(session: &dyn Session) -> (RunOutput, Vec<String>) {
324    let scene = session.scene();
325    let limits = limits(session, &scene);
326    let screens = session.screens();
327    let output = RunOutput {
328        frames: session.frame_count(),
329        top_screen: screens.last().cloned().unwrap_or_default(),
330        screens,
331        quit: session.quit_frame(),
332        scene: scene.ok(),
333        frame: session.snapshot(),
334        events: session.events(),
335        sounds: session.sounds(),
336        sounds_added: session.sounds_added(),
337        log: session.log(),
338        modules: session.modules(),
339        perf: None,
340        session: None,
341    };
342    (output, limits)
343}
344
345/// The heap snapshots before a run and after it.
346type Snapshots = (HeapSnapshot, HeapSnapshot);
347
348/// The summary of every record `session` kept, with the heap snapshots
349/// `heap` before and after its run, the records themselves, for the trace
350/// file, and the limits of both.
351fn perf_of(
352    session: &dyn Session,
353    heap: Option<&Snapshots>,
354) -> (Option<PerfSummary>, Vec<FrameRecord>, Vec<String>) {
355    let read = session.perf(0, u32::try_from(PERF_FRAMES).unwrap_or(u32::MAX));
356    let mut limits = vec![perf::TIMES.to_owned()];
357    if !read.measured {
358        limits.push(perf::unmeasured_limit(session.frame_count()));
359        return (None, Vec::new(), limits);
360    }
361    if read.dropped > 0 {
362        limits.push(perf::dropped_limit(read.dropped));
363    }
364    limits.extend(perf::heap_limits(
365        &read.records,
366        session.counts_process_heap(),
367        perf::other_threads(),
368    ));
369    if heap.is_none() && read.records.iter().any(|r| r.heap.is_some()) {
370        limits.push(perf::HEAP_UNTAGGED.to_owned());
371    }
372    let summary = perf::summary(&read.records).map(|mut summary| {
373        summary.dropped = read.dropped;
374        if let Some((first, last)) = heap {
375            perf::add_heap(&mut summary, first, last);
376        }
377        summary
378    });
379    (summary, read.records, limits)
380}
381
382/// Where `--perf-out <path>` writes: `path` from the project's folder
383/// `root`, as the operating system takes it.
384pub fn perf_out_path(root: &Path, path: &str) -> PathBuf {
385    let path = root.join(path);
386    std::path::absolute(&path).unwrap_or(path)
387}
388
389/// `records` written to `path` as a trace, then the heap snapshots `heap`
390/// and their diff, its folders made.
391fn write_trace(
392    path: &Path,
393    records: &[FrameRecord],
394    heap: Option<&Snapshots>,
395) -> std::io::Result<()> {
396    if let Some(dir) = path.parent() {
397        fs::create_dir_all(dir)?;
398    }
399    let mut trace = perf::lines(records);
400    if let Some((first, last)) = heap {
401        trace.push_str(&perf::heap_lines(first, last));
402    }
403    fs::write(path, trace)
404}
405
406/// The scene `session` shows, as `scene` answers it, with its limits.
407pub fn scene_output(session: &dyn Session) -> (SceneOutput, Vec<String>) {
408    let scene = session.scene();
409    let limits = limits(session, &scene);
410    let screens = session.screens();
411    let output = SceneOutput {
412        frames: session.frame_count(),
413        top_screen: screens.last().cloned().unwrap_or_default(),
414        scene: scene.ok(),
415        log: session.log(),
416        session: None,
417        value: None,
418        shape: None,
419    };
420    (output, limits)
421}
422
423/// `output` answering `ask` in place of its scene: the value at its path,
424/// and its data's shape, with what the shape's absence adds to `limits`;
425/// or the problem code and why, a path that names nothing. With no scene,
426/// or nothing asked, it is left as it is.
427pub fn ask_scene(
428    output: &mut SceneOutput,
429    limits: &mut Vec<String>,
430    ask: SceneAsk<'_>,
431) -> Result<(), (codes::Code, String)> {
432    if ask.path.is_none() && !ask.shape {
433        return Ok(());
434    }
435    let Some(scene) = output.scene.take() else {
436        return Ok(());
437    };
438    if let Some(path) = ask.path {
439        let value = session::at_pointer(&scene.data, path).map_err(|e| (codes::NO_VALUE, e))?;
440        output.value = Some(value.clone());
441    }
442    if ask.shape {
443        match scene.shape {
444            Some(shape) => output.shape = Some(shape.to_value()),
445            None => limits.push(NO_SHAPE.to_owned()),
446        }
447    }
448    Ok(())
449}
450
451/// The limit of `scene --shape` on a scene whose data has no shape.
452pub const NO_SHAPE: &str = "no shape: the game built its scene's data with no `Scene::with_data`";
453
454/// The answer `op` gives with `session`'s scene, asked `ask`, and how far
455/// its record played, `record`: it passes if there is a scene and the
456/// record played no differently (`differs`).
457pub fn scene_answer(
458    op: Op,
459    session: &dyn Session,
460    ask: SceneAsk<'_>,
461    record: Option<SessionPlayed>,
462    differs: bool,
463) -> Answer<SceneOutput> {
464    let (mut output, mut limits) = scene_output(session);
465    output.session = record;
466    let ok = output.scene.is_some() && !differs;
467    if let Err((code, error)) = ask_scene(&mut output, &mut limits, ask) {
468        return Answer::failed(op, code, error);
469    }
470    Answer::new(op, ok, output, limits)
471}
472
473/// `run`: plays `input` in `game`; a trace file (`perf_out`) is written
474/// from the working folder ([`run_at`] says from where).
475pub fn run(game: &dyn GameTools, input: &RunInput) -> Answer<RunOutput> {
476    run_at(game, Path::new("."), input)
477}
478
479/// `run`: plays `input` in `game`; a trace file (`perf_out`) is written
480/// from the project's folder `root`, as the CLI and the MCP server run it.
481pub fn run_at(game: &dyn GameTools, root: &Path, input: &RunInput) -> Answer<RunOutput> {
482    let Ran {
483        mut session,
484        first,
485        record,
486        limits: record_limits,
487        differs,
488    } = match played(Op::Run, game, root, input) {
489        Ok(played) => played,
490        Err(answer) => return answer,
491    };
492    // After the last frame, before the answer is read.
493    let heap = first.zip(session.heap_snapshot());
494    let (mut output, mut limits) = run_output(&*session);
495    output.session = record;
496    limits.extend(record_limits);
497    if input.measures() {
498        let (mut summary, records, perf_limits) = perf_of(&*session, heap.as_ref());
499        limits.extend(perf_limits);
500        if let (Some(file), Some(summary)) = (&input.perf_out, &mut summary) {
501            let path = perf_out_path(root, file);
502            if let Err(e) = write_trace(&path, &records, heap.as_ref()) {
503                return Answer::failed(Op::Run, codes::FILE, format!("--perf-out {file}: {e}"));
504            }
505            summary.file = Some(path.display().to_string());
506        }
507        output.perf = summary;
508    }
509    Answer::new(Op::Run, !differs, output, limits)
510}
511
512/// `scene`: plays `input` in `game`, and answers with the scene; a record
513/// is read from the working folder ([`scene_at`] says from where).
514pub fn scene(game: &dyn GameTools, input: &RunInput) -> Answer<SceneOutput> {
515    scene_at(game, Path::new("."), input)
516}
517
518/// `scene`: plays `input` in `game`, a record read from the project's folder
519/// `root`, and answers with the scene. It passes if there is one and a
520/// record played no differently.
521pub fn scene_at(game: &dyn GameTools, root: &Path, input: &RunInput) -> Answer<SceneOutput> {
522    match played(Op::Scene, game, root, input) {
523        Ok(ran) => {
524            let ask = SceneAsk {
525                path: input.path.as_deref(),
526                shape: input.shape,
527            };
528            let mut answer = scene_answer(Op::Scene, &*ran.session, ask, ran.record, ran.differs);
529            answer.limits.extend(ran.limits);
530            answer
531        }
532        Err(answer) => answer,
533    }
534}
535
536/// The usage of `run` (and `scene`, if `op` says so), with the game's
537/// start `options`.
538pub fn usage(command: &str, op: Op, options: &[GameOption]) -> String {
539    let mut out = format!(
540        "usage: {command} {} [steps] [--frames N] [options] [--json]\n\n\
541         steps (each may repeat, played in order):\n  \
542         --script \"Enter Up\"  press and release each key chord in turn (or --keys)\n  \
543         --pad \"South\"        press and release each controller button in turn\n  \
544         --act Confirm        press the game's action by its key, or else its button\n  \
545         --wait 0.5           let that many seconds pass (60 frames a second, rounded up)\n\n\
546         --frames N           then run N frames (1/60 s each) with no input\n\n\
547         --session <file>     start from a session record, from the project's folder,\n                     \
548         played to its end or the first frame that differs; no start options\n\
549         --any-stamp          play a record another build or other content made too\n",
550        op.name()
551    );
552    if op == Op::Scene {
553        out.push_str(
554            "\n--path <pointer>     answer only the value at that JSON Pointer in the scene's\n                     \
555             data (`/hero/x`)\n\
556             --shape              answer the JSON Schema of the scene's data\n",
557        );
558    }
559    if op == Op::Run {
560        out.push_str(
561            "\n--perf               measure each frame, and answer a summary of the times\n\
562             --perf-out <file>    also write each kept frame as a JSON line, from the\n                     \
563             project's folder (implies --perf)\n\
564             --watch              alone: the game in its window instead, its files on\n                     \
565             disk reloading as they change (a debug build)\n",
566        );
567    }
568    if !options.is_empty() {
569        out.push_str("\nthe game's options:\n");
570        for option in options {
571            let name = match option.value {
572                Some(value) => format!("{} {value}", option.flag),
573                None => option.flag.to_owned(),
574            };
575            let help = option.help.lines().next().unwrap_or_default();
576            // Writing to a `String` can't fail.
577            let _ = writeln!(out, "  {name:<20} {help}");
578        }
579    }
580    out
581}
582
583/// Reads `run`'s and `scene`'s arguments (not `--json`), the game's
584/// start `options` among them.
585pub fn parse_args(args: &[String], options: &[GameOption]) -> Result<RunInput, String> {
586    let mut input = RunInput::default();
587    let mut any_stamp = false;
588    let mut iter = args.iter();
589    while let Some(arg) = iter.next() {
590        let mut value = || {
591            iter.next()
592                .ok_or_else(|| format!("{arg} needs a value"))
593                .cloned()
594        };
595        if matches!(
596            arg.as_str(),
597            "--script" | "--keys" | "--pad" | "--act" | "--wait"
598        ) {
599            let text = value()?;
600            if let Some(step) = frame_png::parse_step(arg, &text) {
601                input.script.steps.push(step?);
602            }
603            continue;
604        }
605        if arg == "--session" {
606            input.session = Some(SessionInput::new(value()?));
607            continue;
608        }
609        if arg == "--any-stamp" {
610            any_stamp = true;
611            continue;
612        }
613        if arg == "--perf" {
614            input.perf = true;
615            continue;
616        }
617        if arg == "--path" {
618            input.path = Some(value()?);
619            continue;
620        }
621        if arg == "--shape" {
622            input.shape = true;
623            continue;
624        }
625        if arg == "--perf-out" {
626            input.perf_out = Some(value()?);
627            input.perf = true;
628            continue;
629        }
630        if arg == "--frames" {
631            let text = value()?;
632            input.frames = text
633                .parse()
634                .ok()
635                .filter(|n| *n <= MAX_IDLE_FRAMES)
636                .ok_or_else(|| {
637                    format!("--frames: a whole number 0 to {MAX_IDLE_FRAMES}, not {text}")
638                })?;
639            continue;
640        }
641        input.start.push(game_arg(arg, options, value)?);
642    }
643    match &mut input.session {
644        Some(session) => session.any_stamp = any_stamp,
645        None if any_stamp => return Err("--any-stamp needs --session".to_owned()),
646        None => {}
647    }
648    Ok(input)
649}
650
651/// One event, for people.
652fn event_text(event: &Event) -> String {
653    match event {
654        Event::Opened { frame, screen } => format!("frame {frame}: opened {screen}"),
655        Event::Closed { frame, screen } => format!("frame {frame}: closed {screen}"),
656        Event::Music {
657            frame,
658            command,
659            cue,
660        } => format!("frame {frame}: music {command} {cue}"),
661        Event::Voice { frame, line } => format!("frame {frame}: voice {line}"),
662        Event::Quit { frame } => format!("frame {frame}: quit"),
663        Event::Game { text } => format!("game: {text}"),
664    }
665}
666
667/// The log's warnings and errors, for people.
668fn log_text(out: &mut String, log: &[LogEntry]) {
669    let serious = |entry: &&LogEntry| matches!(entry.level.as_str(), "warn" | "error");
670    if !log.iter().any(|entry| serious(&entry)) {
671        return;
672    }
673    out.push_str("log (warnings and errors; every line with --json):\n");
674    for entry in log.iter().filter(serious) {
675        let _ = writeln!(
676            out,
677            "  frame {}: {} {}: {}",
678            entry.frame, entry.level, entry.category, entry.text
679        );
680    }
681}
682
683/// `scene`, for people.
684fn scene_text(out: &mut String, scene: Option<&Scene>) {
685    if let Some(scene) = scene {
686        let _ = writeln!(out, "scene ({}, on {}):", scene.kind, scene.screen);
687        out.push_str(&scene.text);
688        if !scene.text.ends_with('\n') {
689            out.push('\n');
690        }
691        if !scene.data.is_null() {
692            let _ = writeln!(out, "{}", scene.data);
693        }
694    }
695}
696
697/// How far a session record played, for people.
698fn session_text(out: &mut String, session: Option<&SessionPlayed>) {
699    let Some(session) = session else {
700        return;
701    };
702    let _ = writeln!(
703        out,
704        "session {}: played {} of {} frame(s)",
705        session.file, session.frames, session.of
706    );
707    if let Some(difference) = &session.first_difference {
708        let _ = writeln!(
709            out,
710            "it first differs at frame {}: the record says {}, this build {}",
711            difference.frame, difference.recorded, difference.played
712        );
713    }
714}
715
716/// What `run` saw, for people.
717pub fn text(output: &RunOutput) -> String {
718    let mut out = format!(
719        "run: {} frame(s); top screen: {} ({})\n",
720        output.frames,
721        if output.top_screen.is_empty() {
722            "none"
723        } else {
724            &output.top_screen
725        },
726        output.screens.join(" > ")
727    );
728    session_text(&mut out, output.session.as_ref());
729    if !output.events.is_empty() {
730        out.push_str("events:\n");
731        for event in &output.events {
732            let _ = writeln!(out, "  {}", event_text(event));
733        }
734    }
735    log_text(&mut out, &output.log);
736    if !output.modules.is_empty() {
737        let modules: Vec<String> = output
738            .modules
739            .iter()
740            .map(|m| format!("{} ({})", m.name, m.hooks.join(", ")))
741            .collect();
742        let _ = writeln!(out, "modules: {}", modules.join(", "));
743    }
744    if !output.sounds.is_empty() {
745        let sounds: Vec<String> = output
746            .sounds
747            .iter()
748            .map(|s| format!("{} (frame {})", s.cue, s.frame))
749            .collect();
750        let _ = writeln!(out, "sounds: {}", sounds.join(", "));
751    }
752    if !output.sounds_added.is_empty() {
753        let added: Vec<String> = output
754            .sounds_added
755            .iter()
756            .map(|s| {
757                format!(
758                    "{} ({} samples a second, {} channel(s), {} frames, {} milliseconds)",
759                    s.cue, s.rate, s.channels, s.frames, s.millis
760                )
761            })
762            .collect();
763        let _ = writeln!(out, "sounds added: {}", added.join(", "));
764    }
765    if let Some(summary) = &output.perf {
766        out.push_str(&perf::text(summary));
767    }
768    scene_text(&mut out, output.scene.as_ref());
769    // The glyphs only: the colours, legend and items are in `--json`.
770    let glyphs = output
771        .frame
772        .split_once("--- ")
773        .map_or(output.frame.as_str(), |(glyphs, _)| glyphs);
774    out.push_str("frame (its colours with --json):\n");
775    out.push_str(glyphs);
776    if !glyphs.ends_with('\n') {
777        out.push('\n');
778    }
779    out
780}
781
782/// What `scene` saw, for people.
783pub fn scene_output_text(output: &SceneOutput) -> String {
784    let mut out = format!(
785        "scene: after {} frame(s), top screen {}\n",
786        output.frames, output.top_screen
787    );
788    session_text(&mut out, output.session.as_ref());
789    log_text(&mut out, &output.log);
790    scene_text(&mut out, output.scene.as_ref());
791    if let Some(value) = &output.value {
792        let _ = writeln!(out, "value: {value}");
793    }
794    if let Some(shape) = &output.shape {
795        let pretty = serde_json::to_string_pretty(shape).unwrap_or_default();
796        let _ = writeln!(out, "shape:\n{pretty}");
797    }
798    out
799}
800
801/// A script of keys, for tests and callers that only press keys.
802pub fn keys(script: &str) -> Script {
803    Script::new(vec![Step::Keys(script.to_owned())])
804}
805
806#[cfg(test)]
807mod tests;