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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ jobs:
- run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-scripting external_runtime -- --nocapture
- name: Scene environment scripting across all adapters
run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play environment -- --nocapture
- name: Entity foundations across all adapters
env:
RUSTIC_REQUIRE_ALL_ENTITY_ADAPTERS: '1'
run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play entity_foundations -- --nocapture

installer:
name: Windows installer
Expand Down
211 changes: 211 additions & 0 deletions Engine/crates/engine-play/src/entity_services.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//! Typed requests shared by all language SDKs; no VM-specific entity logic.
use engine_world::{EntityId, SceneWorld};
use serde::Deserialize;
use serde_json::{Value, json};

fn yes() -> bool {
true
}

#[derive(Deserialize)]
#[serde(tag = "op", deny_unknown_fields)]
enum Query {
#[serde(rename = "tags_add")]
TagAdd { entity: EntityId, name: String },
#[serde(rename = "tags_remove")]
TagRemove { entity: EntityId, name: String },
#[serde(rename = "tags_find_all")]
TagFind { name: String },
#[serde(rename = "groups_create")]
GroupCreate { name: String },
#[serde(rename = "groups_add")]
GroupAdd { entity: EntityId, name: String },
#[serde(rename = "groups_get_members")]
GroupMembers { name: String },
#[serde(rename = "hierarchy_set_parent")]
Parent {
entity: EntityId,
parent: Option<EntityId>,
#[serde(default = "yes")]
keep_world: bool,
},
#[serde(rename = "hierarchy_get_children")]
Children { entity: EntityId },
#[serde(rename = "hierarchy_detach")]
Detach {
entity: EntityId,
#[serde(default = "yes")]
keep_world: bool,
},
#[serde(rename = "entity_destroy")]
Destroy { entity: EntityId },
#[serde(rename = "entity_destroy_after")]
DestroyAfter { entity: EntityId, delay: f64 },
#[serde(rename = "entity_is_valid")]
Valid { entity: EntityId },
#[serde(rename = "components_has")]
Has { entity: EntityId, name: String },
#[serde(rename = "components_get_all")]
Components { entity: EntityId },
#[serde(rename = "components_find_in_children")]
FindComponents { entity: EntityId, name: String },
#[serde(rename = "world_find_by_tag")]
WorldTag { name: String },
#[serde(rename = "world_find_in_radius")]
Radius { center: [f32; 3], radius: f64 },
}

pub(crate) fn handles(op: &str) -> bool {
matches!(
op,
"tags_add"
| "tags_remove"
| "tags_find_all"
| "groups_create"
| "groups_add"
| "groups_get_members"
| "hierarchy_set_parent"
| "hierarchy_get_children"
| "hierarchy_detach"
| "entity_destroy"
| "entity_destroy_after"
| "entity_is_valid"
| "components_has"
| "components_get_all"
| "components_find_in_children"
| "world_find_by_tag"
| "world_find_in_radius"
)
}

pub(crate) fn query(world: &mut SceneWorld, value: Value) -> Result<Value, String> {
let query: Query =
serde_json::from_value(value).map_err(|e| format!("invalid entity query: {e}"))?;
evaluate(world, query).map_err(|e| e.to_string())
}

fn evaluate(world: &mut SceneWorld, query: Query) -> Result<Value, engine_world::WorldError> {
match query {
Query::TagAdd { entity, name } => Ok(json!(world.add_tag(entity, &name)?)),
Query::TagRemove { entity, name } => Ok(json!(world.remove_tag(entity, &name)?)),
Query::TagFind { name } | Query::WorldTag { name } => Ok(json!(world.find_by_tag(&name)?)),
Query::GroupCreate { name } => Ok(json!(world.create_group(&name)?)),
Query::GroupAdd { entity, name } => Ok(json!(world.add_to_group(&name, entity)?)),
Query::GroupMembers { name } => Ok(json!(world.group_members(&name)?)),
Query::Parent {
entity,
parent,
keep_world,
} => {
world.reparent(entity, parent, keep_world)?;
Comment thread
JamieW105 marked this conversation as resolved.
Ok(Value::Null)
}
Query::Detach { entity, keep_world } => {
world.reparent(entity, None, keep_world)?;
Ok(Value::Null)
}
Query::Children { entity } => Ok(json!(world.children(entity)?)),
Query::Destroy { entity } => {
world.destroy_entity(entity)?;
Comment thread
JamieW105 marked this conversation as resolved.
Ok(Value::Null)
}
Query::DestroyAfter { entity, delay } => {
world.destroy_after(entity, delay)?;
Ok(Value::Null)
}
Query::Valid { entity } => Ok(json!(world.contains(entity))),
Query::Components { entity } => Ok(json!(world.component_names(entity)?)),
Query::Has { entity, name } => Ok(json!(world.component_names(entity)?.contains(&name))),
Query::FindComponents { entity, name } => {
let mut found = Vec::new();
for id in world.descendants(entity)? {
if world.component_names(id)?.contains(&name) {
found.push(id);
}
}
Ok(json!(found))
}
Query::Radius { center, radius } => Ok(json!(
world.find_in_radius(glam::Vec3::from_array(center), radius)?
)),
}
}

