Skip to content

Commit ee1beec

Browse files
refactor: lift rhai scripting into nightshade-api as a typed command binding
Scripts now produce typed Command values directly, deserialized from the rhai value with no in-process json round trip. The runtime moves out of the engine to live next to the Command enum in nightshade-api, the driver owns the ScriptRuntime, and the engine drops its script resource, system, schedule entry, and rhai dependency. Ref::Existing now resolves in O(1) against the engine entity locations and recovers the current generation, replacing a linear entity scan. The event journal stores typed events only, and the editor command log reads labels and entity ids off the typed command buffer instead of scanning detail strings. The console field builder is driven by the command manifest roles rather than json schema heuristics. Docs updated to match the api layer runtime and the typed streams.
1 parent af327cc commit ee1beec

26 files changed

Lines changed: 681 additions & 803 deletions

File tree

apps/editor/Cargo.lock

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/editor/src/components/console.rs

Lines changed: 124 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,6 @@ fn signature(spec: &CommandSpec) -> String {
3434
}
3535
}
3636

37-
/// A command's field types, keyed by field name, for the per-field tooltips.
38-
fn field_types(spec: &CommandSpec) -> HashMap<String, String> {
39-
spec.fields
40-
.iter()
41-
.map(|field| (field.name.to_string(), field.type_name.to_string()))
42-
.collect()
43-
}
44-
4537
/// Every command variant the api exposes, as (name, arguments schema), read
4638
/// from the one source: `command_schema`. The builder lists all of them, so it
4739
/// always matches the `Command` enum instead of a hand-picked subset.
@@ -75,22 +67,10 @@ fn variant_fields(args: &Value) -> Vec<(String, Value)> {
7567
.unwrap_or_default()
7668
}
7769

