Skip to main content

workshop_rs/rules/
content.rs

1//! Structured rule content in the versioned `workshop-rs/rule-content-v1`
2//! format.
3//!
4//! [`rule_content`] renders one [`Rule`] as the canonical call tree defined
5//! by [`RULE_CONTENT_V1_SCHEMA`]. Every value and action is a call node named
6//! by its catalog id; literals, strings, enum members, and declared variable
7//! or subroutine names use the tagged forms the schema defines. Output is
8//! deterministic for a given rule and independent of the source locale.
9//!
10//! Constructs the canonical model specializes but the catalog does not name
11//! use the reserved ids declared in the schema: `globalVariable` and
12//! `playerVariable` for variable reads, `assignMember` and `modifyMember` for
13//! member-assignment actions, and a zero-argument operator call such as
14//! `{"call":"add","args":[]}` for the modify operand.
15
16use serde_json::{Map, Value as JsonValue, json};
17
18use crate::core::error::{Result, WorkshopError};
19use crate::program::{Action, Condition, Event, EventTarget, EventTeam, ModifyOp, Rule, Value};
20use crate::wir::AMBIGUOUS_ENUM_CALL;
21
22/// The format identity every produced document carries in `"format"`.
23pub const RULE_CONTENT_V1: &str = "workshop-rs/rule-content-v1";
24
25/// The published JSON Schema for [`RULE_CONTENT_V1`] documents.
26pub const RULE_CONTENT_V1_SCHEMA: &str = include_str!("../../schemas/rule-content-v1.schema.json");
27
28/// The reserved call id for a global variable read.
29pub const GLOBAL_VARIABLE_CALL: &str = "globalVariable";
30/// The reserved call id for a player variable read.
31pub const PLAYER_VARIABLE_CALL: &str = "playerVariable";
32/// The reserved call id for a plain member assignment (`target = value`).
33pub const ASSIGN_MEMBER_CALL: &str = "assignMember";
34/// The reserved call id for a compound member assignment (`target op= value`).
35pub const MODIFY_MEMBER_CALL: &str = "modifyMember";
36/// The reserved call id for a receiver member read (`receiver.member` or
37/// `receiver.member[index]`). It appears inside [`ASSIGN_MEMBER_CALL`] and
38/// [`MODIFY_MEMBER_CALL`] targets and anywhere a member read is a value.
39pub const MEMBER_ACCESS_CALL: &str = "memberAccess";
40/// The reserved call id for an opaque action the parser preserved without
41/// canonical content (a raw Workshop residual). It carries no arguments.
42pub const RAW_WORKSHOP_ACTION_CALL: &str = "rawWorkshopAction";
43
44impl Rule {
45    /// Render this rule in the [`RULE_CONTENT_V1`] format.
46    ///
47    /// The result is deterministic: it depends only on the rule's canonical
48    /// content, never on source spelling or locale. An internal sentinel
49    /// that has no form in the format, such as an unresolved ambiguous enum,
50    /// fails the whole render rather than leaking into the document.
51    pub fn content(&self) -> Result<JsonValue> {
52        rule_content(self)
53    }
54}
55
56/// Render one rule in the [`RULE_CONTENT_V1`] format; see [`Rule::content`].
57pub fn rule_content(rule: &Rule) -> Result<JsonValue> {
58    let mut document = Map::new();
59    document.insert("format".to_string(), RULE_CONTENT_V1.into());
60    document.insert("event".to_string(), event_node(&rule.event));
61    document.insert("disabled".to_string(), rule.disabled.into());
62    document.insert(
63        "conditions".to_string(),
64        rule.conditions
65            .iter()
66            .map(condition_node)
67            .collect::<Result<_>>()?,
68    );
69    document.insert(
70        "actions".to_string(),
71        rule.actions
72            .iter()
73            .map(|action| action_node(action, false))
74            .collect::<Result<_>>()?,
75    );
76    Ok(JsonValue::Object(document))
77}
78
79fn event_node(event: &Event) -> JsonValue {
80    let mut node = Map::new();
81    node.insert("id".to_string(), event.catalog_id().into());
82    match event {
83        Event::EachPlayerWithFilters { team, target } | Event::Player { team, target, .. } => {
84            node.insert("team".to_string(), team_id(*team).into());
85            node.insert("player".to_string(), target_id(target).into());
86        }
87        Event::Subroutine(name) => {
88            node.insert("subroutine".to_string(), name.clone().into());
89        }
90        Event::Global | Event::EachPlayer => {}
91    }
92    JsonValue::Object(node)
93}
94
95/// The `EventTeam` filter id: the canonical `EventTeam` enum member id.
96fn team_id(team: EventTeam) -> &'static str {
97    match team {
98        EventTeam::All => "ALL",
99        EventTeam::Team1 => "TEAM_1",
100        EventTeam::Team2 => "TEAM_2",
101    }
102}
103
104/// The `EventTarget` filter id: the canonical `EventPlayer` member id for
105/// `ALL`/`SLOT_n`, or the `Hero` member id for a hero target.
106fn target_id(target: &EventTarget) -> String {
107    match target {
108        EventTarget::All => "ALL".to_string(),
109        EventTarget::Slot(slot) => format!("SLOT_{slot}"),
110        EventTarget::Hero(hero) => hero.clone(),
111    }
112}
113
114fn condition_node(condition: &Condition) -> Result<JsonValue> {
115    let mut node = Map::new();
116    node.insert("value".to_string(), value_node(&condition.value)?);
117    if condition.disabled {
118        node.insert("disabled".to_string(), true.into());
119    }
120    Ok(JsonValue::Object(node))
121}
122
123fn action_node(action: &Action, disabled: bool) -> Result<JsonValue> {
124    let (call, args) = match action {
125        Action::SetGlobalVariable { variable, value } => (
126            "setGlobalVariable",
127            vec![variable_node(variable), value_node(value)?],
128        ),
129        Action::ModifyGlobalVariable {
130            variable,
131            op,
132            value,
133        } => (
134            "modifyGlobalVariable",
135            vec![
136                variable_node(variable),
137                modify_op_node(*op),
138                value_node(value)?,
139            ],
140        ),
141        Action::SetPlayerVariable {
142            player,
143            variable,
144            value,
145        } => (
146            "setPlayerVariable",
147            vec![
148                value_node(player)?,
149                variable_node(variable),
150                value_node(value)?,
151            ],
152        ),
153        Action::ModifyPlayerVariable {
154            player,
155            variable,
156            op,
157            value,
158        } => (
159            "modifyPlayerVariable",
160            vec![
161                value_node(player)?,
162                variable_node(variable),
163                modify_op_node(*op),
164                value_node(value)?,
165            ],
166        ),
167        Action::AssignMember { target, op, value } => {
168            let args = match op {
169                None => vec![value_node(target)?, value_node(value)?],
170                Some(op) => vec![value_node(target)?, modify_op_node(*op), value_node(value)?],
171            };
172            (
173                if op.is_some() {
174                    MODIFY_MEMBER_CALL
175                } else {
176                    ASSIGN_MEMBER_CALL
177                },
178                args,
179            )
180        }
181        Action::CallSubroutine { subroutine } => {
182            ("callSubroutine", vec![subroutine_node(subroutine)])
183        }
184        Action::If { condition } => ("if", vec![value_node(condition)?]),
185        Action::ElseIf { condition } => ("elseIf", vec![value_node(condition)?]),
186        Action::Else => ("else", Vec::new()),
187        Action::While { condition } => ("while", vec![value_node(condition)?]),
188        Action::ForGlobalVariable {
189            variable,
190            start,
191            stop,
192            step,
193        } => (
194            "forGlobalVariable",
195            vec![
196                variable_node(variable),
197                value_node(start)?,
198                value_node(stop)?,
199                value_node(step)?,
200            ],
201        ),
202        Action::ForPlayerVariable {
203            player,
204            variable,
205            start,
206            stop,
207            step,
208        } => (
209            "forPlayerVariable",
210            vec![
211                value_node(player)?,
212                variable_node(variable),
213                value_node(start)?,
214                value_node(stop)?,
215                value_node(step)?,
216            ],
217        ),
218        Action::End => ("end", Vec::new()),
219        Action::Disabled { action } => return action_node(action, true),
220        Action::Call { name, args } => (
221            name.as_str(),
222            args.iter().map(value_node).collect::<Result<_>>()?,
223        ),
224    };
225    reject_sentinel(call)?;
226    let mut node = Map::new();
227    node.insert("call".to_string(), call.into());
228    node.insert("args".to_string(), JsonValue::Array(args));
229    if disabled {
230        node.insert("disabled".to_string(), true.into());
231    }
232    Ok(JsonValue::Object(node))
233}
234
235fn value_node(value: &Value) -> Result<JsonValue> {
236    Ok(match value {
237        Value::Number(number) => serde_json::Number::from_f64(*number)
238            .ok_or_else(|| {
239                WorkshopError::unsupported(
240                    format!("rule-content-v1 cannot express non-finite number {number}"),
241                    None,
242                )
243            })?
244            .into(),
245        Value::Bool(value) => (*value).into(),
246        Value::Null => JsonValue::Null,
247        Value::String(text) => json!({ "string": text }),
248        Value::LocalizedString(id) => json!({ "localizedString": id }),
249        Value::Enum { value_type, value } => {
250            json!({ "enum": value_type, "member": value })
251        }
252        Value::GlobalVariable(variable) => {
253            call_node(GLOBAL_VARIABLE_CALL, vec![variable_node(variable)])
254        }
255        Value::PlayerVariable { player, variable } => call_node(
256            PLAYER_VARIABLE_CALL,
257            vec![value_node(player)?, variable_node(variable)],
258        ),
259        Value::Subroutine(name) => subroutine_node(name),
260        Value::EventPlayer => call_node("eventPlayer", Vec::new()),
261        Value::Array(elements) => call_node(
262            "array",
263            elements.iter().map(value_node).collect::<Result<_>>()?,
264        ),
265        Value::Vector { x, y, z } => call_node(
266            "vector",
267            vec![value_node(x)?, value_node(y)?, value_node(z)?],
268        ),
269        Value::Call { name, args } => {
270            reject_sentinel(name)?;
271            call_node(name, args.iter().map(value_node).collect::<Result<_>>()?)
272        }
273    })
274}
275
276fn call_node(call: &str, args: Vec<JsonValue>) -> JsonValue {
277    let mut node = Map::new();
278    node.insert("call".to_string(), call.into());
279    node.insert("args".to_string(), JsonValue::Array(args));
280    JsonValue::Object(node)
281}
282
283fn variable_node(name: &str) -> JsonValue {
284    json!({ "variable": name })
285}
286
287fn subroutine_node(name: &str) -> JsonValue {
288    json!({ "subroutine": name })
289}
290
291/// The modify operand is an operator call node with no arguments, matching
292/// how `ModifyOp` operands and comparison operators appear elsewhere.
293fn modify_op_node(op: ModifyOp) -> JsonValue {
294    call_node(op.catalog_id(), Vec::new())
295}
296
297/// Internal sentinels such as `__ambiguous_enum` have no form in
298/// `rule-content-v1`; reject them instead of leaking them into the document.
299fn reject_sentinel(call: &str) -> Result<()> {
300    if call == AMBIGUOUS_ENUM_CALL {
301        return Err(WorkshopError::unsupported(
302            format!("rule-content-v1 cannot express '{call}'"),
303            None,
304        ));
305    }
306    Ok(())
307}