#[macro_use]
mod macros;
pub mod command;
mod connect;
mod geometry3;
mod io;
mod json;
pub mod log;
mod on_drop;
mod placement;
mod structure;
mod utils;
pub use crate::connect::ConnectError;
use crate::{
command::{
enable_logging_command, reset_logging_command, summon_named_entity_command,
SummonNamedEntityOutput,
},
connect::connect,
io::{
create, create_dir_all, io_error, remove_dir_all, remove_file, rename, write, IoErrorAtPath,
},
log::{LogEvent, LogObserver},
placement::generate_structure,
structure::nbt::Structure,
utils::io_invalid_data,
};
use ::log::error;
use fs3::FileExt;
use indexmap::IndexSet;
use json::create_json_text_component;
use std::{
fmt::Display,
fs::{File, OpenOptions},
io::{BufWriter, Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
};
use tokio_stream::Stream;
pub struct MinecraftConnectionBuilder {
identifier: String,
world_dir: PathBuf,
log_file: Option<PathBuf>,
enable_logging_automatically: bool,
}
impl MinecraftConnectionBuilder {
fn new(
identifier: impl Into<String>,
world_dir: impl Into<PathBuf>,
) -> MinecraftConnectionBuilder {
let identifier = identifier.into();
validate_identifier(&identifier);
MinecraftConnectionBuilder {
identifier,
world_dir: world_dir.into(),
log_file: None,
enable_logging_automatically: true,
}
}
pub fn log_file(mut self, log_file: impl Into<PathBuf>) -> MinecraftConnectionBuilder {
self.log_file = Some(log_file.into());
self
}
pub fn enable_logging_automatically(
mut self,
enable_logging_automatically: impl Into<bool>,
) -> MinecraftConnectionBuilder {
self.enable_logging_automatically = enable_logging_automatically.into();
self
}
pub fn build(self) -> MinecraftConnection {
let world_dir = self.world_dir;
let log_file = self
.log_file
.unwrap_or_else(|| log_file_from_world_dir(&world_dir));
MinecraftConnection::new(
self.identifier,
world_dir,
log_file,
self.enable_logging_automatically,
)
}
}
fn validate_identifier(identifier: &str) {
let invalid_chars = identifier
.chars()
.filter(|c| !is_allowed_in_identifier(*c))
.collect::<IndexSet<_>>();
if !invalid_chars.is_empty() {
panic!(
"Invalid characters in MinecraftConnection.identifier: '{}'",
invalid_chars
.iter()
.fold(String::new(), |joined, c| joined + &c.to_string())
);
}
}
fn is_allowed_in_identifier(c: char) -> bool {
return c >= '0' && c <= '9'
|| c >= 'A' && c <= 'Z'
|| c >= 'a' && c <= 'z'
|| c == '+'
|| c == '-'
|| c == '.'
|| c == '_';
}
fn log_file_from_world_dir(world_dir: &PathBuf) -> PathBuf {
let panic_invalid_dir = || {
panic!(
"Expected world_dir to be in .minecraft/saves, but was: {}",
world_dir.display()
)
};
let minecraft_dir = world_dir
.parent()
.unwrap_or_else(panic_invalid_dir)
.parent()
.unwrap_or_else(panic_invalid_dir);
minecraft_dir.join("logs/latest.log")
}
macro_rules! extract_datapack_file {
($output_path:expr, $relative_path:expr) => {{
let path = $output_path.join($relative_path);
let contents = include_datapack_template!($relative_path);
write(&path, &contents)
}};
}
pub struct MinecraftConnection {
identifier: String,
structures_dir: PathBuf,
datapack_dir: PathBuf,
log_file: PathBuf,
log_observer: Option<LogObserver>,
loaded_listener_initialized: bool,
enable_logging_automatically: bool,
_private: (),
}
const NAMESPACE: &str = "minect";
impl MinecraftConnection {
pub fn builder(
identifier: impl Into<String>,
world_dir: impl Into<PathBuf>,
) -> MinecraftConnectionBuilder {
MinecraftConnectionBuilder::new(identifier, world_dir)
}
fn new(
identifier: String,
world_dir: PathBuf,
log_file: PathBuf,
enable_logging_automatically: bool,
) -> MinecraftConnection {
MinecraftConnection {
structures_dir: world_dir
.join("generated")
.join(NAMESPACE)
.join("structures")
.join(&identifier),
datapack_dir: world_dir.join("datapacks").join(NAMESPACE),
identifier,
log_file,
log_observer: None,
loaded_listener_initialized: false,
enable_logging_automatically,
_private: (),
}
}
pub fn get_identifier(&self) -> &str {
&self.identifier
}
pub fn get_datapack_dir(&self) -> &Path {
&self.datapack_dir
}
pub async fn connect(&mut self) -> Result<(), ConnectError> {
connect(self).await
}
pub fn create_datapack(&self) -> Result<(), IoErrorAtPath> {
macro_rules! extract {
($relative_path:expr) => {
extract_datapack_file!(self.datapack_dir, $relative_path)
};
}
extract!("data/minecraft/tags/functions/load.json")?;
extract!("data/minecraft/tags/functions/tick.json")?;
extract!("data/minect_internal/functions/clean_up.mcfunction")?;
extract!("data/minect_internal/functions/connect/align_to_chunk.mcfunction")?;
extract!("data/minect_internal/functions/connect/remove_connector.mcfunction")?;
extract!("data/minect_internal/functions/cursor/clean_up.mcfunction")?;
extract!("data/minect_internal/functions/cursor/initialize.mcfunction")?;
extract!("data/minect_internal/functions/cursor/move_and_place_ahead.mcfunction")?;
extract!("data/minect_internal/functions/cursor/move.mcfunction")?;
extract!("data/minect_internal/functions/cursor/place_ahead.mcfunction")?;
extract!("data/minect_internal/functions/cursor/place.mcfunction")?;
extract!("data/minect_internal/functions/cursor/try_place_facing_east.mcfunction")?;
extract!("data/minect_internal/functions/cursor/try_place_facing_north.mcfunction")?;
extract!("data/minect_internal/functions/cursor/try_place_facing_south.mcfunction")?;
extract!("data/minect_internal/functions/cursor/try_place_facing_west.mcfunction")?;
extract!("data/minect_internal/functions/cursor/try_place_facing_z.mcfunction")?;
extract!("data/minect_internal/functions/enable_logging_initially.mcfunction")?;
extract!("data/minect_internal/functions/load.mcfunction")?;
extract!("data/minect_internal/functions/pulse_redstone.mcfunction")?;
extract!("data/minect_internal/functions/reload.mcfunction")?;
extract!("data/minect_internal/functions/reset_logging_finally.mcfunction")?;
extract!("data/minect_internal/functions/tick.mcfunction")?;
extract!("data/minect_internal/functions/update.mcfunction")?;
extract!("data/minect_internal/functions/v1_uninstall.mcfunction")?;
extract!("data/minect_internal/functions/v2_migrate.mcfunction")?;
extract!("data/minect_internal/functions/v2_uninstall.mcfunction")?;
extract!("data/minect_internal/functions/v3_install.mcfunction")?;
extract!("data/minect_internal/functions/v3_uninstall.mcfunction")?;
extract!("data/minect_internal/tags/blocks/command_blocks.json")?;
extract!("data/minect/functions/connect/choose_chunk.mcfunction")?;
extract!("data/minect/functions/disconnect_self.mcfunction")?;
extract!("data/minect/functions/disconnect.mcfunction")?;
extract!("data/minect/functions/enable_logging.mcfunction")?;
extract!("data/minect/functions/prepare_logged_block.mcfunction")?;
extract!("data/minect/functions/reset_logging.mcfunction")?;
extract!("data/minect/functions/uninstall_completely.mcfunction")?;
extract!("data/minect/functions/uninstall.mcfunction")?;
extract!("pack.mcmeta")?;
Ok(())
}
pub fn remove_datapack(&self) -> Result<(), IoErrorAtPath> {
remove_dir_all(&self.datapack_dir)
}
pub fn execute_commands(
&mut self,
commands: impl IntoIterator<IntoIter = impl ExactSizeIterator<Item = Command>>,
) -> Result<(), ExecuteCommandsError> {
if !self.datapack_dir.is_dir() {
self.create_datapack()?;
}
if !self.loaded_listener_initialized {
self.init_loaded_listener();
}
create_dir_all(&self.structures_dir)?;
let id_path = self.structures_dir.join("id.txt");
let mut id_file = lock_file(&id_path)?;
let id = read_incremented_id(&mut id_file, &id_path)?;
let next_id = id.wrapping_add(1);
let (commands, commands_len) = add_implicit_commands(
commands,
&self.identifier,
id,
self.enable_logging_automatically,
);
let structure = generate_structure(&self.identifier, next_id, commands, commands_len);
let tmp_path = self.get_structure_file("tmp");
create_structure_file(&tmp_path, structure)?;
rename(tmp_path, self.get_structure_file(id))?;
write_id(&mut id_file, id_path, id)?;
Ok(())
}
fn get_structure_file(&self, id: impl Display) -> PathBuf {
self.structures_dir.join(format!("{}.nbt", id))
}
pub fn add_listener(&mut self) -> impl Stream<Item = LogEvent> {
self.get_log_observer().add_listener()
}
pub fn add_named_listener(&mut self, name: impl Into<String>) -> impl Stream<Item = LogEvent> {
self.get_log_observer().add_named_listener(name)
}
fn init_loaded_listener(&mut self) {
let structures_dir = self.structures_dir.clone();
let listener = LoadedListener { structures_dir };
self.get_log_observer().add_loaded_listener(listener);
self.loaded_listener_initialized = true;
}
fn get_log_observer(&mut self) -> &mut LogObserver {
if self.log_observer.is_none() {
self.log_observer = Some(LogObserver::new(&self.log_file));
}
self.log_observer.as_mut().unwrap() }
}
fn lock_file(path: impl AsRef<Path>) -> Result<File, IoErrorAtPath> {
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(&path)
.map_err(io_error("Failed to open file", path.as_ref()))?;
file.lock_exclusive()
.map_err(io_error("Failed to lock file", path.as_ref()))?;
Ok(file)
}
fn read_incremented_id(file: &mut File, path: impl AsRef<Path>) -> Result<u64, IoErrorAtPath> {
let mut content = String::new();
file.read_to_string(&mut content)
.map_err(io_error("Failed to read file", path.as_ref()))?;
let id = if content.is_empty() {
0
} else {
content
.parse::<u64>()
.map_err(io_invalid_data)
.map_err(io_error(
"Failed to parse content as u64 of file",
path.as_ref(),
))?
.wrapping_add(1)
};
Ok(id)
}
fn write_id(file: &mut File, path: impl AsRef<Path>, id: u64) -> Result<(), IoErrorAtPath> {
file.set_len(0)
.map_err(io_error("Failed to truncate file", path.as_ref()))?;
file.seek(SeekFrom::Start(0))
.map_err(io_error("Failed to seek beginning of file", path.as_ref()))?;
file.write_all(id.to_string().as_bytes())
.map_err(io_error("Failed to write to file", path.as_ref()))?;
Ok(())
}
fn create_structure_file(
path: impl AsRef<Path>,
structure: Structure,
) -> Result<(), IoErrorAtPath> {
let file = create(path)?;
let mut writer = BufWriter::new(file);
nbt::to_gzip_writer(&mut writer, &structure, None).unwrap();
Ok(())
}
#[derive(Debug)]
pub struct ExecuteCommandsError {
inner: ExecuteCommandsErrorInner,
}
#[derive(Debug)]
enum ExecuteCommandsErrorInner {
Io(IoErrorAtPath),
}
impl ExecuteCommandsError {
fn new(inner: ExecuteCommandsErrorInner) -> ExecuteCommandsError {
ExecuteCommandsError { inner }
}
}
impl From<IoErrorAtPath> for ExecuteCommandsError {
fn from(value: IoErrorAtPath) -> ExecuteCommandsError {
ExecuteCommandsError::new(ExecuteCommandsErrorInner::Io(value))
}
}
impl Display for ExecuteCommandsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.inner {
ExecuteCommandsErrorInner::Io(error) => error.fmt(f),
}
}
}
impl std::error::Error for ExecuteCommandsError {}
impl From<ExecuteCommandsError> for std::io::Error {
fn from(value: ExecuteCommandsError) -> std::io::Error {
match value.inner {
ExecuteCommandsErrorInner::Io(error) => std::io::Error::from(error),
}
}
}
pub struct Command {
name: Option<String>,
command: String,
}
impl Command {
pub fn new(command: impl Into<String>) -> Command {
Command {
name: None,
command: command.into(),
}
}
pub fn named(name: impl Into<String>, command: impl Into<String>) -> Command {
Command {
name: Some(name.into()),
command: command.into(),
}
}
pub fn get_name(&self) -> Option<&str> {
self.name.as_ref().map(|it| it.as_str())
}
pub fn get_command(&self) -> &str {
&self.command
}
fn get_name_as_json(&self) -> Option<String> {
self.get_name().map(create_json_text_component)
}
}
struct LoadedListener {
structures_dir: PathBuf,
}
impl LoadedListener {
fn on_event(&self, event: LogEvent) {
if let Some(id) = parse_loaded_output(&event) {
let structure_file = self.get_structure_file(id);
if let Err(error) = remove_file(&structure_file) {
error!("{}", error);
}
let mut i = 1;
while let Ok(()) = remove_file(self.get_structure_file(id.wrapping_sub(i))) {
i += 1;
}
}
}
fn get_structure_file(&self, id: impl Display) -> PathBuf {
self.structures_dir.join(format!("{}.nbt", id))
}
}
const LOADED_LISTENER_NAME: &str = "minect_loaded";
const STRUCTURE_LOADED_OUTPUT_PREFIX: &str = "minect_loaded_";
fn parse_loaded_output(event: &LogEvent) -> Option<u64> {
if event.executor != LOADED_LISTENER_NAME {
return None;
}
let output = event.output.parse::<SummonNamedEntityOutput>().ok()?;
let id = &output.name.strip_prefix(STRUCTURE_LOADED_OUTPUT_PREFIX)?;
id.parse().ok()
}
fn add_implicit_commands(
commands: impl IntoIterator<IntoIter = impl ExactSizeIterator<Item = Command>>,
connection_id: &str,
structure_id: u64,
enable_logging_automatically: bool,
) -> (impl Iterator<Item = Command>, usize) {
let mut first_cmds = Vec::from_iter([
Command::new(format!(
"tag @e[type=area_effect_cloud,tag=minect_connection,tag=!minect_connection+{}] add minect_inactive",
connection_id
)),
Command::new(enable_logging_command()),
Command::named(
LOADED_LISTENER_NAME,
summon_named_entity_command(&format!(
"{}{}",
STRUCTURE_LOADED_OUTPUT_PREFIX, structure_id
)),
),
]);
let mut last_cmds = Vec::new();
if !enable_logging_automatically {
first_cmds.push(Command::new(reset_logging_command()));
last_cmds.push(Command::new(enable_logging_command()));
}
last_cmds.push(Command::new("function minect_internal:clean_up"));
let commands = commands.into_iter();
let commands_len = first_cmds.len() + commands.len();
let commands = first_cmds.into_iter().chain(commands).chain(last_cmds);
(commands, commands_len)
}