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;