78-
/// Whether a field's schema is an entity `Ref`: an enum with an `Existing` or
79-
/// `Entity` arm, including the nullable `opt_entity` form. Those fields take a
80-
/// live entity, filled from the selection by id.
81-
fn is_entity_ref(schema: &Value) -> bool {
82-
let arms = schema
83-
.get("oneOf")
84-
.or_else(|| schema.get("anyOf"))
85-
.and_then(Value::as_array);
86-
arms.is_some_and(|variants| {
87-
variants.iter().any(|variant| {
88-
variant
89-
.get("properties")
90-
.and_then(Value::as_object)
91-
.is_some_and(|props| props.contains_key("Existing") || props.contains_key("Entity"))
92-
})
93-
})
70+
/// Whether a field's dispatch role binds a live entity, filled from the
71+
/// selection by id. Read from the manifest, not guessed from the schema shape.
72+
fn is_entity_role(role: &str) -> bool {
73+
role == "entity" || role == "opt_entity"
9474
}
9575

9676
/// The string options of a `string` enum field, for a dropdown.
@@ -125,63 +105,92 @@ fn number_array_len(schema: &Value) -> Option<usize> {
125105
.map(|count| count as usize)
126106
}
127107

128-
/// The default text for a field, so the form starts valid and editable. Entity
129-
/// references prefill with the current selection by id.
130-
fn field_default(schema: &Value, selected: Option<u32>) -> String {
131-
if is_entity_ref(schema) {
132-
return selected.map(|id| id.to_string()).unwrap_or_default();
133-
}
134-
if let Some(options) = enum_options(schema) {
135-
return options.into_iter().next().unwrap_or_default();
136-
}
137-
if let Some(len) = number_array_len(schema) {
138-
return vec!["0"; len].join(", ");
139-
}
140-
match schema.get("type").and_then(Value::as_str) {
141-
Some("integer") | Some("number") => "0".to_string(),
142-
Some("boolean") => "false".to_string(),
143-
Some("string") => String::new(),
144-
_ => String::new(),
145-
}
108+
/// The comma list `raw` parses to, dropping blank entries.
109+
fn parse_numbers(raw: &str) -> Vec<f64> {
110+
raw.split(',')
111+
.filter(|part| !part.trim().is_empty())
112+
.map(|part| part.trim().parse::<f64>().unwrap_or(0.0))
113+
.collect()
146114
}
147115

148-
/// Converts a field's edited text into its json value, by the field's schema.
149-
fn field_to_json(schema: &Value, raw: &str) -> Value {
150-
if is_entity_ref(schema) {
151-
return json!({ "Existing": raw.trim().parse::<u32>().unwrap_or(0) });
152-
}
153-
if enum_options(schema).is_some() {
154-
return json!(raw);
155-
}
156-
if number_array_len(schema).is_some() {
157-
let numbers: Vec<f64> = raw
158-
.split(',')
159-
.filter(|part| !part.trim().is_empty())
160-
.map(|part| part.trim().parse::<f64>().unwrap_or(0.0))
161-
.collect();
162-
return json!(numbers);
116+
/// The default text for a field, so the form starts valid and editable. The
117+
/// dispatch role picks the shape: an entity prefills with the current selection
118+
/// by id, a vec is a comma list, the rest fall to the schema's primitive type.
119+
fn field_default(role: &str, schema: &Value, selected: Option<u32>) -> String {
120+
match role {
121+
"entity" | "opt_entity" => selected.map(|id| id.to_string()).unwrap_or_default(),
122+
"vec3" => vec!["0"; number_array_len(schema).unwrap_or(3)].join(", "),
123+
_ => {
124+
if let Some(options) = enum_options(schema) {
125+
return options.into_iter().next().unwrap_or_default();
126+
}
127+
if let Some(len) = number_array_len(schema) {
128+
return vec!["0"; len].join(", ");
129+
}
130+
match schema.get("type").and_then(Value::as_str) {
131+
Some("integer") | Some("number") => "0".to_string(),
132+
Some("boolean") => "false".to_string(),
133+
_ => String::new(),
134+
}
135+
}
163136
}
164-
match schema.get("type").and_then(Value::as_str) {
165-
Some("integer") => json!(raw.trim().parse::<i64>().unwrap_or(0)),
166-
Some("number") => json!(raw.trim().parse::<f64>().unwrap_or(0.0)),
167-
Some("boolean") => json!(raw == "true"),
168-
Some("string") => json!(raw),
169-
_ => serde_json::from_str::<Value>(raw).unwrap_or(Value::Null),
137+
}
138+
139+
/// Converts a field's edited text into its json value, dispatched by the
140+
/// manifest role first, then by the schema's primitive type for the rest.
141+
fn field_to_json(role: &str, schema: &Value, raw: &str) -> Value {
142+
match role {
143+
"entity" => json!({ "Existing": raw.trim().parse::<u32>().unwrap_or(0) }),
144+
"opt_entity" => {
145+
let trimmed = raw.trim();
146+
if trimmed.is_empty() {
147+
Value::Null
148+
} else {
149+
json!({ "Existing": trimmed.parse::<u32>().unwrap_or(0) })
150+
}
151+
}
152+
"vec3" => json!(parse_numbers(raw)),
153+
_ => {
154+
if enum_options(schema).is_some() {
155+
return json!(raw);
156+
}
157+
if number_array_len(schema).is_some() {
158+
return json!(parse_numbers(raw));
159+
}
160+
match schema.get("type").and_then(Value::as_str) {
161+
Some("integer") => json!(raw.trim().parse::<i64>().unwrap_or(0)),
162+
Some("number") => json!(raw.trim().parse::<f64>().unwrap_or(0.0)),
163+
Some("boolean") => json!(raw == "true"),
164+
Some("string") => json!(raw),
165+
_ => serde_json::from_str::<Value>(raw).unwrap_or(Value::Null),
166+
}
167+
}
170168
}
171169
}
172170

173-
fn defaults_for(args: &Value, selected: Option<u32>) -> HashMap<String, String> {
174-
variant_fields(args)
175-
.into_iter()
176-
.map(|(name, schema)| (name, field_default(&schema, selected)))
171+
fn defaults_for(
172+
spec: &CommandSpec,
173+
schemas: &HashMap<String, Value>,
174+
selected: Option<u32>,
175+
) -> HashMap<String, String> {
176+
spec.fields
177+
.iter()
178+
.map(|field| {
179+
let schema = schemas.get(field.name).cloned().unwrap_or(Value::Null);
180+
(
181+
field.name.to_string(),
182+
field_default(field.role, &schema, selected),
183+
)
184+
})
177185
.collect()
178186
}
179187

180-
/// One labeled input for a field, chosen by the field's schema: a checkbox for
181-
/// a bool, a dropdown for a string enum, a pick-able id for an entity, a comma
182-
/// list for a vec, a number or text box otherwise.
188+
/// One labeled input for a field, chosen by the manifest role first, then the
189+
/// schema: a pick-able id for an entity, a comma list for a vec, then a checkbox
190+
/// for a bool, a dropdown for a string enum, a number or text box otherwise.
183191
fn field_input(
184192
name: String,
193+
role: String,
185194
schema: Value,
186195
type_name: String,
187196
fields: RwSignal<HashMap<String, String>>,
@@ -196,10 +205,10 @@ fn field_input(
196205
});
197206
};
198207

199-
if is_entity_ref(&schema) {
208+
if is_entity_role(&role) {
200209
let key_pick = name.clone();
201-
let hint = if number_array_len(&schema).is_some() {
202-
""
210+
let hint = if role == "opt_entity" {
211+
"entity id (optional)"
203212
} else {
204213
"entity id"
205214
};
@@ -304,44 +313,53 @@ pub fn Console(
304313
catalog.with_value(|c| c.iter().map(|(name, _)| name.clone()).collect());
305314
let first = names.first().cloned().unwrap_or_default();
306315

307-
let args_for = move |name: &str| -> Option<Value> {
316+
let schemas_for = move |name: &str| -> HashMap<String, Value> {
308317
catalog.with_value(|c| {
309318
c.iter()
310319
.find(|(item, _)| item == name)
311-
.map(|(_, args)| args.clone())
320+
.map(|(_, args)| variant_fields(args).into_iter().collect())
321+
.unwrap_or_default()
312322
})
313323
};
324+
let spec_for =
325+
move |name: &str| -> Option<CommandSpec> { docs.with_value(|map| map.get(name).cloned()) };
314326

315327
let selected = RwSignal::new(first.clone());
316328
let fields: RwSignal<HashMap<String, String>> = RwSignal::new(
317-
args_for(&first)
318-
.map(|args| {
329+
spec_for(&first)
330+
.map(|spec| {
319331
defaults_for(
320-
&args,
332+
&spec,
333+
&schemas_for(&first),
321334
state.selected.get_untracked().map(|detail| detail.id),
322335
)
323336
})
324337
.unwrap_or_default(),
325338
);
326339

327340
let choose = move |name: String| {
328-
if let Some(args) = args_for(&name) {
341+
if let Some(spec) = spec_for(&name) {
329342
let selected_id = state.selected.get_untracked().map(|detail| detail.id);
330-
fields.set(defaults_for(&args, selected_id));
343+
fields.set(defaults_for(&spec, &schemas_for(&name), selected_id));
331344
}
332345
selected.set(name);
333346
};
334347

335348
let submit = move |_| {
336349
let name = selected.get();
337-
let Some(args) = args_for(&name) else {
350+
let Some(spec) = spec_for(&name) else {
338351
return;
339352
};
353+
let schemas = schemas_for(&name);
340354
let mut object = serde_json::Map::new();
341355
fields.with(|map| {
342-
for (field, schema) in variant_fields(&args) {
343-
let raw = map.get(&field).cloned().unwrap_or_default();
344-
object.insert(field, field_to_json(&schema, &raw));
356+
for field in &spec.fields {
357+
let raw = map.get(field.name).cloned().unwrap_or_default();
358+
let schema = schemas.get(field.name).cloned().unwrap_or(Value::Null);
359+
object.insert(
360+
field.name.to_string(),
361+
field_to_json(field.role, &schema, &raw),
362+
);
345363
}
346364
});
347365
let command = json!({ name: Value::Object(object) });
@@ -497,18 +515,28 @@ pub fn Console(
497515
<div class="console-fields">
498516
{move || {
499517
let name = selected.get();
500-
let types = docs
501-
.with_value(|map| map.get(&name).map(field_types))
502-
.unwrap_or_default();
503-
match args_for(&name) {
504-
Some(args) => variant_fields(&args)
505-
.into_iter()
506-
.map(|(field, schema)| {
507-
let type_name = types.get(&field).cloned().unwrap_or_default();
508-
field_input(field, schema, type_name, fields, state)
509-
})
510-
.collect_view()
511-
.into_any(),
518+
match spec_for(&name) {
519+
Some(spec) => {
520+
let schemas = schemas_for(&name);
521+
spec.fields
522+
.into_iter()
523+
.map(|field| {
524+
let schema = schemas
525+
.get(field.name)
526+
.cloned()
527+
.unwrap_or(Value::Null);
528+
field_input(
529+
field.name.to_string(),
530+
field.role.to_string(),
531+
schema,
532+
field.type_name.to_string(),
533+
fields,
534+
state,
535+
)
536+
})
537+
.collect_view()
538+
.into_any()
539+
}
512540
None => ().into_any(),
513541
}
514542
}}

apps/editor/worker/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ nightshade = { path = "../../../crates/nightshade", features = [
2121
"physics",
2222
"navmesh",
2323
"navmesh-bake",
24-
"scripting",
2524
] }
2625
nightshade-api = { path = "../../../crates/nightshade-api", default-features = false, features = [
2726
"protocol",

apps/editor/worker/src/ecs.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::systems::input::ShortcutState;
1212
use crate::systems::project_io::ProjectIoState;
1313
use crate::undo::{GizmoDragTracker, UndoHistory};
1414
use nightshade::prelude::freecs;
15+
use nightshade_api::prelude::ScriptRuntime;
1516

1617
freecs::ecs! {
1718
EditorWorld {
@@ -52,6 +53,8 @@ freecs::ecs! {
5253
push_pull: PushPullState,
5354
play: PlayState,
5455
scripting: ScriptingState,
56+
script_runtime: ScriptRuntime,
57+
script_log: ScriptLog,
5558
generation: GenerationSettings,
5659
cloth_sponza: ClothSponzaState,
5760
outbox: Outbox,

apps/editor/worker/src/ecs/resources.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,14 @@ pub struct ScriptingState {
408408
pub restore_show_grid: bool,
409409
}
410410

411+
/// The typed commands participants produced this frame, scripts and the console
412+
/// builder, kept so the sync pass can log them with their real types rather
413+
/// than re-parsing a string.
414+
#[derive(Default)]
415+
pub struct ScriptLog {
416+
pub commands: Vec<nightshade_api::prelude::Command>,
417+
}
418+
411419
pub struct GenerationSettings {
412420
pub footprint_x: f32,
413421
pub footprint_z: f32,

0 commit comments

Comments
 (0)