Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
2 changes: 1 addition & 1 deletion FlintBenchmark
Submodule FlintBenchmark updated 0 files
31 changes: 25 additions & 6 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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:
```
Expand Down Expand Up @@ -273,7 +289,9 @@ The recorder lets you create tests by performing actions in-game. The bot watche
| Command | Description |
|---------|-------------|
| `!record <name> [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 <x> <y> <z>` | 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 |
Expand All @@ -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 <name> [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.
Expand Down
181 changes: 181 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -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::<syn::LitStr>()?.value());
} else if meta.path.is_ident("aliases") {
let expression = meta.value()?.parse::<Expr>()?;
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::<syn::LitStr>()?.value());
} else if meta.path.is_ident("label") {
label = Some(meta.value()?.parse::<syn::LitStr>()?.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<DialogAction>,
pub callback: &'static str,
}

pub const COMMANDS: &[FlintCommandSpec] = &[#(#specs),*];

pub fn from_chat(command: &str) -> Option<FlintCommand> {
COMMANDS.iter().find(|spec| spec.aliases.contains(&command)).map(|spec| spec.command)
}

pub fn from_callback(callback: &str) -> Option<FlintCommand> {
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 <name> 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");
}
Loading
Loading