diff --git a/flint-content/test_spec_schema.json b/flint-content/test_spec_schema.json index a7088c8..ad7500b 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,57 @@ }, "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" + }, + "position_tolerance": { + "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]" + }, + "rotation_tolerance": { + "type": "number", + "minimum": 0, + "description": "Allowed absolute delta for each rotation component" + }, + "nbt": { + "type": "object", + "additionalProperties": true, + "description": "Optional expected entity NBT fields. Object keys are queried as data paths, e.g. Item.id." + } + }, + "additionalProperties": true + }, + "EntityNbt": { + "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 +336,7 @@ }, "do": { "type": "string", - "enum": ["place", "place_each", "fill", "remove", "assert", "use_item_on", "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 +422,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 +466,8 @@ "items": { "oneOf": [ { "$ref": "#/$defs/BlockCheck" }, - { "$ref": "#/$defs/InventoryCheck" } + { "$ref": "#/$defs/InventoryCheck" }, + { "$ref": "#/$defs/EntityCheck" } ] }, "description": "List of checks to perform" @@ -390,27 +479,47 @@ }, { "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_alias": { + "type": "string", + "description": "Entity alias to teleport. Use 'player' for the backing player." }, - "face": { - "$ref": "#/$defs/BlockFace", - "description": "Face of the block to interact with" + "pos": { + "$ref": "#/$defs/NumericCoordinate", + "description": "Destination [x, y, z]" }, + "rot": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2, + "description": "Optional rotation [yaw, pitch]" + } + }, + "required": ["entity_alias", "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/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/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 163cf58..5151ec3 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -3,12 +3,12 @@ //! 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, 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_ref()); + ActionOutcome::Action + } + ActionType::Assert { checks } => { for check in checks { match check { @@ -225,6 +235,19 @@ impl TestRunner { )); } } + AssertType::Entity(entity) => { + 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) { + return ActionOutcome::AssertFailed( + AssertEntityFail::new(_tick, entity, &actual).into(), + ); + } + } #[allow(unused)] _ => { println!("Unsupported assertion type: {:?}", check); @@ -234,19 +257,30 @@ impl TestRunner { 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]]; + ActionType::Tp { + entity_alias, + 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 + } - // Simple mode: if item is specified, set it in hotbar1 and select it + ActionType::Interact { item } => { + 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)); p.select_hotbar(1); } - - p.use_item_on(pos, face); + p.interact(); ActionOutcome::Action } @@ -334,3 +368,92 @@ fn item_matches(actual: &Item, expected: &Item) -> bool { } true } + +pub fn entity_matches(actual: &EntityState, expected: &crate::test_spec::EntityCheck) -> bool { + if actual.exists != expected.exists { + return false; + } + if !expected.exists { + return true; + } + 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 { + let Some(actual_pos) = actual.pos else { + return false; + }; + 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 > position_tolerance { + return false; + } + } + if let Some(expected_rot) = expected.rot { + let Some(actual_rot) = actual.rot else { + return false; + }; + 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.as_ref() { + 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() +} + +#[cfg(test)] +mod entity_match_tests { + use super::*; + use crate::test_spec::EntityCheck; + + 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) + )); + } +} diff --git a/src/test_spec.rs b/src/test_spec.rs index 1abed2c..325e35d 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -339,6 +339,91 @@ fn json_value_to_string(value: &serde_json::Value) -> String { _ => value.to_string(), } } + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EntityNbt(FxHashMap); + +impl EntityNbt { + pub fn to_snbt(&self) -> String { + 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 { + self.0.keys().cloned().collect() + } + + pub fn expected_values(&self) -> FxHashMap { + self.0 + .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 +435,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 @@ -369,17 +454,32 @@ 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, }, - // 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 alias. Use "player" for the backing bot/player. + Tp { + entity_alias: String, + 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, }, @@ -399,6 +499,130 @@ 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(fields)), + (Some(EntityNbt(mut explicit)), Some(flattened)) => { + explicit.extend(flattened); + Some(EntityNbt(explicit)) + } + } +} + fn default_count() -> u8 { 1 } @@ -431,6 +655,58 @@ pub struct BlockCheck { pub is: BlockSpec, } +#[derive(Debug, Clone, Serialize)] +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 position_tolerance: Option, + #[serde(default)] + pub rot: Option<[f32; 2]>, + #[serde(default)] + pub rotation_tolerance: 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 position_tolerance = take_optional(&mut fields, "position_tolerance")?; + let rot = take_optional(&mut fields, "rot")?; + 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)); + + Ok(EntityCheck { + entity_alias, + entity_type, + exists, + pos, + position_tolerance, + rot, + rotation_tolerance, + nbt, + }) + } +} + +fn default_exists() -> bool { + true +} + /// Result of a two-phase test spec load pub enum TestSpecLoadResult { Loaded(TestSpec), @@ -466,6 +742,7 @@ where pub enum AssertType { Block(BlockCheck), Inventory(InventoryCheck), + Entity(EntityCheck), } impl TestSpec { @@ -608,22 +885,44 @@ 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(_) => {} } } } - ActionType::UseItemOn { pos, .. } => { - self.validate_position(*pos, ®ion)?; - } - // SetSlot and SelectHotbar don't have positions to validate - ActionType::SetSlot { .. } | ActionType::SelectHotbar { .. } => {} + // Player actions do not address a block in the cleanup region. + ActionType::Tp { .. } + | ActionType::Interact { .. } + | ActionType::SetSlot { .. } + | ActionType::SelectHotbar { .. } => {} } } @@ -924,4 +1223,151 @@ 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_alias":"player","pos":[1.5,64,2],"rot":[0,90]}"#, + ) + .unwrap(); + + match action { + ActionType::Tp { + entity_alias, + pos, + rot, + } => { + assert_eq!(entity_alias, "player"); + assert_eq!(pos, [1.5, 64.0, 2.0]); + assert_eq!(rot, Some([0.0, 90.0])); + } + _ => panic!("expected tp action"), + } + } + + #[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 = + 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"), + } + } + + #[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],"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]); + let nbt = nbt.expect("expected nbt"); + assert_eq!(nbt.to_snbt(), "{NoGravity:1b}"); + } + _ => panic!("expected summon action"), + } + } + + #[test] + 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_err(); + + assert!(error.to_string().contains("invalid type")); + } + + #[test] + 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"}}"#, + ) + .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_entity_assert_deserializes() { + let check: AssertType = serde_json::from_str( + 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(); + + 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.position_tolerance, Some(0.5)); + assert_eq!(entity.rot, Some([90.0, 0.0])); + assert_eq!(entity.rotation_tolerance, Some(1.0)); + assert_eq!( + entity.nbt.expect("expected nbt").to_snbt(), + "{NoGravity:1b}" + ); + } + _ => 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("invalid type")); + } } diff --git a/src/traits.rs b/src/traits.rs index 802a4fb..cb50833 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -4,7 +4,10 @@ //! to provide the actual block and player operations. use crate::Block; -use crate::test_spec::{BlockFace, GameMode, Item, PlayerSlot}; +use crate::test_spec::{EntityNbt, GameMode, Item, PlayerSlot}; +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::collections::HashMap; /// Position in world coordinates [x, y, z] pub type BlockPos = [i32; 3]; @@ -15,6 +18,15 @@ pub struct ServerInfo { pub minecraft_version: String, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct EntityState { + pub exists: bool, + pub entity_type: Option, + pub pos: Option<[f64; 3]>, + pub rot: Option<[f32; 2]>, + pub nbt: HashMap, +} + // ============================================================================= // Core Traits // ============================================================================= @@ -45,9 +57,27 @@ 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<&EntityNbt>, + ) { + } + + /// 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, _requested_nbt: &[String]) -> EntityState { + EntityState::default() + } + /// 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 +89,8 @@ pub trait FlintWorld: Send + Sync { /// - Select hotbar slots /// - Trigger item use actions pub trait FlintPlayer: Send + Sync { + 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>); @@ -71,10 +103,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);