Skip to main content

shuyi_cli/
lib.rs

1//! Shuyi Engine's tools, `shuyi`: the tool contract's operations (`new`,
2//! `check`, `lint`, `modules`, `add`, `run`, `scene`, `replay`, `playtest`, `autobalance`,
3//! `import`, `bake`, `export`, `doctor`, `explain`, `attach`, each with `--json`; [`contract`]),
4//! the exporters `export` runs ([`export`]), the contract as
5//! an MCP server with a session (`mcp`, `mcp install`; [`mcp`]), and `frame-png`, `font-atlas`, `app-info`,
6//! `glyph-tiles`, `check-keys`, `check-text`, `check-skills`, `lang-status`,
7//! `web` and `private-assets`, each a module of library functions with a
8//! thin wrapper here; `bench`, which plays the game's benchmark scenarios
9//! and gates them against its targets' budgets (`docs/targets/README.md`); the engine's modules as `modules` and `add` read them
10//! ([`catalog`]); templates made of parts, which `new` composes
11//! ([`template`]); the engine's skill pack, which `new` copies into a game
12//! and `check-skills` checks ([`skills`]); the notices a package carries
13//! ([`notices`]); and the pool of threads the slow
14//! ones play on ([`pool`]).
15//!
16//! They run on a project: the folder holding a `shuyi.ron`
17//! ([`project`]), which says what each command scans and builds; `new`,
18//! `doctor`, `explain`, `attach`, `schema` and `check-skills` need none. The commands that run the game itself
19//! get what they need from it ([`GameTools`]) in a program of its own that
20//! calls [`main_with`], and `shuyi` runs that program (`shuyi.ron`'s
21//! `game`) for them. A game on the short path gives [`BasicTools`]
22//! ([`basic`]).
23//!
24//! A CLI tool prints to stdout by design, so `print_stdout` is allowed here.
25#![allow(clippy::print_stdout)]
26
27pub mod app_info;
28pub mod basic;
29mod bench;
30pub mod catalog;
31pub mod check_keys;
32pub mod check_text;
33pub mod codec;
34pub mod contract;
35pub mod export;
36pub mod font_atlas;
37pub mod frame_png;
38pub mod game;
39pub mod glyph_tiles;
40pub mod lang_status;
41pub mod mcp;
42pub mod notices;
43pub mod pool;
44pub mod private_assets;
45pub mod project;
46pub mod skills;
47pub mod template;
48#[cfg(feature = "toy")]
49pub mod toy;
50pub mod watch;
51pub mod web;
52
53use std::path::{Path, PathBuf};
54
55use contract::Op;
56use contract::codes;
57
58pub use basic::BasicTools;
59pub use game::{
60    AutobalancePlan, GameArg, GameAudio, GameBake, GameLang, GameOption, GameTools, OneBitSkin,
61    PlaytestPlan, Tunable, check_start, game_arg,
62};
63pub use project::Project;
64/// JSON, for the scenes a game gives ([`contract::session::Scene::data`]).
65pub use serde_json;
66/// The test card, a generated test image for sprite items (it lives with
67/// the raster in `shuyi-ui`).
68pub use shuyi_ui::frame::raster::test_card;
69
70/// The commands of `shuyi` that aren't operations of the contract
71/// ([`Op::ALL`]), as [`usage`] lists them: with the contract's operations
72/// and the MCP session's tools, the commands a skill may name
73/// ([`skills::check`]).
74pub const COMMANDS: [&str; 15] = [
75    "schema",
76    "mcp",
77    "mcp-host",
78    "check-keys",
79    "check-text",
80    "check-skills",
81    "lang-status",
82    "font-atlas",
83    "app-info",
84    "glyph-tiles",
85    "frame-png",
86    "web",
87    "private-assets",
88    "bench",
89    "bench-play",
90];
91
92/// The usage text, with `command` as the tool's name.
93pub fn usage(command: &str) -> String {
94    format!(
95        "\
96usage: {command} <command>
97
98the tool contract (each also with --json; a wrong argument prints its usage):
99  new <dir> [--template NAME]        make a new game from the engine's template: grid, blank;
100                                     --answers FILE, --set QUESTION=ANSWER pick its parts
101  check [--baked]                    every error of the game's content, with file and line;
102                                     --baked: and every way its pack differs from its sources
103  check --target <id | all>          whether the frame, its text and the controls fit a target
104  lint                               the game's code: check-keys, check-text and the rules
105                                     crate's fence, each finding with file and line
106  modules                            the engine's modules by concern, and the ones the game has on
107  add <module>                       turn a module on: its feature in Cargo.toml, its skill
108  run [steps] [--frames N]          play a key script headless: screens, scene, events, sounds
109  run --watch                        the game in its window, its files on disk reloading as
110                                     they change (a debug build)
111  scene [steps]                     the scene on screen after a script
112  replay <record> [<other>] [--any-stamp]
113                                     a state hash per step; where two runs part; a
114                                     record made by another build: refused, or run
115  playtest [--stage ID] [--entrant NAME] [--tries N]
116                                     each named bot's tries, on every core, against the
117                                     game's bands (or none), and what they were the last
118                                     run; --json: each try's commands and record
119  autobalance [--stage ID] [--budget N] [--time SECONDS] [--apply]
120                                     change the stage until each named bot is in its band:
121                                     the changes, before and after; --apply writes it
122  import <file> [--print]            read a file through the format registry
123  bake [--target <id>] [--out <file>]
124                                     bake the game's sources into one pack: sizes and times
125  export <windows | linux | macos | web | playdate> [--out <dir>] [--release] [--run]
126                                     check the game against the target, then build and package
127                                     it, with NOTICES.txt, export-report.json and
128                                     exposure-report.json
129  doctor                             check the toolchain and say how to fix it
130  explain [<code>]                   every problem code (SH2003), or one code's page
131  attach <host:port> --token <t> [steps] [--screenshot <file.png>]
132                                     a running debug build over the live link: play steps
133                                     as a player, then its screens, frame, log and picture
134  schema [<name> | --out <dir>]      the contract's JSON schemas
135  mcp                                serve the contract over MCP (stdio), with a session
136  mcp --ready                        build the server and the game's tool program, then exit
137  mcp install [--dry-run] [--yes]    register the server with the agents found here
138
139commands:
140  check-keys                         fail on keys hard-coded in game code or text
141  check-text                         count screen text still written as literals,
142                                     and fail on a screen that reads a data name itself
143  check-skills [<dir>] [--tickets <dir>]
144                                     check a skill pack (skills/ or .claude/skills/): each
145                                     skill's frontmatter, the commands and files it names,
146                                     its length, its worked example and its problem codes
147  lang-status <code> [--only ui|data|dialogue] [--file <dlg>]
148                                     list a language pack's missing, stale and orphaned text
149  font-atlas [--no-text] [--wide <wide.bdf>] <font.bdf>... <out-dir>
150                                     build the font atlas from BDF fonts, with the characters
151                                     the game's text uses; --no-text: without them; --wide:
152                                     two cells wide from a font twice as wide (Japanese)
153  app-info [--check]                 write who the game is ({file}'s `app`) as its Rust
154                                     constant; --check: fail if it is out of date
155  glyph-tiles                        draw a tileset of the font's glyphs on colored squares
156  frame-png <out.png> [steps]        render a scripted game frame to a PNG (frame-png --help)
157  bench [--target <id>] [--scenario <name>] [--out <dir>] [--no-instructions]
158                                     play the game's *.bench.ron scenarios headless and gate
159                                     instructions per frame (Valgrind), the heap's peak and
160                                     the package's size against its targets' budgets
161  web [--release] [--<feature>...]   build and package the web (WASM) build
162  private-assets [--library | --pin] fetch the private files into their checkout, or pin it
163  mcp-host                           the game's side of `mcp`: JSON lines on stdin and stdout
164  bench-play <file> --out <file>     the game's side of `bench`: one scenario, its frames
165
166It runs on the project in the first folder from here up that holds a
167{file} (new, doctor, explain, attach, schema, check-skills and mcp need none). An error has a problem
168code: `{command} explain <code>` says what it means. The exit code is 0
169when it passed, 1 when not, 2 for a wrong command, and 3 for a setup to fix
170({command} doctor, {file}) before running it again.",
171        file = project::FILE
172    )
173}
174
175/// Runs the command `args` on the project of the working folder, with the
176/// game's tools if this is the game's tool program, and returns the exit
177/// code: 0 for success, 1 for a failed check or step, 2 for wrong
178/// arguments (`SH0nnn`), 3 for a setup to fix before running it again
179/// (`SH1nnn`: no project, a project file that lacks what the command
180/// needs, no game tool program, cargo that doesn't start).
181pub fn main_with(args: impl Iterator<Item = String>, game: Option<&dyn GameTools>) -> u8 {
182    let args: Vec<String> = args.collect();
183    let here = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
184    let project = Project::find(&here);
185    if let Some((name, rest)) = args.split_first() {
186        let command = project
187            .as_ref()
188            .map_or("shuyi", |project| project.config.command());
189        if let Some(code) = contract::cli::without_project(name, rest, &here, command) {
190            return code;
191        }
192        if name == "check-skills" {
193            return check_skills(&here, rest, command);
194        }
195        if name == "import" && project.is_err() {
196            let (json, rest) = contract::cli::split_json(rest);
197            let importers = game.map_or_else(contract::import::Importers::standard, |game| {
198                game.importers()
199            });
200            let limit = "no project here: only the engine's standard kinds were tried";
201            return contract::cli::import(json, &rest, &importers, &here, Some(limit), "shuyi");
202        }
203    }
204    match project {
205        Ok(project) => run(&project, &args, game),
206        Err((code, e)) => {
207            let (name, rest) = args
208                .split_first()
209                .map_or(("shuyi", &[][..]), |(name, rest)| (name.as_str(), rest));
210            let exit = contract::cli::setup_failed(name, rest, code, &e);
211            // An op asked for JSON gets the answer alone.
212            if Op::named(name).is_none() || !contract::cli::split_json(rest).0 {
213                eprintln!("{}", usage("shuyi"));
214            }
215            exit
216        }
217    }
218}
219
220/// `mcp-host` on `project`: served by the game's tools if they are at hand,
221/// else in the game's tool program.
222fn mcp_host(project: &Project, args: &[String], game: Option<&dyn GameTools>) -> u8 {
223    match game {
224        Some(game) => {
225            if cfg!(windows) {
226                // The server's `start` relinks this program.
227                mcp::aside::this_program();
228            }
229            let stdin = std::io::stdin();
230            mcp::host::serve(
231                game,
232                project.clone(),
233                stdin.lock(),
234                std::io::stdout().lock(),
235            )
236        }
237        // It says so if the project names no game tool program.
238        None => game::run_in_game_program(project, "mcp-host", args),
239    }
240}
241
242/// Runs the command `args` on `project`, with the game's tools if they are
243/// at hand (otherwise a command that runs the game runs the game's tool
244/// program), and returns the exit code (see [`main_with`]).
245pub fn run(project: &Project, args: &[String], game: Option<&dyn GameTools>) -> u8 {
246    let command = project.config.command();
247    let Some((name, args)) = args.split_first() else {
248        println!("{}", usage(command));
249        return 2;
250    };
251    if let Some(code) = contract::cli::without_project(name, args, &project.root, command) {
252        return code;
253    }
254    if name == "check-skills" {
255        let here = std::env::current_dir().unwrap_or_else(|_| project.root.clone());
256        return check_skills(&here, args, command);
257    }
258    if name == "run" && args.iter().any(|arg| arg == "--watch") {
259        return watch::main(project, args);
260    }
261    if name == "mcp-host" {
262        return mcp_host(project, args, game);
263    }
264    if name == "lint" {
265        // On the project's files and cargo: in this program or the game's.
266        return contract::cli::lint(project, args, &contract::lint::ThisMachine);
267    }
268    if name == "modules" || name == "add" {
269        // On the project's files and its engine's: in this program or the
270        // game's.
271        return contract::cli::modules_or_add(project, name, args);
272    }
273    if contract::cli::checks_targets(name, args) {
274        // The controls need the game's tools: in its tool program.
275        let checked = |game| {
276            if name == "export" {
277                contract::cli::export(project, args, game)
278            } else {
279                contract::cli::check_targets(project, args, game)
280            }
281        };
282        return match game {
283            Some(game) => checked(Some(game)),
284            None if project.config.game.is_some() => game::run_in_game_program(project, name, args),
285            None => checked(None),
286        };
287    }
288    if contract::cli::runs_the_game(name) {
289        return match game {
290            Some(game) => contract::cli::with_game(project, name, args, game),
291            None if name == "import" && project.config.game.is_none() => {
292                let (json, rest) = contract::cli::split_json(args);
293                let limit = format!(
294                    "{} names no game tool program: only the engine's standard kinds were tried",
295                    project::FILE
296                );
297                contract::cli::import(
298                    json,
299                    &rest,
300                    &contract::import::Importers::standard(),
301                    &project.root,
302                    Some(&limit),
303                    command,
304                )
305            }
306            None if project.config.game.is_none() => contract::cli::no_game(name, args),
307            None => game::run_in_game_program(project, name, args),
308        };
309    }
310    let outcome = match name.as_str() {
311        "check-keys" => check_keys(project, args),
312        "check-text" => check_text(project, args),
313        "lang-status" => match game {
314            Some(game) => Ok(lang_status(project, args, game)),
315            None => Ok(game::run_in_game_program(project, name, args)),
316        },
317        "font-atlas" => match game {
318            None if project.config.game.is_some() && !args.iter().any(|a| a == NO_TEXT) => Ok(
319                game::run_in_game_program(project, name, &from_the_root(project, args)),
320            ),
321            game => font_atlas(project, args, game),
322        },
323        "app-info" => app_info(project, args),
324        "glyph-tiles" => glyph_tiles(project, args),
325        "frame-png" => match game {
326            Some(game) => Ok(frame_png(project, args, game)),
327            None => Ok(game::run_in_game_program(project, name, args)),
328        },
329        // Builds the game's tool program and runs it: from either program.
330        "bench" => Ok(bench::main(project, args)),
331        "bench-play" => match game {
332            Some(game) => Ok(bench::play_main(project, args, game)),
333            None => Ok(game::run_in_game_program(project, name, args)),
334        },
335        "web" => web(project, args),
336        "private-assets" => private_assets(project, args),
337        other => {
338            let error = format!("unknown command: {other}");
339            eprint!(
340                "{}",
341                codes::said(command, codes::WRONG_ARGUMENTS.0, &error, None)
342            );
343            eprintln!("{}", usage(command));
344            Ok(2)
345        }
346    };
347    outcome.unwrap_or_else(|missing| {
348        contract::cli::setup_failed(name, args, codes::PROJECT_FILE, &missing)
349    })
350}
351
352/// A command's exit code, or the error that the project file lacks its
353/// part (a [`codes::PROJECT_FILE`] problem).
354type Outcome = Result<u8, String>;
355
356fn check_keys(project: &Project, args: &[String]) -> Outcome {
357    let rules = project.part(&project.config.check_keys, "check_keys")?;
358    if !args.is_empty() {
359        eprintln!("usage: {} check-keys", project.config.command());
360        return Ok(2);
361    }
362    let errors = check_keys::run(rules, &project.root);
363    if errors.is_empty() {
364        println!("check-keys: OK");
365        return Ok(0);
366    }
367    eprintln!("check-keys: {} hard-coded key(s)", errors.len());
368    for error in &errors {
369        eprintln!("  {error}");
370    }
371    Ok(1)
372}
373
374fn check_text(project: &Project, args: &[String]) -> Outcome {
375    let rules = project.part(&project.config.check_text, "check_text")?;
376    if !args.is_empty() {
377        eprintln!("usage: {} check-text", project.config.command());
378        return Ok(2);
379    }
380    let (hits, mut errors) = check_text::run(rules, &project.root);
381    print!(
382        "{}",
383        check_text::report(&rules.dir, &hits, rules.max_literals)
384    );
385    errors.extend(check_text::run_names(rules, &project.root));
386    for error in &errors {
387        eprintln!("check-text: {error}");
388    }
389    Ok(
390        match check_text::verdict(&rules.hint, hits.len(), rules.max_literals) {
391            Ok(note) => {
392                if let Some(note) = note {
393                    println!("{note}");
394                }
395                u8::from(!errors.is_empty())
396            }
397            Err(e) => {
398                eprintln!("{e}");
399                1
400            }
401        },
402    )
403}
404
405/// `check-skills [<dir>] [--tickets <dir>]`, from the folder `cwd`: every
406/// problem of the skill pack in `<dir>` (`skills`, or else
407/// `.claude/skills`), with its file and line, the ids its skills wait for
408/// checked against the tickets in `--tickets`' folder when given. The exit
409/// code is 0 when there is none, 1 when there are, 2 for wrong arguments.
410fn check_skills(cwd: &Path, args: &[String], command: &str) -> u8 {
411    let wrong = |e: &str| {
412        eprintln!("check-skills: {e}");
413        eprintln!("usage: {command} check-skills [<dir>] [--tickets <dir>]");
414        2
415    };
416    let mut dir = None;
417    let mut tickets = None;
418    let mut rest = args.iter();
419    while let Some(arg) = rest.next() {
420        match arg.as_str() {
421            "--tickets" => match rest.next() {
422                Some(value) => tickets = Some(cwd.join(value)),
423                None => return wrong("--tickets needs a folder"),
424            },
425            flag if flag.starts_with("--") => return wrong(&format!("unknown option {flag}")),
426            _ if dir.is_none() => dir = Some(arg.clone()),
427            _ => return wrong("one folder, not two"),
428        }
429    }
430    let dir = dir.unwrap_or_else(|| {
431        if cwd.join(skills::PACK).is_dir() {
432            skills::PACK.to_owned()
433        } else {
434            catalog::SKILLS.to_owned()
435        }
436    });
437    let folder = cwd.join(&dir);
438    if !folder.is_dir() {
439        return wrong(&format!("no folder {dir} here"));
440    }
441    let against = match skills::Against::this_shuyi(tickets.as_deref()) {
442        Ok(against) => against,
443        Err(e) => return wrong(&e),
444    };
445    let pack = match skills::Pack::read(&folder) {
446        Ok(pack) => pack,
447        Err(e) => {
448            eprintln!("check-skills: {e}");
449            return 1;
450        }
451    };
452    let problems = skills::check(&pack, &against);
453    let dir = dir.replace('\\', "/");
454    let dir = dir.trim_end_matches('/');
455    for problem in &problems {
456        eprintln!("{dir}/{problem}");
457    }
458    if against.open_tickets.is_none() {
459        println!(
460            "check-skills: the ids in `waits_for` weren't checked: --tickets names the open \
461             tickets"
462        );
463    }
464    if problems.is_empty() {
465        println!("check-skills: {} skill(s) in {dir}: OK", pack.skills.len());
466        0
467    } else {
468        eprintln!("check-skills: {} problem(s) in {dir}", problems.len());
469        1
470    }
471}
472
473/// Prints what a language pack lacks, and its screen text too long for
474/// its place. None of it is a failure: the exit code is 0 unless the game's languages don't load (1)
475/// or there is no such pack (2).
476fn lang_status(project: &Project, args: &[String], game: &dyn GameTools) -> u8 {
477    let (code, options) = match lang_status::parse_args(args) {
478        Ok(parsed) => parsed,
479        Err(e) => {
480            eprintln!("lang-status: {e}");
481            eprintln!(
482                "usage: {} lang-status <code> [--only ui|data|dialogue] [--file <dlg>]",
483                project.config.command()
484            );
485            return 2;
486        }
487    };
488    let lang = match game.lang() {
489        Ok(lang) => lang,
490        Err(e) => {
491            eprintln!("lang-status: {e}");
492            return 1;
493        }
494    };
495    // The screen text is measured as the game's atlas draws it.
496    let widths = match game.font_atlas() {
497        Some(Ok(atlas)) => shuyi_assets::font::CellWidths::of(&atlas),
498        _ => shuyi_assets::font::CellWidths::NARROW,
499    };
500    match lang_status::report(&lang.lang, &lang.variants, &widths, &code, &options) {
501        Ok(report) => {
502            print!("{report}");
503            0
504        }
505        Err(e) => {
506            eprintln!("lang-status: {e}");
507            2
508        }
509    }
510}
511
512/// `font-atlas`'s flag for an atlas without the game's text.
513const NO_TEXT: &str = "--no-text";
514
515/// `font-atlas`'s option naming the wide font.
516const WIDE: &str = "--wide";
517
518/// `args` (paths), each relative one made relative to `project`'s root
519/// rather than the working folder, for the game's tool program, which runs
520/// there.
521fn from_the_root(project: &Project, args: &[String]) -> Vec<String> {
522    let Ok(here) = std::env::current_dir() else {
523        return args.to_vec();
524    };
525    if here == project.root {
526        return args.to_vec();
527    }
528    let moved = |arg: &String| {
529        let path = Path::new(arg);
530        if arg.starts_with('-') || path.is_absolute() {
531            arg.clone()
532        } else {
533            here.join(path).to_string_lossy().into_owned()
534        }
535    };
536    args.iter().map(moved).collect()
537}
538
539/// `font-atlas`: with the game's tools, the atlas keeps the characters its
540/// text uses too; without them (or with `--no-text`), the ranges and the
541/// required glyphs only. `--wide [<set>=]<font>` adds a wide font, once
542/// per set.
543fn font_atlas(project: &Project, args: &[String], game: Option<&dyn GameTools>) -> Outcome {
544    let rules = project.part(&project.config.font_atlas, "font_atlas")?;
545    let command = project.config.command();
546    let no_text = args.iter().any(|a| a == NO_TEXT);
547    let mut rest: Vec<&String> = Vec::new();
548    let mut wides: Vec<font_atlas::WideFont> = Vec::new();
549    let mut words = args.iter().filter(|a| *a != NO_TEXT);
550    let mut usage = false;
551    while let Some(word) = words.next() {
552        if word == WIDE {
553            let wide = words.next().map(|arg| font_atlas::WideFont::parse(arg));
554            match wide {
555                Some(wide) if !wides.iter().any(|w| w.set == wide.set) => wides.push(wide),
556                _ => usage = true,
557            }
558        } else {
559            rest.push(word);
560        }
561    }
562    let Some((out_dir, fonts @ [_, ..])) = rest.split_last().filter(|_| !usage) else {
563        eprintln!(
564            "usage: {command} font-atlas [{NO_TEXT}] [{WIDE} [<set>=]<wide.bdf>]... <font.bdf>... \
565             <out-dir> (one {WIDE} a set)"
566        );
567        return Ok(2);
568    };
569    let fonts: Vec<PathBuf> = fonts.iter().map(PathBuf::from).collect();
570    let chars = if let Some(game) = game.filter(|_| !no_text) {
571        match game.lang() {
572            Ok(lang) => font_atlas::AtlasChars::of_lang(&lang.lang, &rules.required_glyphs),
573            Err(e) => {
574                eprintln!("font-atlas: the game's text doesn't load: {e}");
575                eprintln!("font-atlas: {NO_TEXT} remakes the atlas without the text");
576                return Ok(1);
577            }
578        }
579    } else {
580        let why = if no_text {
581            NO_TEXT.to_owned()
582        } else {
583            format!("{} names no game tool program (`game`)", project::FILE)
584        };
585        println!(
586            "font-atlas: keeping the ranges and the required glyphs only, not the text's \
587             characters: {why}"
588        );
589        let none = shuyi_assets::lang::Lang::default();
590        font_atlas::AtlasChars::of_lang(&none, &rules.required_glyphs)
591    };
592    let mut chars = chars.with_wide(&rules.wide_glyphs);
593    for (set, glyphs) in &rules.wide_set_glyphs {
594        chars = chars.with_wide_set(Some(set), glyphs);
595    }
596    let out_dir = Path::new(out_dir);
597    Ok(
598        match font_atlas::run(rules, command, &fonts, &wides, out_dir, &chars) {
599            Ok(summary) => {
600                println!("{summary}");
601                0
602            }
603            Err(e) => {
604                eprintln!("font-atlas: {e}");
605                1
606            }
607        },
608    )
609}
610
611fn app_info(project: &Project, args: &[String]) -> Outcome {
612    let part = project.part(&project.config.app, "app")?;
613    let command = project.config.command();
614    let Ok(check) = app_info::parse_args(args) else {
615        eprintln!("usage: {command} app-info [--check]");
616        return Ok(2);
617    };
618    Ok(match app_info::run(part, &project.root, command, check) {
619        Ok(summary) => {
620            println!("{summary}");
621            0
622        }
623        Err(e) => {
624            eprintln!("app-info: {e}");
625            1
626        }
627    })
628}
629
630fn glyph_tiles(project: &Project, args: &[String]) -> Outcome {
631    let rules = project.part(&project.config.glyph_tiles, "glyph_tiles")?;
632    if !args.is_empty() {
633        eprintln!("usage: {} glyph-tiles", project.config.command());
634        return Ok(2);
635    }
636    Ok(match glyph_tiles::run(rules, &project.root) {
637        Ok(summary) => {
638            println!("{summary}");
639            0
640        }
641        Err(e) => {
642            eprintln!("glyph-tiles: {e}");
643            1
644        }
645    })
646}
647
648fn frame_png(project: &Project, args: &[String], game: &dyn GameTools) -> u8 {
649    let usage = || {
650        frame_png::usage(
651            project.config.command(),
652            game.frame_options(),
653            game.frame_notes(),
654        )
655    };
656    if args.iter().any(|a| a == "--help") {
657        println!("{}", usage());
658        return 0;
659    }
660    let options = match frame_png::parse_args(args, game.frame_options()) {
661        Ok(options) => options,
662        Err(e) => {
663            eprintln!("{e}\n\n{}", usage());
664            return 2;
665        }
666    };
667    let target = match options
668        .target
669        .as_deref()
670        .map(|id| contract::target::profile(&project.config, &project.root, id))
671    {
672        None => None,
673        Some(Ok(found)) => Some(found.profile),
674        Some(Err((code, e))) => {
675            eprintln!("{}", contract::codes::said("frame-png", code.0, &e, None));
676            return contract::codes::exit_code(Some(code.0));
677        }
678    };
679    match frame_png::run(&project.root, &options, game, target.as_ref()) {
680        Ok(summary) => {
681            println!("{summary}");
682            0
683        }
684        Err(e) => {
685            eprintln!("frame-png: {e}");
686            1
687        }
688    }
689}
690
691/// The `cargo` to run: the `CARGO` cargo sets when it runs us, else
692/// `cargo` from the `PATH`.
693pub(crate) fn cargo() -> std::ffi::OsString {
694    std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())
695}
696
697fn web(project: &Project, args: &[String]) -> Outcome {
698    let rules = project.part(&project.config.web, "web")?;
699    let options = match web::parse_args(rules, project.config.command(), args) {
700        Ok(options) => options,
701        Err(e) => {
702            eprintln!("{e}");
703            return Ok(2);
704        }
705    };
706    let game = match &project.config.notices {
707        Some(notices) => notices::game_texts(&project.root, notices),
708        None => Ok(Vec::new()),
709    };
710    let result = game.and_then(|game| web::run(rules, &project.root, &options, &game));
711    Ok(match result {
712        Ok(summary) => {
713            println!("{summary}");
714            0
715        }
716        Err(e) => {
717            eprintln!("web: {e}");
718            1
719        }
720    })
721}
722
723fn private_assets(project: &Project, args: &[String]) -> Outcome {
724    let rules = project.part(&project.config.private_assets, "private_assets")?;
725    let command = project.config.command();
726    let mode = match private_assets::parse_args(rules, command, args) {
727        Ok(mode) => mode,
728        Err(e) => {
729            eprintln!("{e}");
730            return Ok(2);
731        }
732    };
733    Ok(
734        match private_assets::run(rules, command, &project.root, &rules.url, mode) {
735            Ok(summary) => {
736                println!("{summary}");
737                0
738            }
739            Err(e) => {
740                eprintln!("private-assets: {e}");
741                1
742            }
743        },
744    )
745}
746
747#[cfg(test)]
748mod tests {
749    use std::fs;
750
751    use shuyi_assets::lang::Lang;
752
753    use super::*;
754    use crate::contract::fake::Fake;
755
756    /// A 4 × 4 font of `?`, `A` and `Ж`.
757    const FONT: &str = "STARTFONT 2.1\nFONTBOUNDINGBOX 4 4 0 0\n\
758        STARTCHAR q\nENCODING 63\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n\
759        STARTCHAR A\nENCODING 65\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\n\
760        STARTCHAR zhe\nENCODING 1046\nBBX 1 1 0 0\nBITMAP\n80\nENDCHAR\nENDFONT\n";
761
762    #[test]
763    fn font_atlas_keeps_the_characters_the_games_text_uses() {
764        let dir = std::env::temp_dir().join(format!("shuyi-font-game-{}", std::process::id()));
765        fs::create_dir_all(&dir).unwrap();
766        fs::write(dir.join("f.bdf"), FONT).unwrap();
767        let project = Project {
768            root: dir.clone(),
769            config: project::Config::parse(r#"(font_atlas: (required_glyphs: "?"))"#).unwrap(),
770        };
771        let path = |name: &str| dir.join(name).to_string_lossy().into_owned();
772        let args = [path("f.bdf"), path("out")];
773        let english = [("a.one".to_owned(), "\u{416}A".to_owned())];
774        let game = Fake {
775            lang: Ok(Lang::new(english.into(), std::collections::BTreeMap::new())),
776            ..Fake::default()
777        };
778        let atlas = || fs::read_to_string(dir.join("out/atlas.ron")).unwrap();
779        assert_eq!(font_atlas(&project, &args, Some(&game)), Ok(0));
780        assert!(atlas().contains('\u{416}'), "{}", atlas());
781        // Without the text, the ranges and the required glyphs only.
782        let no_text = [NO_TEXT.to_owned(), path("f.bdf"), path("out")];
783        assert_eq!(font_atlas(&project, &no_text, Some(&game)), Ok(0));
784        assert!(!atlas().contains('\u{416}'), "{}", atlas());
785        // A text that doesn't load fails, before anything is written.
786        let broken = Fake {
787            lang: Err("lang/en/ui.ron: bad".to_owned()),
788            ..Fake::default()
789        };
790        assert_eq!(font_atlas(&project, &args, Some(&broken)), Ok(1));
791        // `--wide` names one font a set, never two, and never none.
792        let words = |words: &[&str]| words.iter().map(|w| (*w).to_owned()).collect::<Vec<_>>();
793        let twice = [words(&[WIDE, "w.bdf", WIDE, "v.bdf"]), args.to_vec()].concat();
794        let set_twice = [words(&[WIDE, "ja=w.bdf", WIDE, "ja=v.bdf"]), args.to_vec()].concat();
795        let none = [args.to_vec(), words(&[WIDE])].concat();
796        for wrong in [twice, set_twice, none] {
797            assert_eq!(
798                font_atlas(&project, &wrong, Some(&game)),
799                Ok(2),
800                "{wrong:?}"
801            );
802        }
803        // Run from another folder than the root, a relative path is made
804        // absolute for the tool program, which runs in the root.
805        assert_eq!(from_the_root(&project, &args), args);
806        let here = std::env::current_dir().unwrap().join("f.bdf");
807        let relative = ["f.bdf".to_owned(), NO_TEXT.to_owned()];
808        assert_eq!(
809            from_the_root(&project, &relative),
810            [here.to_string_lossy().into_owned(), NO_TEXT.to_owned()]
811        );
812        fs::remove_dir_all(&dir).unwrap();
813    }
814}