From e8f68fa4d2610d99d3c6e28b4e05e46f8e9ecee4 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:17:12 +0200 Subject: [PATCH 01/11] add tp and rename use_item_on to interact --- flint-content/test_spec_schema.json | 42 ++++++++++++++----- src/runner.rs | 21 +++++++--- src/test_spec.rs | 62 ++++++++++++++++++++++++----- src/traits.rs | 19 ++++++--- 4 files changed, 112 insertions(+), 32 deletions(-) diff --git a/flint-content/test_spec_schema.json b/flint-content/test_spec_schema.json index a7088c8..76154fe 100644 --- a/flint-content/test_spec_schema.json +++ b/flint-content/test_spec_schema.json @@ -278,7 +278,7 @@ }, "do": { "type": "string", - "enum": ["place", "place_each", "fill", "remove", "assert", "use_item_on", "set_slot", "select_hotbar"], + "enum": ["place", "place_each", "fill", "remove", "assert", "tp", "interact", "set_slot", "select_hotbar"], "description": "Type of action to perform" } }, @@ -390,27 +390,51 @@ }, { "if": { - "properties": { "do": { "const": "use_item_on" } }, + "properties": { "do": { "const": "tp" } }, "required": ["do"] }, "then": { "properties": { "at": true, "do": true, - "pos": { - "$ref": "#/$defs/Coordinate", - "description": "Position of the block to interact with" + "entity_slot": { + "type": "integer", + "const": 0, + "description": "Entity slot to teleport; slot 0 is always the player" }, - "face": { - "$ref": "#/$defs/BlockFace", - "description": "Face of the block to interact with" + "pos": { + "type": "array", + "items": { "type": "number" }, + "minItems": 3, + "maxItems": 3, + "description": "Destination [x, y, z]" }, + "rot": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2, + "description": "Optional rotation [yaw, pitch]" + } + }, + "required": ["entity_slot", "pos"], + "additionalProperties": false + } + }, + { + "if": { + "properties": { "do": { "const": "interact" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, "item": { "type": "string", "description": "Item to use (optional, uses player's active item if not specified)" } }, - "required": ["pos", "face"], "additionalProperties": false } }, diff --git a/src/runner.rs b/src/runner.rs index 163cf58..8c893ac 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -234,19 +234,28 @@ impl TestRunner { ActionOutcome::AssertPassed } - ActionType::UseItemOn { pos, face, item } => { - // Create player on demand if not already created + ActionType::Tp { + entity_slot, + pos, + rot, + } => { + assert_eq!( + *entity_slot, 0, + "only entity_slot 0 (the player) is supported" + ); let p = player.get_or_insert_with(|| world.create_player()); - let pos = [pos[0], pos[1], pos[2]]; + p.teleport(*pos, *rot); + ActionOutcome::Action + } - // Simple mode: if item is specified, set it in hotbar1 and select it + ActionType::Interact { item } => { + let p = player.get_or_insert_with(|| world.create_player()); 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); + p.interact(); ActionOutcome::Action } diff --git a/src/test_spec.rs b/src/test_spec.rs index 1abed2c..dad2b8f 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -374,12 +374,19 @@ pub enum ActionType { 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. + // Entity/player actions + /// Teleport an entity. Entity slot 0 is always the player. + Tp { + entity_slot: u32, + pos: [f64; 3], + /// Rotation as [yaw, pitch]. + #[serde(default)] + rot: Option<[f32; 2]>, + }, + + /// Interact using the item in the player's active hand. + Interact { + /// Item to use. If not specified, uses the player's active item. #[serde(default)] item: Option, }, @@ -619,11 +626,11 @@ impl TestSpec { } } } - ActionType::UseItemOn { pos, .. } => { - self.validate_position(*pos, ®ion)?; - } - // SetSlot and SelectHotbar don't have positions to validate - ActionType::SetSlot { .. } | ActionType::SelectHotbar { .. } => {} + // Player/entity actions do not address a block in the cleanup region. + ActionType::Tp { .. } + | ActionType::Interact { .. } + | ActionType::SetSlot { .. } + | ActionType::SelectHotbar { .. } => {} } } @@ -924,4 +931,37 @@ mod tests { let stone = Block::new("minecraft:stone"); assert!(!stone.is_air()); } + + #[test] + fn test_tp_action_deserializes_with_optional_rotation() { + let action: ActionType = + serde_json::from_str(r#"{"do":"tp","entity_slot":0,"pos":[1.5,64,2],"rot":[0,90]}"#) + .unwrap(); + + match action { + ActionType::Tp { + entity_slot, + pos, + rot, + } => { + assert_eq!(entity_slot, 0); + assert_eq!(pos, [1.5, 64.0, 2.0]); + assert_eq!(rot, Some([0.0, 90.0])); + } + _ => panic!("expected tp action"), + } + } + + #[test] + fn test_interact_action_deserializes() { + let action: ActionType = + serde_json::from_str(r#"{"do":"interact","item":"minecraft:bone_meal"}"#).unwrap(); + + match action { + ActionType::Interact { item } => { + assert_eq!(item.as_deref(), Some("minecraft:bone_meal")); + } + _ => panic!("expected interact action"), + } + } } diff --git a/src/traits.rs b/src/traits.rs index 802a4fb..f2cd1a6 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -4,7 +4,7 @@ //! to provide the actual block and player operations. use crate::Block; -use crate::test_spec::{BlockFace, GameMode, Item, PlayerSlot}; +use crate::test_spec::{GameMode, Item, PlayerSlot}; /// Position in world coordinates [x, y, z] pub type BlockPos = [i32; 3]; @@ -47,7 +47,7 @@ pub trait FlintWorld: Send + Sync { /// Create a simulated player in this world /// - /// Only called when tests use `use_item_on` or player-related actions. + /// Only called when tests use player-related actions. /// Pure block tests (place, fill, assert) don't need a player. fn create_player(&mut self) -> Box; } @@ -59,6 +59,12 @@ pub trait FlintWorld: Send + Sync { /// - Select hotbar slots /// - Trigger item use actions pub trait FlintPlayer: Send + Sync { + /// Restore this simulated player's inventory into the backing server player. + /// + /// Implementations with one shared server player can use this as a context + /// switch when multiple tests execute in parallel. + fn restore_inventory(&mut self) {} + /// Set item in a slot (None = empty/clear the slot) fn set_slot(&mut self, slot: PlayerSlot, item: Option<&Item>); @@ -71,10 +77,11 @@ pub trait FlintPlayer: Send + Sync { /// 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); + /// Teleport the player to a world position and optionally set [yaw, pitch]. + fn teleport(&mut self, pos: [f64; 3], rot: Option<[f32; 2]>); + + /// Use the item in the active hand against the current crosshair target. + fn interact(&mut self); /// Set the game mode of the player (creative, survival, etc.) fn set_game_mode(&mut self, mode: GameMode); From ca6d7413908a5e365140961b9366cd171cd195bd Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:19:29 +0200 Subject: [PATCH 02/11] add summon and entity --- src/runner.rs | 93 +++++++++++++++++++++++++++++--- src/test_spec.rs | 135 ++++++++++++++++++++++++++++++++++++++++++++--- src/traits.rs | 25 +++++++++ 3 files changed, 237 insertions(+), 16 deletions(-) diff --git a/src/runner.rs b/src/runner.rs index 8c893ac..15c7272 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -8,7 +8,7 @@ use crate::results::{ }; use crate::test_spec::{ActionType, AssertType, Item, PlayerSlot}; use crate::timeline::TimelineAggregate; -use crate::traits::{FlintAdapter, FlintPlayer, FlintWorld}; +use crate::traits::{EntityState, FlintAdapter, FlintPlayer, FlintWorld}; use crate::{Block, TestSpec, TestSpecLoadResult}; use std::sync::Arc; use std::time::Instant; @@ -177,6 +177,16 @@ impl TestRunner { ActionOutcome::Action } + ActionType::Summon { + entity_alias, + entity_type, + pos, + nbt, + } => { + world.summon_entity(entity_alias, entity_type, *pos, nbt.as_deref()); + ActionOutcome::Action + } + ActionType::Assert { checks } => { for check in checks { match check { @@ -225,6 +235,37 @@ impl TestRunner { )); } } + AssertType::Entity(entity) => { + let actual = world.get_entity(&entity.entity_alias); + if !entity_matches( + &actual, + entity.exists, + entity.entity_type.as_deref(), + entity.pos, + entity.max_distance, + ) { + return ActionOutcome::AssertFailed(AssertFailure { + tick: _tick, + error_message: format!( + "Entity mismatch for alias '{}'", + entity.entity_alias + ), + position: entity + .pos + .map(|pos| { + AssertPosition::from_array([ + pos[0].floor() as i32, + pos[1].floor() as i32, + pos[2].floor() as i32, + ]) + }) + .unwrap_or_else(|| AssertPosition::from_array([0, 0, 0])), + execution_time_ms: None, + expected: InfoType::String(format!("{entity:?}")), + actual: InfoType::String(format!("{actual:?}")), + }); + } + } #[allow(unused)] _ => { println!("Unsupported assertion type: {:?}", check); @@ -235,16 +276,16 @@ impl TestRunner { } ActionType::Tp { - entity_slot, + entity_alias, pos, rot, } => { - assert_eq!( - *entity_slot, 0, - "only entity_slot 0 (the player) is supported" - ); - let p = player.get_or_insert_with(|| world.create_player()); - p.teleport(*pos, *rot); + if entity_alias == "player" { + let p = player.get_or_insert_with(|| world.create_player()); + p.teleport(*pos, *rot); + } else { + world.teleport_entity(entity_alias, *pos, *rot); + } ActionOutcome::Action } @@ -343,3 +384,39 @@ fn item_matches(actual: &Item, expected: &Item) -> bool { } true } + +fn entity_matches( + actual: &EntityState, + expected_exists: bool, + expected_type: Option<&str>, + expected_pos: Option<[f64; 3]>, + max_distance: Option, +) -> bool { + if actual.exists != expected_exists { + return false; + } + if !expected_exists { + return true; + } + if let Some(expected_type) = expected_type + && actual.entity_type.as_deref() != Some(expected_type) + { + return false; + } + if let Some(expected_pos) = expected_pos { + let Some(actual_pos) = actual.pos else { + return false; + }; + let max_distance = max_distance.unwrap_or(0.25); + let distance = actual_pos + .into_iter() + .zip(expected_pos) + .map(|(actual, expected)| (actual - expected).powi(2)) + .sum::() + .sqrt(); + if distance > max_distance { + return false; + } + } + true +} diff --git a/src/test_spec.rs b/src/test_spec.rs index dad2b8f..4fc7218 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -369,15 +369,23 @@ pub enum ActionType { pos: [i32; 3], }, + Summon { + entity_alias: String, + entity_type: String, + pos: [f64; 3], + #[serde(default)] + nbt: Option, + }, + // Assertion actions Assert { checks: Vec, }, // Entity/player actions - /// Teleport an entity. Entity slot 0 is always the player. + /// Teleport an entity alias. Use "player" for the backing bot/player. Tp { - entity_slot: u32, + entity_alias: String, pos: [f64; 3], /// Rotation as [yaw, pitch]. #[serde(default)] @@ -438,6 +446,23 @@ pub struct BlockCheck { pub is: BlockSpec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityCheck { + pub entity_alias: String, + #[serde(default, rename = "is")] + pub entity_type: Option, + #[serde(default = "default_exists")] + pub exists: bool, + #[serde(default)] + pub pos: Option<[f64; 3]>, + #[serde(default)] + pub max_distance: Option, +} + +fn default_exists() -> bool { + true +} + /// Result of a two-phase test spec load pub enum TestSpecLoadResult { Loaded(TestSpec), @@ -473,6 +498,7 @@ where pub enum AssertType { Block(BlockCheck), Inventory(InventoryCheck), + Entity(EntityCheck), } impl TestSpec { @@ -615,18 +641,40 @@ impl TestSpec { ActionType::Remove { pos } => { self.validate_position(*pos, ®ion)?; } + ActionType::Summon { pos, .. } => { + self.validate_position( + [ + pos[0].floor() as i32, + pos[1].floor() as i32, + pos[2].floor() as i32, + ], + ®ion, + )?; + } ActionType::Assert { checks } => { for check in checks { match check { AssertType::Block(block) => { self.validate_position(block.pos, ®ion)? } + AssertType::Entity(entity) => { + if let Some(pos) = entity.pos { + self.validate_position( + [ + pos[0].floor() as i32, + pos[1].floor() as i32, + pos[2].floor() as i32, + ], + ®ion, + )?; + } + } // Inventory checks are not validated because there are not any boundings AssertType::Inventory(_) => {} } } } - // Player/entity actions do not address a block in the cleanup region. + // Player actions do not address a block in the cleanup region. ActionType::Tp { .. } | ActionType::Interact { .. } | ActionType::SetSlot { .. } @@ -934,17 +982,18 @@ mod tests { #[test] fn test_tp_action_deserializes_with_optional_rotation() { - let action: ActionType = - serde_json::from_str(r#"{"do":"tp","entity_slot":0,"pos":[1.5,64,2],"rot":[0,90]}"#) - .unwrap(); + let action: ActionType = serde_json::from_str( + r#"{"do":"tp","entity_alias":"player","pos":[1.5,64,2],"rot":[0,90]}"#, + ) + .unwrap(); match action { ActionType::Tp { - entity_slot, + entity_alias, pos, rot, } => { - assert_eq!(entity_slot, 0); + assert_eq!(entity_alias, "player"); assert_eq!(pos, [1.5, 64.0, 2.0]); assert_eq!(rot, Some([0.0, 90.0])); } @@ -952,6 +1001,31 @@ mod tests { } } + #[test] + fn test_tp_action_requires_entity_alias() { + let error = + serde_json::from_str::(r#"{"do":"tp","pos":[1.5,64,2],"rot":[0,90]}"#) + .unwrap_err(); + assert!(error.to_string().contains("entity_alias")); + } + + #[test] + fn test_tp_action_accepts_entity_alias() { + let action: ActionType = + serde_json::from_str(r#"{"do":"tp","entity_alias":"falling","pos":[1.5,64,2]}"#) + .unwrap(); + + match action { + ActionType::Tp { + entity_alias, pos, .. + } => { + assert_eq!(entity_alias, "falling"); + assert_eq!(pos, [1.5, 64.0, 2.0]); + } + _ => panic!("expected tp action"), + } + } + #[test] fn test_interact_action_deserializes() { let action: ActionType = @@ -964,4 +1038,49 @@ mod tests { _ => panic!("expected interact action"), } } + + #[test] + fn test_summon_action_deserializes() { + let action: ActionType = serde_json::from_str( + r#"{"do":"summon","entity_alias":"falling","entity_type":"minecraft:falling_block","pos":[1.5,64,2],"nbt":"{NoGravity:1b}"}"#, + ) + .unwrap(); + + match action { + ActionType::Summon { + entity_alias, + entity_type, + pos, + nbt, + } => { + assert_eq!(entity_alias, "falling"); + assert_eq!(entity_type, "minecraft:falling_block"); + assert_eq!(pos, [1.5, 64.0, 2.0]); + assert_eq!(nbt.as_deref(), Some("{NoGravity:1b}")); + } + _ => panic!("expected summon action"), + } + } + + #[test] + fn test_entity_assert_deserializes() { + let check: AssertType = serde_json::from_str( + r#"{"entity_alias":"falling","is":"minecraft:falling_block","pos":[1.5,64,2],"max_distance":0.5}"#, + ) + .unwrap(); + + match check { + AssertType::Entity(entity) => { + assert_eq!(entity.entity_alias, "falling"); + assert_eq!( + entity.entity_type.as_deref(), + Some("minecraft:falling_block") + ); + assert!(entity.exists); + assert_eq!(entity.pos, Some([1.5, 64.0, 2.0])); + assert_eq!(entity.max_distance, Some(0.5)); + } + _ => panic!("expected entity assert"), + } + } } diff --git a/src/traits.rs b/src/traits.rs index f2cd1a6..5f37abc 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -15,6 +15,13 @@ pub struct ServerInfo { pub minecraft_version: String, } +#[derive(Debug, Clone, Default)] +pub struct EntityState { + pub exists: bool, + pub entity_type: Option, + pub pos: Option<[f64; 3]>, +} + // ============================================================================= // Core Traits // ============================================================================= @@ -45,6 +52,24 @@ pub trait FlintWorld: Send + Sync { /// Set block at position (with neighbor updates) fn set_block(&mut self, pos: BlockPos, block: &Block); + /// Summon an entity with a stable test-local alias. + fn summon_entity( + &mut self, + _alias: &str, + _entity_type: &str, + _pos: [f64; 3], + _nbt: Option<&str>, + ) { + } + + /// Teleport an implementation-managed entity alias. + fn teleport_entity(&mut self, _alias: &str, _pos: [f64; 3], _rot: Option<[f32; 2]>) {} + + /// Read the current entity state for a test-local alias. + fn get_entity(&self, _alias: &str) -> EntityState { + EntityState::default() + } + /// Create a simulated player in this world /// /// Only called when tests use player-related actions. From 35888469fa444d534ee9b3fe6066b1dad62df0d5 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:55:21 +0200 Subject: [PATCH 03/11] Update traits.rs --- src/traits.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/traits.rs b/src/traits.rs index 5f37abc..d1b7341 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -5,6 +5,7 @@ use crate::Block; use crate::test_spec::{GameMode, Item, PlayerSlot}; +use std::any::Any; /// Position in world coordinates [x, y, z] pub type BlockPos = [i32; 3]; @@ -84,11 +85,7 @@ pub trait FlintWorld: Send + Sync { /// - Select hotbar slots /// - Trigger item use actions pub trait FlintPlayer: Send + Sync { - /// Restore this simulated player's inventory into the backing server player. - /// - /// Implementations with one shared server player can use this as a context - /// switch when multiple tests execute in parallel. - fn restore_inventory(&mut self) {} + fn as_any_mut(&mut self) -> &mut dyn Any; /// Set item in a slot (None = empty/clear the slot) fn set_slot(&mut self, slot: PlayerSlot, item: Option<&Item>); From b5aabf8a3c96a1ac53eda4d959d8774fa6ed5f76 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:52:50 +0200 Subject: [PATCH 04/11] update schema --- flint-content/test_spec_schema.json | 114 +++++++++- src/index.rs | 2 + src/runner.rs | 42 +++- src/test_spec.rs | 309 +++++++++++++++++++++++++++- src/traits.rs | 9 +- 5 files changed, 454 insertions(+), 22 deletions(-) diff --git a/flint-content/test_spec_schema.json b/flint-content/test_spec_schema.json index 76154fe..db3bcb7 100644 --- a/flint-content/test_spec_schema.json +++ b/flint-content/test_spec_schema.json @@ -65,6 +65,13 @@ "maxItems": 3, "description": "A 3D coordinate [x, y, z]" }, + "NumericCoordinate": { + "type": "array", + "items": { "type": "number" }, + "minItems": 3, + "maxItems": 3, + "description": "A 3D coordinate [x, y, z] that may include fractional entity positions" + }, "Region": { "type": "array", "items": { "$ref": "#/$defs/Coordinate" }, @@ -268,6 +275,64 @@ }, "additionalProperties": false }, + "EntityCheck": { + "type": "object", + "required": ["entity_alias"], + "properties": { + "entity_alias": { + "type": "string", + "description": "Test-local entity alias. Use aliases created by summon, or implementation-defined aliases." + }, + "is": { + "type": "string", + "description": "Optional expected entity type, e.g. 'minecraft:item'" + }, + "exists": { + "type": "boolean", + "default": true, + "description": "Whether the entity is expected to exist" + }, + "pos": { + "$ref": "#/$defs/NumericCoordinate", + "description": "Optional expected entity position" + }, + "max_distance": { + "type": "number", + "minimum": 0, + "description": "Allowed distance from expected position" + }, + "rot": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2, + "description": "Optional expected entity rotation [yaw, pitch]" + }, + "max_rotation_delta": { + "type": "number", + "minimum": 0, + "description": "Allowed absolute delta for each rotation component" + }, + "nbt": { + "$ref": "#/$defs/EntityNbt", + "description": "Optional expected entity NBT fields. Object keys are queried as data paths, e.g. Item.id." + } + }, + "additionalProperties": true + }, + "EntityNbt": { + "oneOf": [ + { + "type": "string", + "description": "Raw Minecraft SNBT compound, kept for backwards compatibility" + }, + { + "type": "object", + "description": "Structured entity NBT fields. Values may be strings, numbers, booleans, arrays, or objects.", + "additionalProperties": true + } + ] + }, "TimelineEntry": { "type": "object", "required": ["at", "do"], @@ -278,7 +343,7 @@ }, "do": { "type": "string", - "enum": ["place", "place_each", "fill", "remove", "assert", "tp", "interact", "set_slot", "select_hotbar"], + "enum": ["place", "place_each", "fill", "remove", "summon", "assert", "tp", "interact", "set_slot", "select_hotbar"], "description": "Type of action to perform" } }, @@ -364,6 +429,36 @@ "additionalProperties": false } }, + { + "if": { + "properties": { "do": { "const": "summon" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "entity_alias": { + "type": "string", + "description": "Test-local alias assigned to the summoned entity" + }, + "entity_type": { + "type": "string", + "description": "Minecraft entity type to summon, e.g. 'minecraft:item'" + }, + "pos": { + "$ref": "#/$defs/NumericCoordinate", + "description": "Summon position [x, y, z]" + }, + "nbt": { + "$ref": "#/$defs/EntityNbt", + "description": "Optional entity NBT. Implementations may inject alias metadata." + } + }, + "required": ["entity_alias", "entity_type", "pos"], + "additionalProperties": true + } + }, { "if": { "properties": { "do": { "const": "assert" } }, @@ -378,7 +473,8 @@ "items": { "oneOf": [ { "$ref": "#/$defs/BlockCheck" }, - { "$ref": "#/$defs/InventoryCheck" } + { "$ref": "#/$defs/InventoryCheck" }, + { "$ref": "#/$defs/EntityCheck" } ] }, "description": "List of checks to perform" @@ -397,16 +493,12 @@ "properties": { "at": true, "do": true, - "entity_slot": { - "type": "integer", - "const": 0, - "description": "Entity slot to teleport; slot 0 is always the player" + "entity_alias": { + "type": "string", + "description": "Entity alias to teleport. Use 'player' for the backing player." }, "pos": { - "type": "array", - "items": { "type": "number" }, - "minItems": 3, - "maxItems": 3, + "$ref": "#/$defs/NumericCoordinate", "description": "Destination [x, y, z]" }, "rot": { @@ -417,7 +509,7 @@ "description": "Optional rotation [yaw, pitch]" } }, - "required": ["entity_slot", "pos"], + "required": ["entity_alias", "pos"], "additionalProperties": false } }, diff --git a/src/index.rs b/src/index.rs index 57e27a6..fa27b58 100644 --- a/src/index.rs +++ b/src/index.rs @@ -403,6 +403,7 @@ mod tests { } #[test] + #[serial] fn test_hash() { let temp_dir = TempDir::new().unwrap(); @@ -428,6 +429,7 @@ mod tests { } #[test] + #[serial] fn test_hash_ignore_not_json() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/runner.rs b/src/runner.rs index 15c7272..6d21b33 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -183,7 +183,7 @@ impl TestRunner { pos, nbt, } => { - world.summon_entity(entity_alias, entity_type, *pos, nbt.as_deref()); + world.summon_entity(entity_alias, entity_type, *pos, nbt.as_ref()); ActionOutcome::Action } @@ -236,13 +236,21 @@ impl TestRunner { } } AssertType::Entity(entity) => { - let actual = world.get_entity(&entity.entity_alias); + let requested_nbt = entity + .nbt + .as_ref() + .map(|nbt| nbt.requested_paths()) + .unwrap_or_default(); + let actual = world.get_entity(&entity.entity_alias, &requested_nbt); if !entity_matches( &actual, entity.exists, entity.entity_type.as_deref(), entity.pos, entity.max_distance, + entity.rot, + entity.max_rotation_delta, + entity.nbt.as_ref(), ) { return ActionOutcome::AssertFailed(AssertFailure { tick: _tick, @@ -391,6 +399,9 @@ fn entity_matches( expected_type: Option<&str>, expected_pos: Option<[f64; 3]>, max_distance: Option, + expected_rot: Option<[f32; 2]>, + max_rotation_delta: Option, + expected_nbt: Option<&crate::test_spec::EntityNbt>, ) -> bool { if actual.exists != expected_exists { return false; @@ -418,5 +429,32 @@ fn entity_matches( return false; } } + if let Some(expected_rot) = expected_rot { + let Some(actual_rot) = actual.rot else { + return false; + }; + let max_delta = max_rotation_delta.unwrap_or(0.5); + if actual_rot + .into_iter() + .zip(expected_rot) + .any(|(actual, expected)| (actual - expected).abs() > max_delta) + { + return false; + } + } + if let Some(expected_nbt) = expected_nbt { + for (key, expected) in expected_nbt.expected_values() { + let Some(actual) = actual.nbt.get(&key) else { + return false; + }; + if normalize_entity_nbt_value(actual) != normalize_entity_nbt_value(&expected) { + return false; + } + } + } true } + +fn normalize_entity_nbt_value(value: &str) -> String { + value.trim().trim_matches('"').to_string() +} diff --git a/src/test_spec.rs b/src/test_spec.rs index 4fc7218..688ad84 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -339,6 +339,104 @@ fn json_value_to_string(value: &serde_json::Value) -> String { _ => value.to_string(), } } + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EntityNbt { + Raw(String), + Compound(FxHashMap), +} + +impl EntityNbt { + pub fn to_snbt(&self) -> String { + match self { + EntityNbt::Raw(raw) => raw.clone(), + EntityNbt::Compound(fields) => { + if fields.is_empty() { + "{}".to_string() + } else { + let fields = fields + .iter() + .map(|(key, value)| format!("{key}:{}", json_value_to_snbt(value))) + .collect::>() + .join(","); + format!("{{{fields}}}") + } + } + } + } + + pub fn requested_paths(&self) -> Vec { + match self { + EntityNbt::Raw(_) => Vec::new(), + EntityNbt::Compound(fields) => fields.keys().cloned().collect(), + } + } + + pub fn expected_values(&self) -> FxHashMap { + match self { + EntityNbt::Raw(_) => FxHashMap::default(), + EntityNbt::Compound(fields) => fields + .iter() + .map(|(key, value)| (key.clone(), json_value_to_entity_assert_string(value))) + .collect(), + } + } +} + +fn json_value_to_snbt(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => { + if is_raw_snbt_literal(s) { + s.clone() + } else { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()) + } + } + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Array(values) => { + let values = values + .iter() + .map(json_value_to_snbt) + .collect::>() + .join(","); + format!("[{values}]") + } + serde_json::Value::Object(fields) => { + let fields = fields + .iter() + .map(|(key, value)| format!("{key}:{}", json_value_to_snbt(value))) + .collect::>() + .join(","); + format!("{{{fields}}}") + } + } +} + +fn json_value_to_entity_assert_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 => "null".to_string(), + _ => json_value_to_snbt(value), + } +} + +fn is_raw_snbt_literal(value: &str) -> bool { + let value = value.trim(); + value.starts_with('{') + || value.starts_with('[') + || value.eq_ignore_ascii_case("true") + || value.eq_ignore_ascii_case("false") + || value + .strip_suffix(|c: char| { + matches!(c, 'b' | 'B' | 's' | 'S' | 'l' | 'L' | 'f' | 'F' | 'd' | 'D') + }) + .is_some_and(|number| !number.is_empty() && number.parse::().is_ok()) +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BlockFace { @@ -350,7 +448,7 @@ pub enum BlockFace { West, // -X } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(tag = "do", rename_all = "snake_case")] pub enum ActionType { // Block actions @@ -374,7 +472,7 @@ pub enum ActionType { entity_type: String, pos: [f64; 3], #[serde(default)] - nbt: Option, + nbt: Option, }, // Assertion actions @@ -414,6 +512,131 @@ pub enum ActionType { }, } +impl<'de> Deserialize<'de> for ActionType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let mut fields = serde_json::Map::::deserialize(deserializer)?; + let action = take_required::(&mut fields, "do")?; + match action.as_str() { + "place" => Ok(ActionType::Place { + pos: take_required(&mut fields, "pos")?, + block: take_required(&mut fields, "block")?, + }), + "place_each" => Ok(ActionType::PlaceEach { + blocks: take_required(&mut fields, "blocks")?, + }), + "fill" => Ok(ActionType::Fill { + region: take_required(&mut fields, "region")?, + with: take_required(&mut fields, "with")?, + }), + "remove" => Ok(ActionType::Remove { + pos: take_required(&mut fields, "pos")?, + }), + "summon" => { + let entity_alias = take_required(&mut fields, "entity_alias")?; + let entity_type = take_required(&mut fields, "entity_type")?; + let pos = take_required(&mut fields, "pos")?; + let explicit_nbt = take_optional(&mut fields, "nbt")?; + let nbt = merge_entity_nbt(explicit_nbt, entity_nbt_from_fields(fields)); + Ok(ActionType::Summon { + entity_alias, + entity_type, + pos, + nbt, + }) + } + "assert" => Ok(ActionType::Assert { + checks: take_required(&mut fields, "checks")?, + }), + "tp" => Ok(ActionType::Tp { + entity_alias: take_required(&mut fields, "entity_alias")?, + pos: take_required(&mut fields, "pos")?, + rot: take_optional(&mut fields, "rot")?, + }), + "interact" => Ok(ActionType::Interact { + item: take_optional(&mut fields, "item")?, + }), + "set_slot" => Ok(ActionType::SetSlot { + slot: take_required(&mut fields, "slot")?, + item: take_optional(&mut fields, "item")?, + count: take_optional(&mut fields, "count")?.unwrap_or_else(default_count), + }), + "select_hotbar" => Ok(ActionType::SelectHotbar { + slot: take_required(&mut fields, "slot")?, + }), + other => Err(serde::de::Error::unknown_variant( + other, + &[ + "place", + "place_each", + "fill", + "remove", + "summon", + "assert", + "tp", + "interact", + "set_slot", + "select_hotbar", + ], + )), + } + } +} + +fn take_required( + fields: &mut serde_json::Map, + key: &'static str, +) -> Result +where + T: serde::de::DeserializeOwned, + E: serde::de::Error, +{ + take_optional(fields, key)?.ok_or_else(|| E::missing_field(key)) +} + +fn take_optional( + fields: &mut serde_json::Map, + key: &str, +) -> Result, E> +where + T: serde::de::DeserializeOwned, + E: serde::de::Error, +{ + fields + .remove(key) + .map(serde_json::from_value) + .transpose() + .map_err(E::custom) +} + +fn entity_nbt_from_fields( + fields: serde_json::Map, +) -> Option> { + if fields.is_empty() { + None + } else { + Some(fields.into_iter().collect()) + } +} + +fn merge_entity_nbt( + explicit: Option, + flattened: Option>, +) -> Option { + match (explicit, flattened) { + (None, None) => None, + (Some(nbt), None) => Some(nbt), + (None, Some(fields)) => Some(EntityNbt::Compound(fields)), + (Some(EntityNbt::Compound(mut explicit)), Some(flattened)) => { + explicit.extend(flattened); + Some(EntityNbt::Compound(explicit)) + } + (Some(raw @ EntityNbt::Raw(_)), Some(_)) => Some(raw), + } +} + fn default_count() -> u8 { 1 } @@ -446,7 +669,7 @@ pub struct BlockCheck { pub is: BlockSpec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct EntityCheck { pub entity_alias: String, #[serde(default, rename = "is")] @@ -457,6 +680,41 @@ pub struct EntityCheck { pub pos: Option<[f64; 3]>, #[serde(default)] pub max_distance: Option, + #[serde(default)] + pub rot: Option<[f32; 2]>, + #[serde(default)] + pub max_rotation_delta: Option, + #[serde(default)] + pub nbt: Option, +} + +impl<'de> Deserialize<'de> for EntityCheck { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let mut fields = serde_json::Map::::deserialize(deserializer)?; + let entity_alias = take_required(&mut fields, "entity_alias")?; + let entity_type = take_optional(&mut fields, "is")?; + let exists = take_optional(&mut fields, "exists")?.unwrap_or_else(default_exists); + let pos = take_optional(&mut fields, "pos")?; + let max_distance = take_optional(&mut fields, "max_distance")?; + let rot = take_optional(&mut fields, "rot")?; + let max_rotation_delta = take_optional(&mut fields, "max_rotation_delta")?; + let explicit_nbt = take_optional(&mut fields, "nbt")?; + let nbt = merge_entity_nbt(explicit_nbt, entity_nbt_from_fields(fields)); + + Ok(EntityCheck { + entity_alias, + entity_type, + exists, + pos, + max_distance, + rot, + max_rotation_delta, + nbt, + }) + } } fn default_exists() -> bool { @@ -1042,7 +1300,7 @@ mod tests { #[test] fn test_summon_action_deserializes() { let action: ActionType = serde_json::from_str( - r#"{"do":"summon","entity_alias":"falling","entity_type":"minecraft:falling_block","pos":[1.5,64,2],"nbt":"{NoGravity:1b}"}"#, + r#"{"do":"summon","entity_alias":"falling","entity_type":"minecraft:falling_block","pos":[1.5,64,2],"NoGravity":"1b"}"#, ) .unwrap(); @@ -1056,7 +1314,40 @@ mod tests { assert_eq!(entity_alias, "falling"); assert_eq!(entity_type, "minecraft:falling_block"); assert_eq!(pos, [1.5, 64.0, 2.0]); - assert_eq!(nbt.as_deref(), Some("{NoGravity:1b}")); + let nbt = nbt.expect("expected nbt"); + assert_eq!(nbt.to_snbt(), "{NoGravity:1b}"); + } + _ => panic!("expected summon action"), + } + } + + #[test] + fn test_summon_action_accepts_raw_nbt() { + let action: ActionType = serde_json::from_str( + r#"{"do":"summon","entity_alias":"falling","entity_type":"minecraft:falling_block","pos":[1.5,64,2],"nbt":"{NoGravity:1b}"}"#, + ) + .unwrap(); + + match action { + ActionType::Summon { nbt, .. } => { + let nbt = nbt.expect("expected nbt"); + assert_eq!(nbt.to_snbt(), "{NoGravity:1b}"); + } + _ => panic!("expected summon action"), + } + } + + #[test] + fn test_summon_action_accepts_nested_nbt_field_for_compatibility() { + let action: ActionType = serde_json::from_str( + r#"{"do":"summon","entity_alias":"falling","entity_type":"minecraft:falling_block","pos":[1.5,64,2],"nbt":{"NoGravity":"1b"}}"#, + ) + .unwrap(); + + match action { + ActionType::Summon { nbt, .. } => { + let nbt = nbt.expect("expected nbt"); + assert_eq!(nbt.to_snbt(), "{NoGravity:1b}"); } _ => panic!("expected summon action"), } @@ -1065,7 +1356,7 @@ mod tests { #[test] fn test_entity_assert_deserializes() { let check: AssertType = serde_json::from_str( - r#"{"entity_alias":"falling","is":"minecraft:falling_block","pos":[1.5,64,2],"max_distance":0.5}"#, + r#"{"entity_alias":"falling","is":"minecraft:falling_block","pos":[1.5,64,2],"max_distance":0.5,"rot":[90,0],"max_rotation_delta":1,"NoGravity":"1b"}"#, ) .unwrap(); @@ -1079,6 +1370,12 @@ mod tests { assert!(entity.exists); assert_eq!(entity.pos, Some([1.5, 64.0, 2.0])); assert_eq!(entity.max_distance, Some(0.5)); + assert_eq!(entity.rot, Some([90.0, 0.0])); + assert_eq!(entity.max_rotation_delta, Some(1.0)); + assert_eq!( + entity.nbt.expect("expected nbt").to_snbt(), + "{NoGravity:1b}" + ); } _ => panic!("expected entity assert"), } diff --git a/src/traits.rs b/src/traits.rs index d1b7341..a255355 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -4,8 +4,9 @@ //! to provide the actual block and player operations. use crate::Block; -use crate::test_spec::{GameMode, Item, PlayerSlot}; +use crate::test_spec::{EntityNbt, GameMode, Item, PlayerSlot}; use std::any::Any; +use std::collections::HashMap; /// Position in world coordinates [x, y, z] pub type BlockPos = [i32; 3]; @@ -21,6 +22,8 @@ pub struct EntityState { pub exists: bool, pub entity_type: Option, pub pos: Option<[f64; 3]>, + pub rot: Option<[f32; 2]>, + pub nbt: HashMap, } // ============================================================================= @@ -59,7 +62,7 @@ pub trait FlintWorld: Send + Sync { _alias: &str, _entity_type: &str, _pos: [f64; 3], - _nbt: Option<&str>, + _nbt: Option<&EntityNbt>, ) { } @@ -67,7 +70,7 @@ pub trait FlintWorld: Send + Sync { fn teleport_entity(&mut self, _alias: &str, _pos: [f64; 3], _rot: Option<[f32; 2]>) {} /// Read the current entity state for a test-local alias. - fn get_entity(&self, _alias: &str) -> EntityState { + fn get_entity(&self, _alias: &str, _requested_nbt: &[String]) -> EntityState { EntityState::default() } From f6b29323fbb250698d26d65495ff9925a3cfcae2 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:57:53 +0200 Subject: [PATCH 05/11] fix clippy --- src/runner.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runner.rs b/src/runner.rs index 6d21b33..6ffe0ea 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -393,6 +393,7 @@ fn item_matches(actual: &Item, expected: &Item) -> bool { true } +#[expect(clippy::too_many_arguments)] fn entity_matches( actual: &EntityState, expected_exists: bool, From 623878b97fc5cade070d94b95e577e12013a5be5 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:09:14 +0200 Subject: [PATCH 06/11] Update runner.rs --- src/runner.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runner.rs b/src/runner.rs index 6ffe0ea..2e79b74 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -6,7 +6,7 @@ use crate::results::{ ActionOutcome, AssertFailure, AssertPosition, AssertionResult, InfoType, TestResult, TestSummary, }; -use crate::test_spec::{ActionType, AssertType, Item, PlayerSlot}; +use crate::test_spec::{ActionType, AssertType, EntityNbt, Item, PlayerSlot}; use crate::timeline::TimelineAggregate; use crate::traits::{EntityState, FlintAdapter, FlintPlayer, FlintWorld}; use crate::{Block, TestSpec, TestSpecLoadResult}; @@ -402,7 +402,7 @@ fn entity_matches( max_distance: Option, expected_rot: Option<[f32; 2]>, max_rotation_delta: Option, - expected_nbt: Option<&crate::test_spec::EntityNbt>, + expected_nbt: Option<&EntityNbt>, ) -> bool { if actual.exists != expected_exists { return false; From f83a97a272cf0e0fe79f66f5e4ab8457988c5e4f Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:33:39 +0200 Subject: [PATCH 07/11] fix some review around --- flint-content/test_spec_schema.json | 7 +- src/runner.rs | 101 ++++++++++++++++++---------- src/test_spec.rs | 34 +++++++--- 3 files changed, 95 insertions(+), 47 deletions(-) diff --git a/flint-content/test_spec_schema.json b/flint-content/test_spec_schema.json index db3bcb7..f457c54 100644 --- a/flint-content/test_spec_schema.json +++ b/flint-content/test_spec_schema.json @@ -296,7 +296,7 @@ "$ref": "#/$defs/NumericCoordinate", "description": "Optional expected entity position" }, - "max_distance": { + "position_tolerance": { "type": "number", "minimum": 0, "description": "Allowed distance from expected position" @@ -308,13 +308,14 @@ "maxItems": 2, "description": "Optional expected entity rotation [yaw, pitch]" }, - "max_rotation_delta": { + "rotation_tolerance": { "type": "number", "minimum": 0, "description": "Allowed absolute delta for each rotation component" }, "nbt": { - "$ref": "#/$defs/EntityNbt", + "type": "object", + "additionalProperties": true, "description": "Optional expected entity NBT fields. Object keys are queried as data paths, e.g. Item.id." } }, diff --git a/src/runner.rs b/src/runner.rs index 2e79b74..edd1492 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -242,16 +242,7 @@ impl TestRunner { .map(|nbt| nbt.requested_paths()) .unwrap_or_default(); let actual = world.get_entity(&entity.entity_alias, &requested_nbt); - if !entity_matches( - &actual, - entity.exists, - entity.entity_type.as_deref(), - entity.pos, - entity.max_distance, - entity.rot, - entity.max_rotation_delta, - entity.nbt.as_ref(), - ) { + if !entity_matches(&actual, entity) { return ActionOutcome::AssertFailed(AssertFailure { tick: _tick, error_message: format!( @@ -393,57 +384,49 @@ fn item_matches(actual: &Item, expected: &Item) -> bool { true } -#[expect(clippy::too_many_arguments)] -fn entity_matches( - actual: &EntityState, - expected_exists: bool, - expected_type: Option<&str>, - expected_pos: Option<[f64; 3]>, - max_distance: Option, - expected_rot: Option<[f32; 2]>, - max_rotation_delta: Option, - expected_nbt: Option<&EntityNbt>, -) -> bool { - if actual.exists != expected_exists { +pub fn entity_matches(actual: &EntityState, expected: &crate::test_spec::EntityCheck) -> bool { + if actual.exists != expected.exists { return false; } - if !expected_exists { + if !expected.exists { return true; } - if let Some(expected_type) = expected_type + if let Some(expected_type) = expected.entity_type.as_deref() && actual.entity_type.as_deref() != Some(expected_type) { return false; } - if let Some(expected_pos) = expected_pos { + if let Some(expected_pos) = expected.pos { let Some(actual_pos) = actual.pos else { return false; }; - let max_distance = max_distance.unwrap_or(0.25); + let position_tolerance = expected.position_tolerance.unwrap_or(0.25); let distance = actual_pos .into_iter() .zip(expected_pos) .map(|(actual, expected)| (actual - expected).powi(2)) .sum::() .sqrt(); - if distance > max_distance { + if distance > position_tolerance { return false; } } - if let Some(expected_rot) = expected_rot { + if let Some(expected_rot) = expected.rot { let Some(actual_rot) = actual.rot else { return false; }; - let max_delta = max_rotation_delta.unwrap_or(0.5); - if actual_rot - .into_iter() - .zip(expected_rot) - .any(|(actual, expected)| (actual - expected).abs() > max_delta) - { + let rotation_tolerance = expected.rotation_tolerance.unwrap_or(0.5); + let yaw_delta = (actual_rot[0] - expected_rot[0]).rem_euclid(360.0); + let yaw_delta = yaw_delta.min(360.0 - yaw_delta); + let pitch_delta = (actual_rot[1] - expected_rot[1]).abs(); + if yaw_delta > rotation_tolerance || pitch_delta > rotation_tolerance { return false; } } - if let Some(expected_nbt) = expected_nbt { + if let Some(expected_nbt) = expected.nbt.as_ref() { + if matches!(expected_nbt, EntityNbt::Raw(_)) { + return false; + } for (key, expected) in expected_nbt.expected_values() { let Some(actual) = actual.nbt.get(&key) else { return false; @@ -459,3 +442,51 @@ fn entity_matches( fn normalize_entity_nbt_value(value: &str) -> String { value.trim().trim_matches('"').to_string() } + +#[cfg(test)] +mod entity_match_tests { + use super::*; + use crate::test_spec::EntityCheck; + use std::collections::HashMap; + + fn check_with_rotation(rot: [f32; 2], tolerance: f32) -> EntityCheck { + EntityCheck { + entity_alias: "entity".to_string(), + entity_type: None, + exists: true, + pos: None, + position_tolerance: None, + rot: Some(rot), + rotation_tolerance: Some(tolerance), + nbt: None, + } + } + + #[test] + fn yaw_comparison_wraps_at_180_degrees() { + let actual = EntityState { + exists: true, + rot: Some([-179.0, 0.0]), + ..EntityState::default() + }; + + assert!(entity_matches( + &actual, + &check_with_rotation([179.0, 0.0], 2.0) + )); + } + + #[test] + fn programmatic_raw_nbt_assertion_never_succeeds_silently() { + let actual = EntityState { + exists: true, + nbt: HashMap::new(), + ..EntityState::default() + }; + let mut expected = check_with_rotation([0.0, 0.0], 0.5); + expected.rot = None; + expected.nbt = Some(EntityNbt::Raw("{NoGravity:1b}".to_string())); + + assert!(!entity_matches(&actual, &expected)); + } +} diff --git a/src/test_spec.rs b/src/test_spec.rs index 688ad84..e98c880 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -679,11 +679,11 @@ pub struct EntityCheck { #[serde(default)] pub pos: Option<[f64; 3]>, #[serde(default)] - pub max_distance: Option, + pub position_tolerance: Option, #[serde(default)] pub rot: Option<[f32; 2]>, #[serde(default)] - pub max_rotation_delta: Option, + pub rotation_tolerance: Option, #[serde(default)] pub nbt: Option, } @@ -698,20 +698,26 @@ impl<'de> Deserialize<'de> for EntityCheck { let entity_type = take_optional(&mut fields, "is")?; let exists = take_optional(&mut fields, "exists")?.unwrap_or_else(default_exists); let pos = take_optional(&mut fields, "pos")?; - let max_distance = take_optional(&mut fields, "max_distance")?; + let position_tolerance = take_optional(&mut fields, "position_tolerance")?; let rot = take_optional(&mut fields, "rot")?; - let max_rotation_delta = take_optional(&mut fields, "max_rotation_delta")?; + let rotation_tolerance = take_optional(&mut fields, "rotation_tolerance")?; let explicit_nbt = take_optional(&mut fields, "nbt")?; let nbt = merge_entity_nbt(explicit_nbt, entity_nbt_from_fields(fields)); + if matches!(nbt, Some(EntityNbt::Raw(_))) { + return Err(serde::de::Error::custom( + "raw SNBT is not supported in entity assertions; use an object of NBT paths and expected values", + )); + } + Ok(EntityCheck { entity_alias, entity_type, exists, pos, - max_distance, + position_tolerance, rot, - max_rotation_delta, + rotation_tolerance, nbt, }) } @@ -1356,7 +1362,7 @@ mod tests { #[test] fn test_entity_assert_deserializes() { let check: AssertType = serde_json::from_str( - r#"{"entity_alias":"falling","is":"minecraft:falling_block","pos":[1.5,64,2],"max_distance":0.5,"rot":[90,0],"max_rotation_delta":1,"NoGravity":"1b"}"#, + r#"{"entity_alias":"falling","is":"minecraft:falling_block","pos":[1.5,64,2],"position_tolerance":0.5,"rot":[90,0],"rotation_tolerance":1,"NoGravity":"1b"}"#, ) .unwrap(); @@ -1369,9 +1375,9 @@ mod tests { ); assert!(entity.exists); assert_eq!(entity.pos, Some([1.5, 64.0, 2.0])); - assert_eq!(entity.max_distance, Some(0.5)); + assert_eq!(entity.position_tolerance, Some(0.5)); assert_eq!(entity.rot, Some([90.0, 0.0])); - assert_eq!(entity.max_rotation_delta, Some(1.0)); + assert_eq!(entity.rotation_tolerance, Some(1.0)); assert_eq!( entity.nbt.expect("expected nbt").to_snbt(), "{NoGravity:1b}" @@ -1380,4 +1386,14 @@ mod tests { _ => panic!("expected entity assert"), } } + + #[test] + fn test_entity_assert_rejects_raw_nbt() { + let error = serde_json::from_str::( + r#"{"entity_alias":"falling","nbt":"{NoGravity:1b}"}"#, + ) + .unwrap_err(); + + assert!(error.to_string().contains("raw SNBT is not supported")); + } } From 335cb6c1bd0d8520b92618273c9daa71aec92982 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:44:59 +0200 Subject: [PATCH 08/11] Update test_spec_schema.json --- flint-content/test_spec_schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flint-content/test_spec_schema.json b/flint-content/test_spec_schema.json index f457c54..099c5c5 100644 --- a/flint-content/test_spec_schema.json +++ b/flint-content/test_spec_schema.json @@ -325,7 +325,7 @@ "oneOf": [ { "type": "string", - "description": "Raw Minecraft SNBT compound, kept for backwards compatibility" + "description": "Raw Minecraft SNBT compound for summon actions" }, { "type": "object", From e066a0971987959eb84f4bf1a1ce17fb397cee78 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:09:13 +0200 Subject: [PATCH 09/11] add AssertEntityFail --- src/results.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++---- src/runner.rs | 27 +++++------------------ src/traits.rs | 3 ++- 3 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/results.rs b/src/results.rs index b9cc042..d462ff6 100644 --- a/src/results.rs +++ b/src/results.rs @@ -1,5 +1,6 @@ use crate::results::AssertionResult::Failure; -use crate::test_spec::Block; +use crate::test_spec::{Block, EntityCheck}; +use crate::traits::EntityState; use crate::{Item, PlayerSlot, format}; use serde::{Deserialize, Serialize}; use std::fmt::Display; @@ -22,15 +23,20 @@ pub enum InfoType { Blocks(Vec), Item(Item), Slot(PlayerSlot), + EntityCheck(Box), + EntityState(Box), } impl InfoType { pub fn get_string(&self) -> Option { match self { InfoType::String(s) => Some(s.clone()), - InfoType::Block(_) | InfoType::Blocks(_) | InfoType::Item(_) | InfoType::Slot(_) => { - None - } + InfoType::Block(_) + | InfoType::Blocks(_) + | InfoType::Item(_) + | InfoType::Slot(_) + | InfoType::EntityCheck(_) + | InfoType::EntityState(_) => None, } } fn type_string_generator(val: &InfoType) -> String { @@ -44,6 +50,8 @@ impl InfoType { .join(" or "), InfoType::Slot(slot) => slot.to_string(), InfoType::Item(item) => item.to_command(), + InfoType::EntityCheck(entity) => format!("{entity:?}"), + InfoType::EntityState(entity) => format!("{entity:?}"), } } } @@ -81,6 +89,50 @@ pub struct AssertFailure { pub actual: InfoType, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssertEntityFail { + pub tick: u32, + pub expected: EntityCheck, + pub actual: EntityState, +} + +impl AssertEntityFail { + pub fn new(tick: u32, expected: &EntityCheck, actual: &EntityState) -> Self { + Self { + tick, + expected: expected.clone(), + actual: actual.clone(), + } + } +} + +impl From for AssertFailure { + fn from(failure: AssertEntityFail) -> Self { + let position = failure + .expected + .pos + .map(|pos| { + [ + pos[0].floor() as i32, + pos[1].floor() as i32, + pos[2].floor() as i32, + ] + }) + .unwrap_or([0, 0, 0]); + Self { + tick: failure.tick, + error_message: format!( + "Entity mismatch for alias '{}'", + failure.expected.entity_alias + ), + position: AssertPosition::from_array(position), + execution_time_ms: None, + expected: InfoType::EntityCheck(Box::new(failure.expected)), + actual: InfoType::EntityState(Box::new(failure.actual)), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] pub enum AssertPosition { Coordinate { x: i32, y: i32, z: i32 }, diff --git a/src/runner.rs b/src/runner.rs index edd1492..ae01362 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -3,8 +3,8 @@ //! The `TestRunner` loads tests and executes them against a server adapter. use crate::results::{ - ActionOutcome, AssertFailure, AssertPosition, AssertionResult, InfoType, TestResult, - TestSummary, + ActionOutcome, AssertEntityFail, AssertFailure, AssertPosition, AssertionResult, InfoType, + TestResult, TestSummary, }; use crate::test_spec::{ActionType, AssertType, EntityNbt, Item, PlayerSlot}; use crate::timeline::TimelineAggregate; @@ -243,26 +243,9 @@ impl TestRunner { .unwrap_or_default(); let actual = world.get_entity(&entity.entity_alias, &requested_nbt); if !entity_matches(&actual, entity) { - return ActionOutcome::AssertFailed(AssertFailure { - tick: _tick, - error_message: format!( - "Entity mismatch for alias '{}'", - entity.entity_alias - ), - position: entity - .pos - .map(|pos| { - AssertPosition::from_array([ - pos[0].floor() as i32, - pos[1].floor() as i32, - pos[2].floor() as i32, - ]) - }) - .unwrap_or_else(|| AssertPosition::from_array([0, 0, 0])), - execution_time_ms: None, - expected: InfoType::String(format!("{entity:?}")), - actual: InfoType::String(format!("{actual:?}")), - }); + return ActionOutcome::AssertFailed( + AssertEntityFail::new(_tick, entity, &actual).into(), + ); } } #[allow(unused)] diff --git a/src/traits.rs b/src/traits.rs index a255355..cb50833 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -5,6 +5,7 @@ use crate::Block; use crate::test_spec::{EntityNbt, GameMode, Item, PlayerSlot}; +use serde::{Deserialize, Serialize}; use std::any::Any; use std::collections::HashMap; @@ -17,7 +18,7 @@ pub struct ServerInfo { pub minecraft_version: String, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct EntityState { pub exists: bool, pub entity_type: Option, From 8ae58bf91123e454bc42a9ee705b6e7d1ad5abb4 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:11:53 +0200 Subject: [PATCH 10/11] simplify --- flint-content/test_spec_schema.json | 14 ++---- src/runner.rs | 20 +------- src/test_spec.rs | 78 ++++++++++------------------- 3 files changed, 30 insertions(+), 82 deletions(-) diff --git a/flint-content/test_spec_schema.json b/flint-content/test_spec_schema.json index 099c5c5..ad7500b 100644 --- a/flint-content/test_spec_schema.json +++ b/flint-content/test_spec_schema.json @@ -322,17 +322,9 @@ "additionalProperties": true }, "EntityNbt": { - "oneOf": [ - { - "type": "string", - "description": "Raw Minecraft SNBT compound for summon actions" - }, - { - "type": "object", - "description": "Structured entity NBT fields. Values may be strings, numbers, booleans, arrays, or objects.", - "additionalProperties": true - } - ] + "type": "object", + "description": "Structured entity NBT fields. Values may be strings, numbers, booleans, arrays, or objects.", + "additionalProperties": true }, "TimelineEntry": { "type": "object", diff --git a/src/runner.rs b/src/runner.rs index ae01362..7bbad18 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -6,7 +6,7 @@ use crate::results::{ ActionOutcome, AssertEntityFail, AssertFailure, AssertPosition, AssertionResult, InfoType, TestResult, TestSummary, }; -use crate::test_spec::{ActionType, AssertType, EntityNbt, Item, PlayerSlot}; +use crate::test_spec::{ActionType, AssertType, Item, PlayerSlot}; use crate::timeline::TimelineAggregate; use crate::traits::{EntityState, FlintAdapter, FlintPlayer, FlintWorld}; use crate::{Block, TestSpec, TestSpecLoadResult}; @@ -407,9 +407,6 @@ pub fn entity_matches(actual: &EntityState, expected: &crate::test_spec::EntityC } } if let Some(expected_nbt) = expected.nbt.as_ref() { - if matches!(expected_nbt, EntityNbt::Raw(_)) { - return false; - } for (key, expected) in expected_nbt.expected_values() { let Some(actual) = actual.nbt.get(&key) else { return false; @@ -430,7 +427,6 @@ fn normalize_entity_nbt_value(value: &str) -> String { mod entity_match_tests { use super::*; use crate::test_spec::EntityCheck; - use std::collections::HashMap; fn check_with_rotation(rot: [f32; 2], tolerance: f32) -> EntityCheck { EntityCheck { @@ -458,18 +454,4 @@ mod entity_match_tests { &check_with_rotation([179.0, 0.0], 2.0) )); } - - #[test] - fn programmatic_raw_nbt_assertion_never_succeeds_silently() { - let actual = EntityState { - exists: true, - nbt: HashMap::new(), - ..EntityState::default() - }; - let mut expected = check_with_rotation([0.0, 0.0], 0.5); - expected.rot = None; - expected.nbt = Some(EntityNbt::Raw("{NoGravity:1b}".to_string())); - - assert!(!entity_matches(&actual, &expected)); - } } diff --git a/src/test_spec.rs b/src/test_spec.rs index e98c880..325e35d 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -341,46 +341,33 @@ fn json_value_to_string(value: &serde_json::Value) -> String { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum EntityNbt { - Raw(String), - Compound(FxHashMap), -} +#[serde(transparent)] +pub struct EntityNbt(FxHashMap); impl EntityNbt { pub fn to_snbt(&self) -> String { - match self { - EntityNbt::Raw(raw) => raw.clone(), - EntityNbt::Compound(fields) => { - if fields.is_empty() { - "{}".to_string() - } else { - let fields = fields - .iter() - .map(|(key, value)| format!("{key}:{}", json_value_to_snbt(value))) - .collect::>() - .join(","); - format!("{{{fields}}}") - } - } + if self.0.is_empty() { + "{}".to_string() + } else { + let fields = self + .0 + .iter() + .map(|(key, value)| format!("{key}:{}", json_value_to_snbt(value))) + .collect::>() + .join(","); + format!("{{{fields}}}") } } pub fn requested_paths(&self) -> Vec { - match self { - EntityNbt::Raw(_) => Vec::new(), - EntityNbt::Compound(fields) => fields.keys().cloned().collect(), - } + self.0.keys().cloned().collect() } pub fn expected_values(&self) -> FxHashMap { - match self { - EntityNbt::Raw(_) => FxHashMap::default(), - EntityNbt::Compound(fields) => fields - .iter() - .map(|(key, value)| (key.clone(), json_value_to_entity_assert_string(value))) - .collect(), - } + self.0 + .iter() + .map(|(key, value)| (key.clone(), json_value_to_entity_assert_string(value))) + .collect() } } @@ -628,12 +615,11 @@ fn merge_entity_nbt( match (explicit, flattened) { (None, None) => None, (Some(nbt), None) => Some(nbt), - (None, Some(fields)) => Some(EntityNbt::Compound(fields)), - (Some(EntityNbt::Compound(mut explicit)), Some(flattened)) => { + (None, Some(fields)) => Some(EntityNbt(fields)), + (Some(EntityNbt(mut explicit)), Some(flattened)) => { explicit.extend(flattened); - Some(EntityNbt::Compound(explicit)) + Some(EntityNbt(explicit)) } - (Some(raw @ EntityNbt::Raw(_)), Some(_)) => Some(raw), } } @@ -704,12 +690,6 @@ impl<'de> Deserialize<'de> for EntityCheck { let explicit_nbt = take_optional(&mut fields, "nbt")?; let nbt = merge_entity_nbt(explicit_nbt, entity_nbt_from_fields(fields)); - if matches!(nbt, Some(EntityNbt::Raw(_))) { - return Err(serde::de::Error::custom( - "raw SNBT is not supported in entity assertions; use an object of NBT paths and expected values", - )); - } - Ok(EntityCheck { entity_alias, entity_type, @@ -1328,23 +1308,17 @@ mod tests { } #[test] - fn test_summon_action_accepts_raw_nbt() { - let action: ActionType = serde_json::from_str( + fn test_summon_action_rejects_raw_nbt() { + let error = serde_json::from_str::( r#"{"do":"summon","entity_alias":"falling","entity_type":"minecraft:falling_block","pos":[1.5,64,2],"nbt":"{NoGravity:1b}"}"#, ) - .unwrap(); + .unwrap_err(); - match action { - ActionType::Summon { nbt, .. } => { - let nbt = nbt.expect("expected nbt"); - assert_eq!(nbt.to_snbt(), "{NoGravity:1b}"); - } - _ => panic!("expected summon action"), - } + assert!(error.to_string().contains("invalid type")); } #[test] - fn test_summon_action_accepts_nested_nbt_field_for_compatibility() { + fn test_summon_action_accepts_nested_nbt_field() { let action: ActionType = serde_json::from_str( r#"{"do":"summon","entity_alias":"falling","entity_type":"minecraft:falling_block","pos":[1.5,64,2],"nbt":{"NoGravity":"1b"}}"#, ) @@ -1394,6 +1368,6 @@ mod tests { ) .unwrap_err(); - assert!(error.to_string().contains("raw SNBT is not supported")); + assert!(error.to_string().contains("invalid type")); } } From c298d91dc7451e773c552c1e721a550b85bff2f4 Mon Sep 17 00:00:00 2001 From: coco875 <59367621+coco875@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:25:41 +0200 Subject: [PATCH 11/11] Update runner.rs --- src/runner.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runner.rs b/src/runner.rs index 7bbad18..5151ec3 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -272,7 +272,9 @@ impl TestRunner { } ActionType::Interact { item } => { - let p = player.get_or_insert_with(|| world.create_player()); + let p = player + .as_mut() + .expect("interact requires an existing player"); if let Some(item_id) = item { let item = Item::new(item_id); p.set_slot(PlayerSlot::Hotbar1, Some(&item));