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
100/// The `EventTeam` filter id: the canonical `EventTeam` enum member id.
101fn team_id(team: EventTeam) -> &'static str {
102    match team {
103        EventTeam::All => "ALL",
104        EventTeam::Team1 => "TEAM_1",
105        EventTeam::Team2 => "TEAM_2",
106    }
107}
108
109/// The `EventTarget` filter id: the canonical `EventPlayer` member id for
110/// `ALL`/`SLOT_n`, or the `Hero` member id for a hero target.
111fn target_id(target: &EventTarget) -> String {
112    match target {
113        EventTarget::All => "ALL".to_string(),
114        EventTarget::Slot(slot) => format!("SLOT_{slot}"),
115        EventTarget::Hero(hero) => hero.clone(),
116    }
117}
118
119fn condition_node(condition: &Condition) -> Result<JsonValue> {
120    let mut node = Map::new();
121    node.insert("value".to_string(), value_node(&condition.value)?);
122    if condition.disabled {
123        node.insert("disabled".to_string(), true.into());
124    }
125    Ok(JsonValue::Object(node))
126}
127
128fn action_node(action: &Action, disabled: bool) -> Result<JsonValue> {
129    let (call, args) = match action {
130        Action::SetGlobalVariable { variable, value } => (
131            "setGlobalVariable",
132            vec![variable_node(variable), value_node(value)?],
133        ),
134        Action::ModifyGlobalVariable {
135            variable,
136            op,
137            value,
138        } => (
139            "modifyGlobalVariable",
140            vec![
141                variable_node(variable),
142                modify_op_node(*op),
143                value_node(value)?,
144            ],
145        ),
146        Action::SetPlayerVariable {
147            player,
148            variable,
149            value,
150        } => (
151            "setPlayerVariable",
152            vec![
153                value_node(player)?,
154                variable_node(variable),
155                value_node(value)?,
156            ],
157        ),
158        Action::ModifyPlayerVariable {
159            player,
160            variable,
161            op,
162            value,
163        } => (
164            "modifyPlayerVariable",
165            vec![
166                value_node(player)?,
167                variable_node(variable),
168                modify_op_node(*op),
169                value_node(value)?,
170            ],
171        ),
172        Action::AssignMember { target, op, value } => {
173            let args = match op {
174                None => vec![value_node(target)?, value_node(value)?],
175                Some(op) => vec![value_node(target)?, modify_op_node(*op), value_node(value)?],
176            };
177            (
178                if op.is_some() {
179                    MODIFY_MEMBER_CALL
180                } else {
181                    ASSIGN_MEMBER_CALL
182                },
183                args,
184            )
185        }
186        Action::CallSubroutine { subroutine } => {
187            ("callSubroutine", vec![subroutine_node(subroutine)])
188        }
189        Action::If { condition } => ("if", vec![value_node(condition)?]),
190        Action::ElseIf { condition } => ("elseIf", vec![value_node(condition)?]),
191        Action::Else => ("else", Vec::new()),
192        Action::While { condition } => ("while", vec![value_node(condition)?]),
193        Action::ForGlobalVariable {
194            variable,
195            start,
196            stop,
197            step,
198        } => (
199            "forGlobalVariable",
200            vec![
201                variable_node(variable),
202                value_node(start)?,
203                value_node(stop)?,
204                value_node(step)?,
205            ],
206        ),
207        Action::ForPlayerVariable {
208            player,
209            variable,
210            start,
211            stop,
212            step,
213        } => (
214            "forPlayerVariable",
215            vec![
216                value_node(player)?,
217                variable_node(variable),
218                value_node(start)?,
219                value_node(stop)?,
220                value_node(step)?,
221            ],
222        ),
223        Action::End => ("end", Vec::new()),
224        Action::Disabled { action } => return action_node(action, true),
225        Action::Call { name, args } => (
226            chase_call_id(name, args),
227            args.iter().map(value_node).collect::<Result<_>>()?,
228        ),
229    };
230    reject_sentinel(call)?;
231    let mut node = Map::new();
232    node.insert("call".to_string(), call.into());
233    node.insert("args".to_string(), JsonValue::Array(args));
234    if disabled {
235        node.insert("disabled".to_string(), true.into());
236    }
237    Ok(JsonValue::Object(node))
238}
239
240fn value_node(value: &Value) -> Result<JsonValue> {
241    Ok(match value {
242        Value::Number(number) => serde_json::Number::from_f64(*number)
243            .ok_or_else(|| {
244                WorkshopError::unsupported(
245                    format!("rule-content-v1 cannot express non-finite number {number}"),
246                    None,
247                )
248            })?
249            .into(),
250        Value::Bool(value) => (*value).into(),
251        Value::Null => JsonValue::Null,
252        Value::String(text) => json!({ "string": text }),
253        Value::LocalizedString(id) => json!({ "localizedString": id }),
254        Value::Enum { value_type, value } => {
255            json!({ "enum": value_type, "member": value })
256        }
257        Value::GlobalVariable(variable) => {
258            call_node(GLOBAL_VARIABLE_CALL, vec![variable_node(variable)])
259        }
260        Value::PlayerVariable { player, variable } => call_node(
261            PLAYER_VARIABLE_CALL,
262            vec![value_node(player)?, variable_node(variable)],
263        ),
264        Value::Subroutine(name) => subroutine_node(name),
265        Value::EventPlayer => call_node("eventPlayer", Vec::new()),
266        Value::Array(elements) => call_node(
267            "array",
268            elements.iter().map(value_node).collect::<Result<_>>()?,
269        ),
270        Value::Vector { x, y, z } => call_node(
271            "vector",
272            vec![value_node(x)?, value_node(y)?, value_node(z)?],
273        ),
274        Value::Call { name, args } if name == AMBIGUOUS_ENUM_CALL => ambiguous_enum_node(args)?,
275        Value::Call { name, args } => {
276            call_node(name, args.iter().map(value_node).collect::<Result<_>>()?)
277        }
278    })
279}
280
281/// A preserved ambiguous enum member renders as the `ambiguous` node: the
282/// authored spelling plus the canonical `{enum, member}` identity of every
283/// candidate. The parser only preserves a member that matched more than one
284/// domain without a signature pin, so no domain is invented here.
285fn ambiguous_enum_node(args: &[Value]) -> Result<JsonValue> {
286    let [Value::String(spelling), Value::Array(candidates)] = args else {
287        return Err(WorkshopError::malformed(
288            "ambiguous enum value expects spelling and candidates",
289            None,
290        ));
291    };
292    if candidates.is_empty() {
293        return Err(WorkshopError::malformed(
294            "ambiguous enum value has no candidates",
295            None,
296        ));
297    }
298    let candidates = candidates
299        .iter()
300        .map(|candidate| match candidate {
301            Value::Enum { value_type, value } => Ok(json!({ "enum": value_type, "member": value })),
302            _ => Err(WorkshopError::malformed(
303                "ambiguous enum candidate must be an enum value",
304                None,
305            )),
306        })
307        .collect::<Result<Vec<JsonValue>>>()?;
308    Ok(json!({ "ambiguous": spelling, "candidates": candidates }))
309}
310
311/// The catalog id a `Call` action is named by. The parser stores a
312/// player-variable chase under the global chase's WIR name; the document
313/// names it by its own catalog id, like the other folded player-variable
314/// actions (`setPlayerVariableAtIndex`, `stopChasingPlayerVariable`). The
315/// folded `playerVariable` target stays the first argument.
316fn chase_call_id<'a>(name: &'a str, args: &[Value]) -> &'a str {
317    match (name, args.first()) {
318        ("chaseAtRate", Some(Value::PlayerVariable { .. })) => "chasePlayerVariableAtRate",
319        ("chaseOverTime", Some(Value::PlayerVariable { .. })) => "chasePlayerVariableOverTime",
320        _ => name,
321    }
322}
323
324fn call_node(call: &str, args: Vec<JsonValue>) -> JsonValue {
325    let mut node = Map::new();
326    node.insert("call".to_string(), call.into());
327    node.insert("args".to_string(), JsonValue::Array(args));
328    JsonValue::Object(node)
329}
330
331fn variable_node(name: &str) -> JsonValue {
332    json!({ "variable": name })
333}
334
335fn subroutine_node(name: &str) -> JsonValue {
336    json!({ "subroutine": name })
337}
338
339/// The modify operand is an operator call node with no arguments, matching
340/// how `ModifyOp` operands and comparison operators appear elsewhere.
341fn modify_op_node(op: ModifyOp) -> JsonValue {
342    call_node(op.catalog_id(), Vec::new())
343}
344
345/// The `__ambiguous_enum` sentinel is a value construct; a call action can
346/// never legitimately carry it. An internal name in the action position has
347/// no form in `rule-content-v1`, so reject it instead of leaking it into the
348/// document.
349fn reject_sentinel(call: &str) -> Result<()> {
350    if call == AMBIGUOUS_ENUM_CALL {
351        return Err(WorkshopError::unsupported(
352            format!("rule-content-v1 cannot express '{call}'"),
353            None,
354        ));
355    }
356    Ok(())
357}