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); +}