workshop-rs 0.1.10

Canonical multi-locale Overwatch Workshop semantic core: catalog, parser, WIR, validation, emitter.
Documentation
use workshop_rs::gameplay::{
    Ability, AbilityRef, AbilityVariant, EvidenceRef, Fact, GameplayCatalog,
    GameplayDatasetIdentity, Hero, HeroId, LocalizedText, LogicalSlot, Quantity, StatKey,
    StatValue, Unit, hero_ids, slots, units,
};
use workshop_rs::gameplay_data::builtin;
use workshop_rs::gameplay_query::{
    AbilityNameResolutionError, CooldownError, CooldownNonApplicability, CooldownPercentage,
    CooldownPercentageError, GameplayQueryError, StatOwner,
};

fn evidence(locator: &str) -> EvidenceRef {
    EvidenceRef {
        source: "test-fixture".to_string(),
        locator: locator.to_string(),
        note: None,
    }
}

fn names(en: &str, zh: &str, locator: &str) -> Fact<LocalizedText> {
    Fact::new(
        LocalizedText::new([
            ("en-US".to_string(), en.to_string()),
            ("zh-CN".to_string(), zh.to_string()),
        ]),
        vec![evidence(locator)],
    )
}

fn ability(en: &str, zh: &str, slot: &str, variant: Option<&str>) -> Ability {
    Ability::new(
        LogicalSlot::new(slot),
        variant.map(AbilityVariant::new),
        names(en, zh, &format!("ability.{slot}")),
        vec![evidence(&format!("ability.{slot}"))],
    )
}

fn seconds(value: f64) -> Quantity {
    Quantity::new(value, Unit::from(units::SECONDS)).unwrap()
}

fn identity() -> GameplayDatasetIdentity {
    GameplayDatasetIdentity {
        dataset_id: "gameplay-test".to_string(),
        version: "2026-08-18".to_string(),
        digest: "sha256:test".to_string(),
        source: "test-fixture".to_string(),
        license: "MIT".to_string(),
        target: "gameplay query tests".to_string(),
        reviewed: true,
    }
}

fn catalog() -> GameplayCatalog {
    let sleep = ability("Sleep Dart", "麻醉镖", "ability1", None)
        .with_keyword("crowd-control")
        .with_stat(
            StatKey::new("cooldown"),
            Fact::new(
                StatValue::Quantity(seconds(12.0)),
                vec![evidence("ana.sleep")],
            ),
        );
    let nano = ability("Nano Boost", "纳米激素", "ultimate", None)
        .with_keyword("buff")
        .with_stat(
            StatKey::new("description"),
            Fact::new(
                StatValue::Text("ultimate".to_string()),
                vec![evidence("ana.nano.description")],
            ),
        );
    let ana = Hero::new(
        HeroId::new("ana"),
        names("Ana", "安娜", "heroes.ana"),
        vec![sleep, nano],
        vec![evidence("heroes.ana")],
    );
    let ramattra = Hero::new(
        HeroId::new("ramattra"),
        names("Ramattra", "拉玛刹", "heroes.ramattra"),
        vec![
            ability("Void Barrier", "虚空屏障", "secondaryFire", Some("nemesis"))
                .with_keyword("barrier"),
            ability("Void Barrier", "虚空屏障", "secondaryFire", Some("omnic"))
                .with_keyword("barrier"),
        ],
        vec![evidence("heroes.ramattra")],
    );
    GameplayCatalog::new(identity(), vec![ramattra, ana]).unwrap()
}

#[test]
fn queries_use_open_typed_hero_slot_and_variant_semantics() {
    let catalog = catalog();
    let query = catalog.query();
    assert_eq!(
        query
            .heroes()
            .iter()
            .map(|hero| hero.id().as_str())
            .collect::<Vec<_>>(),
        ["ana", "ramattra"]
    );
    assert_eq!(
        query
            .kit(hero_ids::ANA)
            .unwrap()
            .iter()
            .map(|ability| ability.slot().as_str())
            .collect::<Vec<_>>(),
        ["ability1", "ultimate"]
    );
    assert_eq!(
        query
            .slot(hero_ids::RAMATTRA, slots::SECONDARY_FIRE)
            .unwrap()
            .len(),
        2
    );
    assert_eq!(
        query
            .variant(hero_ids::RAMATTRA, slots::SECONDARY_FIRE, "omnic")
            .unwrap()
            .variant()
            .unwrap()
            .as_str(),
        "omnic"
    );
    assert_eq!(query.keyword("barrier").len(), 2);
    assert_eq!(
        query
            .stat(hero_ids::ANA, slots::ABILITY_1, None, "cooldown")
            .unwrap()
            .value(),
        &StatValue::Quantity(seconds(12.0))
    );
    assert_eq!(
        query
            .quantity_stat(hero_ids::ANA, slots::ABILITY_1, None, "cooldown")
            .unwrap()
            .value,
        12.0
    );
}

