diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..1ae05a8
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+ - package-ecosystem: "cargo"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ commit-message:
+ prefix: "deps"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ commit-message:
+ prefix: "ci"
diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml
new file mode 100644
index 0000000..0829dff
--- /dev/null
+++ b/.github/workflows/audit.yml
@@ -0,0 +1,25 @@
+name: Security Audit
+
+on:
+ push:
+ paths:
+ - "**/Cargo.toml"
+ - "**/Cargo.lock"
+ pull_request:
+ paths:
+ - "**/Cargo.toml"
+ - "**/Cargo.lock"
+ schedule:
+ - cron: "0 0 * * *" # Daily at midnight UTC
+
+permissions:
+ contents: read
+
+jobs:
+ audit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: rustsec/audit-check@v2
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..0579530
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,62 @@
+name: Documentation
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build Documentation
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-action@stable
+
+ - name: Cache cargo registry
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-docs-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cargo-docs-
+
+ - name: Build rustdoc
+ run: cargo doc --no-deps --document-private-items
+
+ - name: Add redirect to index
+ run: echo '' > target/doc/index.html
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: target/doc
+
+ deploy:
+ name: Deploy to GitHub Pages
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/Cargo.lock b/Cargo.lock
index bcabc13..1f774aa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -51,6 +51,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"colored",
+ "rustc-hash",
"serde",
"serde_json",
"serial_test",
@@ -259,6 +260,12 @@ dependencies = [
"bitflags",
]
+[[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
[[package]]
name = "rustix"
version = "1.1.2"
diff --git a/Cargo.toml b/Cargo.toml
index bed1649..ab39518 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,11 +2,20 @@
name = "flint-core"
version = "0.1.0"
edition = "2024"
+license = "MIT"
+description = "The shared functionality for flint implementations"
+keywords = ["flint", "steel", "minecraft", "test"]
+categories = ["development-tools::testing", "development-tools", "development-tools::debugging"]
+readme = "README.md"
+repository = "https://github.com/FlintTestMC/flint-core"
+homepage = "https://github.com/FlintTestMC/flint-core"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0.100"
+rustc-hash = "2.1"
+# rsjsonnet = "0.4.0" # for jsonnet support
colored = "3"
[dev-dependencies]
diff --git a/flint-content/test_spec_schema.json b/flint-content/test_spec_schema.json
new file mode 100644
index 0000000..b8d8086
--- /dev/null
+++ b/flint-content/test_spec_schema.json
@@ -0,0 +1,408 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://flint.dev/schemas/test-spec.json",
+ "title": "Flint Test Specification",
+ "description": "Schema for Flint test specification files (.json) used by the test framework",
+ "type": "object",
+ "required": ["name", "timeline"],
+ "properties": {
+ "flintVersion": {
+ "type": "string",
+ "description": "Version of the Flint test specification format"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the test"
+ },
+ "description": {
+ "type": "string",
+ "description": "Description of what the test verifies"
+ },
+ "tags": {
+ "type": "array",
+ "items": { "type": "string" },
+ "default": [],
+ "description": "Tags for categorizing and filtering tests"
+ },
+ "dependencies": {
+ "type": "array",
+ "items": { "type": "string" },
+ "default": [],
+ "description": "Names of tests that must pass before this test can run"
+ },
+ "setup": {
+ "$ref": "#/$defs/SetupSpec",
+ "description": "Setup configuration including cleanup region and player configuration"
+ },
+ "timeline": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/TimelineEntry" },
+ "description": "Sequence of actions to perform during the test"
+ },
+ "breakpoints": {
+ "type": "array",
+ "items": { "type": "integer", "minimum": 0 },
+ "default": [],
+ "description": "Tick numbers at which to pause execution for debugging"
+ }
+ },
+ "additionalProperties": false,
+ "$defs": {
+ "Coordinate": {
+ "type": "array",
+ "items": { "type": "integer" },
+ "minItems": 3,
+ "maxItems": 3,
+ "description": "A 3D coordinate [x, y, z]"
+ },
+ "Region": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/Coordinate" },
+ "minItems": 2,
+ "maxItems": 2,
+ "description": "A region defined by two coordinates [[minX, minY, minZ], [maxX, maxY, maxZ]]"
+ },
+ "SetupSpec": {
+ "type": "object",
+ "properties": {
+ "cleanup": {
+ "$ref": "#/$defs/CleanupSpec",
+ "description": "Cleanup region configuration"
+ },
+ "player": {
+ "$ref": "#/$defs/PlayerConfig",
+ "description": "Player configuration for initial inventory and hotbar"
+ }
+ },
+ "additionalProperties": false
+ },
+ "CleanupSpec": {
+ "type": "object",
+ "required": ["region"],
+ "properties": {
+ "region": {
+ "$ref": "#/$defs/Region",
+ "description": "The region to clear before and after the test"
+ }
+ },
+ "additionalProperties": false
+ },
+ "PlayerSlot": {
+ "type": "string",
+ "enum": [
+ "hotbar_1", "hotbar_2", "hotbar_3", "hotbar_4", "hotbar_5",
+ "hotbar_6", "hotbar_7", "hotbar_8", "hotbar_9",
+ "off_hand",
+ "helmet", "chestplate", "leggings", "boots"
+ ],
+ "description": "Player inventory slot identifier"
+ },
+ "SlotConfig": {
+ "type": "object",
+ "required": ["item"],
+ "properties": {
+ "item": {
+ "type": "string",
+ "description": "Item identifier, e.g., 'minecraft:honeycomb'"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 64,
+ "default": 1,
+ "description": "Stack count"
+ }
+ },
+ "additionalProperties": false
+ },
+ "PlayerConfig": {
+ "type": "object",
+ "properties": {
+ "inventory": {
+ "type": "object",
+ "additionalProperties": { "$ref": "#/$defs/SlotConfig" },
+ "propertyNames": { "$ref": "#/$defs/PlayerSlot" },
+ "default": {},
+ "description": "Initial inventory state (slot name -> item config)"
+ },
+ "selected_hotbar": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9,
+ "default": 1,
+ "description": "Initially selected hotbar slot (1-9)"
+ }
+ },
+ "additionalProperties": false
+ },
+ "TickSpec": {
+ "oneOf": [
+ {
+ "type": "integer",
+ "minimum": 0,
+ "description": "A single tick number"
+ },
+ {
+ "type": "array",
+ "items": { "type": "integer", "minimum": 0 },
+ "minItems": 1,
+ "description": "Multiple tick numbers at which to execute the action"
+ }
+ ],
+ "description": "Tick specification - either a single tick or array of ticks"
+ },
+ "Block": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Block identifier as a string, e.g., 'minecraft:stone' or 'minecraft:lever[powered=true]'"
+ },
+ {
+ "type": "object",
+ "required": ["id"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Block identifier, e.g., 'minecraft:lever'"
+ },
+ "properties": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Block state properties as a nested object"
+ }
+ },
+ "additionalProperties": true,
+ "description": "Block with identifier and optional state properties (can be flat or nested under 'properties')"
+ }
+ ],
+ "description": "Block specification - either a string identifier or an object with id and properties"
+ },
+ "BlockFace": {
+ "type": "string",
+ "enum": ["top", "bottom", "north", "south", "east", "west"],
+ "description": "Face of a block"
+ },
+ "BlockPlacement": {
+ "type": "object",
+ "required": ["pos", "block"],
+ "properties": {
+ "pos": {
+ "$ref": "#/$defs/Coordinate",
+ "description": "Position to place the block"
+ },
+ "block": {
+ "$ref": "#/$defs/Block",
+ "description": "Block to place"
+ }
+ },
+ "additionalProperties": false
+ },
+ "BlockCheck": {
+ "type": "object",
+ "required": ["pos", "is"],
+ "properties": {
+ "pos": {
+ "$ref": "#/$defs/Coordinate",
+ "description": "Position to check"
+ },
+ "is": {
+ "$ref": "#/$defs/Block",
+ "description": "Expected block at the position"
+ }
+ },
+ "additionalProperties": false
+ },
+ "TimelineEntry": {
+ "type": "object",
+ "required": ["at", "do"],
+ "properties": {
+ "at": {
+ "$ref": "#/$defs/TickSpec",
+ "description": "Tick(s) at which to execute this action"
+ },
+ "do": {
+ "type": "string",
+ "enum": ["place", "place_each", "fill", "remove", "assert", "use_item_on", "set_slot", "select_hotbar"],
+ "description": "Type of action to perform"
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": { "do": { "const": "place" } },
+ "required": ["do"]
+ },
+ "then": {
+ "properties": {
+ "at": true,
+ "do": true,
+ "pos": {
+ "$ref": "#/$defs/Coordinate",
+ "description": "Position to place the block"
+ },
+ "block": {
+ "$ref": "#/$defs/Block",
+ "description": "Block to place"
+ }
+ },
+ "required": ["pos", "block"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "if": {
+ "properties": { "do": { "const": "place_each" } },
+ "required": ["do"]
+ },
+ "then": {
+ "properties": {
+ "at": true,
+ "do": true,
+ "blocks": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/BlockPlacement" },
+ "description": "List of blocks to place"
+ }
+ },
+ "required": ["blocks"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "if": {
+ "properties": { "do": { "const": "fill" } },
+ "required": ["do"]
+ },
+ "then": {
+ "properties": {
+ "at": true,
+ "do": true,
+ "region": {
+ "$ref": "#/$defs/Region",
+ "description": "Region to fill"
+ },
+ "with": {
+ "$ref": "#/$defs/Block",
+ "description": "Block to fill the region with"
+ }
+ },
+ "required": ["region", "with"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "if": {
+ "properties": { "do": { "const": "remove" } },
+ "required": ["do"]
+ },
+ "then": {
+ "properties": {
+ "at": true,
+ "do": true,
+ "pos": {
+ "$ref": "#/$defs/Coordinate",
+ "description": "Position of the block to remove"
+ }
+ },
+ "required": ["pos"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "if": {
+ "properties": { "do": { "const": "assert" } },
+ "required": ["do"]
+ },
+ "then": {
+ "properties": {
+ "at": true,
+ "do": true,
+ "checks": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/BlockCheck" },
+ "description": "List of block checks to perform"
+ }
+ },
+ "required": ["checks"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "if": {
+ "properties": { "do": { "const": "use_item_on" } },
+ "required": ["do"]
+ },
+ "then": {
+ "properties": {
+ "at": true,
+ "do": true,
+ "pos": {
+ "$ref": "#/$defs/Coordinate",
+ "description": "Position of the block to interact with"
+ },
+ "face": {
+ "$ref": "#/$defs/BlockFace",
+ "description": "Face of the block to interact with"
+ },
+ "item": {
+ "type": "string",
+ "description": "Item to use (optional, uses player's active item if not specified)"
+ }
+ },
+ "required": ["pos", "face"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "if": {
+ "properties": { "do": { "const": "set_slot" } },
+ "required": ["do"]
+ },
+ "then": {
+ "properties": {
+ "at": true,
+ "do": true,
+ "slot": {
+ "$ref": "#/$defs/PlayerSlot",
+ "description": "Inventory slot to set"
+ },
+ "item": {
+ "type": "string",
+ "description": "Item to put in the slot (omit to clear)"
+ },
+ "count": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 64,
+ "default": 1,
+ "description": "Stack count"
+ }
+ },
+ "required": ["slot"],
+ "additionalProperties": false
+ }
+ },
+ {
+ "if": {
+ "properties": { "do": { "const": "select_hotbar" } },
+ "required": ["do"]
+ },
+ "then": {
+ "properties": {
+ "at": true,
+ "do": true,
+ "slot": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9,
+ "description": "Hotbar slot to select (1-9)"
+ }
+ },
+ "required": ["slot"],
+ "additionalProperties": false
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 59db326..c564088 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,7 +2,17 @@ pub mod format;
pub mod index;
pub mod loader;
pub mod results;
+pub mod runner;
pub mod spatial;
pub mod test_spec;
pub mod timeline;
+pub mod traits;
pub mod utils;
+
+// Re-export main types for convenience
+pub use runner::{TestRunConfig, TestRunner};
+pub use traits::{BlockPos, FlintAdapter, FlintPlayer, FlintWorld, ServerInfo};
+
+// Re-export flint-core types commonly used with this library
+pub use crate::loader::TestLoader;
+pub use crate::test_spec::{Block, Item, PlayerSlot, TestSpec};
diff --git a/src/results.rs b/src/results.rs
index ce9a0fd..0b76f70 100644
--- a/src/results.rs
+++ b/src/results.rs
@@ -1,6 +1,8 @@
+use crate::format;
use crate::results::AssertionResult::Failure;
use crate::test_spec::Block;
use serde::{Deserialize, Serialize};
+use std::time::Duration;
/// Outcome of executing a single action
pub enum ActionOutcome {
@@ -257,6 +259,36 @@ impl TestSummary {
(self.passed_tests as f64 / self.total_tests as f64) * 100.0
}
}
+
+ /// Get total execution time as Duration
+ fn elapsed(&self) -> Duration {
+ Duration::from_millis(self.total_execution_time_ms)
+ }
+
+ /// Print concise summary (default mode)
+ pub fn print_concise_summary(&self) {
+ format::print_concise_summary(&self.results, self.elapsed());
+ }
+
+ /// Print verbose test summary (used in -v mode)
+ pub fn print_test_summary(&self, separator_width: usize) {
+ format::print_test_summary(&self.results, separator_width);
+ }
+
+ /// Print results in JUnit XML format
+ pub fn print_junit(&self) {
+ format::print_junit(&self.results, self.elapsed());
+ }
+
+ /// Print results in TAP (Test Anything Protocol) format
+ pub fn print_tap(&self) {
+ format::print_tap(&self.results);
+ }
+
+ /// Print results as JSON
+ pub fn print_json(&self) {
+ format::print_json(&self.results, self.elapsed());
+ }
}
#[cfg(test)]
diff --git a/src/runner.rs b/src/runner.rs
new file mode 100644
index 0000000..6c921b3
--- /dev/null
+++ b/src/runner.rs
@@ -0,0 +1,270 @@
+//! Test execution engine.
+//!
+//! The `TestRunner` loads tests and executes them against a server adapter.
+
+use crate::results::{
+ ActionOutcome, AssertFailure, AssertionResult, InfoType, TestResult, TestSummary,
+};
+use crate::test_spec::{ActionType, Item, PlayerSlot};
+use crate::timeline::TimelineAggregate;
+use crate::traits::{FlintAdapter, FlintPlayer, FlintWorld};
+use crate::{Block, TestSpec};
+use std::time::Instant;
+
+/// Configuration for test execution
+#[allow(dead_code)]
+#[derive(Debug, Clone)]
+pub struct TestRunConfig {
+ /// Enable debug mode with breakpoints
+ pub debug_enabled: bool,
+ /// Run tests in parallel
+ pub parallel: bool,
+ /// Maximum parallel test worlds
+ pub max_parallel_worlds: usize,
+}
+
+#[allow(dead_code)]
+impl Default for TestRunConfig {
+ fn default() -> Self {
+ Self {
+ debug_enabled: false,
+ parallel: false,
+ max_parallel_worlds: 4,
+ }
+ }
+}
+
+/// Test execution engine
+pub struct TestRunner<'a, A: FlintAdapter> {
+ adapter: &'a A,
+ // is needed for later, to run multiple tests in parallel or have more configs
+ // config: TestRunConfig,
+}
+
+impl<'a, A: FlintAdapter> TestRunner<'a, A> {
+ pub fn new(adapter: &'a A) -> Self {
+ Self { adapter }
+ }
+
+ /// Run a single test
+ pub fn run_test(&self, spec: &TestSpec) -> TestResult {
+ let start_time = Instant::now();
+ let mut world = self.adapter.create_test_world();
+
+ // Build timeline for single test (no offset)
+ let tests_with_offsets = vec![(spec.clone(), [0i32, 0, 0])];
+ let timeline = TimelineAggregate::from_tests(&tests_with_offsets);
+
+ let mut result = TestResult::new(&spec.name);
+
+ // Player is created on demand when player actions are used
+ let mut player: Option> = None;
+
+ // Initialize player from config if present (advanced mode)
+ if let Some(setup) = &spec.setup
+ && let Some(player_config) = setup.player.as_ref()
+ {
+ let p = player.get_or_insert_with(|| world.create_player());
+
+ // Set initial inventory
+ for (slot_name, item) in &player_config.inventory {
+ p.set_slot(*slot_name, Some(item));
+ }
+
+ // Set initial hotbar selection
+ p.select_hotbar(player_config.selected_hotbar);
+ }
+
+ // Execute timeline tick by tick
+ for tick in 0..=timeline.max_tick {
+ // Check for breakpoints (debug mode)
+ // if self.config.debug_enabled && timeline.breakpoints.contains(&tick) {
+ // // TODO: Implement breakpoint pause mechanism
+ // // For now, just continue
+ // }
+
+ // Execute actions for this tick
+ if let Some(actions) = timeline.timeline.get(&tick) {
+ for (_test_idx, entry, _value_idx) in actions.iter() {
+ match self.execute_action(&mut *world, &mut player, &entry.action_type, tick) {
+ ActionOutcome::Action => {}
+ ActionOutcome::AssertPassed => {
+ result.add_assertion(AssertionResult::Success(tick));
+ }
+ ActionOutcome::AssertFailed(fail) => {
+ result.add_assertion(AssertionResult::Failure(fail));
+ result.success = false;
+ result.total_ticks = tick;
+ result.execution_time_ms = start_time.elapsed().as_millis() as u64;
+ return result;
+ }
+ }
+ }
+ }
+
+ // Advance game tick
+ world.do_tick();
+ }
+
+ result.total_ticks = timeline.max_tick;
+ result.execution_time_ms = start_time.elapsed().as_millis() as u64;
+ result
+ }
+
+ /// Run multiple tests
+ pub fn run_tests(&self, specs: &[TestSpec]) -> TestSummary {
+ // For now, run sequentially
+ // TODO: Implement parallel execution
+ let results: Vec = specs.iter().map(|spec| self.run_test(spec)).collect();
+ TestSummary::from_results(results)
+ }
+
+ /// Execute a single action
+ fn execute_action(
+ &self,
+ world: &mut dyn FlintWorld,
+ player: &mut Option>,
+ action: &ActionType,
+ _tick: u32,
+ ) -> ActionOutcome {
+ match action {
+ ActionType::Place { pos, block } => {
+ let pos = [pos[0], pos[1], pos[2]];
+ world.set_block(pos, block);
+ ActionOutcome::Action
+ }
+
+ ActionType::PlaceEach { blocks } => {
+ for placement in blocks {
+ let pos = [placement.pos[0], placement.pos[1], placement.pos[2]];
+ world.set_block(pos, &placement.block);
+ }
+ ActionOutcome::Action
+ }
+
+ ActionType::Fill { region, with } => {
+ // Flint handles fill by iterating set_block
+ // Handle potentially inverted coordinates
+ let min_x = region[0][0].min(region[1][0]);
+ let max_x = region[0][0].max(region[1][0]);
+ let min_y = region[0][1].min(region[1][1]);
+ let max_y = region[0][1].max(region[1][1]);
+ let min_z = region[0][2].min(region[1][2]);
+ let max_z = region[0][2].max(region[1][2]);
+
+ for x in min_x..=max_x {
+ for y in min_y..=max_y {
+ for z in min_z..=max_z {
+ world.set_block([x, y, z], with);
+ }
+ }
+ }
+ ActionOutcome::Action
+ }
+
+ ActionType::Remove { pos } => {
+ let pos = [pos[0], pos[1], pos[2]];
+ let air = Block {
+ id: "minecraft:air".to_string(),
+ properties: Default::default(),
+ };
+ world.set_block(pos, &air);
+ ActionOutcome::Action
+ }
+
+ ActionType::Assert { checks } => {
+ for check in checks {
+ let pos = [check.pos[0], check.pos[1], check.pos[2]];
+ let actual = world.get_block(pos);
+
+ if !block_matches(&actual, &check.is) {
+ return ActionOutcome::AssertFailed(AssertFailure {
+ tick: _tick,
+ error_message: format!(
+ "Block mismatch at {:?}: expected '{}', got '{}'",
+ pos,
+ check.is.to_command(),
+ actual.to_command(),
+ ),
+ position: pos,
+ execution_time_ms: None,
+ expected: InfoType::Block(check.is.clone()),
+ actual: InfoType::Block(actual),
+ });
+ }
+ }
+ ActionOutcome::AssertPassed
+ }
+
+ ActionType::UseItemOn { pos, face, item } => {
+ // Create player on demand if not already created
+ let p = player.get_or_insert_with(|| world.create_player());
+ let pos = [pos[0], pos[1], pos[2]];
+
+ // Simple mode: if item is specified, set it in hotbar1 and select it
+ if let Some(item_id) = item {
+ let item = Item::new(item_id);
+ p.set_slot(PlayerSlot::Hotbar1, Some(&item));
+ p.select_hotbar(1);
+ }
+
+ p.use_item_on(pos, face);
+ ActionOutcome::Action
+ }
+
+ ActionType::SetSlot { slot, item, count } => {
+ // Create player on demand if not already created
+ let p = player.get_or_insert_with(|| world.create_player());
+ if let Some(item_id) = item {
+ let item = Item::with_count(item_id, *count);
+ p.set_slot(*slot, Some(&item));
+ } else {
+ p.set_slot(*slot, None);
+ }
+ ActionOutcome::Action
+ }
+
+ ActionType::SelectHotbar { slot } => {
+ // Create player on demand if not already created
+ let p = player.get_or_insert_with(|| world.create_player());
+ p.select_hotbar(*slot);
+ ActionOutcome::Action
+ }
+ }
+ }
+}
+
+/// Check if actual block matches expected.
+fn block_matches(actual: &Block, expected: &Block) -> bool {
+ // Check block ID
+ if actual.id != expected.id {
+ // Also try without minecraft: prefix
+ let expected_id = if expected.id.starts_with("minecraft:") {
+ &expected.id[10..]
+ } else {
+ &expected.id
+ };
+ let actual_id = if actual.id.starts_with("minecraft:") {
+ &actual.id[10..]
+ } else {
+ &actual.id
+ };
+ if actual_id != expected_id {
+ return false;
+ }
+ }
+
+ // Check properties if specified in expected
+ for (key, expected_value) in &expected.properties {
+ if let Some(actual_value) = actual.properties.get(key) {
+ if actual_value != expected_value {
+ return false;
+ }
+ } else {
+ // Property expected but not found in actual block - this is a mismatch
+ return false;
+ }
+ }
+
+ true
+}
diff --git a/src/test_spec.rs b/src/test_spec.rs
index ccb2c60..e0e4567 100644
--- a/src/test_spec.rs
+++ b/src/test_spec.rs
@@ -1,5 +1,8 @@
-use serde::{Deserialize, Serialize};
+use rustc_hash::FxHashMap;
+use serde::de::{MapAccess, Visitor};
+use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
+use std::fmt::Formatter;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -22,13 +25,111 @@ pub struct TestSpec {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupSpec {
- pub cleanup: CleanupSpec,
+ #[serde(default)]
+ pub cleanup: Option,
+ #[serde(default)]
+ pub player: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CleanupSpec {
pub region: [[i32; 3]; 2],
}
+/// Player inventory slots
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PlayerSlot {
+ // Hotbar (9 slots)
+ Hotbar1,
+ Hotbar2,
+ Hotbar3,
+ Hotbar4,
+ Hotbar5,
+ Hotbar6,
+ Hotbar7,
+ Hotbar8,
+ Hotbar9,
+
+ // Off-hand
+ OffHand,
+
+ // Armor
+ Helmet,
+ Chestplate,
+ Leggings,
+ Boots,
+}
+
+impl PlayerSlot {
+ /// Convert hotbar number (1-9) to PlayerSlot
+ pub fn hotbar(n: u8) -> Option {
+ match n {
+ 1 => Some(Self::Hotbar1),
+ 2 => Some(Self::Hotbar2),
+ 3 => Some(Self::Hotbar3),
+ 4 => Some(Self::Hotbar4),
+ 5 => Some(Self::Hotbar5),
+ 6 => Some(Self::Hotbar6),
+ 7 => Some(Self::Hotbar7),
+ 8 => Some(Self::Hotbar8),
+ 9 => Some(Self::Hotbar9),
+ _ => None,
+ }
+ }
+}
+
+/// Player configuration for advanced mode (initial inventory setup)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct PlayerConfig {
+ /// Initial inventory state (slot name -> item config)
+ #[serde(default)]
+ pub inventory: HashMap,
+ /// Initially selected hotbar slot (1-9), defaults to 1
+ #[serde(default = "default_selected_hotbar")]
+ pub selected_hotbar: u8,
+}
+
+fn default_selected_hotbar() -> u8 {
+ 1
+}
+
+/// An item that can be held or placed in a slot.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Item {
+ /// Item identifier, e.g., "minecraft:honeycomb"
+ pub id: String,
+ /// Stack count (default 1)
+ #[serde(default = "default_count")]
+ pub count: u8,
+}
+
+impl Item {
+ /// Create a new item with count 1.
+ pub fn new(id: impl Into) -> Self {
+ let id = id.into();
+ if id.starts_with("empty") {
+ return Item::empty();
+ }
+ Self { id, count: 1 }
+ }
+
+ /// Create an empty item (air with count 0).
+ pub fn empty() -> Self {
+ Self {
+ id: "minecraft:air".to_string(),
+ count: 0,
+ }
+ }
+
+ /// Create an item with a specific count.
+ pub fn with_count(id: impl Into, count: u8) -> Self {
+ Self {
+ id: id.into(),
+ count,
+ }
+ }
+}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEntry {
@@ -54,61 +155,178 @@ impl TickSpec {
}
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
+/// Block specification with ID and properties.
+///
+/// Deserializes from JSON with backwards compatibility:
+/// - `"powered": false` → `"powered": "false"`
+/// - `"delay": 2` → `"delay": "2"`
+/// - `"facing": "north"` → `"facing": "north"`
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Block {
+ /// Block identifier, e.g., "minecraft:stone"
pub id: String,
- #[serde(flatten)]
- pub properties: HashMap,
+ /// Block state properties, e.g., {"powered": "true", "facing": "north"}
+ #[serde(flatten, skip_serializing_if = "FxHashMap::is_empty")]
+ pub properties: FxHashMap,
}
impl Block {
+ /// Create a new block with no properties.
+ pub fn new(id: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ properties: FxHashMap::default(),
+ }
+ }
+
+ /// Create a block with the given properties.
+ pub fn with_properties(id: impl Into, properties: FxHashMap) -> Self {
+ Self {
+ id: id.into(),
+ properties,
+ }
+ }
+
+ /// Check if this block is air.
+ pub fn is_air(&self) -> bool {
+ self.id == "minecraft:air" || self.id == "air"
+ }
+
+ /// Generate a Minecraft command string like `minecraft:lever[powered=false,face=floor]`.
pub fn to_command(&self) -> String {
if self.properties.is_empty() {
self.id.clone()
} else {
- let mut props: Vec = Vec::new();
-
- for (key, value) in &self.properties {
- if key == "properties" {
- if let serde_json::Value::Object(nested) = value {
- for (nested_key, nested_value) in nested {
- let val = match nested_value {
- serde_json::Value::String(s) => s.clone(),
- serde_json::Value::Bool(b) => b.to_string(),
- serde_json::Value::Number(n) => n.to_string(),
- _ => nested_value.to_string(),
- };
- props.push(format!("{}={}", nested_key, val));
+ let props: Vec = self
+ .properties
+ .iter()
+ .map(|(key, value)| format!("{}={}", key, value))
+ .collect();
+ format!("{}[{}]", self.id, props.join(","))
+ }
+ }
+}
+
+impl<'de> Deserialize<'de> for Block {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: Deserializer<'de>,
+ {
+ struct BlockVisitor;
+
+ impl<'de> Visitor<'de> for BlockVisitor {
+ type Value = Block;
+
+ fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
+ formatter.write_str("a block object with 'id' field and optional properties")
+ }
+
+ fn visit_map(self, mut map: M) -> Result
+ where
+ M: MapAccess<'de>,
+ {
+ let mut id: Option = None;
+ let mut properties = FxHashMap::default();
+
+ while let Some(key) = map.next_key::()? {
+ if key == "id" {
+ id = Some(map.next_value()?);
+ } else if key == "properties" {
+ // Handle nested properties object
+ let nested: FxHashMap = map.next_value()?;
+ for (k, v) in nested {
+ let value_str = json_value_to_string(&v);
+ properties.insert(k, value_str);
}
+ } else {
+ // Handle flat properties - convert JSON values to strings
+ let value: serde_json::Value = map.next_value()?;
+ let value_str = json_value_to_string(&value);
+ properties.insert(key, value_str);
}
- } else {
- let val = match value {
- serde_json::Value::String(s) => s.clone(),
- serde_json::Value::Bool(b) => b.to_string(),
- serde_json::Value::Number(n) => n.to_string(),
- _ => value.to_string(),
- };
- props.push(format!("{}={}", key, val));
}
- }
- if props.is_empty() {
- self.id.clone()
- } else {
- format!("{}[{}]", self.id, props.join(","))
+ let id = id.ok_or_else(|| serde::de::Error::missing_field("id"))?;
+ Ok(Block { id, properties })
}
}
+
+ deserializer.deserialize_map(BlockVisitor)
}
}
+/// Convert a JSON value to a string representation for block properties.
+fn json_value_to_string(value: &serde_json::Value) -> String {
+ match value {
+ serde_json::Value::String(s) => s.clone(),
+ serde_json::Value::Bool(b) => b.to_string(),
+ serde_json::Value::Number(n) => n.to_string(),
+ serde_json::Value::Null => String::new(),
+ _ => value.to_string(),
+ }
+}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum BlockFace {
+ Top, // +Y
+ Bottom, // -Y
+ North, // -Z
+ South, // +Z
+ East, // +X
+ West, // -X
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "do", rename_all = "snake_case")]
pub enum ActionType {
- Place { pos: [i32; 3], block: Block },
- PlaceEach { blocks: Vec },
- Fill { region: [[i32; 3]; 2], with: Block },
- Remove { pos: [i32; 3] },
- Assert { checks: Vec },
+ // Block actions
+ Place {
+ pos: [i32; 3],
+ block: Block,
+ },
+ PlaceEach {
+ blocks: Vec,
+ },
+ Fill {
+ region: [[i32; 3]; 2],
+ with: Block,
+ },
+ Remove {
+ pos: [i32; 3],
+ },
+
+ // Assertion actions
+ Assert {
+ checks: Vec,
+ },
+
+ // Player actions (for item interactions)
+ /// Use an item on a block face (e.g., honeycomb on copper, axe on log)
+ UseItemOn {
+ pos: [i32; 3],
+ face: BlockFace,
+ /// Item to use (for simple mode). If not specified, uses player's active item.
+ #[serde(default)]
+ item: Option,
+ },
+
+ /// Set an item in a player slot
+ SetSlot {
+ slot: PlayerSlot,
+ #[serde(default)]
+ item: Option,
+ #[serde(default = "default_count")]
+ count: u8,
+ },
+
+ /// Select which hotbar slot is active (1-9)
+ SelectHotbar {
+ slot: u8,
+ },
+}
+
+fn default_count() -> u8 {
+ 1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -149,7 +367,11 @@ impl TestSpec {
pub fn cleanup_region(&self) -> [[i32; 3]; 2] {
self.setup
.as_ref()
- .map(|s| s.cleanup.region)
+ .ok_or_else(|| panic!("setup is missing"))
+ .unwrap()
+ .cleanup
+ .as_ref()
+ .map(|s| s.region)
.expect("Cleanup region is required but not present")
}
@@ -158,8 +380,10 @@ impl TestSpec {
let setup = self.setup.as_ref().ok_or_else(|| {
anyhow::anyhow!("Test '{}' missing required 'setup' section", self.name)
})?;
-
- let region = setup.cleanup.region;
+ if setup.cleanup.is_none() {
+ anyhow::bail!("Test '{}' missing 'cleanup' section", self.name);
+ }
+ let region = setup.cleanup.as_ref().unwrap().region;
let min = region[0];
let max = region[1];
@@ -234,6 +458,11 @@ impl TestSpec {
self.validate_position(check.pos, ®ion)?;
}
}
+ ActionType::UseItemOn { pos, .. } => {
+ self.validate_position(*pos, ®ion)?;
+ }
+ // SetSlot and SelectHotbar don't have positions to validate
+ ActionType::SetSlot { .. } | ActionType::SelectHotbar { .. } => {}
}
}
@@ -272,19 +501,16 @@ impl TestSpec {
#[cfg(test)]
mod tests {
use super::*;
- use serde_json::Value;
+
#[test]
fn redstone_lever_with_two_properties_command_string() {
- let mut block = Block {
- id: "minecraft:lever".to_string(),
- properties: HashMap::new(),
- };
+ let mut block = Block::new("minecraft:lever");
block
.properties
- .insert("powered".to_string(), Value::from(false));
+ .insert("powered".to_string(), "false".to_string());
block
.properties
- .insert("face".to_string(), Value::from("floor"));
+ .insert("face".to_string(), "floor".to_string());
let result = block.to_command();
assert!(
result == "minecraft:lever[powered=false,face=floor]"
@@ -293,46 +519,38 @@ mod tests {
result
);
}
+
#[test]
fn only_id_command_string() {
- let block = Block {
- id: "minecraft:stone".to_string(),
- properties: HashMap::new(),
- };
+ let block = Block::new("minecraft:stone");
let result = block.to_command();
assert_eq!(result, "minecraft:stone");
}
+
#[test]
fn empty_id_command_string() {
- let block = Block {
- id: "".to_string(),
- properties: HashMap::new(),
- };
+ let block = Block::new("");
let result = block.to_command();
assert_eq!(result, "");
}
#[test]
fn test_redstone_wire() {
- let mut block = Block {
- id: "minecraft:redstone_wire".to_string(),
- properties: HashMap::new(),
- };
+ let mut block = Block::new("minecraft:redstone_wire");
block
.properties
- .insert("north".to_string(), Value::from("side"));
+ .insert("north".to_string(), "side".to_string());
block
.properties
- .insert("east".to_string(), Value::from("up"));
+ .insert("east".to_string(), "up".to_string());
block
.properties
- .insert("south".to_string(), Value::from("none"));
+ .insert("south".to_string(), "none".to_string());
block
.properties
- .insert("west".to_string(), Value::from("side"));
+ .insert("west".to_string(), "side".to_string());
let result = block.to_command();
- // Prüfe dass ID und Properties vorhanden sind
assert!(result.starts_with("minecraft:redstone_wire["));
assert!(result.ends_with("]"));
assert!(result.contains("north=side"));
@@ -340,6 +558,7 @@ mod tests {
assert!(result.contains("south=none"));
assert!(result.contains("west=side"));
}
+
#[test]
fn test_parse_lever() {
let json = r#"{
@@ -350,12 +569,11 @@ mod tests {
let block: Block = serde_json::from_str(json).unwrap();
assert_eq!(block.id, "minecraft:lever");
- assert_eq!(block.properties.get("powered"), Some(&Value::Bool(false)));
- assert_eq!(
- block.properties.get("face"),
- Some(&Value::String("floor".to_string()))
- );
+ // Values are converted to strings
+ assert_eq!(block.properties.get("powered"), Some(&"false".to_string()));
+ assert_eq!(block.properties.get("face"), Some(&"floor".to_string()));
}
+
#[test]
#[should_panic(expected = "missing field `id`")]
fn test_parse_missing_id() {
@@ -366,6 +584,7 @@ mod tests {
let _block: Block = serde_json::from_str(json).unwrap();
}
+
#[test]
#[should_panic(expected = "missing field `id`")]
fn test_parse_missing_object() {
@@ -384,11 +603,9 @@ mod tests {
let block: Block = serde_json::from_str(json).unwrap();
assert_eq!(block.id, "minecraft:lever");
- assert_eq!(block.properties.get("powered"), Some(&Value::Null));
- assert_eq!(
- block.properties.get("face"),
- Some(&Value::String("floor".to_string()))
- );
+ // Null is converted to empty string
+ assert_eq!(block.properties.get("powered"), Some(&String::new()));
+ assert_eq!(block.properties.get("face"), Some(&"floor".to_string()));
}
#[test]
@@ -403,7 +620,9 @@ mod tests {
let block: Block = serde_json::from_str(json).unwrap();
assert_eq!(block.id, "minecraft:chest");
- assert!(block.properties.get("metadata").unwrap().is_object());
+ assert_eq!(block.properties.get("facing"), Some(&"north".to_string()));
+ // Complex objects are serialized as JSON strings
+ assert!(block.properties.contains_key("metadata"));
}
#[test]
@@ -415,7 +634,8 @@ mod tests {
let block: Block = serde_json::from_str(json).unwrap();
assert_eq!(block.id, "minecraft:custom_block");
- assert!(block.properties.get("colors").unwrap().is_array());
+ // Arrays are serialized as JSON strings
+ assert!(block.properties.contains_key("colors"));
}
#[test]
@@ -428,6 +648,7 @@ mod tests {
let block: Block = serde_json::from_str(json).unwrap();
assert_eq!(block.id, "");
assert_eq!(block.properties.len(), 1);
+ assert_eq!(block.properties.get("powered"), Some(&"false".to_string()));
}
#[test]
@@ -442,7 +663,7 @@ mod tests {
assert_eq!(block.id, "minecraft:custom");
assert_eq!(
block.properties.get("name"),
- Some(&Value::String("Test \"quoted\" value".to_string()))
+ Some(&"Test \"quoted\" value".to_string())
);
}
@@ -457,14 +678,14 @@ mod tests {
let block: Block = serde_json::from_str(json).unwrap();
assert_eq!(block.id, "minecraft:block");
- assert!(block.properties.get("integer").unwrap().is_number());
- assert!(block.properties.get("float").unwrap().is_number());
- assert!(block.properties.get("negative").unwrap().is_number());
+ // Numbers are converted to strings
+ assert_eq!(block.properties.get("integer"), Some(&"42".to_string()));
+ assert_eq!(block.properties.get("float"), Some(&"3.14".to_string()));
+ assert_eq!(block.properties.get("negative"), Some(&"-10".to_string()));
}
#[test]
fn test_nested_properties_object() {
- // Test the new format where properties are nested inside a "properties" key
let json = r#"{
"id": "minecraft:lever",
"properties": {
@@ -476,7 +697,6 @@ mod tests {
let block: Block = serde_json::from_str(json).unwrap();
let result = block.to_command();
- // Order may vary due to HashMap
assert!(result.contains("minecraft:lever["));
assert!(result.contains("powered=true"));
assert!(result.contains("face=floor"));
@@ -502,7 +722,6 @@ mod tests {
#[test]
fn test_empty_nested_properties() {
- // When properties object is empty, should return just the id
let json = r#"{
"id": "minecraft:stone",
"properties": {}
@@ -514,33 +733,6 @@ mod tests {
assert_eq!(result, "minecraft:stone");
}
- #[test]
- fn test_mixed_flat_and_nested_properties() {
- // Test when there's both flat properties and nested ones
- let mut block = Block {
- id: "minecraft:test".to_string(),
- properties: HashMap::new(),
- };
-
- // Add a flat property
- block
- .properties
- .insert("flat_prop".to_string(), Value::from("value1"));
-
- // Add nested properties
- let mut nested = serde_json::Map::new();
- nested.insert("nested_prop".to_string(), Value::from("value2"));
- block
- .properties
- .insert("properties".to_string(), Value::Object(nested));
-
- let result = block.to_command();
-
- assert!(result.contains("minecraft:test["));
- assert!(result.contains("flat_prop=value1"));
- assert!(result.contains("nested_prop=value2"));
- }
-
#[test]
fn test_nested_properties_bool_values() {
let json = r#"{
@@ -557,4 +749,16 @@ mod tests {
assert!(result.contains("extended=true"));
assert!(result.contains("facing=up"));
}
+
+ #[test]
+ fn test_is_air() {
+ let air = Block::new("minecraft:air");
+ assert!(air.is_air());
+
+ let air_short = Block::new("air");
+ assert!(air_short.is_air());
+
+ let stone = Block::new("minecraft:stone");
+ assert!(!stone.is_air());
+ }
}
diff --git a/src/traits.rs b/src/traits.rs
new file mode 100644
index 0000000..1dbd34b
--- /dev/null
+++ b/src/traits.rs
@@ -0,0 +1,78 @@
+//! Core traits that server implementations must provide.
+//!
+//! Servers implement `FlintAdapter` to create test worlds, and `FlintWorld`/`FlintPlayer`
+//! to provide the actual block and player operations.
+
+use crate::Block;
+use crate::test_spec::{BlockFace, Item, PlayerSlot};
+
+/// Position in world coordinates [x, y, z]
+pub type BlockPos = [i32; 3];
+
+/// Server metadata
+#[derive(Debug, Clone)]
+pub struct ServerInfo {
+ pub minecraft_version: String,
+}
+
+// =============================================================================
+// Core Traits
+// =============================================================================
+
+/// Main adapter trait - server implements this to create test worlds
+pub trait FlintAdapter: Send + Sync {
+ /// Create a new disposable in-memory test world
+ fn create_test_world(&self) -> Box;
+
+ /// Server metadata for logging
+ fn server_info(&self) -> ServerInfo;
+}
+
+/// World operations - server implements this
+///
+/// This is the minimal interface servers must provide.
+/// Flint handles fill/clear by iterating `set_block()`.
+pub trait FlintWorld: Send + Sync {
+ /// Execute exactly one game tick
+ fn do_tick(&mut self);
+
+ /// Get current tick count
+ fn current_tick(&self) -> u64;
+
+ /// Get block at position
+ fn get_block(&self, pos: BlockPos) -> Block;
+
+ /// Set block at position (with neighbor updates)
+ fn set_block(&mut self, pos: BlockPos, block: &Block);
+
+ /// Create a simulated player in this world
+ ///
+ /// Only called when tests use `use_item_on` or player-related actions.
+ /// Pure block tests (place, fill, assert) don't need a player.
+ fn create_player(&mut self) -> Box;
+}
+
+/// Player operations - server implements this
+///
+/// Hybrid model: Server owns the player entity, but flint can:
+/// - Manipulate inventory slots directly
+/// - Select hotbar slots
+/// - Trigger item use actions
+pub trait FlintPlayer: Send + Sync {
+ /// Set item in a slot (None = empty/clear the slot)
+ fn set_slot(&mut self, slot: PlayerSlot, item: Option<&Item>);
+
+ /// Get item from a slot (None if empty)
+ fn get_slot(&self, slot: PlayerSlot) -> Option- ;
+
+ /// Select which hotbar slot is active (1-9)
+ fn select_hotbar(&mut self, slot: u8);
+
+ /// Get currently selected hotbar slot (1-9)
+ fn selected_hotbar(&self) -> u8;
+
+ /// Use the item in the active hotbar slot on a block face
+ ///
+ /// This tests the server's actual interaction logic.
+ fn use_item_on(&mut self, pos: BlockPos, face: &BlockFace);
+}