1pub mod aside;
44pub mod attached;
45pub mod child;
46pub mod forward;
47pub mod host;
48pub mod install;
49pub mod server;
50
51use std::io::{BufRead, BufReader, Write};
52use std::path::Path;
53use std::process::Command;
54use std::sync::mpsc::{RecvTimeoutError, channel};
55use std::time::Duration;
56
57use schemars::JsonSchema;
58use serde::{Deserialize, Serialize};
59use serde_json::{Map, Value, json};
60
61use crate::contract::perf::PerfSummary;
62use crate::contract::session::{AddedSound, Event, SessionInput, SessionPlayed, Sound};
63use crate::contract::{Op, VERSION, op_schemas};
64use crate::frame_png::Script;
65use crate::game::{GameArg, GameOption};
66use crate::project::Project;
67use shuyi_ui::perf::{FrameRecord, HeapDiff, HeapSnapshot, PERF_FRAMES};
68
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71#[serde(deny_unknown_fields)]
72pub struct NoInput {}
73
74#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields, default)]
78#[non_exhaustive]
81pub struct SceneInput {
82 pub path: Option<String>,
85 pub shape: bool,
87}
88
89impl SceneInput {
90 pub(crate) fn ask(&self) -> crate::contract::run::SceneAsk<'_> {
92 crate::contract::run::SceneAsk {
93 path: self.path.as_deref(),
94 shape: self.shape,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields, default)]
102#[non_exhaustive]
105pub struct StartInput {
106 pub project: Option<String>,
110 pub start: Vec<GameArg>,
113 pub link: Option<StartLink>,
116 pub session: Option<SessionInput>,
121}
122
123#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126#[serde(deny_unknown_fields, default)]
127#[non_exhaustive]
130pub struct StartLink {
131 pub address: String,
133 pub token: String,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(deny_unknown_fields)]
140pub struct StartOption {
141 pub flag: String,
143 pub value: Option<String>,
145 pub help: String,
147}
148
149impl From<&GameOption> for StartOption {
150 fn from(option: &GameOption) -> Self {
151 Self {
152 flag: option.flag.to_owned(),
153 value: option.value.map(str::to_owned),
154 help: option.help.to_owned(),
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162#[non_exhaustive]
164pub struct StartOutput {
165 pub project: String,
167 pub game: String,
170 pub start: Vec<GameArg>,
172 pub options: Vec<StartOption>,
174 pub frames: u32,
176 pub top_screen: String,
178 pub screens: Vec<String>,
180 pub session: Option<SessionPlayed>,
182}
183
184#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
186#[serde(deny_unknown_fields, default)]
187pub struct StepInput {
188 pub script: Script,
190 pub frames: u32,
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
196#[serde(deny_unknown_fields, default)]
197pub struct FramePngInput {
198 pub scale: Option<u32>,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204#[serde(deny_unknown_fields)]
205pub struct FramePngOutput {
206 pub frames: u32,
208 pub top_screen: String,
210 pub width: u32,
212 pub height: u32,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(deny_unknown_fields)]
219pub struct EventsOutput {
220 pub frames: u32,
222 pub events: Vec<Event>,
225 pub sounds: Vec<Sound>,
227 #[serde(default, skip_serializing_if = "Vec::is_empty")]
230 pub sounds_added: Vec<AddedSound>,
231}
232
233pub const PERF_MAX: u32 = 120;
235
236pub const KEPT_SNAPSHOTS: usize = 8;
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
242#[serde(deny_unknown_fields, default)]
243#[non_exhaustive]
246pub struct PerfInput {
247 pub since: u64,
249 #[schemars(range(min = 1, max = 600))]
251 pub max: u32,
252 pub snapshot: bool,
255 pub diff_from: Option<u32>,
258}
259
260impl Default for PerfInput {
261 fn default() -> Self {
262 Self {
263 since: 0,
264 max: PERF_MAX,
265 snapshot: false,
266 diff_from: None,
267 }
268 }
269}
270
271impl PerfInput {
272 pub fn check(&self) -> Result<(), String> {
274 let most = u32::try_from(PERF_FRAMES).unwrap_or(u32::MAX);
275 if (1..=most).contains(&self.max) {
276 Ok(())
277 } else {
278 Err(format!("max: 1 to {most}, not {}", self.max))
279 }
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
285#[serde(deny_unknown_fields)]
286#[non_exhaustive]
288pub struct PerfOutput {
289 pub records: Vec<FrameRecord>,
291 pub next: u64,
293 pub dropped: u64,
295 pub summary: Option<PerfSummary>,
297 pub snapshot: Option<HeapSnapshot>,
300 pub diff: Option<HeapDiff>,
302 pub kept: Vec<u32>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
309#[serde(deny_unknown_fields)]
310pub struct StopOutput {
311 pub frames: u32,
313}
314
315pub fn tool(name: &str) -> Option<(Op, bool)> {
318 if let Some(op) = Op::SESSION.into_iter().find(|op| op.name() == name) {
319 return Some((op, true));
320 }
321 Op::ALL
322 .into_iter()
323 .find(|op| op.name() == name)
324 .map(|op| (op, false))
325}
326
327const ATTACH: &str = "Reach a game's debug build already running in its window, over the live link \
329 (`address` and `token`: its log line `shuyi link: listening on ..., token ...`; the \
330 build runs with its `link` feature and SHUYI_LINK set). Play `steps` as a player's \
331 input (keys, pad buttons, an action by name with `act`, waits in its frames), let \
332 `frames` pass, then read its screens, frame as text, log from `log_since`, and, \
333 with `screenshot` (a PNG's path), the window's picture; with `perf_since`, its \
334 frames' records and their summary; with `perf_follow` (a file), each new frame's \
335 record written to the file for `follow_for` seconds, then their summary.";
336
337const START: &str = "Build the game's tool program and start its game headless in the session, as \
339 its start options say (the answer lists them); a game already running is stopped. \
340 `project`: the game's folder. Call it again after changing the game's code. With `link` \
341 (`address` and `token`, from a debug build's log line `shuyi link: listening on ..., token \
342 ...`), attach the session to that build running in its window instead: the session tools \
343 play a player's input on it and read its frame, picture and log (no scene, events or \
344 sounds), `reset` is refused, and `stop` leaves it running.";
345
346const EXPORT: &str = "Check the game against `target` (`windows`, `linux`, `macos`, `web`), then \
348 build and package it there: a zip, a tar.gz, a signed .app or a web folder and its zip, \
349 each with NOTICES.txt, and export-report.json and exposure-report.json beside it, in `out` \
350 (dist/export/<target> by default). A package that breaks shuyi.ron's `disclosure` part is \
351 removed. `release` remaps the build's paths and keeps the debug file beside the package; \
352 `run` runs the package after; `without` leaves features of shuyi.ron's `export` part off.";
353
354const LINT: &str = "Check the game's code: keys named in code or text (check-keys), screen text as literals \
356 (check-text), and the rules crate's fence (clippy's bans on hash maps, floats, clocks, \
357 files and outside luck; no screen or device crate under it). Each finding with its \
358 file, line and code.";
359
360fn description(op: Op, session: bool) -> &'static str {
362 match (op, session) {
363 (Op::New, _) => {
364 "Make a new game in `dir` (from the server's working folder) from the engine's \
365 template, its parts picked by the answers: `template`, a short name (`grid`, a \
366 title and a turn-based grid stage with a bot and records; `blank`, one screen), \
367 then `answers` (an answers file) and `set` (answers by question). `force` writes \
368 into a folder that isn't empty."
369 }
370 (Op::Check, _) => {
371 "Load the game's content through every validator: each error with its file, line and \
372 code. With `baked`, its pack too; with `target`, whether its frame, text and \
373 controls fit a target (or `all`)."
374 }
375 (Op::Lint, _) => LINT,
376 (Op::Modules, _) => {
377 "The engine's catalog of modules by concern (perspective, bodies and motion, online \
378 play, ...): each answer, who gives it (the default engine or a module) and its \
379 status, the modules the game's engine builds with what they answer and serve, and \
380 the ones the game has on."
381 }
382 (Op::Add, _) => {
383 "Turn a module of the game's engine on (`module`: `physics` or `shuyi-physics`): its \
384 feature of `shuyi` in the game's Cargo.toml (it changes the project; a second call \
385 changes nothing), its skill copied into the game's skills, what it is for, an \
386 alternative already on, and any line the game's code still needs."
387 }
388 (Op::Run, _) => {
389 "Start the game headless as its start options say, play a script of keys, pad \
390 buttons and waits, run idle frames: the screens, the scene, the frame as text, what \
391 happened, the sounds and the log. A fresh game each call; the session tools keep \
392 one running."
393 }
394 (Op::Scene, false) => "The scene on screen after a one-off run.",
395 (Op::Scene, true) => {
396 "The scene on screen in the session now (a stage's map and state), and the log. Not \
397 ok when no stage is open. `path` (a JSON Pointer, `/hero/x`) answers one value of \
398 its data instead, and `shape` its data's JSON Schema."
399 }
400 (Op::Replay, _) => {
401 "Re-run a record (a file in the game's folder) by the game's rules: a state hash per \
402 step; with `other`, the first step where two runs differ."
403 }
404 (Op::Playtest, _) => {
405 "Each of the game's named bots (its `entrants`, or the game's choice) plays `tries` \
406 tries of a `stage` (or `every_stage`) on every core; the game's checks say per bot \
407 whether it succeeded as often as its band wants (with no checks, a plain bot run, \
408 nothing is judged), and each bot's time per try, outcomes counted and the spread \
409 of its turns and measures. Each try has its commands and, if the game keeps one, \
410 its `record` for `replay`."
411 }
412 (Op::Autobalance, _) => {
413 "Change a `stage` with the game's knobs, never its bots, until each of its named \
414 bots (`entrants`) succeeds as often as its checks' bands want: the smallest change \
415 the search finds, in the knobs' words, and the reports before and after. Each \
416 setting's tries run on every core; it stops at `budget` evaluations or after \
417 `time` seconds. With `apply`, the game writes the changed stage to its files: it \
418 changes the project."
419 }
420 (Op::Import, _) => {
421 "Read a file (from the game's folder) through the format registry as every kind \
422 that knows it, or give its errors."
423 }
424 (Op::Bake, _) => {
425 "Bake the game's sources into one pack: what went in, the sizes, how long baking and \
426 loading took."
427 }
428 (Op::Doctor, _) => {
429 "Check the toolchain (Rust, Cargo, targets, the linker) and say how to fix each \
430 missing piece."
431 }
432 (Op::Explain, _) => {
433 "Every problem code, or one code's page (`SH2003` or `missing-file`): what it means \
434 and how to fix it. Every failed answer has a `code`."
435 }
436 (Op::Attach, _) => ATTACH,
437 (Op::Export, _) => EXPORT,
438 (Op::Start, _) => START,
439 (Op::Step, _) => {
440 "Play on in the session: a script of keys, pad buttons and waits, then `frames` idle \
441 frames. Answers as `run` does, for everything since the start."
442 }
443 (Op::FramePng, _) => {
444 "A picture of the session's frame now, as the game paints it, as an image."
445 }
446 (Op::Events, _) => {
447 "What happened in the session since the start: screens opened and closed, music, \
448 voices, the quit, the game's own events, and the sounds."
449 }
450 (Op::Perf, _) => {
451 "The session's frames' records (times in microseconds, the game's spans) numbered \
452 `since` or more, at most `max`, and their summary: a stream, `step` then `perf` with \
453 the last `next`. `snapshot` takes a heap snapshot (live bytes by memory tag; the \
454 last 8 are `kept`); `diff_from`, a kept one's frame, answers what grew since."
455 }
456 (Op::Reset, _) => {
457 "Start the session's game again as `start` started it, without building again."
458 }
459 (Op::Stop, _) => "Stop the session's game.",
460 }
461}
462
463pub fn takes_project(op: Op, session: bool) -> bool {
466 !session
467 && (crate::contract::cli::runs_the_game(op.name())
468 || matches!(op, Op::Lint | Op::Modules | Op::Add))
469}
470
471fn project_schema() -> Value {
473 json!({
474 "description": "The game's folder (the one holding its `shuyi.ron`, or one below it), \
475 from the session's folder (the agent's working folder); the session \
476 folder's own project if none.",
477 "type": ["string", "null"],
478 })
479}
480
481fn schema_value(text: &str, project: bool) -> Value {
484 let mut schema: Value = serde_json::from_str(text).unwrap_or_else(|_| json!({}));
485 if project
486 && let Some(properties) = schema.as_object_mut().and_then(|schema| {
487 schema
488 .entry("properties")
489 .or_insert_with(|| json!({}))
490 .as_object_mut()
491 })
492 {
493 properties.insert("project".to_owned(), project_schema());
494 }
495 schema
496}
497
498fn tool_listing(op: Op, session: bool) -> Value {
500 let [input, output] = op_schemas(op, session);
501 let mut tool = Map::new();
502 tool.insert("name".to_owned(), json!(op.name()));
503 tool.insert("description".to_owned(), json!(description(op, session)));
504 tool.insert(
505 "inputSchema".to_owned(),
506 schema_value(&input.text, takes_project(op, session)),
507 );
508 tool.insert("outputSchema".to_owned(), schema_value(&output.text, false));
509 Value::Object(tool)
510}
511
512pub fn tools() -> Vec<Value> {
515 let ops = Op::ALL
516 .into_iter()
517 .filter(|op| *op != Op::Scene)
518 .map(|op| tool_listing(op, false));
519 let session = Op::SESSION.into_iter().map(|op| tool_listing(op, true));
520 ops.chain(session).collect()
521}
522
523pub fn base64(bytes: &[u8]) -> String {
525 shuyi_platform::link::base64::encode(bytes)
526}
527
528pub fn usage(command: &str) -> String {
530 format!(
531 "\
532usage: {command} mcp serve the tool contract over MCP on stdin and stdout
533 {command} mcp --ready build what the server needs, then exit
534 {command} mcp install [--dry-run] [--yes] [--command \"<how to run shuyi>\"]
535 register the server with the agents found here
536
537--ready builds the server (it runs through the same command) and the
538game's tool program, if shuyi.ron names one, so an agent's first
539connection doesn't wait for a compile. A running server follows the
540agent's folder and the engine by itself: a project in another workspace
541goes to that workspace's own server, and a server whose engine moved
542under it hands its calls to a fresh one. install shows what it will
543change and asks before writing, keeping a backup of each file it
544changes, then runs the server with --ready; --yes doesn't ask, and
545--dry-run writes nothing and prints each file as it would write it. The server is run as --command's
546words and `mcp` (the project's `command` in shuyi.ron, or `shuyi`, if not
547given)."
548 )
549}
550
551pub fn main(args: &[String], cwd: &Path) -> u8 {
554 match args.split_first() {
555 None => {
556 if cfg!(windows) {
557 aside::this_program();
559 }
560 let mut server = server::Server::new(cwd.to_path_buf(), Box::new(child::Cargo));
561 if std::env::var_os(forward::FORWARDED).is_none() {
562 server = server.forwarding(Box::new(forward::Processes));
563 }
564 let stdout = std::io::stdout();
565 match serve(&mut server, BufReader::new(std::io::stdin()), stdout.lock()) {
566 Ok(()) => 0,
567 Err(e) => {
568 eprintln!("mcp: {e}");
569 1
570 }
571 }
572 }
573 Some((first, rest)) if first == "install" => install::main(rest, cwd),
574 Some((first, [])) if first == "--ready" => ready(cwd),
575 Some(_) => {
576 eprintln!("{}", usage("shuyi"));
577 2
578 }
579 }
580}
581
582fn ready(cwd: &Path) -> u8 {
587 if let Ok(project) = Project::find(cwd)
588 && let Some(program) = &project.config.game
589 {
590 let cargo = crate::cargo();
591 let built = Command::new(cargo)
592 .args(program.cargo_build_args())
593 .current_dir(&project.root)
594 .status();
595 match built {
596 Ok(status) if status.success() => {}
597 Ok(status) => {
598 let code = status
599 .code()
600 .map_or_else(String::new, |c| format!(" (exit code {c})"));
601 eprintln!("mcp: the game's tool program didn't build{code}");
602 return 1;
603 }
604 Err(e) => {
605 eprintln!("mcp: cannot run cargo: {e}");
606 return 1;
607 }
608 }
609 }
610 println!("mcp: ready (contract {VERSION})");
611 0
612}
613
614const POLL: Duration = Duration::from_millis(200);
617
618pub fn serve(
623 server: &mut server::Server<'_>,
624 input: impl BufRead + Send + 'static,
625 mut output: impl Write,
626) -> std::io::Result<()> {
627 let (sent, lines) = channel();
628 crate::contract::perf::note_other_thread();
631 std::thread::spawn(move || {
632 for line in input.lines() {
633 if sent.send(line).is_err() {
634 break;
635 }
636 }
637 });
638 loop {
639 let messages = match lines.recv_timeout(POLL) {
640 Ok(line) => {
641 let mut messages = server.handle(&line?);
642 messages.extend(server.poll());
643 messages
644 }
645 Err(RecvTimeoutError::Timeout) => server.poll(),
646 Err(RecvTimeoutError::Disconnected) => return Ok(()),
647 };
648 for message in &messages {
649 writeln!(output, "{message}")?;
650 }
651 output.flush()?;
652 }
653}
654
655#[cfg(test)]
656mod tests;