diff --git a/Cargo.lock b/Cargo.lock index 105440e..f21a5a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1476,6 +1476,7 @@ dependencies = [ [[package]] name = "flint-core" version = "1.1.4" +source = "git+https://github.com/FlintTestMC/flint-core?rev=a6f4d12c02cd37877d5b77e81b51477b16ab54c3#a6f4d12c02cd37877d5b77e81b51477b16ab54c3" dependencies = [ "anyhow", "base64", @@ -1499,9 +1500,11 @@ dependencies = [ "colored", "flint-core", "parking_lot", + "quote", "rustc-hash", "serde", "serde_json", + "syn", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 0676442..c291d2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,7 @@ parking_lot = "0.12" flint-core = { git = "https://github.com/FlintTestMC/flint-core", rev = "a6f4d12c02cd37877d5b77e81b51477b16ab54c3" } clap_complete = "4.5.65" rustc-hash = "2.1" + +[build-dependencies] +quote = "1.0" +syn = { version = "2.0", features = ["full", "parsing"] } diff --git a/FlintBenchmark b/FlintBenchmark index 957db1a..c9f7139 160000 --- a/FlintBenchmark +++ b/FlintBenchmark @@ -1 +1 @@ -Subproject commit 957db1a3809d8169ed35f284ad36a7dc5db50e78 +Subproject commit c9f71399afebae6be195b4965609172a03513308 diff --git a/USAGE.md b/USAGE.md index 973e1dd..5f7c44a 100644 --- a/USAGE.md +++ b/USAGE.md @@ -221,7 +221,7 @@ The recorder lets you create tests by performing actions in-game. The bot watche flintmc -s localhost:25565 -i ``` -2. Begin recording: +2. Begin recording with the existing chat command: ``` !record redstone/my_test ``` @@ -232,13 +232,29 @@ The recorder lets you create tests by performing actions in-game. The bot watche flintmc -s localhost:25565 --record redstone/my_test ``` -3. Place and break blocks in-game. The bot scans for changes in a fixed 10-block radius around the position where recording started. +3. Place and break blocks in-game. The bot scans for changes in a fixed two-chunk + (32-block) radius around the position where recording started. -4. When ready to advance a tick: +4. Use the recorder dialog to advance a tick, record all block changes since the + previous snapshot, record a use, convert changes to + assertions, assert the block under the player's crosshair, set a region + corner from that block, save, or cancel. The dialog refreshes after each + action to show the current tick and recorded action count. Coordinate-specific + chat commands remain available through `!assert` and `!pos1`. + + The equivalent chat command for advancing a tick is: ``` !tick ``` - This snapshots the current block changes, steps the game one tick, and advances the recording tick counter. You can also use `!next` as an alias. + This steps the game exactly one tick and advances the recording tick counter. You can also use `!next` as an alias. + + To add all block changes since the previous saved world-state snapshot, use the + **Record block changes** button or either chat command: + + ```text + !snapshot + !changes + ``` 5. Add assertions for blocks you want to verify: ``` @@ -273,7 +289,9 @@ The recorder lets you create tests by performing actions in-game. The bot watche | Command | Description | |---------|-------------| | `!record [player]` | Start recording. Optional player name for position tracking | -| `!tick` / `!next` | Snapshot changes and advance one game tick | +| `!recorder` | Reopen the recorder dialog without changing recording state | +| `!tick` / `!next` | Advance exactly one game tick | +| `!snapshot` / `!changes` | Record all block changes since the previous snapshot | | `!assert ` | Assert the block at the given coordinates | | `!use [item]` | Record `tp` to the tracked player's current pose, then `interact` | | `!assert_changes` | Convert all detected block changes to assertions | @@ -282,7 +300,8 @@ The recorder lets you create tests by performing actions in-game. The bot watche ### Tips -- The recorder auto-detects block placements and removals within the fixed scan range captured when recording starts. +- The recorder auto-detects block placements and removals within the fixed + two-chunk scan range captured when recording starts. - Use `!record [player]` to choose which player is used for the initial scan center and later `!use` pose capture. - Positions are stored relative to the first block changed (origin), so tests are portable. - The cleanup region is computed automatically from the bounding box of all affected blocks. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3ca764c --- /dev/null +++ b/build.rs @@ -0,0 +1,181 @@ +use quote::{format_ident, quote}; +use std::{env, fs, path::PathBuf}; +use syn::{Expr, Item, Lit}; + +fn main() { + const DEFINITIONS: &[&str] = &[ + "src/executor/command_definitions.rs", + "src/executor/recorder/command_definitions.rs", + ]; + let mut items = Vec::new(); + for definitions in DEFINITIONS { + println!("cargo:rerun-if-changed={definitions}"); + let source = fs::read_to_string(definitions).expect("read interactive command definitions"); + let file = syn::parse_file(&source).expect("parse interactive command definitions"); + items.extend(file.items); + } + let mut variants = Vec::new(); + let mut functions = Vec::new(); + let mut specs = Vec::new(); + let mut dispatch_arms = Vec::new(); + let mut callback_arms = Vec::new(); + let mut next_dialog_trigger = 1u8; + + for item in items { + let Item::Fn(mut function) = item else { + panic!("interactive command definitions may only contain functions"); + }; + let attribute_index = function + .attrs + .iter() + .position(|attribute| { + attribute.path().is_ident("main_command") + || attribute.path().is_ident("recorder_command") + }) + .expect("command function is missing #[main_command(...)] or #[recorder_command(...)]"); + let attribute = function.attrs.remove(attribute_index); + let scope = if attribute.path().is_ident("recorder_command") { + quote!(CommandScope::Recorder) + } else { + quote!(CommandScope::Main) + }; + + let mut id = None; + let mut aliases = Vec::new(); + let mut help = None; + let mut label = None; + attribute + .parse_nested_meta(|meta| { + if meta.path.is_ident("id") { + id = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("aliases") { + let expression = meta.value()?.parse::()?; + let Expr::Array(array) = expression else { + return Err(meta.error("aliases must be a string array")); + }; + for element in array.elems { + let Expr::Lit(literal) = element else { + return Err(meta.error("alias must be a string")); + }; + let Lit::Str(value) = literal.lit else { + return Err(meta.error("alias must be a string")); + }; + aliases.push(value.value()); + } + } else if meta.path.is_ident("help") { + help = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("label") { + label = Some(meta.value()?.parse::()?.value()); + } else { + return Err(meta.error("unknown command property")); + } + Ok(()) + }) + .expect("invalid recorder command attribute"); + + let variant = format_ident!("{}", id.expect("command id is required")); + let function_name = function.sig.ident.clone(); + let help_tokens = help + .map(|value| quote!(Some(#value))) + .unwrap_or_else(|| quote!(None)); + let dialog_tokens = match label { + Some(label) => { + let trigger = next_dialog_trigger; + next_dialog_trigger = next_dialog_trigger + .checked_add(1) + .expect("too many recorder dialog commands"); + quote!(Some(DialogAction { trigger: #trigger, label: #label })) + } + None => quote!(None), + }; + let callback = function_name.to_string(); + + variants.push(variant.clone()); + specs.push(quote! { + FlintCommandSpec { + command: FlintCommand::#variant, + scope: #scope, + aliases: &[#(#aliases),*], + help: #help_tokens, + dialog: #dialog_tokens, + callback: #callback, + } + }); + dispatch_arms.push(quote! { + FlintCommand::#variant => #function_name(executor, context)? + }); + callback_arms.push(quote! { + FlintCommand::#variant => #callback + }); + functions.push(function); + } + + let generated = quote! { + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum FlintCommand { #(#variants),* } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum CommandScope { + Main, + Recorder, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct DialogAction { + pub trigger: u8, + pub label: &'static str, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct FlintCommandSpec { + pub command: FlintCommand, + pub scope: CommandScope, + pub aliases: &'static [&'static str], + pub help: Option<&'static str>, + pub dialog: Option, + pub callback: &'static str, + } + + pub const COMMANDS: &[FlintCommandSpec] = &[#(#specs),*]; + + pub fn from_chat(command: &str) -> Option { + COMMANDS.iter().find(|spec| spec.aliases.contains(&command)).map(|spec| spec.command) + } + + pub fn from_callback(callback: &str) -> Option { + COMMANDS.iter().find(|spec| spec.callback == callback && spec.dialog.is_some()).map(|spec| spec.command) + } + + pub const fn callback_id(command: FlintCommand) -> &'static str { + match command { #(#callback_arms),* } + } + + pub fn primary_alias(command: FlintCommand) -> &'static str { + COMMANDS.iter().find(|spec| spec.command == command) + .and_then(|spec| spec.aliases.first().copied()) + .expect("every recorder command must have a primary alias") + } + + pub fn dispatch( + executor: &mut TestExecutor, + command: FlintCommand, + context: &mut FlintCommandContext<'_>, + ) -> Result<()> { + let spec = COMMANDS.iter().find(|spec| spec.command == command) + .expect("generated command must have a specification"); + if spec.scope == CommandScope::Recorder && executor.recorder.is_none() { + executor.bot.send_command( + "say No recording in progress. Use !record to start." + )?; + return Ok(()); + } + match command { #(#dispatch_arms),* } + Ok(()) + } + + #(#functions)* + }; + + let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")).join("flint_commands.rs"); + fs::write(output, generated.to_string()).expect("write generated recorder commands"); +} diff --git a/src/bot.rs b/src/bot.rs index c94cc4f..5a15925 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -15,6 +15,12 @@ const GAME_STATE_WAIT_ATTEMPTS: u32 = 100; const STATE_SYNC_TIMEOUT_MS: u64 = 2_000; const CHUNK_SYNC_TIMEOUT_MS: u64 = 10_000; const STATE_SYNC_POLL_MS: u64 = 5; +const WORLD_READY_TIMEOUT_SECS: u64 = 5; +const POSITION_TOLERANCE: f64 = 0.01; +const TELEPORT_VERTICAL_TOLERANCE: f64 = 0.75; +const TEST_ORIGIN_CENTER: f64 = 0.5; +const TEST_ORIGIN_HEIGHT: f64 = 64.0; +const MAX_HOTBAR_SLOT: u8 = 9; // Must be configured during Event::Init, before Azalea allocates PartialWorld. // Vanilla servers support at most 32 chunks and may clamp this request lower. const CLIENT_VIEW_DISTANCE: u8 = 32; @@ -179,16 +185,11 @@ impl TestBot { tracing::info!("Bot world spawned"); } Event::Chat(m) => { - // 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('<') { - message.find('>').map(|end| message[1..end].to_string()) - } else { - None - }; - + // Use Azalea's structured chat parsing. Rendering the full + // component and looking for `` loses the sender on + // modern chat types, which made recorder dialogs fall back + // to @p and target the FlintMC bot itself. + let (sender, message) = m.split_sender_and_content(); if message.contains("__flintmc_ack_") { if let Some(tx) = &state.ack_tx { let _ = tx.send(message); @@ -285,7 +286,7 @@ impl TestBot { tracing::info!("Connected successfully and in game state"); world_ready_rx - .recv_timeout(std::time::Duration::from_secs(5)) + .recv_timeout(std::time::Duration::from_secs(WORLD_READY_TIMEOUT_SECS)) .map_err(|_| anyhow::anyhow!("Bot world did not spawn within timeout"))?; self.reset_to_test_origin()?; @@ -309,7 +310,8 @@ impl TestBot { .world_name() .is_ok_and(|world| world.to_string() == "minecraft:overworld"); let at_origin = client.position().is_ok_and(|position| { - (position.x - 0.5).abs() < 0.01 && (position.z - 0.5).abs() < 0.01 + (position.x - TEST_ORIGIN_CENTER).abs() < POSITION_TOLERANCE + && (position.z - TEST_ORIGIN_CENTER).abs() < POSITION_TOLERANCE }); in_overworld && at_origin }) @@ -395,14 +397,16 @@ impl TestBot { .as_ref() .ok_or_else(|| anyhow::anyhow!("Bot not initialized"))?; - // Add "/" prefix if not present - let command_with_slash = if command.starts_with('/') { - command.to_string() - } else { - format!("/{}", command) - }; - tracing::debug!("Sending command: {}", command_with_slash); - client.chat(&command_with_slash); + let command = command.strip_prefix('/').unwrap_or(command); + tracing::debug!("Sending command: /{}", command); + // Client::chat truncates both chat and commands to 256 characters. + // Inline Minecraft dialogs regularly exceed that, so send the command + // packet directly; the protocol supports the full command string. + client.write_packet( + azalea::protocol::packets::game::s_chat_command::ServerboundChatCommand { + command: command.to_string(), + }, + ); Ok(()) } @@ -480,9 +484,9 @@ impl TestBot { .world_name() .is_ok_and(|world| world.to_string() == "minecraft:overworld") && client.position().is_ok_and(|actual| { - (actual.x - pos[0]).abs() < 0.01 - && (actual.y - pos[1]).abs() < 0.75 - && (actual.z - pos[2]).abs() < 0.01 + (actual.x - pos[0]).abs() < POSITION_TOLERANCE + && (actual.y - pos[1]).abs() < TELEPORT_VERTICAL_TOLERANCE + && (actual.z - pos[2]).abs() < POSITION_TOLERANCE }) })?; let _ = updates.blocking_recv(); @@ -520,7 +524,7 @@ impl TestBot { } pub fn select_hotbar(&self, slot: u8) -> Result<()> { - if !(1..=9).contains(&slot) { + if !(1..=MAX_HOTBAR_SLOT).contains(&slot) { anyhow::bail!("hotbar slot must be in the range 1..=9"); } let client = { @@ -604,7 +608,10 @@ impl TestBot { self.teleport(pos, rotation)?; } None if active.position.is_some() || active.owner != Some(owner) => { - self.teleport([0.5, 64.0, 0.5], Some([0.0, 0.0]))?; + self.teleport( + [TEST_ORIGIN_CENTER, TEST_ORIGIN_HEIGHT, TEST_ORIGIN_CENTER], + Some([0.0, 0.0]), + )?; } _ => {} } @@ -718,7 +725,7 @@ impl TestBot { operation: &str, predicate: impl FnMut() -> bool, ) -> Result<()> { - self.wait_until_timeout( + Self::wait_until_timeout( operation, std::time::Duration::from_millis(STATE_SYNC_TIMEOUT_MS), predicate, @@ -726,7 +733,7 @@ impl TestBot { } pub(crate) fn wait_for_block_chunk(&self, pos: [i32; 3]) -> Result<()> { - self.wait_until_timeout( + Self::wait_until_timeout( "target block chunk availability", std::time::Duration::from_millis(CHUNK_SYNC_TIMEOUT_MS), || self.get_block(pos).is_ok_and(|block| block.is_some()), @@ -734,7 +741,6 @@ impl TestBot { } fn wait_until_timeout( - &self, operation: &str, timeout: std::time::Duration, mut predicate: impl FnMut() -> bool, diff --git a/src/executor/adapter.rs b/src/executor/adapter.rs index b0ff3f3..15015c7 100644 --- a/src/executor/adapter.rs +++ b/src/executor/adapter.rs @@ -1,5 +1,6 @@ use crate::bot::{TestBot, slot_to_minecraft_name}; use crate::executor::block; +use crate::executor::parsing::numbers_after_colon; use crate::executor::tick; use anyhow::Result; use flint_core::BlockPos; @@ -7,6 +8,11 @@ use flint_core::test_spec::{Block, EntityNbt, GameMode, Item, PlayerSlot}; use flint_core::traits::{EntityState, FlintAdapter, FlintPlayer, FlintWorld, ServerInfo}; use std::collections::HashMap; +const BLOCK_READ_ATTEMPTS: usize = 10; +const BLOCK_READ_RETRY_DELAY_MS: u64 = 50; +const COMMAND_QUERY_TIMEOUT_SECS: u64 = 3; +const MINECRAFT_DAY_TICKS: u64 = 24_000; + #[allow(dead_code)] pub struct MinecraftAdapter { bot: TestBot, @@ -130,7 +136,7 @@ impl FlintWorld for MinecraftWorld { fn get_block(&self, pos: BlockPos, requested_nbt: &[String]) -> Result { let world_pos = self.world_pos(pos); - for _ in 0..10 { + for _ in 0..BLOCK_READ_ATTEMPTS { if let Ok(Some(actual_block_str)) = self.bot.get_block(world_pos) { let normalized_id = block::extract_block_id(&actual_block_str); let mut block = block::make_block(&normalized_id); @@ -146,7 +152,7 @@ impl FlintWorld for MinecraftWorld { return Ok(block); } } - std::thread::sleep(std::time::Duration::from_millis(50)); + std::thread::sleep(std::time::Duration::from_millis(BLOCK_READ_RETRY_DELAY_MS)); } anyhow::bail!("timed out reading block at {world_pos:?}") @@ -338,7 +344,7 @@ impl FlintWorld for MinecraftWorld { fn query_entity_numbers(bot: &TestBot, selector: &str, path: &str) -> Result> { let message = query_entity_data_message(bot, selector, path)?; - let values = parse_numbers_after_colon(&message); + let values = numbers_after_colon(&message); if values.is_empty() { anyhow::bail!("entity query returned no numbers: {message}"); } @@ -349,7 +355,7 @@ fn query_entity_count(bot: &TestBot, selector: &str) -> Result { let _query_guard = bot.lock_command_query(); drain_chat(bot); bot.send_command(&format!("execute if entity {selector}"))?; - let timeout = std::time::Duration::from_secs(3); + let timeout = std::time::Duration::from_secs(COMMAND_QUERY_TIMEOUT_SECS); let started = std::time::Instant::now(); while started.elapsed() < timeout { if let Some((sender, message)) = @@ -374,11 +380,8 @@ fn query_entity_count(bot: &TestBot, selector: &str) -> Result { } pub(crate) fn query_daytime(bot: &TestBot) -> Result { - query_time_command(bot, "time query minecraft:day", "daytime").map(|time| time % 24_000) -} - -pub(crate) fn query_gametime(bot: &TestBot) -> Result { - query_time_command(bot, "time query gametime", "game time") + query_time_command(bot, "time query minecraft:day", "daytime") + .map(|time| time % MINECRAFT_DAY_TICKS) } fn query_time_command(bot: &TestBot, command: &str, label: &str) -> Result { @@ -386,7 +389,7 @@ fn query_time_command(bot: &TestBot, command: &str, label: &str) -> Result drain_chat(bot); bot.send_command(command)?; - let timeout = std::time::Duration::from_secs(3); + let timeout = std::time::Duration::from_secs(COMMAND_QUERY_TIMEOUT_SECS); let started = std::time::Instant::now(); while started.elapsed() < timeout { if let Some((sender, message)) = @@ -423,7 +426,7 @@ fn query_block_data(bot: &TestBot, pos: BlockPos, path: &str) -> Result "data get block {} {} {} {path}", pos[0], pos[1], pos[2] ))?; - let timeout = std::time::Duration::from_secs(3); + let timeout = std::time::Duration::from_secs(COMMAND_QUERY_TIMEOUT_SECS); let started = std::time::Instant::now(); while started.elapsed() < timeout { if let Some((sender, message)) = @@ -459,7 +462,7 @@ fn query_entity_data_message(bot: &TestBot, selector: &str, path: &str) -> Resul drain_chat(bot); bot.send_command(&format!("data get entity {selector} {path}"))?; - let timeout = std::time::Duration::from_secs(3); + let timeout = std::time::Duration::from_secs(COMMAND_QUERY_TIMEOUT_SECS); let started = std::time::Instant::now(); while started.elapsed() < timeout { @@ -487,40 +490,21 @@ fn drain_chat(bot: &TestBot) { {} } -fn parse_numbers_after_colon(message: &str) -> Vec { - let value_part = message - .split_once(':') - .map(|(_, value)| value) - .unwrap_or(message); - value_part - .split(|c: char| { - !(c.is_ascii_digit() || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E') - }) - .filter_map(|part| { - if part.is_empty() || part == "-" || part == "+" || part == "." { - None - } else { - part.parse::().ok() - } - }) - .collect() -} - fn summon_nbt_with_tag(nbt: Option<&str>, tag: &str) -> String { + let tag_only = || format!("{{Tags:[\"{tag}\"]}}"); match nbt.map(str::trim).filter(|nbt| !nbt.is_empty()) { - None => format!("{{Tags:[\"{tag}\"]}}"), - Some("{}") => format!("{{Tags:[\"{tag}\"]}}"), + None | Some("{}") => tag_only(), Some(nbt) if nbt.starts_with('{') && nbt.ends_with('}') => { let inner = &nbt[1..nbt.len() - 1]; if inner.trim().is_empty() { - format!("{{Tags:[\"{tag}\"]}}") + tag_only() } else { format!("{{{inner},Tags:[\"{tag}\"]}}") } } Some(nbt) => { tracing::warn!("Invalid summon NBT '{}', using only FlintMC alias tag", nbt); - format!("{{Tags:[\"{tag}\"]}}") + tag_only() } } } diff --git a/src/executor/block.rs b/src/executor/block.rs index cf40a63..6b494b7 100644 --- a/src/executor/block.rs +++ b/src/executor/block.rs @@ -18,85 +18,84 @@ pub fn properties_match(actual: &Block, expected: &Block) -> bool { /// Output: "minecraft:oak_fence[east=false,west=false]" pub fn extract_block_id(debug_str: &str) -> String { let s = debug_str.trim(); + let (name, properties) = block_state_parts(s); + let block_id = normalize_block_id(name); + format_properties(block_id, properties) +} - // 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([',', '}']).unwrap_or(inner.len()); - (inner[..end].trim(), None) - } else { - ("air", None) - } - } else { - // Raw string? - (s.split([',', '{', ' ', '}']).next().unwrap_or(s), None) +fn block_state_parts(value: &str) -> (&str, Option<&str>) { + if let Some(after_id) = value + .strip_prefix("BlockState(id:") + .and_then(|rest| rest.split_once(',').map(|(_, value)| value.trim())) + { + return split_name_and_properties(after_id); + } + if let Some(inner) = value + .strip_prefix("BlockState") + .and_then(|rest| rest.split_once('{').map(|(_, value)| value)) + { + let end = inner.find([',', '}']).unwrap_or(inner.len()); + return (inner[..end].trim(), None); + } + let name = value.split([',', '{', ' ', '}']).next().unwrap_or(value); + (name, None) +} + +fn split_name_and_properties(value: &str) -> (&str, Option<&str>) { + let Some(brace_start) = value.find('{') else { + let end = value.find(')').unwrap_or(value.len()); + return (value[..end].trim(), None); }; + let end = value.rfind('}').unwrap_or(value.len()); + ( + value[..brace_start].trim(), + Some(&value[brace_start + 1..end]), + ) +} - // 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); +fn normalize_block_id(name: &str) -> String { + let mut normalized = String::new(); + for (index, character) in name.chars().enumerate() { + if character.is_uppercase() && index > 0 { + normalized.push('_'); } + normalized.push(character.to_ascii_lowercase()); } - let block_id = if snake.contains(':') { - snake + if normalized.contains(':') { + normalized } 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(':') { - let key = k.trim().to_lowercase(); - let key = if key == "kind" { "type" } else { &key }; - pairs.push(format!("{}={}", key, v.trim().to_lowercase())); - } - } - if !pairs.is_empty() { - // Sort for deterministic output - pairs.sort(); - return format!("{}[{}]", block_id, pairs.join(",")); - } + format!("minecraft:{normalized}") } +} - block_id +fn format_properties(block_id: String, properties: Option<&str>) -> String { + let mut pairs: Vec<_> = properties + .into_iter() + .flat_map(|properties| properties.split(',')) + .filter_map(|property| property.trim().split_once(':')) + .map(|(key, value)| { + let key = match key.trim() { + "kind" => "type", + key => key, + }; + format!("{}={}", key.to_lowercase(), value.trim().to_lowercase()) + }) + .collect(); + if pairs.is_empty() { + return block_id; + } + pairs.sort(); + format!("{block_id}[{}]", pairs.join(",")) } /// 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 { + let base = Block { + id: block_str.to_string(), + properties: FxHashMap::default(), + nbt: None, + }; // Check for properties: "minecraft:oak_fence[east=true,west=false]" if let Some(open_bracket) = block_str.find('[') && let Some(close_bracket) = block_str.find(']') @@ -117,15 +116,11 @@ pub fn make_block(block_str: &str) -> Block { return Block { id, properties, - nbt: None, + ..base }; } - Block { - id: block_str.to_string(), - properties: FxHashMap::default(), - nbt: None, - } + base } /// Normalize block name for comparison (remove minecraft: prefix and underscores) diff --git a/src/executor/command_definitions.rs b/src/executor/command_definitions.rs new file mode 100644 index 0000000..88c7d84 --- /dev/null +++ b/src/executor/command_definitions.rs @@ -0,0 +1,72 @@ +// General interactive commands. Parsed by build.rs; not compiled directly. + +#[main_command(id = "Help", aliases = ["!help"], help = "!help - Show commands")] +fn help(executor: &mut TestExecutor, _context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.handle_help() +} + +#[main_command(id = "List", aliases = ["!list"], help = "!list - List all tests")] +fn list(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.handle_list(context.all_test_files) +} + +#[main_command(id = "Search", aliases = ["!search"], help = "!search - Search tests by name")] +fn search(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + if context.args.is_empty() { + executor.bot.send_command("say Usage: !search ")?; + return Ok(()); + } + executor.handle_search(context.all_test_files, &context.args.join(" ")) +} + +#[main_command(id = "Run", aliases = ["!run"], help = "!run [step] - Run a specific test")] +fn run(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + if context.args.is_empty() { + executor.bot.send_command("say Usage: !run [step]")?; + return Ok(()); + } + let (test_name, step_mode) = if context.args.last().map(String::as_str) == Some("step") && context.args.len() > 1 { + (context.args[..context.args.len() - 1].join(" "), true) + } else { + (context.args.join(" "), false) + }; + executor.handle_run(context.all_test_files, &test_name, step_mode) +} + +#[main_command( + id = "Failure", + aliases = ["!failure", "!failures"], + help = "!failure [test] - Show clickable links for the latest failed tests" +)] +fn failure(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.handle_failure_link(context.args, context.sender.as_deref()) +} + +#[main_command(id = "RunAll", aliases = ["!run-all"], help = "!run-all - Run all tests")] +fn run_all(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.handle_run_all(context.all_test_files) +} + +#[main_command(id = "RunTags", aliases = ["!run-tags"], help = "!run-tags - Run tests with tags")] +fn run_tags(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + if context.args.is_empty() { + executor.bot.send_command("say Usage: !run-tags ")?; + return Ok(()); + } + let tags = context.args[0].split(',').map(|tag| tag.trim().to_string()).collect::>(); + executor.handle_run_tags(context.test_loader, &tags) +} + +#[main_command(id = "Reload", aliases = ["!reload"], help = "!reload - Reload test files")] +fn reload(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + context.test_loader.verify_and_rebuild_index()?; + *context.all_test_files = context.test_loader.collect_all_test_files()?; + executor.bot.send_command(&format!("say Reloaded {} tests", context.all_test_files.len())) +} + +#[main_command(id = "Stop", aliases = ["!stop"], help = "!stop - Exit interactive mode")] +fn stop(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.bot.send_command("say Exiting interactive mode. Goodbye!")?; + context.exit_interactive = true; + Ok(()) +} diff --git a/src/executor/commands.rs b/src/executor/commands.rs new file mode 100644 index 0000000..bf88a85 --- /dev/null +++ b/src/executor/commands.rs @@ -0,0 +1,109 @@ +//! Generated main and recorder command registry and dispatch. +//! +//! General commands live in `executor/command_definitions.rs`; recorder commands +//! live in `executor/recorder/command_definitions.rs`. `build.rs` combines them. + +use super::TestExecutor; +use anyhow::Result; +use flint_core::loader::TestLoader; +use std::path::PathBuf; + +pub struct FlintCommandContext<'a> { + pub args: &'a [String], + pub sender: Option, + pub test_loader: &'a mut TestLoader, + pub all_test_files: &'a mut Vec, + pub exit_interactive: bool, +} + +include!(concat!(env!("OUT_DIR"), "/flint_commands.rs")); + +/// Primary aliases for commands available in a given interactive-mode scope. +pub(crate) fn command_aliases(scope: CommandScope, include_prefix: bool) -> String { + COMMANDS + .iter() + .filter(|spec| spec.scope == scope) + .filter_map(|spec| spec.aliases.first().copied()) + .map(|alias| { + if include_prefix { + alias + } else { + alias.strip_prefix('!').unwrap_or(alias) + } + }) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_dialog_triggers_and_callbacks_are_unique() { + let mut triggers = std::collections::HashSet::new(); + let mut callbacks = std::collections::HashSet::new(); + for spec in COMMANDS.iter().filter(|spec| spec.dialog.is_some()) { + assert_eq!(spec.scope, CommandScope::Recorder); + assert!(triggers.insert(spec.dialog.unwrap().trigger)); + assert!(callbacks.insert(spec.callback)); + assert_eq!(from_callback(spec.callback), Some(spec.command)); + } + } + + #[test] + fn generated_commands_keep_main_and_recorder_scopes_separate() { + assert_eq!( + COMMANDS + .iter() + .find(|spec| spec.aliases.contains(&"!record")) + .unwrap() + .scope, + CommandScope::Main + ); + assert_eq!( + COMMANDS + .iter() + .find(|spec| spec.aliases.contains(&"!recorder")) + .unwrap() + .scope, + CommandScope::Recorder + ); + } + + #[test] + fn generated_chat_aliases_are_unique() { + let mut aliases = std::collections::HashSet::new(); + for spec in COMMANDS { + for alias in spec.aliases { + assert!(aliases.insert(*alias)); + assert_eq!(from_chat(alias), Some(spec.command)); + } + } + } + + #[test] + fn main_command_hint_is_derived_from_registry() { + let expected = COMMANDS + .iter() + .filter(|spec| spec.scope == CommandScope::Main) + .filter_map(|spec| spec.aliases.first().copied()) + .collect::>() + .join(", "); + + assert_eq!(command_aliases(CommandScope::Main, true), expected); + assert!(command_aliases(CommandScope::Main, true).contains("!record")); + assert!(!command_aliases(CommandScope::Main, true).contains("!recorder")); + } + + #[test] + fn help_is_not_a_recorder_dialog_command() { + let help = COMMANDS + .iter() + .find(|spec| spec.aliases.contains(&"!help")) + .unwrap(); + + assert_eq!(help.scope, CommandScope::Main); + assert!(help.dialog.is_none()); + } +} diff --git a/src/executor/handlers.rs b/src/executor/handlers.rs index 08d9b6f..6ebb7e1 100644 --- a/src/executor/handlers.rs +++ b/src/executor/handlers.rs @@ -1,13 +1,82 @@ //! Command handlers for interactive mode -use anyhow::Result; +use anyhow::{Context, Result}; use flint_core::loader::TestLoader; -use flint_core::spatial::pair_tests_with_offsets; use flint_core::test_spec::TestSpec; use std::path::PathBuf; -use super::{DEFAULT_TESTS_DIR, TestExecutor, block, recorder, tick}; -use crate::spatial_batch::group_tests_by_world_config; +use super::{ + DEFAULT_TESTS_DIR, TestExecutor, block, commands, parsing::numbers_after_colon, recorder, tick, +}; +use crate::spatial_batch::{group_tests_by_world_config, pair_tests_with_offsets}; + +const RECORDER_GUIDANCE: &str = "Recorder commands work in chat; type !help to list them or !recorder to open the optional controls. Press Esc to close the controls; recording stays active."; + +fn block_snapshot_changes( + previous: &std::collections::HashMap<[i32; 3], String>, + current: &std::collections::HashMap<[i32; 3], String>, + min: [i32; 3], + max: [i32; 3], +) -> Vec<([i32; 3], Option)> { + let in_region = + |pos: &[i32; 3]| (0..3).all(|axis| pos[axis] >= min[axis] && pos[axis] <= max[axis]); + let mut positions: Vec<_> = previous + .keys() + .filter(|pos| in_region(pos)) + .chain(current.keys()) + .copied() + .collect(); + positions.sort_unstable(); + positions.dedup(); + + positions + .into_iter() + .filter_map(|pos| { + let before = previous.get(&pos); + let after = current.get(&pos); + (before != after).then(|| (pos, after.cloned())) + }) + .collect() +} + +fn position_within_scan(pos: [i32; 3], center: [i32; 3], radius: i32) -> bool { + (0..3).all(|axis| (pos[axis] - center[axis]).abs() <= radius) +} + +fn command_action(label: &str, command: &str) -> serde_json::Value { + serde_json::json!({ + "label": label, + "action": { "type": "run_command", "command": command } + }) +} + +fn ui_action(spec: &commands::FlintCommandSpec) -> serde_json::Value { + let dialog = spec.dialog.expect("dialog action spec"); + command_action( + dialog.label, + &format!("trigger flintmc_recorder set {}", dialog.trigger), + ) +} + +fn recorder_dialog(test_name: &str, tick: u32, action_count: usize) -> serde_json::Value { + let actions: Vec<_> = commands::COMMANDS + .iter() + .filter(|spec| spec.scope == commands::CommandScope::Recorder && spec.dialog.is_some()) + .map(ui_action) + .collect(); + serde_json::json!({ + "type": "minecraft:multi_action", + "title": format!("FlintMC Recorder: {test_name} ({tick}/{action_count})"), + "body": [{ + "type": "minecraft:plain_message", + "contents": { "text": RECORDER_GUIDANCE, "color": "yellow" } + }], + "columns": 2, + "actions": actions, + "pause": false, + "after_action": "wait_for_response" + }) +} /// Parse command parts from a chat message /// Returns (command, args) if a valid command was found @@ -19,6 +88,14 @@ pub fn parse_command(message: &str) -> Option<(String, Vec)> { let msg_lower = message.to_lowercase(); + if let Some(action) = msg_lower + .split_whitespace() + .find_map(|part| part.strip_prefix("__flintmc_recorder_")) + { + let command = commands::from_callback(action)?; + return Some((commands::primary_alias(command).to_string(), Vec::new())); + } + // Extract command from message (look for !command pattern) let cmd_start = msg_lower.find('!')?; let command_str = &message[cmd_start..]; @@ -34,10 +111,15 @@ pub fn parse_command(message: &str) -> Option<(String, Vec)> { Some((command, args)) } -fn load_test_specs(test_files: &[PathBuf]) -> impl Iterator + '_ { - test_files - .iter() - .filter_map(|test_file| TestSpec::from_file(test_file, false).ok()) +pub fn is_bot_sender(sender: Option<&str>) -> bool { + sender == Some("flintmc_testbot") +} + +fn load_test_specs(test_files: &[PathBuf]) -> impl Iterator> + '_ { + test_files.iter().map(|test_file| { + TestSpec::from_file(test_file, false) + .with_context(|| format!("Failed to load test {}", test_file.display())) + }) } fn test_label(test: &TestSpec) -> String { @@ -48,51 +130,119 @@ fn test_label(test: &TestSpec) -> String { } } -fn find_test(test_files: &[PathBuf], name: &str) -> Option { +fn find_test(test_files: &[PathBuf], name: &str) -> (Option, Vec) { let name = name.to_lowercase(); let mut partial_match = None; + let mut errors = Vec::new(); for test in load_test_specs(test_files) { + let test = match test { + Ok(test) => test, + Err(error) => { + errors.push(error); + continue; + } + }; let test_name = test.name.to_lowercase(); if test_name == name { - return Some(test); + return (Some(test), errors); } if partial_match.is_none() && test_name.contains(&name) { partial_match = Some(test); } } - partial_match + (partial_match, errors) } impl TestExecutor { // Command handlers - pub(super) fn handle_help(&mut self) -> Result<()> { - self.bot.send_command("say Commands:")?; - self.bot - .send_command("say !search - Search tests by name")?; - self.bot - .send_command("say !run [step] - Run a specific test")?; - self.bot.send_command("say !run-all - Run all tests")?; - self.bot - .send_command("say !run-tags - Run tests with tags")?; - self.bot.send_command("say !list - List all tests")?; - self.bot.send_command("say !reload - Reload test files")?; - self.bot - .send_command("say Recorder: !record , !tick/!next, !save, !cancel")?; + fn report_test_load_errors(&self, errors: &[anyhow::Error]) -> Result<()> { + for error in errors { + for line in format!("{error:#}").lines() { + self.bot.send_command(&format!("say Error: {line}"))?; + } + } + Ok(()) + } + + pub(super) fn show_recorder_dialog(&self) -> Result<()> { + let Some(recorder) = self.recorder.as_ref() else { + return Ok(()); + }; + let target = recorder.player_name.as_deref().unwrap_or("@p"); + let action_count = recorder + .timeline + .iter() + .map(|step| step.actions.len()) + .sum(); + self.bot.send_command(&format!( + "scoreboard players enable {target} flintmc_recorder" + ))?; + self.show_dialog( + target, + &recorder_dialog(&recorder.test_name, recorder.current_tick, action_count), + ) + } + + fn show_dialog(&self, target: &str, dialog: &serde_json::Value) -> Result<()> { + validate_entity_target(target)?; self.bot - .send_command("say Recorder actions: !assert , !assert_changes")?; - self.bot.send_command( - "say Recorder actions: !use [item] - record tp + interact at player pose", - )?; - self.bot.send_command( - "say Recorder actions: !pos1 , !pos - Allow to use assert for a 3d area", - )?; + .send_command(&format!("dialog show {target} {dialog}")) + } + + pub(super) fn poll_recorder_dialog_action(&self) -> Result<()> { + if self.recorder.is_none() { + return Ok(()); + } + for spec in commands::COMMANDS + .iter() + .filter(|spec| spec.dialog.is_some()) + { + let dialog = spec.dialog.expect("filtered dialog action"); + let action = commands::callback_id(spec.command); + self.bot.send_command(&format!( + "execute as @a[scores={{flintmc_recorder={}}}] run tell flintmc_testbot __flintmc_recorder_{action}", + dialog.trigger + ))?; + } self.bot.send_command( - "say Recorder actions: !sprint - ticks this ticks and asserts after each tick", + "scoreboard players reset @a[scores={flintmc_recorder=1..}] flintmc_recorder", )?; - self.bot.send_command("say !stop - Exit interactive mode")?; + Ok(()) + } + + pub(super) fn consume_recorder_dialog_action(&self, sender: Option<&str>) -> Result<()> { + let target = sender + .or_else(|| { + self.recorder + .as_ref() + .and_then(|recorder| recorder.player_name.as_deref()) + }) + .unwrap_or("@a[name=!flintmc_testbot,limit=1,sort=nearest]"); + validate_entity_target(target)?; + // Reset before the handler refreshes and re-enables the trigger. This + // makes a button click one-shot even if multiple poll responses arrive. + self.bot.send_command(&format!( + "scoreboard players reset {target} flintmc_recorder" + )) + } + + pub(super) fn handle_help(&mut self) -> Result<()> { + self.bot.send_command("say Commands:")?; + let active_scope = if self.recorder.is_some() { + commands::CommandScope::Recorder + } else { + commands::CommandScope::Main + }; + for help in commands::COMMANDS + .iter() + .filter(|spec| spec.scope == active_scope) + .filter_map(|spec| spec.help) + { + self.bot.send_command(&format!("say {help}"))?; + } Ok(()) } @@ -100,8 +250,12 @@ impl TestExecutor { self.bot .send_command(&format!("say Found {} tests:", all_test_files.len()))?; for test in load_test_specs(all_test_files) { - self.bot - .send_command_synced(&format!("say - {}", test_label(&test)))?; + match test { + Ok(test) => self + .bot + .send_command_synced(&format!("say - {}", test_label(&test)))?, + Err(error) => self.report_test_load_errors(&[error])?, + } } Ok(()) } @@ -114,6 +268,13 @@ impl TestExecutor { let pattern_lower = pattern.to_lowercase(); let mut found = 0; for test in load_test_specs(all_test_files) { + let test = match test { + Ok(test) => test, + Err(error) => { + self.report_test_load_errors(&[error])?; + continue; + } + }; if test.name.to_lowercase().contains(&pattern_lower) { self.bot .send_command_synced(&format!("say - {}", test_label(&test)))?; @@ -136,7 +297,10 @@ impl TestExecutor { test_name: &str, step_mode: bool, ) -> Result<()> { - if let Some(test) = find_test(all_test_files, test_name) { + self.last_failure_links.clear(); + let (test, load_errors) = find_test(all_test_files, test_name); + self.report_test_load_errors(&load_errors)?; + if let Some(test) = test { if step_mode { self.bot.send_command(&format!( "say Running test: {} (step mode - type 's' or 'c')", @@ -155,7 +319,7 @@ impl TestExecutor { self.bot .send_command(&format!("say [{}] {}", status, result.test_name))?; } - } else { + } else if load_errors.is_empty() { self.bot .send_command(&format!("say Test '{}' not found", test_name))?; } @@ -163,12 +327,18 @@ impl TestExecutor { } pub(super) fn handle_run_all(&mut self, all_test_files: &[std::path::PathBuf]) -> Result<()> { + self.last_failure_links.clear(); self.bot.send_command(&format!( "say Running all {} tests...", all_test_files.len() ))?; - let (passed, failed) = self.run_test_groups(load_test_specs(all_test_files).collect())?; + let (tests, load_errors): (Vec<_>, Vec<_>) = + load_test_specs(all_test_files).partition(Result::is_ok); + let tests = tests.into_iter().filter_map(Result::ok).collect(); + let load_errors: Vec<_> = load_errors.into_iter().filter_map(Result::err).collect(); + self.report_test_load_errors(&load_errors)?; + let (passed, failed) = self.run_test_groups(tests)?; self.bot.send_command(&format!( "say Results: {} passed, {} failed", passed, failed @@ -181,6 +351,7 @@ impl TestExecutor { test_loader: &TestLoader, tags: &[String], ) -> Result<()> { + self.last_failure_links.clear(); let test_files = test_loader.collect_by_tags(tags); if test_files.is_empty() { @@ -195,7 +366,12 @@ impl TestExecutor { tags ))?; - let (passed, failed) = self.run_test_groups(load_test_specs(&test_files).collect())?; + let (tests, load_errors): (Vec<_>, Vec<_>) = + load_test_specs(&test_files).partition(Result::is_ok); + let tests = tests.into_iter().filter_map(Result::ok).collect(); + let load_errors: Vec<_> = load_errors.into_iter().filter_map(Result::err).collect(); + self.report_test_load_errors(&load_errors)?; + let (passed, failed) = self.run_test_groups(tests)?; self.bot.send_command(&format!( "say Results: {} passed, {} failed", passed, failed @@ -203,6 +379,44 @@ impl TestExecutor { Ok(()) } + pub(super) fn handle_failure_link(&self, args: &[String], sender: Option<&str>) -> Result<()> { + let filter = args.join(" ").to_lowercase(); + let links: Vec<_> = self + .last_failure_links + .iter() + .filter(|(test_name, _)| { + filter.is_empty() || test_name.to_lowercase().contains(&filter) + }) + .collect(); + + if links.is_empty() { + self.bot + .send_command("say No matching failures from the latest test run.")?; + return Ok(()); + } + + let target = sender.unwrap_or("@a[name=!flintmc_testbot]"); + validate_entity_target(target)?; + for (test_name, url) in links { + let message = serde_json::json!({ + "text": format!("[Open failure: {test_name}]"), + "color": "red", + "underlined": true, + "click_event": { + "action": "open_url", + "url": url + }, + "hover_event": { + "action": "show_text", + "value": { "text": "Open this failure in FlintViz" } + } + }); + self.bot + .send_command(&format!("tellraw {target} {message}"))?; + } + Ok(()) + } + fn run_test_groups(&mut self, specs: Vec) -> Result<(usize, usize)> { let mut passed = 0; let mut failed = 0; @@ -217,6 +431,36 @@ impl TestExecutor { // Recorder command handlers + fn record_player_is_within_scan(&self) -> Result { + let Some(recorder) = self.recorder.as_ref() else { + return Ok(false); + }; + let center = recorder.scan_center.unwrap_or([0, 64, 0]); + let radius = recorder.scan_radius; + let (player_pos, _) = self.query_record_player_pose(recorder)?; + let player_pos = [ + player_pos[0].floor() as i32, + player_pos[1].floor() as i32, + player_pos[2].floor() as i32, + ]; + + if position_within_scan(player_pos, center, radius) { + return Ok(true); + } + + self.bot.send_command(&format!( + "say Recorder warning: player [{}, {}, {}] is outside the scan centered at [{}, {}, {}] with radius {}. Move back before recording changes.", + player_pos[0], + player_pos[1], + player_pos[2], + center[0], + center[1], + center[2], + radius + ))?; + Ok(false) + } + pub(super) fn handle_record_start( &mut self, test_name: &str, @@ -231,8 +475,10 @@ impl TestExecutor { 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())); + // Never let the fallback select FlintMC itself: @p is relative to the + // command source and therefore commonly resolves to the bot. + recorder_state.player_name = player_name + .or_else(|| Some("@a[name=!flintmc_testbot,limit=1,sort=nearest]".to_string())); // Get tracked player position to set scan center. let scan_center = match self.query_record_player_pose(&recorder_state) { @@ -241,32 +487,35 @@ impl TestExecutor { pos[1].floor() as i32, pos[2].floor() as i32, ], - Err(_) => { + Err(error) => { self.bot.send_command( - "say Warning: Could not get player position, using bot position", + "say Recorder could not start: failed to locate the tracked player", )?; - self.bot.get_position().unwrap_or([0, 64, 0]) + eprintln!("Recorder could not query tracked player position: {error:#}"); + return Ok(()); } }; 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)?; recorder_state.snapshot = initial_blocks; self.recorder = Some(recorder_state); + self.recorder_dialog_enabled = false; + + // Dialog buttons communicate through a trigger objective, avoiding the + // confirmation screen Minecraft shows for chat-sending click actions. + self.bot + .send_command("scoreboard objectives add flintmc_recorder trigger")?; // Freeze time for controlled recording self.bot.send_command_synced("tick freeze")?; + let guidance = serde_json::json!({ "text": RECORDER_GUIDANCE, "color": "yellow" }); self.bot - .send_command(&format!("say Recording started: {}", test_name))?; - self.bot - .send_command("say Time frozen. Block changes will be detected automatically!")?; - self.bot - .send_command("say Commands: !assert, !use, !tick, !save, !cancel")?; + .send_command(&format!("tellraw @a[name=!flintmc_testbot] {guidance}"))?; Ok(()) } @@ -279,23 +528,26 @@ impl TestExecutor { return Ok(()); } - let current_tick = self.recorder.as_ref().unwrap().current_tick; + if !self.record_player_is_within_scan()? { + return Ok(()); + } - // Snapshot before advancing tick to capture all changes - self.handle_record_snapshot()?; + // Refresh the buffer without adding actions, then step exactly one tick. + let (scan_center, scan_radius) = { + let recorder = self.recorder.as_ref().unwrap(); + ( + recorder.scan_center.unwrap_or([0, 64, 0]), + recorder.scan_radius, + ) + }; + let current_blocks = self.scan_blocks_around(scan_center, scan_radius)?; + self.recorder.as_mut().unwrap().snapshot = current_blocks; - // Step the game tick tick::step_tick(&mut self.bot, false)?; // 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 - ))?; Ok(()) } @@ -351,9 +603,8 @@ impl TestExecutor { let block_id = block::extract_block_id(&block_str); let recorder = self.recorder.as_mut().unwrap(); recorder.add_assertion(pos, &block_id); - self.bot.send_command(&format!( - "say Added assert at [{}, {}, {}] = {}", + "say Recorder: asserted [{}, {}, {}] is {}", pos[0], pos[1], pos[2], block_id ))?; } else { @@ -366,16 +617,72 @@ impl TestExecutor { Ok(()) } + pub(super) fn handle_record_assert_target(&mut self) -> Result<()> { + let Some(pos) = self.looked_at_record_block()? else { + self.bot + .send_command("say Recorder: no block in sight within 6 blocks.")?; + return Ok(()); + }; + let args = pos.map(|coordinate| coordinate.to_string()).to_vec(); + self.last_assert_pos = args.clone(); + self.handle_record_assert(&args) + } + + pub(super) fn handle_record_pos1_target(&mut self) -> Result<()> { + let Some(pos) = self.looked_at_record_block()? else { + self.bot + .send_command("say Recorder: no block in sight within 6 blocks.")?; + return Ok(()); + }; + self.pos1 = Some(pos); + Ok(()) + } + + pub(super) fn handle_record_sprint_target(&mut self) -> Result<()> { + let Some(pos) = self.looked_at_record_block()? else { + self.bot + .send_command("say Recorder: no block in sight within 6 blocks.")?; + return Ok(()); + }; + self.last_assert_pos = pos.map(|coordinate| coordinate.to_string()).to_vec(); + self.handle_record_sprint(1) + } + pub(super) fn handle_record_assert_changes(&mut self) -> Result<()> { - let Some(recorder) = self.require_recorder() else { + if self.recorder.is_none() { self.bot.send_command("say No recording in progress.")?; return Ok(()); - }; + } + + if !self.record_player_is_within_scan()? { + return Ok(()); + } + + let recorder = self.recorder.as_ref().unwrap(); + + let scan_radius = recorder.scan_radius; + let scan_center = recorder.scan_center.unwrap_or([0, 64, 0]); + let min = [ + scan_center[0] - scan_radius, + (scan_center[1] - scan_radius).max(-64), + scan_center[2] - scan_radius, + ]; + let max = [ + scan_center[0] + scan_radius, + (scan_center[1] + scan_radius).min(319), + scan_center[2] + scan_radius, + ]; + let current_blocks = self.scan_region(min, max)?; + let changes = block_snapshot_changes(&recorder.snapshot, ¤t_blocks, min, max); - let count = recorder.convert_actions_to_asserts(); + let recorder = self.recorder.as_mut().unwrap(); + for (pos, current_block) in &changes { + recorder.add_assertion(*pos, current_block.as_deref().unwrap_or("minecraft:air")); + } + recorder.snapshot = current_blocks; + let count = changes.len(); self.bot.send_command(&format!( - "say Converted {} actions to assertions for this tick.", - count + "say Recorder: converted {count} block changes to assertions" ))?; Ok(()) } @@ -403,17 +710,6 @@ impl TestExecutor { let recorder = self.recorder.as_mut().unwrap(); recorder.record_use(pos, Some(rot), item.clone()); - self.bot.send_command(&format!( - "say Recorded use at [{:.2}, {:.2}, {:.2}] rot [{:.1}, {:.1}]{}", - pos[0], - pos[1], - pos[2], - rot[0], - rot[1], - item.as_ref() - .map(|item| format!(" with {item}")) - .unwrap_or_default() - ))?; Ok(()) } @@ -460,66 +756,51 @@ impl TestExecutor { } pub(super) fn handle_record_snapshot(&mut self) -> Result<()> { - let recorder = match self.recorder.as_ref() { - Some(r) => r, - None => { - self.bot.send_command("say No recording in progress.")?; - return Ok(()); - } - }; + if self.recorder.is_none() { + self.bot.send_command("say No recording in progress.")?; + return Ok(()); + } + + if !self.record_player_is_within_scan()? { + return Ok(()); + } + + let recorder = self.recorder.as_ref().unwrap(); let scan_radius = recorder.scan_radius; let scan_center = recorder.scan_center.unwrap_or([0, 64, 0]); - - self.bot.send_command("say Scanning for block changes...")?; + let min = [ + scan_center[0] - scan_radius, + (scan_center[1] - scan_radius).max(-64), + scan_center[2] - scan_radius, + ]; + let max = [ + scan_center[0] + scan_radius, + (scan_center[1] + scan_radius).min(319), + scan_center[2] + scan_radius, + ]; // Scan current blocks - let current_blocks = self.scan_blocks_around(scan_center, scan_radius)?; + let current_blocks = self.scan_region(min, max)?; - // Compare with initial snapshot and record differences - let mut changes = 0; + // Compare with the previous saved world state. Both scans contain only + // non-air blocks, so a missing current entry is a removal. + let changes = block_snapshot_changes(&recorder.snapshot, ¤t_blocks, min, max); 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 in initial_snapshot.keys() { - 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 - recorder.record_remove(*pos); - changes += 1; + for (pos, current_block) in &changes { + match current_block { + Some(block) => recorder.record_place(*pos, block), + None => recorder.record_remove(*pos), } } - - self.bot - .send_command(&format!("say Found {} block changes", changes))?; + recorder.snapshot = current_blocks; + self.bot.send_command(&format!( + "say Recorder: snapshot centered at [{}, {}, {}], recorded {} block changes", + scan_center[0], + scan_center[1], + scan_center[2], + changes.len() + ))?; Ok(()) } @@ -546,12 +827,51 @@ impl TestExecutor { &self, recorder: &recorder::RecorderState, ) -> Result<([f64; 3], [f32; 2])> { - let target = recorder.player_name.as_deref().unwrap_or("@p"); + let target = recorder + .player_name + .as_deref() + .unwrap_or("@a[name=!flintmc_testbot,limit=1,sort=nearest]"); let pos = self.query_entity_vec3(target, "Pos")?; let rot = self.query_entity_vec2_f32(target, "Rotation")?; Ok((pos, rot)) } + fn looked_at_record_block(&self) -> Result> { + let Some(recorder) = self.recorder.as_ref() else { + return Ok(None); + }; + let (feet, rot) = self.query_record_player_pose(recorder)?; + let yaw = (rot[0] as f64).to_radians(); + let pitch = (rot[1] as f64).to_radians(); + let direction = [ + -yaw.sin() * pitch.cos(), + -pitch.sin(), + yaw.cos() * pitch.cos(), + ]; + let eye = [feet[0], feet[1] + 1.62, feet[2]]; + let mut previous = None; + for step in 1..=120 { + let distance = step as f64 * 0.05; + let pos = [ + (eye[0] + direction[0] * distance).floor() as i32, + (eye[1] + direction[1] * distance).floor() as i32, + (eye[2] + direction[2] * distance).floor() as i32, + ]; + if previous == Some(pos) { + continue; + } + previous = Some(pos); + if let Some(block) = self.bot.get_block(pos)? + && !block::extract_block_id(&block) + .to_lowercase() + .contains("air") + { + return Ok(Some(pos)); + } + } + Ok(None) + } + fn query_entity_vec3(&self, target: &str, path: &str) -> Result<[f64; 3]> { let values = self.query_entity_numbers(target, path)?; if values.len() < 3 { @@ -588,7 +908,7 @@ impl TestExecutor { .recv_chat_timeout(std::time::Duration::from_millis(tick::CHAT_POLL_TIMEOUT_MS)) { if message.contains(path) || message.contains("entity data") { - let values = parse_numbers_after_colon(&message); + let values = numbers_after_colon(&message); if !values.is_empty() { return Ok(values); } @@ -615,21 +935,106 @@ fn validate_entity_target(target: &str) -> Result<()> { Ok(()) } -fn parse_numbers_after_colon(message: &str) -> Vec { - let value_part = message - .split_once(':') - .map(|(_, value)| value) - .unwrap_or(message); - value_part - .split(|c: char| { - !(c.is_ascii_digit() || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E') - }) - .filter_map(|part| { - if part.is_empty() || part == "-" || part == "+" || part == "." { - None - } else { - part.parse::().ok() - } - }) - .collect() +#[cfg(test)] +mod tests { + use super::{ + RECORDER_GUIDANCE, block_snapshot_changes, find_test, is_bot_sender, parse_command, + position_within_scan, recorder_dialog, + }; + + #[test] + fn scan_boundary_is_inclusive() { + assert!(position_within_scan([42, 32, -32], [10, 0, 0], 32)); + assert!(!position_within_scan([43, 32, -32], [10, 0, 0], 32)); + assert!(!position_within_scan([42, 33, -32], [10, 0, 0], 32)); + } + + #[test] + fn snapshot_diff_records_additions_changes_and_removals_in_position_order() { + let previous = std::collections::HashMap::from([ + ([2, 0, 0], "minecraft:stone".to_string()), + ([0, 0, 0], "minecraft:dirt".to_string()), + ]); + let current = std::collections::HashMap::from([ + ([0, 0, 0], "minecraft:grass_block".to_string()), + ([1, 0, 0], "minecraft:oak_planks".to_string()), + ]); + + assert_eq!( + block_snapshot_changes(&previous, ¤t, [-10, -10, -10], [10, 10, 10]), + vec![ + ([0, 0, 0], Some("minecraft:grass_block".to_string())), + ([1, 0, 0], Some("minecraft:oak_planks".to_string())), + ([2, 0, 0], None), + ] + ); + assert!( + block_snapshot_changes(¤t, ¤t, [-10, -10, -10], [10, 10, 10]).is_empty() + ); + } + + #[test] + fn snapshot_diff_does_not_remove_blocks_outside_moving_scan_window() { + let previous = std::collections::HashMap::from([ + ([0, 64, 0], "minecraft:stone".to_string()), + ([100, 64, 100], "minecraft:dirt".to_string()), + ]); + let current = + std::collections::HashMap::from([([101, 64, 100], "minecraft:oak_planks".to_string())]); + + assert_eq!( + block_snapshot_changes(&previous, ¤t, [90, 54, 90], [110, 74, 110]), + vec![ + ([100, 64, 100], None), + ([101, 64, 100], Some("minecraft:oak_planks".to_string())), + ] + ); + } + + #[test] + fn recorder_dialog_callback_maps_to_existing_command() { + assert_eq!( + parse_command("__flintmc_recorder_assert_changes"), + Some(("!assert_changes".to_string(), vec![])) + ); + assert_eq!( + parse_command("__flintmc_recorder_snapshot"), + Some(("!snapshot".to_string(), vec![])) + ); + } + + #[test] + fn identifies_bot_as_command_sender() { + assert!(is_bot_sender(Some("flintmc_testbot"))); + assert!(!is_bot_sender(Some("Coco9486"))); + assert!(!is_bot_sender(None)); + } + + #[test] + fn recorder_dialog_explains_chat_commands_and_escape() { + let dialog = recorder_dialog("example", 2, 3); + let guidance = dialog["body"][0]["contents"]["text"] + .as_str() + .expect("recorder dialog guidance"); + + assert_eq!(guidance, RECORDER_GUIDANCE); + assert!(guidance.contains("!help")); + assert!(guidance.contains("Esc")); + assert!(guidance.contains("!recorder")); + assert!( + dialog["actions"] + .as_array() + .is_some_and(|actions| !actions.is_empty()) + ); + } + + #[test] + fn test_lookup_preserves_load_errors() { + let missing = std::path::PathBuf::from("definitely-missing-flint-test.json"); + let (test, errors) = find_test(&[missing], "example"); + + assert!(test.is_none()); + assert_eq!(errors.len(), 1); + assert!(format!("{:#}", errors[0]).contains("Failed to load test")); + } } diff --git a/src/executor/mod.rs b/src/executor/mod.rs index 0468ab2..ca8c83e 100644 --- a/src/executor/mod.rs +++ b/src/executor/mod.rs @@ -3,8 +3,10 @@ mod actions; pub mod adapter; mod block; +pub(crate) mod commands; mod events; mod handlers; +mod parsing; mod recorder; mod tick; @@ -22,6 +24,7 @@ use std::io::Write; // Timing constants const DEFAULT_TESTS_DIR: &str = "FlintBenchmark/tests"; +const CHUNK_WIDTH: i32 = 16; fn asserted_entity_types(test: &TestSpec) -> BTreeSet { test.timeline @@ -57,6 +60,7 @@ pub struct TestRunOutput { pub struct TestExecutor { pub bot: TestBot, recorder: Option, + recorder_dialog_enabled: bool, verbose: bool, quiet: bool, fail_fast: bool, @@ -65,6 +69,7 @@ pub struct TestExecutor { events_path: Option, events: Option, enable_breakpoints: bool, + last_failure_links: Vec<(String, String)>, } impl Default for TestExecutor { @@ -72,6 +77,7 @@ impl Default for TestExecutor { Self { bot: TestBot::new(), recorder: None, + recorder_dialog_enabled: false, verbose: false, quiet: false, fail_fast: false, @@ -80,6 +86,7 @@ impl Default for TestExecutor { events_path: None, events: None, enable_breakpoints: true, + last_failure_links: Vec::new(), } } } @@ -130,7 +137,7 @@ impl TestExecutor { } /// Helper to apply offset to a position - fn apply_offset(&self, pos: [i32; 3], offset: [i32; 3]) -> [i32; 3] { + fn apply_offset(pos: [i32; 3], offset: [i32; 3]) -> [i32; 3] { [pos[0] + offset[0], pos[1] + offset[1], pos[2] + offset[2]] } @@ -141,12 +148,12 @@ impl TestExecutor { ) -> Result<()> { for (test, offset) in tests_with_offsets { let region = test.cleanup_region(); - let min = self.apply_offset(region[0], *offset); - let max = self.apply_offset(region[1], *offset); - let cx0 = min[0].div_euclid(16); - let cz0 = min[2].div_euclid(16); - let cx1 = max[0].div_euclid(16); - let cz1 = max[2].div_euclid(16); + let min = Self::apply_offset(region[0], *offset); + let max = Self::apply_offset(region[1], *offset); + let cx0 = min[0].div_euclid(CHUNK_WIDTH); + let cz0 = min[2].div_euclid(CHUNK_WIDTH); + let cx1 = max[0].div_euclid(CHUNK_WIDTH); + let cz1 = max[2].div_euclid(CHUNK_WIDTH); let verb = if add { "add" } else { "remove" }; let cmd = format!("forceload {verb} {cx0} {cz0} {cx1} {cz1}"); self.bot.send_command_synced(&cmd)?; @@ -159,170 +166,76 @@ impl TestExecutor { // Interactive mode always uses verbose output self.verbose = true; - // Send help message to chat (without ! to avoid self-triggering) + // Omit ! in bot-authored chat so the hint cannot trigger command parsing. self.bot .send_command_synced("say FlintMC Interactive Mode active")?; - self.bot.send_command_synced( - "say Type: help, search, run, run-all, run-tags, list, reload, stop (prefix with !)", - )?; + let command_hint = commands::command_aliases(commands::CommandScope::Main, false); + self.bot + .send_command_synced(&format!("say Type: {command_hint} (prefix with !)"))?; // Drain any messages tick::drain_chat_messages(&mut self.bot); // Collect all tests upfront let mut all_test_files = test_loader.collect_all_test_files()?; + let mut next_recorder_ui_poll = std::time::Instant::now(); + let mut last_recorder_ui_action = None::; loop { + if self.recorder.is_some() + && self.recorder_dialog_enabled + && std::time::Instant::now() >= next_recorder_ui_poll + { + self.poll_recorder_dialog_action()?; + next_recorder_ui_poll = + std::time::Instant::now() + std::time::Duration::from_millis(500); + } // Poll for chat messages if let Some((sender, message)) = self .bot .recv_chat_timeout(std::time::Duration::from_millis(tick::CHAT_POLL_TIMEOUT_MS)) { + if handlers::is_bot_sender(sender.as_deref()) { + continue; + } let Some((command, args)) = handlers::parse_command(&message) else { continue; }; - - match command.as_str() { - "!help" => { - self.handle_help()?; - } - - "!list" => { - self.handle_list(&all_test_files)?; - } - - "!search" => { - if args.is_empty() { - self.bot.send_command("say Usage: !search ")?; - continue; - } - let pattern = args.join(" "); - self.handle_search(&all_test_files, &pattern)?; - } - - "!run" => { - if args.is_empty() { - self.bot - .send_command("say Usage: !run [step]")?; - 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)?; - } - - "!run-all" => { - self.handle_run_all(&all_test_files)?; - } - - "!run-tags" => { - if args.is_empty() { - self.bot - .send_command("say Usage: !run-tags ")?; - continue; - } - let tags: Vec = - args[0].split(',').map(|s| s.trim().to_string()).collect(); - self.handle_run_tags(test_loader, &tags)?; - } - - "!stop" => { - self.bot - .send_command("say Exiting interactive mode. Goodbye!")?; - 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() - ))?; - } - - // Recorder commands - "!record" => { - if args.is_empty() { - self.bot - .send_command("say Usage: !record [player_name]")?; - self.bot.send_command( - "say Example: !record my_test or !record fence/fence_connect", - )?; - continue; - } - let test_name = args[0].clone(); - let player_name = args.get(1).cloned().or_else(|| sender.clone()); - self.handle_record_start(&test_name, test_loader, player_name)?; - } - "!assert_changes" => { - self.handle_record_assert_changes()?; - } - - "!use" => { - self.handle_record_use(&args)?; - } - - "!tick" | "!next" => { - self.handle_record_tick()?; - } - - "!pos1" | "!pos" => { - if (!args.is_empty() && args.len() < 3) || args.len() > 3 { - self.bot.send_command("say Usage: !assert ")?; - continue; - } - self.handle_pos1(&args); + let is_recorder_ui_action = message.contains("__flintmc_recorder_"); + if is_recorder_ui_action { + let now = std::time::Instant::now(); + if last_recorder_ui_action + .is_some_and(|previous| now.duration_since(previous).as_millis() < 750) + { + continue; } + last_recorder_ui_action = Some(now); + self.consume_recorder_dialog_action(sender.as_deref())?; + } - "!assert" => { - if args.len() < 3 { - self.bot.send_command("say Usage: !assert ")?; - continue; - } - self.last_assert_pos = args.clone(); - self.handle_record_assert(&args)?; - } - "!sprint" => { - if args.len() != 1 { - self.bot.send_command("say Usage: !sprint ")?; - self.bot.send_command( - "say: please be assert before a start state of a block/region", - )?; - continue; - } - let ticks = args[0].parse::().unwrap_or(1); - if ticks == 0 { - self.bot - .send_command("say Sprint ticks must be greater than 0")?; - continue; - } - if self.last_assert_pos.is_empty() { - self.bot.send_command("say Please assert a position first, which should be used for each string (can be also a 3d area)")?; - continue; + match command.as_str() { + _ if commands::from_chat(&command).is_some() => { + let flint_command = commands::from_chat(&command).expect("guarded command"); + let opens_recorder_dialog = command == "!recorder"; + let mut context = commands::FlintCommandContext { + args: &args, + sender: sender.clone(), + test_loader, + all_test_files: &mut all_test_files, + exit_interactive: false, + }; + commands::dispatch(self, flint_command, &mut context)?; + if context.exit_interactive { + return Ok(()); } - self.handle_record_sprint(ticks)?; - } - - "!save" => { - if self.handle_record_save()? { - // Reload tests after successful save - test_loader.verify_and_rebuild_index()?; - all_test_files = test_loader.collect_all_test_files()?; + if self.recorder.is_some() + && (is_recorder_ui_action || opens_recorder_dialog) + { + self.recorder_dialog_enabled = true; + self.show_recorder_dialog()?; } } - "!cancel" => { - self.handle_record_cancel()?; - } - _ => { if command.starts_with('!') { self.bot.send_command(&format!( @@ -405,10 +318,6 @@ impl TestExecutor { fn configure_batch_world(&mut self, first: &TestSpec) -> Result<()> { self.bot.send_command_synced("tick freeze")?; - let game_time = adapter::query_gametime(&self.bot)?; - for _ in 0..(20 - game_time % 20) % 20 { - tick::step_tick(&mut self.bot, false)?; - } let world_config = first.world_config(); let mut gamerules: Vec<_> = world_config.gamerules.iter().collect(); @@ -426,8 +335,8 @@ impl TestExecutor { fn cleanup_test_area(&self, test: &TestSpec, offset: [i32; 3]) -> Result<()> { let region = test.cleanup_region(); - let min = self.apply_offset(region[0], offset); - let max = self.apply_offset(region[1], offset); + let min = Self::apply_offset(region[0], offset); + let max = Self::apply_offset(region[1], offset); self.bot.send_command_synced(&format!( "fill {} {} {} {} {} {} air", min[0], min[1], min[2], max[0], max[1], max[2] @@ -460,8 +369,8 @@ impl TestExecutor { current_tick: 0, entities: std::collections::HashMap::new(), entity_bounds: Some([ - self.apply_offset(region[0], *offset), - self.apply_offset(region[1], *offset), + Self::apply_offset(region[0], *offset), + Self::apply_offset(region[1], *offset), ]), } }) @@ -589,8 +498,8 @@ impl TestExecutor { let scan_bounds: Option<([i32; 3], [i32; 3])> = if self.events.is_some() { let (test, offset) = &tests_with_offsets[0]; let region = test.cleanup_region(); - let world_min = self.apply_offset(region[0], *offset); - let world_max = self.apply_offset(region[1], *offset); + let world_min = Self::apply_offset(region[0], *offset); + let world_max = Self::apply_offset(region[1], *offset); if let Some(events) = self.events.as_mut() { events.run_started(&test.name, [world_min, world_max])?; } @@ -890,6 +799,30 @@ impl TestExecutor { }) .collect(); + let base_url = std::env::var("FLINT_VIZ_URL") + .unwrap_or_else(|_| "https://flinttestmc.github.io/FlintViz/#".to_string()); + for (test_name, failure) in &failures { + let Some((test, _)) = tests_with_offsets + .iter() + .find(|(test, _)| test.name == *test_name) + else { + continue; + }; + let payload = flint_core::viz_link::FailurePayload::new( + test.clone(), + None, + vec![failure.clone()], + failure.tick(), + ); + if let Ok(url) = flint_core::viz_link::failure_url(&payload, &base_url) { + self.last_failure_links.push((test_name.clone(), url)); + } + } + if !failures.is_empty() { + self.bot + .send_command_synced("say Type !failure for clickable FlintViz failure links")?; + } + // A virtual player may have left the shared bot inside a test region. Park the // physical bot at the layout center after every run, including playerless runs. self.bot.park_at(layout_center)?; @@ -898,7 +831,7 @@ impl TestExecutor { } fn execute_action( - &mut self, + &self, world: &mut MinecraftWorld, player: &mut Option>, tick: u32, diff --git a/src/executor/parsing.rs b/src/executor/parsing.rs new file mode 100644 index 0000000..ef537bc --- /dev/null +++ b/src/executor/parsing.rs @@ -0,0 +1,25 @@ +pub(super) fn numbers_after_colon(message: &str) -> Vec { + let value = message.split_once(':').map_or(message, |(_, value)| value); + value + .split(|character: char| { + !(character.is_ascii_digit() || matches!(character, '-' | '+' | '.' | 'e' | 'E')) + }) + .filter_map(|part| match part { + "" | "-" | "+" | "." => None, + number => number.parse().ok(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::numbers_after_colon; + + #[test] + fn parses_signed_decimal_and_exponent_values() { + assert_eq!( + numbers_after_colon("Position: [1.5d, -2, +3e2]"), + vec![1.5, -2.0, 300.0] + ); + } +} diff --git a/src/executor/recorder/command_definitions.rs b/src/executor/recorder/command_definitions.rs new file mode 100644 index 0000000..bc10a20 --- /dev/null +++ b/src/executor/recorder/command_definitions.rs @@ -0,0 +1,198 @@ +// Recorder commands. Parsed by build.rs; not compiled directly. + +#[main_command( + id = "Start", + aliases = ["!record"], + help = "!record [player] - Start recording" +)] +fn start(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + if context.args.is_empty() { + executor + .bot + .send_command("say Usage: !record [player_name]")?; + return Ok(()); + } + let player_name = context + .args + .get(1) + .cloned() + .or_else(|| context.sender.clone()); + executor.handle_record_start(&context.args[0], context.test_loader, player_name) +} + +#[recorder_command( + id = "Open", + aliases = ["!recorder"], + help = "!recorder - Open recorder controls" +)] +fn open(executor: &mut TestExecutor, _context: &mut FlintCommandContext<'_>) -> Result<()> { + if executor.recorder.is_none() { + executor + .bot + .send_command("say No recording in progress. Use !record to start.")?; + } + Ok(()) +} + +#[recorder_command( + id = "Tick", + aliases = ["!tick", "!next"], + help = "!tick/!next - Advance exactly one tick", + label = "Next tick" +)] +fn tick(executor: &mut TestExecutor, _context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.handle_record_tick() +} + +#[recorder_command( + id = "Snapshot", + aliases = ["!snapshot", "!changes"], + help = "!snapshot/!changes - Record all block changes since the previous snapshot", + label = "Record block changes" +)] +fn snapshot(executor: &mut TestExecutor, _context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.handle_record_snapshot() +} + +#[recorder_command( + id = "Use", + aliases = ["!use"], + help = "!use [item] - Record interaction at player pose", + label = "Record use" +)] +fn use_item(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.handle_record_use(context.args) +} + +#[recorder_command( + id = "AssertChanges", + aliases = ["!assert_changes"], + help = "!assert_changes - Convert detected changes to assertions", + label = "Assert changes" +)] +fn assert_changes( + executor: &mut TestExecutor, + _context: &mut FlintCommandContext<'_>, +) -> Result<()> { + executor.handle_record_assert_changes() +} + +#[recorder_command( + id = "Assert", + aliases = ["!assert"], + help = "!assert - Assert block or selected region" +)] +fn assert(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + if context.args.len() < 3 { + executor + .bot + .send_command("say Usage: !assert ")?; + return Ok(()); + } + executor.last_assert_pos = context.args.to_vec(); + executor.handle_record_assert(context.args) +} + +#[recorder_command( + id = "AssertTarget", + aliases = ["!assert_target"], + label = "Assert looked-at block" +)] +fn assert_target( + executor: &mut TestExecutor, + _context: &mut FlintCommandContext<'_>, +) -> Result<()> { + executor.handle_record_assert_target() +} + +#[recorder_command( + id = "Position", + aliases = ["!pos1", "!pos"], + help = "!pos1 / !pos - Set or clear region corner", + label = "Clear first corner" +)] +fn position(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + if (!context.args.is_empty() && context.args.len() < 3) || context.args.len() > 3 { + executor + .bot + .send_command("say Usage: !pos1 ")?; + return Ok(()); + } + executor.handle_pos1(context.args); + Ok(()) +} + +#[recorder_command( + id = "PositionTarget", + aliases = ["!pos1_target"], + label = "Set corner from target" +)] +fn position_target( + executor: &mut TestExecutor, + _context: &mut FlintCommandContext<'_>, +) -> Result<()> { + executor.handle_record_pos1_target() +} + +#[recorder_command( + id = "Sprint", + aliases = ["!sprint"], + help = "!sprint - Advance and assert after each tick" +)] +fn sprint(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + if context.args.len() != 1 { + executor + .bot + .send_command("say Usage: !sprint ")?; + return Ok(()); + } + let ticks = context.args[0].parse::().unwrap_or(1); + if ticks == 0 { + executor + .bot + .send_command("say Sprint ticks must be greater than 0")?; + return Ok(()); + } else if executor.last_assert_pos.is_empty() { + executor + .bot + .send_command("say Please assert a position first, which should be used for each sprint")?; + return Ok(()); + } + executor.handle_record_sprint(ticks) +} + +#[recorder_command( + id = "SprintTarget", + aliases = ["!sprint_target"], + label = "Tick + assert target" +)] +fn sprint_target( + executor: &mut TestExecutor, + _context: &mut FlintCommandContext<'_>, +) -> Result<()> { + executor.handle_record_sprint_target() +} + +#[recorder_command( + id = "Save", + aliases = ["!save"], + help = "!save - Save recording", + label = "Save recording" +)] +fn save(executor: &mut TestExecutor, context: &mut FlintCommandContext<'_>) -> Result<()> { + if executor.handle_record_save()? { + context.test_loader.verify_and_rebuild_index()?; + *context.all_test_files = context.test_loader.collect_all_test_files()?; + } + Ok(()) +} + +#[recorder_command( + id = "Cancel", + aliases = ["!cancel"], + help = "!cancel - Discard recording", + label = "Cancel recording" +)] +fn cancel(executor: &mut TestExecutor, _context: &mut FlintCommandContext<'_>) -> Result<()> { + executor.handle_record_cancel() +} diff --git a/src/executor/recorder/state.rs b/src/executor/recorder/state.rs index 12e8317..8d2689a 100644 --- a/src/executor/recorder/state.rs +++ b/src/executor/recorder/state.rs @@ -14,7 +14,8 @@ use super::actions::{RecordedAction, TimelineStep}; use super::bounding_box::BoundingBox; // Constants -const DEFAULT_SCAN_RADIUS: i32 = 16; +/// Two chunks in every direction from the recorder's fixed center. +const DEFAULT_SCAN_RADIUS: i32 = 32; const DEFAULT_CLEANUP_REGION: [[i32; 3]; 2] = [[0, 0, 0], [10, 10, 10]]; /// State for an active recording session @@ -99,13 +100,18 @@ impl RecorderState { /// 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 { + if self + .timeline + .last() + .is_none_or(|step| step.tick != self.current_tick) + { self.timeline.push(TimelineStep { tick: self.current_tick, actions: Vec::new(), }); } - self.timeline.last_mut().unwrap() + let current_step = self.timeline.len() - 1; + &mut self.timeline[current_step] } /// Remove any existing Place/Remove actions for this position in the current tick @@ -157,8 +163,7 @@ impl RecorderState { /// 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; + self.set_origin(world_pos); } let local_pos = self.to_local(world_pos); @@ -170,8 +175,8 @@ impl RecorderState { 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()); + // Snapshots contain non-air blocks only. + self.snapshot.remove(&world_pos); } /// Add an assertion for a block @@ -212,47 +217,6 @@ impl RecorderState { step.actions.push(RecordedAction::Interact { item }); } - /// 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() - && 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); - } - player_action @ (RecordedAction::Tp { .. } - | RecordedAction::Interact { .. }) => { - new_actions.push(player_action); - } - } - } - - step.actions = new_actions; - } - - converted_count - } - /// Advance to the next tick pub fn next_tick(&mut self) { self.current_tick += 1; @@ -378,10 +342,28 @@ impl RecorderState { 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)?; + // flint-core's ActionType serializer writes absent optional action fields + // as null, while its deserializer expects those fields to be omitted. + let mut json = serde_json::to_value(&test_spec)?; + omit_null_timeline_fields(&mut json); + let json_str = serde_json::to_string_pretty(&json)?; std::fs::write(&self.test_path, json_str)?; Ok(self.test_path.clone()) } } + +fn omit_null_timeline_fields(json: &mut serde_json::Value) { + let Some(timeline) = json + .get_mut("timeline") + .and_then(|value| value.as_array_mut()) + else { + return; + }; + + for entry in timeline { + if let Some(fields) = entry.as_object_mut() { + fields.retain(|_, value| !value.is_null()); + } + } +} diff --git a/src/executor/recorder/tests.rs b/src/executor/recorder/tests.rs index c3f3ea4..6ab0056 100644 --- a/src/executor/recorder/tests.rs +++ b/src/executor/recorder/tests.rs @@ -28,6 +28,20 @@ fn test_local_position() { assert_eq!(recorder.to_local([105, 65, 198]), [5, 1, -2]); } +#[test] +fn test_removal_can_establish_origin_and_clears_snapshot() { + let mut recorder = RecorderState::new("test", std::path::Path::new("/tmp")); + recorder + .snapshot + .insert([10, 64, 20], "minecraft:stone".to_string()); + + recorder.record_remove([10, 64, 20]); + + assert_eq!(recorder.origin, Some([10, 64, 20])); + assert!(!recorder.snapshot.contains_key(&[10, 64, 20])); + assert_eq!(recorder.timeline[0].actions.len(), 1); +} + #[test] fn test_record_use_emits_tp_then_interact() { let mut recorder = RecorderState::new("test", std::path::Path::new("/tmp")); @@ -61,3 +75,27 @@ fn test_record_use_emits_tp_then_interact() { other => panic!("expected interact, got {other:?}"), } } + +#[test] +fn test_record_use_without_item_saves_a_loadable_spec() { + let temp_dir = std::env::temp_dir().join(format!( + "flintmc-recorder-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&temp_dir).unwrap(); + let mut recorder = RecorderState::new("active_hand", &temp_dir); + recorder.record_use([1.5, 64.0, 2.5], None, None); + + let path = recorder.save().unwrap(); + let json = std::fs::read_to_string(&path).unwrap(); + + assert!(!json.contains("\"item\": null")); + assert!(!json.contains("\"rot\": null")); + flint_core::test_spec::TestSpec::from_file(&path, false).unwrap(); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(temp_dir).unwrap(); +} diff --git a/src/main.rs b/src/main.rs index f063351..39af665 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,10 @@ use flint_core::format; use flint_core::format::{format_number, print_concise_summary, print_test_summary}; use flint_core::loader::TestLoader; use flint_core::results::AssertFailure; -use flint_core::spatial::calculate_test_offsets_for_batch_default; use flint_core::test_spec::{ActionType, TestSpec}; -use spatial_batch::{group_tests_by_world_config, split_tests_by_simulation_distance}; +use spatial_batch::{ + calculate_test_offsets, group_tests_by_world_config, split_tests_by_simulation_distance, +}; use std::path::Path; use std::path::PathBuf; use std::time::Instant; @@ -81,7 +82,7 @@ struct Args { #[arg(short = 't', long = "tag")] tags: Vec, - /// Interactive mode: listen for chat commands (!search, !run, !run-all, !run-tags) + /// Interactive mode: listen for commands from the generated chat-command registry. #[arg(short = 'i', long)] interactive: bool, @@ -233,8 +234,7 @@ fn print_dry_run(test_files: &[PathBuf]) { for test_file in *chunk { match TestSpec::from_file(test_file, false) { Ok(test) => { - let offset = - calculate_test_offsets_for_batch_default(std::slice::from_ref(&test))[0]; + let offset = calculate_test_offsets(std::slice::from_ref(&test))[0]; let assertions = test .timeline .iter() @@ -296,7 +296,10 @@ fn run_interactive_mode( "{} Interactive mode enabled - listening for chat commands", "→".yellow().bold() ); - println!(" Commands: !search, !run, !run-all, !run-tags, !list, !reload, !help, !stop"); + println!( + " Commands: {}", + executor::commands::command_aliases(executor::commands::CommandScope::Main, true) + ); println!(" During tests: type 's' to step, 'c' to continue\n"); println!("{} Connecting to {}...", "→".blue(), server); executor.connect(server)?; @@ -447,7 +450,7 @@ fn main() -> Result<()> { } executor.bot.reset_to_test_origin()?; - let offsets = calculate_test_offsets_for_batch_default(sim_batch); + let offsets = calculate_test_offsets(sim_batch); let bot_position = executor.bot.get_position()?; tests_with_offsets.clear(); for (test_index, (test, offset)) in sim_batch.iter().cloned().zip(offsets).enumerate() { diff --git a/src/spatial_batch.rs b/src/spatial_batch.rs index a1f8de0..c23ea2e 100644 --- a/src/spatial_batch.rs +++ b/src/spatial_batch.rs @@ -3,6 +3,66 @@ use flint_core::spatial::calculate_test_offsets_for_batch_default; use flint_core::test_spec::TestSpec; +const CHUNK_WIDTH: i32 = 16; +const SIMULATION_MARGIN_CHUNKS: u32 = 2; +const X_OFFSET_MOD_TAG_PREFIX: &str = "x-offset-mod-"; +const Z_OFFSET_TAG_PREFIX: &str = "z-offset="; + +/// Calculate the normal compact layout, then move position-sensitive tests to the +/// nearest coordinates in their declared safe location zone. +pub fn calculate_test_offsets(tests: &[TestSpec]) -> Vec<[i32; 3]> { + if let [test] = tests + && exact_z_offset(test).is_none() + && x_offset_constraint(test).is_none() + { + return vec![[0, 0, 0]]; + } + + tests + .iter() + .zip(calculate_test_offsets_for_batch_default(tests)) + .map(|(test, mut offset)| { + if let Some(z) = exact_z_offset(test) { + offset[2] = z; + } + if let Some((modulus, phase)) = x_offset_constraint(test) { + offset[0] = nearest_congruent(offset[0], phase, modulus); + } + offset + }) + .collect() +} + +pub fn pair_tests_with_offsets(tests: Vec) -> Vec<(TestSpec, [i32; 3])> { + let offsets = calculate_test_offsets(&tests); + tests.into_iter().zip(offsets).collect() +} + +fn x_offset_constraint(test: &TestSpec) -> Option<(i32, i32)> { + test.tags.iter().find_map(|tag| { + let (modulus, phase) = tag.strip_prefix(X_OFFSET_MOD_TAG_PREFIX)?.split_once('=')?; + let modulus = modulus.parse::().ok()?; + let phase = phase.parse::().ok()?; + (modulus > 0).then_some((modulus, phase.rem_euclid(modulus))) + }) +} + +fn exact_z_offset(test: &TestSpec) -> Option { + test.tags + .iter() + .find_map(|tag| tag.strip_prefix(Z_OFFSET_TAG_PREFIX)?.parse().ok()) +} + +fn nearest_congruent(value: i32, phase: i32, modulus: i32) -> i32 { + let lower = value - (value - phase).rem_euclid(modulus); + let upper = lower + modulus; + if value - lower <= upper - value { + lower + } else { + upper + } +} + /// Partition tests by their resolved world configuration while preserving the order in /// which each configuration and test first appeared. pub fn group_tests_by_world_config(tests: Vec) -> Vec> { @@ -23,7 +83,7 @@ pub fn group_tests_by_world_config(tests: Vec) -> Vec> { /// Blocks from world origin (0, 0) that can still be simulated when the bot stands at /// the layout center. Reserves two chunks of margin for the player and chunk edges. pub fn simulation_radius_blocks(simulation_distance: u32) -> i32 { - (simulation_distance.saturating_sub(2) as i32) * 16 + (simulation_distance.saturating_sub(SIMULATION_MARGIN_CHUNKS) as i32) * CHUNK_WIDTH } /// Maximum Chebyshev distance from origin for any corner of any test region in a batch. @@ -40,6 +100,44 @@ pub fn max_extent_from_origin(tests: &[(TestSpec, [i32; 3])]) -> i32 { max } +/// Maximum horizontal span occupied by a batch. When a timeline teleports the shared +/// player, every test region must remain within simulation distance of that new focus. +pub fn max_horizontal_span(tests: &[(TestSpec, [i32; 3])]) -> i32 { + let mut bounds: Option<[i32; 4]> = None; + for (test, offset) in tests { + let region = test.cleanup_region(); + let test_bounds = [ + offset[0] + region[0][0], + offset[0] + region[1][0], + offset[2] + region[0][2], + offset[2] + region[1][2], + ]; + bounds = Some(match bounds { + Some([min_x, max_x, min_z, max_z]) => [ + min_x.min(test_bounds[0]), + max_x.max(test_bounds[1]), + min_z.min(test_bounds[2]), + max_z.max(test_bounds[3]), + ], + None => test_bounds, + }); + } + + bounds.map_or(0, |[min_x, max_x, min_z, max_z]| { + (max_x - min_x).max(max_z - min_z) + }) +} + +fn teleports_player(test: &TestSpec) -> bool { + test.timeline.iter().any(|entry| { + matches!( + &entry.action_type, + flint_core::test_spec::ActionType::Tp { entity_alias, .. } + if entity_alias == "player" + ) + }) +} + /// Split a batch so every sub-batch fits within the server's simulation distance from the /// layout center (origin). Tests stay parallel within each sub-batch. pub fn split_tests_by_simulation_distance( @@ -56,13 +154,17 @@ pub fn split_tests_by_simulation_distance( for test in tests { current.push(test); - let offsets = calculate_test_offsets_for_batch_default(¤t); + let offsets = calculate_test_offsets(¤t); let paired: Vec<(TestSpec, [i32; 3])> = current.iter().cloned().zip(offsets).collect(); - if max_extent_from_origin(&paired) > max_radius && current.len() > 1 { - let overflow = current.pop().expect("current has at least two tests"); + let moves_shared_player = current.iter().any(teleports_player); + let exceeds_origin_radius = max_extent_from_origin(&paired) > max_radius; + let exceeds_player_radius = + moves_shared_player && max_horizontal_span(&paired) > max_radius; + if (exceeds_origin_radius || exceeds_player_radius) && current.len() > 1 { + let overflow = current.split_off(current.len() - 1); batches.push(current); - current = vec![overflow]; + current = overflow; } } @@ -166,6 +268,26 @@ mod tests { assert_eq!(batches[0].len(), 2); } + #[test] + fn split_starts_next_cycle_when_player_teleport_would_unload_tests() { + let distant = test_spec("distant", [[0, 0, 0], [100, 1, 1]]); + let mut player = test_spec("player", [[0, 0, 0], [100, 1, 1]]); + player.timeline.push(flint_core::test_spec::TimelineEntry { + at: flint_core::test_spec::TickSpec::Single(0), + action_type: ActionType::Tp { + entity_alias: "player".to_string(), + pos: [0.5, 0.0, 0.5], + rot: None, + }, + }); + let other = test_spec("other", [[0, 0, 0], [100, 1, 1]]); + + let batches = split_tests_by_simulation_distance(vec![distant, player, other], 10); + + assert_eq!(batches.len(), 3); + assert_eq!(batches.iter().map(Vec::len).collect::>(), [1, 1, 1]); + } + #[test] fn player_timeline_stays_with_block_timelines() { let block_before = test_spec("before", [[0, 0, 0], [1, 1, 1]]); @@ -181,4 +303,56 @@ mod tests { assert_eq!(batches.len(), 1); assert_eq!(batches[0].len(), 3); } + + #[test] + fn locationality_fixture_preserves_hash_cycle_in_parallel_layout() { + let fixture_path = std::path::PathBuf::from("FlintBenchmark/tests/locationality.json"); + let fixture = + TestSpec::from_file(&fixture_path, false).expect("load locationality fixture"); + let solo_offset = calculate_test_offsets(std::slice::from_ref(&fixture)); + let parallel_offsets = calculate_test_offsets(&[fixture.clone(), fixture]); + + assert_eq!(solo_offset, vec![[-15, 0, 0]]); + assert_eq!(parallel_offsets, vec![[-47, 0, 0], [17, 0, 0]]); + assert_eq!(parallel_offsets[1][0] - parallel_offsets[0][0], 64); + assert!( + parallel_offsets + .iter() + .all(|offset| (offset[0] - solo_offset[0][0]).rem_euclid(32) == 0) + ); + } + + #[test] + fn unconstrained_layout_is_unchanged() { + let tests = vec![ + test_spec("a", [[-2, 0, 0], [32, 0, 0]]), + test_spec("b", [[-2, 0, 0], [32, 0, 0]]), + ]; + + assert_eq!( + calculate_test_offsets(&tests), + calculate_test_offsets_for_batch_default(&tests) + ); + } + + #[test] + fn unconstrained_test_run_alone_uses_world_origin() { + let test = test_spec("solo", [[-2, -1, -7], [14, 5, 23]]); + + assert_eq!(calculate_test_offsets(&[test]), vec![[0, 0, 0]]); + } + + #[test] + fn locationality_constraint_clamps_to_safe_z_plane() { + let fixture_path = std::path::PathBuf::from("FlintBenchmark/tests/locationality.json"); + let fixture = + TestSpec::from_file(&fixture_path, false).expect("load locationality fixture"); + let mut offset = [127, 0, -6]; + offset[2] = exact_z_offset(&fixture).expect("Z constraint"); + let (modulus, phase) = x_offset_constraint(&fixture).expect("X constraint"); + offset[0] = nearest_congruent(offset[0], phase, modulus); + + assert_eq!(offset, [113, 0, 0]); + assert_eq!(offset[0].rem_euclid(modulus), phase); + } }