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