Skip to main content

shuyi_cli/
contract.rs

1//! The tool contract (ADR-0005 ยง1, ADR-0022): the operations an agent, CI
2//! or a person runs on a game, each a library function with a typed input
3//! and output that serde reads and writes and JSON schemas describe. The
4//! CLI (`shuyi <op> [--json]`) is one surface over them; the MCP server
5//! (`shuyi mcp`, [`crate::mcp`]) is another, where each is a tool and a
6//! session keeps one game running between calls.
7//!
8//! | Op | Does | Module |
9//! | -- | ---- | ------ |
10//! | `new <dir>` | a new game from the engine's template, `templates/shuyi-default`, its parts picked by the answers (`--template grid`, `--answers`, `--set`) | [`new`] |
11//! | `check [--baked]` | every error of every validator of the game's content; with `--baked`, every way its pack differs from its sources | [`check`] |
12//! | `check --target <id>` | whether the game's frame, text and controls fit a target | [`check`], [`target`] |
13//! | `lint` | the game's code: `check-keys`, `check-text` and the rules crate's fence, each finding with file and line | [`lint`] |
14//! | `modules` | the catalog by concern, the modules the game's engine builds, and the ones the game has on | [`modules`] |
15//! | `add <module>` | a module turned on: its feature of `shuyi` in `Cargo.toml`, its skill copied, what it is for, an alternative already on | [`add`] |
16//! | `run` | plays a key script headless: the top screen, the scene, events, sounds; with `--perf`, the frames' times | [`run`], [`perf`] |
17//! | `scene` | the scene or stage on screen after a script; with `--path` one value of its data, with `--shape` its shape | [`run`] |
18//! | `replay <record>` | a state hash per step, and the first step where two runs differ | [`replay`] |
19//! | `playtest` | the game's named bots each play K tries on every core, timed and judged against its bands (none: a plain bot run); each try's commands and record for `replay`, each bot's outcomes counted and the spread of its numbers | [`playtest`] |
20//! | `autobalance` | the game's knobs change a stage until its named bots land in their bands: the changes, the reports before and after; with `--apply`, the stage written | [`autobalance`] |
21//! | `import <file>` | a file read through the format registry, or its errors | [`import`] |
22//! | `bake [--target <id>]` | the game's sources baked into one pack: sizes and times | [`bake`] |
23//! | `export <target>` | the target's check, then the game built and packaged for it: a zip, a `tar.gz`, an `.app` or a web folder, with `NOTICES.txt`, `export-report.json` and `exposure-report.json` | [`export`] |
24//! | `doctor` | the toolchain: what is missing and how to install it | [`doctor`] |
25//! | `explain [<code>]` | every problem code, or one code's page | [`explain`] |
26//! | `attach <host:port>` | a running debug build over the live link: steps played as a player's input, then its screens, frame, log and window's picture | [`attach`] |
27//!
28//! Every answer is an [`Answer`]: the contract's [`VERSION`], the
29//! operation, whether it passed, its result or the error that stopped it,
30//! and its **limits**, what it could not determine ("no scene: the top
31//! screen is no stage"). An error carries a problem code and a help line
32//! ([`codes`]), and the code's class sets the exit code: 0 passed, 1
33//! failed, 2 wrong arguments, 3 fix the setup, then run it again.
34//!
35//! The MCP server's session tools (`start`, `step`, `scene`, `frame_png`,
36//! `events`, `perf`, `reset`, `stop`; [`Op::SESSION`]) answer the same way.
37//!
38//! The schemas of every input and answer are generated from these types
39//! ([`schemas`]) and checked in under `crates/cli/schemas/`. A test fails
40//! when they change and [`VERSION`] doesn't.
41
42pub mod add;
43pub mod attach;
44pub mod autobalance;
45pub mod bake;
46pub mod check;
47pub mod cli;
48pub mod codes;
49pub mod doctor;
50pub mod explain;
51pub mod export;
52#[cfg(test)]
53pub(crate) mod fake;
54pub mod import;
55pub mod lint;
56pub mod modules;
57pub mod new;
58pub mod perf;
59pub mod playtest;
60pub mod replay;
61pub mod run;
62pub mod session;
63pub mod target;
64
65use std::fmt::Write as _;
66
67use schemars::{JsonSchema, Schema, schema_for};
68use serde::{Deserialize, Serialize};
69use shuyi_assets::ContentError;
70
71use codes::Code;
72
73/// The contract's version, in every answer. Raise it whenever an input
74/// or an answer changes shape (the schemas' test says when), and add the
75/// new schemas' fingerprint to `schemas/versions.json`.
76pub const VERSION: u32 = 40;
77
78/// An operation of the contract.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80#[serde(rename_all = "kebab-case")]
81pub enum Op {
82    /// A new game from a template.
83    New,
84    /// Every error of the game's content.
85    Check,
86    /// What is wrong with the shape of the game's code.
87    Lint,
88    /// The catalog of modules, and the game's.
89    Modules,
90    /// A module turned on.
91    Add,
92    /// A key script played headless.
93    Run,
94    /// The scene on screen.
95    Scene,
96    /// A record replayed.
97    Replay,
98    /// Named bots' tries judged against the game's checks.
99    Playtest,
100    /// A stage changed until the game's named bots land in their bands.
101    Autobalance,
102    /// A file read through the format registry.
103    Import,
104    /// The game's sources baked into a pack.
105    Bake,
106    /// The toolchain checked.
107    Doctor,
108    /// A problem code's page.
109    Explain,
110    /// A running build reached over the live link.
111    Attach,
112    /// The game checked against a target, built and packaged for it.
113    Export,
114    /// The MCP session's game started headless.
115    Start,
116    /// The MCP session's game played on: a script, then idle frames.
117    Step,
118    /// A picture of the MCP session's frame.
119    #[serde(rename = "frame_png")]
120    FramePng,
121    /// What happened in the MCP session's game.
122    Events,
123    /// The MCP session's frames' records since a number.
124    Perf,
125    /// The MCP session's game started again.
126    Reset,
127    /// The MCP session's game stopped.
128    Stop,
129}
130
131impl Op {
132    /// Every operation, in the order the usage lists them.
133    pub const ALL: [Op; 16] = [
134        Op::New,
135        Op::Check,
136        Op::Lint,
137        Op::Modules,
138        Op::Add,
139        Op::Run,
140        Op::Scene,
141        Op::Replay,
142        Op::Playtest,
143        Op::Autobalance,
144        Op::Import,
145        Op::Bake,
146        Op::Export,
147        Op::Doctor,
148        Op::Explain,
149        Op::Attach,
150    ];
151
152    /// The MCP server's session tools ([`crate::mcp`]), in the order it
153    /// lists them: `scene` is the session's, not the one-off operation.
154    pub const SESSION: [Op; 8] = [
155        Op::Start,
156        Op::Step,
157        Op::Scene,
158        Op::FramePng,
159        Op::Events,
160        Op::Perf,
161        Op::Reset,
162        Op::Stop,
163    ];
164
165    /// Its name on the command line, or as an MCP tool.
166    pub fn name(self) -> &'static str {
167        match self {
168            Op::New => "new",
169            Op::Check => "check",
170            Op::Lint => "lint",
171            Op::Modules => "modules",
172            Op::Add => "add",
173            Op::Run => "run",
174            Op::Scene => "scene",
175            Op::Replay => "replay",
176            Op::Playtest => "playtest",
177            Op::Autobalance => "autobalance",
178            Op::Import => "import",
179            Op::Bake => "bake",
180            Op::Doctor => "doctor",
181            Op::Explain => "explain",
182            Op::Attach => "attach",
183            Op::Export => "export",
184            Op::Start => "start",
185            Op::Step => "step",
186            Op::FramePng => "frame_png",
187            Op::Events => "events",
188            Op::Perf => "perf",
189            Op::Reset => "reset",
190            Op::Stop => "stop",
191        }
192    }
193
194    /// The operation named `name` on the command line (not a session
195    /// tool).
196    pub fn named(name: &str) -> Option<Op> {
197        Op::ALL.into_iter().find(|op| op.name() == name)
198    }
199}
200
201/// What an operation answers.
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
203#[serde(deny_unknown_fields)]
204pub struct Answer<T> {
205    /// The contract's version ([`VERSION`]).
206    pub contract: u32,
207    /// The operation.
208    pub op: Op,
209    /// Whether it passed: nothing found by a check, runs that agree, a
210    /// game made, a toolchain complete. False too when it could not run.
211    pub ok: bool,
212    /// What it found or made; absent when it could not run.
213    pub result: Option<T>,
214    /// Why it could not run, if it could not.
215    pub error: Option<String>,
216    /// The problem code of its error (`SH1002`), or of its failure when
217    /// it ran and failed on the setup (`doctor`); `shuyi explain <code>`
218    /// prints its page.
219    pub code: Option<String>,
220    /// How to fix it, with the code.
221    pub help: Option<String>,
222    /// What it could not determine, in plain words.
223    pub limits: Vec<String>,
224}
225
226impl<T> Answer<T> {
227    /// Operation `op`'s `result`, passed if `ok`, with `limits`.
228    pub fn new(op: Op, ok: bool, result: T, limits: Vec<String>) -> Self {
229        Self {
230            contract: VERSION,
231            op,
232            ok,
233            result: Some(result),
234            error: None,
235            code: None,
236            help: None,
237            limits,
238        }
239    }
240
241    /// Operation `op`, which could not run because of `error`, a problem
242    /// of code `code`, with its row's help.
243    pub fn failed(op: Op, code: Code, error: impl Into<String>) -> Self {
244        Self {
245            contract: VERSION,
246            op,
247            ok: false,
248            result: None,
249            error: Some(error.into()),
250            code: None,
251            help: None,
252            limits: Vec::new(),
253        }
254        .with_code(code)
255    }
256
257    /// This answer with problem code `code` and its row's help: for an
258    /// answer that ran and failed on the setup.
259    #[must_use]
260    pub fn with_code(mut self, code: Code) -> Self {
261        self.code = Some(code.0.to_owned());
262        self.help = codes::help_of(code.0).map(str::to_owned);
263        self
264    }
265
266    /// This answer with `help` instead of its code's: a usage, or what
267    /// to fix first.
268    #[must_use]
269    pub fn with_help(mut self, help: impl Into<String>) -> Self {
270        self.help = Some(help.into());
271        self
272    }
273
274    /// This answer with `limit` added to its limits.
275    #[must_use]
276    pub fn limited(mut self, limit: impl Into<String>) -> Self {
277        self.limits.push(limit.into());
278        self
279    }
280
281    /// The process exit code for it: 0 if it passed, else its code's
282    /// class's ([`codes::Class::exit_code`]), 1 with no code.
283    pub fn exit_code(&self) -> u8 {
284        if self.ok {
285            0
286        } else {
287            codes::exit_code(self.code.as_deref())
288        }
289    }
290
291    /// The answer as JSON, one document, pretty-printed.
292    pub fn to_json(&self) -> String
293    where
294        T: Serialize,
295    {
296        // A struct of strings, numbers and lists: serializing can't fail.
297        serde_json::to_string_pretty(self).unwrap_or_default()
298    }
299
300    /// The answer for people: the result as `text` writes it, then the
301    /// error with its code and help ([`codes::said`]), then the limits.
302    pub fn to_text(&self, text: impl FnOnce(&T) -> String) -> String {
303        let mut out = self.result.as_ref().map(text).unwrap_or_default();
304        match (&self.code, &self.error) {
305            (Some(code), error) => {
306                let title = codes::entry(code).map_or("", |entry| entry.title);
307                let message = error.as_deref().unwrap_or(title);
308                out.push_str(&codes::said(
309                    self.op.name(),
310                    code,
311                    message,
312                    self.help.as_deref(),
313                ));
314            }
315            (None, Some(error)) => {
316                // Writing to a `String` can't fail.
317                let _ = writeln!(out, "{}: {error}", self.op.name());
318            }
319            (None, None) => {}
320        }
321        if !self.limits.is_empty() {
322            out.push_str("limits:\n");
323            for limit in &self.limits {
324                // Writing to a `String` can't fail.
325                let _ = writeln!(out, "  - {limit}");
326            }
327        }
328        out
329    }
330}
331
332/// A problem in a file: what a validator found, with where.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
334#[serde(deny_unknown_fields)]
335pub struct Problem {
336    /// The file, as the game shows it (`files/stage.map`).
337    pub file: String,
338    /// Its 1-based line, if known.
339    pub line: Option<u32>,
340    /// Its 1-based column, if known.
341    pub column: Option<u32>,
342    /// What is wrong.
343    pub message: String,
344    /// Its problem code (`SH2001`), if its class has one.
345    pub code: Option<String>,
346    /// How to fix it, with the code.
347    pub help: Option<String>,
348}
349
350impl From<&ContentError> for Problem {
351    fn from(e: &ContentError) -> Self {
352        Self {
353            file: e.file.clone(),
354            line: e.line,
355            column: e.column,
356            message: e.message.clone(),
357            code: e.code.map(|code| code.0.to_owned()),
358            help: e
359                .code
360                .and_then(|code| codes::help_of(code.0))
361                .map(str::to_owned),
362        }
363    }
364}
365
366impl std::fmt::Display for Problem {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        write!(f, "{}", self.file)?;
369        if let Some(line) = self.line {
370            write!(f, ":{line}")?;
371            if let Some(column) = self.column {
372                write!(f, ":{column}")?;
373            }
374        }
375        if let Some(code) = &self.code {
376            write!(f, ": error[{code}]")?;
377        }
378        write!(f, ": {}", self.message)
379    }
380}
381
382/// One schema file: its name (`run.input.json`) and its text.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct SchemaFile {
385    /// The file's name.
386    pub name: String,
387    /// Its JSON, pretty-printed, ending in a newline.
388    pub text: String,
389}
390
391/// `schema` as a schema file named `name`.
392fn file(name: String, schema: &Schema) -> SchemaFile {
393    let mut text = serde_json::to_string_pretty(schema).unwrap_or_default();
394    text.push('\n');
395    SchemaFile { name, text }
396}
397
398/// The schemas of an input `I` and an answer `Answer<O>`, named `name`
399/// (`run.input.json`, `run.output.json`).
400fn pair<I: JsonSchema, O: JsonSchema>(op: Op, name: &str) -> [SchemaFile; 2] {
401    [
402        file(format!("{name}.input.json"), &schema_for!(I)),
403        file(
404            format!("{name}.output.json"),
405            &names_its_op(schema_for!(Answer<O>), op),
406        ),
407    ]
408}
409
410/// `answer`, the schema of an `Answer`, with its `op` the one name of
411/// `op` (a `const`) instead of a reference to every [`Op`], so adding an
412/// operation changes no other operation's schema.
413fn names_its_op(mut answer: Schema, op: Op) -> Schema {
414    if let Some(defs) = answer.get_mut("$defs").and_then(|d| d.as_object_mut()) {
415        defs.remove("Op");
416        if defs.is_empty() {
417            answer.remove("$defs");
418        }
419    }
420    if let Some(property) = answer.get_mut("properties").and_then(|p| p.get_mut("op")) {
421        *property = serde_json::json!({
422            "description": "The operation.",
423            "type": "string",
424            "const": op.name(),
425        });
426    }
427    answer
428}
429
430/// The schemas of `op`'s input and answer: as the CLI and the MCP tool
431/// of its name take it, or, if `session`, as the MCP server's session
432/// tool does (named `session.<op>`; [`Op::SESSION`]).
433pub fn op_schemas(op: Op, session: bool) -> [SchemaFile; 2] {
434    use crate::mcp::{
435        EventsOutput, FramePngInput, FramePngOutput, NoInput, PerfInput, PerfOutput, SceneInput,
436        StartInput, StartOutput, StepInput, StopOutput,
437    };
438    let name = if session {
439        format!("session.{}", op.name())
440    } else {
441        op.name().to_owned()
442    };
443    let name = name.as_str();
444    match (op, session) {
445        (Op::New, _) => pair::<new::NewInput, new::NewOutput>(op, name),
446        (Op::Check, _) => pair::<check::CheckInput, check::CheckOutput>(op, name),
447        (Op::Lint, _) => pair::<lint::LintInput, lint::LintOutput>(op, name),
448        (Op::Modules, _) => pair::<modules::ModulesInput, modules::ModulesOutput>(op, name),
449        (Op::Add, _) => pair::<add::AddInput, add::AddOutput>(op, name),
450        (Op::Run, _) => pair::<run::RunInput, run::RunOutput>(op, name),
451        (Op::Scene, false) => pair::<run::RunInput, run::SceneOutput>(op, name),
452        (Op::Scene, true) => pair::<SceneInput, run::SceneOutput>(op, name),
453        (Op::Replay, _) => pair::<replay::ReplayInput, replay::ReplayOutput>(op, name),
454        (Op::Playtest, _) => {
455            pair::<playtest::PlaytestInput, Vec<playtest::PlaytestOutput>>(op, name)
456        }
457        (Op::Autobalance, _) => {
458            pair::<autobalance::AutobalanceInput, autobalance::AutobalanceOutput>(op, name)
459        }
460        (Op::Import, _) => pair::<import::ImportInput, import::ImportOutput>(op, name),
461        (Op::Bake, _) => pair::<bake::BakeInput, bake::BakeOutput>(op, name),
462        (Op::Doctor, _) => pair::<doctor::DoctorInput, doctor::DoctorOutput>(op, name),
463        (Op::Explain, _) => pair::<explain::ExplainInput, explain::ExplainOutput>(op, name),
464        (Op::Attach, _) => pair::<attach::AttachInput, attach::AttachOutput>(op, name),
465        (Op::Export, _) => pair::<export::ExportInput, export::ExportOutput>(op, name),
466        (Op::Start, _) => pair::<StartInput, StartOutput>(op, name),
467        (Op::Step, _) => pair::<StepInput, run::RunOutput>(op, name),
468        (Op::FramePng, _) => pair::<FramePngInput, FramePngOutput>(op, name),
469        (Op::Events, _) => pair::<NoInput, EventsOutput>(op, name),
470        (Op::Perf, _) => pair::<PerfInput, PerfOutput>(op, name),
471        (Op::Reset, _) => pair::<NoInput, StartOutput>(op, name),
472        (Op::Stop, _) => pair::<NoInput, StopOutput>(op, name),
473    }
474}
475
476/// Every operation's input and output schema, in [`Op::ALL`]'s order,
477/// then the MCP server's session tools' ([`Op::SESSION`]), then the file
478/// `export` writes beside its package.
479pub fn schemas() -> Vec<SchemaFile> {
480    let ops = Op::ALL.into_iter().flat_map(|op| op_schemas(op, false));
481    let session = Op::SESSION.into_iter().flat_map(|op| op_schemas(op, true));
482    let exposure = file(
483        "export.exposure.json".to_owned(),
484        &schema_for!(crate::export::disclosure::ExposureReport),
485    );
486    ops.chain(session).chain([exposure]).collect()
487}
488
489/// The fingerprint of `files`: a hash of their names and texts, which
490/// `schemas/versions.json` keeps for each [`VERSION`].
491pub fn fingerprint(files: &[SchemaFile]) -> String {
492    let parts: Vec<(&str, &str)> = files
493        .iter()
494        .map(|f| (f.name.as_str(), f.text.as_str()))
495        .collect();
496    shuyi_core::state_hash(&parts)
497        .map(|hash| hash.to_string())
498        .unwrap_or_default()
499}
500
501/// One line of `schemas/versions.json`: a version and its schemas'
502/// fingerprint.
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(deny_unknown_fields)]
505pub struct VersionLine {
506    /// The contract's version.
507    pub version: u32,
508    /// Its schemas' [`fingerprint`].
509    pub fingerprint: String,
510}
511
512#[cfg(test)]
513mod tests;