#[test]
fn missing_unknown_ambiguous_and_wrong_stat_paths_are_explicit() {
    let catalog = catalog();
    let query = catalog.query();
    assert!(matches!(
        query.hero("missing"),
        Err(GameplayQueryError::MissingHero { .. })
    ));
    assert!(matches!(
        query.slot(hero_ids::ANA, slots::ABILITY_2),
        Err(GameplayQueryError::MissingSlot { .. })
    ));
    assert!(
        matches!(query.ability(hero_ids::RAMATTRA, slots::SECONDARY_FIRE), Err(GameplayQueryError::AmbiguousSlot { candidates, .. }) if candidates.iter().all(|reference| reference.variant().is_some()))
    );
    assert!(matches!(
        query.variant(hero_ids::RAMATTRA, slots::SECONDARY_FIRE, "missing"),
        Err(GameplayQueryError::MissingVariant { .. })
    ));
    assert!(matches!(
        query.stat(hero_ids::ANA, slots::ABILITY_1, None, "damage"),
        Err(GameplayQueryError::MissingStat {
            owner: StatOwner::Ability { .. },
            ..
        })
    ));
    assert!(matches!(
        query.quantity_stat(hero_ids::ANA, slots::ULTIMATE, None, "description"),
        Err(GameplayQueryError::WrongStatType { .. })
    ));
    let unknown = AbilityRef::new(HeroId::new("ana"), LogicalSlot::new("ability2"), None);
    assert!(matches!(
        query.ability_ref(&unknown),
        Err(GameplayQueryError::MissingSlot { .. } | GameplayQueryError::MissingAbility { .. })
    ));
}

#[test]
fn locale_forward_and_inverse_resolution_is_exact_and_preserves_reference() {
    let catalog = catalog();
    let query = catalog.query();
    let sleep = AbilityRef::new(
        HeroId::from(hero_ids::ANA),
        LogicalSlot::from(slots::ABILITY_1),
        None,
    );
    assert_eq!(
        query
            .ability_name(hero_ids::ANA, slots::ABILITY_1, None, "en-US")
            .unwrap(),
        "Sleep Dart"
    );
    assert_eq!(
        query
            .ability_name(hero_ids::ANA, slots::ABILITY_1, None, "zh-CN")
            .unwrap(),
        "麻醉镖"
    );
    assert_eq!(
        query
            .resolve_ability_name(hero_ids::ANA, "zh-CN", "麻醉镖")
            .unwrap(),
        sleep
    );
    assert!(matches!(
        query.ability_name(hero_ids::ANA, slots::ABILITY_1, None, "fr-FR"),
        Err(AbilityNameResolutionError::UnsupportedLocale { .. })
    ));
    assert!(matches!(
        query.resolve_ability_name(hero_ids::ANA, "en-US", "unknown"),
        Err(AbilityNameResolutionError::MissingDisplayName { .. })
    ));
    assert!(matches!(
        query.ability_name(hero_ids::RAMATTRA, slots::SECONDARY_FIRE, None, "en-US"),
        Err(AbilityNameResolutionError::AmbiguousSlot { .. })
    ));
    assert!(matches!(
        query.resolve_ability_name(hero_ids::RAMATTRA, "en-US", "Void Barrier"),
        Err(AbilityNameResolutionError::AmbiguousName { .. })
    ));
}

#[test]
fn cooldown_calculations_are_unit_safe_bounded_and_non_mutating() {
    let catalog = catalog();
    let query = catalog.query();
    let reference = AbilityRef::new(
        HeroId::from(hero_ids::ANA),
        LogicalSlot::from(slots::ABILITY_1),
        None,
    );
    assert_eq!(query.cooldown(&reference).unwrap().value, 12.0);
    assert_eq!(
        query
            .effective_cooldown(&reference, CooldownPercentage::new(50.0).unwrap())
            .unwrap()
            .value,
        6.0
    );
    assert_eq!(
        query
            .effective_cooldown(&reference, CooldownPercentage::new(500.0).unwrap())
            .unwrap()
            .value,
        60.0
    );
    assert_eq!(
        query
            .required_cooldown_percentage(&reference, &seconds(3.0))
            .unwrap()
            .value(),
        25.0
    );
    assert_eq!(query.cooldown(&reference).unwrap().value, 12.0);
}