#[cfg(test)]
mod tests {
use super::*;
use engine_world::{EntitySnapshot, WorldCommand};

#[test]
fn typed_requests_reject_invalid_shapes_without_mutation() {
let mut world = SceneWorld::new();
let entity = EntitySnapshot::default();
let id = entity.id;
world
.apply_commands(&[WorldCommand::Spawn(Box::new(entity))])
.unwrap();
for request in [
json!({"op":"tags_add","entity":id,"name":false}),
json!({"op":"tags_add","entity":"bad","name":"enemy"}),
json!({"op":"tags_add","entity":id,"name":"enemy","extra":true}),
json!({"op":"entity_destroy_after","entity":id,"delay":-1}),
json!({"op":"world_find_in_radius","center":[0,0],"radius":1}),
json!({"op":"hierarchy_set_parent","entity":id,"parent":id}),
] {
assert!(query(&mut world, request).is_err());
assert!(world.contains(id));
assert!(world.find_by_tag("enemy").unwrap().is_empty());
}
assert_eq!(
query(
&mut world,
json!({"op":"entity_is_valid","entity":EntityId::new()})
)
.unwrap(),
json!(false)
);
}

#[test]
fn component_queries_search_descendants_and_not_the_root() {
let mut world = SceneWorld::new();
let root = EntitySnapshot::default();
let child = EntitySnapshot {
parent: Some(root.id),
name: Some("child".into()),
..EntitySnapshot::default()
};
let (r, c) = (root.id, child.id);
world
.apply_commands(&[
WorldCommand::Spawn(Box::new(root)),
WorldCommand::Spawn(Box::new(child)),
])
.unwrap();
assert_eq!(
query(
&mut world,
json!({"op":"components_has","entity":c,"name":"rustic.name"})
)
.unwrap(),
json!(true)
);
assert_eq!(
query(
&mut world,
json!({"op":"components_find_in_children","entity":r,"name":"rustic.transform"})
)
.unwrap(),
json!([c])
);
assert_eq!(
query(&mut world, json!({"op":"components_get_all","entity":c})).unwrap(),
json!([
"rustic.name",
"rustic.parent",
"rustic.part_attributes",
"rustic.transform"
])
);
}
}
1 change: 1 addition & 0 deletions Engine/crates/engine-play/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! runtime, tests, and future command-line tooling.

