From 136d73f6f81e1d8e599805044685d9c6750f3555 Mon Sep 17 00:00:00 2001 From: coco875 Date: Tue, 20 Jan 2026 02:49:27 +0100 Subject: [PATCH] interactive mode + recorder + some refactor --- src/bot.rs | 30 +- src/executor.rs | 800 -------------------------- src/executor/actions.rs | 237 ++++++++ src/executor/block.rs | 176 ++++++ src/executor/handlers.rs | 419 ++++++++++++++ src/executor/mod.rs | 488 ++++++++++++++++ src/executor/recorder/actions.rs | 16 + src/executor/recorder/bounding_box.rs | 44 ++ src/executor/recorder/mod.rs | 10 + src/executor/recorder/state.rs | 309 ++++++++++ src/executor/recorder/tests.rs | 28 + src/executor/tick.rs | 218 +++++++ src/main.rs | 35 +- 13 files changed, 1993 insertions(+), 817 deletions(-) delete mode 100644 src/executor.rs create mode 100644 src/executor/actions.rs create mode 100644 src/executor/block.rs create mode 100644 src/executor/handlers.rs create mode 100644 src/executor/mod.rs create mode 100644 src/executor/recorder/actions.rs create mode 100644 src/executor/recorder/bounding_box.rs create mode 100644 src/executor/recorder/mod.rs create mode 100644 src/executor/recorder/state.rs create mode 100644 src/executor/recorder/tests.rs create mode 100644 src/executor/tick.rs diff --git a/src/bot.rs b/src/bot.rs index 91e52ae..a80f824 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -15,7 +15,7 @@ const WORLD_SYNC_DELAY_MS: u64 = 500; struct State { client_handle: Arc>>, in_game: Arc, - chat_tx: Option>, + chat_tx: Option, String)>>, } impl Default for State { @@ -32,7 +32,7 @@ impl Default for State { pub struct TestBot { client: Option>>>, in_game: Option>, - chat_rx: Option>, + chat_rx: Option, String)>>, } impl TestBot { @@ -85,10 +85,18 @@ impl TestBot { tracing::info!("Bot in game state"); } Event::Chat(m) => { - // Extract the message content and send it through the channel + // Extract the message content let message = m.message().to_string(); + // Try to get sender name (best effort) + // Fallback: parse "" + let sender = if message.starts_with('<') { + if let Some(end) = message.find('>') { + Some(message[1..end].to_string()) + } else { None } + } else { None }; + if let Some(ref tx) = state.chat_tx { - let _ = tx.send(message); + let _ = tx.send((sender, message)); } } _ => {} @@ -145,7 +153,7 @@ impl TestBot { } /// Wait for a chat message with timeout - pub async fn recv_chat_timeout(&mut self, timeout: std::time::Duration) -> Option { + pub async fn recv_chat_timeout(&mut self, timeout: std::time::Duration) -> Option<(Option, String)> { if let Some(ref mut rx) = self.chat_rx { tokio::time::timeout(timeout, rx.recv()) .await @@ -192,4 +200,16 @@ impl TestBot { Ok(None) } } + + /// Get the bot's current position + pub fn get_position(&self) -> Result<[i32; 3]> { + let client_guard = self.get_client()?; + let client = client_guard + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Bot not initialized"))?; + + let pos = client.position(); + Ok([pos.x as i32, pos.y as i32, pos.z as i32]) + } } + diff --git a/src/executor.rs b/src/executor.rs deleted file mode 100644 index 638f404..0000000 --- a/src/executor.rs +++ /dev/null @@ -1,800 +0,0 @@ -use crate::bot::TestBot; -use anyhow::Result; -use colored::Colorize; -use flint_core::results::TestResult; -use flint_core::test_spec::{ActionType, TestSpec, TimelineEntry}; -use flint_core::timeline::TimelineAggregate; -use std::io::{self, Write}; - -// Constants for timing and retries -const CHAT_DRAIN_TIMEOUT_MS: u64 = 10; -const CHAT_POLL_TIMEOUT_MS: u64 = 100; -const COMMAND_DELAY_MS: u64 = 100; -const CLEANUP_DELAY_MS: u64 = 200; -const BLOCK_POLL_ATTEMPTS: u32 = 10; -const BLOCK_POLL_DELAY_MS: u64 = 50; -const PLACE_EACH_DELAY_MS: u64 = 10; -const GAMETIME_QUERY_TIMEOUT_SECS: u64 = 5; -const TICK_STEP_TIMEOUT_SECS: u64 = 5; -const TICK_STEP_POLL_MS: u64 = 50; -const TEST_RESULT_DELAY_MS: u64 = 50; -const SPRINT_TIMEOUT_SECS: u64 = 30; -const MIN_RETRY_DELAY_MS: u64 = 200; - -pub struct TestExecutor { - bot: TestBot, - use_chat_control: bool, - action_delay_ms: u64, -} - -impl Default for TestExecutor { - fn default() -> Self { - Self { - bot: TestBot::new(), - use_chat_control: false, - action_delay_ms: COMMAND_DELAY_MS, - } - } -} - -impl TestExecutor { - pub fn new() -> Self { - Self::default() - } - - pub fn set_chat_control(&mut self, enabled: bool) { - self.use_chat_control = enabled; - } - - pub fn set_action_delay(&mut self, delay_ms: u64) { - self.action_delay_ms = delay_ms; - } - - fn apply_offset(&self, pos: [i32; 3], offset: [i32; 3]) -> [i32; 3] { - [pos[0] + offset[0], pos[1] + offset[1], pos[2] + offset[2]] - } - - /// Drain old chat messages - async fn drain_chat_messages(&mut self) { - while self - .bot - .recv_chat_timeout(std::time::Duration::from_millis(CHAT_DRAIN_TIMEOUT_MS)) - .await - .is_some() - { - // Discard old messages - } - } - - /// Normalize block name for comparison (remove minecraft: prefix and underscores) - fn normalize_block_name(name: &str) -> String { - name.trim_start_matches("minecraft:") - .to_lowercase() - .replace("_", "") - } - - /// Check if actual block matches expected block name - fn block_matches(actual: &str, expected: &str) -> bool { - let actual_lower = actual.to_lowercase(); - let expected_normalized = Self::normalize_block_name(expected); - actual_lower.contains(&expected_normalized) - || actual_lower.replace("_", "").contains(&expected_normalized) - } - - /// Returns true to continue, false to step to next tick only - async fn wait_for_step(&mut self, reason: &str) -> Result { - println!( - "\n{} {} {}", - "⏸".yellow().bold(), - "BREAKPOINT:".yellow().bold(), - reason - ); - - if self.use_chat_control { - println!( - " Waiting for in-game chat command: {} = step, {} = continue", - "s".cyan().bold(), - "c".cyan().bold() - ); - - // Send chat message to inform player - self.bot - .send_command("say Waiting for step/continue (s = step, c = continue)") - .await?; - - // First, drain any old messages from the chat queue - self.drain_chat_messages().await; - - // Now wait for a fresh chat command - loop { - if let Some(message) = self - .bot - .recv_chat_timeout(std::time::Duration::from_millis(CHAT_POLL_TIMEOUT_MS)) - .await - { - // Skip messages from the bot itself (contains "Waiting for step/continue") - if message.contains("Waiting for step/continue") { - continue; - } - - // Look for commands in the message - match exact commands only - let msg_lower = message.to_lowercase(); - let trimmed = msg_lower.trim(); - - // Match the message ending with just "s" or "c" (player commands) - if trimmed.ends_with(" s") - || trimmed == "s" - || trimmed.ends_with(" step") - || trimmed == "step" - { - println!(" {} Received 's' from chat", "→".blue()); - return Ok(false); // Step mode - } else if trimmed.ends_with(" c") - || trimmed == "c" - || trimmed.ends_with(" continue") - || trimmed == "continue" - { - println!(" {} Received 'c' from chat", "→".blue()); - return Ok(true); // Continue mode - } - } - } - } else { - println!( - " Commands: {} = step one tick, {} = continue to next breakpoint", - "s".cyan().bold(), - "c".cyan().bold() - ); - print!(" > "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let cmd = input.trim().to_lowercase(); - - match cmd.as_str() { - "s" | "step" => Ok(false), // Step mode: only advance one tick - _ => Ok(true), // Continue mode (default for Enter or "c") - } - } - } - - /// Poll for a block at the given position with retries - /// This handles timing issues in CI environments where block updates may take longer - async fn poll_block_with_retry( - &self, - world_pos: [i32; 3], - expected_block: &str, - ) -> Result> { - for attempt in 0..BLOCK_POLL_ATTEMPTS { - let block = self.bot.get_block(world_pos).await?; - - // Check if the block matches what we expect - if let Some(ref actual) = block - && Self::block_matches(actual, expected_block) - { - return Ok(block); - } - - // If not the last attempt, wait before retrying - if attempt < BLOCK_POLL_ATTEMPTS - 1 { - tokio::time::sleep(tokio::time::Duration::from_millis(BLOCK_POLL_DELAY_MS)).await; - } - } - - // Return whatever we have after all retries - self.bot.get_block(world_pos).await - } - - pub async fn connect(&mut self, server: &str) -> Result<()> { - self.bot.connect(server).await - } - - /// Query the current game time from the server - /// Returns the game time in ticks - async fn query_gametime(&mut self) -> Result { - // Clear any pending chat messages - self.drain_chat_messages().await; - - // Send the time query command - self.bot.send_command("time query gametime").await?; - - // Wait for response: "The time is " - let timeout = std::time::Duration::from_secs(GAMETIME_QUERY_TIMEOUT_SECS); - let start = std::time::Instant::now(); - - while start.elapsed() < timeout { - if let Some(message) = self - .bot - .recv_chat_timeout(std::time::Duration::from_millis(CHAT_POLL_TIMEOUT_MS)) - .await - { - // Look for "The time is" message - if message.contains("The time is") { - // Extract the time value - if let Some(time_str) = message.split("The time is ").nth(1) { - // Parse the number (might have formatting) - let time_clean = time_str - .chars() - .filter(|c| c.is_ascii_digit()) - .collect::(); - if let Ok(time) = time_clean.parse::() { - return Ok(time); - } - } - } - } - } - - anyhow::bail!("Failed to query game time: timeout waiting for response") - } - - /// Step a single tick using /tick step and verify completion - /// Returns the time taken - async fn step_tick(&mut self) -> Result { - let before = self.query_gametime().await?; - - let start = std::time::Instant::now(); - self.bot.send_command("tick step").await?; - - // Wait for the tick to actually complete by polling gametime - let timeout = std::time::Duration::from_secs(TICK_STEP_TIMEOUT_SECS); - let poll_start = std::time::Instant::now(); - - loop { - tokio::time::sleep(std::time::Duration::from_millis(TICK_STEP_POLL_MS)).await; - let after = self.query_gametime().await?; - - if after > before { - let elapsed = start.elapsed().as_millis() as u64; - println!( - " {} Stepped 1 tick (verified: {} -> {}) in {} ms", - "→".dimmed(), - before, - after, - elapsed - ); - return Ok(elapsed); - } - - if poll_start.elapsed() >= timeout { - anyhow::bail!("Tick step verification timeout: game time did not advance"); - } - } - } - - /// Sprint ticks and capture the time taken from server output - /// Returns the ms per tick from the server's sprint completion message - /// NOTE: Accounts for Minecraft's off-by-one bug where "tick sprint N" executes N+1 ticks - async fn sprint_ticks(&mut self, ticks: u32) -> Result { - // Clear any pending chat messages - self.drain_chat_messages().await; - - // Account for Minecraft's off-by-one bug: "tick sprint N" executes N+1 ticks - // So to execute `ticks` ticks, we request ticks-1 - let ticks_to_request = ticks - 1; - - // Send the sprint command - self.bot - .send_command(&format!("tick sprint {}", ticks_to_request)) - .await?; - - // Wait for the "Sprint completed" message - // Server message format: "Sprint completed with X ticks per second, or Y ms per tick" - let timeout = std::time::Duration::from_secs(SPRINT_TIMEOUT_SECS); - let start = std::time::Instant::now(); - - while start.elapsed() < timeout { - if let Some(message) = self - .bot - .recv_chat_timeout(std::time::Duration::from_millis(CHAT_POLL_TIMEOUT_MS)) - .await - { - // Look for "Sprint completed" message - if message.contains("Sprint completed") { - // Try to extract ms per tick - // Format: "... or X ms per tick" - if let Some(ms_part) = message.split("or ").nth(1) - && let Some(ms_str) = ms_part.split(" ms per tick").next() - && let Ok(ms) = ms_str.trim().parse::() - { - let ms_rounded = ms.ceil() as u64; - println!( - " {} Sprint {} ticks completed in {} ms per tick", - "⚡".dimmed(), - ticks, - ms_rounded - ); - // Return total time: ms per tick * number of ticks - return Ok(ms_rounded * ticks as u64); - } - // If we found the message but couldn't parse, use default - println!( - " {} Sprint {} ticks completed (timing not parsed)", - "⚡".dimmed(), - ticks - ); - return Ok(MIN_RETRY_DELAY_MS); - } - } - } - - // Timeout - return default - println!( - " {} Sprint {} ticks (no completion message received)", - "⚡".dimmed(), - ticks - ); - Ok(MIN_RETRY_DELAY_MS) - } - - pub async fn run_tests_parallel( - &mut self, - tests_with_offsets: &[(TestSpec, [i32; 3])], - break_after_setup: bool, - ) -> Result> { - println!( - "{} Running {} tests in parallel\n", - "→".blue().bold(), - tests_with_offsets.len() - ); - - // Build global merged timeline using flint-core - let aggregate = TimelineAggregate::from_tests(tests_with_offsets); - - println!(" Global timeline: {} ticks", aggregate.max_tick); - println!( - " {} unique tick steps with actions", - aggregate.unique_tick_count() - ); - if !aggregate.breakpoints.is_empty() { - let mut sorted_breakpoints: Vec<_> = aggregate.breakpoints.iter().collect(); - sorted_breakpoints.sort(); - println!( - " {} breakpoints at ticks: {:?}", - aggregate.breakpoints.len(), - sorted_breakpoints - ); - } - if break_after_setup { - println!(" {} Break after setup enabled", "→".yellow()); - } - println!(); - - // Clean all test areas before starting - println!("{} Cleaning all test areas...", "→".blue()); - for (test, offset) in tests_with_offsets.iter() { - let region = test.cleanup_region(); - let world_min = self.apply_offset(region[0], *offset); - let world_max = self.apply_offset(region[1], *offset); - let cmd = format!( - "fill {} {} {} {} {} {} air", - world_min[0], world_min[1], world_min[2], world_max[0], world_max[1], world_max[2] - ); - self.bot.send_command(&cmd).await?; - } - tokio::time::sleep(tokio::time::Duration::from_millis(CLEANUP_DELAY_MS)).await; - - // Freeze time globally - self.bot.send_command("tick freeze").await?; - tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; - - // Break after setup if requested - let mut stepping_mode = false; - if break_after_setup { - let should_continue = self - .wait_for_step("After test setup (cleanup complete, time frozen)") - .await?; - stepping_mode = !should_continue; - } - - // Track results per test - let mut test_results: Vec<(usize, usize)> = vec![(0, 0); tests_with_offsets.len()]; // (passed, failed) - - // Track which tests have been cleaned up - let mut tests_cleaned: Vec = vec![false; tests_with_offsets.len()]; - - // Calculate max tick for each test - let mut test_max_ticks: Vec = vec![0; tests_with_offsets.len()]; - for (tick, entries) in &aggregate.timeline { - for (test_idx, _, _) in entries { - test_max_ticks[*test_idx] = test_max_ticks[*test_idx].max(*tick); - } - } - - // Execute merged timeline - let mut current_tick = 0; - while current_tick <= aggregate.max_tick { - if let Some(entries) = aggregate.timeline.get(¤t_tick) { - for (test_idx, entry, value_idx) in entries { - let (test, offset) = &tests_with_offsets[*test_idx]; - - match self - .execute_action(current_tick, entry, *value_idx, *offset) - .await - { - Ok(true) => { - test_results[*test_idx].0 += 1; // increment passed - } - Ok(false) => { - // Non-assertion action - } - Err(e) => { - test_results[*test_idx].1 += 1; // increment failed - println!( - " {} [{}] Tick {}: {}", - "✗".red().bold(), - test.name, - current_tick, - e.to_string().red() - ); - } - } - } - } - - // Clean up tests that have completed (current tick exceeds their max tick) - for test_idx in 0..tests_with_offsets.len() { - if !tests_cleaned[test_idx] && current_tick > test_max_ticks[test_idx] { - let (test, offset) = &tests_with_offsets[test_idx]; - println!( - "\n{} Cleaning up test [{}] (completed at tick {})...", - "→".blue(), - test.name, - test_max_ticks[test_idx] - ); - let region = test.cleanup_region(); - let world_min = self.apply_offset(region[0], *offset); - let world_max = self.apply_offset(region[1], *offset); - let cmd = format!( - "fill {} {} {} {} {} {} air", - world_min[0], - world_min[1], - world_min[2], - world_max[0], - world_max[1], - world_max[2] - ); - self.bot.send_command(&cmd).await?; - tests_cleaned[test_idx] = true; - tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; - } - } - - // Check for breakpoint at end of this tick (before stepping) - // Or if we're in stepping mode, break at every tick - if aggregate.breakpoints.contains(¤t_tick) || stepping_mode { - let should_continue = self - .wait_for_step(&format!( - "End of tick {} (before step to next tick)", - current_tick - )) - .await?; - stepping_mode = !should_continue; - } - - // Advance to next tick (step or sprint depending on mode) - if current_tick < aggregate.max_tick { - if stepping_mode { - // In stepping mode, only advance one tick at a time - self.step_tick().await?; - tokio::time::sleep(tokio::time::Duration::from_millis(CLEANUP_DELAY_MS)).await; - current_tick += 1; - } else { - // In continue mode, sprint to next event or breakpoint - // Use the aggregate's helper method to find the next event - let next_event_tick = aggregate - .next_event_tick(current_tick) - .unwrap_or(aggregate.max_tick + 1); - - // Calculate how many ticks to sprint - let ticks_to_sprint = if next_event_tick <= aggregate.max_tick { - next_event_tick - current_tick - } else { - aggregate.max_tick - current_tick - }; - - // Sprint the ticks (use step_tick for single tick, sprint_ticks for multiple) - let sprint_time_ms = if ticks_to_sprint == 1 { - self.step_tick().await? - } else if ticks_to_sprint > 1 { - self.sprint_ticks(ticks_to_sprint).await? - } else { - 0 - }; - - // Use sprint timing for retry delay (ensure minimum) - let retry_delay = sprint_time_ms.max(MIN_RETRY_DELAY_MS); - tokio::time::sleep(tokio::time::Duration::from_millis(retry_delay)).await; - - current_tick += ticks_to_sprint; - } - } else { - current_tick += 1; - } - } - - // Unfreeze time - self.bot.send_command("tick unfreeze").await?; - - // Clean up any remaining tests that haven't been cleaned yet (edge case) - for test_idx in 0..tests_with_offsets.len() { - if !tests_cleaned[test_idx] { - let (test, offset) = &tests_with_offsets[test_idx]; - println!( - "\n{} Cleaning up remaining test [{}]...", - "→".blue(), - test.name - ); - let region = test.cleanup_region(); - let world_min = self.apply_offset(region[0], *offset); - let world_max = self.apply_offset(region[1], *offset); - let cmd = format!( - "fill {} {} {} {} {} {} air", - world_min[0], - world_min[1], - world_min[2], - world_max[0], - world_max[1], - world_max[2] - ); - self.bot.send_command(&cmd).await?; - tests_cleaned[test_idx] = true; - tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; - } - } - - // Build results - let results: Vec = tests_with_offsets - .iter() - .enumerate() - .map(|(idx, (test, _))| { - let (passed, failed) = test_results[idx]; - let success = failed == 0; - - println!(); - if success { - println!( - " {} [{}] Test passed: {} assertions", - "✓".green().bold(), - test.name, - passed - ); - } else { - println!( - " {} [{}] Test failed: {} passed, {} failed", - "✗".red().bold(), - test.name, - passed, - failed - ); - } - - if success { - TestResult::new(test.name.clone()) - } else { - TestResult::new(test.name.clone()) - .with_failure_reason(format!("{} assertions failed", failed)) - } - }) - .collect(); - - // Send test results summary to chat - let total_passed = results.iter().filter(|r| r.success).count(); - let total_failed = results.len() - total_passed; - let summary = format!( - "Tests complete: {}/{} passed, {} failed", - total_passed, - results.len(), - total_failed - ); - self.bot.send_command(&format!("say {}", summary)).await?; - tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; - - // Send individual test results to chat - for result in &results { - let status = if result.success { "PASS" } else { "FAIL" }; - let msg = format!("say [{}] {}", status, result.test_name); - self.bot.send_command(&msg).await?; - tokio::time::sleep(tokio::time::Duration::from_millis(TEST_RESULT_DELAY_MS)).await; - } - - // Give messages time to be sent before potential disconnect - tokio::time::sleep(tokio::time::Duration::from_millis(CLEANUP_DELAY_MS)).await; - - Ok(results) - } - - async fn execute_action( - &mut self, - tick: u32, - entry: &TimelineEntry, - _value_idx: usize, - offset: [i32; 3], - ) -> Result { - match &entry.action_type { - ActionType::Place { pos, block } => { - let world_pos = self.apply_offset(*pos, offset); - let block_spec = block.to_command(); - let cmd = format!( - "setblock {} {} {} {}", - world_pos[0], world_pos[1], world_pos[2], block_spec - ); - self.bot.send_command(&cmd).await?; - println!( - " {} Tick {}: place at [{}, {}, {}] = {}", - "→".blue(), - tick, - pos[0], - pos[1], - pos[2], - block_spec.dimmed() - ); - tokio::time::sleep(tokio::time::Duration::from_millis(self.action_delay_ms)).await; - Ok(false) - } - - ActionType::PlaceEach { blocks } => { - for placement in blocks { - let world_pos = self.apply_offset(placement.pos, offset); - let block_spec = placement.block.to_command(); - let cmd = format!( - "setblock {} {} {} {}", - world_pos[0], world_pos[1], world_pos[2], block_spec - ); - self.bot.send_command(&cmd).await?; - println!( - " {} Tick {}: place at [{}, {}, {}] = {}", - "→".blue(), - tick, - placement.pos[0], - placement.pos[1], - placement.pos[2], - block_spec.dimmed() - ); - tokio::time::sleep(tokio::time::Duration::from_millis(PLACE_EACH_DELAY_MS)) - .await; - } - Ok(false) - } - - ActionType::Fill { region, with } => { - let world_min = self.apply_offset(region[0], offset); - let world_max = self.apply_offset(region[1], offset); - let block_spec = with.to_command(); - let cmd = format!( - "fill {} {} {} {} {} {} {}", - world_min[0], - world_min[1], - world_min[2], - world_max[0], - world_max[1], - world_max[2], - block_spec - ); - self.bot.send_command(&cmd).await?; - println!( - " {} Tick {}: fill [{},{},{}] to [{},{},{}] = {}", - "→".blue(), - tick, - region[0][0], - region[0][1], - region[0][2], - region[1][0], - region[1][1], - region[1][2], - block_spec.dimmed() - ); - tokio::time::sleep(tokio::time::Duration::from_millis(self.action_delay_ms)).await; - Ok(false) - } - - ActionType::Remove { pos } => { - let world_pos = self.apply_offset(*pos, offset); - let cmd = format!( - "setblock {} {} {} air", - world_pos[0], world_pos[1], world_pos[2] - ); - self.bot.send_command(&cmd).await?; - println!( - " {} Tick {}: remove at [{}, {}, {}]", - "→".blue(), - tick, - pos[0], - pos[1], - pos[2] - ); - tokio::time::sleep(tokio::time::Duration::from_millis(self.action_delay_ms)).await; - Ok(false) - } - - ActionType::Assert { checks } => { - for check in checks { - let world_pos = self.apply_offset(check.pos, offset); - - // Poll with retries to handle timing issues in CI environments - let actual_block = self.poll_block_with_retry(world_pos, &check.is.id).await?; - - // Check block type - let block_matches = actual_block - .as_ref() - .is_some_and(|actual| Self::block_matches(actual, &check.is.id)); - - if !block_matches { - anyhow::bail!( - "Block at [{}, {}, {}] is not {} (got {:?})", - check.pos[0], - check.pos[1], - check.pos[2], - check.is.id, - actual_block - ); - } - - // Check state properties if any are specified - if !check.is.properties.is_empty() { - let actual_str = actual_block.as_ref().unwrap(); - - for (prop_name, prop_value) in &check.is.properties { - // Convert the expected value to string for comparison - let expected_value = match prop_value { - serde_json::Value::String(s) => s.clone(), - other => other.to_string().trim_matches('"').to_string(), - }; - - // Check if the property value is in the block state string - // Block state format examples: - // - "BlockState(id: X, Water { level: _0 })" - // - "BlockState(id: X, Lever { powered: false })" - // - "BlockState(id: X, Repeater { facing: North })" - // Use lowercase for case-insensitive matching (e.g., "north" matches "North") - let actual_lower = actual_str.to_lowercase(); - let prop_pattern = format!("{}: {}", prop_name, expected_value).to_lowercase(); - let prop_pattern_quoted = format!("{}: \"{}\"", prop_name, expected_value).to_lowercase(); - // Handle numeric values with underscore prefix (e.g., level: _0) - let prop_pattern_underscore = format!("{}: _{}", prop_name, expected_value).to_lowercase(); - - let matches = actual_lower.contains(&prop_pattern) - || actual_lower.contains(&prop_pattern_quoted) - || actual_lower.contains(&prop_pattern_underscore); - - if !matches { - anyhow::bail!( - "Block at [{}, {}, {}] property '{}' is not '{}' (got {:?})", - check.pos[0], - check.pos[1], - check.pos[2], - prop_name, - expected_value, - actual_str - ); - } - - println!( - " {} Tick {}: assert block at [{}, {}, {}] state {} = {}", - "✓".green(), - tick, - check.pos[0], - check.pos[1], - check.pos[2], - prop_name.dimmed(), - expected_value.dimmed() - ); - } - } else { - println!( - " {} Tick {}: assert block at [{}, {}, {}] is {}", - "✓".green(), - tick, - check.pos[0], - check.pos[1], - check.pos[2], - check.is.id.dimmed() - ); - } - } - Ok(true) - } - } - } -} diff --git a/src/executor/actions.rs b/src/executor/actions.rs new file mode 100644 index 0000000..657bd27 --- /dev/null +++ b/src/executor/actions.rs @@ -0,0 +1,237 @@ +//! Test action execution - block placement, assertions, etc. + +use crate::bot::TestBot; +use anyhow::Result; +use colored::Colorize; +use flint_core::test_spec::{ActionType, TimelineEntry}; + +use super::block::block_matches; + +// Constants for action timing +pub const BLOCK_POLL_ATTEMPTS: u32 = 10; +pub const BLOCK_POLL_DELAY_MS: u64 = 50; +pub const PLACE_EACH_DELAY_MS: u64 = 10; + +/// Apply offset to a position +pub fn apply_offset(pos: [i32; 3], offset: [i32; 3]) -> [i32; 3] { + [pos[0] + offset[0], pos[1] + offset[1], pos[2] + offset[2]] +} + +/// Poll for a block at the given position with retries +/// This handles timing issues in CI environments where block updates may take longer +pub async fn poll_block_with_retry( + bot: &TestBot, + world_pos: [i32; 3], + expected_block: &str, +) -> Result> { + for attempt in 0..BLOCK_POLL_ATTEMPTS { + let block = bot.get_block(world_pos).await?; + + // Check if the block matches what we expect + if let Some(ref actual) = block + && block_matches(actual, expected_block) + { + return Ok(block); + } + + // If not the last attempt, wait before retrying + if attempt < BLOCK_POLL_ATTEMPTS - 1 { + tokio::time::sleep(tokio::time::Duration::from_millis(BLOCK_POLL_DELAY_MS)).await; + } + } + + // Return whatever we have after all retries + bot.get_block(world_pos).await +} + +/// Execute a single test action +/// Returns true if this was an assertion that passed, false otherwise +pub async fn execute_action( + bot: &mut TestBot, + tick: u32, + entry: &TimelineEntry, + _value_idx: usize, + offset: [i32; 3], + action_delay_ms: u64, +) -> Result { + match &entry.action_type { + ActionType::Place { pos, block } => { + let world_pos = apply_offset(*pos, offset); + let block_spec = block.to_command(); + let cmd = format!( + "setblock {} {} {} {}", + world_pos[0], world_pos[1], world_pos[2], block_spec + ); + bot.send_command(&cmd).await?; + println!( + " {} Tick {}: place at [{}, {}, {}] = {}", + "→".blue(), + tick, + pos[0], + pos[1], + pos[2], + block_spec.dimmed() + ); + tokio::time::sleep(tokio::time::Duration::from_millis(action_delay_ms)).await; + Ok(false) + } + + ActionType::PlaceEach { blocks } => { + for placement in blocks { + let world_pos = apply_offset(placement.pos, offset); + let block_spec = placement.block.to_command(); + let cmd = format!( + "setblock {} {} {} {}", + world_pos[0], world_pos[1], world_pos[2], block_spec + ); + bot.send_command(&cmd).await?; + println!( + " {} Tick {}: place at [{}, {}, {}] = {}", + "→".blue(), + tick, + placement.pos[0], + placement.pos[1], + placement.pos[2], + block_spec.dimmed() + ); + tokio::time::sleep(tokio::time::Duration::from_millis(PLACE_EACH_DELAY_MS)).await; + } + Ok(false) + } + + ActionType::Fill { region, with } => { + let world_min = apply_offset(region[0], offset); + let world_max = apply_offset(region[1], offset); + let block_spec = with.to_command(); + let cmd = format!( + "fill {} {} {} {} {} {} {}", + world_min[0], + world_min[1], + world_min[2], + world_max[0], + world_max[1], + world_max[2], + block_spec + ); + bot.send_command(&cmd).await?; + println!( + " {} Tick {}: fill [{},{},{}] to [{},{},{}] = {}", + "→".blue(), + tick, + region[0][0], + region[0][1], + region[0][2], + region[1][0], + region[1][1], + region[1][2], + block_spec.dimmed() + ); + tokio::time::sleep(tokio::time::Duration::from_millis(action_delay_ms)).await; + Ok(false) + } + + ActionType::Remove { pos } => { + let world_pos = apply_offset(*pos, offset); + let cmd = format!( + "setblock {} {} {} air", + world_pos[0], world_pos[1], world_pos[2] + ); + bot.send_command(&cmd).await?; + println!( + " {} Tick {}: remove at [{}, {}, {}]", + "→".blue(), + tick, + pos[0], + pos[1], + pos[2] + ); + tokio::time::sleep(tokio::time::Duration::from_millis(action_delay_ms)).await; + Ok(false) + } + + ActionType::Assert { checks } => { + for check in checks { + let world_pos = apply_offset(check.pos, offset); + + // Poll with retries to handle timing issues in CI environments + let actual_block = poll_block_with_retry(bot, world_pos, &check.is.id).await?; + + // Check block type + let matches = actual_block + .as_ref() + .is_some_and(|actual| block_matches(actual, &check.is.id)); + + if !matches { + anyhow::bail!( + "Block at [{}, {}, {}] is not {} (got {:?})", + check.pos[0], + check.pos[1], + check.pos[2], + check.is.id, + actual_block + ); + } + + // Check state properties if any are specified + if !check.is.properties.is_empty() { + let actual_str = actual_block.as_ref().unwrap(); + + for (prop_name, prop_value) in &check.is.properties { + // Convert the expected value to string for comparison + let expected_value = match prop_value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string().trim_matches('"').to_string(), + }; + + // Check if the property value is in the block state string + let actual_lower = actual_str.to_lowercase(); + let prop_pattern = format!("{}: {}", prop_name, expected_value).to_lowercase(); + let prop_pattern_quoted = + format!("{}: \"{}\"", prop_name, expected_value).to_lowercase(); + // Handle numeric values with underscore prefix (e.g., level: _0) + let prop_pattern_underscore = + format!("{}: _{}", prop_name, expected_value).to_lowercase(); + + let prop_matches = actual_lower.contains(&prop_pattern) + || actual_lower.contains(&prop_pattern_quoted) + || actual_lower.contains(&prop_pattern_underscore); + + if !prop_matches { + anyhow::bail!( + "Block at [{}, {}, {}] property '{}' is not '{}' (got {:?})", + check.pos[0], + check.pos[1], + check.pos[2], + prop_name, + expected_value, + actual_str + ); + } + + println!( + " {} Tick {}: assert block at [{}, {}, {}] state {} = {}", + "✓".green(), + tick, + check.pos[0], + check.pos[1], + check.pos[2], + prop_name.dimmed(), + expected_value.dimmed() + ); + } + } else { + println!( + " {} Tick {}: assert block at [{}, {}, {}] is {}", + "✓".green(), + tick, + check.pos[0], + check.pos[1], + check.pos[2], + check.is.id.dimmed() + ); + } + } + Ok(true) + } + } +} diff --git a/src/executor/block.rs b/src/executor/block.rs new file mode 100644 index 0000000..88400d0 --- /dev/null +++ b/src/executor/block.rs @@ -0,0 +1,176 @@ +//! Block-related utilities for parsing, normalization, and matching + +use flint_core::test_spec::Block; +use std::collections::HashMap; + +/// Extract block ID and properties from Azalea debug string +/// Input: "BlockState(id: 6795, OakFence { east: false, ... })" +/// Output: "minecraft:oak_fence[east=false,west=false]" +pub fn extract_block_id(debug_str: &str) -> String { + let s = debug_str.trim(); + + // 1. Extract Name and Properties part + let (name_part, props_part) = if s.starts_with("BlockState(id:") { + if let Some(comma_pos) = s.find(',') { + let after_id = s[comma_pos + 1..].trim(); // "OakFence { ... })" + // Check if it has properties + if let Some(brace_start) = after_id.find('{') { + let name = after_id[..brace_start].trim(); + let props_end = after_id.rfind('}').unwrap_or(after_id.len()); + let props = &after_id[brace_start + 1..props_end]; + (name, Some(props)) + } else { + // No properties: "Stone)" + let end = after_id.find(')').unwrap_or(after_id.len()); + (after_id[..end].trim(), None) + } + } else { + ("air", None) + } + } else if s.starts_with("BlockState") { + // Fallback for "BlockState { stone, properties: {...} }" + if let Some(inner_start) = s.find('{') { + let inner = &s[inner_start + 1..]; + let end = inner.find(|c| c == ',' || c == '}').unwrap_or(inner.len()); + (inner[..end].trim(), None) + } else { + ("air", None) + } + } else { + // Raw string? + ( + s.split(|c| c == ',' || c == '{' || c == ' ' || c == '}') + .next() + .unwrap_or(s), + None, + ) + }; + + // 2. Normalize Name (PascalCase -> snake_case) + let mut snake = String::new(); + for (i, c) in name_part.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + snake.push('_'); + } + snake.push(c.to_ascii_lowercase()); + } else { + snake.push(c); + } + } + let block_id = if snake.contains(':') { + snake + } else { + format!("minecraft:{}", snake) + }; + + // 3. Format Properties + if let Some(props_str) = props_part { + // "east: false, north: false" + let mut pairs = Vec::new(); + for part in props_str.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if let Some((k, v)) = part.split_once(':') { + pairs.push(format!("{}={}", k.trim().to_lowercase(), v.trim().to_lowercase())); + } + } + if !pairs.is_empty() { + // Sort for deterministic output + pairs.sort(); + return format!("{}[{}]", block_id, pairs.join(",")); + } + } + + block_id +} + +/// Create a Block from a block ID string (potentially with properties) +/// Input: "minecraft:oak_fence[east=true,west=false]" +pub fn make_block(block_str: &str) -> Block { + // Check for properties: "minecraft:oak_fence[east=true,west=false]" + if let Some(open_bracket) = block_str.find('[') { + if let Some(close_bracket) = block_str.find(']') { + let id = block_str[..open_bracket].to_string(); + let props_str = &block_str[open_bracket + 1..close_bracket]; + + let mut properties = HashMap::new(); + for pair in props_str.split(',') { + if let Some((k, v)) = pair.split_once('=') { + properties.insert( + k.trim().to_string(), + serde_json::Value::String(v.trim().to_string()), + ); + } + } + + return Block { id, properties }; + } + } + + Block { + id: block_str.to_string(), + properties: HashMap::new(), + } +} + +/// Normalize block name for comparison (remove minecraft: prefix and underscores) +pub fn normalize_block_name(name: &str) -> String { + name.trim_start_matches("minecraft:") + .to_lowercase() + .replace('_', "") +} + +/// Check if actual block matches expected block name +pub fn block_matches(actual: &str, expected: &str) -> bool { + let actual_lower = actual.to_lowercase(); + let expected_normalized = normalize_block_name(expected); + actual_lower.contains(&expected_normalized) + || actual_lower.replace('_', "").contains(&expected_normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_block_id_simple() { + let input = "BlockState(id: 1, Stone)"; + assert_eq!(extract_block_id(input), "minecraft:stone"); + } + + #[test] + fn test_extract_block_id_with_properties() { + let input = "BlockState(id: 6795, OakFence { east: false, north: true })"; + let result = extract_block_id(input); + assert!(result.starts_with("minecraft:oak_fence[")); + assert!(result.contains("east=false")); + assert!(result.contains("north=true")); + } + + #[test] + fn test_make_block_simple() { + let block = make_block("minecraft:stone"); + assert_eq!(block.id, "minecraft:stone"); + assert!(block.properties.is_empty()); + } + + #[test] + fn test_make_block_with_properties() { + let block = make_block("minecraft:oak_fence[east=true,west=false]"); + assert_eq!(block.id, "minecraft:oak_fence"); + assert_eq!( + block.properties.get("east"), + Some(&serde_json::Value::String("true".to_string())) + ); + } + + #[test] + fn test_block_matches() { + assert!(block_matches("OakFence", "minecraft:oak_fence")); + assert!(block_matches("minecraft:oak_fence", "oak_fence")); + assert!(!block_matches("SpruceFence", "oak_fence")); + } +} diff --git a/src/executor/handlers.rs b/src/executor/handlers.rs new file mode 100644 index 0000000..923480f --- /dev/null +++ b/src/executor/handlers.rs @@ -0,0 +1,419 @@ +//! Command handlers for interactive mode + +use anyhow::Result; +use flint_core::loader::TestLoader; +use flint_core::spatial::calculate_test_offset_default; +use flint_core::test_spec::TestSpec; + +use super::{block, recorder, TestExecutor, COMMAND_DELAY_MS, DEFAULT_TESTS_DIR, TEST_RESULT_DELAY_MS}; + +/// Parse command parts from a chat message +/// Returns (command, args) if a valid command was found +pub fn parse_command(message: &str) -> Option<(String, Vec)> { + // Skip bot's own messages + if message.contains("flintmc_testbot") || message.contains("[Server]") { + return None; + } + + let msg_lower = message.to_lowercase(); + + // Extract command from message (look for !command pattern) + let command_str = if let Some(cmd_start) = msg_lower.find('!') { + &message[cmd_start..] + } else { + return None; + }; + + let parts: Vec<&str> = command_str.trim().split_whitespace().collect(); + if parts.is_empty() { + return None; + } + + let command = parts[0].to_lowercase(); + let args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + + Some((command, args)) +} + +impl TestExecutor { + // Command handlers + + pub(super) async fn handle_help(&mut self) -> Result<()> { + self.bot.send_command("say Commands:").await?; + self.bot.send_command("say !search - Search tests by name").await?; + self.bot.send_command("say !run [step] - Run a specific test").await?; + self.bot.send_command("say !run-all - Run all tests").await?; + self.bot.send_command("say !run-tags - Run tests with tags").await?; + self.bot.send_command("say !list - List all tests").await?; + self.bot.send_command("say !reload - Reload test files").await?; + self.bot.send_command("say Recorder: !record , !tick/!next, !save, !cancel").await?; + self.bot.send_command("say Recorder actions: !assert , !assert_changes").await?; + self.bot.send_command("say !stop - Exit interactive mode").await?; + Ok(()) + } + + pub(super) async fn handle_list(&mut self, all_test_files: &[std::path::PathBuf]) -> Result<()> { + self.bot.send_command(&format!("say Found {} tests:", all_test_files.len())).await?; + for test_file in all_test_files { + if let Ok(test) = TestSpec::from_file(test_file) { + let tags = if test.tags.is_empty() { + String::new() + } else { + format!(" [{}]", test.tags.join(", ")) + }; + self.bot.send_command(&format!("say - {}{}", test.name, tags)).await?; + tokio::time::sleep(tokio::time::Duration::from_millis(TEST_RESULT_DELAY_MS)).await; + } + } + Ok(()) + } + + pub(super) async fn handle_search(&mut self, all_test_files: &[std::path::PathBuf], pattern: &str) -> Result<()> { + let pattern_lower = pattern.to_lowercase(); + let mut found = 0; + for test_file in all_test_files { + if let Ok(test) = TestSpec::from_file(test_file) { + if test.name.to_lowercase().contains(&pattern_lower) { + let tags = if test.tags.is_empty() { + String::new() + } else { + format!(" [{}]", test.tags.join(", ")) + }; + self.bot.send_command(&format!("say - {}{}", test.name, tags)).await?; + found += 1; + tokio::time::sleep(tokio::time::Duration::from_millis(TEST_RESULT_DELAY_MS)).await; + } + } + } + if found == 0 { + self.bot.send_command(&format!("say No tests matching '{}'", pattern)).await?; + } else { + self.bot.send_command(&format!("say Found {} matching tests", found)).await?; + } + Ok(()) + } + + pub(super) async fn handle_run( + &mut self, + all_test_files: &[std::path::PathBuf], + test_name: &str, + step_mode: bool, + ) -> Result<()> { + let name_lower = test_name.to_lowercase(); + + // First pass: look for exact match + let mut found_test = None; + for test_file in all_test_files { + if let Ok(test) = TestSpec::from_file(test_file) { + if test.name.to_lowercase() == name_lower { + found_test = Some(test); + break; + } + } + } + + // Second pass: fall back to partial match if no exact match + if found_test.is_none() { + for test_file in all_test_files { + if let Ok(test) = TestSpec::from_file(test_file) { + if test.name.to_lowercase().contains(&name_lower) { + found_test = Some(test); + break; + } + } + } + } + + if let Some(test) = found_test { + if step_mode { + self.bot.send_command(&format!("say Running test: {} (step mode - type 's' or 'c')", test.name)).await?; + } else { + self.bot.send_command(&format!("say Running test: {}", test.name)).await?; + } + + let offset = calculate_test_offset_default(0, 1); + let tests_with_offsets = vec![(test, offset)]; + let results = self.run_tests_parallel(&tests_with_offsets, step_mode).await?; + + for result in &results { + let status = if result.success { "PASS" } else { "FAIL" }; + self.bot.send_command(&format!("say [{}] {}", status, result.test_name)).await?; + } + } else { + self.bot.send_command(&format!("say Test '{}' not found", test_name)).await?; + } + Ok(()) + } + + pub(super) async fn handle_run_all(&mut self, all_test_files: &[std::path::PathBuf]) -> Result<()> { + self.bot.send_command(&format!("say Running all {} tests...", all_test_files.len())).await?; + + let mut tests_with_offsets = Vec::new(); + for (idx, test_file) in all_test_files.iter().enumerate() { + if let Ok(test) = TestSpec::from_file(test_file) { + let offset = calculate_test_offset_default(idx, all_test_files.len()); + tests_with_offsets.push((test, offset)); + } + } + + let results = self.run_tests_parallel(&tests_with_offsets, false).await?; + + let passed = results.iter().filter(|r| r.success).count(); + let failed = results.len() - passed; + self.bot.send_command(&format!("say Results: {} passed, {} failed", passed, failed)).await?; + Ok(()) + } + + pub(super) async fn handle_run_tags(&mut self, test_loader: &TestLoader, tags: &[String]) -> Result<()> { + let test_files = test_loader.collect_by_tags(tags)?; + + if test_files.is_empty() { + self.bot.send_command(&format!("say No tests found with tags: {:?}", tags)).await?; + return Ok(()); + } + + self.bot.send_command(&format!("say Running {} tests with tags {:?}...", test_files.len(), tags)).await?; + + let mut tests_with_offsets = Vec::new(); + for (idx, test_file) in test_files.iter().enumerate() { + if let Ok(test) = TestSpec::from_file(test_file) { + let offset = calculate_test_offset_default(idx, test_files.len()); + tests_with_offsets.push((test, offset)); + } + } + + let results = self.run_tests_parallel(&tests_with_offsets, false).await?; + + let passed = results.iter().filter(|r| r.success).count(); + let failed = results.len() - passed; + self.bot.send_command(&format!("say Results: {} passed, {} failed", passed, failed)).await?; + Ok(()) + } + + // Recorder command handlers + + pub(super) async fn handle_record_start( + &mut self, + test_name: &str, + _test_loader: &TestLoader, + player_name: Option, + ) -> Result<()> { + if self.recorder.is_some() { + self.bot.send_command("say Recording already in progress. Use !save or !cancel first.").await?; + return Ok(()); + } + + let tests_root = std::path::Path::new(DEFAULT_TESTS_DIR); + let mut recorder_state = recorder::RecorderState::new(test_name, tests_root); + // Default to @p if nothing works + recorder_state.player_name = player_name.or_else(|| Some("@p".to_string())); + + // Get bot position to set scan center + let scan_center = match self.bot.get_position() { + Ok(pos) => pos, + Err(_) => { + self.bot.send_command("say Warning: Could not get bot position, using spawn").await?; + [0, 64, 0] + } + }; + + recorder_state.set_scan_center(scan_center); + recorder_state.scan_radius = 10; // 10 block radius for scanning + + // Take initial snapshot of blocks + let initial_blocks = self.scan_blocks_around(scan_center, recorder_state.scan_radius).await?; + recorder_state.snapshot = initial_blocks; + + self.recorder = Some(recorder_state); + + // Freeze time for controlled recording + self.bot.send_command("tick freeze").await?; + tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; + + self.bot.send_command(&format!("say Recording started: {}", test_name)).await?; + self.bot.send_command("say Time frozen. Block changes will be detected automatically!").await?; + self.bot.send_command("say Commands: !assert (add check), !tick (step game tick), !save, !cancel").await?; + + Ok(()) + } + + pub(super) async fn handle_record_tick(&mut self) -> Result<()> { + // Check if recorder exists first + if self.recorder.is_none() { + self.bot.send_command("say No recording in progress. Use !record to start.").await?; + return Ok(()); + } + + let current_tick = self.recorder.as_ref().unwrap().current_tick; + + // Snapshot before advancing tick to capture all changes + self.handle_record_snapshot().await?; + + // Step the game tick + self.bot.send_command("tick step").await?; + self.delay().await; + + // Now advance our recording tick counter + let recorder = self.require_recorder().unwrap(); + recorder.next_tick(); + let new_tick = recorder.current_tick; + + self.bot.send_command(&format!("say Stepped game tick, now recording tick {} (was {})", new_tick, current_tick)).await?; + + Ok(()) + } + + pub(super) async fn handle_record_assert(&mut self, args: &[String]) -> Result<()> { + let _recorder = match self.recorder.as_mut() { + Some(r) => r, + None => { + self.bot.send_command("say No recording in progress. Use !record to start.").await?; + return Ok(()); + } + }; + + // Parse coordinates from args + let x = args[0].parse::().unwrap_or(0); + let y = args[1].parse::().unwrap_or(0); + let z = args[2].parse::().unwrap_or(0); + let block_pos = [x, y, z]; + + // Get block at position + if let Some(block_str) = self.bot.get_block(block_pos).await? { + let block_id = block::extract_block_id(&block_str); + let recorder = self.recorder.as_mut().unwrap(); + recorder.add_assertion(block_pos, &block_id); + + self.bot.send_command(&format!( + "say Added assert at [{}, {}, {}] = {}", + block_pos[0], block_pos[1], block_pos[2], block_id + )).await?; + } else { + self.bot.send_command(&format!( + "say No block found at [{}, {}, {}]", + block_pos[0], block_pos[1], block_pos[2] + )).await?; + } + + Ok(()) + } + + pub(super) async fn handle_record_assert_changes(&mut self) -> Result<()> { + let Some(recorder) = self.require_recorder() else { + self.bot.send_command("say No recording in progress.").await?; + return Ok(()); + }; + + let count = recorder.convert_actions_to_asserts(); + self.bot.send_command(&format!("say Converted {} actions to assertions for this tick.", count)).await?; + Ok(()) + } + + pub(super) async fn handle_record_save(&mut self) -> Result { + let Some(recorder) = self.recorder.take() else { + self.bot.send_command("say No recording in progress.").await?; + return Ok(false); + }; + + // Check if there's anything to save + if recorder.timeline.is_empty() { + self.bot.send_command("say Warning: No actions recorded! Test will be empty.").await?; + } + + match recorder.save() { + Ok(path) => { + self.bot.send_command(&format!( + "say Test saved to: {}", + path.file_name().unwrap_or_default().to_string_lossy() + )).await?; + println!("Test saved to: {}", path.display()); + + // Print execution commands + self.bot.send_command(&format!("say To execute: !run {}", recorder.test_name)).await?; + println!("To execute this test locally:\ncargo run -- --server localhost:25565 {}", recorder.test_name); + } + Err(e) => { + self.bot.send_command(&format!("say Failed to save test: {}", e)).await?; + eprintln!("Failed to save: {}", e); + return Err(e); + } + } + + // Unfreeze time after recording + self.bot.send_command("tick unfreeze").await?; + + Ok(true) + } + + pub(super) async fn handle_record_snapshot(&mut self) -> Result<()> { + let recorder = match self.recorder.as_mut() { + Some(r) => r, + None => { + self.bot.send_command("say No recording in progress.").await?; + return Ok(()); + } + }; + + let scan_center = recorder.scan_center.unwrap_or([0, 64, 0]); + let scan_radius = recorder.scan_radius; + + self.bot.send_command("say Scanning for block changes...").await?; + + // Scan current blocks + let current_blocks = self.scan_blocks_around(scan_center, scan_radius).await?; + + // Compare with initial snapshot and record differences + let mut changes = 0; + let recorder = self.recorder.as_mut().unwrap(); + let initial_snapshot = recorder.snapshot.clone(); + + for (pos, current_block) in ¤t_blocks { + let prev_block = initial_snapshot.get(pos); + let is_air = current_block.to_lowercase().contains("air"); + + // Check if changed + let changed = match prev_block { + Some(prev) => prev != current_block, + None => !is_air, // New non-air block + }; + + if changed { + if is_air { + recorder.record_remove(*pos); + } else { + recorder.record_place(*pos, current_block); + } + changes += 1; + } + } + + // Also check for blocks that were removed (in initial but now air/gone) + for (pos, _prev_block) in &initial_snapshot { + if !current_blocks.contains_key(pos) { + // Block is gone (probably outside scan range now, skip) + continue; + } + let current = current_blocks.get(pos); + if current.map(|b| b.to_lowercase().contains("air")).unwrap_or(true) { + // Was a block, now is air + let recorder = self.recorder.as_mut().unwrap(); + recorder.record_remove(*pos); + changes += 1; + } + } + + self.bot.send_command(&format!("say Found {} block changes", changes)).await?; + Ok(()) + } + + pub(super) async fn handle_record_cancel(&mut self) -> Result<()> { + if self.recorder.take().is_some() { + // Unfreeze time after cancelling + self.bot.send_command("tick unfreeze").await?; + self.bot.send_command("say Recording cancelled.").await?; + } else { + self.bot.send_command("say No recording in progress.").await?; + } + Ok(()) + } +} diff --git a/src/executor/mod.rs b/src/executor/mod.rs new file mode 100644 index 0000000..a4c7987 --- /dev/null +++ b/src/executor/mod.rs @@ -0,0 +1,488 @@ +//! Test executor module - core test orchestration + +mod actions; +mod block; +mod handlers; +mod recorder; +mod tick; + +use crate::bot::TestBot; +use anyhow::Result; +use colored::Colorize; +use flint_core::loader::TestLoader; +use flint_core::results::TestResult; +use flint_core::test_spec::{TestSpec, TimelineEntry}; +use flint_core::timeline::TimelineAggregate; + +pub use tick::{COMMAND_DELAY_MS, MIN_RETRY_DELAY_MS}; + +// Timing constants +const CLEANUP_DELAY_MS: u64 = 200; +const TEST_RESULT_DELAY_MS: u64 = 50; +const DEFAULT_TESTS_DIR: &str = "FlintBenchmark/tests"; + +pub struct TestExecutor { + bot: TestBot, + action_delay_ms: u64, + recorder: Option, +} + +impl Default for TestExecutor { + fn default() -> Self { + Self { + bot: TestBot::new(), + action_delay_ms: COMMAND_DELAY_MS, + recorder: None, + } + } +} + +impl TestExecutor { + pub fn new() -> Self { + Self::default() + } + + pub fn set_action_delay(&mut self, delay_ms: u64) { + self.action_delay_ms = delay_ms; + } + + pub async fn connect(&mut self, server: &str) -> Result<()> { + self.bot.connect(server).await + } + + /// Helper to get a mutable reference to the recorder, or return an error + fn require_recorder(&mut self) -> Option<&mut recorder::RecorderState> { + self.recorder.as_mut() + } + + /// Helper to apply the standard command delay + async fn delay(&self) { + tokio::time::sleep(tokio::time::Duration::from_millis(self.action_delay_ms)).await; + } + + /// Interactive mode: listen for chat commands and execute them + pub async fn interactive_mode(&mut self, test_loader: &mut TestLoader) -> Result<()> { + // Send help message to chat (without ! to avoid self-triggering) + self.bot.send_command("say FlintMC Interactive Mode active").await?; + tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; + self.bot.send_command("say Type: help, search, run, run-all, run-tags, list, reload, stop (prefix with !)").await?; + tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; + + // Drain any messages (including our own welcome messages) + tick::drain_chat_messages(&mut self.bot).await; + + // Collect all tests upfront (mutable to allow reload) + let mut all_test_files = test_loader.collect_all_test_files()?; + + loop { + // Poll for chat messages + if let Some((sender, message)) = self + .bot + .recv_chat_timeout(std::time::Duration::from_millis(tick::CHAT_POLL_TIMEOUT_MS)) + .await + { + let Some((command, args)) = handlers::parse_command(&message) else { + continue; + }; + + match command.as_str() { + "!help" => { + self.handle_help().await?; + } + + "!list" => { + self.handle_list(&all_test_files).await?; + } + + "!search" => { + if args.is_empty() { + self.bot.send_command("say Usage: !search ").await?; + continue; + } + let pattern = args.join(" "); + self.handle_search(&all_test_files, &pattern).await?; + } + + "!run" => { + if args.is_empty() { + self.bot.send_command("say Usage: !run [step]").await?; + continue; + } + + // Check for step flag + let (test_name, step_mode) = if args.last().map(|s| s.as_str()) == Some("step") && args.len() > 1 { + (args[..args.len()-1].join(" "), true) + } else { + (args.join(" "), false) + }; + + self.handle_run(&all_test_files, &test_name, step_mode).await?; + } + + "!run-all" => { + self.handle_run_all(&all_test_files).await?; + } + + "!run-tags" => { + if args.is_empty() { + self.bot.send_command("say Usage: !run-tags ").await?; + continue; + } + let tags: Vec = args[0].split(',').map(|s| s.trim().to_string()).collect(); + self.handle_run_tags(test_loader, &tags).await?; + } + + "!stop" => { + self.bot.send_command("say Exiting interactive mode. Goodbye!").await?; + return Ok(()); + } + + "!reload" => { + test_loader.verify_and_rebuild_index()?; + all_test_files = test_loader.collect_all_test_files()?; + self.bot.send_command(&format!("say Reloaded {} tests", all_test_files.len())).await?; + } + + // Recorder commands + "!record" => { + if args.is_empty() { + self.bot.send_command("say Usage: !record [player_name]").await?; + self.bot.send_command("say Example: !record my_test or !record fence/fence_connect").await?; + continue; + } + let test_name = args[0].clone(); + // If player name not provided, use sender if available + let player_name = args.get(1).cloned().or_else(|| sender.clone()); + self.handle_record_start(&test_name, test_loader, player_name).await?; + } + "!assert_changes" => { + self.handle_record_assert_changes().await?; + } + + "!tick" | "!next" => { + self.handle_record_tick().await?; + } + + "!assert" => { + if args.len() < 3 { + self.bot.send_command("say Usage: !assert ").await?; + continue; + } + self.handle_record_assert(&args).await?; + } + + + "!save" => { + if self.handle_record_save().await? { + // Reload tests after successful save + test_loader.verify_and_rebuild_index()?; + all_test_files = test_loader.collect_all_test_files()?; + } + } + + "!cancel" => { + self.handle_record_cancel().await?; + } + + _ => { + if command.starts_with('!') { + self.bot.send_command(&format!("say Unknown command: {}. Type !help for commands.", command)).await?; + } + } + } + } + } + } + + /// Scan blocks in a cube around a center point (ignores air) + async fn scan_blocks_around( + &self, + center: [i32; 3], + radius: i32, + ) -> Result> { + let mut blocks = std::collections::HashMap::new(); + + for x in (center[0] - radius)..=(center[0] + radius) { + for y in (center[1] - radius).max(-64)..=(center[1] + radius).min(319) { + for z in (center[2] - radius)..=(center[2] + radius) { + let pos = [x, y, z]; + if let Ok(Some(block)) = self.bot.get_block(pos).await { + let block_id = block::extract_block_id(&block); + // Ignore air blocks + if !block_id.to_lowercase().contains("air") { + blocks.insert(pos, block_id); + } + } + } + } + } + + Ok(blocks) + } + + + + /// Run tests in parallel with merged timeline + pub async fn run_tests_parallel( + &mut self, + tests_with_offsets: &[(TestSpec, [i32; 3])], + break_after_setup: bool, + ) -> Result> { + println!( + "{} Running {} tests in parallel\n", + "→".blue().bold(), + tests_with_offsets.len() + ); + + // Build global merged timeline using flint-core + let aggregate = TimelineAggregate::from_tests(tests_with_offsets); + + println!(" Global timeline: {} ticks", aggregate.max_tick); + println!( + " {} unique tick steps with actions", + aggregate.unique_tick_count() + ); + if !aggregate.breakpoints.is_empty() { + let mut sorted_breakpoints: Vec<_> = aggregate.breakpoints.iter().collect(); + sorted_breakpoints.sort(); + println!( + " {} breakpoints at ticks: {:?}", + aggregate.breakpoints.len(), + sorted_breakpoints + ); + } + if break_after_setup { + println!(" {} Break after setup enabled", "→".yellow()); + } + println!(); + + // Clean all test areas before starting + println!("{} Cleaning all test areas...", "→".blue()); + for (test, offset) in tests_with_offsets.iter() { + let region = test.cleanup_region(); + let world_min = actions::apply_offset(region[0], *offset); + let world_max = actions::apply_offset(region[1], *offset); + let cmd = format!( + "fill {} {} {} {} {} {} air", + world_min[0], world_min[1], world_min[2], world_max[0], world_max[1], world_max[2] + ); + self.bot.send_command(&cmd).await?; + } + tokio::time::sleep(tokio::time::Duration::from_millis(CLEANUP_DELAY_MS)).await; + + // Freeze time globally + self.bot.send_command("tick freeze").await?; + tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; + + // Break after setup if requested + let mut stepping_mode = false; + if break_after_setup { + let should_continue = tick::wait_for_step( + &mut self.bot, + "After test setup (cleanup complete, time frozen)", + ) + .await?; + stepping_mode = !should_continue; + } + + // Track results per test + let mut test_results: Vec<(usize, usize)> = vec![(0, 0); tests_with_offsets.len()]; + + // Track which tests have been cleaned up + let mut tests_cleaned: Vec = vec![false; tests_with_offsets.len()]; + + // Calculate max tick for each test + let mut test_max_ticks: Vec = vec![0; tests_with_offsets.len()]; + for (tick_num, entries) in &aggregate.timeline { + for (test_idx, _, _) in entries { + test_max_ticks[*test_idx] = test_max_ticks[*test_idx].max(*tick_num); + } + } + + // Execute merged timeline + let mut current_tick = 0; + while current_tick <= aggregate.max_tick { + if let Some(entries) = aggregate.timeline.get(¤t_tick) { + for (test_idx, entry, value_idx) in entries { + let (test, offset) = &tests_with_offsets[*test_idx]; + + match self.execute_action(current_tick, entry, *value_idx, *offset).await { + Ok(true) => { + test_results[*test_idx].0 += 1; + } + Ok(false) => {} + Err(e) => { + test_results[*test_idx].1 += 1; + println!( + " {} [{}] Tick {}: {}", + "✗".red().bold(), + test.name, + current_tick, + e.to_string().red() + ); + } + } + } + } + + // Clean up tests that have completed + for test_idx in 0..tests_with_offsets.len() { + if !tests_cleaned[test_idx] && current_tick > test_max_ticks[test_idx] { + let (test, offset) = &tests_with_offsets[test_idx]; + println!( + "\n{} Cleaning up test [{}] (completed at tick {})...", + "→".blue(), + test.name, + test_max_ticks[test_idx] + ); + let region = test.cleanup_region(); + let world_min = actions::apply_offset(region[0], *offset); + let world_max = actions::apply_offset(region[1], *offset); + let cmd = format!( + "fill {} {} {} {} {} {} air", + world_min[0], world_min[1], world_min[2], + world_max[0], world_max[1], world_max[2] + ); + self.bot.send_command(&cmd).await?; + tests_cleaned[test_idx] = true; + tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; + } + } + + // Check for breakpoint + if aggregate.breakpoints.contains(¤t_tick) || stepping_mode { + let should_continue = tick::wait_for_step( + &mut self.bot, + &format!("End of tick {} (before step to next tick)", current_tick), + ) + .await?; + stepping_mode = !should_continue; + } + + // Advance to next tick + if current_tick < aggregate.max_tick { + if stepping_mode { + tick::step_tick(&mut self.bot).await?; + tokio::time::sleep(tokio::time::Duration::from_millis(CLEANUP_DELAY_MS)).await; + current_tick += 1; + } else { + let next_event_tick = aggregate + .next_event_tick(current_tick) + .unwrap_or(aggregate.max_tick + 1); + + let ticks_to_sprint = if next_event_tick <= aggregate.max_tick { + next_event_tick - current_tick + } else { + aggregate.max_tick - current_tick + }; + + let sprint_time_ms = if ticks_to_sprint == 1 { + tick::step_tick(&mut self.bot).await? + } else if ticks_to_sprint > 1 { + tick::sprint_ticks(&mut self.bot, ticks_to_sprint).await? + } else { + 0 + }; + + let retry_delay = sprint_time_ms.max(MIN_RETRY_DELAY_MS); + tokio::time::sleep(tokio::time::Duration::from_millis(retry_delay)).await; + + current_tick += ticks_to_sprint; + } + } else { + current_tick += 1; + } + } + + // Unfreeze time + self.bot.send_command("tick unfreeze").await?; + + // Clean up remaining tests + for test_idx in 0..tests_with_offsets.len() { + if !tests_cleaned[test_idx] { + let (test, offset) = &tests_with_offsets[test_idx]; + println!("\n{} Cleaning up remaining test [{}]...", "→".blue(), test.name); + let region = test.cleanup_region(); + let world_min = actions::apply_offset(region[0], *offset); + let world_max = actions::apply_offset(region[1], *offset); + let cmd = format!( + "fill {} {} {} {} {} {} air", + world_min[0], world_min[1], world_min[2], + world_max[0], world_max[1], world_max[2] + ); + self.bot.send_command(&cmd).await?; + tests_cleaned[test_idx] = true; + tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; + } + } + + // Build results + let results: Vec = tests_with_offsets + .iter() + .enumerate() + .map(|(idx, (test, _))| { + let (passed, failed) = test_results[idx]; + let success = failed == 0; + + println!(); + if success { + println!( + " {} [{}] Test passed: {} assertions", + "✓".green().bold(), + test.name, + passed + ); + } else { + println!( + " {} [{}] Test failed: {} passed, {} failed", + "✗".red().bold(), + test.name, + passed, + failed + ); + } + + if success { + TestResult::new(test.name.clone()) + } else { + TestResult::new(test.name.clone()) + .with_failure_reason(format!("{} assertions failed", failed)) + } + }) + .collect(); + + // Send test results summary to chat + let total_passed = results.iter().filter(|r| r.success).count(); + let total_failed = results.len() - total_passed; + let summary = format!( + "Tests complete: {}/{} passed, {} failed", + total_passed, + results.len(), + total_failed + ); + self.bot.send_command(&format!("say {}", summary)).await?; + tokio::time::sleep(tokio::time::Duration::from_millis(COMMAND_DELAY_MS)).await; + + // Send individual test results to chat + for result in &results { + let status = if result.success { "PASS" } else { "FAIL" }; + let msg = format!("say [{}] {}", status, result.test_name); + self.bot.send_command(&msg).await?; + tokio::time::sleep(tokio::time::Duration::from_millis(TEST_RESULT_DELAY_MS)).await; + } + + tokio::time::sleep(tokio::time::Duration::from_millis(CLEANUP_DELAY_MS)).await; + + Ok(results) + } + + async fn execute_action( + &mut self, + tick: u32, + entry: &TimelineEntry, + value_idx: usize, + offset: [i32; 3], + ) -> Result { + actions::execute_action(&mut self.bot, tick, entry, value_idx, offset, self.action_delay_ms).await + } +} diff --git a/src/executor/recorder/actions.rs b/src/executor/recorder/actions.rs new file mode 100644 index 0000000..5e357f0 --- /dev/null +++ b/src/executor/recorder/actions.rs @@ -0,0 +1,16 @@ +//! Recorded action types for the test recorder + +/// A recorded action in the timeline +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordedAction { + Place { pos: [i32; 3], block: String }, + Remove { pos: [i32; 3] }, + Assert { pos: [i32; 3], block: String }, +} + +/// A step in the recorded timeline +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TimelineStep { + pub tick: u32, + pub actions: Vec, +} diff --git a/src/executor/recorder/bounding_box.rs b/src/executor/recorder/bounding_box.rs new file mode 100644 index 0000000..8e26590 --- /dev/null +++ b/src/executor/recorder/bounding_box.rs @@ -0,0 +1,44 @@ +//! Bounding box for tracking affected region in recordings + +/// Bounding box for tracking affected region +#[derive(Debug, Clone)] +pub struct BoundingBox { + pub min: [i32; 3], + pub max: [i32; 3], +} + +impl BoundingBox { + pub fn new() -> Self { + Self { + min: [i32::MAX, i32::MAX, i32::MAX], + max: [i32::MIN, i32::MIN, i32::MIN], + } + } + + /// Expand the bounding box to include a position + pub fn expand(&mut self, pos: [i32; 3]) { + for i in 0..3 { + self.min[i] = self.min[i].min(pos[i]); + self.max[i] = self.max[i].max(pos[i]); + } + } + + /// Check if the bounding box has any valid points + pub fn is_valid(&self) -> bool { + self.min[0] <= self.max[0] && self.min[1] <= self.max[1] && self.min[2] <= self.max[2] + } + + /// Get cleanup region with padding + pub fn to_cleanup_region(&self, padding: i32) -> [[i32; 3]; 2] { + [ + [self.min[0] - padding, self.min[1] - padding, self.min[2] - padding], + [self.max[0] + padding, self.max[1] + padding, self.max[2] + padding], + ] + } +} + +impl Default for BoundingBox { + fn default() -> Self { + Self::new() + } +} diff --git a/src/executor/recorder/mod.rs b/src/executor/recorder/mod.rs new file mode 100644 index 0000000..bf19d1f --- /dev/null +++ b/src/executor/recorder/mod.rs @@ -0,0 +1,10 @@ +//! Test recorder module - record player actions to create tests + +mod actions; +mod bounding_box; +mod state; +#[cfg(test)] +mod tests; + +pub use state::RecorderState; + diff --git a/src/executor/recorder/state.rs b/src/executor/recorder/state.rs new file mode 100644 index 0000000..c13ac7b --- /dev/null +++ b/src/executor/recorder/state.rs @@ -0,0 +1,309 @@ +//! State management for recording sessions + +use anyhow::Result; +use flint_core::test_spec::{ + ActionType, BlockCheck, BlockPlacement, CleanupSpec, SetupSpec, TestSpec, TickSpec, + TimelineEntry, +}; +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::executor::block::make_block; + +use super::actions::{RecordedAction, TimelineStep}; +use super::bounding_box::BoundingBox; + +// Constants +const DEFAULT_SCAN_RADIUS: i32 = 16; +const DEFAULT_CLEANUP_REGION: [[i32; 3]; 2] = [[0, 0, 0], [10, 10, 10]]; + +/// State for an active recording session +pub struct RecorderState { + /// Test name (e.g., "fence_connect" or "fence/fence_connect") + pub test_name: String, + /// Full path where the test file will be saved + pub test_path: PathBuf, + /// Current recording tick + pub current_tick: u32, + /// Recorded timeline steps + pub timeline: Vec, + /// Bounding box of all affected blocks + pub bounds: BoundingBox, + /// Block states snapshot for change detection (world_pos -> block_id) + pub snapshot: HashMap<[i32; 3], String>, + /// Origin point (first block changed becomes 0,0,0) + pub origin: Option<[i32; 3]>, + /// Player name/entity to track + pub player_name: Option, + /// Center position for scanning (typically player position) + pub scan_center: Option<[i32; 3]>, + /// Scan radius around player to detect block changes + pub scan_radius: i32, +} + +impl RecorderState { + /// Create a new recorder state + pub fn new(test_name: &str, tests_dir: &std::path::Path) -> Self { + // Parse test_name which may include subdirectories like "fence/fence_connect" + let test_path = if test_name.contains('/') { + let parts: Vec<&str> = test_name.split('/').collect(); + let mut path = tests_dir.to_path_buf(); + for part in &parts[..parts.len() - 1] { + path.push(part); + } + path.push(format!("{}.json", parts.last().unwrap())); + path + } else { + tests_dir.join(format!("{}.json", test_name)) + }; + + Self { + test_name: test_name.to_string(), + test_path, + current_tick: 0, + timeline: Vec::new(), + bounds: BoundingBox::new(), + snapshot: HashMap::new(), + origin: None, + player_name: None, + scan_center: None, + scan_radius: DEFAULT_SCAN_RADIUS, + } + } + + /// Set the scan center for block change detection + pub fn set_scan_center(&mut self, pos: [i32; 3]) { + self.scan_center = Some(pos); + } + + /// Set the origin point (normalizes all positions relative to this) + pub fn set_origin(&mut self, pos: [i32; 3]) { + if self.origin.is_none() { + self.origin = Some(pos); + } + } + + /// Convert world position to local position (relative to origin) + #[must_use] + pub fn to_local(&self, world_pos: [i32; 3]) -> [i32; 3] { + if let Some(origin) = self.origin { + [ + world_pos[0] - origin[0], + world_pos[1] - origin[1], + world_pos[2] - origin[2], + ] + } else { + world_pos + } + } + + /// Get or create the timeline step for the current tick + fn get_or_create_current_step(&mut self) -> &mut TimelineStep { + if self.timeline.is_empty() || self.timeline.last().unwrap().tick != self.current_tick { + self.timeline.push(TimelineStep { + tick: self.current_tick, + actions: Vec::new(), + }); + } + self.timeline.last_mut().unwrap() + } + + /// Remove any existing Place/Remove actions for this position in the current tick + fn deduplicate_actions(&mut self, pos: [i32; 3]) { + let step = self.get_or_create_current_step(); + step.actions.retain(|a| match a { + RecordedAction::Place { pos: p, .. } => *p != pos, + RecordedAction::Remove { pos: p } => *p != pos, + // Keep asserts/others + _ => true, + }); + } + + /// Record a block placement + pub fn record_place(&mut self, world_pos: [i32; 3], block: &str) { + // Set origin on first placement + self.set_origin(world_pos); + + let local_pos = self.to_local(world_pos); + self.bounds.expand(local_pos); + + // Deduplicate before adding + self.deduplicate_actions(local_pos); + + let step = self.get_or_create_current_step(); + step.actions.push(RecordedAction::Place { + pos: local_pos, + block: block.to_string(), + }); + + // Update snapshot + self.snapshot.insert(world_pos, block.to_string()); + } + + /// Record a block removal + pub fn record_remove(&mut self, world_pos: [i32; 3]) { + if self.origin.is_none() { + // Can't remove before any placement + return; + } + + let local_pos = self.to_local(world_pos); + self.bounds.expand(local_pos); + + // Deduplicate before adding + self.deduplicate_actions(local_pos); + + let step = self.get_or_create_current_step(); + step.actions.push(RecordedAction::Remove { pos: local_pos }); + + // Update snapshot - store air to track the removal + self.snapshot.insert(world_pos, "minecraft:air".to_string()); + } + + /// Add an assertion for a block + pub fn add_assertion(&mut self, world_pos: [i32; 3], block: &str) { + if self.origin.is_none() { + self.set_origin(world_pos); + } + + let local_pos = self.to_local(world_pos); + self.bounds.expand(local_pos); + + let step = self.get_or_create_current_step(); + step.actions.push(RecordedAction::Assert { + pos: local_pos, + block: block.to_string(), + }); + } + + /// Convert all Place/Remove actions in the current tick to Assertions + pub fn convert_actions_to_asserts(&mut self) -> usize { + let mut converted_count = 0; + + if let Some(step) = self.timeline.last_mut() { + if step.tick == self.current_tick { + let mut new_actions = Vec::new(); + + // Drain existing actions and convert them + for action in step.actions.drain(..) { + match action { + RecordedAction::Place { pos, block } => { + new_actions.push(RecordedAction::Assert { pos, block }); + converted_count += 1; + } + RecordedAction::Remove { pos } => { + // Removing a block means asserting it is air + new_actions.push(RecordedAction::Assert { + pos, + block: "minecraft:air".to_string() + }); + converted_count += 1; + } + // Keep existing asserts unchanged + assert_action @ RecordedAction::Assert { .. } => { + new_actions.push(assert_action); + } + } + } + + step.actions = new_actions; + } + } + + converted_count + } + + /// Advance to the next tick + pub fn next_tick(&mut self) { + self.current_tick += 1; + } + + /// Generate a TestSpec from the recorded data + #[must_use] + pub fn generate_test_spec(&self) -> TestSpec { + let cleanup_region = if self.bounds.is_valid() { + self.bounds.to_cleanup_region(1) + } else { + DEFAULT_CLEANUP_REGION + }; + + // Build timeline entries using flint-core types + let mut timeline_entries: Vec = Vec::new(); + + for step in &self.timeline { + // Group actions by type for this tick + let mut placements: Vec = Vec::new(); + let mut checks: Vec = Vec::new(); + + for action in &step.actions { + match action { + RecordedAction::Place { pos, block } => { + placements.push(BlockPlacement { + pos: *pos, + block: make_block(block), + }); + } + RecordedAction::Remove { pos } => { + placements.push(BlockPlacement { + pos: *pos, + block: make_block("minecraft:air"), + }); + } + RecordedAction::Assert { pos, block } => { + checks.push(BlockCheck { + pos: *pos, + is: make_block(block), + }); + } + } + } + + // Emit place_each if there are placements + if !placements.is_empty() { + timeline_entries.push(TimelineEntry { + at: TickSpec::Single(step.tick), + action_type: ActionType::PlaceEach { blocks: placements }, + }); + } + + // Emit assert if there are checks + if !checks.is_empty() { + timeline_entries.push(TimelineEntry { + at: TickSpec::Single(step.tick), + action_type: ActionType::Assert { checks }, + }); + } + } + + TestSpec { + flint_version: None, + name: self.test_name.replace('/', "_"), + description: Some(format!("Recorded test: {}", self.test_name)), + tags: vec!["recorded".to_string()], + dependencies: Vec::new(), + setup: Some(SetupSpec { + cleanup: CleanupSpec { + region: cleanup_region, + }, + }), + timeline: timeline_entries, + breakpoints: Vec::new(), + } + } + + /// Save the test to a file + pub fn save(&self) -> Result { + let test_spec = self.generate_test_spec(); + + // Create parent directories if needed + if let Some(parent) = self.test_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Write the JSON file with pretty formatting using serde + let json_str = serde_json::to_string_pretty(&test_spec)?; + std::fs::write(&self.test_path, json_str)?; + + Ok(self.test_path.clone()) + } +} diff --git a/src/executor/recorder/tests.rs b/src/executor/recorder/tests.rs new file mode 100644 index 0000000..0018707 --- /dev/null +++ b/src/executor/recorder/tests.rs @@ -0,0 +1,28 @@ +//! Tests for the recorder module + +use super::bounding_box::BoundingBox; +use super::state::RecorderState; + +#[test] +fn test_bounding_box() { + let mut bb = BoundingBox::new(); + assert!(!bb.is_valid()); + + bb.expand([0, 0, 0]); + assert!(bb.is_valid()); + assert_eq!(bb.min, [0, 0, 0]); + assert_eq!(bb.max, [0, 0, 0]); + + bb.expand([5, 10, -3]); + assert_eq!(bb.min, [0, 0, -3]); + assert_eq!(bb.max, [5, 10, 0]); +} + +#[test] +fn test_local_position() { + let mut recorder = RecorderState::new("test", std::path::Path::new("/tmp")); + recorder.set_origin([100, 64, 200]); + + assert_eq!(recorder.to_local([100, 64, 200]), [0, 0, 0]); + assert_eq!(recorder.to_local([105, 65, 198]), [5, 1, -2]); +} diff --git a/src/executor/tick.rs b/src/executor/tick.rs new file mode 100644 index 0000000..60239cd --- /dev/null +++ b/src/executor/tick.rs @@ -0,0 +1,218 @@ +//! Tick management - gametime queries, stepping, and sprinting + +use crate::bot::TestBot; +use anyhow::Result; +use colored::Colorize; + +// Constants for tick timing +pub const CHAT_DRAIN_TIMEOUT_MS: u64 = 10; +pub const CHAT_POLL_TIMEOUT_MS: u64 = 100; +pub const COMMAND_DELAY_MS: u64 = 100; +pub const GAMETIME_QUERY_TIMEOUT_SECS: u64 = 5; +pub const TICK_STEP_TIMEOUT_SECS: u64 = 5; +pub const TICK_STEP_POLL_MS: u64 = 50; +pub const SPRINT_TIMEOUT_SECS: u64 = 30; +pub const MIN_RETRY_DELAY_MS: u64 = 200; + +/// Drain old chat messages from the bot's queue +pub async fn drain_chat_messages(bot: &mut TestBot) { + while bot + .recv_chat_timeout(std::time::Duration::from_millis(CHAT_DRAIN_TIMEOUT_MS)) + .await + .is_some() + { + // Discard old messages + } +} + +/// Returns true to continue, false to step to next tick only +pub async fn wait_for_step(bot: &mut TestBot, reason: &str) -> Result { + println!( + "\n{} {} {}", + "⏸".yellow().bold(), + "BREAKPOINT:".yellow().bold(), + reason + ); + + println!( + " Waiting for in-game chat command: {} = step, {} = continue", + "s".cyan().bold(), + "c".cyan().bold() + ); + + // Send chat message to inform player + bot.send_command("say Waiting for step/continue (s = step, c = continue)") + .await?; + + // First, drain any old messages from the chat queue + drain_chat_messages(bot).await; + + // Now wait for a fresh chat command + loop { + if let Some((_, message)) = bot + .recv_chat_timeout(std::time::Duration::from_millis(CHAT_POLL_TIMEOUT_MS)) + .await + { + // Skip messages from the bot itself (contains "Waiting for step/continue") + if message.contains("Waiting for step/continue") { + continue; + } + + // Look for commands in the message - match exact commands only + let msg_lower = message.to_lowercase(); + let trimmed = msg_lower.trim(); + + // Match the message ending with just "s" or "c" (player commands) + if trimmed.ends_with(" s") + || trimmed == "s" + || trimmed.ends_with(" step") + || trimmed == "step" + { + println!(" {} Received 's' from chat", "→".blue()); + return Ok(false); // Step mode + } else if trimmed.ends_with(" c") + || trimmed == "c" + || trimmed.ends_with(" continue") + || trimmed == "continue" + { + println!(" {} Received 'c' from chat", "→".blue()); + return Ok(true); // Continue mode + } + } + } +} + +/// Query the current game time from the server +/// Returns the game time in ticks +pub async fn query_gametime(bot: &mut TestBot) -> Result { + // Clear any pending chat messages + drain_chat_messages(bot).await; + + // Send the time query command + bot.send_command("time query gametime").await?; + + // Wait for response: "The time is " + let timeout = std::time::Duration::from_secs(GAMETIME_QUERY_TIMEOUT_SECS); + let start = std::time::Instant::now(); + + while start.elapsed() < timeout { + if let Some((_, message)) = bot + .recv_chat_timeout(std::time::Duration::from_millis(CHAT_POLL_TIMEOUT_MS)) + .await + { + // Look for "The time is" message + if message.contains("The time is") { + // Extract the time value + if let Some(time_str) = message.split("The time is ").nth(1) { + // Parse the number (might have formatting) + let time_clean = time_str + .chars() + .filter(|c| c.is_ascii_digit()) + .collect::(); + if let Ok(time) = time_clean.parse::() { + return Ok(time); + } + } + } + } + } + + anyhow::bail!("Failed to query game time: timeout waiting for response") +} + +/// Step a single tick using /tick step and verify completion +/// Returns the time taken in ms +pub async fn step_tick(bot: &mut TestBot) -> Result { + let before = query_gametime(bot).await?; + + let start = std::time::Instant::now(); + bot.send_command("tick step").await?; + + // Wait for the tick to actually complete by polling gametime + let timeout = std::time::Duration::from_secs(TICK_STEP_TIMEOUT_SECS); + let poll_start = std::time::Instant::now(); + + loop { + tokio::time::sleep(std::time::Duration::from_millis(TICK_STEP_POLL_MS)).await; + let after = query_gametime(bot).await?; + + if after > before { + let elapsed = start.elapsed().as_millis() as u64; + println!( + " {} Stepped 1 tick (verified: {} -> {}) in {} ms", + "→".dimmed(), + before, + after, + elapsed + ); + return Ok(elapsed); + } + + if poll_start.elapsed() >= timeout { + anyhow::bail!("Tick step verification timeout: game time did not advance"); + } + } +} + +/// Sprint ticks and capture the time taken from server output +/// Returns the ms per tick from the server's sprint completion message +/// NOTE: Accounts for Minecraft's off-by-one bug where "tick sprint N" executes N+1 ticks +pub async fn sprint_ticks(bot: &mut TestBot, ticks: u32) -> Result { + // Clear any pending chat messages + drain_chat_messages(bot).await; + + // Account for Minecraft's off-by-one bug: "tick sprint N" executes N+1 ticks + // So to execute `ticks` ticks, we request ticks-1 + let ticks_to_request = ticks - 1; + + // Send the sprint command + bot.send_command(&format!("tick sprint {}", ticks_to_request)) + .await?; + + // Wait for the "Sprint completed" message + // Server message format: "Sprint completed with X ticks per second, or Y ms per tick" + let timeout = std::time::Duration::from_secs(SPRINT_TIMEOUT_SECS); + let start = std::time::Instant::now(); + + while start.elapsed() < timeout { + if let Some((_, message)) = bot + .recv_chat_timeout(std::time::Duration::from_millis(CHAT_POLL_TIMEOUT_MS)) + .await + { + // Look for "Sprint completed" message + if message.contains("Sprint completed") { + // Try to extract ms per tick + // Format: "... or X ms per tick" + if let Some(ms_part) = message.split("or ").nth(1) + && let Some(ms_str) = ms_part.split(" ms per tick").next() + && let Ok(ms) = ms_str.trim().parse::() + { + let ms_rounded = ms.ceil() as u64; + println!( + " {} Sprint {} ticks completed in {} ms per tick", + "⚡".dimmed(), + ticks, + ms_rounded + ); + // Return total time: ms per tick * number of ticks + return Ok(ms_rounded * ticks as u64); + } + // If we found the message but couldn't parse, use default + println!( + " {} Sprint {} ticks completed (timing not parsed)", + "⚡".dimmed(), + ticks + ); + return Ok(MIN_RETRY_DELAY_MS); + } + } + } + + // Timeout - return default + println!( + " {} Sprint {} ticks (no completion message received)", + "⚡".dimmed(), + ticks + ); + Ok(MIN_RETRY_DELAY_MS) +} diff --git a/src/main.rs b/src/main.rs index 35eb2e7..2301ddc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,14 +84,14 @@ struct Args { #[arg(long)] break_after_setup: bool, - /// Use in-game chat for breakpoint control (type 's' or 'c' in chat) - #[arg(long)] - chat_control: bool, - /// Filter tests by tags (can be specified multiple times) #[arg(short = 't', long = "tag")] tags: Vec, + /// Interactive mode: listen for chat commands (!search, !run, !run-all, !run-tags) + #[arg(short = 'i', long)] + interactive: bool, + /// Delay in milliseconds between each action (default: 100) #[arg(short = 'd', long = "action-delay", default_value = "100")] action_delay: u64, @@ -111,7 +111,7 @@ async fn main() -> Result<()> { println!("{}", "FlintMC - Minecraft Testing Framework".green().bold()); println!(); - let test_loader = if let Some(ref path) = args.path { + let mut test_loader = if let Some(ref path) = args.path { println!("{} Loading tests from {}...", "→".blue(), path.display()); TestLoader::new(path, args.recursive) .with_context(|| format!("Failed to initialize test loader for path: {}", path.display()))? @@ -131,7 +131,8 @@ async fn main() -> Result<()> { .context("Failed to collect test files")? }; - if test_files.is_empty() { + // In interactive mode, we don't require tests to be found initially + if test_files.is_empty() && !args.interactive { let location = if !args.tags.is_empty() { format!("with tags: {:?}", args.tags) } else if let Some(ref path) = args.path { @@ -143,7 +144,9 @@ async fn main() -> Result<()> { std::process::exit(1); } - println!("Found {} test file(s)\n", test_files.len()); + if !args.interactive { + println!("Found {} test file(s)\n", test_files.len()); + } // Connect to server let mut executor = executor::TestExecutor::new(); @@ -158,13 +161,21 @@ async fn main() -> Result<()> { ); } - // Enable chat control if requested - if args.chat_control { - executor.set_chat_control(true); + // Interactive mode: enter command loop + if args.interactive { println!( - "{} Chat control enabled - you can type 's' or 'c' in game chat", - "→".yellow() + "{} Interactive mode enabled - listening for chat commands", + "→".yellow().bold() ); + println!(" Commands: !search, !run, !run-all, !run-tags, !list, !reload, !help, !stop"); + println!(" During tests: type 's' to step, 'c' to continue\n"); + + println!("{} Connecting to {}...", "→".blue(), args.server); + executor.connect(&args.server).await?; + println!("{} Connected successfully\n", "✓".green()); + + executor.interactive_mode(&mut test_loader).await?; + return Ok(()); } println!("{} Connecting to {}...", "→".blue(), args.server);