Skip to main content

shuyi_cli/
game.rs

1//! What the commands that run the game need from it: [`GameTools`].
2//!
3//! The `shuyi` binary knows no game. A game gives its tools in a program
4//! of its own, which runs [`crate::main_with`] with them; `shuyi.ron`
5//! names that program ([`crate::project::GameProgram`]) and `shuyi` runs
6//! it with `cargo run` for the commands that run the game: `frame-png`,
7//! `lang-status`, `font-atlas` (which keeps the characters the game's text
8//! uses; `--no-text` runs it without the game), and the contract's
9//! `check`, `run`, `scene`, `replay`, `playtest`, `autobalance`,
10//! `import` and `bake`.
11
12use std::collections::BTreeMap;
13use std::process::Command;
14
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use shuyi_assets::ContentError;
18use shuyi_assets::audio::{AudioManifest, Files};
19use shuyi_assets::bundle::Bundle;
20use shuyi_assets::font::FontAtlasDef;
21use shuyi_assets::format::Formats;
22use shuyi_assets::lang::Lang;
23use shuyi_assets::onebit::PatternMapDef;
24use shuyi_assets::pack::Bake;
25use shuyi_assets::palette::PaletteDef;
26use shuyi_ui::input::fit::Controls;
27
28use shuyi_core::bots::playtest::{Check, PlayedTry, PlaytestReport, Tries};
29use shuyi_core::{BalanceSettings, Balanced, Knob, Made, autobalance_by};
30use shuyi_platform::session::{SessionRecord, Stamps};
31
32use crate::contract::autobalance::AutobalanceInput;
33use crate::contract::codes::{self, Code};
34use crate::contract::import::Importers;
35use crate::contract::playtest::{DEFAULT_TRIES, PlaytestInput, Summary};
36use crate::contract::replay::{Record, ReplayRun};
37use crate::contract::session::Session;
38use crate::frame_png::{Script, Shot};
39use crate::project::Project;
40
41/// An option a game adds to `frame-png`. Build it with
42/// [`GameOption::new`] (ADR-0039).
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct GameOption {
46    /// The flag (`--light`).
47    pub flag: &'static str,
48    /// What its value is called in the usage text (`NAME`), if it takes
49    /// one.
50    pub value: Option<&'static str>,
51    /// What it does, for the usage text (lines after the first are
52    /// indented under it).
53    pub help: &'static str,
54}
55
56impl GameOption {
57    /// The flag `flag`, explained by `help`, taking no value
58    /// ([`with_value`](Self::with_value) names one).
59    #[must_use]
60    pub const fn new(flag: &'static str, help: &'static str) -> Self {
61        Self {
62            flag,
63            value: None,
64            help,
65        }
66    }
67
68    /// The option with its value called `value` in the usage text.
69    #[must_use]
70    pub const fn with_value(self, value: Option<&'static str>) -> Self {
71        Self { value, ..self }
72    }
73}
74
75/// A game option as given.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct GameArg {
79    /// Its flag.
80    pub flag: String,
81    /// Its value, if it takes one.
82    #[serde(default)]
83    pub value: Option<String>,
84}
85
86/// The game's start option `flag`, one of `options`, with its value from
87/// `value` if it takes one.
88///
89/// # Errors
90/// `unknown option: {flag}` if the game doesn't offer `flag`, or what
91/// `value` fails with.
92pub fn game_arg(
93    flag: &str,
94    options: &[GameOption],
95    value: impl FnOnce() -> Result<String, String>,
96) -> Result<GameArg, String> {
97    let option = options
98        .iter()
99        .find(|option| option.flag == flag)
100        .ok_or_else(|| format!("unknown option: {flag}"))?;
101    let value = option.value.map(|_| value()).transpose()?;
102    Ok(GameArg {
103        flag: flag.to_owned(),
104        value,
105    })
106}
107
108/// Checks that the start options `start` are ones the game offers
109/// (`options`): each names one, has a value if it takes one and has none
110/// if it takes none. Every operation that starts the game calls it first,
111/// whether the options came from the command line or from JSON.
112///
113/// # Errors
114/// What is wrong with the first option that is not right.
115pub fn check_start(options: &[GameOption], start: &[GameArg]) -> Result<(), String> {
116    for arg in start {
117        let option = options
118            .iter()
119            .find(|option| option.flag == arg.flag)
120            .ok_or_else(|| format!("unknown option: {}", arg.flag))?;
121        match (option.value, &arg.value) {
122            (Some(_), None) => return Err(format!("{} needs a value", arg.flag)),
123            (None, Some(_)) => return Err(format!("{} takes no value", arg.flag)),
124            _ => {}
125        }
126    }
127    Ok(())
128}
129
130/// A game's languages, for `lang-status`. Build it with
131/// [`GameLang::new`] (ADR-0039).
132#[derive(Debug, Clone)]
133#[non_exhaustive]
134pub struct GameLang {
135    /// The game's English and its packs, with the data's and dialogue's
136    /// English.
137    pub lang: Lang,
138    /// The variants of a pack's line, in the pack files' order (`text_m`,
139    /// `text_f`: `["m", "f"]`).
140    pub variants: Vec<String>,
141}
142
143impl GameLang {
144    /// The game's languages `lang`, a pack's line in the variants
145    /// `variants`.
146    pub fn new(lang: Lang, variants: Vec<String>) -> Self {
147        Self { lang, variants }
148    }
149}
150
151/// What a game bakes into its pack ([`GameTools::bake`]). Build it with
152/// [`GameBake::new`] (ADR-0039).
153#[derive(Debug)]
154#[non_exhaustive]
155pub struct GameBake {
156    /// Which assets of which kinds, and the game's stamp.
157    pub bake: Bake,
158    /// The bundle of its sources: the layers it ships for the target.
159    pub bundle: Bundle,
160    /// The formats its loaders read the sources with (the pack's own are
161    /// added to them).
162    pub formats: Formats,
163}
164
165impl GameBake {
166    /// The bake `bake` of the sources `bundle`, read with `formats`.
167    pub fn new(bake: Bake, bundle: Bundle, formats: Formats) -> Self {
168        Self {
169            bake,
170            bundle,
171            formats,
172        }
173    }
174}
175
176/// The audio a game ships on a target whose export bakes it
177/// ([`GameTools::audio`]). Build it with [`GameAudio::new`] (ADR-0039).
178#[non_exhaustive]
179pub struct GameAudio {
180    /// The cues it ships there. A music cue left out isn't baked, and plays
181    /// as silence there, with a warning.
182    pub manifest: AudioManifest,
183    /// How to read their files. Where the game can't read its music folder
184    /// (`music` is `None`), it is read from the project's root.
185    pub files: Files<'static>,
186}
187
188impl GameAudio {
189    /// The cues of `manifest`, read with `files`.
190    pub fn new(manifest: AudioManifest, files: Files<'static>) -> Self {
191        Self { manifest, files }
192    }
193}
194
195/// A skin's one-bit look ([`GameTools::one_bit`]): its palette of roles
196/// and the pattern map beside it (ADR-0008 §4). Build it with
197/// [`OneBitSkin::new`] (ADR-0039).
198#[derive(Debug, Clone, PartialEq, Eq)]
199#[non_exhaustive]
200pub struct OneBitSkin {
201    /// The palette's file, as messages show it.
202    pub palette_file: String,
203    /// The palette: each role's colour.
204    pub palette: PaletteDef,
205    /// The pattern map's file, as messages show it
206    /// (`files/palette@onebit.ron`).
207    pub map_file: String,
208    /// The pattern map.
209    pub map: PatternMapDef,
210}
211
212impl OneBitSkin {
213    /// The palette `palette` from the file `palette_file`, and the pattern
214    /// map `map` from `map_file`.
215    pub fn new(
216        palette_file: impl Into<String>,
217        palette: PaletteDef,
218        map_file: impl Into<String>,
219        map: PatternMapDef,
220    ) -> Self {
221        Self {
222            palette_file: palette_file.into(),
223            palette,
224            map_file: map_file.into(),
225            map,
226        }
227    }
228}
229
230/// What `playtest` plays on one of the game's stages
231/// ([`GameTools::playtest_plan`]): its entrants, its checks, and how one
232/// try is played. The command picks the entrants asked for, plays their
233/// tries on every core, times them and judges them once. Build it with
234/// [`PlaytestPlan::new`] and assign the rest (ADR-0039).
235#[non_exhaustive]
236pub struct PlaytestPlan {
237    /// Shown at the top of the report: the stage and its tier, say.
238    pub title: String,
239    /// Every entrant, in report order: its name and a label (a mode), if
240    /// any.
241    pub entrants: Vec<(String, Option<String>)>,
242    /// Named groups of entrants (`"all"`), which `--entrant` takes too.
243    pub groups: BTreeMap<String, Vec<String>>,
244    /// The entrants that play when `--entrant` isn't given (all of them
245    /// if empty).
246    pub default_entrants: Vec<String>,
247    /// The checks over the entrants that play, given their names: a
248    /// check naming an entrant who doesn't play is left out. None at all
249    /// is a plain bot run: nothing is judged, and it passes when every
250    /// try played.
251    pub checks: PlaytestChecks,
252    /// One try: of the entrant at that index of
253    /// [`entrants`](Self::entrants), from that seed, stopped at that turn
254    /// if it hasn't ended, with the commands applied and its record for
255    /// `replay`, if the game keeps one. Tries run on several threads at
256    /// once, in any order; most simply
257    /// [`play_try`](shuyi_core::bots::playtest::play_try).
258    pub play: PlayTry,
259    /// Each entrant's summary items, in [`entrants`](Self::entrants)
260    /// order, printed on its line: `won 83/100 · turns median 12`. An
261    /// entrant past the end, or with none, has no summary.
262    pub summary: Vec<Vec<Summary>>,
263}
264
265impl PlaytestPlan {
266    /// The plan titled `title`: `entrants` judged by `checks`, a try played
267    /// by `play`. It has no [`groups`](Self::groups) and no
268    /// [`summary`](Self::summary), and every entrant plays unless
269    /// `--entrant` says ([`default_entrants`](Self::default_entrants)
270    /// empty).
271    pub fn new(
272        title: impl Into<String>,
273        entrants: Vec<(String, Option<String>)>,
274        checks: PlaytestChecks,
275        play: PlayTry,
276    ) -> Self {
277        Self {
278            title: title.into(),
279            entrants,
280            groups: BTreeMap::new(),
281            default_entrants: Vec::new(),
282            checks,
283            play,
284            summary: Vec::new(),
285        }
286    }
287}
288
289/// A plan's checks ([`PlaytestPlan::checks`]): given the names of the
290/// entrants that play, the checks over them.
291pub type PlaytestChecks = Box<dyn Fn(&[&str]) -> Vec<Check> + Send + Sync>;
292
293/// A plan's one try ([`PlaytestPlan::play`]): entrant index, seed, the
294/// turn it stops at.
295pub type PlayTry = Box<dyn Fn(usize, u64, u32) -> Result<PlayedTry, String> + Send + Sync>;
296
297/// What `autobalance` searches on one of the game's stages
298/// ([`GameTools::autobalance_plan`]): who plays it and how they are judged,
299/// as a [`PlaytestPlan`] says, and the stage it may change, its type the
300/// game's ([`Tunable`]). The command picks the entrants asked for and runs
301/// the search (`shuyi_core::autobalance_by`), each setting's tries played
302/// on every core and judged once. Build it with [`AutobalancePlan::new`]
303/// and assign the rest (ADR-0039).
304#[non_exhaustive]
305pub struct AutobalancePlan {
306    /// Shown at the top of the answer: the stage and its tier, say.
307    pub title: String,
308    /// Every entrant, in report order: its name and a label (a mode), if
309    /// any.
310    pub entrants: Vec<(String, Option<String>)>,
311    /// Named groups of entrants (`"all"`), which `--entrant` takes too.
312    pub groups: BTreeMap<String, Vec<String>>,
313    /// The entrants that play when `--entrant` isn't given (all of them
314    /// if empty).
315    pub default_entrants: Vec<String>,
316    /// The checks over the entrants that play, given their names: the
317    /// bands the search lands them in.
318    pub checks: PlaytestChecks,
319    /// The stage as authored, its knobs, how a try is played on a changed
320    /// stage and how one is written: most simply a boxed [`Tunable`].
321    pub stage: Box<dyn TunableStage>,
322}
323
324impl AutobalancePlan {
325    /// The plan titled `title`: `entrants` judged by `checks` on `stage`.
326    /// It has no [`groups`](Self::groups), and every entrant plays unless
327    /// `--entrant` says ([`default_entrants`](Self::default_entrants)
328    /// empty).
329    pub fn new(
330        title: impl Into<String>,
331        entrants: Vec<(String, Option<String>)>,
332        checks: PlaytestChecks,
333        stage: Box<dyn TunableStage>,
334    ) -> Self {
335        Self {
336            title: title.into(),
337            entrants,
338            groups: BTreeMap::new(),
339            default_entrants: Vec::new(),
340            checks,
341            stage,
342        }
343    }
344}
345
346/// The stage an [`AutobalancePlan`] may change, of the game's stage
347/// definition type `S`: the command never sees `S`, only what the search
348/// found ([`TunableStage`]). Build it with [`Tunable::new`] (ADR-0039).
349#[non_exhaustive]
350pub struct Tunable<S> {
351    /// The stage as authored.
352    pub stage: S,
353    /// What the search may change, in the order it tries them.
354    pub knobs: Vec<Box<dyn Knob<S>>>,
355    /// One try on a stage: the stage (as authored, or changed by the
356    /// knobs), the entrant at that index of the plan's entrants, the seed,
357    /// the turn it stops at. Tries run on several threads at once, in any
358    /// order; most simply
359    /// [`play_try`](shuyi_core::bots::playtest::play_try).
360    pub play: StagePlay<S>,
361    /// Writes a changed stage back to the game's files (`--apply`): the
362    /// files written, as the game shows them.
363    pub write: StageWrite<S>,
364}
365
366impl<S> Tunable<S> {
367    /// The stage `stage`, changed by `knobs`, a try on it played by `play`
368    /// and a changed one written by `write`.
369    pub fn new(
370        stage: S,
371        knobs: Vec<Box<dyn Knob<S>>>,
372        play: StagePlay<S>,
373        write: StageWrite<S>,
374    ) -> Self {
375        Self {
376            stage,
377            knobs,
378            play,
379            write,
380        }
381    }
382}
383
384/// A [`Tunable`]'s one try: the stage, entrant index, seed, the turn it
385/// stops at.
386pub type StagePlay<S> = Box<dyn Fn(&S, usize, u64, u32) -> Result<PlayedTry, String> + Send + Sync>;
387
388/// A [`Tunable`]'s writer: the files it wrote of a changed stage.
389pub type StageWrite<S> = Box<dyn Fn(&S) -> Result<Vec<String>, String>>;
390
391/// How the search has a setting played: given one try on the stage of
392/// that setting (entrant index, seed, the turn it stops at) and the tries
393/// to play, their report, judged. `autobalance` plays them on its threads.
394pub type Evaluate<'a> = dyn FnMut(
395        &(dyn Fn(usize, u64, u32) -> Result<PlayedTry, String> + Sync),
396        &Tries,
397    ) -> Result<PlaytestReport, String>
398    + 'a;
399
400/// A [`Tunable`] of any stage type, as `autobalance` runs it.
401pub trait TunableStage {
402    /// The search over the stage (`shuyi_core::autobalance_by`) as
403    /// `settings` say, each setting played by `evaluate`, asking
404    /// `should_stop` before each run; with `apply`, the stage found is
405    /// written if it changed.
406    fn search(
407        &self,
408        settings: &BalanceSettings,
409        evaluate: &mut Evaluate<'_>,
410        should_stop: &mut dyn FnMut() -> bool,
411        apply: bool,
412    ) -> Result<Searched, String>;
413}
414
415/// What [`TunableStage::search`] found.
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub struct Searched {
418    /// The changes, the reports before and after and why it stopped; the
419    /// stage itself stays the game's.
420    pub balanced: Balanced<()>,
421    /// With `apply`: the files the game wrote (none if nothing changed),
422    /// or why it couldn't.
423    pub written: Option<Result<Vec<String>, String>>,
424}
425
426impl<S: Clone + Sync> TunableStage for Tunable<S> {
427    fn search(
428        &self,
429        settings: &BalanceSettings,
430        evaluate: &mut Evaluate<'_>,
431        should_stop: &mut dyn FnMut() -> bool,
432        apply: bool,
433    ) -> Result<Searched, String> {
434        let knobs: Vec<&dyn Knob<S>> = self.knobs.iter().map(AsRef::as_ref).collect();
435        let play = &self.play;
436        let found = autobalance_by(
437            &self.stage,
438            &knobs,
439            settings,
440            |stage, tries| {
441                let one = |e: usize, seed: u64, turns: u32| play(stage, e, seed, turns);
442                evaluate(&one, tries)
443            },
444            should_stop,
445        )?;
446        let written = apply.then(|| {
447            if found.changes.is_empty() {
448                Ok(Vec::new())
449            } else {
450                (self.write)(&found.stage)
451            }
452        });
453        Ok(Searched {
454            balanced: Balanced {
455                stage: (),
456                changes: found.changes,
457                before: found.before,
458                after: found.after,
459                stop: found.stop,
460                evaluations: found.evaluations,
461            },
462            written,
463        })
464    }
465}
466
467/// What a game gives the commands that run it. Only `frame-png`'s and
468/// `lang-status`'s parts are required; a contract operation whose part a
469/// game leaves out answers that the game's tools don't offer it.
470pub trait GameTools {
471    /// The options the game adds to `frame-png`, which also start its
472    /// game for `run`, `scene` and `playtest` (none: `&[]`).
473    fn frame_options(&self) -> &[GameOption];
474
475    /// What else `frame-png --help` says about the game's pictures.
476    fn frame_notes(&self) -> &'static str {
477        ""
478    }
479
480    /// Starts the game in its Harness as `options` say, plays `script`
481    /// and paints the frame, each console pixel `scale` × `scale` (the
482    /// game's choice if `None`).
483    fn frame(
484        &self,
485        options: &[GameArg],
486        script: &Script,
487        scale: Option<u32>,
488    ) -> Result<Shot, String>;
489
490    /// `frame-png --target <id>` on a one-bit target: as
491    /// [`frame`](Self::frame), but painted as the target's display shows
492    /// it, most simply with [`to_one_bit`](shuyi_ui::frame::onebit::to_one_bit)
493    /// and [`one_bit_picture`](crate::frame_png::one_bit_picture), each of
494    /// its pixels `scale` × `scale` (1 if `None`).
495    fn frame_one_bit(
496        &self,
497        target: &str,
498        options: &[GameArg],
499        script: &Script,
500        scale: Option<u32>,
501    ) -> Result<Shot, String> {
502        let _ = (target, options, script, scale);
503        Err(unsupported(
504            "frame-png --target on a one-bit target",
505            "frame_one_bit",
506        ))
507    }
508
509    /// The game's languages.
510    fn lang(&self) -> Result<GameLang, String>;
511
512    /// `check`: every error of every validator of the game's content,
513    /// each with its file and, when known, its line.
514    fn check(&self) -> Result<Vec<ContentError>, String> {
515        Err(unsupported("check", "check"))
516    }
517
518    /// `run` and `scene`: the game started headless as `options` say
519    /// (its [`frame_options`](Self::frame_options)), most simply a
520    /// [`Headless`](crate::contract::session::Headless) over its Harness.
521    fn session(&self, options: &[GameArg]) -> Result<Box<dyn Session>, String> {
522        let _ = options;
523        Err(unsupported("run and scene", "session"))
524    }
525
526    /// `bench` on a one-bit target: the game started headless as `options`
527    /// say on target `target` (its console and its buttons), each frame
528    /// converted as the device converts it, most simply a
529    /// [`Headless`](crate::contract::session::Headless) over its Harness on
530    /// that target with a
531    /// [`set_present_hook`](shuyi_platform::harness::Harness::set_present_hook)
532    /// that runs [`to_one_bit`](shuyi_ui::frame::onebit::to_one_bit) on the
533    /// frame. Left at its default, `bench` measures [`session`](Self::session)
534    /// there and says the frames weren't converted.
535    fn session_on(&self, target: &str, options: &[GameArg]) -> Result<Box<dyn Session>, String> {
536        let _ = (target, options);
537        Err(unsupported("bench on a one-bit target", "session_on"))
538    }
539
540    /// `run --session` and `scene --session`: the game's real start, made
541    /// ready to play `record`, most simply a
542    /// [`Headless`](crate::contract::session::Headless) over
543    /// [`Harness::from_session`](shuyi_platform::harness::Harness::from_session),
544    /// its error as text. The engine plays it. Give [`made`](Self::made)
545    /// too, so a record another build made is refused as such.
546    fn session_from(
547        &self,
548        record: &SessionRecord,
549        stamps: Stamps,
550    ) -> Result<Box<dyn Session>, String> {
551        let _ = (record, stamps);
552        Err(unsupported(
553            "run --session and scene --session",
554            "session_from",
555        ))
556    }
557
558    /// `replay`: `record`, in the game's record format, re-run by its
559    /// rules (for a turn game, [`turns`](crate::contract::replay::turns)).
560    fn replay(&self, record: &Record) -> Result<ReplayRun, String> {
561        let _ = record;
562        Err(unsupported("replay", "replay"))
563    }
564
565    /// This build's stamp (`shuyi_core::made`): the game's word for its
566    /// build and the fingerprint of the bundle it ships, most simply
567    /// `Made::new(stamp, bundle.fingerprint())`. `replay` refuses a record
568    /// stamped otherwise, and `playtest` keeps it in its history. `None`,
569    /// the default: no record is checked.
570    fn made(&self) -> Option<Made> {
571        None
572    }
573
574    /// `playtest --stage`: the ids of the game's playtest stages, in the
575    /// order `--every-stage` plays them. Empty, the default: one unnamed
576    /// stage.
577    fn playtest_stages(&self) -> Vec<String> {
578        Vec::new()
579    }
580
581    /// `playtest`: the plan of `stage` (one of
582    /// [`playtest_stages`](Self::playtest_stages), `None` if there are
583    /// none), started as `input`'s game options say. Its numbers are those
584    /// the tries are played with, the game's defaults filled in. A game's
585    /// bot run is a plan too: one entrant and no checks.
586    fn playtest_plan(
587        &self,
588        stage: Option<&str>,
589        input: &PlaytestInput,
590    ) -> Result<PlaytestPlan, String> {
591        let _ = (stage, input);
592        Err(unsupported("playtest", "playtest_plan"))
593    }
594
595    /// `playtest`: how many tries, from which seed, stopped at which turn,
596    /// when the input doesn't say. The engine's defaults
597    /// ([`DEFAULT_TRIES`]) unless the game has its own.
598    fn playtest_defaults(&self) -> Tries {
599        DEFAULT_TRIES
600    }
601
602    /// `autobalance`: the plan of `stage` (one of
603    /// [`playtest_stages`](Self::playtest_stages), `None` if there are
604    /// none), started as `input`'s game options say. Its numbers are those
605    /// the search runs with, the defaults filled in.
606    fn autobalance_plan(
607        &self,
608        stage: Option<&str>,
609        input: &AutobalanceInput,
610    ) -> Result<AutobalancePlan, String> {
611        let _ = (stage, input);
612        Err(unsupported("autobalance", "autobalance_plan"))
613    }
614
615    /// `import`: the kinds and formats to read a file with. The engine's
616    /// standard ones unless the game adds its own.
617    fn importers(&self) -> Importers {
618        Importers::standard()
619    }
620
621    /// `check --target`: the game's keymap and every screen it can show,
622    /// for the check that its controls fit each target (ADR-0008 §3), most
623    /// simply a [`GameControls`](shuyi_ui::input::fit::GameControls).
624    /// `None`, the default, doesn't offer them: `check --target` then
625    /// checks the display alone and says so. An error (the game's files
626    /// don't load) fails the check.
627    fn controls(&self) -> Option<Result<Box<dyn Controls>, String>> {
628        None
629    }
630
631    /// `check`: the game's font atlas, for the check that it draws every
632    /// character of the game's text ([`lang`](Self::lang);
633    /// `shuyi_assets::font::undrawn`), most simply the atlas it loads.
634    /// `None`, the default, doesn't offer it: `check` then says the text
635    /// isn't checked against it. An error (the atlas doesn't load) fails
636    /// the check.
637    fn font_atlas(&self) -> Option<Result<FontAtlasDef, String>> {
638        None
639    }
640
641    /// `check --target` on a one-bit target: the skin's palette and its
642    /// pattern map for `target`, for the check that every role has
643    /// patterns and the roles that tell things apart stay distinct
644    /// (ADR-0008 §4; `shuyi_assets::onebit::check`). `None`, the default,
645    /// doesn't offer them, and `check --target` says they weren't checked.
646    /// An error (the files don't load) fails the check.
647    fn one_bit(&self, _target: &str) -> Option<Result<OneBitSkin, String>> {
648        None
649    }
650
651    /// `bake` and `check --baked`: what the game bakes for `target`
652    /// (`None` for its default bundle), from which bundle, read with which
653    /// formats. The target is the game's to pick layers and kinds by.
654    fn bake(&self, target: Option<&str>) -> Result<GameBake, String> {
655        let _ = target;
656        Err(unsupported("bake and check --baked", "bake"))
657    }
658
659    /// `export` on a target that bakes its audio (the Playdate's): the sounds
660    /// and music the game ships on `target`, whose profile's id it is. A game
661    /// that leaves this at its default is silent there.
662    fn audio(&self, target: &str) -> Result<GameAudio, String> {
663        let _ = target;
664        Err(unsupported("audio for a target that bakes it", "audio"))
665    }
666}
667
668/// What [`unsupported`]'s errors start with, which
669/// [`is_unsupported`] tells them by.
670const UNSUPPORTED: &str = "the game's tools don't offer ";
671
672/// What a game whose tools don't offer `op` answers: they lack the
673/// method `method`.
674pub fn unsupported(op: &str, method: &str) -> String {
675    format!("{UNSUPPORTED}{op} (GameTools::{method})")
676}
677
678/// Whether `error`, from a [`GameTools`] method, is [`unsupported`]'s:
679/// the game left the method at its default.
680pub fn is_unsupported(error: &str) -> bool {
681    error.starts_with(UNSUPPORTED)
682}
683
684/// The problem code of `error`, from a [`GameTools`] method:
685/// [`codes::NOT_OFFERED`] if [`is_unsupported`], else
686/// [`codes::GAME_ERROR`].
687pub fn code_of(error: &str) -> Code {
688    if is_unsupported(error) {
689        codes::NOT_OFFERED
690    } else {
691        codes::GAME_ERROR
692    }
693}
694
695/// Runs `command` with `args` in the game's tool program of `project`
696/// and returns its exit code; 3 if there is none, or cargo doesn't start
697/// ([`codes::PROJECT_FILE`], [`codes::TOOLCHAIN`]).
698pub fn run_in_game_program(project: &Project, command: &str, args: &[String]) -> u8 {
699    let Some(program) = &project.config.game else {
700        return crate::contract::cli::no_game(command, args);
701    };
702    let cargo = crate::cargo();
703    match Command::new(cargo)
704        .args(program.cargo_args(command, args))
705        .current_dir(&project.root)
706        .status()
707    {
708        Ok(status) => status
709            .code()
710            .and_then(|code| u8::try_from(code).ok())
711            .unwrap_or(1),
712        Err(e) => crate::contract::cli::setup_failed(
713            command,
714            args,
715            codes::TOOLCHAIN,
716            &format!("cannot run cargo: {e}"),
717        ),
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use shuyi_core::bots::playtest::Band;
725
726    /// A game that adds nothing to `frame-png`.
727    struct Plain;
728
729    impl GameTools for Plain {
730        fn frame_options(&self) -> &[GameOption] {
731            &[]
732        }
733
734        fn frame(&self, _: &[GameArg], _: &Script, _: Option<u32>) -> Result<Shot, String> {
735            Err("no frame".to_owned())
736        }
737
738        fn lang(&self) -> Result<GameLang, String> {
739            Ok(GameLang {
740                lang: Lang::default(),
741                variants: Vec::new(),
742            })
743        }
744    }
745
746    #[test]
747    fn a_game_adds_no_options_or_notes_unless_it_says_so() {
748        assert_eq!(Plain.frame_options(), &[]);
749        assert_eq!(Plain.frame_notes(), "");
750        let got = Plain.lang().unwrap().variants;
751        assert!(got.is_empty(), "{got:?}");
752        assert!(Plain.frame(&[], &Script::default(), None).is_err());
753    }
754
755    #[test]
756    fn a_game_offers_no_contract_operation_unless_it_says_so() {
757        assert_eq!(
758            Plain.check().unwrap_err(),
759            "the game's tools don't offer check (GameTools::check)"
760        );
761        let session = Plain.session(&[]).err().unwrap();
762        assert_eq!(
763            session,
764            "the game's tools don't offer run and scene (GameTools::session)"
765        );
766        let on = Plain.session_on("playdate", &[]).err().unwrap();
767        assert_eq!(
768            on,
769            "the game's tools don't offer bench on a one-bit target (GameTools::session_on)"
770        );
771        let record = Record {
772            file: "a.ron".to_owned(),
773            text: String::new(),
774        };
775        assert!(
776            Plain
777                .replay(&record)
778                .unwrap_err()
779                .contains("GameTools::replay")
780        );
781        let playtest = Plain
782            .playtest_plan(None, &PlaytestInput::default())
783            .err()
784            .unwrap();
785        assert!(playtest.contains("GameTools::playtest_plan"));
786        let balance = Plain
787            .autobalance_plan(None, &AutobalanceInput::default())
788            .err()
789            .unwrap();
790        assert_eq!(
791            balance,
792            "the game's tools don't offer autobalance (GameTools::autobalance_plan)"
793        );
794        let got = Plain.playtest_stages();
795        assert!(got.is_empty(), "{got:?}");
796        assert_eq!(Plain.playtest_defaults(), DEFAULT_TRIES);
797        assert_eq!(Plain.importers().kinds(), Importers::standard().kinds());
798        assert_eq!(Plain.made(), None);
799        assert!(Plain.controls().is_none());
800        assert!(Plain.font_atlas().is_none());
801        assert!(Plain.one_bit("playdate").is_none());
802        assert_eq!(
803            Plain
804                .frame_one_bit("playdate", &[], &Script::default(), None)
805                .unwrap_err(),
806            "the game's tools don't offer frame-png --target on a one-bit target \
807             (GameTools::frame_one_bit)"
808        );
809        assert_eq!(
810            Plain.bake(Some("desktop")).unwrap_err(),
811            "the game's tools don't offer bake and check --baked (GameTools::bake)"
812        );
813    }
814
815    #[test]
816    fn a_method_left_at_its_default_is_told_from_the_games_own_errors() {
817        let error = Plain
818            .playtest_plan(None, &PlaytestInput::default())
819            .err()
820            .unwrap();
821        assert!(is_unsupported(&error));
822        assert_eq!(code_of(&error), codes::NOT_OFFERED);
823        assert!(!is_unsupported("records/a.ron: no turn 4"));
824        assert_eq!(code_of("records/a.ron: no turn 4"), codes::GAME_ERROR);
825    }
826
827    #[test]
828    fn with_no_game_tool_program_a_command_that_runs_the_game_exits_3() {
829        let project = Project {
830            root: std::path::PathBuf::from("."),
831            config: crate::project::Config::default(),
832        };
833        assert_eq!(run_in_game_program(&project, "frame-png", &[]), 3);
834    }
835
836    #[test]
837    fn each_new_gives_the_literal_it_replaces() {
838        const SEED: GameOption = GameOption::new("--seed", "seed it").with_value(Some("N"));
839        let literal = GameOption {
840            flag: "--seed",
841            value: Some("N"),
842            help: "seed it",
843        };
844        assert_eq!(SEED, literal);
845        let plain = GameOption::new("--hard", "harder");
846        assert_eq!(
847            (plain.flag, plain.value, plain.help),
848            ("--hard", None, "harder")
849        );
850        let lang = GameLang::new(Lang::default(), vec!["m".to_owned()]);
851        assert_eq!(lang.variants, ["m"]);
852        let skin = OneBitSkin::new(
853            "p.ron",
854            PaletteDef::default(),
855            "m.ron",
856            PatternMapDef::default(),
857        );
858        let literal = OneBitSkin {
859            palette_file: "p.ron".to_owned(),
860            palette: PaletteDef::default(),
861            map_file: "m.ron".to_owned(),
862            map: PatternMapDef::default(),
863        };
864        assert_eq!(skin, literal);
865        let made = GameBake::new(Bake::new("v1"), Bundle::new(Vec::new()), Formats::new());
866        let literal = GameBake {
867            bake: Bake::new("v1"),
868            bundle: Bundle::new(Vec::new()),
869            formats: Formats::new(),
870        };
871        assert_eq!(format!("{made:?}"), format!("{literal:?}"));
872    }
873
874    #[test]
875    fn a_plan_starts_with_no_groups_and_every_entrant_by_default() {
876        let entrants = vec![
877            ("a".to_owned(), None),
878            ("b".to_owned(), Some("hard".to_owned())),
879        ];
880        let plan = PlaytestPlan::new(
881            "stage",
882            entrants.clone(),
883            Box::new(|names| vec![Check::rate(names[0], Vec::new(), Band { min: 0, max: 100 })]),
884            Box::new(|_, _, _| Err("no try".to_owned())),
885        );
886        assert_eq!(plan.title, "stage");
887        assert_eq!(plan.entrants, entrants);
888        assert!(plan.groups.is_empty() && plan.default_entrants.is_empty());
889        assert_eq!((plan.checks)(&["a"]).len(), 1);
890        assert_eq!((plan.play)(0, 0, 1).unwrap_err(), "no try");
891        let tunable = Tunable::new(
892            7_u32,
893            Vec::new(),
894            Box::new(|stage: &u32, _, _, _| Err(format!("stage {stage}"))),
895            Box::new(|_: &u32| Ok(vec!["s.ron".to_owned()])),
896        );
897        assert_eq!(tunable.stage, 7);
898        assert!(tunable.knobs.is_empty());
899        assert_eq!((tunable.play)(&3, 0, 0, 1).unwrap_err(), "stage 3");
900        assert_eq!((tunable.write)(&3).unwrap(), ["s.ron"]);
901        let plan = AutobalancePlan::new(
902            "tuned",
903            entrants.clone(),
904            Box::new(|_| Vec::new()),
905            Box::new(tunable),
906        );
907        assert_eq!(plan.title, "tuned");
908        assert_eq!(plan.entrants, entrants);
909        assert!(plan.groups.is_empty() && plan.default_entrants.is_empty());
910        let got = (plan.checks)(&["a"]);
911        assert!(got.is_empty(), "{got:?}");
912    }
913}
914
915#[cfg(test)]
916mod start_tests {
917    use super::*;
918
919    const OPTIONS: [GameOption; 2] = [
920        GameOption::new("--hard", "harder"),
921        GameOption::new("--seed", "seed it").with_value(Some("N")),
922    ];
923
924    fn arg(flag: &str, value: Option<&str>) -> GameArg {
925        GameArg {
926            flag: flag.to_owned(),
927            value: value.map(str::to_owned),
928        }
929    }
930
931    #[test]
932    fn nothing_given_is_fine_with_or_without_options() {
933        assert_eq!(check_start(&[], &[]), Ok(()));
934        assert_eq!(check_start(&OPTIONS, &[]), Ok(()));
935    }
936
937    #[test]
938    fn every_option_given_right_passes() {
939        let start = [arg("--hard", None), arg("--seed", Some("7"))];
940        assert_eq!(check_start(&OPTIONS, &start), Ok(()));
941    }
942
943    #[test]
944    fn a_wrong_option_is_named() {
945        let err = |start: &[GameArg]| check_start(&OPTIONS, start).unwrap_err();
946        assert_eq!(err(&[arg("--hrad", None)]), "unknown option: --hrad");
947        assert_eq!(err(&[arg("--seed", None)]), "--seed needs a value");
948        assert_eq!(err(&[arg("--hard", Some("1"))]), "--hard takes no value");
949        assert_eq!(
950            check_start(&[], &[arg("--hard", None)]).unwrap_err(),
951            "unknown option: --hard"
952        );
953    }
954}