pub mod changes;
mod entity_services;
pub mod frame_ring;
pub mod local_ipc;
mod physics;
Expand Down
138 changes: 138 additions & 0 deletions Engine/crates/engine-play/src/runtime_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,141 @@ pub enum RuntimeServerError {
#[error("runtime script startup/execution failed: {0}")]
Scripts(String),
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{RuntimeValue, SnapshotBuilder, SnapshotInput, connect_authenticated};
use engine_scripting::{ScriptManifest, ScriptManifestEntry};
use engine_world::{EntitySnapshot, LocalTransform, SceneDocument};

#[test]
#[allow(
clippy::too_many_lines,
reason = "exercise the complete authenticated Stop/change-review exchange"
)]
fn stop_acknowledges_after_scripts_destroy_and_reparent_authored_entities() {
let temp = tempfile::tempdir().unwrap();
let script_id = engine_scripting::ScriptId::new();
let old_parent = EntitySnapshot {
name: Some("OldParent".into()),
local_transform: LocalTransform {
translation: glam::Vec3::new(10., 0., 0.),
..LocalTransform::IDENTITY
},
..EntitySnapshot::default()
};
let new_parent = EntitySnapshot {
name: Some("NewParent".into()),
local_transform: LocalTransform {
translation: glam::Vec3::new(20., 0., 0.),
..LocalTransform::IDENTITY
},
..EntitySnapshot::default()
};
let child = EntitySnapshot {
name: Some("Child".into()),
parent: Some(old_parent.id),
local_transform: LocalTransform {
translation: glam::Vec3::X,
..LocalTransform::IDENTITY
},
..EntitySnapshot::default()
};
let deleted = EntitySnapshot {
scripts: vec![engine_world::ScriptComponent::new(script_id)],
name: Some("Deleted".into()),
..EntitySnapshot::default()
};
let moved = EntitySnapshot {
name: Some("Moved".into()),
..EntitySnapshot::default()
};
let moved_id = moved.id;
let mut scene = SceneDocument::new("Review");
scene.entities = vec![old_parent, new_parent, child, deleted, moved];
let scene_bytes = scene.to_bytes().unwrap();
let manifest = ScriptManifest {
format_version: engine_scripting::CURRENT_SCRIPT_MANIFEST_VERSION,
scripts: vec![ScriptManifestEntry {
id: script_id,
language: engine_scripting::ScriptLanguage::Lua54,
relative_path: "scripts/review.lua".into(),
api_version: engine_scripting::ScriptApiVersion::CURRENT,
public_properties: Vec::new(),
}],
};
let snapshot = SnapshotBuilder::new(temp.path().join("play"))
.stage(
PlayMode::Play,
SnapshotInput::new("scene/main.scene", scene_bytes.clone()),
&[
SnapshotInput::new(
"config/scripts.ron",
ron::ser::to_string(&manifest).unwrap().into_bytes(),
),
SnapshotInput::new(
"scripts/review.lua",
br"return {Start=function()
Entity.destroy('Deleted')
Hierarchy.set_parent('OldParent.Child','NewParent')
rustic.edit_object_attribute('Moved','Position',{4,0,0})
end}"
.to_vec(),
),
],
)
.unwrap();
let endpoint = LocalEndpoint::unique(temp.path());
let token = AuthenticationToken::generate();
let config = RuntimeServerConfig {
endpoint: endpoint.clone(),
authentication_token: token.clone(),
snapshot_root: snapshot.root().into(),
logs_directory: temp.path().join("logs"),
mode: PlayMode::Play,
fixed_delta: Duration::from_millis(16),
crash_after_ready: false,
ignore_stop: false,
};
let server = thread::spawn(move || run_runtime_server(config));
let mut client = connect_authenticated(&endpoint, token, Duration::from_secs(5)).unwrap();
client
.set_receive_timeout(Some(Duration::from_secs(5)))
.unwrap();
while !matches!(
client.receive().unwrap().message,
ProtocolMessage::Ready { .. }
) {}
client
.send(42, &ProtocolMessage::Control(ControlRequest::Stop))
.unwrap();
let mut changes = None;
loop {
let received = client.receive().unwrap();
match received.message {
ProtocolMessage::RuntimeChanges(value) => {
assert_eq!(received.request_id, 42);
changes = Some(value);
}
ProtocolMessage::ControlAck(ack) => {
assert_eq!(received.request_id, 42);
assert_eq!(ack.request, ControlRequest::Stop);
assert_eq!(ack.state, RuntimeState::Stopped);
break;
}
ProtocolMessage::Error { message, .. } => panic!("Stop failed: {message}"),
_ => {}
}
}
assert!(server.join().unwrap().is_ok());
let changes = changes.expect("Stop must deliver changes before its acknowledgement");
assert_eq!(changes.changes.len(), 1);
assert_eq!(changes.changes[0].target.entity_id, moved_id.to_string());
assert_eq!(
changes.changes[0].after,
RuntimeValue::Vector3([4., 0., 0.])
);
assert_eq!(snapshot.scene_bytes().unwrap(), scene_bytes);
}
}
Loading
Loading