Skip to content

Commit bf86f9a

Browse files
committed
Port trigger and variable commands to native
1 parent 2018848 commit bf86f9a

33 files changed

Lines changed: 1072 additions & 28 deletions

docs/porting/01-slices.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ spread across the slices they belong to, not deferred as a catch-all.
3838
| 6 | [Areas](#6-areas) | done (stable) |
3939
| 7 | [Teams & diplomacy](#7-teams--diplomacy) | done (stable) |
4040
| 8 | [Project lifecycle](#8-project-lifecycle) — save / load / export / sync / start / stop + scenario-info | review (scenario info in stable) |
41-
| 9 | [Triggers + Variables](#9-triggers--variables) — depends on areas, teams | wip (not in stable) |
41+
| 9 | [Triggers + Variables](#9-triggers--variables) — depends on areas, teams | done (stable) |
4242

4343
Notes from review:
4444
- **Map settings** (sun/lighting/atmosphere/water/map-rendering) are general map
@@ -316,6 +316,13 @@ project-scoped values triggers reference. Trigger conditions/actions are
316316
polymorphic nested data — the heaviest serialization shape in the codebase, but
317317
pure project logic (no engine API).
318318

319+
**Status:** done (stable) — add / remove / update for variables and triggers
320+
are Rust-only, backed by native managers. Native mirrors trigger/variable state
321+
into LuaRules because the existing Lua `RuntimeModel` still executes trigger
322+
actions. Integration tests cover LuaRules and LuaUI bridge delivery independently,
323+
plus both runtime execute paths: `ExecuteTriggerCommand` and
324+
`ExecuteTriggerActionsCommand`.
325+
319326
**Model:**
320327
- [scen_edit/model/variable_manager.lua](../../scen_edit/model/variable_manager.lua)
321328
- [scen_edit/model/trigger_manager.lua](../../scen_edit/model/trigger_manager.lua)

native/src/sbc/lua_bridge.rs

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
//! Native → Lua message channel.
22
//!
3-
//! The native plugin pushes messages to the widget side via
4-
//! `Spring.SendLuaUIMsg`, framed `springboard|native|<json>`. The widget's
5-
//! `RecieveGadgetMessage` (`op == "native"`) decodes the JSON and routes by
6-
//! `tag`:
3+
//! The native plugin pushes messages to Lua as
4+
//! `springboard|native|<json>`. LuaUI receives messages via
5+
//! `Spring.SendLuaUIMsg`; LuaRules receives messages via `SendLuaRulesMsg`.
6+
//! Both sides decode JSON and route by `tag`:
77
//!
8-
//! - `tag: "command"` → reconstruct + execute a `Widget*Command` (used by the
9-
//! command-system widget-notify, e.g. `WidgetCommandExecuted`, and by model
10-
//! mirror updates).
8+
//! - `tag: "command"` -> reconstruct + execute a `Widget*Command` mirror update.
9+
//! - `tag: "notify"` -> fire a model-manager listener on LuaUI.
1110
//!
1211
//! Everything goes through one channel + one widget router, so adding a new
1312
//! notification is just another `{tag, ...}` shape — no per-manager plumbing.
@@ -24,6 +23,12 @@ pub fn send(interface: &NativeInterfaceRef, body: serde_json::Value) {
2423
let _ = interface.messages().send_lua_uimsg(&payload, "");
2524
}
2625

26+
/// Send a JSON envelope to the synced LuaRules side.
27+
pub fn send_rules(interface: &NativeInterfaceRef, body: serde_json::Value) {
28+
let payload = format!("{MESSAGE_PREFIX}|native|{body}");
29+
let _ = interface.messages().send_lua_rules_msg(&payload);
30+
}
31+
2732
/// Send a widget-side command over the native message channel.
2833
pub fn widget_command(interface: &NativeInterfaceRef, data: serde_json::Value) {
2934
send(
@@ -34,3 +39,15 @@ pub fn widget_command(interface: &NativeInterfaceRef, data: serde_json::Value) {
3439
}),
3540
);
3641
}
42+
43+
/// Send a command to LuaRules. This is used when native owns project data that
44+
/// Lua runtime code still consumes from the synced Lua model.
45+
pub fn rules_command(interface: &NativeInterfaceRef, data: serde_json::Value) {
46+
send_rules(
47+
interface,
48+
serde_json::json!({
49+
"tag": "command",
50+
"data": data,
51+
}),
52+
);
53+
}

native/src/sbc/lua_bridge_tests.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
use spring_native::RulesParamValue;
2+
use std::collections::HashMap;
3+
use std::sync::{Mutex, OnceLock};
4+
5+
use crate::sbc::lua_bridge::MESSAGE_PREFIX;
6+
use crate::sbc::message_handler::MessageHandler;
7+
use crate::sbc::sbc::SBC;
8+
use crate::sbc::tests::tests_api::TestCtx;
9+
10+
inventory::submit! {
11+
MessageHandler {
12+
tag: "bridge_test_ack",
13+
handler: bridge_test_ack,
14+
}
15+
}
16+
17+
fn acks() -> &'static Mutex<HashMap<String, f32>> {
18+
static ACKS: OnceLock<Mutex<HashMap<String, f32>>> = OnceLock::new();
19+
ACKS.get_or_init(|| Mutex::new(HashMap::new()))
20+
}
21+
22+
fn bridge_test_ack(_sbc: &mut SBC, data: serde_json::Value) {
23+
let Some(name) = data.get("name").and_then(|v| v.as_str()) else {
24+
return;
25+
};
26+
let Some(value) = data.get("value").and_then(|v| v.as_f64()) else {
27+
return;
28+
};
29+
if let Ok(mut acks) = acks().lock() {
30+
acks.insert(name.to_string(), value as f32);
31+
}
32+
}
33+
34+
fn bridge_payload(name: &str, value: f32) -> String {
35+
let msg = serde_json::json!({
36+
"tag": "bridge_test",
37+
"data": {
38+
"name": name,
39+
"value": value,
40+
},
41+
});
42+
format!("{MESSAGE_PREFIX}|native|{msg}")
43+
}
44+
45+
fn read_game_rules_param(ctx: &mut TestCtx, name: &str) -> Result<f32, String> {
46+
let (value, _, exists) = ctx
47+
.sbc
48+
.interface()
49+
.rules_params()
50+
.get_game_rules_param(name)
51+
.map_err(|err| format!("GetGameRulesParam({name}) failed: {err:?}"))?;
52+
if !exists {
53+
return Err(format!("{name} was not set by the bridge"));
54+
}
55+
let RulesParamValue::Float(value) = value else {
56+
return Err(format!("{name} came back as {value:?}, expected float"));
57+
};
58+
Ok(value)
59+
}
60+
61+
fn assert_bridge_value(ctx: &mut TestCtx, name: &str, expected: f32) -> Result<(), String> {
62+
let actual = read_game_rules_param(ctx, name)?;
63+
if (actual - expected).abs() > 0.01 {
64+
return Err(format!("{name} = {actual}, expected {expected}"));
65+
}
66+
Ok(())
67+
}
68+
69+
fn assert_luaui_ack(name: &str, expected: f32) -> Result<(), String> {
70+
let actual = acks()
71+
.lock()
72+
.map_err(|err| format!("bridge ack lock poisoned: {err}"))?
73+
.remove(name)
74+
.ok_or_else(|| format!("{name} was not acknowledged by LuaUI"))?;
75+
if (actual - expected).abs() > 0.01 {
76+
return Err(format!("{name} ack = {actual}, expected {expected}"));
77+
}
78+
Ok(())
79+
}
80+
81+
fn lua_rules_bridge(ctx: &mut TestCtx) -> Result<(), String> {
82+
let name = "sbc_luarules_bridge_test";
83+
let expected = 321.0;
84+
let payload = bridge_payload(name, expected);
85+
match ctx.sbc.interface().messages().send_lua_rules_msg(&payload) {
86+
Ok(true) => assert_bridge_value(ctx, name, expected),
87+
Ok(false) => Err(format!("LuaRules rejected payload: {payload}")),
88+
Err(err) => Err(format!("SendLuaRulesMsg failed: {err:?}")),
89+
}
90+
}
91+
92+
fn luaui_bridge(ctx: &mut TestCtx) -> Result<(), String> {
93+
let name = "sbc_luaui_bridge_test";
94+
let expected = 654.0;
95+
let payload = bridge_payload(name, expected);
96+
match ctx.sbc.interface().messages().send_lua_uimsg(&payload, "") {
97+
Ok(true) => assert_luaui_ack(name, expected),
98+
Ok(false) => Err(format!("LuaUI rejected payload: {payload}")),
99+
Err(err) => Err(format!("SendLuaUIMsg failed: {err:?}")),
100+
}
101+
}
102+
103+
crate::integration_test!("lua_rules_bridge", lua_rules_bridge);
104+
crate::integration_test!("luaui_bridge", luaui_bridge);

native/src/sbc/message_handler.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//! Registry for non-command Lua → native messages, keyed by the envelope `tag`.
2+
//! Each feature self-registers its handlers via `inventory::submit!`, so
3+
//! [`SBC::route`](crate::sbc::sbc::SBC) stays feature-agnostic: it knows only
4+
//! `"command"` and otherwise asks this registry.
5+
6+
use crate::sbc::sbc::SBC;
7+
8+
/// One non-command message handler, collected by `inventory` and keyed by the
9+
/// envelope tag it answers (e.g. `"save_model"`).
10+
pub struct MessageHandler {
11+
pub tag: &'static str,
12+
pub handler: fn(&mut SBC, serde_json::Value),
13+
}
14+
inventory::collect!(MessageHandler);
15+
16+
/// Dispatch a tagged message to its registered handler. Returns `false` if no
17+
/// feature registered a handler for `tag`.
18+
pub fn dispatch(sbc: &mut SBC, tag: &str, data: serde_json::Value) -> bool {
19+
for handler in inventory::iter::<MessageHandler> {
20+
if handler.tag == tag {
21+
(handler.handler)(sbc, data);
22+
return true;
23+
}
24+
}
25+
false
26+
}

native/src/sbc/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ mod objects;
77
mod project;
88
mod teams;
99
mod textures;
10+
mod triggers;
11+
mod variables;
1012

1113
mod hashable_float;
1214
mod io;
1315
mod log;
1416
mod lua_bridge;
17+
mod lua_bridge_tests;
18+
mod message_handler;
1519
pub mod sbc;
1620
mod tests;

native/src/sbc/sbc.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ impl SBC {
9292

9393
match envelope.tag.as_str() {
9494
"command" => self.run_command(envelope.data),
95-
other => error!("Not a command (tag: {other})"),
95+
other => {
96+
if !crate::sbc::message_handler::dispatch(self, other, envelope.data) {
97+
error!("Not a command (tag: {other})");
98+
}
99+
}
96100
}
97101
}
98102

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use serde::Deserialize;
2+
3+
use crate::sbc::command_system::command::Command;
4+
use crate::sbc::command_system::context::Context;
5+
use crate::sbc::command_system::registry::register_command;
6+
use crate::sbc::triggers::TriggerManager;
7+
8+
/// Adds a trigger. 1:1 with `scen_edit/command/add_trigger_command.lua`:
9+
/// execute adds (recording the id), unexecute removes.
10+
#[derive(Deserialize, Debug)]
11+
pub struct AddTriggerCommand {
12+
trigger: serde_json::Value,
13+
#[serde(skip)]
14+
id: Option<i32>,
15+
}
16+
17+
impl Command for AddTriggerCommand {
18+
fn execute(&mut self, ctx: &mut Context) {
19+
// On redo, reuse the id assigned the first time so the trigger returns
20+
// with the same id (we clone the data, so carry the id forward).
21+
let mut trigger = self.trigger.clone();
22+
if let (Some(id), Some(obj)) = (self.id, trigger.as_object_mut()) {
23+
obj.insert("id".to_string(), serde_json::json!(id));
24+
}
25+
let id = ctx.model::<TriggerManager>().add_trigger(trigger);
26+
self.id = Some(id);
27+
}
28+
29+
fn unexecute(&mut self, ctx: &mut Context) {
30+
if let Some(id) = self.id {
31+
ctx.model::<TriggerManager>().remove_trigger(id);
32+
}
33+
}
34+
}
35+
36+
register_command!(AddTriggerCommand, "AddTriggerCommand");
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mod add_trigger_command;
2+
mod remove_trigger_command;
3+
mod update_trigger_command;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use serde::Deserialize;
2+
3+
use crate::sbc::command_system::command::Command;
4+
use crate::sbc::command_system::context::Context;
5+
use crate::sbc::command_system::registry::register_command;
6+
use crate::sbc::triggers::TriggerManager;
7+
8+
/// Removes a trigger. 1:1 with `scen_edit/command/remove_trigger_command.lua`:
9+
/// execute saves the trigger then removes it; unexecute re-adds the saved
10+
/// trigger (which carries its id, so it returns unchanged).
11+
#[derive(Deserialize, Debug)]
12+
pub struct RemoveTriggerCommand {
13+
#[serde(rename = "triggerID")]
14+
trigger_id: i32,
15+
#[serde(skip)]
16+
saved: Option<serde_json::Value>,
17+
}
18+
19+
impl Command for RemoveTriggerCommand {
20+
fn execute(&mut self, ctx: &mut Context) {
21+
self.saved = ctx
22+
.model::<TriggerManager>()
23+
.get_trigger(self.trigger_id)
24+
.cloned();
25+
ctx.model::<TriggerManager>()
26+
.remove_trigger(self.trigger_id);
27+
}
28+
29+
fn unexecute(&mut self, ctx: &mut Context) {
30+
if let Some(t) = self.saved.clone() {
31+
ctx.model::<TriggerManager>().add_trigger(t);
32+
}
33+
}
34+
}
35+
36+
register_command!(RemoveTriggerCommand, "RemoveTriggerCommand");
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use serde::Deserialize;
2+
3+
use crate::sbc::command_system::command::Command;
4+
use crate::sbc::command_system::context::Context;
5+
use crate::sbc::command_system::registry::register_command;
6+
use crate::sbc::triggers::TriggerManager;
7+
8+
/// Updates a trigger in place. 1:1 with
9+
/// `scen_edit/command/update_trigger_command.lua`: execute saves the old trigger
10+
/// then sets the new one (keyed by `trigger.id`); unexecute restores the old.
11+
#[derive(Deserialize, Debug)]
12+
pub struct UpdateTriggerCommand {
13+
trigger: serde_json::Value,
14+
#[serde(skip)]
15+
old: Option<serde_json::Value>,
16+
}
17+
18+
impl UpdateTriggerCommand {
19+
fn trigger_id(&self) -> Option<i32> {
20+
self.trigger
21+
.get("id")
22+
.and_then(|v| v.as_i64())
23+
.map(|v| v as i32)
24+
}
25+
}
26+
27+
impl Command for UpdateTriggerCommand {
28+
fn execute(&mut self, ctx: &mut Context) {
29+
let Some(id) = self.trigger_id() else {
30+
return;
31+
};
32+
// Snapshot once: redo re-runs this same instance, so re-capturing here
33+
// would store the already-updated value.
34+
if self.old.is_none() {
35+
self.old = ctx.model::<TriggerManager>().get_trigger(id).cloned();
36+
}
37+
ctx.model::<TriggerManager>()
38+
.set_trigger(id, self.trigger.clone());
39+
}
40+
41+
fn unexecute(&mut self, ctx: &mut Context) {
42+
let Some(id) = self.trigger_id() else {
43+
return;
44+
};
45+
if let Some(old) = self.old.clone() {
46+
ctx.model::<TriggerManager>().set_trigger(id, old);
47+
}
48+
}
49+
}
50+
51+
register_command!(UpdateTriggerCommand, "UpdateTriggerCommand");

0 commit comments

Comments
 (0)