├── mc-server-wrapper-lib ├── src │ ├── test │ │ ├── mod.rs │ │ └── parse │ │ │ ├── mod.rs │ │ │ ├── spigot.rs │ │ │ └── vanilla.rs │ ├── communication.rs │ ├── parse.rs │ └── lib.rs ├── README.md ├── Cargo.toml └── examples │ └── basic.rs ├── rustfmt.toml ├── tui-demo.png ├── Cargo.toml ├── .gitignore ├── .cargo └── audit.toml ├── RELEASE.md ├── .github └── workflows │ ├── audit.yml │ ├── publish.yml │ └── ci.yml ├── LICENSE-MIT ├── mc-server-wrapper ├── Cargo.toml └── src │ ├── logging.rs │ ├── discord │ ├── message_span_iter.rs │ └── mod.rs │ ├── config.rs │ ├── ui.rs │ └── main.rs ├── README.md ├── CHANGELOG.md ├── sample-data └── 1.15.2 │ └── stats │ ├── stats8.json │ ├── stats1.json │ ├── stats10.json │ └── stats6.json └── LICENSE-APACHE /mc-server-wrapper-lib/src/test/mod.rs: -------------------------------------------------------------------------------- 1 | mod parse; 2 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/src/test/parse/mod.rs: -------------------------------------------------------------------------------- 1 | mod spigot; 2 | mod vanilla; 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | unstable_features = true 2 | 3 | imports_granularity = "crate" 4 | -------------------------------------------------------------------------------- /tui-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cldfire/mc-server-wrapper/HEAD/tui-demo.png -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "mc-server-wrapper", 4 | "mc-server-wrapper-lib" 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | /test-servers 4 | mc-server-wrapper-config.toml 5 | .ds_store 6 | .env 7 | .vscode 8 | -------------------------------------------------------------------------------- /.cargo/audit.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | # "Tungstenite allows remote attackers to cause a denial of service" 3 | # 4 | # Very unapplicable to our usecase (communication with Discord). Will eventually 5 | # be resolved by a future Twilight update. 6 | ignore = ["RUSTSEC-2023-0065"] 7 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/README.md: -------------------------------------------------------------------------------- 1 | # mc-server-wrapper-lib 2 | 3 | Rust library that handles communicating with a Java Minecraft server (vanilla, Spigot, or PaperSpigot), allowing you to send commands and receive events via channels. 4 | 5 | This library is currently heavily WIP and in the process of being designed. Documentation and examples will be added in the future. 6 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to release mc-server-wrapper 2 | 3 | 1. Make the necessary updates to [CHANGELOG.md](./CHANGELOG.md). 4 | 2. Bump version in [Cargo.toml](./mc-server-wrapper/Cargo.toml). 5 | 3. Commit changes. 6 | 4. Tag new version and push tag. 7 | ``` 8 | git tag -sm "" 9 | git push origin 10 | ``` 11 | 5. Confirm the release is built and published appropriately by CI. 12 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mc-server-wrapper-lib" 3 | version = "0.1.0" 4 | license = "MIT OR Apache-2.0" 5 | authors = ["Cldfire"] 6 | edition = "2018" 7 | 8 | [dependencies] 9 | time = { version = "0.3.17", features = [ 10 | "local-offset", 11 | "formatting", 12 | "macros", 13 | ] } 14 | tokio = { version = "1.24.1", features = ["full"] } 15 | thiserror = "1.0" 16 | log = "0.4" 17 | once_cell = "1.5" 18 | 19 | [dev-dependencies] 20 | structopt = "0.3" 21 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | schedule: 4 | - cron: '0 0 * * 0' 5 | push: 6 | paths: 7 | - '**/Cargo.toml' 8 | - '**/Cargo.lock' 9 | pull_request: 10 | paths: 11 | - '**/Cargo.toml' 12 | - '**/Cargo.lock' 13 | 14 | jobs: 15 | audit: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions-rs/audit-check@v1 20 | with: 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | name: Publish for ${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | include: 15 | - os: ubuntu-latest 16 | artifact_name: mc-server-wrapper 17 | asset_name: mc-server-wrapper-linux-amd64 18 | - os: windows-latest 19 | artifact_name: mc-server-wrapper.exe 20 | asset_name: mc-server-wrapper-windows-amd64.exe 21 | - os: macos-latest 22 | artifact_name: mc-server-wrapper 23 | asset_name: mc-server-wrapper-macos-amd64 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Build 28 | run: cargo build --release --locked 29 | - name: Upload binaries to release 30 | uses: svenstaro/upload-release-action@v2 31 | with: 32 | repo_token: ${{ secrets.GITHUB_TOKEN }} 33 | file: target/release/${{ matrix.artifact_name }} 34 | asset_name: ${{ matrix.asset_name }} 35 | tag: ${{ github.ref }} 36 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jarek Samic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mc-server-wrapper/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mc-server-wrapper" 3 | version = "0.1.0-alpha9" 4 | license = "MIT OR Apache-2.0" 5 | authors = ["Cldfire"] 6 | edition = "2018" 7 | 8 | [dependencies] 9 | mc-server-wrapper-lib = { path = "../mc-server-wrapper-lib" } 10 | structopt = "0.3" 11 | tokio = { version = "1.24.1", features = ["full"] } 12 | futures = "0.3" 13 | twilight-http = "0.15.1" 14 | twilight-gateway = "0.15.1" 15 | twilight-cache-inmemory = "0.15.1" 16 | twilight-model = "0.15.1" 17 | twilight-mention = "0.15.1" 18 | # TODO: replace this with a crate of my own 19 | minecraft-chat = "0.1" 20 | log = "0.4" 21 | fern = "0.6" 22 | log-panics = "2.0" 23 | time = { version = "0.3.17", features = [ 24 | "local-offset", 25 | "formatting", 26 | "macros", 27 | ] } 28 | once_cell = "1.5" 29 | scopeguard = "1.1" 30 | anyhow = "1.0" 31 | crossterm = { version = "0.27.0", features = ["event-stream"] } 32 | ratatui = "0.23.0" 33 | unicode-width = "0.1" 34 | textwrap = "0.16.0" 35 | toml = "0.8.0" 36 | serde = "1.0" 37 | serde_derive = "1.0" 38 | notify-debouncer-mini = { version = "0.4.1", default-features = false } 39 | 40 | [dev-dependencies] 41 | expect-test = "1.0" 42 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/src/test/parse/spigot.rs: -------------------------------------------------------------------------------- 1 | //! Tests for parsing Spigot-specific console output 2 | 3 | use crate::parse::{ConsoleMsg, ConsoleMsgSpecific}; 4 | 5 | #[test] 6 | fn loading_libraries() { 7 | // spigot prints this non-standard line without a timestamp 8 | let msg = "Loading libraries, please wait..."; 9 | assert!(ConsoleMsg::try_parse_from(msg).is_none()); 10 | } 11 | 12 | #[test] 13 | fn player_login() { 14 | let msg = 15 | "[23:11:12] [Server thread/INFO]: Cldfire[/127.0.0.1:56538] logged in with entity id 97 \ 16 | at ([world]8185.897723692287, 65.0, -330.1145592972985)"; 17 | let specific_msg = 18 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 19 | 20 | match specific_msg { 21 | ConsoleMsgSpecific::PlayerLogin { 22 | name, 23 | ip, 24 | entity_id, 25 | coords, 26 | world, 27 | } => { 28 | assert_eq!(name, "Cldfire"); 29 | assert_eq!(ip, "127.0.0.1:56538"); 30 | assert_eq!(entity_id, 97); 31 | assert_eq!(coords, (8_185.898, 65.0, -330.114_56)); 32 | assert_eq!(world.unwrap(), "world"); 33 | } 34 | _ => unreachable!(), 35 | } 36 | } 37 | 38 | #[test] 39 | fn player_msg() { 40 | let msg = "[23:12:39] [Async Chat Thread - #8/INFO]: hi!"; 41 | let specific_msg = 42 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 43 | 44 | match specific_msg { 45 | ConsoleMsgSpecific::PlayerMsg { name, msg } => { 46 | assert_eq!(name, "Cldfire"); 47 | assert_eq!(msg, "hi!"); 48 | } 49 | _ => unreachable!(), 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | 7 | name: CI 8 | 9 | jobs: 10 | check: 11 | name: Check 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions-rs/toolchain@v1 19 | with: 20 | profile: minimal 21 | toolchain: stable 22 | - uses: actions-rs/cargo@v1 23 | with: 24 | command: check 25 | 26 | test: 27 | name: Test 28 | runs-on: ${{ matrix.os }} 29 | strategy: 30 | matrix: 31 | os: [ubuntu-latest, windows-latest] 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: actions-rs/toolchain@v1 35 | with: 36 | profile: minimal 37 | toolchain: stable 38 | - uses: actions-rs/cargo@v1 39 | with: 40 | command: test 41 | 42 | fmt: 43 | name: Rustfmt 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v2 47 | - uses: actions-rs/toolchain@v1 48 | with: 49 | profile: minimal 50 | toolchain: stable 51 | components: rustfmt 52 | - uses: actions-rs/cargo@v1 53 | with: 54 | command: fmt 55 | args: --all -- --check 56 | 57 | clippy: 58 | name: Clippy 59 | runs-on: ${{ matrix.os }} 60 | strategy: 61 | matrix: 62 | os: [ubuntu-latest, windows-latest] 63 | steps: 64 | - uses: actions/checkout@v2 65 | - uses: actions-rs/toolchain@v1 66 | with: 67 | profile: minimal 68 | toolchain: stable 69 | components: clippy 70 | - uses: actions-rs/cargo@v1 71 | with: 72 | command: clippy 73 | args: -- -D warnings 74 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/src/communication.rs: -------------------------------------------------------------------------------- 1 | use crate::{parse::*, McServerConfig, McServerStartError}; 2 | 3 | use std::{io, process::ExitStatus}; 4 | 5 | /// Events from a Minecraft server. 6 | // TODO: derive serialize, deserialize 7 | // TODO: restructure so there are two main variants: stuff you get directly 8 | // from the server, and stuff more related to management 9 | #[derive(Debug)] 10 | pub enum ServerEvent { 11 | /// An event parsed from the server's console output (stderr or stdout) 12 | /// 13 | /// You are given a `ConsoleMsg` representing a generic form of the console 14 | /// output. This can be directly printed to your program's stdout in order 15 | /// to replicate (with slightly nicer formatting) the Minecraft server's 16 | /// output. 17 | /// 18 | /// You are also given an `Option`. Some `ConsoleMsg`s 19 | /// can be parsed into more specific representations, and in that case you 20 | /// will be given one. These are not for printing; they are useful for 21 | /// triggering actions based on events coming from the server. 22 | ConsoleEvent(ConsoleMsg, Option), 23 | /// An unknown line received from the server's stdout 24 | StdoutLine(String), 25 | /// An unknown line received from the server's stderr 26 | StderrLine(String), 27 | 28 | /// The Minecraft server process finished with the given result and, if 29 | /// known, a reason for exiting 30 | ServerStopped(io::Result, Option), 31 | 32 | /// Response to `AgreeToEula` 33 | AgreeToEulaResult(io::Result<()>), 34 | /// Response to `StartServer` 35 | StartServerResult(Result<(), McServerStartError>), 36 | } 37 | 38 | /// Commands that can be sent over channels to be performed by the MC server. 39 | /// 40 | /// Note that all commands will be ignored if they cannot be performed (i.e., 41 | /// telling the server to send a message ) 42 | #[derive(Debug, Clone)] 43 | pub enum ServerCommand { 44 | /// Send a message to all players on the server 45 | /// 46 | /// Message should be JSON of the following format: 47 | /// https://minecraft.wiki/w/Raw_JSON_text_format 48 | TellRawAll(String), 49 | /// Write the given string to the server's stdin as a command 50 | /// 51 | /// This means that the given string will have "\n" appended to it 52 | WriteCommandToStdin(String), 53 | /// Write the given string verbatim to stdin 54 | WriteToStdin(String), 55 | 56 | /// Agree to the EULA (required to run the server) 57 | AgreeToEula, 58 | /// Start the Minecraft server with the given config 59 | /// 60 | /// If no config is provided, the manager will use the previously provided 61 | /// config (if there was one) 62 | StartServer { config: Option }, 63 | /// Stop the Minecraft server (if it is running) 64 | /// 65 | /// Setting `forever` to true will cause the `McServer` instance to stop 66 | /// listening for commands and gracefully shutdown everything related to 67 | /// it. 68 | StopServer { forever: bool }, 69 | } 70 | 71 | /// Reasons that a Minecraft server stopped running 72 | // TODO: add variant indicating user requested server be stopped 73 | #[derive(Debug, Clone)] 74 | pub enum ShutdownReason { 75 | /// The server stopped because the EULA has not been accepted 76 | EulaNotAccepted, 77 | /// The server stopped because `ServerCommand::StopServer` was received 78 | RequestedToStop, 79 | } 80 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/examples/basic.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | Simple usage of the library to transparently wrap a Minecraft server. 3 | 4 | This example stays as close to the default server experience as possible; 5 | the only changes are improved console output formatting and auto-agreeing 6 | to the EULA. 7 | 8 | This is a nice starter for quickly adding some simple event-driven functionality 9 | to a Minecraft server. 10 | */ 11 | 12 | use std::path::PathBuf; 13 | 14 | use structopt::StructOpt; 15 | 16 | use mc_server_wrapper_lib::{communication::*, McServerConfig, McServerManager}; 17 | 18 | #[derive(StructOpt, Debug)] 19 | pub struct Opt { 20 | /// Path to the Minecraft server jar 21 | #[structopt(parse(from_os_str))] 22 | server_path: PathBuf, 23 | } 24 | 25 | #[tokio::main] 26 | async fn main() { 27 | let opt = Opt::from_args(); 28 | 29 | let config = McServerConfig::new(opt.server_path.clone(), 1024, None, true); 30 | let (_, cmd_sender, mut event_receiver) = McServerManager::new(); 31 | cmd_sender 32 | .send(ServerCommand::StartServer { 33 | config: Some(config), 34 | }) 35 | .await 36 | .unwrap(); 37 | 38 | while let Some(e) = event_receiver.recv().await { 39 | match e { 40 | ServerEvent::ConsoleEvent(console_msg, Some(specific_msg)) => { 41 | println!("{}", console_msg); 42 | // You can match on and handle the `specific_msg`s as desired 43 | println!(" specific_msg: {:?}", specific_msg); 44 | } 45 | ServerEvent::ConsoleEvent(console_msg, None) => { 46 | println!("{}", console_msg); 47 | } 48 | ServerEvent::StdoutLine(line) => { 49 | println!("{}", line); 50 | } 51 | ServerEvent::StderrLine(line) => { 52 | eprintln!("{}", line); 53 | } 54 | 55 | ServerEvent::ServerStopped(process_result, reason) => { 56 | if let Some(ShutdownReason::EulaNotAccepted) = reason { 57 | println!("Agreeing to EULA!"); 58 | cmd_sender.send(ServerCommand::AgreeToEula).await.unwrap(); 59 | } else { 60 | match process_result { 61 | Ok(exit_status) => { 62 | if !exit_status.success() { 63 | eprintln!("Minecraft server process finished with {}", exit_status) 64 | } 65 | } 66 | Err(e) => eprintln!("Minecraft server process finished with error: {}", e), 67 | } 68 | 69 | // Note that this example does not implement any kind of restart-after-crash 70 | // functionality 71 | cmd_sender 72 | .send(ServerCommand::StopServer { forever: true }) 73 | .await 74 | .unwrap(); 75 | } 76 | } 77 | 78 | ServerEvent::AgreeToEulaResult(res) => { 79 | if let Err(e) = res { 80 | eprintln!("Failed to agree to EULA: {:?}", e); 81 | cmd_sender 82 | .send(ServerCommand::StopServer { forever: true }) 83 | .await 84 | .unwrap(); 85 | } else { 86 | cmd_sender 87 | .send(ServerCommand::StartServer { config: None }) 88 | .await 89 | .unwrap(); 90 | } 91 | } 92 | ServerEvent::StartServerResult(res) => { 93 | if let Err(e) = res { 94 | eprintln!("Failed to start the Minecraft server: {}", e); 95 | cmd_sender 96 | .send(ServerCommand::StopServer { forever: true }) 97 | .await 98 | .unwrap(); 99 | } 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /mc-server-wrapper/src/logging.rs: -------------------------------------------------------------------------------- 1 | use mc_server_wrapper_lib::CONSOLE_MSG_LOG_TARGET; 2 | use std::path::Path; 3 | use time::format_description::FormatItem; 4 | use tokio::sync::mpsc::Sender; 5 | 6 | pub fn setup_logger>( 7 | logfile_path: P, 8 | log_sender: Sender, 9 | log_level_all: log::Level, 10 | log_level_self: log::Level, 11 | log_level_discord: log::Level, 12 | ) -> Result<(), fern::InitError> { 13 | let file_logger = fern::Dispatch::new() 14 | .format(|out, message, record| { 15 | const LOG_TIMESTAMP_FORMAT: &[FormatItem] = time::macros::format_description!( 16 | "[[[month]-[day]-[year]][[[hour repr:12 padding:none]:[minute]:[second] [period]]" 17 | ); 18 | 19 | let formatted_time_now = || -> Option { 20 | // TODO: log errors here somehow 21 | time::OffsetDateTime::now_local() 22 | .ok() 23 | .and_then(|datetime| datetime.format(&LOG_TIMESTAMP_FORMAT).ok()) 24 | }; 25 | 26 | out.finish(format_args!( 27 | "{}[{}][{}] {}", 28 | formatted_time_now().unwrap_or_else(|| String::from("time error")), 29 | record.target(), 30 | record.level(), 31 | message 32 | )) 33 | }) 34 | .level(log_level_all.to_level_filter()) 35 | .level_for("twilight_http", log_level_discord.to_level_filter()) 36 | .level_for("twilight_gateway", log_level_discord.to_level_filter()) 37 | .level_for("twilight-cache", log_level_discord.to_level_filter()) 38 | .level_for( 39 | "twilight-command-parser", 40 | log_level_discord.to_level_filter(), 41 | ) 42 | .level_for("twilight-model", log_level_discord.to_level_filter()) 43 | .level_for( 44 | "twilight-cache-inmemory", 45 | log_level_discord.to_level_filter(), 46 | ) 47 | .level_for("twilight-cache-trait", log_level_discord.to_level_filter()) 48 | .level_for("mc_server_wrapper", log_level_self.to_level_filter()) 49 | .level_for( 50 | *CONSOLE_MSG_LOG_TARGET.get().unwrap(), 51 | log::LevelFilter::Off, 52 | ) 53 | .chain(fern::log_file(logfile_path)?); 54 | 55 | let tui_logger = fern::Dispatch::new() 56 | .level(log::LevelFilter::Error) 57 | .level_for("twilight_http", log::LevelFilter::Warn) 58 | .level_for("twilight_gateway", log::LevelFilter::Warn) 59 | .level_for("twilight-cache", log::LevelFilter::Warn) 60 | .level_for("twilight-command-parser", log::LevelFilter::Warn) 61 | .level_for("twilight-model", log::LevelFilter::Warn) 62 | .level_for("twilight-cache-inmemory", log::LevelFilter::Warn) 63 | .level_for("twilight-cache-trait", log::LevelFilter::Warn) 64 | .level_for("mc_server_wrapper", log::LevelFilter::Info) 65 | .level_for( 66 | *CONSOLE_MSG_LOG_TARGET.get().unwrap(), 67 | log::LevelFilter::Info, 68 | ) 69 | .chain(fern::Output::call(move |record| { 70 | const CONSOLE_TIMESTAMP_FORMAT: &[FormatItem] = time::macros::format_description!( 71 | "[hour repr:12 padding:none]:[minute]:[second] [period]" 72 | ); 73 | 74 | let formatted_time_now = || -> Option { 75 | // TODO: log errors here somehow 76 | time::OffsetDateTime::now_local() 77 | .ok() 78 | .and_then(|datetime| datetime.format(&CONSOLE_TIMESTAMP_FORMAT).ok()) 79 | }; 80 | 81 | let record = format!( 82 | "[{}] [{}, {}]: {}", 83 | formatted_time_now().unwrap_or_else(|| String::from("time error")), 84 | record.target(), 85 | record.level(), 86 | record.args() 87 | ); 88 | 89 | let log_sender_clone = log_sender.clone(); 90 | // TODO: right now log messages can print out-of-order because we 91 | // don't block on sending them 92 | // 93 | // Tried using `Handle::block_on` but couldn't get it to not panic 94 | // with `Illegal instruction` 95 | // 96 | // Need to investigate 97 | tokio::spawn(async move { 98 | let _ = log_sender_clone.send(record).await; 99 | }); 100 | })); 101 | 102 | fern::Dispatch::new() 103 | .chain(tui_logger) 104 | .chain(file_logger) 105 | .apply()?; 106 | 107 | Ok(()) 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mc-server-wrapper 2 | 3 | ![CI](https://github.com/Cldfire/mc-server-wrapper/workflows/CI/badge.svg) 4 | [![dependency status](https://deps.rs/repo/github/cldfire/mc-server-wrapper/status.svg)](https://deps.rs/repo/github/cldfire/mc-server-wrapper) 5 | 6 | Lightweight Rust program to manage a Java Minecraft server process (vanilla, Spigot, or PaperSpigot), providing niceties such as a Discord chat bridge, server restart-on-crash, and improved console output. 7 | 8 | This has been running over top of a small server of mine 24/7 since March 2020 with zero crashes / major issues of any kind. I'd consider it ready for small-scale production usage. 9 | 10 | ## Features 11 | 12 | * Runs on Linux, macOS, and Windows 13 | * Optionally enabled bi-directional Discord chat bridge (see [Discord Bridge Setup](#discord-bridge-setup)) 14 | * Commands (prefixed by `!mc`): 15 | * `list`: replies with a list of people playing Minecraft 16 | * Embeds, mentions, and attachments in Discord messages are neatly formatted in Minecraft 17 | * Bot status message displays server info (such as the names of online players) 18 | * Run server with configurable memory allocation 19 | * Also allows passing custom JVM flags if desired 20 | * Restart server on crash 21 | * Auto-agree to EULA 22 | * Improved console output formatting 23 | 24 | ## Installation 25 | 26 | ### Downloading 27 | 28 | You can download prebuilt binaries in the [releases section](https://github.com/Cldfire/mc-server-wrapper/releases). 29 | 30 | ### Building 31 | 32 | You can also build and install from source (requires an up-to-date [Rust](https://www.rust-lang.org) install): 33 | 34 | ``` 35 | cargo install --git https://github.com/Cldfire/mc-server-wrapper.git --locked 36 | ``` 37 | 38 | ## Usage 39 | 40 | Generate a default config (customize the config path with the `-c` flag if desired): 41 | 42 | ``` 43 | mc-server-wrapper -g 44 | ``` 45 | 46 | Edit the config as required, explanation of options below. You can then start the server with the following (if you used the `-c` flag above use it again here): 47 | 48 | ``` 49 | mc-server-wrapper 50 | ``` 51 | 52 | Run `mc-server-wrapper --help` for some CLI args to quickly override the config with. 53 | 54 | ### Config 55 | 56 | ```toml 57 | [minecraft] 58 | # The path to the server jar 59 | server_path = "./server.jar" 60 | # The memory in megabytes to allocate for the server 61 | memory = 1024 62 | # If you would like to pass custom flags to the JVM you can do so here 63 | jvm_flags = "-XX:MaxGCPauseMillis=200" 64 | 65 | # The Discord section is optional 66 | [discord] 67 | # Enable or disable the Discord bridge 68 | enable_bridge = true 69 | # The Discord bot token 70 | token = "..." 71 | # The Discord channel ID to bridge to 72 | channel_id = 123 73 | # Enable or disable bot status message updates 74 | update_status = true 75 | 76 | # Valid log levels: error, warn, info, debug, trace 77 | # 78 | # Logging levels set here only affect file logging 79 | [logging] 80 | # The log level for general mc-server-wrapper dependencies 81 | all = "Warn" 82 | # The log level for mc-server-wrapper 83 | self = "Debug" 84 | # The log level for Discord-related dependencies 85 | discord = "Info" 86 | ``` 87 | 88 | ### Discord bridge setup 89 | 90 | * Register an application and a bot with [Discord](https://discordapp.com/developers/applications) 91 | * Enable some things in the `Privileged Gateway Intents` section of the bot's admin portal 92 | * Toggle `Server Members Intent` on 93 | * This is used to receive member updates from your guild (such as when someone changes their nickname so we can change the name we display in-game) 94 | * Toggle `Message Content Intent` on 95 | * This is used to receive messsage content from the channel for the chat bridge in Discord so we can relay chat there into Minecraft 96 | * Add the bot to the guild you want to bridge to 97 | * Get the ID of the channel you want to bridge to (Google this for instructions) 98 | * Provide the bot token and channel ID in the config file 99 | * Enable the Discord bridge in the config file or with the `-b` flag 100 | 101 | ## Future plans 102 | 103 | * Simple web and CLI interface to administrate server 104 | * Change most common settings 105 | * View online players 106 | * Chat from the web 107 | * Different levels of accounts (user, admin) 108 | * _further ideas here_ 109 | 110 | ## Library 111 | 112 | The binary for this project is built on top of a library; if you want to implement a different feature set than the one I've chosen to, or implement the features in a different way, you can easily do so. See the [`mc-server-wrapper-lib` README](mc-server-wrapper-lib/README.md) and its [basic example](mc-server-wrapper-lib/examples/basic.rs) for more. 113 | 114 | ``` 115 | cargo run --example basic -- path/to/server.jar 116 | ``` 117 | 118 | ## Screenshot 119 | 120 | Early screenshot, subject to change: 121 | 122 | ![demo screenshot showing off the TUI](tui-demo.png) 123 | 124 | #### License 125 | 126 | 127 | Licensed under either of Apache License, Version 128 | 2.0 or MIT license at your option. 129 | 130 | 131 |
132 | 133 | 134 | Unless you explicitly state otherwise, any contribution intentionally submitted 135 | for inclusion in this crate by you, as defined in the Apache-2.0 license, shall 136 | be dual licensed as above, without any additional terms or conditions. 137 | 138 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # mc-server-wrapper changelog 2 | 3 | Notable `mc-server-wrapper` changes, tracked in the [keep a changelog](https://keepachangelog.com/en/1.0.0/) format with the addition of the `Internal` change type. 4 | 5 | ## [Unreleased] 6 | 7 | ## [alpha9] - 2023-10-10 8 | 9 | ### Added 10 | 11 | * Added support for insecure chat messages ([#17](https://github.com/Cldfire/mc-server-wrapper/pull/17) - @derspyy) 12 | 13 | ### Internal 14 | 15 | * Replace `tui` with `ratatui` 0.23.0 16 | * Updated `crossterm` to 0.27.0 17 | * Updated `notify-debouncer-mini` to 0.4.1 18 | * Updated `toml` to 0.8.0 19 | * Updated other dependencies 20 | 21 | ## [alpha8] - 2023-06-08 22 | 23 | Dependency updates and adjustments for the new Discord username format. 24 | 25 | ## Added 26 | 27 | * `--version` flag for checking the application version 28 | 29 | ### Changed 30 | 31 | * Chat message formatting has been updated to handle the new Discord username system. 32 | 33 | ### Internal 34 | 35 | * Updated `twilight` to 0.15 36 | * Updated `tui` to 0.19 37 | * Updated `crossterm` to 0.26.0 38 | * Updated `toml` to 0.7 39 | * Replaced `notify` with `notify-debouncer-mini` 0.3.0 40 | * Updated `textwrap` to 0.16.0 41 | * Updated other dependencies 42 | * Renamed `master` branch to `main` 43 | * Added RELEASE.md documenting how to release `mc-server-wrapper` 44 | 45 | ## [alpha7] - 2022-3-21 46 | 47 | Small release updating some dependencies. 48 | 49 | ### Changed 50 | 51 | * All commands in Discord chat have been removed (aka `!mc list`). These commands need to be brought back in the future as interactions, a new Discord API feature 52 | * Removed all usage of the `chrono` crate and replaced it with the `time` crate for datetime operations 53 | 54 | ### Internal 55 | 56 | * Added dependency on `time` 0.3 57 | * Updated `twilight` to 0.10 58 | * Updated `tui` to 0.17 59 | * Updated `crostterm` to 0.23.1 60 | * Updated `textwrap` to 0.15 61 | * Removed dependency on `chrono` 62 | * Updated other dependencies 63 | 64 | ## [alpha6] - 2021-11-17 65 | 66 | Small release updating some dependencies. 67 | 68 | ### Changed 69 | 70 | * The hover text for embed links in Minecraft now includes the full link URL 71 | 72 | ### Internal 73 | 74 | * Updated `twilight` to 0.7 75 | * Updated `crossterm` to 0.22 76 | * Updated `tui` to 0.16 77 | * Updated `textwrap` to 0.14 78 | * Updated `tokio` to 1.8.1 79 | * Updated other dependencies 80 | * Removed `rusty-hook` and its corresponding git hooks 81 | 82 | ## [alpha5] - 2021-5-24 83 | 84 | Small release updating some dependencies. 85 | 86 | ### Internal 87 | 88 | * Updated `tokio` to 1.0 89 | * Updated `twilight` to 0.4 90 | * Updated `tui` to 0.15 91 | * Updated other dependencies 92 | * Replaced `minecraft-protocol` dependency with `minecraft-chat` 93 | 94 | ## [alpha4] - 2020-12-20 95 | 96 | ### Added 97 | 98 | * Mentions within Discord are now formatted with a blue color in Minecraft (similar to how they look in the Discord client) 99 | * Hovering over the username part of a Discord message in Minecraft will now reveal the Discord user's account name and discriminator (e.g. Cldfire#3395) 100 | * Also displayed when hovering over user mentions 101 | * This information is now logged as well 102 | 103 | ### Changed 104 | 105 | * `Tab` / `Shift + Tab` are now the shortcuts used to change tabs in the UI 106 | 107 | ### Fixed 108 | 109 | * The session times displayed in the "Players" tab are now accurate 110 | 111 | ### Internal 112 | 113 | * Removed dependency on `ringbuffer` (using `VecDeque` instead) 114 | 115 | ## [alpha3] - 2020-12-15 116 | 117 | ### Added 118 | 119 | * The UI now has a "Players" tab to display information about players on the server 120 | * Displays a list of online players with login time and session length 121 | 122 | ### Changed 123 | 124 | * We no longer attempt to fetch missing member info via Discord's HTTP API if it is not present in the cache ([explanation](https://github.com/twilight-rs/twilight/pull/437)) 125 | * This should not have any user-facing impact because technically speaking all needed info will be present in the cache anyway 126 | * The config that gets generated by default now includes a blank `[discord]` section for easier Discord bridge setup 127 | 128 | ### Fixed 129 | 130 | * Minecraft player names are no longer markdown-sanitized for display in the bot status (the bot status message doesn't support markdown) 131 | 132 | ### Internal 133 | 134 | * Updated to `twilight` 0.2 release from crates.io 135 | * Replaced custom mention parsing code with the new `twilight-mention` support for parsing mentions from Discord messages 136 | * Started adjusting the binary's `Cargo.toml` version for each release 137 | * Added a deps.rs badge to the README 138 | * Updated workflows 139 | * Clippy job now denies warnings 140 | * Daily audit job is now a weekly audit job 141 | 142 | ## [alpha2] - 2020-08-22 143 | 144 | ### Added 145 | 146 | * The bot's status message is now updated with server information (such as the names of online players) 147 | * Content that failed to validate will now be logged alongside the warning that a message failed to send to Discord 148 | 149 | ## [alpha1] - 2020-07-26 150 | 151 | Tagging a first alpha release after a few months of working on the project. 152 | 153 | [Unreleased]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha9...HEAD 154 | [alpha9]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha8...alpha9 155 | [alpha8]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha7...alpha8 156 | [alpha7]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha6...alpha7 157 | [alpha6]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha5...alpha6 158 | [alpha5]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha4...alpha5 159 | [alpha4]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha3...alpha4 160 | [alpha3]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha2...alpha3 161 | [alpha2]: https://github.com/Cldfire/mc-server-wrapper/compare/alpha1...alpha2 162 | [alpha1]: https://github.com/Cldfire/mc-server-wrapper/releases/tag/alpha1 163 | -------------------------------------------------------------------------------- /mc-server-wrapper/src/discord/message_span_iter.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroU64; 2 | 3 | use twilight_mention::parse::MentionType; 4 | use twilight_model::id::Id; 5 | 6 | trait MentionTypeExt: Sized { 7 | fn try_parse(buf: &str) -> Option; 8 | } 9 | 10 | impl MentionTypeExt for MentionType { 11 | /// Try to parse the inner part of a mention from the given `buf`. 12 | /// 13 | /// This function will parse input such as "@!21984" successfully. It *does 14 | /// not* handle the < or > characters. 15 | fn try_parse(buf: &str) -> Option { 16 | if let Some(buf) = buf.strip_prefix("@!") { 17 | // Parse user ID 18 | buf.parse::() 19 | .ok() 20 | .map(|n| Self::User(Id::from(n))) 21 | } else if let Some(buf) = buf.strip_prefix("@&") { 22 | // Parse role ID 23 | buf.parse::() 24 | .ok() 25 | .map(|n| Self::Role(Id::from(n))) 26 | } else if let Some(buf) = buf.strip_prefix('@') { 27 | // Parse user ID 28 | buf.parse::() 29 | .ok() 30 | .map(|n| Self::User(Id::from(n))) 31 | } else if let Some(buf) = buf.strip_prefix(':') { 32 | // Parse emoji ID (looks like "<:name:123>") 33 | // 34 | // Find the second ":" 35 | buf.find(':') 36 | // Skip past the second : to get to the ID 37 | .and_then(|idx| buf.get(idx + 1..)) 38 | .and_then(|s| s.parse::().ok()) 39 | .map(|n| Self::Emoji(Id::from(n))) 40 | } else if let Some(buf) = buf.strip_prefix('#') { 41 | // Parse channel ID 42 | buf.parse::() 43 | .ok() 44 | .map(|n| Self::Channel(Id::from(n))) 45 | } else { 46 | None 47 | } 48 | } 49 | } 50 | 51 | trait StrExt { 52 | fn find_after(&self, idx: usize, slice: &str) -> Option; 53 | } 54 | 55 | impl StrExt for str { 56 | /// Looks for the given `slice` beginning after the given `idx` in `self` 57 | fn find_after(&self, idx: usize, slice: &str) -> Option { 58 | self.get(idx + 1..) 59 | .and_then(|s| s.find(slice).map(|next_idx| next_idx + idx + 1)) 60 | } 61 | } 62 | 63 | /// Spans parsed out of a Discord message. 64 | #[derive(Debug, Eq, PartialEq)] 65 | pub enum MessageSpan<'a> { 66 | /// Plain text 67 | Text(&'a str), 68 | /// Some sort of mention 69 | /// 70 | /// The left side of the tuple is the parsed data and the right side is the 71 | /// string slice that it was parsed from. 72 | Mention(MentionType, &'a str), 73 | } 74 | 75 | impl<'a> MessageSpan<'a> { 76 | pub fn iter(buf: &'a str) -> MessageSpanIter<'a> { 77 | MessageSpanIter { buf, mention: None } 78 | } 79 | } 80 | 81 | #[derive(Debug)] 82 | pub struct MessageSpanIter<'a> { 83 | buf: &'a str, 84 | mention: Option<(MentionType, &'a str)>, 85 | } 86 | 87 | impl<'a> Iterator for MessageSpanIter<'a> { 88 | type Item = MessageSpan<'a>; 89 | 90 | fn next(&mut self) -> Option { 91 | if let Some(mention_type) = self.mention.take() { 92 | // Yield the previously stored mention info if it's present 93 | Some(MessageSpan::Mention(mention_type.0, mention_type.1)) 94 | } else if let Some((start, end)) = self 95 | .buf 96 | .find('<') 97 | .and_then(|start| self.buf.find_after(start, ">").map(|end| (start, end))) 98 | { 99 | // Check and see if we can parse a valid mention 100 | if let Some(mention_type) = self 101 | .buf 102 | .get(start + 1..end) 103 | .and_then(MentionType::try_parse) 104 | { 105 | // Store the mention info to be yielded on the next iteration 106 | self.mention = Some((mention_type, &self.buf[start..=end])); 107 | let ret = Some(MessageSpan::Text(&self.buf[..start])); 108 | self.buf = self.buf.get(end + 1..).unwrap_or(""); 109 | ret 110 | } else { 111 | // The mention wasn't valid, yield everything through the > as 112 | // plain text 113 | let ret = Some(MessageSpan::Text(&self.buf[..=end])); 114 | self.buf = self.buf.get(end + 1..).unwrap_or(""); 115 | ret 116 | } 117 | } else if !self.buf.is_empty() { 118 | let ret = Some(MessageSpan::Text(self.buf)); 119 | self.buf = ""; 120 | ret 121 | } else { 122 | None 123 | } 124 | } 125 | } 126 | 127 | #[cfg(test)] 128 | mod test { 129 | use expect_test::{expect, Expect}; 130 | 131 | use super::MessageSpan; 132 | 133 | fn check(actual: &str, expect: Expect) { 134 | let spans = MessageSpan::iter(actual).collect::>(); 135 | expect.assert_debug_eq(&spans); 136 | } 137 | 138 | #[test] 139 | fn empty_string() { 140 | check( 141 | "", 142 | expect![[r#" 143 | [] 144 | "#]], 145 | ); 146 | } 147 | 148 | #[test] 149 | fn various_mention_types() { 150 | check( 151 | "channel <#12> emoji <:name:34> role <@&56> user <@78>", 152 | expect![[r##" 153 | [ 154 | Text( 155 | "channel ", 156 | ), 157 | Mention( 158 | Channel( 159 | Id(12), 160 | ), 161 | "<#12>", 162 | ), 163 | Text( 164 | " emoji ", 165 | ), 166 | Mention( 167 | Emoji( 168 | Id(34), 169 | ), 170 | "<:name:34>", 171 | ), 172 | Text( 173 | " role ", 174 | ), 175 | Mention( 176 | Role( 177 | Id(56), 178 | ), 179 | "<@&56>", 180 | ), 181 | Text( 182 | " user ", 183 | ), 184 | Mention( 185 | User( 186 | Id(78), 187 | ), 188 | "<@78>", 189 | ), 190 | ] 191 | "##]], 192 | ); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/src/test/parse/vanilla.rs: -------------------------------------------------------------------------------- 1 | //! Tests for parsing vanilla console output 2 | 3 | use crate::parse::{ConsoleMsg, ConsoleMsgSpecific, ConsoleMsgType}; 4 | 5 | #[test] 6 | fn warn_msg() { 7 | let msg = "[23:10:30] [main/WARN]: Ambiguity between arguments [teleport, targets, location] \ 8 | and [teleport, targets, destination] with inputs: [0.1 -0.5 .9, 0 0 0]"; 9 | let console_msg = ConsoleMsg::try_parse_from(msg).unwrap(); 10 | 11 | assert_eq!(console_msg.timestamp.hour(), 23); 12 | assert_eq!(console_msg.timestamp.minute(), 10); 13 | assert_eq!(console_msg.timestamp.second(), 30); 14 | assert_eq!(console_msg.thread_name, "main"); 15 | assert_eq!(console_msg.msg_type, ConsoleMsgType::Warn); 16 | assert_eq!( 17 | console_msg.msg, 18 | "Ambiguity between arguments [teleport, targets, location] \ 19 | and [teleport, targets, destination] with inputs: [0.1 -0.5 .9, 0 0 0]" 20 | ); 21 | 22 | assert!(ConsoleMsgSpecific::try_parse_from(&console_msg).is_none()); 23 | } 24 | 25 | #[test] 26 | fn info_msg() { 27 | let msg = "[23:10:31] [Server thread/INFO]: Starting Minecraft server on *:25565"; 28 | let console_msg = ConsoleMsg::try_parse_from(msg).unwrap(); 29 | 30 | assert_eq!(console_msg.timestamp.hour(), 23); 31 | assert_eq!(console_msg.timestamp.minute(), 10); 32 | assert_eq!(console_msg.timestamp.second(), 31); 33 | assert_eq!(console_msg.thread_name, "Server thread"); 34 | assert_eq!(console_msg.msg_type, ConsoleMsgType::Info); 35 | assert_eq!(console_msg.msg, "Starting Minecraft server on *:25565"); 36 | 37 | assert!(ConsoleMsgSpecific::try_parse_from(&console_msg).is_none()); 38 | } 39 | 40 | #[test] 41 | fn newline() { 42 | let msg = "\n"; 43 | assert!(ConsoleMsg::try_parse_from(msg).is_none()); 44 | } 45 | 46 | #[test] 47 | fn blank_here() { 48 | // somehow occurs when rapidly firing unknown commands 49 | let msg = "[19:23:04] [Server thread/INFO]: <--[HERE]"; 50 | let console_msg = ConsoleMsg::try_parse_from(msg).unwrap(); 51 | 52 | assert!(ConsoleMsgSpecific::try_parse_from(&console_msg).is_none()); 53 | } 54 | 55 | #[test] 56 | fn must_accept_eula() { 57 | let msg = "[00:03:56] [Server thread/INFO]: You need to agree to the EULA in order to run the \ 58 | server. Go to eula.txt for more info."; 59 | let specific_msg = 60 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 61 | 62 | assert_eq!(specific_msg, ConsoleMsgSpecific::MustAcceptEula); 63 | } 64 | 65 | #[test] 66 | fn player_msg() { 67 | let msg = "[23:12:39] [Server thread/INFO]: hi!"; 68 | let specific_msg = 69 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 70 | 71 | match specific_msg { 72 | ConsoleMsgSpecific::PlayerMsg { name, msg } => { 73 | assert_eq!(name, "Cldfire"); 74 | assert_eq!(msg, "hi!"); 75 | } 76 | _ => unreachable!(), 77 | } 78 | } 79 | 80 | #[test] 81 | fn insecure_player_msg() { 82 | let msg = "[19:19:48] [Server thread/INFO]: [Not Secure] hello world! :)"; 83 | let specific_msg = 84 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 85 | 86 | match specific_msg { 87 | ConsoleMsgSpecific::PlayerMsg { name, msg } => { 88 | assert_eq!(name, "despiuvas"); 89 | assert_eq!(msg, "hello world! :)"); 90 | } 91 | _ => unreachable!(), 92 | } 93 | } 94 | 95 | #[test] 96 | fn player_login() { 97 | let msg = "[23:11:12] [Server thread/INFO]: Cldfire[/127.0.0.1:56538] logged in with entity \ 98 | id 121 at (-2.5, 63.0, 256.5)"; 99 | let specific_msg = 100 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 101 | 102 | match specific_msg { 103 | ConsoleMsgSpecific::PlayerLogin { 104 | name, 105 | ip, 106 | entity_id, 107 | coords, 108 | world, 109 | } => { 110 | assert_eq!(name, "Cldfire"); 111 | assert_eq!(ip, "127.0.0.1:56538"); 112 | assert_eq!(entity_id, 121); 113 | assert_eq!(coords, (-2.5, 63.0, 256.5)); 114 | assert!(world.is_none()); 115 | } 116 | _ => unreachable!(), 117 | } 118 | } 119 | 120 | #[test] 121 | fn player_auth() { 122 | let msg = "[23:11:12] [User Authenticator #1/INFO]: UUID of player Cldfire is \ 123 | 361e5fb3-dbce-4f91-86b2-43423a4888d5"; 124 | let specific_msg = 125 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 126 | 127 | match specific_msg { 128 | ConsoleMsgSpecific::PlayerAuth { name, uuid } => { 129 | assert_eq!(name, "Cldfire"); 130 | assert_eq!(uuid, "361e5fb3-dbce-4f91-86b2-43423a4888d5"); 131 | } 132 | _ => unreachable!(), 133 | } 134 | } 135 | 136 | #[test] 137 | fn spawn_prepare_progress() { 138 | let msg = "[23:10:35] [Server thread/INFO]: Preparing spawn area: 44%"; 139 | let specific_msg = 140 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 141 | 142 | match specific_msg { 143 | ConsoleMsgSpecific::SpawnPrepareProgress { progress } => { 144 | assert_eq!(progress, 44); 145 | } 146 | _ => unreachable!(), 147 | } 148 | } 149 | 150 | #[test] 151 | fn spawn_prepare_finished() { 152 | let msg = "[23:10:35] [Server thread/INFO]: Time elapsed: 3292 ms"; 153 | let specific_msg = 154 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 155 | 156 | match specific_msg { 157 | ConsoleMsgSpecific::SpawnPrepareFinish { time_elapsed_ms } => { 158 | assert_eq!(time_elapsed_ms, 3292); 159 | } 160 | _ => unreachable!(), 161 | } 162 | } 163 | 164 | #[test] 165 | fn player_lost_connection() { 166 | let msg = "[19:10:21] [Server thread/INFO]: Cldfire lost connection: Disconnected"; 167 | let specific_msg = 168 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 169 | 170 | match specific_msg { 171 | ConsoleMsgSpecific::PlayerLostConnection { name, reason } => { 172 | assert_eq!(name, "Cldfire"); 173 | assert_eq!(reason, "Disconnected"); 174 | } 175 | _ => unreachable!(), 176 | } 177 | } 178 | 179 | #[test] 180 | fn player_left_game() { 181 | let msg = "[19:10:21] [Server thread/INFO]: Cldfire left the game"; 182 | let specific_msg = 183 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 184 | 185 | match specific_msg { 186 | ConsoleMsgSpecific::PlayerLogout { name } => { 187 | assert_eq!(name, "Cldfire"); 188 | } 189 | _ => unreachable!(), 190 | } 191 | } 192 | 193 | #[test] 194 | fn server_finished_loading() { 195 | let msg = "[21:57:50] [Server thread/INFO]: Done (7.410s)! For help, type \"help\""; 196 | let specific_msg = 197 | ConsoleMsgSpecific::try_parse_from(&ConsoleMsg::try_parse_from(msg).unwrap()).unwrap(); 198 | 199 | match specific_msg { 200 | ConsoleMsgSpecific::FinishedLoading { time_elapsed_s } => { 201 | assert_eq!(time_elapsed_s, 7.410); 202 | } 203 | _ => unreachable!(), 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /mc-server-wrapper/src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::Opt; 2 | use anyhow::{anyhow, Context}; 3 | use notify_debouncer_mini::{new_debouncer, notify, DebouncedEvent}; 4 | use serde_derive::{Deserialize, Serialize}; 5 | use std::{ 6 | num::NonZeroU64, 7 | path::{Path, PathBuf}, 8 | time::Duration, 9 | }; 10 | use tokio::{ 11 | fs::File, 12 | io::{AsyncReadExt, AsyncWriteExt}, 13 | sync::mpsc, 14 | }; 15 | 16 | /// Represents the mc-server-wrapper config structure 17 | #[derive(Serialize, Deserialize, Debug)] 18 | pub struct Config { 19 | /// Minecraft-related config options 20 | pub minecraft: Minecraft, 21 | /// Discord-related config options 22 | pub discord: Option, 23 | /// Logging-related config options 24 | pub logging: Logging, 25 | } 26 | 27 | impl Default for Config { 28 | fn default() -> Self { 29 | Self { 30 | minecraft: Minecraft::default(), 31 | discord: Some(Discord::default()), 32 | logging: Logging::default(), 33 | } 34 | } 35 | } 36 | 37 | impl Config { 38 | /// Load a config file at `path` 39 | /// 40 | /// If the config does not exist at the path a default config will be created, 41 | /// returned, and also written to the path. 42 | /// 43 | /// This will not overwrite an existing file, however. 44 | pub async fn load(path: impl AsRef) -> Result { 45 | let path = path.as_ref(); 46 | if !path.exists() { 47 | let default_config = Self::default(); 48 | default_config 49 | .store(path) 50 | .await 51 | .with_context(|| "Failed to save default config file")?; 52 | 53 | Ok(default_config) 54 | } else { 55 | let mut file = File::open(path) 56 | .await 57 | .with_context(|| format!("Failed to open config file at {:?}", path))?; 58 | let mut buffer = String::new(); 59 | file.read_to_string(&mut buffer) 60 | .await 61 | .with_context(|| format!("Failed to read config file at {:?}", path))?; 62 | 63 | Ok(toml::from_str(&buffer) 64 | .with_context(|| format!("Failed to parse config file at {:?}", path))?) 65 | } 66 | } 67 | 68 | /// Write the current config to `path` 69 | /// 70 | /// This will overwrite whatever file is currently at `path`. 71 | pub async fn store(&self, path: impl AsRef) -> Result<(), anyhow::Error> { 72 | let path = path.as_ref(); 73 | let mut file = File::create(path) 74 | .await 75 | .with_context(|| format!("Failed to open config file at {:?}", path))?; 76 | 77 | file.write_all(toml::to_string(self)?.as_bytes()) 78 | .await 79 | .with_context(|| format!("Failed to write config file to {:?}", path)) 80 | } 81 | 82 | /// Merge args passed in via the CLI into this config 83 | pub fn merge_in_args(&mut self, args: Opt) -> Result<(), anyhow::Error> { 84 | if args.bridge_to_discord { 85 | if let Some(discord) = &mut self.discord { 86 | discord.enable_bridge = true; 87 | } else { 88 | return Err(anyhow!( 89 | "Discord bridge cannot be enabled if the bot token and channel ID \ 90 | are not specified in the config" 91 | )); 92 | } 93 | } 94 | 95 | if let Some(path) = args.server_path { 96 | self.minecraft.server_path = path; 97 | } 98 | 99 | Ok(()) 100 | } 101 | 102 | /// Setup a file watcher to be notified when the config file changes 103 | /// 104 | /// This spawns a separate thread to watch the config file because there aren't 105 | /// any file watcher libs that integrate with tokio right now. 106 | pub fn setup_watcher( 107 | &self, 108 | config_filepath: impl Into, 109 | ) -> mpsc::Receiver, notify::Error>> { 110 | let (notify_sender, notify_receiver) = mpsc::channel(8); 111 | let config_filepath = config_filepath.into(); 112 | let handle = tokio::runtime::Handle::current(); 113 | 114 | std::thread::spawn(move || { 115 | let (tx, rx) = std::sync::mpsc::channel(); 116 | 117 | let mut debouncer = new_debouncer(Duration::from_millis(500), tx).unwrap(); 118 | 119 | debouncer 120 | .watcher() 121 | .watch(&config_filepath, notify::RecursiveMode::NonRecursive) 122 | .unwrap(); 123 | 124 | loop { 125 | // rx.recv() can only error if the sender was disconnected 126 | // 127 | // This should never occur, so it's safe to unwrap here 128 | let event = rx.recv().unwrap(); 129 | let sender_clone = notify_sender.clone(); 130 | handle.spawn(async move { 131 | sender_clone.send(event).await.unwrap(); 132 | }); 133 | } 134 | }); 135 | 136 | notify_receiver 137 | } 138 | } 139 | 140 | /// Minecraft-related config options 141 | #[derive(Serialize, Deserialize, Debug)] 142 | pub struct Minecraft { 143 | /// Path to the Minecraft server jar 144 | pub server_path: PathBuf, 145 | /// Amount of memory in megabytes to allocate for the server 146 | pub memory: u16, 147 | /// Custom flags to pass to the JVM 148 | pub jvm_flags: Option, 149 | } 150 | 151 | impl Default for Minecraft { 152 | fn default() -> Self { 153 | Self { 154 | server_path: "./server.jar".into(), 155 | memory: 1024, 156 | jvm_flags: None, 157 | } 158 | } 159 | } 160 | 161 | /// Discord-related config options 162 | #[derive(Serialize, Deserialize, Debug)] 163 | pub struct Discord { 164 | pub enable_bridge: bool, 165 | pub token: String, 166 | pub channel_id: NonZeroU64, 167 | pub update_status: bool, 168 | } 169 | 170 | impl Default for Discord { 171 | fn default() -> Self { 172 | Self { 173 | enable_bridge: false, 174 | token: "".into(), 175 | channel_id: NonZeroU64::new(123).unwrap(), 176 | update_status: true, 177 | } 178 | } 179 | } 180 | 181 | /// Logging-related config options 182 | #[derive(Serialize, Deserialize, Debug)] 183 | pub struct Logging { 184 | /// Logging level for mc-server-wrapper dependencies 185 | /// 186 | /// This only affects file logging. 187 | #[serde(with = "LevelDef")] 188 | pub all: log::Level, 189 | /// Logging level for mc-server-wrapper dependencies 190 | /// 191 | /// This only affects file logging. 192 | #[serde(rename = "self")] 193 | #[serde(with = "LevelDef")] 194 | pub self_level: log::Level, 195 | #[serde(with = "LevelDef")] 196 | /// Logging level for mc-server-wrapper dependencies 197 | /// 198 | /// This only affects file logging. 199 | pub discord: log::Level, 200 | } 201 | 202 | impl Default for Logging { 203 | fn default() -> Self { 204 | Self { 205 | all: log::Level::Warn, 206 | self_level: log::Level::Debug, 207 | discord: log::Level::Info, 208 | } 209 | } 210 | } 211 | 212 | #[derive(Serialize, Deserialize)] 213 | #[serde(remote = "log::Level")] 214 | enum LevelDef { 215 | Error = 1, 216 | Warn, 217 | Info, 218 | Debug, 219 | Trace, 220 | } 221 | -------------------------------------------------------------------------------- /sample-data/1.15.2/stats/stats8.json: -------------------------------------------------------------------------------- 1 | { 2 | "stats": { 3 | "minecraft:killed": { 4 | "minecraft:pig": 9, 5 | "minecraft:zombie_pigman": 1, 6 | "minecraft:rabbit": 1, 7 | "minecraft:spider": 7, 8 | "minecraft:creeper": 2, 9 | "minecraft:skeleton": 15, 10 | "minecraft:zombie": 30, 11 | "minecraft:bee": 2, 12 | "minecraft:chicken": 8, 13 | "minecraft:silverfish": 4, 14 | "minecraft:wither_skeleton": 2, 15 | "minecraft:cow": 4, 16 | "minecraft:enderman": 5, 17 | "minecraft:blaze": 1 18 | }, 19 | "minecraft:custom": { 20 | "minecraft:interact_with_furnace": 41, 21 | "minecraft:interact_with_anvil": 2, 22 | "minecraft:mob_kills": 93, 23 | "minecraft:interact_with_crafting_table": 5, 24 | "minecraft:play_one_minute": 163332, 25 | "minecraft:interact_with_grindstone": 2, 26 | "minecraft:leave_game": 4, 27 | "minecraft:time_since_death": 29995, 28 | "minecraft:time_since_rest": 30026, 29 | "minecraft:sprint_one_cm": 486315, 30 | "minecraft:damage_taken": 3064, 31 | "minecraft:walk_one_cm": 698197, 32 | "minecraft:damage_blocked_by_shield": 2199, 33 | "minecraft:drop": 9, 34 | "minecraft:damage_dealt": 17070, 35 | "minecraft:swim_one_cm": 27794, 36 | "minecraft:fly_one_cm": 349049, 37 | "minecraft:open_chest": 149, 38 | "minecraft:crouch_one_cm": 6463, 39 | "minecraft:deaths": 5, 40 | "minecraft:sneak_time": 1696, 41 | "minecraft:walk_under_water_one_cm": 20077, 42 | "minecraft:jump": 3177, 43 | "minecraft:interact_with_blast_furnace": 2, 44 | "minecraft:enchant_item": 2, 45 | "minecraft:walk_on_water_one_cm": 18725, 46 | "minecraft:fall_one_cm": 72023 47 | }, 48 | "minecraft:dropped": { 49 | "minecraft:diamond": 101, 50 | "minecraft:crossbow": 1, 51 | "minecraft:coal_block": 12, 52 | "minecraft:diamond_pickaxe": 1 53 | }, 54 | "minecraft:crafted": { 55 | "minecraft:cooked_beef": 3, 56 | "minecraft:coal_block": 24, 57 | "minecraft:oak_planks": 128, 58 | "minecraft:diamond_sword": 1, 59 | "minecraft:cooked_porkchop": 20, 60 | "minecraft:diamond_axe": 1, 61 | "minecraft:cooked_chicken": 14, 62 | "minecraft:bread": 4, 63 | "minecraft:air": 0, 64 | "minecraft:iron_hoe": 1, 65 | "minecraft:stick": 64, 66 | "minecraft:cooked_rabbit": 1 67 | }, 68 | "minecraft:killed_by": { 69 | "minecraft:zombie_pigman": 1 70 | }, 71 | "minecraft:mined": { 72 | "minecraft:diamond_ore": 55, 73 | "minecraft:polished_granite": 2, 74 | "minecraft:granite": 52, 75 | "minecraft:emerald_ore": 4, 76 | "minecraft:grass_block": 4, 77 | "minecraft:nether_quartz_ore": 202, 78 | "minecraft:cobblestone": 4, 79 | "minecraft:polished_diorite": 1, 80 | "minecraft:spruce_leaves": 5, 81 | "minecraft:sugar_cane": 2, 82 | "minecraft:diorite": 39, 83 | "minecraft:magma_block": 76, 84 | "minecraft:redstone_ore": 22, 85 | "minecraft:glowstone": 14, 86 | "minecraft:iron_ore": 75, 87 | "minecraft:sweet_berry_bush": 15, 88 | "minecraft:wheat": 14, 89 | "minecraft:spruce_log": 2, 90 | "minecraft:andesite": 12, 91 | "minecraft:birch_leaves": 2, 92 | "minecraft:grass": 4, 93 | "minecraft:oak_log": 4, 94 | "minecraft:coal_ore": 42, 95 | "minecraft:dirt": 2, 96 | "minecraft:stone_bricks": 6, 97 | "minecraft:stone": 573, 98 | "minecraft:netherrack": 544, 99 | "minecraft:mossy_stone_bricks": 3, 100 | "minecraft:oak_leaves": 14, 101 | "minecraft:infested_stone": 1, 102 | "minecraft:fern": 2 103 | }, 104 | "minecraft:used": { 105 | "minecraft:diorite": 1, 106 | "minecraft:iron_ore": 2, 107 | "minecraft:stone_sword": 39, 108 | "minecraft:stone_pickaxe": 59, 109 | "minecraft:cooked_chicken": 17, 110 | "minecraft:netherrack": 11, 111 | "minecraft:carrot": 5, 112 | "minecraft:crossbow": 1, 113 | "minecraft:iron_hoe": 21, 114 | "minecraft:diamond_pickaxe": 1715, 115 | "minecraft:bread": 4, 116 | "minecraft:stone_axe": 11, 117 | "minecraft:diamond_sword": 278, 118 | "minecraft:cobblestone": 64, 119 | "minecraft:sweet_berries": 11, 120 | "minecraft:cooked_porkchop": 7, 121 | "minecraft:cooked_rabbit": 1, 122 | "minecraft:cooked_beef": 3, 123 | "minecraft:potato": 1 124 | }, 125 | "minecraft:picked_up": { 126 | "minecraft:wheat": 4, 127 | "minecraft:magma_block": 54, 128 | "minecraft:feather": 7, 129 | "minecraft:chicken": 8, 130 | "minecraft:iron_door": 1, 131 | "minecraft:andesite": 12, 132 | "minecraft:leather": 2, 133 | "minecraft:red_mushroom": 1, 134 | "minecraft:potato": 1, 135 | "minecraft:oak_log": 5, 136 | "minecraft:gunpowder": 7, 137 | "minecraft:string": 11, 138 | "minecraft:dirt": 19, 139 | "minecraft:polished_granite": 2, 140 | "minecraft:bone": 17, 141 | "minecraft:stone_bricks": 10, 142 | "minecraft:bow": 2, 143 | "minecraft:cracked_stone_bricks": 5, 144 | "minecraft:diamond_leggings": 1, 145 | "minecraft:wheat_seeds": 19, 146 | "minecraft:beef": 3, 147 | "minecraft:iron_ore": 74, 148 | "minecraft:mossy_stone_bricks": 7, 149 | "minecraft:rabbit": 1, 150 | "minecraft:diamond_helmet": 1, 151 | "minecraft:glowstone_dust": 50, 152 | "minecraft:sugar_cane": 2, 153 | "minecraft:sweet_berries": 29, 154 | "minecraft:quartz": 437, 155 | "minecraft:porkchop": 20, 156 | "minecraft:carrot": 1, 157 | "minecraft:stone_button": 2, 158 | "minecraft:grass_block": 1, 159 | "minecraft:spruce_sapling": 1, 160 | "minecraft:diorite": 47, 161 | "minecraft:spruce_log": 6, 162 | "minecraft:redstone": 138, 163 | "minecraft:spider_eye": 1, 164 | "minecraft:arrow": 21, 165 | "minecraft:diamond_chestplate": 1, 166 | "minecraft:rotten_flesh": 33, 167 | "minecraft:diamond": 176, 168 | "minecraft:polished_diorite": 2, 169 | "minecraft:diamond_boots": 1, 170 | "minecraft:crossbow": 1, 171 | "minecraft:granite": 54, 172 | "minecraft:emerald": 12, 173 | "minecraft:netherrack": 529, 174 | "minecraft:coal": 96, 175 | "minecraft:ender_pearl": 1, 176 | "minecraft:cobblestone": 736, 177 | "minecraft:diamond_pickaxe": 1 178 | } 179 | }, 180 | "DataVersion": 2230 181 | } 182 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/src/parse.rs: -------------------------------------------------------------------------------- 1 | use log::log; 2 | 3 | use fmt::Display; 4 | use std::fmt; 5 | use time::{format_description::FormatItem, OffsetDateTime, Time}; 6 | 7 | /// More informative representations for specific, supported console messages. 8 | #[derive(Debug, Clone, PartialEq)] 9 | pub enum ConsoleMsgSpecific { 10 | MustAcceptEula, 11 | PlayerMsg { 12 | name: String, 13 | msg: String, 14 | }, 15 | PlayerLogin { 16 | name: String, 17 | ip: String, 18 | entity_id: u32, 19 | coords: (f32, f32, f32), 20 | /// Present on Spigot servers 21 | world: Option, 22 | }, 23 | PlayerAuth { 24 | name: String, 25 | uuid: String, 26 | }, 27 | PlayerLogout { 28 | name: String, 29 | }, 30 | PlayerLostConnection { 31 | name: String, 32 | reason: String, 33 | }, 34 | SpawnPrepareProgress { 35 | progress: u8, 36 | }, 37 | SpawnPrepareFinish { 38 | time_elapsed_ms: u64, 39 | }, 40 | /// The server is finished loading and is ready for people to connect 41 | FinishedLoading { 42 | /// The amount of time the server took to load 43 | time_elapsed_s: f32, 44 | }, 45 | } 46 | 47 | impl ConsoleMsgSpecific { 48 | /// Tries to determine a `ConsoleMsgSpecific` variant for the given 49 | /// `ConsoleMsg`. 50 | pub(crate) fn try_parse_from(console_msg: &ConsoleMsg) -> Option { 51 | // Note that the order in which these conditions are tested is important: 52 | // we need to make sure that we are not dealing with a player message before 53 | // it is okay to test for other things, for instance 54 | Some(if console_msg.thread_name.contains("User Authenticator") { 55 | let (name, uuid) = { 56 | // Get rid of "UUID of player " 57 | let minus_start = &console_msg.msg[15..]; 58 | let (name, remain) = minus_start.split_at(minus_start.find(' ').unwrap()); 59 | 60 | // Slice `remain` to get rid of " is " 61 | (name.to_string(), remain[4..].to_string()) 62 | }; 63 | 64 | ConsoleMsgSpecific::PlayerAuth { name, uuid } 65 | } else if console_msg.msg_type == ConsoleMsgType::Info 66 | && (console_msg.thread_name.starts_with("Async Chat Thread") 67 | || (console_msg.msg.starts_with('<') 68 | || console_msg.msg.starts_with("[Not Secure]")) 69 | && console_msg.thread_name == "Server thread") 70 | { 71 | let (name, msg) = { 72 | // trim prefix from insecure messages. 73 | let msg = console_msg 74 | .msg 75 | .strip_prefix("[Not Secure] ") 76 | .unwrap_or(&console_msg.msg); 77 | 78 | let (name, remain) = msg 79 | // If a > cannot be found, this is not a player message 80 | // and therefore we return 81 | .split_at(msg.find('>')?); 82 | 83 | // Trim "<" from the player's name and "> " from the msg 84 | (name[1..].to_string(), remain[2..].to_string()) 85 | }; 86 | 87 | ConsoleMsgSpecific::PlayerMsg { name, msg } 88 | } else if console_msg.msg 89 | == "You need to agree to the EULA in order to run the server. Go to \ 90 | eula.txt for more info." 91 | && console_msg.msg_type == ConsoleMsgType::Info 92 | { 93 | ConsoleMsgSpecific::MustAcceptEula 94 | } else if console_msg.msg.contains("logged in with entity id") 95 | && console_msg.msg_type == ConsoleMsgType::Info 96 | { 97 | let (name, remain) = console_msg.msg.split_at(console_msg.msg.find('[').unwrap()); 98 | let name = name.to_string(); 99 | 100 | let (ip, mut remain) = remain.split_at(remain.find(']').unwrap()); 101 | let ip = ip[2..].to_string(); 102 | 103 | // Get rid of "] logged in with entity id " 104 | remain = &remain[27..]; 105 | 106 | let (entity_id, mut remain) = remain.split_at(remain.find(' ').unwrap()); 107 | let entity_id = entity_id.parse().unwrap(); 108 | 109 | // Get rid of " at (" in front and ")" behind 110 | remain = &remain[5..remain.len() - 1]; 111 | 112 | let (world, remain) = if remain.starts_with('[') { 113 | // This is a Spigot server; parse world 114 | let (world, remain) = remain.split_at(remain.find(']').unwrap()); 115 | (Some(world[1..].to_string()), &remain[1..]) 116 | } else { 117 | (None, remain) 118 | }; 119 | 120 | // `remain = &remain[2..]` is used to skip ", " 121 | let (x_coord, mut remain) = remain.split_at(remain.find(',').unwrap()); 122 | remain = &remain[2..]; 123 | 124 | let (y_coord, mut remain) = remain.split_at(remain.find(',').unwrap()); 125 | remain = &remain[2..]; 126 | 127 | let x_coord = x_coord.parse().unwrap(); 128 | let y_coord = y_coord.parse().unwrap(); 129 | let z_coord = remain.parse().unwrap(); 130 | 131 | ConsoleMsgSpecific::PlayerLogin { 132 | name, 133 | ip, 134 | entity_id, 135 | coords: (x_coord, y_coord, z_coord), 136 | world, 137 | } 138 | } else if console_msg.msg.contains("Preparing spawn area: ") 139 | && console_msg.msg_type == ConsoleMsgType::Info 140 | { 141 | let progress = console_msg.msg 142 | [console_msg.msg.find(':').unwrap() + 2..console_msg.msg.len() - 1] 143 | .parse() 144 | .unwrap(); 145 | 146 | ConsoleMsgSpecific::SpawnPrepareProgress { progress } 147 | } else if console_msg.msg.contains("Time elapsed: ") { 148 | let time_elapsed_ms = console_msg.msg 149 | [console_msg.msg.find(':').unwrap() + 2..console_msg.msg.find("ms").unwrap() - 1] 150 | .parse() 151 | .unwrap(); 152 | 153 | ConsoleMsgSpecific::SpawnPrepareFinish { time_elapsed_ms } 154 | } else if console_msg.msg.contains("lost connection: ") { 155 | let (name, remain) = console_msg.msg.split_at(console_msg.msg.find(' ').unwrap()); 156 | let name = name.into(); 157 | let reason = remain[remain.find(':').unwrap() + 2..].into(); 158 | 159 | ConsoleMsgSpecific::PlayerLostConnection { name, reason } 160 | } else if console_msg.msg.contains("left the game") { 161 | let name = console_msg 162 | .msg 163 | .split_at(console_msg.msg.find(' ').unwrap()) 164 | .0 165 | .into(); 166 | 167 | ConsoleMsgSpecific::PlayerLogout { name } 168 | } else if console_msg.msg.starts_with("Done (") { 169 | let time = &console_msg 170 | .msg 171 | .split_at(console_msg.msg.find('(').unwrap()) 172 | .1[1..]; 173 | 174 | let time_elapsed_s = time.split_at(time.find('s').unwrap()).0.parse().unwrap(); 175 | 176 | ConsoleMsgSpecific::FinishedLoading { time_elapsed_s } 177 | } else { 178 | // It wasn't anything specific we're looking for 179 | return None; 180 | }) 181 | } 182 | } 183 | 184 | #[derive(Debug, Clone, PartialEq, Eq)] 185 | pub struct ConsoleMsg { 186 | pub timestamp: Time, 187 | pub thread_name: String, 188 | pub msg_type: ConsoleMsgType, 189 | pub msg: String, 190 | } 191 | 192 | impl fmt::Display for ConsoleMsg { 193 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 194 | const TIMESTAMP_FORMAT: &[FormatItem] = time::macros::format_description!( 195 | "[hour repr:12 padding:none]:[minute]:[second] [period]" 196 | ); 197 | 198 | write!( 199 | f, 200 | "[{}] [mc, {}]: {}", 201 | // TODO: log failure here somehow 202 | self.timestamp 203 | .format(&TIMESTAMP_FORMAT) 204 | .unwrap_or_else(|_| String::from("time error")), 205 | self.msg_type, 206 | self.msg 207 | ) 208 | } 209 | } 210 | 211 | impl ConsoleMsg { 212 | /// Create a new `ConsoleMsg` with the current time and a blank thread name. 213 | pub fn new(msg_type: ConsoleMsgType, msg: String) -> Self { 214 | Self { 215 | // TODO: do something better than unix epoch fallback in failure case 216 | timestamp: OffsetDateTime::now_local() 217 | .unwrap_or(OffsetDateTime::UNIX_EPOCH) 218 | .time(), 219 | thread_name: "".into(), 220 | msg_type, 221 | msg, 222 | } 223 | } 224 | 225 | /// Logs the `ConsoleMsg` based on its type 226 | /// 227 | /// This uses the `log!` macro from the `log` crate; you will need to set 228 | /// up logging in your application in order to see output from this. 229 | /// 230 | /// The `target:` parameter of `log!` will be set to 231 | /// `CONSOLE_MSG_LOG_TARGET`. 232 | pub fn log(&self) { 233 | log!( 234 | target: crate::CONSOLE_MSG_LOG_TARGET.get_or_init(|| "mc"), 235 | self.msg_type.clone().into(), 236 | "{}", 237 | self.msg 238 | ); 239 | } 240 | 241 | /// Constructs a `ConsoleMsg` from a line of console output. 242 | pub(crate) fn try_parse_from(raw: &str) -> Option { 243 | let (mut timestamp, remain) = raw.split_at(raw.find(']')?); 244 | timestamp = ×tamp[1..]; 245 | 246 | let (mut thread_name, remain) = remain.split_at(remain.find('/')?); 247 | thread_name = &thread_name[3..]; 248 | 249 | let (mut msg_type, remain) = remain.split_at(remain.find(']')?); 250 | msg_type = &msg_type[1..]; 251 | 252 | Some(Self { 253 | // TODO: do something better than midnight as failure fallback here 254 | timestamp: Time::from_hms( 255 | timestamp[..2].parse().unwrap(), 256 | timestamp[3..5].parse().unwrap(), 257 | timestamp[6..].parse().unwrap(), 258 | ) 259 | .unwrap_or(Time::MIDNIGHT), 260 | thread_name: thread_name.into(), 261 | msg_type: ConsoleMsgType::parse_from(msg_type), 262 | msg: remain[3..].into(), 263 | }) 264 | } 265 | } 266 | 267 | /// Various types of console messages that can occur 268 | #[derive(Debug, PartialEq, Eq, Clone)] 269 | pub enum ConsoleMsgType { 270 | Info, 271 | Warn, 272 | Error, 273 | /// An unknown type of message with its value from Minecraft 274 | Unknown(String), 275 | } 276 | 277 | impl From for log::Level { 278 | fn from(msg: ConsoleMsgType) -> Self { 279 | use ConsoleMsgType::*; 280 | 281 | match msg { 282 | Info | Unknown(_) => log::Level::Info, 283 | Warn => log::Level::Warn, 284 | Error => log::Level::Error, 285 | } 286 | } 287 | } 288 | 289 | impl Display for ConsoleMsgType { 290 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 291 | use ConsoleMsgType::*; 292 | 293 | match *self { 294 | Info => f.write_str("INFO"), 295 | Warn => f.write_str("WARN"), 296 | Error => f.write_str("ERROR"), 297 | Unknown(ref s) => f.write_str(s), 298 | } 299 | } 300 | } 301 | 302 | impl ConsoleMsgType { 303 | fn parse_from(raw: &str) -> Self { 304 | match raw { 305 | "INFO" => ConsoleMsgType::Info, 306 | "WARN" => ConsoleMsgType::Warn, 307 | "ERROR" => ConsoleMsgType::Error, 308 | _ => ConsoleMsgType::Unknown(raw.into()), 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /sample-data/1.15.2/stats/stats1.json: -------------------------------------------------------------------------------- 1 | { 2 | "stats": { 3 | "minecraft:used": { 4 | "minecraft:red_sand": 1, 5 | "minecraft:stone_sword": 17, 6 | "minecraft:flint_and_steel": 91, 7 | "minecraft:stone_shovel": 29, 8 | "minecraft:poppy": 2, 9 | "minecraft:oak_sign": 2, 10 | "minecraft:chest": 4, 11 | "minecraft:rotten_flesh": 9, 12 | "minecraft:cooked_chicken": 8, 13 | "minecraft:netherrack": 6, 14 | "minecraft:oak_door": 3, 15 | "minecraft:torch": 9, 16 | "minecraft:dirt": 302, 17 | "minecraft:mutton": 5, 18 | "minecraft:carrot": 3, 19 | "minecraft:orange_wool": 1, 20 | "minecraft:snowball": 4, 21 | "minecraft:spruce_boat": 3, 22 | "minecraft:lava_bucket": 5, 23 | "minecraft:iron_axe": 15, 24 | "minecraft:stone_bricks": 31, 25 | "minecraft:bamboo": 1, 26 | "minecraft:bow": 5, 27 | "minecraft:dark_oak_boat": 2, 28 | "minecraft:hay_block": 8, 29 | "minecraft:sweet_berries": 9, 30 | "minecraft:furnace": 2, 31 | "minecraft:cooked_beef": 4, 32 | "minecraft:wheat_seeds": 18, 33 | "minecraft:cooked_mutton": 10, 34 | "minecraft:dark_oak_stairs": 3, 35 | "minecraft:water_bucket": 35, 36 | "minecraft:cobblestone_stairs": 7, 37 | "minecraft:dark_oak_sign": 1, 38 | "minecraft:baked_potato": 2, 39 | "minecraft:stone_pickaxe": 52, 40 | "minecraft:oak_planks": 53, 41 | "minecraft:spruce_sign": 3, 42 | "minecraft:white_wool": 3, 43 | "minecraft:crafting_table": 2, 44 | "minecraft:campfire": 4, 45 | "minecraft:white_bed": 2, 46 | "minecraft:cod": 5, 47 | "minecraft:painting": 22, 48 | "minecraft:redstone_torch": 2, 49 | "minecraft:diamond_pickaxe": 3, 50 | "minecraft:apple": 3, 51 | "minecraft:dark_oak_log": 3, 52 | "minecraft:bread": 1, 53 | "minecraft:oak_button": 1, 54 | "minecraft:iron_pickaxe": 105, 55 | "minecraft:spruce_pressure_plate": 1, 56 | "minecraft:stone_axe": 66, 57 | "minecraft:diamond_sword": 216, 58 | "minecraft:cobblestone": 385, 59 | "minecraft:porkchop": 5, 60 | "minecraft:cooked_porkchop": 12, 61 | "minecraft:dark_oak_fence": 1, 62 | "minecraft:bucket": 40 63 | }, 64 | "minecraft:custom": { 65 | "minecraft:interact_with_anvil": 1, 66 | "minecraft:mob_kills": 31, 67 | "minecraft:inspect_hopper": 4, 68 | "minecraft:interact_with_crafting_table": 25, 69 | "minecraft:open_barrel": 3, 70 | "minecraft:leave_game": 2, 71 | "minecraft:time_since_death": 5451, 72 | "minecraft:climb_one_cm": 44104, 73 | "minecraft:sprint_one_cm": 1635164, 74 | "minecraft:walk_one_cm": 1524528, 75 | "minecraft:drop": 100, 76 | "minecraft:deaths": 40, 77 | "minecraft:sneak_time": 6627, 78 | "minecraft:walk_under_water_one_cm": 42333, 79 | "minecraft:boat_one_cm": 362434, 80 | "minecraft:jump": 4789, 81 | "minecraft:walk_on_water_one_cm": 50866, 82 | "minecraft:interact_with_furnace": 54, 83 | "minecraft:tune_noteblock": 26, 84 | "minecraft:play_one_minute": 371287, 85 | "minecraft:sleep_in_bed": 2, 86 | "minecraft:time_since_rest": 5479, 87 | "minecraft:damage_taken": 13863, 88 | "minecraft:damage_dealt": 7523, 89 | "minecraft:swim_one_cm": 48020, 90 | "minecraft:fly_one_cm": 596419, 91 | "minecraft:player_kills": 3, 92 | "minecraft:open_chest": 101, 93 | "minecraft:crouch_one_cm": 17484, 94 | "minecraft:fall_one_cm": 137393 95 | }, 96 | "minecraft:killed_by": { 97 | "minecraft:player": 20, 98 | "minecraft:creeper": 1, 99 | "minecraft:enderman": 1, 100 | "minecraft:skeleton": 1, 101 | "minecraft:zombie": 2 102 | }, 103 | "minecraft:killed": { 104 | "minecraft:spider": 1, 105 | "minecraft:skeleton": 4, 106 | "minecraft:chicken": 2, 107 | "minecraft:llama": 4, 108 | "minecraft:pig": 5, 109 | "minecraft:creeper": 1, 110 | "minecraft:sheep": 2, 111 | "minecraft:trader_llama": 1, 112 | "minecraft:zombie": 9 113 | }, 114 | "minecraft:dropped": { 115 | "minecraft:spruce_sign": 1, 116 | "minecraft:wheat_seeds": 16, 117 | "minecraft:iron_helmet": 2, 118 | "minecraft:cobblestone": 29, 119 | "minecraft:leather": 8, 120 | "minecraft:granite": 7, 121 | "minecraft:rail": 46, 122 | "minecraft:dirt": 203, 123 | "minecraft:diorite": 1, 124 | "minecraft:sand": 4, 125 | "minecraft:string": 5, 126 | "minecraft:lily_pad": 5, 127 | "minecraft:kelp": 7, 128 | "minecraft:coarse_dirt": 1, 129 | "minecraft:cobblestone_wall": 1, 130 | "minecraft:wheat": 12, 131 | "minecraft:rotten_flesh": 22, 132 | "minecraft:iron_hoe": 1, 133 | "minecraft:oak_sign": 1, 134 | "minecraft:flint_and_steel": 2, 135 | "minecraft:diamond_sword": 1, 136 | "minecraft:tipped_arrow": 1, 137 | "minecraft:gunpowder": 3, 138 | "minecraft:oak_button": 2, 139 | "minecraft:andesite": 12, 140 | "minecraft:spruce_pressure_plate": 1, 141 | "minecraft:ladder": 1, 142 | "minecraft:redstone_torch": 3, 143 | "minecraft:stone_bricks": 17, 144 | "minecraft:painting": 5, 145 | "minecraft:birch_boat": 1, 146 | "minecraft:red_sand": 1, 147 | "minecraft:netherrack": 117 148 | }, 149 | "minecraft:broken": { 150 | "minecraft:flint_and_steel": 1 151 | }, 152 | "minecraft:crafted": { 153 | "minecraft:oak_door": 3, 154 | "minecraft:oak_planks": 132, 155 | "minecraft:oak_sign": 21, 156 | "minecraft:cooked_chicken": 8, 157 | "minecraft:cobblestone_stairs": 8, 158 | "minecraft:iron_axe": 1, 159 | "minecraft:cooked_beef": 4, 160 | "minecraft:cooked_porkchop": 12, 161 | "minecraft:iron_helmet": 1, 162 | "minecraft:campfire": 4, 163 | "minecraft:charcoal": 19, 164 | "minecraft:stick": 24, 165 | "minecraft:white_bed": 1 166 | }, 167 | "minecraft:picked_up": { 168 | "minecraft:charcoal": 18, 169 | "minecraft:iron_chestplate": 1, 170 | "minecraft:crafting_table": 3, 171 | "minecraft:coarse_dirt": 1, 172 | "minecraft:wheat": 13, 173 | "minecraft:chicken": 1, 174 | "minecraft:ladder": 2, 175 | "minecraft:diamond_sword": 1, 176 | "minecraft:string": 5, 177 | "minecraft:flint_and_steel": 1, 178 | "minecraft:oak_sign": 4, 179 | "minecraft:red_sand": 2, 180 | "minecraft:spruce_planks": 1, 181 | "minecraft:oak_boat": 1, 182 | "minecraft:redstone_torch": 6, 183 | "minecraft:cobblestone_slab": 6, 184 | "minecraft:poppy": 2, 185 | "minecraft:chest": 4, 186 | "minecraft:furnace": 2, 187 | "minecraft:spruce_pressure_plate": 2, 188 | "minecraft:stick": 4, 189 | "minecraft:end_stone": 12, 190 | "minecraft:stone": 3, 191 | "minecraft:oak_door": 2, 192 | "minecraft:torch": 22, 193 | "minecraft:porkchop": 9, 194 | "minecraft:diorite": 1, 195 | "minecraft:redstone": 9, 196 | "minecraft:dark_oak_planks": 13, 197 | "minecraft:arrow": 5, 198 | "minecraft:rotten_flesh": 23, 199 | "minecraft:beetroot_seeds": 7, 200 | "minecraft:hay_block": 8, 201 | "minecraft:mutton": 5, 202 | "minecraft:white_wool": 6, 203 | "minecraft:rail": 46, 204 | "minecraft:cobblestone": 185, 205 | "minecraft:water_bucket": 2, 206 | "minecraft:andesite": 12, 207 | "minecraft:leather": 8, 208 | "minecraft:dark_oak_boat": 4, 209 | "minecraft:oak_log": 38, 210 | "minecraft:lily_pad": 5, 211 | "minecraft:gunpowder": 3, 212 | "minecraft:cobblestone_wall": 1, 213 | "minecraft:dirt": 938, 214 | "minecraft:bone": 3, 215 | "minecraft:apple": 1, 216 | "minecraft:stone_bricks": 69, 217 | "minecraft:kelp": 7, 218 | "minecraft:wheat_seeds": 47, 219 | "minecraft:dark_oak_sign": 1, 220 | "minecraft:iron_ore": 8, 221 | "minecraft:bamboo": 1, 222 | "minecraft:oak_planks": 16, 223 | "minecraft:dark_oak_stairs": 3, 224 | "minecraft:birch_boat": 1, 225 | "minecraft:pumpkin": 1, 226 | "minecraft:sweet_berries": 15, 227 | "minecraft:lava_bucket": 1, 228 | "minecraft:sand": 4, 229 | "minecraft:gravel": 9, 230 | "minecraft:white_bed": 1, 231 | "minecraft:dark_oak_log": 29, 232 | "minecraft:orange_wool": 1, 233 | "minecraft:iron_hoe": 1, 234 | "minecraft:spruce_boat": 4, 235 | "minecraft:iron_helmet": 1, 236 | "minecraft:granite": 7, 237 | "minecraft:painting": 29, 238 | "minecraft:spruce_sign": 18, 239 | "minecraft:oak_button": 3, 240 | "minecraft:baked_potato": 1, 241 | "minecraft:netherrack": 59, 242 | "minecraft:dark_oak_fence": 1 243 | }, 244 | "minecraft:mined": { 245 | "minecraft:white_bed": 1, 246 | "minecraft:dark_oak_leaves": 19, 247 | "minecraft:dark_oak_log": 10, 248 | "minecraft:orange_wool": 1, 249 | "minecraft:red_sand": 1, 250 | "minecraft:dark_oak_fence": 1, 251 | "minecraft:grass_block": 74, 252 | "minecraft:oak_wall_sign": 1, 253 | "minecraft:hay_block": 8, 254 | "minecraft:furnace": 2, 255 | "minecraft:iron_ore": 8, 256 | "minecraft:pumpkin": 1, 257 | "minecraft:dark_oak_stairs": 3, 258 | "minecraft:bamboo_sapling": 1, 259 | "minecraft:stone": 44, 260 | "minecraft:white_wool": 3, 261 | "minecraft:redstone_wall_torch": 1, 262 | "minecraft:crafting_table": 2, 263 | "minecraft:torch": 12, 264 | "minecraft:poppy": 1, 265 | "minecraft:chest": 4, 266 | "minecraft:campfire": 7, 267 | "minecraft:spruce_wall_sign": 1, 268 | "minecraft:glass": 6, 269 | "minecraft:cobblestone": 67, 270 | "minecraft:oak_planks": 15, 271 | "minecraft:diorite": 1, 272 | "minecraft:wall_torch": 2, 273 | "minecraft:dark_oak_wall_sign": 1, 274 | "minecraft:redstone_torch": 1, 275 | "minecraft:sweet_berry_bush": 2, 276 | "minecraft:wheat": 19, 277 | "minecraft:grass": 11, 278 | "minecraft:oak_log": 35, 279 | "minecraft:gravel": 1, 280 | "minecraft:dirt": 81, 281 | "minecraft:stone_bricks": 13, 282 | "minecraft:netherrack": 10, 283 | "minecraft:oak_leaves": 41 284 | } 285 | }, 286 | "DataVersion": 2230 287 | } 288 | -------------------------------------------------------------------------------- /mc-server-wrapper-lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | use tokio::{ 2 | fs::File, 3 | io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, 4 | process, 5 | sync::{mpsc, oneshot, Mutex}, 6 | }; 7 | 8 | use thiserror::Error; 9 | 10 | use once_cell::sync::OnceCell; 11 | 12 | use std::{ 13 | ffi::OsStr, 14 | io, 15 | path::{Path, PathBuf}, 16 | process::{ExitStatus, Stdio}, 17 | sync::Arc, 18 | }; 19 | 20 | use crate::{ 21 | communication::*, 22 | parse::{ConsoleMsg, ConsoleMsgSpecific}, 23 | }; 24 | use process::Child; 25 | 26 | pub mod communication; 27 | pub mod parse; 28 | #[cfg(test)] 29 | mod test; 30 | 31 | /// The value that `ConsoleMsg.log()` will use for `log!`'s target parameter 32 | /// 33 | /// Will be set to a default of `mc` if not set elsewhere. 34 | pub static CONSOLE_MSG_LOG_TARGET: OnceCell<&str> = OnceCell::new(); 35 | 36 | /// Configuration to run a Minecraft server instance with 37 | // TODO: make a builder for this 38 | #[derive(Debug, Clone)] 39 | pub struct McServerConfig { 40 | /// The path to the server jarfile 41 | server_path: PathBuf, 42 | /// The amount of memory in megabytes to allocate for the server 43 | memory: u16, 44 | /// Custom flags to pass to the JVM 45 | jvm_flags: Option, 46 | /// Whether or not the server's `stdin` should be inherited from the parent 47 | /// process's `stdin`. 48 | /// 49 | /// An server constructed with `inherit_stdin` set to true will ignore 50 | /// any commands it receives to write to the server's stdin. 51 | /// 52 | /// Set this to true if you want simple hands-free passthrough of whatever 53 | /// you enter on the console to the Minecraft server. Set this to false 54 | /// if you'd rather manually handle stdin and send data to the Minecraft 55 | /// server yourself (more work, but more flexible). 56 | inherit_stdin: bool, 57 | } 58 | 59 | /// Errors regarding an `McServerConfig` 60 | #[derive(Error, Debug)] 61 | pub enum McServerConfigError { 62 | #[error("the provided server path \"{0}\" was not an accessible file")] 63 | ServerPathFileNotPresent(PathBuf), 64 | } 65 | 66 | impl McServerConfig { 67 | /// Create a new `McServerConfig` 68 | pub fn new>( 69 | server_path: P, 70 | memory: u16, 71 | jvm_flags: Option, 72 | inherit_stdin: bool, 73 | ) -> Self { 74 | let server_path = server_path.into(); 75 | McServerConfig { 76 | server_path, 77 | memory, 78 | jvm_flags, 79 | inherit_stdin, 80 | } 81 | } 82 | 83 | /// Validates aspects of the config 84 | /// 85 | /// The validation ensures that the provided `server_path` is a path to a 86 | /// file present on the filesystem. 87 | pub fn validate(&self) -> Result<(), McServerConfigError> { 88 | use McServerConfigError::*; 89 | 90 | if !self.server_path.is_file() { 91 | return Err(ServerPathFileNotPresent(self.server_path.clone())); 92 | } 93 | 94 | Ok(()) 95 | } 96 | } 97 | 98 | /// Errors that can occur when starting up a server 99 | #[derive(Error, Debug)] 100 | pub enum McServerStartError { 101 | #[error("config error: {0}")] 102 | ConfigError(#[from] McServerConfigError), 103 | #[error("io error: {0}")] 104 | IoError(#[from] io::Error), 105 | #[error( 106 | "no config provided with the request to start the server and no previous \ 107 | config existed" 108 | )] 109 | NoPreviousConfig, 110 | } 111 | 112 | /// Manages a single Minecraft server, running or stopped 113 | #[derive(Debug)] 114 | pub struct McServerManager { 115 | /// Handle to server internals (present if server is running) 116 | internal: Arc>>, 117 | } 118 | 119 | impl McServerManager { 120 | /// Create a new `McServerManager` 121 | /// 122 | /// The returned channel halves can be used to send commands to the server 123 | /// and receive events from the server. 124 | /// 125 | /// Commands that require the server to be running to do anything will be 126 | /// ignored. 127 | pub fn new() -> ( 128 | Arc, 129 | mpsc::Sender, 130 | mpsc::Receiver, 131 | ) { 132 | let (cmd_sender, cmd_receiver) = mpsc::channel::(64); 133 | let (event_sender, event_receiver) = mpsc::channel::(64); 134 | 135 | let server = Arc::new(McServerManager { 136 | internal: Arc::new(Mutex::new(None)), 137 | }); 138 | 139 | let self_clone = server.clone(); 140 | self_clone.spawn_listener(event_sender, cmd_receiver); 141 | 142 | (server, cmd_sender, event_receiver) 143 | } 144 | 145 | fn spawn_listener( 146 | self: Arc, 147 | event_sender: mpsc::Sender, 148 | mut cmd_receiver: mpsc::Receiver, 149 | ) { 150 | tokio::spawn(async move { 151 | let mut current_config: Option = None; 152 | 153 | while let Some(cmd) = cmd_receiver.recv().await { 154 | use ServerCommand::*; 155 | use ServerEvent::*; 156 | 157 | match cmd { 158 | TellRawAll(json) => { 159 | self.write_to_stdin(format!("tellraw @a {}\n", json)).await; 160 | } 161 | WriteCommandToStdin(text) => { 162 | self.write_to_stdin(text + "\n").await; 163 | } 164 | WriteToStdin(text) => { 165 | self.write_to_stdin(text).await; 166 | } 167 | 168 | AgreeToEula => { 169 | let event_sender_clone = event_sender.clone(); 170 | 171 | if let Some(config) = ¤t_config { 172 | let server_path = config.server_path.clone(); 173 | tokio::spawn(async move { 174 | event_sender_clone 175 | .send(AgreeToEulaResult( 176 | McServerManager::agree_to_eula(server_path).await, 177 | )) 178 | .await 179 | .unwrap(); 180 | }); 181 | } 182 | } 183 | StartServer { config } => { 184 | if self.running().await { 185 | continue; 186 | } 187 | 188 | let config = if let Some(config) = config { 189 | current_config = Some(config); 190 | current_config.as_ref().unwrap() 191 | } else if let Some(current_config) = ¤t_config { 192 | current_config 193 | } else { 194 | event_sender 195 | .send(ServerEvent::StartServerResult(Err( 196 | McServerStartError::NoPreviousConfig, 197 | ))) 198 | .await 199 | .unwrap(); 200 | continue; 201 | }; 202 | 203 | let (child, rx) = match McServerInternal::setup_server(config) { 204 | Ok((internal, child, rx)) => { 205 | *self.internal.lock().await = Some(internal); 206 | (child, rx) 207 | } 208 | Err(e) => { 209 | event_sender 210 | .send(ServerEvent::StartServerResult(Err(e))) 211 | .await 212 | .unwrap(); 213 | continue; 214 | } 215 | }; 216 | 217 | let event_sender_clone = event_sender.clone(); 218 | let internal_clone = self.internal.clone(); 219 | 220 | // Spawn a task to drive the server process to completion 221 | // and send an event when it exits 222 | tokio::spawn(async move { 223 | let event_sender = event_sender_clone; 224 | let ret = 225 | McServerInternal::run_server(child, rx, event_sender.clone()).await; 226 | let _ = internal_clone.lock().await.take(); 227 | 228 | event_sender 229 | .send(ServerStopped(ret.0, ret.1)) 230 | .await 231 | .unwrap(); 232 | }); 233 | } 234 | StopServer { forever } => { 235 | // TODO: handle error 236 | self.write_to_stdin("stop\n").await; 237 | 238 | if forever { 239 | break; 240 | } 241 | } 242 | } 243 | } 244 | }); 245 | } 246 | 247 | /// Writes the given bytes to the server's stdin if the server is running 248 | async fn write_to_stdin>(&self, bytes: B) { 249 | let bytes = bytes.as_ref(); 250 | let mut internal = self.internal.lock().await; 251 | 252 | if let Some(internal) = &mut *internal { 253 | if bytes == b"stop\n" { 254 | if let Some(tx) = internal.shutdown_reason_oneshot.take() { 255 | let _ = tx.send(ShutdownReason::RequestedToStop); 256 | } 257 | } 258 | 259 | if let Some(stdin) = &mut internal.stdin { 260 | if let Err(e) = stdin.write_all(bytes).await { 261 | log::warn!("Failed to write to Minecraft server stdin: {}", e); 262 | } 263 | } 264 | } 265 | } 266 | 267 | /// Returns true if the server is currently running 268 | pub async fn running(&self) -> bool { 269 | let running = self.internal.lock().await; 270 | running.is_some() 271 | } 272 | 273 | /// Overwrites the `eula.txt` file with the contents `eula=true`. 274 | async fn agree_to_eula>(server_path: P) -> io::Result<()> { 275 | let mut file = File::create(server_path.as_ref().with_file_name("eula.txt")).await?; 276 | 277 | file.write_all(b"eula=true").await 278 | } 279 | } 280 | 281 | /// Groups together stuff needed internally by the library 282 | /// 283 | /// Anything inside of here needs to both be accessed by the manager and have 284 | /// its lifetime tied to the Minecraft server process it was created for. 285 | /// 286 | /// Stuff that needs to be tied to the lifetime of the server process but does 287 | /// not need to be accessed by the manager should be passed directly into 288 | /// `run_server`. 289 | /// 290 | /// Stuff that needs to outlive the Minecraft server process belongs at the 291 | /// manager level (either in the struct, if it needs to be accessed by the 292 | /// library consumer, or in `spawn_listener` if not). 293 | #[derive(Debug)] 294 | struct McServerInternal { 295 | /// Handle to the server's stdin (if captured) 296 | stdin: Option, 297 | /// Provides a way for the manager to set a shutdown reason 298 | shutdown_reason_oneshot: Option>, 299 | } 300 | 301 | impl McServerInternal { 302 | /// Set up the server process with the given config 303 | /// 304 | /// The config will be validated before it is used. 305 | fn setup_server( 306 | config: &McServerConfig, 307 | ) -> Result<(Self, Child, oneshot::Receiver), McServerStartError> { 308 | config.validate()?; 309 | 310 | let folder = config 311 | .server_path 312 | .as_path() 313 | .parent() 314 | .map(|p| p.as_os_str()) 315 | .unwrap_or_else(|| OsStr::new(".")); 316 | let file = config.server_path.file_name().unwrap(); 317 | 318 | let java_args = format!( 319 | "-Xms{}M -Xmx{}M {} -jar {:?} nogui", 320 | config.memory, 321 | config.memory, 322 | config.jvm_flags.as_deref().unwrap_or(""), 323 | file 324 | ); 325 | 326 | // I don't know much about powershell but this works so ¯\_(ツ)_/¯ 327 | let args = if cfg!(windows) { 328 | vec![ 329 | "Start-Process", 330 | "-NoNewWindow", 331 | "-FilePath", 332 | "java.exe", 333 | "-WorkingDirectory", 334 | &folder.to_string_lossy(), 335 | "-ArgumentList", 336 | &format!("'{}'", &java_args), 337 | ] 338 | .into_iter() 339 | .map(|s| s.into()) 340 | .collect() 341 | } else { 342 | vec![ 343 | "-c".into(), 344 | format!( 345 | "cd {} && exec java {}", 346 | folder.to_string_lossy(), 347 | &java_args 348 | ), 349 | ] 350 | }; 351 | 352 | let mut process = process::Command::new(if cfg!(windows) { "PowerShell" } else { "sh" }) 353 | .stdin(if config.inherit_stdin { 354 | Stdio::inherit() 355 | } else { 356 | Stdio::piped() 357 | }) 358 | .stdout(Stdio::piped()) 359 | .stderr(Stdio::piped()) 360 | .args(&args) 361 | .spawn()?; 362 | 363 | let stdin = if !config.inherit_stdin { 364 | Some(process.stdin.take().unwrap()) 365 | } else { 366 | None 367 | }; 368 | 369 | let (tx, rx) = oneshot::channel(); 370 | 371 | Ok(( 372 | Self { 373 | stdin, 374 | shutdown_reason_oneshot: Some(tx), 375 | }, 376 | process, 377 | rx, 378 | )) 379 | } 380 | 381 | /// Drive the given server process to completion, sending any events over the 382 | /// `event_sender` 383 | async fn run_server( 384 | mut process: Child, 385 | mut shutdown_reason_oneshot: oneshot::Receiver, 386 | event_sender: mpsc::Sender, 387 | ) -> (io::Result, Option) { 388 | let mut stdout = BufReader::new(process.stdout.take().unwrap()).lines(); 389 | let mut stderr = BufReader::new(process.stderr.take().unwrap()).lines(); 390 | 391 | let status_handle = tokio::spawn(async move { process.wait().await }); 392 | 393 | let event_sender_clone = event_sender.clone(); 394 | let stderr_handle = tokio::spawn(async move { 395 | use ServerEvent::*; 396 | let event_sender = event_sender_clone; 397 | 398 | while let Some(line) = stderr.next_line().await.unwrap() { 399 | event_sender.send(StderrLine(line)).await.unwrap(); 400 | } 401 | }); 402 | 403 | let stdout_handle = tokio::spawn(async move { 404 | use ServerEvent::*; 405 | let event_sender = event_sender; 406 | let mut shutdown_reason = None; 407 | 408 | while let Some(line) = stdout.next_line().await.unwrap() { 409 | if let Some(console_msg) = ConsoleMsg::try_parse_from(&line) { 410 | let specific_msg = ConsoleMsgSpecific::try_parse_from(&console_msg); 411 | 412 | if specific_msg == Some(ConsoleMsgSpecific::MustAcceptEula) { 413 | shutdown_reason = Some(ShutdownReason::EulaNotAccepted); 414 | } 415 | 416 | event_sender 417 | .send(ConsoleEvent(console_msg, specific_msg)) 418 | .await 419 | .unwrap(); 420 | } else { 421 | // spigot servers print lines that reach this branch ("\n", 422 | // "Loading libraries, please wait...") 423 | event_sender.send(StdoutLine(line)).await.unwrap(); 424 | } 425 | } 426 | 427 | shutdown_reason 428 | }); 429 | 430 | let (status, shutdown_reason, _) = 431 | tokio::join!(status_handle, stdout_handle, stderr_handle,); 432 | let mut shutdown_reason = shutdown_reason.unwrap(); 433 | 434 | // Shutdown reason from the manager gets preference 435 | if let Ok(reason) = shutdown_reason_oneshot.try_recv() { 436 | shutdown_reason = Some(reason); 437 | } 438 | 439 | (status.unwrap(), shutdown_reason) 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /mc-server-wrapper/src/ui.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{BTreeMap, VecDeque}, 3 | fmt::Display, 4 | }; 5 | 6 | use crossterm::event::{Event, KeyCode}; 7 | use ratatui::{ 8 | backend::Backend, 9 | layout::{Constraint, Direction, Layout, Rect}, 10 | style::{Color, Style}, 11 | text::{Line, Span, Text}, 12 | widgets::{Block, Borders, List, ListItem, Paragraph, Row, Table, Tabs}, 13 | Frame, 14 | }; 15 | use time::{format_description::FormatItem, Duration, OffsetDateTime, UtcOffset}; 16 | use unicode_width::UnicodeWidthStr; 17 | 18 | use crate::OnlinePlayerInfo; 19 | 20 | /// Represents the current state of the terminal UI 21 | #[derive(Debug)] 22 | pub struct TuiState { 23 | pub tab_state: TabsState, 24 | pub logs_state: LogsState, 25 | pub players_state: PlayersState, 26 | } 27 | 28 | impl TuiState { 29 | pub fn new() -> Self { 30 | TuiState { 31 | // TODO: don't hardcode this 32 | tab_state: TabsState::new(vec!["Logs".into(), "Players".into()]), 33 | logs_state: LogsState { 34 | records: VecDeque::with_capacity(512), 35 | progress_bar: None, 36 | input_state: InputState { value: "".into() }, 37 | }, 38 | players_state: PlayersState, 39 | } 40 | } 41 | 42 | /// Draw the current state to the given frame 43 | pub fn draw( 44 | &mut self, 45 | f: &mut Frame, 46 | online_players: &BTreeMap, 47 | ) { 48 | let chunks = Layout::default() 49 | .direction(Direction::Vertical) 50 | .constraints([Constraint::Length(2), Constraint::Min(0)].as_ref()) 51 | .split(f.size()); 52 | 53 | self.tab_state.draw(f, chunks[0]); 54 | // TODO: create tab structs that report what index they belong at so this 55 | // isn't hardcoded 56 | match self.tab_state.current_idx { 57 | 0 => self.logs_state.draw(f, chunks[1]), 58 | 1 => self.players_state.draw(f, chunks[1], online_players), 59 | _ => unreachable!(), 60 | } 61 | } 62 | 63 | /// Update the state based on the given input 64 | // TODO: make input dispatch more generic 65 | pub fn handle_input(&mut self, event: Event) { 66 | self.tab_state.handle_input(&event); 67 | match self.tab_state.current_idx { 68 | 0 => self.logs_state.handle_input(&event), 69 | 1 => self.players_state.handle_input(&event), 70 | _ => unreachable!(), 71 | } 72 | } 73 | } 74 | 75 | #[derive(Debug)] 76 | pub struct TabsState { 77 | /// List of tab titles 78 | titles: Vec, 79 | /// The currently active tab 80 | current_idx: usize, 81 | } 82 | 83 | impl TabsState { 84 | fn new(titles: Vec) -> Self { 85 | Self { 86 | titles, 87 | current_idx: 0, 88 | } 89 | } 90 | 91 | /// Draw the current state in the given `area` 92 | fn draw(&self, f: &mut Frame, area: Rect) { 93 | let tabs = Tabs::new( 94 | self.titles 95 | .iter() 96 | .map(|s| s.as_ref()) 97 | .map(Line::from) 98 | .collect(), 99 | ) 100 | .block(Block::default().borders(Borders::BOTTOM)) 101 | .highlight_style(Style::default().fg(Color::Yellow)) 102 | .select(self.current_idx); 103 | 104 | f.render_widget(tabs, area); 105 | } 106 | 107 | /// Update the state based on the given input 108 | fn handle_input(&mut self, event: &Event) { 109 | if let Event::Key(key_event) = event { 110 | if key_event.code == KeyCode::Tab { 111 | self.next(); 112 | } else if key_event.code == KeyCode::BackTab { 113 | self.previous(); 114 | } 115 | } 116 | } 117 | 118 | /// Change to the next tab 119 | fn next(&mut self) { 120 | self.current_idx = (self.current_idx + 1) % self.titles.len(); 121 | } 122 | 123 | /// Change to the previous tab 124 | fn previous(&mut self) { 125 | if self.current_idx > 0 { 126 | self.current_idx -= 1; 127 | } else { 128 | self.current_idx = self.titles.len() - 1; 129 | } 130 | } 131 | 132 | /// Get the current tab index 133 | // TODO: this being public is a hack 134 | pub fn current_idx(&self) -> usize { 135 | self.current_idx 136 | } 137 | } 138 | 139 | /// A simple struct to represent the state of a progress bar 140 | /// 141 | /// This is used to display world loading progress bars in the logs 142 | #[derive(Debug, PartialEq)] 143 | struct ProgressBarState { 144 | complete: u32, 145 | out_of: u32, 146 | } 147 | 148 | impl Display for ProgressBarState { 149 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 150 | f.write_str("[")?; 151 | 152 | let num_progress_chars = ((self.complete as f32 / self.out_of as f32) * 10.0) as i32; 153 | let num_empty_chars = 10 - num_progress_chars; 154 | 155 | for _ in 0..num_progress_chars { 156 | f.write_str("=")?; 157 | } 158 | 159 | for _ in 0..num_empty_chars { 160 | f.write_str(" ")?; 161 | } 162 | 163 | f.write_fmt(format_args!("] {}%", self.complete)) 164 | } 165 | } 166 | 167 | #[derive(Debug)] 168 | #[allow(clippy::type_complexity)] 169 | pub struct LogsState { 170 | /// Stores the log messages to be displayed 171 | /// 172 | /// (original_message, (wrapped_message, wrapped_at_width)) 173 | records: VecDeque<(String, Option<(Vec>, u16)>)>, 174 | /// The current state of the active progress bar (if present) 175 | progress_bar: Option, 176 | /// State for the input (child widget) 177 | // TODO: this being public is a hack 178 | pub input_state: InputState, 179 | } 180 | 181 | impl LogsState { 182 | /// Draw the current state in the given `area` 183 | fn draw(&mut self, f: &mut Frame, area: Rect) { 184 | let (input_area, logs_area) = { 185 | let chunks = Layout::default() 186 | .direction(Direction::Vertical) 187 | .constraints([Constraint::Min(0), Constraint::Length(2)].as_ref()) 188 | .split(area); 189 | let input_area = chunks[1]; 190 | let logs_area = chunks[0]; 191 | 192 | (input_area, logs_area) 193 | }; 194 | 195 | let available_lines = if self.progress_bar.is_some() { 196 | // Account for space needed for progress bar 197 | logs_area.height as usize - 1 198 | } else { 199 | logs_area.height as usize 200 | }; 201 | let logs_area_width = logs_area.width as usize; 202 | 203 | let bar_string = if let Some(bar) = &self.progress_bar { 204 | bar.to_string() 205 | } else { 206 | String::new() 207 | }; 208 | 209 | let num_records = self.records.len(); 210 | // Keep track of the number of lines after wrapping so we can skip lines as 211 | // needed below 212 | let mut wrapped_lines_len = 0; 213 | 214 | let mut items = Vec::with_capacity(logs_area.height as usize); 215 | items.extend( 216 | self.records 217 | .iter_mut() 218 | // Only wrap the records we could potentially be displaying 219 | .skip(num_records.saturating_sub(available_lines)) 220 | .flat_map(|r| { 221 | // See if we can use a cached wrapped line 222 | if let Some(wrapped) = &r.1 { 223 | if wrapped.1 as usize == logs_area_width { 224 | wrapped_lines_len += wrapped.0.len(); 225 | return wrapped.0.clone(); 226 | } 227 | } 228 | 229 | // If not, wrap the line and cache it 230 | r.1 = Some(( 231 | textwrap::wrap(r.0.as_ref(), logs_area_width) 232 | .into_iter() 233 | .map(|s| s.to_string()) 234 | .map(Span::from) 235 | .map(ListItem::new) 236 | .collect::>(), 237 | logs_area.width, 238 | )); 239 | 240 | wrapped_lines_len += r.1.as_ref().unwrap().0.len(); 241 | r.1.as_ref().unwrap().0.clone() 242 | }), 243 | ); 244 | 245 | if self.progress_bar.is_some() { 246 | items.push(ListItem::new(bar_string.as_str())); 247 | } 248 | 249 | // TODO: we should be wrapping text with paragraph, but it currently 250 | // doesn't support wrapping and staying scrolled to the bottom 251 | // 252 | // see https://github.com/fdehau/tui-rs/issues/89 253 | let logs = List::new( 254 | items 255 | .into_iter() 256 | // Wrapping could have created more lines than what we can display; 257 | // skip them 258 | .skip(wrapped_lines_len.saturating_sub(available_lines)) 259 | .collect::>(), 260 | ) 261 | .block(Block::default().borders(Borders::NONE)); 262 | 263 | f.render_widget(logs, logs_area); 264 | self.input_state.draw(f, input_area); 265 | } 266 | 267 | /// Update the state based on the given input 268 | fn handle_input(&mut self, event: &Event) { 269 | self.input_state.handle_input(event); 270 | } 271 | 272 | /// Add a record to be displayed 273 | pub fn add_record(&mut self, record: String) { 274 | self.records.push_back((record, None)); 275 | } 276 | 277 | /// Set the progress bar to the given percentage of completion 278 | /// 279 | /// Setting to 100 clears the bar 280 | pub fn set_progress_percent(&mut self, percent: u32) { 281 | match self.progress_bar.as_mut() { 282 | Some(bar) => { 283 | if percent >= 100 { 284 | self.progress_bar = None; 285 | } else { 286 | bar.complete = percent; 287 | } 288 | } 289 | None => { 290 | self.progress_bar = Some(ProgressBarState { 291 | complete: percent, 292 | out_of: 100, 293 | }) 294 | } 295 | } 296 | } 297 | } 298 | 299 | #[derive(Debug)] 300 | pub struct PlayersState; 301 | 302 | impl PlayersState { 303 | /// Draw the current state in the given `area` 304 | fn draw( 305 | &self, 306 | f: &mut Frame, 307 | area: Rect, 308 | online_players: &BTreeMap, 309 | ) { 310 | let now_utc = OffsetDateTime::now_utc(); 311 | 312 | // TODO: doing all this work every draw for every online player is gonna 313 | // be bad with high player counts 314 | let online_players = online_players 315 | .iter() 316 | .map(|(n, info)| { 317 | const LOGIN_TIME_FORMAT: &[FormatItem] = 318 | time::macros::format_description!("[hour repr:12]:[minute]:[second] [period]"); 319 | 320 | // TODO: log failure here somehow 321 | let local_login_time = UtcOffset::current_local_offset() 322 | .map(|offset| info.joined_at.to_offset(offset)) 323 | .ok(); 324 | 325 | let session_time = now_utc - info.joined_at; 326 | let session_time_string = make_session_time_string(session_time); 327 | 328 | [ 329 | n.to_string(), 330 | local_login_time 331 | .and_then(|local_login_time| { 332 | // TODO: log failure here somehow 333 | local_login_time.format(&LOGIN_TIME_FORMAT).ok() 334 | }) 335 | .unwrap_or_else(|| String::from("time error")), 336 | session_time_string, 337 | ] 338 | }) 339 | .collect::>(); 340 | 341 | let online_players = Table::new( 342 | online_players 343 | .iter() 344 | .map(|d| Row::new(d.iter().map(|s| s.as_str()).map(Text::from))), 345 | ) 346 | .header(Row::new(vec!["Name", "Login Time", "Session Length"])) 347 | .block(Block::default().borders(Borders::NONE)) 348 | .widths(&[ 349 | Constraint::Length(16), 350 | Constraint::Length(11), 351 | Constraint::Length(14), 352 | ]) 353 | .column_spacing(3); 354 | 355 | f.render_widget(online_players, area); 356 | } 357 | 358 | /// Update the state based on the given input 359 | fn handle_input(&mut self, _event: &Event) {} 360 | } 361 | 362 | fn make_session_time_string(session_duration: Duration) -> String { 363 | let (session_minutes, session_hours, session_days) = ( 364 | (session_duration - Duration::hours(session_duration.whole_hours())).whole_minutes(), 365 | (session_duration - Duration::days(session_duration.whole_days())).whole_hours(), 366 | session_duration.whole_days(), 367 | ); 368 | 369 | if session_hours == 0 { 370 | format!("{}m", session_minutes) 371 | } else if session_days == 0 { 372 | format!("{}h {}m", session_hours, session_minutes) 373 | } else { 374 | format!("{}d {}h {}m", session_days, session_hours, session_minutes) 375 | } 376 | } 377 | 378 | #[derive(Debug)] 379 | pub struct InputState { 380 | /// The current value of the input 381 | value: String, 382 | } 383 | 384 | impl InputState { 385 | /// Draw the current state in the given `area` 386 | fn draw(&self, f: &mut Frame, area: Rect) { 387 | let text = Line::from(vec![Span::raw("> "), Span::raw(&self.value)]); 388 | let value_width = self.value.width() as u16; 389 | 390 | let input = Paragraph::new(text) 391 | .style(Style::default().fg(Color::Yellow)) 392 | .block(Block::default().borders(Borders::NONE)); 393 | 394 | f.render_widget(input, area); 395 | f.set_cursor(value_width + 2, area.y); 396 | } 397 | 398 | /// Update the state based on the given input 399 | fn handle_input(&mut self, event: &Event) { 400 | if let Event::Key(key_event) = event { 401 | match key_event.code { 402 | KeyCode::Char(c) => self.value.push(c), 403 | KeyCode::Backspace => { 404 | self.value.pop(); 405 | } 406 | _ => {} 407 | } 408 | } 409 | } 410 | 411 | /// Clear the input 412 | pub fn clear(&mut self) { 413 | self.value.clear(); 414 | } 415 | 416 | /// Get the current value of the input 417 | pub fn value(&self) -> &str { 418 | &self.value 419 | } 420 | } 421 | 422 | #[cfg(test)] 423 | mod test { 424 | mod progress_bar { 425 | use crate::ui::ProgressBarState; 426 | 427 | #[test] 428 | fn progress_zero() { 429 | assert_eq!( 430 | ProgressBarState { 431 | complete: 0, 432 | out_of: 100 433 | } 434 | .to_string(), 435 | "[ ] 0%" 436 | ); 437 | } 438 | 439 | #[test] 440 | fn progress_forty() { 441 | assert_eq!( 442 | ProgressBarState { 443 | complete: 40, 444 | out_of: 100 445 | } 446 | .to_string(), 447 | "[==== ] 40%" 448 | ); 449 | } 450 | 451 | #[test] 452 | fn progress_one_hundred() { 453 | assert_eq!( 454 | ProgressBarState { 455 | complete: 100, 456 | out_of: 100 457 | } 458 | .to_string(), 459 | "[==========] 100%" 460 | ); 461 | } 462 | } 463 | 464 | mod session_time_string { 465 | use time::Duration; 466 | 467 | use crate::ui::make_session_time_string; 468 | 469 | #[test] 470 | fn minutes() { 471 | assert_eq!(make_session_time_string(Duration::minutes(23)), "23m"); 472 | } 473 | 474 | #[test] 475 | fn hours() { 476 | assert_eq!( 477 | make_session_time_string(Duration::hours(2) + Duration::minutes(12)), 478 | "2h 12m" 479 | ); 480 | } 481 | 482 | #[test] 483 | fn days() { 484 | assert_eq!( 485 | make_session_time_string( 486 | Duration::days(1) + Duration::hours(2) + Duration::minutes(12) 487 | ), 488 | "1d 2h 12m" 489 | ); 490 | } 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /sample-data/1.15.2/stats/stats10.json: -------------------------------------------------------------------------------- 1 | { 2 | "stats": { 3 | "minecraft:killed": { 4 | "minecraft:witch": 1, 5 | "minecraft:spider": 6, 6 | "minecraft:slime": 16, 7 | "minecraft:horse": 2, 8 | "minecraft:skeleton": 33, 9 | "minecraft:chicken": 18, 10 | "minecraft:silverfish": 5, 11 | "minecraft:drowned": 1, 12 | "minecraft:magma_cube": 1, 13 | "minecraft:pig": 36, 14 | "minecraft:zombie_villager": 1, 15 | "minecraft:bat": 1, 16 | "minecraft:creeper": 2, 17 | "minecraft:sheep": 18, 18 | "minecraft:zombie": 66, 19 | "minecraft:wither_skeleton": 1, 20 | "minecraft:blaze": 13, 21 | "minecraft:enderman": 3, 22 | "minecraft:cow": 31 23 | }, 24 | "minecraft:dropped": { 25 | "minecraft:birch_planks": 4, 26 | "minecraft:brown_mushroom": 3, 27 | "minecraft:wheat_seeds": 2, 28 | "minecraft:diamond": 16, 29 | "minecraft:feather": 11, 30 | "minecraft:iron_helmet": 2, 31 | "minecraft:cobblestone": 256, 32 | "minecraft:granite": 83, 33 | "minecraft:red_mushroom": 11, 34 | "minecraft:stick": 15, 35 | "minecraft:diorite": 9, 36 | "minecraft:ink_sac": 3, 37 | "minecraft:torch": 66, 38 | "minecraft:cornflower": 4, 39 | "minecraft:dark_oak_trapdoor": 4, 40 | "minecraft:rose_bush": 1, 41 | "minecraft:snowball": 16, 42 | "minecraft:iron_leggings": 1, 43 | "minecraft:bone_meal": 5, 44 | "minecraft:sand": 6, 45 | "minecraft:iron_axe": 1, 46 | "minecraft:iron_pickaxe": 2, 47 | "minecraft:iron_shovel": 1, 48 | "minecraft:lily_pad": 17, 49 | "minecraft:white_wool": 9, 50 | "minecraft:bucket": 1, 51 | "minecraft:bone": 16, 52 | "minecraft:gravel": 6, 53 | "minecraft:sweet_berries": 10, 54 | "minecraft:cobblestone_wall": 2, 55 | "minecraft:rotten_flesh": 77, 56 | "minecraft:oak_planks": 112, 57 | "minecraft:iron_boots": 2, 58 | "minecraft:flint_and_steel": 1, 59 | "minecraft:nether_bricks": 17, 60 | "minecraft:carrot": 1, 61 | "minecraft:potato": 1, 62 | "minecraft:stone": 111, 63 | "minecraft:gunpowder": 64, 64 | "minecraft:diamond_pickaxe": 2, 65 | "minecraft:iron_chestplate": 2, 66 | "minecraft:yellow_bed": 1, 67 | "minecraft:oak_sapling": 5, 68 | "minecraft:oak_boat": 1, 69 | "minecraft:spider_eye": 4, 70 | "minecraft:bow": 3, 71 | "minecraft:arrow": 6, 72 | "minecraft:wooden_pickaxe": 1, 73 | "minecraft:quartz": 396, 74 | "minecraft:netherrack": 516, 75 | "minecraft:black_wool": 1 76 | }, 77 | "minecraft:killed_by": { 78 | "minecraft:creeper": 2, 79 | "minecraft:skeleton": 1, 80 | "minecraft:zombie": 1 81 | }, 82 | "minecraft:picked_up": { 83 | "minecraft:charcoal": 25, 84 | "minecraft:iron_chestplate": 3, 85 | "minecraft:crafting_table": 5, 86 | "minecraft:wheat": 5, 87 | "minecraft:chicken": 17, 88 | "minecraft:ladder": 7, 89 | "minecraft:clay_ball": 68, 90 | "minecraft:suspicious_stew": 1, 91 | "minecraft:redstone_lamp": 44, 92 | "minecraft:brown_mushroom": 81, 93 | "minecraft:wooden_pickaxe": 1, 94 | "minecraft:shield": 1, 95 | "minecraft:cornflower": 12, 96 | "minecraft:dark_oak_pressure_plate": 2, 97 | "minecraft:string": 41, 98 | "minecraft:dark_oak_trapdoor": 6, 99 | "minecraft:iron_axe": 2, 100 | "minecraft:compass": 2, 101 | "minecraft:oak_sign": 1, 102 | "minecraft:diamond_leggings": 1, 103 | "minecraft:cartography_table": 1, 104 | "minecraft:spruce_planks": 8, 105 | "minecraft:beef": 50, 106 | "minecraft:red_dye": 1, 107 | "minecraft:oak_boat": 7, 108 | "minecraft:rose_bush": 1, 109 | "minecraft:sugar_cane": 9, 110 | "minecraft:coal_block": 8, 111 | "minecraft:chest": 44, 112 | "minecraft:diamond_ore": 67, 113 | "minecraft:iron_boots": 2, 114 | "minecraft:furnace": 20, 115 | "minecraft:dark_oak_door": 2, 116 | "minecraft:dark_oak_sapling": 2, 117 | "minecraft:stick": 38, 118 | "minecraft:stone": 234, 119 | "minecraft:torch": 111, 120 | "minecraft:porkchop": 75, 121 | "minecraft:diorite": 143, 122 | "minecraft:carrot": 17, 123 | "minecraft:redstone": 119, 124 | "minecraft:dark_oak_planks": 16, 125 | "minecraft:quartz_block": 64, 126 | "minecraft:stone_sword": 1, 127 | "minecraft:arrow": 41, 128 | "minecraft:rotten_flesh": 250, 129 | "minecraft:diamond_chestplate": 1, 130 | "minecraft:nether_bricks": 58, 131 | "minecraft:diamond_boots": 1, 132 | "minecraft:blaze_rod": 9, 133 | "minecraft:mutton": 26, 134 | "minecraft:obsidian": 3, 135 | "minecraft:snowball": 158, 136 | "minecraft:white_wool": 33, 137 | "minecraft:cobblestone": 4684, 138 | "minecraft:birch_log": 1, 139 | "minecraft:feather": 19, 140 | "minecraft:cooked_beef": 11, 141 | "minecraft:iron_door": 1, 142 | "minecraft:andesite": 242, 143 | "minecraft:leather": 37, 144 | "minecraft:brown_wool": 2, 145 | "minecraft:red_mushroom": 87, 146 | "minecraft:potato": 1, 147 | "minecraft:birch_planks": 5, 148 | "minecraft:flint": 41, 149 | "minecraft:oak_log": 178, 150 | "minecraft:lily_pad": 46, 151 | "minecraft:cobblestone_wall": 2, 152 | "minecraft:golden_boots": 1, 153 | "minecraft:yellow_bed": 1, 154 | "minecraft:gunpowder": 22, 155 | "minecraft:cobblestone_stairs": 11, 156 | "minecraft:map": 15, 157 | "minecraft:lapis_lazuli": 163, 158 | "minecraft:dirt": 821, 159 | "minecraft:paper": 12, 160 | "minecraft:glass": 49, 161 | "minecraft:bone": 47, 162 | "minecraft:apple": 12, 163 | "minecraft:stone_bricks": 1, 164 | "minecraft:bow": 5, 165 | "minecraft:wheat_seeds": 112, 166 | "minecraft:iron_ore": 164, 167 | "minecraft:dark_oak_sign": 1, 168 | "minecraft:diamond_helmet": 1, 169 | "minecraft:glass_bottle": 2, 170 | "minecraft:glowstone_dust": 16, 171 | "minecraft:oak_planks": 82, 172 | "minecraft:iron_sword": 1, 173 | "minecraft:soul_sand": 29, 174 | "minecraft:pumpkin": 24, 175 | "minecraft:sweet_berries": 95, 176 | "minecraft:quartz": 543, 177 | "minecraft:gravel": 197, 178 | "minecraft:sand": 30, 179 | "minecraft:ink_sac": 3, 180 | "minecraft:white_bed": 5, 181 | "minecraft:spruce_log": 1, 182 | "minecraft:gold_ore": 58, 183 | "minecraft:dark_oak_log": 42, 184 | "minecraft:spider_eye": 6, 185 | "minecraft:mossy_cobblestone": 14, 186 | "minecraft:polished_andesite": 2, 187 | "minecraft:slime_ball": 6, 188 | "minecraft:diamond": 230, 189 | "minecraft:slime_block": 1, 190 | "minecraft:emerald": 1, 191 | "minecraft:iron_helmet": 1, 192 | "minecraft:granite": 237, 193 | "minecraft:oak_sapling": 61, 194 | "minecraft:netherrack": 1241, 195 | "minecraft:black_wool": 1, 196 | "minecraft:dark_oak_fence": 11, 197 | "minecraft:coal": 620, 198 | "minecraft:hopper": 3, 199 | "minecraft:diamond_pickaxe": 2 200 | }, 201 | "minecraft:broken": { 202 | "minecraft:wooden_pickaxe": 1, 203 | "minecraft:stone_pickaxe": 2, 204 | "minecraft:iron_axe": 1, 205 | "minecraft:stone_axe": 1, 206 | "minecraft:iron_pickaxe": 8 207 | }, 208 | "minecraft:mined": { 209 | "minecraft:diamond_ore": 184, 210 | "minecraft:white_bed": 4, 211 | "minecraft:oak_sapling": 1, 212 | "minecraft:cobblestone_stairs": 11, 213 | "minecraft:dark_oak_log": 42, 214 | "minecraft:nether_bricks": 27, 215 | "minecraft:dark_oak_pressure_plate": 1, 216 | "minecraft:ladder": 2, 217 | "minecraft:granite": 219, 218 | "minecraft:grass_block": 50, 219 | "minecraft:brown_mushroom_block": 95, 220 | "minecraft:spruce_leaves": 8, 221 | "minecraft:hopper": 3, 222 | "minecraft:redstone_ore": 48, 223 | "minecraft:magma_block": 65, 224 | "minecraft:glowstone": 5, 225 | "minecraft:furnace": 20, 226 | "minecraft:dark_oak_trapdoor": 1, 227 | "minecraft:iron_ore": 177, 228 | "minecraft:snow": 144, 229 | "minecraft:andesite": 265, 230 | "minecraft:pumpkin": 23, 231 | "minecraft:bell": 1, 232 | "minecraft:bookshelf": 27, 233 | "minecraft:stone": 3660, 234 | "minecraft:brown_mushroom": 65, 235 | "minecraft:soul_sand": 29, 236 | "minecraft:dandelion": 1, 237 | "minecraft:clay": 17, 238 | "minecraft:cornflower": 8, 239 | "minecraft:torch": 17, 240 | "minecraft:crafting_table": 5, 241 | "minecraft:emerald_ore": 1, 242 | "minecraft:chest": 30, 243 | "minecraft:mushroom_stem": 43, 244 | "minecraft:nether_quartz_ore": 1919, 245 | "minecraft:cobblestone": 45, 246 | "minecraft:slime_block": 1, 247 | "minecraft:lapis_ore": 18, 248 | "minecraft:diorite": 250, 249 | "minecraft:wall_torch": 84, 250 | "minecraft:birch_planks": 1, 251 | "minecraft:sweet_berry_bush": 31, 252 | "minecraft:spruce_log": 1, 253 | "minecraft:birch_leaves": 1, 254 | "minecraft:grass": 90, 255 | "minecraft:coal_ore": 473, 256 | "minecraft:oak_log": 178, 257 | "minecraft:gravel": 104, 258 | "minecraft:dirt": 167, 259 | "minecraft:sand": 8, 260 | "minecraft:cobweb": 36, 261 | "minecraft:cracked_stone_bricks": 1, 262 | "minecraft:gold_ore": 58, 263 | "minecraft:red_mushroom_block": 255, 264 | "minecraft:stone_bricks": 1, 265 | "minecraft:polished_andesite": 2, 266 | "minecraft:oak_leaves": 107, 267 | "minecraft:netherrack": 5679, 268 | "minecraft:redstone_lamp": 44, 269 | "minecraft:fern": 2, 270 | "minecraft:infested_stone": 4 271 | }, 272 | "minecraft:crafted": { 273 | "minecraft:smooth_stone": 5, 274 | "minecraft:oak_planks": 456, 275 | "minecraft:cobblestone_slab": 6, 276 | "minecraft:dark_oak_pressure_plate": 2, 277 | "minecraft:cooked_chicken": 4, 278 | "minecraft:furnace": 19, 279 | "minecraft:blast_furnace": 1, 280 | "minecraft:dark_oak_trapdoor": 8, 281 | "minecraft:cobblestone_stairs": 216, 282 | "minecraft:stone": 717, 283 | "minecraft:bone_meal": 207, 284 | "minecraft:dark_oak_door": 6, 285 | "minecraft:hopper": 4, 286 | "minecraft:iron_boots": 1, 287 | "minecraft:iron_axe": 4, 288 | "minecraft:ladder": 84, 289 | "minecraft:stone_pickaxe": 2, 290 | "minecraft:chest": 7, 291 | "minecraft:dark_oak_planks": 160, 292 | "minecraft:smoker": 1, 293 | "minecraft:cooked_beef": 42, 294 | "minecraft:coal_block": 13, 295 | "minecraft:diamond_sword": 2, 296 | "minecraft:arrow": 44, 297 | "minecraft:cooked_porkchop": 9, 298 | "minecraft:crafting_table": 7, 299 | "minecraft:spruce_planks": 4, 300 | "minecraft:wooden_pickaxe": 1, 301 | "minecraft:iron_helmet": 1, 302 | "minecraft:iron_sword": 2, 303 | "minecraft:bucket": 1, 304 | "minecraft:birch_planks": 4, 305 | "minecraft:stone_axe": 1, 306 | "minecraft:air": 0, 307 | "minecraft:oak_boat": 3, 308 | "minecraft:iron_chestplate": 1, 309 | "minecraft:diamond_pickaxe": 7, 310 | "minecraft:stick": 364, 311 | "minecraft:iron_ingot": 236, 312 | "minecraft:iron_pickaxe": 13, 313 | "minecraft:snow_block": 35, 314 | "minecraft:iron_shovel": 3, 315 | "minecraft:iron_leggings": 1, 316 | "minecraft:gold_ingot": 52, 317 | "minecraft:white_bed": 1, 318 | "minecraft:torch": 271 319 | }, 320 | "minecraft:custom": { 321 | "minecraft:interact_with_anvil": 13, 322 | "minecraft:mob_kills": 257, 323 | "minecraft:inspect_hopper": 3, 324 | "minecraft:interact_with_crafting_table": 107, 325 | "minecraft:leave_game": 14, 326 | "minecraft:time_since_death": 14856, 327 | "minecraft:climb_one_cm": 24397, 328 | "minecraft:sprint_one_cm": 903722, 329 | "minecraft:walk_one_cm": 2780576, 330 | "minecraft:drop": 111, 331 | "minecraft:talked_to_villager": 16, 332 | "minecraft:sneak_time": 3103, 333 | "minecraft:deaths": 8, 334 | "minecraft:walk_under_water_one_cm": 12178, 335 | "minecraft:boat_one_cm": 447620, 336 | "minecraft:jump": 4120, 337 | "minecraft:interact_with_blast_furnace": 11, 338 | "minecraft:enchant_item": 7, 339 | "minecraft:walk_on_water_one_cm": 36331, 340 | "minecraft:interact_with_furnace": 214, 341 | "minecraft:interact_with_cartography_table": 1, 342 | "minecraft:play_one_minute": 719705, 343 | "minecraft:interact_with_grindstone": 6, 344 | "minecraft:sleep_in_bed": 8, 345 | "minecraft:time_since_rest": 14888, 346 | "minecraft:open_enderchest": 3, 347 | "minecraft:damage_taken": 5910, 348 | "minecraft:damage_dealt": 36901, 349 | "minecraft:swim_one_cm": 38416, 350 | "minecraft:fly_one_cm": 365348, 351 | "minecraft:crouch_one_cm": 5283, 352 | "minecraft:open_chest": 667, 353 | "minecraft:fall_one_cm": 75425, 354 | "minecraft:interact_with_smoker": 10 355 | }, 356 | "minecraft:used": { 357 | "minecraft:iron_shovel": 307, 358 | "minecraft:ladder": 44, 359 | "minecraft:chest": 40, 360 | "minecraft:stone": 119, 361 | "minecraft:iron_sword": 740, 362 | "minecraft:netherrack": 118, 363 | "minecraft:torch": 156, 364 | "minecraft:dirt": 88, 365 | "minecraft:hopper": 3, 366 | "minecraft:lava_bucket": 1, 367 | "minecraft:iron_axe": 433, 368 | "minecraft:bow": 2, 369 | "minecraft:dark_oak_pressure_plate": 2, 370 | "minecraft:oak_sapling": 45, 371 | "minecraft:furnace": 37, 372 | "minecraft:cooked_beef": 45, 373 | "minecraft:water_bucket": 2, 374 | "minecraft:cobblestone_stairs": 137, 375 | "minecraft:stone_pickaxe": 264, 376 | "minecraft:oak_planks": 2, 377 | "minecraft:lily_pad": 5, 378 | "minecraft:wooden_pickaxe": 60, 379 | "minecraft:dark_oak_trapdoor": 1, 380 | "minecraft:crafting_table": 10, 381 | "minecraft:blast_furnace": 1, 382 | "minecraft:white_bed": 5, 383 | "minecraft:dark_oak_door": 4, 384 | "minecraft:smoker": 1, 385 | "minecraft:diamond_pickaxe": 10714, 386 | "minecraft:apple": 8, 387 | "minecraft:iron_pickaxe": 2246, 388 | "minecraft:diamond_ore": 70, 389 | "minecraft:stone_axe": 131, 390 | "minecraft:diamond_sword": 146, 391 | "minecraft:bucket": 4, 392 | "minecraft:cobblestone": 443, 393 | "minecraft:cooked_porkchop": 6, 394 | "minecraft:oak_boat": 8, 395 | "minecraft:andesite": 6, 396 | "minecraft:bone_meal": 13 397 | } 398 | }, 399 | "DataVersion": 2230 400 | } 401 | -------------------------------------------------------------------------------- /mc-server-wrapper/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeMap, path::PathBuf, time::Instant}; 2 | 3 | use anyhow::Context; 4 | 5 | use futures::{FutureExt, StreamExt}; 6 | use time::OffsetDateTime; 7 | use tokio::sync::{mpsc, Mutex}; 8 | 9 | use once_cell::sync::OnceCell; 10 | use scopeguard::defer; 11 | 12 | use mc_server_wrapper_lib::{ 13 | communication::*, parse::*, McServerConfig, McServerManager, CONSOLE_MSG_LOG_TARGET, 14 | }; 15 | 16 | use log::*; 17 | 18 | use crate::discord::{util::sanitize_for_markdown, *}; 19 | 20 | use crate::ui::TuiState; 21 | 22 | use config::Config; 23 | use crossterm::{ 24 | event::{Event, EventStream, KeyCode}, 25 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 26 | ExecutableCommand, 27 | }; 28 | use ratatui::{backend::CrosstermBackend, Terminal}; 29 | use structopt::StructOpt; 30 | use util::{format_online_players, OnlinePlayerFormat}; 31 | 32 | mod config; 33 | mod discord; 34 | mod logging; 35 | mod ui; 36 | 37 | static APPLICATION_VERSION: &str = env!("CARGO_PKG_VERSION"); 38 | 39 | /// Maintains a hashset of players currently on the Minecraft server 40 | /// 41 | /// Player name -> info 42 | static ONLINE_PLAYERS: OnceCell>> = OnceCell::new(); 43 | 44 | /// Info about online players 45 | #[derive(Debug)] 46 | pub struct OnlinePlayerInfo { 47 | joined_at: OffsetDateTime, 48 | } 49 | 50 | impl Default for OnlinePlayerInfo { 51 | fn default() -> Self { 52 | Self { 53 | joined_at: OffsetDateTime::now_utc(), 54 | } 55 | } 56 | } 57 | 58 | #[derive(StructOpt, Debug)] 59 | pub struct Opt { 60 | /// Path to config 61 | #[structopt( 62 | short = "c", 63 | long, 64 | parse(from_os_str), 65 | default_value = "./mc-server-wrapper-config.toml" 66 | )] 67 | config: PathBuf, 68 | 69 | /// Generate a default config and then exit the program 70 | #[structopt(short = "g", long)] 71 | gen_config: bool, 72 | 73 | /// Print application version and then exit the program 74 | #[structopt(short = "V", long)] 75 | version: bool, 76 | 77 | /// Path to the Minecraft server jar 78 | #[structopt(parse(from_os_str))] 79 | server_path: Option, 80 | 81 | /// Bridge server chat to discord 82 | #[structopt(short = "b", long)] 83 | bridge_to_discord: bool, 84 | } 85 | 86 | #[tokio::main] 87 | async fn main() -> Result<(), anyhow::Error> { 88 | // See https://github.com/time-rs/time/issues/293#issuecomment-1005002386. The 89 | // unsoundness here is not in the `time` library, but in the Rust stdlib, and as 90 | // such it needs to be fixed there. 91 | unsafe { 92 | time::util::local_offset::set_soundness(time::util::local_offset::Soundness::Unsound); 93 | } 94 | 95 | log_panics::init(); 96 | CONSOLE_MSG_LOG_TARGET.set("mc").unwrap(); 97 | ONLINE_PLAYERS.set(Mutex::new(BTreeMap::new())).unwrap(); 98 | 99 | let opt = Opt::from_args(); 100 | let config_filepath = opt.config.clone(); 101 | let mut config = Config::load(&config_filepath).await?; 102 | let mut notify_receiver = config.setup_watcher(config_filepath.clone()); 103 | 104 | if opt.gen_config { 105 | return Ok(()); 106 | } 107 | 108 | if opt.version { 109 | println!("mc-server-wrapper {APPLICATION_VERSION}"); 110 | return Ok(()); 111 | } 112 | 113 | config.merge_in_args(opt)?; 114 | let (log_sender, mut log_receiver) = mpsc::channel(64); 115 | let stdout = std::io::stdout(); 116 | let backend = CrosstermBackend::new(stdout); 117 | let mut terminal = Terminal::new(backend)?; 118 | let mut tui_state = TuiState::new(); 119 | 120 | enable_raw_mode()?; 121 | terminal.backend_mut().execute(EnterAlternateScreen)?; 122 | defer! { 123 | std::io::stdout().execute(LeaveAlternateScreen).unwrap(); 124 | disable_raw_mode().unwrap(); 125 | } 126 | 127 | logging::setup_logger( 128 | config 129 | .minecraft 130 | .server_path 131 | .with_file_name("mc-server-wrapper.log"), 132 | log_sender, 133 | config.logging.all, 134 | config.logging.self_level, 135 | config.logging.discord, 136 | ) 137 | .with_context(|| "Failed to set up logging")?; 138 | 139 | let mc_config = McServerConfig::new( 140 | config.minecraft.server_path.clone(), 141 | config.minecraft.memory, 142 | config.minecraft.jvm_flags, 143 | false, 144 | ); 145 | let (mc_server, mc_cmd_sender, mut mc_event_receiver) = McServerManager::new(); 146 | 147 | info!("Starting the Minecraft server"); 148 | mc_cmd_sender 149 | .send(ServerCommand::StartServer { 150 | config: Some(mc_config), 151 | }) 152 | .await 153 | .unwrap(); 154 | let mut last_start_time = Instant::now(); 155 | 156 | // TODO: start drawing UI before setting up discord 157 | let discord = if let Some(discord_config) = config.discord { 158 | if discord_config.enable_bridge { 159 | setup_discord( 160 | discord_config.token, 161 | discord_config.channel_id.into(), 162 | mc_cmd_sender.clone(), 163 | discord_config.update_status, 164 | ) 165 | .await 166 | .with_context(|| "Failed to connect to Discord")? 167 | } else { 168 | DiscordBridge::new_noop() 169 | } 170 | } else { 171 | DiscordBridge::new_noop() 172 | }; 173 | 174 | let mut term_events = EventStream::new(); 175 | 176 | // This loop handles both user input and events from the Minecraft server 177 | loop { 178 | // Make sure we are up-to-date on logs before drawing the UI 179 | while let Some(Some(record)) = log_receiver.recv().now_or_never() { 180 | tui_state.logs_state.add_record(record); 181 | } 182 | 183 | { 184 | let online_players = ONLINE_PLAYERS.get().unwrap().lock().await; 185 | // TODO: figure out what to do if the terminal fails to draw 186 | let _ = terminal.draw(|f| tui_state.draw(f, &online_players)); 187 | } 188 | 189 | tokio::select! { 190 | e = mc_event_receiver.recv() => if let Some(e) = e { 191 | match e { 192 | ServerEvent::ConsoleEvent(console_msg, Some(specific_msg)) => { 193 | if let ConsoleMsgType::Unknown(ref s) = console_msg.msg_type { 194 | warn!("Encountered unknown message type from Minecraft: {}", s); 195 | } 196 | 197 | let mut should_log = true; 198 | 199 | match specific_msg { 200 | ConsoleMsgSpecific::PlayerLogout { name } => { 201 | discord.clone().send_channel_msg(format!( 202 | "_**{}** left the game_", 203 | sanitize_for_markdown(&name) 204 | )); 205 | 206 | let mut online_players = ONLINE_PLAYERS.get().unwrap().lock().await; 207 | online_players.remove(&name); 208 | discord.clone().update_status(format_online_players( 209 | &online_players, 210 | OnlinePlayerFormat::BotStatus 211 | )); 212 | }, 213 | ConsoleMsgSpecific::PlayerLogin { name, .. } => { 214 | discord.clone().send_channel_msg(format!( 215 | "_**{}** joined the game_", 216 | sanitize_for_markdown(&name) 217 | )); 218 | 219 | let mut online_players = ONLINE_PLAYERS.get().unwrap().lock().await; 220 | online_players.insert(name, OnlinePlayerInfo::default()); 221 | discord.clone().update_status(format_online_players( 222 | &online_players, 223 | OnlinePlayerFormat::BotStatus 224 | )); 225 | }, 226 | ConsoleMsgSpecific::PlayerMsg { name, msg } => { 227 | discord.clone().send_channel_msg(format!( 228 | "**{}** {}", 229 | sanitize_for_markdown(name), 230 | msg 231 | )); 232 | }, 233 | ConsoleMsgSpecific::SpawnPrepareProgress { progress } => { 234 | tui_state.logs_state.set_progress_percent(progress as u32); 235 | should_log = false; 236 | }, 237 | ConsoleMsgSpecific::SpawnPrepareFinish { .. } => { 238 | tui_state.logs_state.set_progress_percent(100); 239 | }, 240 | ConsoleMsgSpecific::FinishedLoading { .. } => { 241 | let online_players = ONLINE_PLAYERS.get().unwrap().lock().await; 242 | discord.clone().update_status(format_online_players( 243 | &online_players, 244 | OnlinePlayerFormat::BotStatus 245 | )); 246 | }, 247 | _ => {} 248 | } 249 | 250 | if should_log { 251 | console_msg.log(); 252 | } 253 | }, 254 | ServerEvent::ConsoleEvent(console_msg, None) => { 255 | console_msg.log(); 256 | }, 257 | ServerEvent::StdoutLine(line) => { 258 | info!(target: CONSOLE_MSG_LOG_TARGET.get().unwrap(), "{}", line); 259 | }, 260 | ServerEvent::StderrLine(line) => { 261 | warn!(target: CONSOLE_MSG_LOG_TARGET.get().unwrap(), "{}", line); 262 | }, 263 | 264 | ServerEvent::ServerStopped(process_result, reason) => { 265 | if let Some(ShutdownReason::EulaNotAccepted) = reason { 266 | info!("Agreeing to EULA!"); 267 | mc_cmd_sender.send(ServerCommand::AgreeToEula).await.unwrap(); 268 | } else { 269 | let mut sent_restart_command = false; 270 | 271 | // How we handle this depends on whether or not we asked the server to stop 272 | if let Some(ShutdownReason::RequestedToStop) = reason { 273 | match process_result { 274 | Ok(exit_status) => if exit_status.success() { 275 | info!("Minecraft server process exited successfully"); 276 | } else { 277 | warn!("Minecraft server process exited non-successfully with code {}", &exit_status); 278 | }, 279 | Err(e) => { 280 | // TODO: print exit status here as well? 281 | error!("Minecraft server process exited non-successfully with error {}", e); 282 | } 283 | } 284 | } else { 285 | // We did not ask the server to stop 286 | match process_result { 287 | Ok(exit_status) => { 288 | warn!("Minecraft server process exited with code {}", &exit_status); 289 | discord.clone().send_channel_msg("The Minecraft server crashed!"); 290 | 291 | // Attempt to restart the server if it's been up for at least 5 minutes 292 | // TODO: make this configurable 293 | // TODO: maybe parse logs for things that definitely indicate a crash? 294 | if last_start_time.elapsed().as_secs() > 300 { 295 | mc_cmd_sender.send(ServerCommand::StartServer { config: None }).await.unwrap(); 296 | 297 | last_start_time = Instant::now(); 298 | sent_restart_command = true; 299 | } else { 300 | error!("Fatal error believed to have been encountered, not restarting server"); 301 | } 302 | }, 303 | Err(e) => { 304 | error!("Minecraft server process exited with error: {}, not restarting server", e); 305 | } 306 | } 307 | } 308 | 309 | if sent_restart_command { 310 | discord.clone().send_channel_msg("Restarting the Minecraft server..."); 311 | discord.clone().update_status("server is restarting"); 312 | info!("Restarting server..."); 313 | } else { 314 | discord.clone().update_status("server is offline"); 315 | info!("Start the Minecraft server back up with `start` or shutdown the wrapper with `stop`"); 316 | } 317 | } 318 | }, 319 | 320 | ServerEvent::AgreeToEulaResult(res) => { 321 | if let Err(e) = res { 322 | error!("Failed to agree to EULA: {:?}", e); 323 | mc_cmd_sender.send(ServerCommand::StopServer { forever: true }).await.unwrap(); 324 | } else { 325 | mc_cmd_sender.send(ServerCommand::StartServer { config: None }).await.unwrap(); 326 | last_start_time = Instant::now(); 327 | } 328 | } 329 | ServerEvent::StartServerResult(res) => { 330 | // TODO: it's impossible to read start failures right now because the TUI 331 | // leaves the alternate screen right away and the logs are gone 332 | if let Err(e) = res { 333 | error!("Failed to start the Minecraft server: {}", e); 334 | mc_cmd_sender.send(ServerCommand::StopServer { forever: true }).await.unwrap(); 335 | } 336 | } 337 | } 338 | } else { 339 | break; 340 | }, 341 | Some(record) = log_receiver.recv() => { 342 | tui_state.logs_state.add_record(record); 343 | }, 344 | maybe_term_event = term_events.next() => { 345 | match maybe_term_event { 346 | Some(Ok(event)) => { 347 | if let Event::Key(key_event) = event { 348 | #[allow(clippy::single_match)] 349 | match key_event.code { 350 | KeyCode::Enter => if tui_state.tab_state.current_idx() == 0 { 351 | if mc_server.running().await { 352 | mc_cmd_sender.send(ServerCommand::WriteCommandToStdin(tui_state.logs_state.input_state.value().to_string())).await.unwrap(); 353 | } else { 354 | // TODO: create a command parser for user input? 355 | // https://docs.rs/clap/2.33.1/clap/struct.App.html#method.get_matches_from_safe 356 | match tui_state.logs_state.input_state.value() { 357 | "start" => { 358 | info!("Starting the Minecraft server"); 359 | mc_cmd_sender.send(ServerCommand::StartServer { config: None }).await.unwrap(); 360 | last_start_time = Instant::now(); 361 | }, 362 | "stop" => { 363 | mc_cmd_sender.send(ServerCommand::StopServer { forever: true }).await.unwrap(); 364 | }, 365 | _ => {} 366 | } 367 | } 368 | 369 | tui_state.logs_state.input_state.clear(); 370 | }, 371 | _ => {} 372 | } 373 | } 374 | 375 | tui_state.handle_input(event); 376 | }, 377 | Some(Err(e)) => { 378 | error!("TUI input error: {}", e); 379 | }, 380 | None => { 381 | // TODO: need to make sure that after this is reached it isn't reached again 382 | mc_cmd_sender.send(ServerCommand::StopServer { forever: true }).await.unwrap(); 383 | }, 384 | } 385 | }, 386 | config_file_event = notify_receiver.recv() => { 387 | match config_file_event { 388 | // this currently is not used for anything, it's here 389 | // for future use 390 | Some(event) => match event { 391 | Ok(_events) => debug!("Events fired for config file at path"), 392 | Err(_error) => debug!("Received error from config file watcher"), 393 | }, 394 | // TODO: should we break or panic in these cases? 395 | None => unreachable!() 396 | } 397 | } 398 | // TODO: get rid of this 399 | else => break, 400 | } 401 | } 402 | 403 | Ok(()) 404 | } 405 | -------------------------------------------------------------------------------- /mc-server-wrapper/src/discord/mod.rs: -------------------------------------------------------------------------------- 1 | use log::{debug, info, warn}; 2 | 3 | use twilight_cache_inmemory::{model::CachedMember, InMemoryCache, Reference, ResourceType}; 4 | use twilight_gateway::{Event, MessageSender, Shard, ShardId}; 5 | use twilight_http::Client as DiscordClient; 6 | use twilight_model::{ 7 | channel::{message::MessageType, Message}, 8 | gateway::{ 9 | payload::outgoing::{RequestGuildMembers, UpdatePresence}, 10 | presence::Status, 11 | Intents, 12 | }, 13 | id::{ 14 | marker::{ChannelMarker, GuildMarker, UserMarker}, 15 | Id, 16 | }, 17 | }; 18 | 19 | use mc_server_wrapper_lib::{communication::*, parse::*}; 20 | use minecraft_chat::{Color, Payload}; 21 | 22 | use util::{activity, format_mentions_in, tellraw_prefix}; 23 | 24 | use std::{borrow::Cow, collections::HashMap, sync::Arc}; 25 | use tokio::sync::mpsc::Sender; 26 | 27 | mod message_span_iter; 28 | pub mod util; 29 | 30 | static CHAT_PREFIX: &str = "[D] "; 31 | 32 | /// Sets up a `DiscordBridge` and starts handling events 33 | /// 34 | /// If `allow_status_updates` is set to `false` any calls to `update_status()` 35 | /// will be no-ops 36 | pub async fn setup_discord( 37 | token: String, 38 | bridge_channel_id: Id, 39 | mc_cmd_sender: Sender, 40 | allow_status_updates: bool, 41 | ) -> Result { 42 | info!("Setting up Discord"); 43 | let (discord, mut shard) = 44 | DiscordBridge::new(token, bridge_channel_id, allow_status_updates).await?; 45 | 46 | let discord_clone = discord.clone(); 47 | tokio::spawn(async move { 48 | let discord = discord_clone; 49 | 50 | // For all received Discord events, map the event to a `ServerCommand` 51 | // (if necessary) and send it to the Minecraft server 52 | loop { 53 | match shard.next_event().await { 54 | Ok(e) => { 55 | let discord = discord.clone(); 56 | let cmd_sender_clone = mc_cmd_sender.clone(); 57 | 58 | // Update the cache 59 | discord.inner.as_ref().unwrap().cache.update(&e); 60 | 61 | tokio::spawn(async move { 62 | if let Err(e) = discord.handle_discord_event(e, cmd_sender_clone).await { 63 | warn!("Failed to handle Discord event: {}", e); 64 | } 65 | }); 66 | } 67 | Err(source) => { 68 | log::warn!("error receiving event from shard: {}", source); 69 | 70 | if source.is_fatal() { 71 | log::error!("fatal event received, breaking shard event loop"); 72 | break; 73 | } 74 | 75 | continue; 76 | } 77 | }; 78 | } 79 | }); 80 | 81 | Ok(discord) 82 | } 83 | 84 | /// Represents a maybe-present Discord bridge to a single text channel 85 | /// 86 | /// All operations are no-ops if this struct is constructed without the 87 | /// necessary info to set up the Discord bridge. 88 | /// 89 | /// This struct can be cloned and passed around as needed. 90 | #[derive(Debug, Clone)] 91 | pub struct DiscordBridge { 92 | inner: Option>, 93 | /// The ID of the channel we're bridging to 94 | bridge_channel_id: Id, 95 | /// If set to `false` calls to `update_status()` will be no-ops 96 | allow_status_updates: bool, 97 | } 98 | 99 | /// Groups together objects that are only available when the Discord bridge is 100 | /// active. 101 | #[derive(Debug)] 102 | struct DiscordBridgeInner { 103 | client: DiscordClient, 104 | shard_message_sender: MessageSender, 105 | cache: InMemoryCache, 106 | } 107 | 108 | impl DiscordBridge { 109 | /// Connects to Discord with the given `token` and `bridge_channel_id`. 110 | /// 111 | /// If `allow_status_updates` is set to `false` any calls to `update_status()` 112 | /// will be no-ops. 113 | pub async fn new( 114 | token: String, 115 | bridge_channel_id: Id, 116 | allow_status_updates: bool, 117 | ) -> Result<(Self, Shard), anyhow::Error> { 118 | // Use intents to only receive guild message events. 119 | let shard = Shard::new( 120 | ShardId::ONE, 121 | token.clone(), 122 | Intents::GUILDS 123 | | Intents::GUILD_MESSAGES 124 | | Intents::GUILD_MEMBERS 125 | | Intents::MESSAGE_CONTENT, 126 | ); 127 | 128 | let client = DiscordClient::new(token); 129 | 130 | let cache = InMemoryCache::builder() 131 | .resource_types(ResourceType::GUILD | ResourceType::CHANNEL | ResourceType::MEMBER) 132 | .build(); 133 | 134 | Ok(( 135 | Self { 136 | inner: Some(Arc::new(DiscordBridgeInner { 137 | client, 138 | shard_message_sender: shard.sender(), 139 | cache, 140 | })), 141 | bridge_channel_id, 142 | allow_status_updates, 143 | }, 144 | shard, 145 | )) 146 | } 147 | 148 | /// Constructs an instance of this struct that does nothing 149 | pub fn new_noop() -> Self { 150 | Self { 151 | inner: None, 152 | bridge_channel_id: Id::new(1), 153 | allow_status_updates: false, 154 | } 155 | } 156 | 157 | /// Provides access to the `MessageSender` inside this struct 158 | pub fn shard_message_sender(&self) -> Option { 159 | self.inner.as_ref().map(|i| i.shard_message_sender.clone()) 160 | } 161 | 162 | /// Provides access to the `InMemoryCache` inside this struct 163 | pub fn cache(&self) -> Option<&InMemoryCache> { 164 | self.inner.as_ref().map(|i| &i.cache) 165 | } 166 | 167 | /// Get cached info for the guild member specified by the given IDs. 168 | /// 169 | /// `None` will be returned if the member is not present in the cache. 170 | // 171 | // TODO: previously this was used to try fetching the member info over the HTTP 172 | // api if the member was not cached. Unfortunately support for caching out-of-band 173 | // like that was removed from twilight's inmemory cache crate, so we can't do that 174 | // any more until it's re-added. 175 | // 176 | // This method now exists to log failures to find requested member info in the 177 | // cache. 178 | #[allow(clippy::type_complexity)] 179 | pub fn cached_guild_member( 180 | &self, 181 | guild_id: Id, 182 | user_id: Id, 183 | ) -> Option, Id), CachedMember>> { 184 | self.cache().unwrap().member(guild_id, user_id).or_else(|| { 185 | warn!( 186 | "Member info for user with guild_id {} and user_id {} was not cached", 187 | guild_id, user_id 188 | ); 189 | 190 | None 191 | }) 192 | } 193 | 194 | /// Handle an event from Discord 195 | /// 196 | /// The provided `cmd_parser` is used to parse commands (not 197 | /// `ServerCommands`) from Discord messages. 198 | #[allow(clippy::single_match)] 199 | pub async fn handle_discord_event( 200 | &self, 201 | event: Event, 202 | mc_cmd_sender: Sender, 203 | ) -> Result<(), anyhow::Error> { 204 | match event { 205 | Event::Ready(_) => { 206 | info!("Discord bridge online"); 207 | } 208 | Event::GuildCreate(guild) => { 209 | // Log the name of the channel we're bridging to as well if it's 210 | // in this guild 211 | if let Some(channel_name) = guild 212 | .channels 213 | .iter() 214 | .find(|c| c.id == self.bridge_channel_id) 215 | .and_then(|c| c.name.as_ref()) 216 | { 217 | info!( 218 | "Connected to guild '{}', bridging chat to '#{}'", 219 | guild.name, channel_name 220 | ); 221 | 222 | let message_sender = self.shard_message_sender().unwrap(); 223 | 224 | // This is the guild containing the channel we're bridging to. We want to 225 | // initially cache all of the members in the guild so that we can later use 226 | // the cached info to display nicknames when outputting Discord messages in 227 | // Minecraft 228 | // TODO: if bigger servers start using this it might be undesirable to cache 229 | // all member info right out of the gate 230 | message_sender 231 | .command(&RequestGuildMembers::builder(guild.id).query("", None))?; 232 | } else { 233 | info!("Connected to guild '{}'", guild.name); 234 | } 235 | } 236 | Event::MessageCreate(msg) => { 237 | if msg.kind == MessageType::Regular 238 | && !msg.author.bot 239 | && msg.channel_id == self.bridge_channel_id 240 | { 241 | let cached_member = msg 242 | .guild_id 243 | .and_then(|guild_id| self.cached_guild_member(guild_id, msg.author.id)); 244 | 245 | let author_display_name = cached_member 246 | .as_ref() 247 | .and_then(|cm| cm.nick()) 248 | .unwrap_or(&msg.author.name); 249 | 250 | self.handle_attachments_in_msg( 251 | &msg, 252 | author_display_name, 253 | mc_cmd_sender.clone(), 254 | ) 255 | .await; 256 | 257 | self.handle_msg_content(&msg, author_display_name, mc_cmd_sender.clone()) 258 | .await; 259 | 260 | // We handle embeds after the message contents to replicate 261 | // Discord's layout (embeds after message) 262 | self.handle_embeds_in_msg(&msg, author_display_name, mc_cmd_sender) 263 | .await; 264 | } 265 | } 266 | _ => {} 267 | } 268 | 269 | Ok(()) 270 | } 271 | 272 | /// Handles any attachments in the given message 273 | async fn handle_attachments_in_msg( 274 | &self, 275 | msg: &Message, 276 | author_display_name: &str, 277 | mc_cmd_sender: Sender, 278 | ) { 279 | for attachment in &msg.attachments { 280 | let type_str = if attachment.height.is_some() { 281 | // TODO: it could also be a video.... 282 | "image" 283 | } else { 284 | "file" 285 | }; 286 | 287 | let tellraw_msg = tellraw_prefix() 288 | .then(Payload::text(&format!("{} uploaded ", author_display_name))) 289 | .italic(true) 290 | .color(Color::Gray) 291 | .then(Payload::text(type_str)) 292 | .underlined(true) 293 | .italic(true) 294 | .color(Color::Gray) 295 | .hover_show_text(&format!( 296 | "Click to open the {} in your web browser", 297 | type_str 298 | )) 299 | .click_open_url(&attachment.url) 300 | .build(); 301 | 302 | ConsoleMsg::new( 303 | ConsoleMsgType::Info, 304 | format!( 305 | "{}{} uploaded {}: {}", 306 | CHAT_PREFIX, author_display_name, type_str, attachment.url 307 | ), 308 | ) 309 | .log(); 310 | 311 | mc_cmd_sender 312 | .send(ServerCommand::TellRawAll(tellraw_msg.to_json().unwrap())) 313 | .await 314 | .ok(); 315 | } 316 | } 317 | 318 | /// Handles the content of the message 319 | /// 320 | /// This can only be called if `self.inner` is `Some` 321 | async fn handle_msg_content<'a>( 322 | &self, 323 | msg: &Message, 324 | author_display_name: &str, 325 | mc_cmd_sender: Sender, 326 | ) { 327 | if msg.content.is_empty() { 328 | debug!("Empty message from Discord: {:?}", &msg); 329 | return; 330 | } 331 | 332 | let cache = self.cache().unwrap(); 333 | 334 | // Gather cached information about all guild members mentioned in the 335 | // message. 336 | // 337 | // TODO: it might be better to just pass the cache where it's needed 338 | // directly and read from it there. 339 | let cached_mentioned_members = msg 340 | .guild_id 341 | .map(|guild_id| { 342 | msg.mentions 343 | .iter() 344 | .map(|m| m.id) 345 | .map(move |id| self.cached_guild_member(guild_id, id)) 346 | .collect::>() 347 | }) 348 | .unwrap_or_default(); 349 | 350 | // Use the cached info to format mentions with the member's nickname if one is 351 | // set 352 | let mut mentions_map = HashMap::new(); 353 | for (mention, cmm) in msg.mentions.iter().zip(cached_mentioned_members.iter()) { 354 | mentions_map.insert( 355 | mention.id, 356 | cmm.as_ref() 357 | .and_then(|cm| cm.nick()) 358 | .unwrap_or(mention.name.as_str()), 359 | ); 360 | } 361 | 362 | let username = || { 363 | // Technically a discriminator of "0" means the user has migrated to the 364 | // new username system (which does not use a discriminator). A user that 365 | // still has a discriminator of "0000" is possible, but only as a 366 | // webhook (all human users will have a non-zero discriminator). 367 | // There's no way to differentiate between a discriminator of "0" and 368 | // a discriminator of "0000" in Twilight right now (because the type is 369 | // u16 and not string), so we perform this check instead. 370 | if msg.author.discriminator > 0 || msg.webhook_id.is_none() { 371 | Cow::Borrowed(&msg.author.name) 372 | } else { 373 | Cow::Owned(format!( 374 | "{}#{}", 375 | &msg.author.name, 376 | msg.author.discriminator() 377 | )) 378 | } 379 | }; 380 | 381 | let tellraw_msg_builder = tellraw_prefix() 382 | .then(Payload::text(&format!("<{}> ", author_display_name))) 383 | .hover_show_text(username().as_str()); 384 | 385 | let (content, tellraw_msg_builder) = format_mentions_in( 386 | &msg.content, 387 | tellraw_msg_builder, 388 | mentions_map, 389 | &msg.mention_roles, 390 | cache, 391 | ); 392 | 393 | // Tellraw commands do not get logged to the console, so we 394 | // make up for that here 395 | ConsoleMsg::new( 396 | ConsoleMsgType::Info, 397 | format!( 398 | "{}<{} ({})> {}", 399 | CHAT_PREFIX, 400 | author_display_name, 401 | username(), 402 | &content 403 | ), 404 | ) 405 | .log(); 406 | 407 | mc_cmd_sender 408 | .send(ServerCommand::TellRawAll( 409 | tellraw_msg_builder.build().to_json().unwrap(), 410 | )) 411 | .await 412 | .ok(); 413 | } 414 | 415 | /// Handles any embeds in the given message 416 | async fn handle_embeds_in_msg( 417 | &self, 418 | msg: &Message, 419 | author_display_name: &str, 420 | mc_cmd_sender: Sender, 421 | ) { 422 | for (embed, embed_url) in msg 423 | .embeds 424 | .iter() 425 | // Right now we only handle embeds with URLs 426 | .filter_map(|e| e.url.as_ref().map(|embed_url| (e, embed_url))) 427 | { 428 | let link_text = embed 429 | .title 430 | .as_ref() 431 | .zip( 432 | embed 433 | .provider 434 | .as_ref() 435 | .and_then(|provider| provider.name.as_ref()), 436 | ) 437 | .map(|(embed_title, provider_name)| format!("{} - {}", provider_name, embed_title)) 438 | .unwrap_or_else(|| embed_url.clone()); 439 | 440 | let tellraw_msg = tellraw_prefix() 441 | .then(Payload::text(&format!("{} linked \"", author_display_name))) 442 | .italic(true) 443 | .color(Color::Gray) 444 | .then(Payload::text(&link_text)) 445 | .underlined(true) 446 | .italic(true) 447 | .color(Color::Gray) 448 | .hover_show_text(&format!("Click to open in your browser: {}", embed_url)) 449 | .click_open_url(embed_url) 450 | .then(Payload::text("\"")) 451 | .italic(true) 452 | .color(Color::Gray) 453 | .build(); 454 | 455 | ConsoleMsg::new( 456 | ConsoleMsgType::Info, 457 | format!( 458 | "{}{} linked \"{}\": {}", 459 | CHAT_PREFIX, author_display_name, link_text, embed_url 460 | ), 461 | ) 462 | .log(); 463 | 464 | mc_cmd_sender 465 | .send(ServerCommand::TellRawAll(tellraw_msg.to_json().unwrap())) 466 | .await 467 | .ok(); 468 | } 469 | } 470 | 471 | /// Sends the given text to the channel being bridged to 472 | /// 473 | /// A new task is spawned to send the message, and its `JoinHandle` is 474 | /// returned so its completion can be `await`ed if desired. 475 | pub fn send_channel_msg + Send + 'static>( 476 | self, 477 | text: T, 478 | ) -> tokio::task::JoinHandle<()> { 479 | tokio::spawn(async move { 480 | let text = text.as_ref(); 481 | 482 | if let Some(inner) = self.inner { 483 | let content_res = inner 484 | .client 485 | .create_message(self.bridge_channel_id) 486 | .content(text); 487 | 488 | match content_res { 489 | Ok(cm) => { 490 | if let Err(e) = cm.await { 491 | warn!("Failed to send Discord message: {}", e); 492 | } 493 | } 494 | Err(validation_err) => warn!( 495 | "Validation error while attempting to send message to channel: {}", 496 | validation_err 497 | ), 498 | } 499 | } 500 | }) 501 | } 502 | 503 | /// Sets the bot's status to the given text 504 | /// 505 | /// A new task is spawned to update the status, and its `JoinHandle` is 506 | /// returned so its completion can be `await`ed if desired. 507 | /// 508 | /// This will be a no-op if `self.allow_status_updates` is false 509 | pub fn update_status + Send + 'static>( 510 | self, 511 | text: T, 512 | ) -> tokio::task::JoinHandle<()> { 513 | tokio::spawn(async move { 514 | if !self.allow_status_updates { 515 | return; 516 | } 517 | let text = text.into(); 518 | 519 | if let Some(inner) = self.inner { 520 | let message_sender = inner.shard_message_sender.clone(); 521 | match message_sender.command( 522 | &UpdatePresence::new(vec![activity(text)], false, None, Status::Online) 523 | .unwrap(), 524 | ) { 525 | Ok(()) => {} 526 | Err(e) => warn!("Failed to update bot's status: {}", e), 527 | } 528 | } 529 | }) 530 | } 531 | } 532 | -------------------------------------------------------------------------------- /sample-data/1.15.2/stats/stats6.json: -------------------------------------------------------------------------------- 1 | { 2 | "stats": { 3 | "minecraft:mined": { 4 | "minecraft:dark_oak_planks": 22, 5 | "minecraft:spruce_planks": 21, 6 | "minecraft:white_bed": 9, 7 | "minecraft:jungle_leaves": 112, 8 | "minecraft:carved_pumpkin": 1, 9 | "minecraft:granite": 59, 10 | "minecraft:red_sand": 2, 11 | "minecraft:acacia_log": 38, 12 | "minecraft:grass_block": 30, 13 | "minecraft:nether_wart": 119, 14 | "minecraft:iron_ore": 162, 15 | "minecraft:pumpkin": 367, 16 | "minecraft:dark_oak_stairs": 12, 17 | "minecraft:oak_stairs": 11, 18 | "minecraft:blue_orchid": 42, 19 | "minecraft:brown_mushroom": 22, 20 | "minecraft:kelp_plant": 7, 21 | "minecraft:smoker": 2, 22 | "minecraft:orange_terracotta": 1, 23 | "minecraft:torch": 43, 24 | "minecraft:blast_furnace": 2, 25 | "minecraft:melon": 149, 26 | "minecraft:chest": 4, 27 | "minecraft:dead_bush": 1, 28 | "minecraft:mushroom_stem": 4, 29 | "minecraft:glass": 1, 30 | "minecraft:cobblestone": 172, 31 | "minecraft:cyan_bed": 3, 32 | "minecraft:sugar_cane": 18, 33 | "minecraft:oak_planks": 37, 34 | "minecraft:wall_torch": 34, 35 | "minecraft:seagrass": 133, 36 | "minecraft:birch_log": 8, 37 | "minecraft:wheat": 627, 38 | "minecraft:orange_tulip": 9, 39 | "minecraft:grass": 319, 40 | "minecraft:oak_log": 27, 41 | "minecraft:sand": 47, 42 | "minecraft:dirt": 46, 43 | "minecraft:red_mushroom_block": 45, 44 | "minecraft:oak_leaves": 40, 45 | "minecraft:diamond_ore": 20, 46 | "minecraft:dark_oak_leaves": 8, 47 | "minecraft:dark_oak_log": 82, 48 | "minecraft:dark_oak_fence": 37, 49 | "minecraft:spruce_leaves": 2, 50 | "minecraft:red_mushroom": 5, 51 | "minecraft:kelp": 7, 52 | "minecraft:sandstone": 8, 53 | "minecraft:redstone_ore": 34, 54 | "minecraft:furnace": 4, 55 | "minecraft:carrots": 108, 56 | "minecraft:andesite": 71, 57 | "minecraft:vine": 8, 58 | "minecraft:rail": 4, 59 | "minecraft:stripped_acacia_log": 7, 60 | "minecraft:packed_ice": 2, 61 | "minecraft:bamboo": 3, 62 | "minecraft:stone": 1472, 63 | "minecraft:acacia_leaves": 8, 64 | "minecraft:soul_sand": 1, 65 | "minecraft:lily_of_the_valley": 6, 66 | "minecraft:oak_fence": 19, 67 | "minecraft:smooth_sandstone": 2, 68 | "minecraft:crafting_table": 18, 69 | "minecraft:cornflower": 11, 70 | "minecraft:campfire": 1, 71 | "minecraft:lapis_ore": 8, 72 | "minecraft:diorite": 39, 73 | "minecraft:spruce_stairs": 1, 74 | "minecraft:birch_planks": 10, 75 | "minecraft:dark_oak_wall_sign": 1, 76 | "minecraft:sweet_berry_bush": 14, 77 | "minecraft:birch_leaves": 26, 78 | "minecraft:bee_nest": 1, 79 | "minecraft:coal_ore": 525, 80 | "minecraft:cocoa": 8, 81 | "minecraft:cobweb": 3, 82 | "minecraft:terracotta": 3, 83 | "minecraft:acacia_planks": 1, 84 | "minecraft:stone_bricks": 2, 85 | "minecraft:gold_ore": 145, 86 | "minecraft:fern": 104 87 | }, 88 | "minecraft:dropped": { 89 | "minecraft:brown_mushroom": 1, 90 | "minecraft:birch_planks": 4, 91 | "minecraft:dark_oak_stairs": 6, 92 | "minecraft:crafting_table": 1, 93 | "minecraft:leather_helmet": 2, 94 | "minecraft:cod": 1, 95 | "minecraft:emerald": 5, 96 | "minecraft:dirt": 110, 97 | "minecraft:dark_oak_fence": 5, 98 | "minecraft:bone_meal": 1, 99 | "minecraft:sand": 14, 100 | "minecraft:dark_oak_planks": 43, 101 | "minecraft:fishing_rod": 1, 102 | "minecraft:bone": 10, 103 | "minecraft:cooked_chicken": 1, 104 | "minecraft:iron_nugget": 5, 105 | "minecraft:golden_chestplate": 1, 106 | "minecraft:rotten_flesh": 85, 107 | "minecraft:oak_planks": 2, 108 | "minecraft:salmon": 1, 109 | "minecraft:carrot": 5, 110 | "minecraft:poppy": 2, 111 | "minecraft:turtle_helmet": 1, 112 | "minecraft:gunpowder": 32, 113 | "minecraft:leather_boots": 2, 114 | "minecraft:chest": 1, 115 | "minecraft:ladder": 1, 116 | "minecraft:leather_leggings": 1, 117 | "minecraft:golden_boots": 1, 118 | "minecraft:iron_ingot": 1, 119 | "minecraft:wheat_seeds": 525, 120 | "minecraft:cobblestone": 192, 121 | "minecraft:book": 4, 122 | "minecraft:granite": 9, 123 | "minecraft:coal": 29, 124 | "minecraft:stick": 1, 125 | "minecraft:diorite": 14, 126 | "minecraft:potion": 2, 127 | "minecraft:iron_leggings": 1, 128 | "minecraft:oak_stairs": 11, 129 | "minecraft:white_banner": 2, 130 | "minecraft:kelp": 7, 131 | "minecraft:nether_wart": 15, 132 | "minecraft:light_gray_wool": 1, 133 | "minecraft:shears": 1, 134 | "minecraft:diamond_sword": 2, 135 | "minecraft:potato": 2, 136 | "minecraft:andesite": 7, 137 | "minecraft:spider_eye": 1, 138 | "minecraft:bow": 1, 139 | "minecraft:stone_pickaxe": 2, 140 | "minecraft:black_wool": 1 141 | }, 142 | "minecraft:killed": { 143 | "minecraft:witch": 1, 144 | "minecraft:spider": 27, 145 | "minecraft:cod": 7, 146 | "minecraft:skeleton": 67, 147 | "minecraft:fox": 1, 148 | "minecraft:chicken": 7, 149 | "minecraft:silverfish": 8, 150 | "minecraft:cat": 1, 151 | "minecraft:pig": 10, 152 | "minecraft:zombie_villager": 7, 153 | "minecraft:salmon": 5, 154 | "minecraft:creeper": 50, 155 | "minecraft:sheep": 12, 156 | "minecraft:pillager": 14, 157 | "minecraft:zombie": 234, 158 | "minecraft:cow": 13, 159 | "minecraft:vindicator": 1, 160 | "minecraft:phantom": 7, 161 | "minecraft:drowned": 26, 162 | "minecraft:husk": 5, 163 | "minecraft:rabbit": 1, 164 | "minecraft:enderman": 3 165 | }, 166 | "minecraft:broken": { 167 | "minecraft:golden_helmet": 2, 168 | "minecraft:iron_boots": 2, 169 | "minecraft:iron_chestplate": 1, 170 | "minecraft:stone_pickaxe": 3, 171 | "minecraft:diamond_pickaxe": 1, 172 | "minecraft:stone_axe": 1, 173 | "minecraft:iron_sword": 2, 174 | "minecraft:fishing_rod": 2, 175 | "minecraft:leather_helmet": 1, 176 | "minecraft:stone_sword": 1, 177 | "minecraft:wooden_pickaxe": 1, 178 | "minecraft:iron_axe": 3, 179 | "minecraft:iron_pickaxe": 2, 180 | "minecraft:iron_helmet": 2 181 | }, 182 | "minecraft:custom": { 183 | "minecraft:animals_bred": 5, 184 | "minecraft:interact_with_crafting_table": 100, 185 | "minecraft:bell_ring": 1, 186 | "minecraft:leave_game": 21, 187 | "minecraft:time_since_death": 7393, 188 | "minecraft:sprint_one_cm": 2259203, 189 | "minecraft:drop": 167, 190 | "minecraft:talked_to_villager": 76, 191 | "minecraft:deaths": 14, 192 | "minecraft:jump": 11170, 193 | "minecraft:walk_on_water_one_cm": 147967, 194 | "minecraft:enchant_item": 8, 195 | "minecraft:interact_with_furnace": 59, 196 | "minecraft:traded_with_villager": 103, 197 | "minecraft:play_one_minute": 842443, 198 | "minecraft:sleep_in_bed": 14, 199 | "minecraft:damage_dealt": 74128, 200 | "minecraft:crouch_one_cm": 63569, 201 | "minecraft:interact_with_smoker": 9, 202 | "minecraft:inspect_dispenser": 1, 203 | "minecraft:interact_with_anvil": 5, 204 | "minecraft:mob_kills": 518, 205 | "minecraft:climb_one_cm": 69391, 206 | "minecraft:walk_one_cm": 2820188, 207 | "minecraft:sneak_time": 30081, 208 | "minecraft:walk_under_water_one_cm": 192091, 209 | "minecraft:interact_with_lectern": 1, 210 | "minecraft:boat_one_cm": 490776, 211 | "minecraft:interact_with_blast_furnace": 41, 212 | "minecraft:time_since_rest": 7437, 213 | "minecraft:damage_taken": 14145, 214 | "minecraft:swim_one_cm": 258002, 215 | "minecraft:fly_one_cm": 2483791, 216 | "minecraft:open_chest": 383, 217 | "minecraft:fish_caught": 87, 218 | "minecraft:fall_one_cm": 288896 219 | }, 220 | "minecraft:used": { 221 | "minecraft:melon": 4, 222 | "minecraft:ladder": 71, 223 | "minecraft:poppy": 1, 224 | "minecraft:chest": 8, 225 | "minecraft:iron_sword": 454, 226 | "minecraft:nether_wart": 120, 227 | "minecraft:rotten_flesh": 3, 228 | "minecraft:soul_sand": 2, 229 | "minecraft:lily_of_the_valley": 3, 230 | "minecraft:carrot": 152, 231 | "minecraft:acacia_sapling": 4, 232 | "minecraft:glass": 28, 233 | "minecraft:birch_sapling": 1, 234 | "minecraft:stone_bricks": 2, 235 | "minecraft:bow": 165, 236 | "minecraft:sweet_berries": 8, 237 | "minecraft:furnace": 5, 238 | "minecraft:cooked_cod": 16, 239 | "minecraft:dark_oak_sapling": 4, 240 | "minecraft:dark_oak_stairs": 33, 241 | "minecraft:diorite": 1, 242 | "minecraft:stripped_acacia_log": 7, 243 | "minecraft:stone_pickaxe": 373, 244 | "minecraft:dark_oak_sign": 2, 245 | "minecraft:oak_planks": 1, 246 | "minecraft:orange_tulip": 1, 247 | "minecraft:wooden_pickaxe": 59, 248 | "minecraft:sandstone": 5, 249 | "minecraft:sugar_cane": 2, 250 | "minecraft:sand": 3, 251 | "minecraft:blast_furnace": 3, 252 | "minecraft:crossbow": 4, 253 | "minecraft:dark_oak_log": 6, 254 | "minecraft:melon_slice": 19, 255 | "minecraft:bread": 83, 256 | "minecraft:cyan_bed": 2, 257 | "minecraft:diamond_sword": 796, 258 | "minecraft:dark_oak_fence": 48, 259 | "minecraft:andesite": 41, 260 | "minecraft:oak_boat": 5, 261 | "minecraft:experience_bottle": 1, 262 | "minecraft:oak_fence": 12, 263 | "minecraft:stone_sword": 221, 264 | "minecraft:acacia_log": 33, 265 | "minecraft:carved_pumpkin": 1, 266 | "minecraft:tropical_fish": 4, 267 | "minecraft:cooked_salmon": 24, 268 | "minecraft:cooked_chicken": 6, 269 | "minecraft:torch": 209, 270 | "minecraft:dirt": 65, 271 | "minecraft:birch_door": 1, 272 | "minecraft:dark_oak_planks": 25, 273 | "minecraft:iron_block": 4, 274 | "minecraft:iron_axe": 756, 275 | "minecraft:smooth_sandstone": 2, 276 | "minecraft:cooked_beef": 20, 277 | "minecraft:acacia_planks": 4, 278 | "minecraft:pumpkin": 5, 279 | "minecraft:wheat_seeds": 620, 280 | "minecraft:cooked_mutton": 11, 281 | "minecraft:water_bucket": 5, 282 | "minecraft:cobblestone_stairs": 48, 283 | "minecraft:baked_potato": 9, 284 | "minecraft:crafting_table": 21, 285 | "minecraft:red_mushroom": 1, 286 | "minecraft:birch_sign": 1, 287 | "minecraft:white_bed": 9, 288 | "minecraft:campfire": 1, 289 | "minecraft:cod": 4, 290 | "minecraft:dark_oak_fence_gate": 1, 291 | "minecraft:smoker": 2, 292 | "minecraft:diamond_pickaxe": 1884, 293 | "minecraft:apple": 1, 294 | "minecraft:iron_pickaxe": 497, 295 | "minecraft:stone_axe": 131, 296 | "minecraft:fishing_rod": 132, 297 | "minecraft:cobblestone": 770, 298 | "minecraft:cooked_porkchop": 12, 299 | "minecraft:bucket": 3 300 | }, 301 | "minecraft:picked_up": { 302 | "minecraft:seagrass": 262, 303 | "minecraft:birch_door": 2, 304 | "minecraft:wheat": 645, 305 | "minecraft:diamond_sword": 2, 306 | "minecraft:oak_stairs": 11, 307 | "minecraft:potion": 5, 308 | "minecraft:enchanted_book": 3, 309 | "minecraft:string": 86, 310 | "minecraft:cooked_salmon": 4, 311 | "minecraft:pufferfish": 14, 312 | "minecraft:gold_nugget": 6, 313 | "minecraft:bucket": 1, 314 | "minecraft:spruce_planks": 4, 315 | "minecraft:melon_slice": 692, 316 | "minecraft:furnace": 4, 317 | "minecraft:iron_boots": 1, 318 | "minecraft:golden_helmet": 2, 319 | "minecraft:stick": 11, 320 | "minecraft:jungle_door": 1, 321 | "minecraft:oak_fence": 21, 322 | "minecraft:torch": 86, 323 | "minecraft:acacia_log": 38, 324 | "minecraft:porkchop": 16, 325 | "minecraft:diorite": 51, 326 | "minecraft:golden_chestplate": 1, 327 | "minecraft:rotten_flesh": 544, 328 | "minecraft:tropical_fish": 5, 329 | "minecraft:carved_pumpkin": 1, 330 | "minecraft:cod": 81, 331 | "minecraft:cobblestone": 1577, 332 | "minecraft:nautilus_shell": 1, 333 | "minecraft:birch_log": 8, 334 | "minecraft:magma_block": 1, 335 | "minecraft:leather_chestplate": 1, 336 | "minecraft:feather": 14, 337 | "minecraft:cooked_beef": 8, 338 | "minecraft:birch_slab": 1, 339 | "minecraft:andesite": 91, 340 | "minecraft:golden_leggings": 1, 341 | "minecraft:smoker": 2, 342 | "minecraft:potato": 13, 343 | "minecraft:lapis_lazuli": 54, 344 | "minecraft:dirt": 238, 345 | "minecraft:name_tag": 3, 346 | "minecraft:leather_boots": 3, 347 | "minecraft:bone": 145, 348 | "minecraft:acacia_planks": 1, 349 | "minecraft:bow": 4, 350 | "minecraft:wheat_seeds": 1770, 351 | "minecraft:orange_tulip": 9, 352 | "minecraft:dark_oak_sign": 4, 353 | "minecraft:glowstone_dust": 1, 354 | "minecraft:rabbit": 1, 355 | "minecraft:bamboo": 38, 356 | "minecraft:oak_planks": 36, 357 | "minecraft:poisonous_potato": 8, 358 | "minecraft:sand": 93, 359 | "minecraft:ink_sac": 10, 360 | "minecraft:cyan_bed": 2, 361 | "minecraft:grass_block": 7, 362 | "minecraft:lily_of_the_valley": 6, 363 | "minecraft:dark_oak_log": 82, 364 | "minecraft:stripped_acacia_log": 7, 365 | "minecraft:diamond": 20, 366 | "minecraft:granite": 74, 367 | "minecraft:bone_meal": 15, 368 | "minecraft:dark_oak_fence": 40, 369 | "minecraft:cocoa_beans": 24, 370 | "minecraft:fishing_rod": 2, 371 | "minecraft:crafting_table": 19, 372 | "minecraft:bee_nest": 1, 373 | "minecraft:ladder": 1, 374 | "minecraft:chicken": 8, 375 | "minecraft:nether_wart": 324, 376 | "minecraft:leather_leggings": 1, 377 | "minecraft:brown_mushroom": 23, 378 | "minecraft:cornflower": 11, 379 | "minecraft:iron_shovel": 1, 380 | "minecraft:blue_orchid": 42, 381 | "minecraft:beef": 24, 382 | "minecraft:oak_boat": 5, 383 | "minecraft:light_gray_wool": 1, 384 | "minecraft:jungle_sapling": 4, 385 | "minecraft:iron_nugget": 46, 386 | "minecraft:poppy": 3, 387 | "minecraft:minecart": 1, 388 | "minecraft:chest": 6, 389 | "minecraft:sugar_cane": 37, 390 | "minecraft:birch_sapling": 1, 391 | "minecraft:phantom_membrane": 7, 392 | "minecraft:egg": 4, 393 | "minecraft:dark_oak_sapling": 4, 394 | "minecraft:sandstone": 38, 395 | "minecraft:carrot": 405, 396 | "minecraft:redstone": 162, 397 | "minecraft:prismarine_crystals": 3, 398 | "minecraft:dark_oak_planks": 54, 399 | "minecraft:tnt": 1, 400 | "minecraft:arrow": 118, 401 | "minecraft:shears": 1, 402 | "minecraft:gold_ingot": 5, 403 | "minecraft:mutton": 18, 404 | "minecraft:rail": 6, 405 | "minecraft:white_wool": 16, 406 | "minecraft:tripwire_hook": 2, 407 | "minecraft:salmon": 35, 408 | "minecraft:leather": 18, 409 | "minecraft:red_mushroom": 17, 410 | "minecraft:birch_planks": 4, 411 | "minecraft:oak_log": 26, 412 | "minecraft:golden_boots": 2, 413 | "minecraft:gunpowder": 83, 414 | "minecraft:paper": 25, 415 | "minecraft:white_banner": 4, 416 | "minecraft:smooth_sandstone": 2, 417 | "minecraft:apple": 1, 418 | "minecraft:stone_bricks": 2, 419 | "minecraft:kelp": 8, 420 | "minecraft:iron_ingot": 10, 421 | "minecraft:stone_pickaxe": 2, 422 | "minecraft:iron_ore": 229, 423 | "minecraft:glass_bottle": 1, 424 | "minecraft:iron_sword": 2, 425 | "minecraft:acacia_sapling": 8, 426 | "minecraft:soul_sand": 1, 427 | "minecraft:dark_oak_stairs": 11, 428 | "minecraft:green_dye": 1, 429 | "minecraft:book": 2, 430 | "minecraft:pumpkin": 367, 431 | "minecraft:sweet_berries": 29, 432 | "minecraft:blast_furnace": 2, 433 | "minecraft:white_bed": 9, 434 | "minecraft:gold_ore": 138, 435 | "minecraft:spider_eye": 36, 436 | "minecraft:leather_helmet": 3, 437 | "minecraft:pumpkin_seeds": 4, 438 | "minecraft:slime_ball": 2, 439 | "minecraft:campfire": 1, 440 | "minecraft:chainmail_leggings": 1, 441 | "minecraft:scute": 9, 442 | "minecraft:birch_sign": 2, 443 | "minecraft:terracotta": 3, 444 | "minecraft:baked_potato": 9, 445 | "minecraft:black_wool": 1, 446 | "minecraft:coal": 760, 447 | "minecraft:sunflower": 6, 448 | "minecraft:ender_pearl": 8 449 | }, 450 | "minecraft:killed_by": { 451 | "minecraft:creeper": 2, 452 | "minecraft:zombie": 3, 453 | "minecraft:player": 1, 454 | "minecraft:iron_golem": 1, 455 | "minecraft:skeleton": 6 456 | }, 457 | "minecraft:crafted": { 458 | "minecraft:oak_planks": 116, 459 | "minecraft:smooth_stone": 3, 460 | "minecraft:melon": 76, 461 | "minecraft:cooked_chicken": 7, 462 | "minecraft:stone": 3, 463 | "minecraft:cobblestone_stairs": 48, 464 | "minecraft:cooked_mutton": 16, 465 | "minecraft:green_dye": 2, 466 | "minecraft:diamond_helmet": 1, 467 | "minecraft:bow": 2, 468 | "minecraft:iron_boots": 2, 469 | "minecraft:chest": 3, 470 | "minecraft:name_tag": 4, 471 | "minecraft:smoker": 1, 472 | "minecraft:turtle_helmet": 2, 473 | "minecraft:emerald": 99, 474 | "minecraft:cooked_porkchop": 12, 475 | "minecraft:crafting_table": 4, 476 | "minecraft:wooden_pickaxe": 1, 477 | "minecraft:iron_helmet": 2, 478 | "minecraft:birch_planks": 32, 479 | "minecraft:campfire": 1, 480 | "minecraft:iron_chestplate": 2, 481 | "minecraft:diamond_pickaxe": 3, 482 | "minecraft:stick": 344, 483 | "minecraft:iron_ingot": 162, 484 | "minecraft:iron_pickaxe": 2, 485 | "minecraft:iron_block": 4, 486 | "minecraft:stone_sword": 1, 487 | "minecraft:gold_ingot": 138, 488 | "minecraft:dark_oak_fence_gate": 1, 489 | "minecraft:shears": 1, 490 | "minecraft:fishing_rod": 2, 491 | "minecraft:furnace": 3, 492 | "minecraft:blast_furnace": 1, 493 | "minecraft:dark_oak_sign": 6, 494 | "minecraft:bone_meal": 3, 495 | "minecraft:dark_oak_fence": 6, 496 | "minecraft:iron_axe": 4, 497 | "minecraft:ladder": 105, 498 | "minecraft:lime_dye": 2, 499 | "minecraft:stone_pickaxe": 3, 500 | "minecraft:diamond_boots": 1, 501 | "minecraft:dark_oak_planks": 120, 502 | "minecraft:birch_sign": 3, 503 | "minecraft:cooked_beef": 21, 504 | "minecraft:cooked_cod": 16, 505 | "minecraft:diamond_sword": 1, 506 | "minecraft:dark_oak_stairs": 28, 507 | "minecraft:iron_sword": 2, 508 | "minecraft:cooked_salmon": 20, 509 | "minecraft:stone_axe": 1, 510 | "minecraft:air": 0, 511 | "minecraft:bread": 75, 512 | "minecraft:oak_boat": 1, 513 | "minecraft:white_dye": 3, 514 | "minecraft:iron_leggings": 1, 515 | "minecraft:acacia_planks": 4, 516 | "minecraft:torch": 136 517 | } 518 | }, 519 | "DataVersion": 2230 520 | } 521 | --------------------------------------------------------------------------------