1#![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;
64pub use serde_json;
66pub use shuyi_ui::frame::raster::test_card;
69
70pub 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
92pub 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
175pub 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 if Op::named(name).is_none() || !contract::cli::split_json(rest).0 {
213 eprintln!("{}", usage("shuyi"));
214 }
215 exit
216 }
217 }
218}
219
220fn mcp_host(project: &Project, args: &[String], game: Option<&dyn GameTools>) -> u8 {
223 match game {
224 Some(game) => {
225 if cfg!(windows) {
226 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 None => game::run_in_game_program(project, "mcp-host", args),
239 }
240}
241
242pub 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 return contract::cli::lint(project, args, &contract::lint::ThisMachine);
267 }
268 if name == "modules" || name == "add" {
269 return contract::cli::modules_or_add(project, name, args);
272 }
273 if contract::cli::checks_targets(name, args) {
274 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 "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
352type 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
405fn 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
473fn 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 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
512const NO_TEXT: &str = "--no-text";
514
515const WIDE: &str = "--wide";
517
518fn 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
539fn 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
691pub(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 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 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 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 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 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}