1#[macro_use]
63mod macros;
64
65pub mod command;
66mod connect;
67mod geometry3;
68mod io;
69mod json;
70pub mod log;
71mod on_drop;
72mod placement;
73mod structure;
74mod utils;
75
76pub use crate::connect::ConnectError;
77
78use crate::{
79 command::{
80 enable_logging_command, reset_logging_command, summon_named_entity_command,
81 SummonNamedEntityOutput,
82 },
83 connect::connect,
84 io::{
85 create, create_dir_all, io_error, remove_dir_all, remove_file, rename, write, IoErrorAtPath,
86 },
87 log::{LogEvent, LogObserver},
88 placement::generate_structure,
89 structure::nbt::Structure,
90 utils::io_invalid_data,
91};
92use ::log::error;
93use fs3::FileExt;
94use indexmap::IndexSet;
95use json::create_json_text_component;
96use std::{
97 fmt::Display,
98 fs::{File, OpenOptions},
99 io::{BufWriter, Read, Seek, SeekFrom, Write},
100 path::{Path, PathBuf},
101};
102use tokio_stream::Stream;
103
104pub struct MinecraftConnectionBuilder {
108 identifier: String,
109 world_dir: PathBuf,
110 log_file: Option<PathBuf>,
111 enable_logging_automatically: bool,
112}
113
114impl MinecraftConnectionBuilder {
115 fn new(
116 identifier: impl Into<String>,
117 world_dir: impl Into<PathBuf>,
118 ) -> MinecraftConnectionBuilder {
119 let identifier = identifier.into();
120 validate_identifier(&identifier);
121 MinecraftConnectionBuilder {
122 identifier,
123 world_dir: world_dir.into(),
124 log_file: None,
125 enable_logging_automatically: true,
126 }
127 }
128
129 pub fn log_file(mut self, log_file: impl Into<PathBuf>) -> MinecraftConnectionBuilder {
141 self.log_file = Some(log_file.into());
142 self
143 }
144
145 pub fn enable_logging_automatically(
154 mut self,
155 enable_logging_automatically: impl Into<bool>,
156 ) -> MinecraftConnectionBuilder {
157 self.enable_logging_automatically = enable_logging_automatically.into();
158 self
159 }
160
161 pub fn build(self) -> MinecraftConnection {
169 let world_dir = self.world_dir;
170 let log_file = self
171 .log_file
172 .unwrap_or_else(|| log_file_from_world_dir(&world_dir));
173 MinecraftConnection::new(
174 self.identifier,
175 world_dir,
176 log_file,
177 self.enable_logging_automatically,
178 )
179 }
180}
181
182fn validate_identifier(identifier: &str) {
183 let invalid_chars = identifier
184 .chars()
185 .filter(|c| !is_allowed_in_identifier(*c))
186 .collect::<IndexSet<_>>();
187 if !invalid_chars.is_empty() {
188 panic!(
189 "Invalid characters in MinecraftConnection.identifier: '{}'",
190 invalid_chars
191 .iter()
192 .fold(String::new(), |joined, c| joined + &c.to_string())
193 );
194 }
195}
196fn is_allowed_in_identifier(c: char) -> bool {
197 return c >= '0' && c <= '9'
198 || c >= 'A' && c <= 'Z'
199 || c >= 'a' && c <= 'z'
200 || c == '+'
201 || c == '-'
202 || c == '.'
203 || c == '_';
204}
205
206fn log_file_from_world_dir(world_dir: &PathBuf) -> PathBuf {
207 let panic_invalid_dir = || {
208 panic!(
209 "Expected world_dir to be in .minecraft/saves, but was: {}",
210 world_dir.display()
211 )
212 };
213 let minecraft_dir = world_dir
214 .parent()
215 .unwrap_or_else(panic_invalid_dir)
216 .parent()
217 .unwrap_or_else(panic_invalid_dir);
218 minecraft_dir.join("logs/latest.log")
219}
220
221macro_rules! extract_datapack_file {
222 ($output_path:expr, $relative_path:expr) => {{
223 let path = $output_path.join($relative_path);
224 let contents = include_datapack_template!($relative_path);
225 write(&path, &contents)
226 }};
227}
228
229pub struct MinecraftConnection {
247 identifier: String,
248 structures_dir: PathBuf,
249 datapack_dir: PathBuf,
250 log_file: PathBuf,
251 log_observer: Option<LogObserver>,
252 loaded_listener_initialized: bool,
253 enable_logging_automatically: bool,
254 _private: (),
255}
256
257const NAMESPACE: &str = "minect";
258
259impl MinecraftConnection {
260 pub fn builder(
278 identifier: impl Into<String>,
279 world_dir: impl Into<PathBuf>,
280 ) -> MinecraftConnectionBuilder {
281 MinecraftConnectionBuilder::new(identifier, world_dir)
282 }
283
284 fn new(
285 identifier: String,
286 world_dir: PathBuf,
287 log_file: PathBuf,
288 enable_logging_automatically: bool,
289 ) -> MinecraftConnection {
290 MinecraftConnection {
291 structures_dir: world_dir
292 .join("generated")
293 .join(NAMESPACE)
294 .join("structures")
295 .join(&identifier),
296 datapack_dir: world_dir.join("datapacks").join(NAMESPACE),
297 identifier,
298 log_file,
299 log_observer: None,
300 loaded_listener_initialized: false,
301 enable_logging_automatically,
302 _private: (),
303 }
304 }
305
306 pub fn get_identifier(&self) -> &str {
308 &self.identifier
309 }
310
311 pub fn get_datapack_dir(&self) -> &Path {
313 &self.datapack_dir
314 }
315
316 pub async fn connect(&mut self) -> Result<(), ConnectError> {
335 connect(self).await
336 }
337
338 pub fn create_datapack(&self) -> Result<(), IoErrorAtPath> {
340 macro_rules! extract {
341 ($relative_path:expr) => {
342 extract_datapack_file!(self.datapack_dir, $relative_path)
343 };
344 }
345
346 extract!("data/minecraft/tags/functions/load.json")?;
347 extract!("data/minecraft/tags/functions/tick.json")?;
348 extract!("data/minect_internal/functions/clean_up.mcfunction")?;
349 extract!("data/minect_internal/functions/connect/align_to_chunk.mcfunction")?;
350 extract!("data/minect_internal/functions/connect/remove_connector.mcfunction")?;
351 extract!("data/minect_internal/functions/cursor/clean_up.mcfunction")?;
352 extract!("data/minect_internal/functions/cursor/initialize.mcfunction")?;
353 extract!("data/minect_internal/functions/cursor/move_and_place_ahead.mcfunction")?;
354 extract!("data/minect_internal/functions/cursor/move.mcfunction")?;
355 extract!("data/minect_internal/functions/cursor/place_ahead.mcfunction")?;
356 extract!("data/minect_internal/functions/cursor/place.mcfunction")?;
357 extract!("data/minect_internal/functions/cursor/try_place_facing_east.mcfunction")?;
358 extract!("data/minect_internal/functions/cursor/try_place_facing_north.mcfunction")?;
359 extract!("data/minect_internal/functions/cursor/try_place_facing_south.mcfunction")?;
360 extract!("data/minect_internal/functions/cursor/try_place_facing_west.mcfunction")?;
361 extract!("data/minect_internal/functions/cursor/try_place_facing_z.mcfunction")?;
362 extract!("data/minect_internal/functions/enable_logging_initially.mcfunction")?;
363 extract!("data/minect_internal/functions/load.mcfunction")?;
364 extract!("data/minect_internal/functions/pulse_redstone.mcfunction")?;
365 extract!("data/minect_internal/functions/reload.mcfunction")?;
366 extract!("data/minect_internal/functions/reset_logging_finally.mcfunction")?;
367 extract!("data/minect_internal/functions/tick.mcfunction")?;
368 extract!("data/minect_internal/functions/update.mcfunction")?;
369 extract!("data/minect_internal/functions/v1_uninstall.mcfunction")?;
370 extract!("data/minect_internal/functions/v2_migrate.mcfunction")?;
371 extract!("data/minect_internal/functions/v2_uninstall.mcfunction")?;
372 extract!("data/minect_internal/functions/v3_install.mcfunction")?;
373 extract!("data/minect_internal/functions/v3_uninstall.mcfunction")?;
374 extract!("data/minect_internal/tags/blocks/command_blocks.json")?;
375 extract!("data/minect/functions/connect/choose_chunk.mcfunction")?;
376 extract!("data/minect/functions/disconnect_self.mcfunction")?;
377 extract!("data/minect/functions/disconnect.mcfunction")?;
378 extract!("data/minect/functions/enable_logging.mcfunction")?;
379 extract!("data/minect/functions/prepare_logged_block.mcfunction")?;
380 extract!("data/minect/functions/reset_logging.mcfunction")?;
381 extract!("data/minect/functions/uninstall_completely.mcfunction")?;
382 extract!("data/minect/functions/uninstall.mcfunction")?;
383 extract!("pack.mcmeta")?;
384 Ok(())
385 }
386
387 pub fn remove_datapack(&self) -> Result<(), IoErrorAtPath> {
389 remove_dir_all(&self.datapack_dir)
390 }
391
392 pub fn execute_commands(
398 &mut self,
399 commands: impl IntoIterator<IntoIter = impl ExactSizeIterator<Item = Command>>,
400 ) -> Result<(), ExecuteCommandsError> {
401 if !self.datapack_dir.is_dir() {
402 self.create_datapack()?;
403 }
404 if !self.loaded_listener_initialized {
405 self.init_loaded_listener();
406 }
407 create_dir_all(&self.structures_dir)?;
408
409 let id_path = self.structures_dir.join("id.txt");
410 let mut id_file = lock_file(&id_path)?; let id = read_incremented_id(&mut id_file, &id_path)?;
413 let next_id = id.wrapping_add(1);
414
415 let (commands, commands_len) = add_implicit_commands(
416 commands,
417 &self.identifier,
418 id,
419 self.enable_logging_automatically,
420 );
421 let structure = generate_structure(&self.identifier, next_id, commands, commands_len);
422
423 let tmp_path = self.get_structure_file("tmp");
429 create_structure_file(&tmp_path, structure)?;
430 rename(tmp_path, self.get_structure_file(id))?;
431
432 write_id(&mut id_file, id_path, id)?;
434
435 Ok(())
436 }
437
438 fn get_structure_file(&self, id: impl Display) -> PathBuf {
439 self.structures_dir.join(format!("{}.nbt", id))
440 }
441
442 pub fn add_listener(&mut self) -> impl Stream<Item = LogEvent> {
447 self.get_log_observer().add_listener()
448 }
449
450 pub fn add_named_listener(&mut self, name: impl Into<String>) -> impl Stream<Item = LogEvent> {
460 self.get_log_observer().add_named_listener(name)
461 }
462
463 fn init_loaded_listener(&mut self) {
464 let structures_dir = self.structures_dir.clone();
465 let listener = LoadedListener { structures_dir };
466 self.get_log_observer().add_loaded_listener(listener);
467 self.loaded_listener_initialized = true;
468 }
469
470 fn get_log_observer(&mut self) -> &mut LogObserver {
471 if self.log_observer.is_none() {
472 self.log_observer = Some(LogObserver::new(&self.log_file));
474 }
475 self.log_observer.as_mut().unwrap() }
477}
478
479fn lock_file(path: impl AsRef<Path>) -> Result<File, IoErrorAtPath> {
480 let file = OpenOptions::new()
481 .create(true)
482 .read(true)
483 .write(true)
484 .open(&path)
485 .map_err(io_error("Failed to open file", path.as_ref()))?;
486 file.lock_exclusive()
487 .map_err(io_error("Failed to lock file", path.as_ref()))?;
488 Ok(file)
489}
490
491fn read_incremented_id(file: &mut File, path: impl AsRef<Path>) -> Result<u64, IoErrorAtPath> {
492 let mut content = String::new();
493 file.read_to_string(&mut content)
494 .map_err(io_error("Failed to read file", path.as_ref()))?;
495 let id = if content.is_empty() {
496 0
497 } else {
498 content
499 .parse::<u64>()
500 .map_err(io_invalid_data)
501 .map_err(io_error(
502 "Failed to parse content as u64 of file",
503 path.as_ref(),
504 ))?
505 .wrapping_add(1)
506 };
507 Ok(id)
508}
509
510fn write_id(file: &mut File, path: impl AsRef<Path>, id: u64) -> Result<(), IoErrorAtPath> {
511 file.set_len(0)
512 .map_err(io_error("Failed to truncate file", path.as_ref()))?;
513 file.seek(SeekFrom::Start(0))
514 .map_err(io_error("Failed to seek beginning of file", path.as_ref()))?;
515 file.write_all(id.to_string().as_bytes())
516 .map_err(io_error("Failed to write to file", path.as_ref()))?;
517 Ok(())
518}
519
520fn create_structure_file(
521 path: impl AsRef<Path>,
522 structure: Structure,
523) -> Result<(), IoErrorAtPath> {
524 let file = create(path)?;
525 let mut writer = BufWriter::new(file);
526 nbt::to_gzip_writer(&mut writer, &structure, None).unwrap();
527 Ok(())
528}
529
530#[derive(Debug)]
532pub struct ExecuteCommandsError {
533 inner: ExecuteCommandsErrorInner,
534}
535#[derive(Debug)]
536enum ExecuteCommandsErrorInner {
537 Io(IoErrorAtPath),
538 }
540impl ExecuteCommandsError {
541 fn new(inner: ExecuteCommandsErrorInner) -> ExecuteCommandsError {
542 ExecuteCommandsError { inner }
543 }
544}
545impl From<IoErrorAtPath> for ExecuteCommandsError {
546 fn from(value: IoErrorAtPath) -> ExecuteCommandsError {
547 ExecuteCommandsError::new(ExecuteCommandsErrorInner::Io(value))
548 }
549}
550impl Display for ExecuteCommandsError {
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 match &self.inner {
553 ExecuteCommandsErrorInner::Io(error) => error.fmt(f),
554 }
555 }
556}
557impl std::error::Error for ExecuteCommandsError {}
558impl From<ExecuteCommandsError> for std::io::Error {
559 fn from(value: ExecuteCommandsError) -> std::io::Error {
560 match value.inner {
561 ExecuteCommandsErrorInner::Io(error) => std::io::Error::from(error),
562 }
563 }
564}
565
566pub struct Command {
572 name: Option<String>,
573 command: String,
574}
575impl Command {
576 pub fn new(command: impl Into<String>) -> Command {
579 Command {
580 name: None,
581 command: command.into(),
582 }
583 }
584
585 pub fn named(name: impl Into<String>, command: impl Into<String>) -> Command {
587 Command {
588 name: Some(name.into()),
589 command: command.into(),
590 }
591 }
592
593 pub fn get_name(&self) -> Option<&str> {
595 self.name.as_ref().map(|it| it.as_str())
596 }
597
598 pub fn get_command(&self) -> &str {
600 &self.command
601 }
602
603 fn get_name_as_json(&self) -> Option<String> {
604 self.get_name().map(create_json_text_component)
605 }
606}
607
608struct LoadedListener {
609 structures_dir: PathBuf,
610}
611impl LoadedListener {
612 fn on_event(&self, event: LogEvent) {
613 if let Some(id) = parse_loaded_output(&event) {
614 let structure_file = self.get_structure_file(id);
615 if let Err(error) = remove_file(&structure_file) {
616 error!("{}", error);
617 }
618 let mut i = 1;
621 while let Ok(()) = remove_file(self.get_structure_file(id.wrapping_sub(i))) {
622 i += 1;
623 }
624 }
625 }
626
627 fn get_structure_file(&self, id: impl Display) -> PathBuf {
628 self.structures_dir.join(format!("{}.nbt", id))
629 }
630}
631
632const LOADED_LISTENER_NAME: &str = "minect_loaded";
633const STRUCTURE_LOADED_OUTPUT_PREFIX: &str = "minect_loaded_";
634
635fn parse_loaded_output(event: &LogEvent) -> Option<u64> {
636 if event.executor != LOADED_LISTENER_NAME {
637 return None;
638 }
639 let output = event.output.parse::<SummonNamedEntityOutput>().ok()?;
640 let id = &output.name.strip_prefix(STRUCTURE_LOADED_OUTPUT_PREFIX)?;
641 id.parse().ok()
642}
643
644fn add_implicit_commands(
645 commands: impl IntoIterator<IntoIter = impl ExactSizeIterator<Item = Command>>,
646 connection_id: &str,
647 structure_id: u64,
648 enable_logging_automatically: bool,
649) -> (impl Iterator<Item = Command>, usize) {
650 let mut first_cmds = Vec::from_iter([
651 Command::new(format!(
652 "tag @e[type=area_effect_cloud,tag=minect_connection,tag=!minect_connection+{}] add minect_inactive",
653 connection_id
654 )),
655 Command::new(enable_logging_command()),
656 Command::named(
657 LOADED_LISTENER_NAME,
658 summon_named_entity_command(&format!(
659 "{}{}",
660 STRUCTURE_LOADED_OUTPUT_PREFIX, structure_id
661 )),
662 ),
663 ]);
664 let mut last_cmds = Vec::new();
665 if !enable_logging_automatically {
666 first_cmds.push(Command::new(reset_logging_command()));
667 last_cmds.push(Command::new(enable_logging_command()));
668 }
669 last_cmds.push(Command::new("function minect_internal:clean_up"));
670
671 let commands = commands.into_iter();
672 let commands_len = first_cmds.len() + commands.len();
673 let commands = first_cmds.into_iter().chain(commands).chain(last_cmds);
674 (commands, commands_len)
675}