workshop-rs 0.4.4

Canonical multi-locale Overwatch Workshop semantic core: catalog, parser, WIR, validation, emitter.
Documentation
use workshop_rs::catalog::{Catalog, Kind, Locale};
use workshop_rs::wir::{self, EventTarget, EventTeam, PlayerEventKind};
use workshop_rs::{emitter, parser, roundtrip, validate};

const RULE_EVENTS: &str = r#"
rule ("each player filtered") {
    event {
        Ongoing - Each Player;
        Team 1;
        Slot 3;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("dealt damage") {
    event {
        Player Dealt Damage;
        All;
        Ana;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("dealt final blow") {
    event {
        Player Dealt Final Blow;
        Team 2;
        All;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("dealt healing") {
    event {
        Player Dealt Healing;
        All;
        Slot 0;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("died") {
    event {
        Player Died;
        Team 1;
        All;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("earned elimination") {
    event {
        Player Earned Elimination;
        All;
        Slot 11;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("joined") {
    event {
        Player Joined Match;
        Team 2;
        Ana;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("left") {
    event {
        Player Left Match;
        All;
        All;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("received healing") {
    event {
        Player Received Healing;
        Team 1;
        Slot 4;
    }
    actions {
        Disable Inspector Recording;
    }
}

rule ("took damage") {
    event {
        Player Took Damage;
        All;
        All;
    }
    actions {
        Disable Inspector Recording;
    }
}
"#;

const LEGACY_EACH_PLAYER: &str = r#"
rule ("legacy each player") {
    event {
        Ongoing - Each Player;
    }
    actions {
        Disable Inspector Recording;
    }
}
"#;

const EXPLICIT_EACH_PLAYER: &str = r#"
rule ("legacy each player") {
    event {
        Ongoing - Each Player;
        All;
        All;
    }
    actions {
        Disable Inspector Recording;
    }
}
"#;

fn catalog() -> Catalog {
    Catalog::builtin().expect("built-in catalog")
}

fn en() -> Locale {
    Locale::new("en-US")
}

fn zh() -> Locale {
    Locale::new("zh-CN")
}

#[test]
fn all_player_events_parse_validate_emit_and_round_trip() {
    let catalog = catalog();
    let program = parser::parse_wir_with_context(RULE_EVENTS, &catalog, &en(), &catalog)
        .expect("all canonical player events parse");
    program.validate().expect("event filters validate");
    validate::validate_canonical_ids_wir(&program, &catalog).expect("event catalog ids validate");

    let player_kinds: Vec<_> = program
        .rules
        .iter()
        .filter_map(|rule| match &rule.event {
            wir::Event::Player { kind, .. } => Some(*kind),
            _ => None,
        })
        .collect();
    assert_eq!(
        player_kinds,
        vec![
            PlayerEventKind::DealtDamage,
            PlayerEventKind::DealtFinalBlow,
            PlayerEventKind::DealtHealing,
            PlayerEventKind::Died,
            PlayerEventKind::EarnedElimination,
            PlayerEventKind::Joined,
            PlayerEventKind::Left,
            PlayerEventKind::ReceivedHealing,
            PlayerEventKind::TookDamage,
        ]
    );
    assert!(matches!(
        program.rules.get(wir::RuleId::from_index(0)).unwrap().event,
        wir::Event::EachPlayerWithFilters {
            team: EventTeam::Team1,
            target: EventTarget::Slot(3),
        }
    ));

    let en_text = emitter::emit_wir(&program, &catalog, &en()).expect("en-US event emission");
    let reparsed_en = parser::parse_wir_with_context(&en_text, &catalog, &en(), &catalog)
        .expect("en-US event reparse");
    assert!(roundtrip::equivalent_wir(&program, &reparsed_en));

    let zh_text = emitter::emit_wir(&program, &catalog, &zh()).expect("zh-CN event emission");
    assert!(zh_text.contains("持续 - 每名玩家"));
    assert!(zh_text.contains("玩家造成伤害"));
    assert!(zh_text.contains("队伍1;"));
    assert!(zh_text.contains("栏位 3;"));
    let reparsed_zh = parser::parse_wir_with_context(&zh_text, &catalog, &zh(), &catalog)
        .expect("zh-CN event reparse");
    assert!(roundtrip::equivalent_wir(&program, &reparsed_zh));
}

#[test]
fn legacy_each_player_emits_all_filters_and_round_trips_in_supported_locales() {
    let catalog = catalog();
    let legacy = parser::parse_wir_with_context(LEGACY_EACH_PLAYER, &catalog, &en(), &catalog)
        .expect("legacy parameterless eachPlayer parses");
    assert!(matches!(
        legacy
            .rules
            .get(wir::RuleId::from_index(0))
            .map(|rule| &rule.event),
        Some(wir::Event::EachPlayer)
    ));
    legacy.validate().expect("legacy event validates");
    validate::validate_canonical_ids_wir(&legacy, &catalog)
        .expect("legacy event catalog id validates");

    let explicit = parser::parse_wir_with_context(EXPLICIT_EACH_PLAYER, &catalog, &en(), &catalog)
        .expect("explicit All/All eachPlayer parses");
    assert!(matches!(
        explicit
            .rules
            .get(wir::RuleId::from_index(0))
            .map(|rule| &rule.event),
        Some(wir::Event::EachPlayerWithFilters {
            team: EventTeam::All,
            target: EventTarget::All,
        })
    ));
    assert!(roundtrip::equivalent_wir(&legacy, &explicit));

    let en_text = emitter::emit_wir(&legacy, &catalog, &en()).expect("legacy event emits in en-US");
    assert!(en_text.contains("Ongoing - Each Player;\n        All;\n        All;"));
    let reparsed_en = parser::parse_wir_with_context(&en_text, &catalog, &en(), &catalog)
        .expect("legacy en-US emission reparses");
    assert!(roundtrip::equivalent_wir(&legacy, &reparsed_en));

    let zh_text = emitter::emit_wir(&legacy, &catalog, &zh()).expect("legacy event emits in zh-CN");
    assert!(zh_text.contains("持续 - 每名玩家;\n        双方;\n        全部;"));
    let reparsed_zh = parser::parse_wir_with_context(&zh_text, &catalog, &zh(), &catalog)
        .expect("legacy zh-CN emission reparses");
    assert!(roundtrip::equivalent_wir(&legacy, &reparsed_zh));
}

#[test]
fn event_catalog_declares_parameter_and_filter_provenance_surface() {
    let catalog = catalog();
    for event_id in [
        "eachPlayer",
        "playerDealtDamage",
        "playerDealtFinalBlow",
        "playerDealtHealing",
        "playerDied",
        "playerEarnedElimination",
        "playerJoined",
        "playerLeft",
        "playerReceivedHealing",
        "playerTookDamage",
    ] {
        let event = catalog
            .entry(Kind::Event, event_id)
            .unwrap_or_else(|| panic!("{event_id} catalog entry"));
        assert_eq!(event.params, vec!["Team", "Player"]);
        assert_eq!(
            event.param_domains,
            vec![Some("EventTeam".to_string()), None]
        );
    }
    assert!(catalog.enum_domain("EventPlayer").is_some());
    assert!(catalog.enum_domain("Hero").is_some());
    assert_eq!(
        catalog.enum_spelling("EventTeam", &zh(), "ALL"),
        Some("双方")
    );
    assert_eq!(
        catalog.enum_spelling("EventPlayer", &zh(), "SLOT_3"),
        Some("栏位 3")
    );
}

#[test]
fn invalid_event_filter_is_rejected_and_invalid_slot_fails_wir_validation() {
    let catalog = catalog();
    let invalid_text = RULE_EVENTS.replace("Slot 3;", "Unknown Player;");
    let error = parser::parse_wir_with_context(&invalid_text, &catalog, &en(), &catalog)
        .expect_err("unknown event player filter must fail");
    assert!(error.to_string().contains("unknown event player"));

    let mut program = wir::Program::default();
    program
        .files
        .push(workshop_rs::source::SourceFile::new("events.ws"));
    program.rules.push(wir::Rule {
        name: "invalid slot".into(),
        span: None,
        name_span: None,
        disabled: false,
        event: wir::Event::Player {
            kind: PlayerEventKind::Died,
            team: EventTeam::All,
            target: EventTarget::Slot(12),
        },
        conditions: vec![],
        actions: vec![],
    });
    let error = program
        .validate()
        .expect_err("slot 12 is outside Workshop range");
    assert_eq!(error.code(), "invalid-event-slot");
}

#[test]
fn player_event_without_catalog_filters_is_rejected() {
    let catalog = catalog();
    let text = r#"
rule ("missing filters") {
    event {
        Player Died;
    }
}
"#;
    let error = parser::parse_wir_with_context(text, &catalog, &en(), &catalog)
        .expect_err("player events require both canonical filters");
    assert!(
        error
            .to_string()
            .contains("requires team and player parameters")
    );

    let empty_parameter = r#"
rule ("empty filter") {
    event {
        Player Died;
        ;
    }
}
"#;
    parser::parse_wir_with_context(empty_parameter, &catalog, &en(), &catalog)
        .expect_err("an empty player filter must not be silently discarded");
}

#[test]
fn canonical_validation_checks_rule_event_identities() {
    let catalog = Catalog::load(
        r#"{
            "schemaVersion": 1,
            "locales": ["en-US"],
            "target": {"game": "Overwatch", "format": "Workshop", "surface": "test"},
            "provenance": {"generator": "test", "generatorVersion": "1", "source": "test", "license": "MIT", "reviewed": true},
            "events": [{"id": "global", "aliases": {"en-US": "Ongoing - Global"}}]
        }"#,
    )
    .expect("minimal event catalog");
    let mut program = wir::Program::default();
    program.rules.push(wir::Rule {
        name: "undeclared event".into(),
        span: None,
        name_span: None,
        disabled: false,
        event: wir::Event::Player {
            kind: PlayerEventKind::Died,
            team: EventTeam::All,
            target: EventTarget::All,
        },
        conditions: vec![],
        actions: vec![],
    });
    let error = validate::validate_canonical_ids_wir(&program, &catalog)
        .expect_err("event identities must be catalog-backed");
    assert!(error.to_string().contains("playerDied"), "{error}");
}