#[test]
fn cooldown_percentage_and_data_errors_never_default() {
    assert!(matches!(
        CooldownPercentage::new(f64::NAN),
        Err(CooldownPercentageError::NotFinite { .. })
    ));
    assert!(matches!(
        CooldownPercentage::new(-0.1),
        Err(CooldownPercentageError::OutOfRange { .. })
    ));
    assert!(matches!(
        CooldownPercentage::new(500.1),
        Err(CooldownPercentageError::OutOfRange { .. })
    ));
    let missing = Hero::new(
        HeroId::new("missing"),
        names("Missing", "缺失", "heroes.missing"),
        vec![ability("No Cooldown", "无冷却", "ability1", None)],
        vec![evidence("heroes.missing")],
    );
    let wrong_type = ability("Text Cooldown", "文本冷却", "ability1", None).with_stat(
        StatKey::new("cooldown"),
        Fact::new(StatValue::Text("12".to_string()), vec![evidence("text")]),
    );
    let wrong_unit = ability("Meter Cooldown", "米冷却", "ability2", None).with_stat(
        StatKey::new("cooldown"),
        Fact::new(
            StatValue::Quantity(Quantity::new(12.0, Unit::new("meters")).unwrap()),
            vec![evidence("meters")],
        ),
    );
    let zero = ability("Zero Cooldown", "零冷却", "ability3", None).with_stat(
        StatKey::new("cooldown"),
        Fact::new(StatValue::Quantity(seconds(0.0)), vec![evidence("zero")]),
    );
    let edge = Hero::new(
        HeroId::new("edge"),
        names("Edge", "边界", "heroes.edge"),
        vec![wrong_type, wrong_unit, zero],
        vec![evidence("heroes.edge")],
    );
    let catalog = GameplayCatalog::new(identity(), vec![missing, edge]).unwrap();
    let query = catalog.query();
    let no = AbilityRef::new(HeroId::new("missing"), LogicalSlot::new("ability1"), None);
    assert!(matches!(
        query.cooldown(&no),
        Err(CooldownError::Missing { .. })
    ));
    for (slot, reason) in [
        ("ability1", CooldownNonApplicability::WrongValueType),
        (
            "ability2",
            CooldownNonApplicability::WrongUnit {
                actual: Unit::new("meters"),
            },
        ),
        ("ability3", CooldownNonApplicability::NonPositiveBase),
    ] {
        let reference = AbilityRef::new(HeroId::new("edge"), LogicalSlot::new(slot), None);
        assert!(
            matches!(query.cooldown(&reference), Err(CooldownError::NonApplicable { reason: actual, .. }) if actual == reason)
        );
    }
}

#[test]
fn builtin_query_and_locale_resolution_use_real_records_without_fabricated_facts() {
    let catalog = builtin().unwrap();
    let query = catalog.query();
    let ana = query.ability(hero_ids::ANA, slots::ABILITY_1).unwrap();
    assert_eq!(ana.name().value().get("en-US"), Some("Sleep Dart"));
    assert_eq!(
        query
            .ability_name(hero_ids::ANA, slots::ABILITY_1, None, "en-US")
            .unwrap(),
        "Sleep Dart"
    );
    assert_eq!(
        query
            .ability_name(hero_ids::ANA, slots::ABILITY_1, None, "zh-CN")
            .unwrap(),
        "麻醉镖"
    );
    assert_eq!(
        query
            .resolve_ability_name(hero_ids::ANA, "zh-CN", "麻醉镖")
            .unwrap()
            .slot()
            .as_str(),
        "ability1"
    );
    assert!(matches!(
        query.ability(hero_ids::RAMATTRA, slots::PRIMARY_FIRE),
        Err(GameplayQueryError::AmbiguousSlot { .. })
    ));
    let venture = AbilityRef::new(
        HeroId::from(hero_ids::VENTURE),
        LogicalSlot::from(slots::SECONDARY_FIRE),
        None,
    );
    assert!(matches!(
        query.cooldown(&venture),
        Err(CooldownError::Missing { .. })
    ));
}