Skip to main content

shuyi_cli/mcp/
host.rs

1//! The game's side of `shuyi mcp`: `shuyi mcp-host`, run in the game's
2//! tool program, answers the server's [`Request`]s, one JSON line each on
3//! stdin, with a [`Reply`] line each on stdout, after the mark [`REPLY`]. It answers the
4//! operations that run the game with the game's tools, and keeps the
5//! session's game ([`Host`]).
6//!
7//! [`InProcess`] is the same host without a process between, for games'
8//! and the engine's tests of the server.
9
10use std::io::{BufRead, Write};
11use std::path::Path;
12
13use serde::de::DeserializeOwned;
14use serde::{Deserialize, Serialize};
15use serde_json::{Value, json};
16use shuyi_ui::perf::HeapSnapshot;
17
18use super::server::{Launch, Link};
19use super::{
20    EventsOutput, FramePngInput, FramePngOutput, KEPT_SNAPSHOTS, NoInput, PerfInput, PerfOutput,
21    SceneInput, StartInput, StartOption, StartOutput, StepInput, StopOutput, base64,
22};
23use crate::codec::encode_png;
24use crate::contract::attach::Failed;
25use crate::contract::codes::{self, Code};
26use crate::contract::import::Importers;
27use crate::contract::perf::{self, PerfRead};
28use crate::contract::run::{self, RunInput};
29use crate::contract::session::{Session, SessionInput, SessionPlayed, from_record, measured_start};
30use crate::contract::{
31    Answer, Op, add, attach, autobalance, bake, check, doctor, explain, export, import, lint,
32    modules, new, playtest, replay,
33};
34use crate::frame_png::MAX_SCALE;
35use crate::game::{GameArg, GameTools, check_start, code_of};
36use crate::project::{FILE, Project};
37
38/// What starts each reply line, so the server tells replies from what
39/// the game prints itself.
40pub const REPLY: &str = "shuyi-mcp-reply ";
41
42/// What the server asks the host: the tool of `op`, with its input.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Request {
46    /// The operation, or the session tool.
47    pub op: Op,
48    /// Its input, as the tool's schema says.
49    #[serde(default)]
50    pub input: Value,
51}
52
53/// What the host answers.
54#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct Reply {
57    /// The answer, an [`Answer`] as JSON.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub answer: Option<Value>,
60    /// A picture, as PNG in base64 (`frame_png`).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub png: Option<String>,
63    /// Why the host gave no answer: a request it doesn't read.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub error: Option<String>,
66}
67
68impl Reply {
69    /// A reply of `answer`.
70    fn of(answer: Value) -> Self {
71        Self {
72            answer: Some(answer),
73            ..Self::default()
74        }
75    }
76}
77
78/// `answer` as JSON.
79pub fn to_json<T: Serialize>(answer: &Answer<T>) -> Value {
80    // An answer of strings, numbers and lists: serializing can't fail.
81    serde_json::to_value(answer).unwrap_or(Value::Null)
82}
83
84/// The answer of `op` that failed with `code` because of `error`, as
85/// JSON.
86pub fn failed(op: Op, code: Code, error: impl Into<String>) -> Value {
87    to_json(&Answer::<()>::failed(op, code, error))
88}
89
90/// `input` read as `I` (nothing given reads as `{}`), and `f`'s answer to
91/// it; a [`codes::WRONG_ARGUMENTS`] answer of `op` if it doesn't read.
92pub fn answered<I: DeserializeOwned, T: Serialize>(
93    op: Op,
94    input: Value,
95    f: impl FnOnce(I) -> Answer<T>,
96) -> Value {
97    match read(input) {
98        Ok(input) => to_json(&f(input)),
99        Err(e) => failed(op, codes::WRONG_ARGUMENTS, e),
100    }
101}
102
103/// `input` read as `I`, nothing given read as `{}`.
104pub(super) fn read<I: DeserializeOwned>(input: Value) -> Result<I, String> {
105    let input = if input.is_null() { json!({}) } else { input };
106    serde_json::from_value(input).map_err(|e| format!("input: {e}"))
107}
108
109/// The answer of the operation `op` (one of [`Op::ALL`]) to `input`, as
110/// JSON: with the game's tools `game` on `project` where it runs the
111/// game; `new` makes its folder from `cwd`, and `import` reads from the
112/// project's root, or `cwd` outside one. Without the game's tools or a
113/// project, an operation that needs them says so, as the CLI does.
114pub fn one_off(
115    op: Op,
116    input: Value,
117    cwd: &Path,
118    project: &Result<Project, (Code, String)>,
119    game: Option<&dyn GameTools>,
120) -> Value {
121    let no_game = || {
122        let error = format!(
123            "{FILE} names no game tool program (`game`), and {} runs the game",
124            op.name()
125        );
126        failed(op, codes::PROJECT_FILE, error)
127    };
128    let needs = |f: &dyn Fn(&dyn GameTools, &Project) -> Value| match (game, project) {
129        (_, Err((code, error))) => failed(op, *code, error.clone()),
130        (None, Ok(_)) => no_game(),
131        (Some(game), Ok(project)) => f(game, project),
132    };
133    match op {
134        Op::New => answered(op, input, |i| new::new(cwd, &i)),
135        Op::Doctor => answered(op, input, |i| doctor::doctor(&doctor::ThisMachine, &i)),
136        Op::Explain => answered(op, input, |i| explain::explain(&i)),
137        Op::Attach => answered(op, input, |i| attach::attach(&i, cwd)),
138        Op::Check => {
139            let input: check::CheckInput = match read(input) {
140                Ok(input) => input,
141                Err(e) => return failed(op, codes::WRONG_ARGUMENTS, e),
142            };
143            match (&input.target, project) {
144                (Some(_), Ok(p)) => to_json(&check::targets(&p.config, &p.root, &input, game)),
145                (None, _) => needs(&|game, _| to_json(&check::check(game, &input))),
146                (Some(_), Err((code, error))) => failed(op, *code, error.clone()),
147            }
148        }
149        Op::Import => {
150            let (importers, limit) = match (game, project) {
151                (Some(game), _) => (game.importers(), None),
152                (None, Ok(_)) => (
153                    Importers::standard(),
154                    Some(format!(
155                        "{FILE} names no game tool program: only the engine's standard kinds \
156                         were tried"
157                    )),
158                ),
159                (None, Err(_)) => (
160                    Importers::standard(),
161                    Some("no project here: only the engine's standard kinds were tried".to_owned()),
162                ),
163            };
164            let root = project.as_ref().map_or(cwd, |p| p.root.as_path());
165            answered(op, input, |i| {
166                let answer = import::import(&importers, root, &i);
167                match &limit {
168                    Some(limit) => answer.limited(limit),
169                    None => answer,
170                }
171            })
172        }
173        Op::Export => match project {
174            Ok(p) => answered(op, input, |i| export::export(p, &i, game)),
175            Err((code, error)) => failed(op, *code, error.clone()),
176        },
177        Op::Lint => match project {
178            Ok(p) => answered(op, input, |i| lint::lint(&lint::ThisMachine, p, &i)),
179            Err((code, error)) => failed(op, *code, error.clone()),
180        },
181        Op::Modules => match project {
182            Ok(p) => answered(op, input, |i| modules::modules(p, &i)),
183            Err((code, error)) => failed(op, *code, error.clone()),
184        },
185        Op::Add => match project {
186            Ok(p) => answered(op, input, |i| add::add(p, &i)),
187            Err((code, error)) => failed(op, *code, error.clone()),
188        },
189        Op::Run => {
190            needs(&|game, p| answered(op, input.clone(), |i| run::run_at(game, &p.root, &i)))
191        }
192        Op::Scene => {
193            needs(&|game, p| answered(op, input.clone(), |i| run::scene_at(game, &p.root, &i)))
194        }
195        Op::Replay => {
196            needs(&|game, p| answered(op, input.clone(), |i| replay::replay(game, &p.root, &i)))
197        }
198        Op::Playtest => {
199            needs(&|game, p| answered(op, input.clone(), |i| playtest::kept(game, &p.root, &i)))
200        }
201        Op::Autobalance => {
202            needs(&|game, _| answered(op, input.clone(), |i| autobalance::autobalance(game, &i)))
203        }
204        Op::Bake => needs(&|game, p| answered(op, input.clone(), |i| bake::bake(game, p, &i))),
205        Op::Start | Op::Step | Op::FramePng | Op::Events | Op::Perf | Op::Reset | Op::Stop => {
206            failed(
207                op,
208                codes::WRONG_ARGUMENTS,
209                format!("{} is a session tool, not an operation", op.name()),
210            )
211        }
212    }
213}
214
215/// The session's game: the options or the session record it started with,
216/// the game, and the heap snapshots `perf` took of it, by frame.
217struct Live {
218    start: Vec<GameArg>,
219    session: Option<SessionInput>,
220    game: Box<dyn Session>,
221    snapshots: Vec<HeapSnapshot>,
222}
223
224/// The game's side of the server: the game's tools on its project, and
225/// the session's game, if one is running.
226pub struct Host<'a> {
227    tools: &'a dyn GameTools,
228    project: Project,
229    live: Option<Live>,
230}
231
232impl<'a> Host<'a> {
233    /// A host of `tools` on `project`, with no game running.
234    pub fn new(tools: &'a dyn GameTools, project: Project) -> Self {
235        Self {
236            tools,
237            project,
238            live: None,
239        }
240    }
241
242    /// The reply to `request`.
243    pub fn call(&mut self, request: &Request) -> Reply {
244        let (op, input) = (request.op, request.input.clone());
245        match op {
246            Op::Start => Reply::of(answered(op, input, |i: StartInput| {
247                self.start(op, i.start, i.session)
248            })),
249            Op::Reset => match (read::<NoInput>(input), &self.live) {
250                (Err(e), _) => Reply::of(failed(op, codes::WRONG_ARGUMENTS, e)),
251                (Ok(_), Some(live)) => {
252                    let (start, session) = (live.start.clone(), live.session.clone());
253                    Reply::of(to_json(&self.start(op, start, session)))
254                }
255                (Ok(_), None) => Reply::of(no_session(op)),
256            },
257            Op::Step => self.with_game(op, |game| {
258                answered(op, input, |i: StepInput| step(game, &i))
259            }),
260            Op::Scene => self.with_game(op, |game| {
261                answered(op, input, |i: SceneInput| {
262                    run::scene_answer(op, game, i.ask(), None, false)
263                })
264            }),
265            Op::Events => self.with_game(op, |game| {
266                answered(op, input, |NoInput {}| {
267                    let output = EventsOutput {
268                        frames: game.frame_count(),
269                        events: game.events(),
270                        sounds: game.sounds(),
271                        sounds_added: game.sounds_added(),
272                    };
273                    Answer::new(op, true, output, Vec::new())
274                })
275            }),
276            Op::Perf => match self.live.as_mut() {
277                Some(live) => Reply::of(answered(op, input, |i: PerfInput| perf_read(live, &i))),
278                None => Reply::of(no_session(op)),
279            },
280            Op::FramePng => match self.live.as_ref() {
281                Some(live) => frame_png(&*live.game, input),
282                None => Reply::of(no_session(op)),
283            },
284            Op::Stop => match (read::<NoInput>(input), self.live.take()) {
285                (Err(e), live) => {
286                    self.live = live;
287                    Reply::of(failed(op, codes::WRONG_ARGUMENTS, e))
288                }
289                (Ok(_), Some(live)) => {
290                    let output = StopOutput {
291                        frames: live.game.frame_count(),
292                    };
293                    Reply::of(to_json(&Answer::new(op, true, output, Vec::new())))
294                }
295                (Ok(_), None) => Reply::of(no_session(op)),
296            },
297            _ => Reply::of(one_off(
298                op,
299                input,
300                &self.project.root,
301                &Ok(self.project.clone()),
302                Some(self.tools),
303            )),
304        }
305    }
306
307    /// The reply of `f` on the session's game, or the answer of `op` that
308    /// there is none.
309    fn with_game(&mut self, op: Op, f: impl FnOnce(&mut dyn Session) -> Value) -> Reply {
310        match self.live.as_mut() {
311            Some(live) => Reply::of(f(&mut *live.game)),
312            None => Reply::of(no_session(op)),
313        }
314    }
315
316    /// The session's game started as `start` says, or from the record
317    /// `session` names, for `start` or `reset` (`op`). A record that plays
318    /// differently answers `ok: false`, and the game stands on that frame.
319    fn start(
320        &mut self,
321        op: Op,
322        start: Vec<GameArg>,
323        session: Option<SessionInput>,
324    ) -> Answer<StartOutput> {
325        if let Err(e) = check_start(self.tools.frame_options(), &start) {
326            return Answer::failed(op, codes::WRONG_ARGUMENTS, e);
327        }
328        if session.is_some() && !start.is_empty() {
329            let error =
330                "start: a session record starts as the game really starts, with no start options";
331            return Answer::failed(op, codes::WRONG_ARGUMENTS, error);
332        }
333        let root = self.project.root.clone();
334        let started = measured_start(|| match &session {
335            Some(input) => {
336                from_record(self.tools, &root, input).map(|(game, played)| (game, Some(played)))
337            }
338            None => self
339                .tools
340                .session(&start)
341                .map(|game| (game, None))
342                .map_err(|e| (code_of(&e), e)),
343        });
344        let (mut game, played) = match started {
345            Ok(started) => started,
346            Err((code, e)) => return Answer::failed(op, code, e),
347        };
348        // So `perf` always has records.
349        game.set_perf(true);
350        let screens = game.screens();
351        let output = StartOutput {
352            project: self.project.root.display().to_string(),
353            game: String::new(),
354            start: start.clone(),
355            options: self
356                .tools
357                .frame_options()
358                .iter()
359                .map(StartOption::from)
360                .collect(),
361            frames: game.frame_count(),
362            top_screen: screens.last().cloned().unwrap_or_default(),
363            screens,
364            session: session
365                .as_ref()
366                .zip(played.as_ref())
367                .map(|(input, played)| SessionPlayed::of(&input.file, played)),
368        };
369        self.live = Some(Live {
370            start,
371            session,
372            game,
373            snapshots: Vec::new(),
374        });
375        let (differs, limits) = played.map_or((false, Vec::new()), |played| {
376            (played.first_difference.is_some(), played.limits)
377        });
378        Answer::new(op, !differs, output, limits)
379    }
380}
381
382/// The answer of session tool `op` with no game running.
383pub fn no_session(op: Op) -> Value {
384    failed(
385        op,
386        codes::NO_SESSION,
387        "no game is running: call `start` first",
388    )
389}
390
391/// `step`: `input` played on `game`, answered as `run` answers.
392fn step(game: &mut dyn Session, input: &StepInput) -> Answer<run::RunOutput> {
393    let checked = RunInput {
394        script: input.script.clone(),
395        frames: input.frames,
396        ..RunInput::default()
397    }
398    .check();
399    if let Err(e) = checked.and_then(|()| run::check_actions(game, &input.script)) {
400        return Answer::failed(Op::Step, codes::WRONG_ARGUMENTS, e);
401    }
402    game.play(&input.script);
403    game.idle(input.frames);
404    let (output, limits) = run::run_output(game);
405    Answer::new(Op::Step, true, output, limits)
406}
407
408/// The frames of `snapshots`, as `perf` lists them.
409fn frames(snapshots: &[HeapSnapshot]) -> Vec<u32> {
410    snapshots.iter().map(|s| s.frame).collect()
411}
412
413/// Keeps `snapshot` in `kept`, by frame (in place of one of the same
414/// frame), and only the last [`KEPT_SNAPSHOTS`].
415fn keep(kept: &mut Vec<HeapSnapshot>, snapshot: HeapSnapshot) {
416    kept.retain(|s| s.frame != snapshot.frame);
417    let at = kept.partition_point(|s| s.frame < snapshot.frame);
418    kept.insert(at, snapshot);
419    let extra = kept.len().saturating_sub(KEPT_SNAPSHOTS);
420    kept.drain(..extra);
421}
422
423/// The kept snapshot of `frame`, or why there is none: the frames kept.
424fn kept_of(kept: &[HeapSnapshot], frame: u32) -> Result<HeapSnapshot, String> {
425    if let Some(snapshot) = kept.iter().find(|s| s.frame == frame) {
426        return Ok(snapshot.clone());
427    }
428    let frames: Vec<String> = frames(kept).iter().map(u32::to_string).collect();
429    let frames = if frames.is_empty() {
430        "none: take one with `snapshot`".to_owned()
431    } else {
432        frames.join(", ")
433    };
434    Err(format!(
435        "diff_from: no snapshot of frame {frame} is kept (kept: {frames})"
436    ))
437}
438
439/// `perf`: the session's frames' records as `input` asks, with their
440/// summary, and a heap snapshot and a diff if it asks for them.
441fn perf_read(live: &mut Live, input: &PerfInput) -> Answer<PerfOutput> {
442    let game = &mut live.game;
443    let taken = perf_answer(input, &mut live.snapshots, |snapshot| {
444        let snapshot = if snapshot { game.heap_snapshot() } else { None };
445        Ok(PerfTaken {
446            read: game.perf(input.since, input.max),
447            snapshot,
448            frames: game.frame_count(),
449            process_heap: game.counts_process_heap(),
450        })
451    });
452    taken.unwrap_or_else(|(code, error)| Answer::failed(Op::Perf, code, error))
453}
454
455/// What a session's game gave `perf`: its records, the heap snapshot it
456/// took if asked, the frames it ran, and whether its heap counts are this
457/// program's own ([`Session::counts_process_heap`]).
458pub(crate) struct PerfTaken {
459    pub(crate) read: PerfRead,
460    pub(crate) snapshot: Option<HeapSnapshot>,
461    pub(crate) frames: u32,
462    pub(crate) process_heap: bool,
463}
464
465/// `perf`'s answer to `input`: what `take` gives (asked for a heap
466/// snapshot or not), with the records' summary and the diff from a
467/// snapshot in `kept`, which keeps the one taken. A headless session's
468/// game and one attached over the live link answer alike through it.
469pub(crate) fn perf_answer(
470    input: &PerfInput,
471    kept: &mut Vec<HeapSnapshot>,
472    take: impl FnOnce(bool) -> Result<PerfTaken, Failed>,
473) -> Result<Answer<PerfOutput>, Failed> {
474    input.check().map_err(|e| (codes::WRONG_ARGUMENTS, e))?;
475    let from = input
476        .diff_from
477        .map(|frame| kept_of(kept, frame))
478        .transpose()
479        .map_err(|e| (codes::WRONG_ARGUMENTS, e))?;
480    let PerfTaken {
481        read,
482        snapshot,
483        frames: ran,
484        process_heap,
485    } = take(input.snapshot)?;
486    if let Some(snapshot) = &snapshot {
487        keep(kept, snapshot.clone());
488    }
489    let to = if input.snapshot {
490        snapshot.as_ref()
491    } else {
492        kept.last()
493    };
494    let diff = from.zip(to).map(|(from, to)| from.diff(to));
495    let mut limits = vec![perf::TIMES.to_owned()];
496    if !read.measured {
497        limits.push(perf::unmeasured_limit(ran));
498    }
499    if input.since > read.next {
500        limits.push(perf::RESET.to_owned());
501    }
502    if read.dropped > 0 {
503        limits.push(perf::dropped_limit(read.dropped));
504    }
505    limits.extend(perf::heap_limits(
506        &read.records,
507        process_heap,
508        perf::other_threads(),
509    ));
510    if input.snapshot && snapshot.is_none() {
511        limits.push(perf::HEAP_UNTAGGED.to_owned());
512    }
513    let output = PerfOutput {
514        summary: perf::summary(&read.records),
515        records: read.records,
516        next: read.next,
517        dropped: read.dropped,
518        snapshot,
519        diff,
520        kept: frames(kept),
521    };
522    Ok(Answer::new(Op::Perf, true, output, limits))
523}
524
525/// `frame_png`: `game`'s frame painted as `input` says.
526fn frame_png(game: &dyn Session, input: Value) -> Reply {
527    let op = Op::FramePng;
528    let input: FramePngInput = match read(input) {
529        Ok(input) => input,
530        Err(e) => return Reply::of(failed(op, codes::WRONG_ARGUMENTS, e)),
531    };
532    if let Some(scale) = input.scale.filter(|s| !(1..=MAX_SCALE).contains(s)) {
533        let error = format!("scale: 1 to {MAX_SCALE}, not {scale}");
534        return Reply::of(failed(op, codes::WRONG_ARGUMENTS, error));
535    }
536    let image = match game.picture(input.scale) {
537        Ok(image) => image,
538        Err(e) => return Reply::of(failed(op, code_of(&e), e)),
539    };
540    let png = match encode_png(image.width, image.height, &image.rgba) {
541        Ok(png) => png,
542        Err(e) => return Reply::of(failed(op, codes::GAME_ERROR, format!("the picture: {e}"))),
543    };
544    let screens = game.screens();
545    let output = FramePngOutput {
546        frames: game.frame_count(),
547        top_screen: screens.last().cloned().unwrap_or_default(),
548        width: image.width,
549        height: image.height,
550    };
551    Reply {
552        answer: Some(to_json(&Answer::new(op, true, output, Vec::new()))),
553        png: Some(base64(&png)),
554        error: None,
555    }
556}
557
558/// `shuyi mcp-host`: answers each request line of `input` on `output`
559/// with `game`'s tools on `project`, until `input` ends. Returns the exit
560/// code: 0, or 1 if a line couldn't be read or written.
561pub fn serve(
562    game: &dyn GameTools,
563    project: Project,
564    input: impl BufRead,
565    mut output: impl Write,
566) -> u8 {
567    let mut host = Host::new(game, project);
568    for line in input.lines() {
569        let Ok(line) = line else { return 1 };
570        if line.trim().is_empty() {
571            continue;
572        }
573        let reply = match serde_json::from_str::<Request>(&line) {
574            Ok(request) => host.call(&request),
575            Err(e) => Reply {
576                error: Some(format!(
577                    "the game's tool program doesn't read the request ({e}): is its engine older \
578                     than the server's?"
579                )),
580                ..Reply::default()
581            },
582        };
583        // A reply of strings, numbers and lists: serializing can't fail.
584        let line = serde_json::to_string(&reply).unwrap_or_default();
585        if writeln!(output, "{REPLY}{line}")
586            .and_then(|()| output.flush())
587            .is_err()
588        {
589            return 1;
590        }
591    }
592    0
593}
594
595/// The host in this process: a [`Launch`] whose game is `tools`, with no
596/// process between, for tests of the server.
597pub struct InProcess<'a> {
598    tools: &'a dyn GameTools,
599}
600
601impl<'a> InProcess<'a> {
602    /// Launches hosts of `tools`.
603    pub fn new(tools: &'a dyn GameTools) -> Self {
604        Self { tools }
605    }
606}
607
608impl<'a> Launch<'a> for InProcess<'a> {
609    fn launch(&self, project: &Project) -> Result<Box<dyn Link + 'a>, (Code, String)> {
610        Ok(Box::new(Host::new(self.tools, project.clone())))
611    }
612}
613
614impl Link for Host<'_> {
615    fn call(&mut self, request: &Request) -> Result<Reply, String> {
616        Ok(Host::call(self, request))
617    }
618}
619
620#[cfg(test)]
621mod tests;