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}