workshop-rs 0.1.6

Canonical multi-locale Overwatch Workshop semantic core: catalog, parser, WIR, validation, emitter.
Documentation
use workshop_rs::gameplay::{
    Ability, AbilityLookupError, AbilityRef, AbilityVariant, EvidenceRef, Fact, GameplayCatalog,
    GameplayDataError, GameplayDatasetIdentity, Hero, HeroId, LocalizedText, LogicalSlot, Quantity,
    StatKey, StatValue, Unit, hero_ids, slots,
};

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

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

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

fn hero(id: &str, abilities: Vec<Ability>) -> Hero {
    Hero::new(
        HeroId::new(id),
        names(id, &format!("heroes.{id}")),
        abilities,
        vec![evidence(&format!("heroes.{id}"))],
    )
}

fn identity() -> GameplayDatasetIdentity {
    GameplayDatasetIdentity {
        dataset_id: "overwatch-workshop-hero-gameplay".to_string(),
        version: "2026-08-12".to_string(),
        digest: "sha256:test".to_string(),
        source: "workshop-data@d854bf01fc7bbf3b2169f67408c07a8da8989ad6".to_string(),
        license: "MIT-compatible user-provided export".to_string(),
        target: "Overwatch Workshop hero identity and gameplay facts".to_string(),
        reviewed: true,
    }
}

#[test]
fn open_ids_have_typed_constants_without_being_closed_enums() {
    assert_eq!(HeroId::from(hero_ids::ANA).as_str(), "ana");
    assert_eq!(LogicalSlot::from(slots::ABILITY_3).as_str(), "ability3");
    assert_eq!(HeroId::new("future-hero").as_str(), "future-hero");
}

#[test]
fn non_uniform_kits_and_explicit_variant_lookup_are_supported() {
    let brigitte = hero(
        "brigitte",
        vec![
            ability("ability1", None, "Whip Shot"),
            ability("ability3", None, "Shield Bash"),
        ],
    );
    let ramattra = hero(
        "ramattra",
        vec![
            ability("secondaryFire", Some("omnic"), "Void Barrier"),
            ability("secondaryFire", Some("nemesis"), "Void Barrier"),
        ],
    );
    let catalog = GameplayCatalog::new(identity(), vec![ramattra, brigitte]).unwrap();
    assert_eq!(catalog.heroes()[0].id().as_str(), "brigitte");
    assert_eq!(catalog.hero_by_id("ramattra").unwrap().abilities().len(), 2);
    assert!(matches!(
        catalog
            .hero_by_id("ramattra")
            .unwrap()
            .ability(&LogicalSlot::new("secondaryFire")),
        Err(AbilityLookupError::Ambiguous { .. })
    ));
    assert_eq!(
        catalog
            .hero_by_id("ramattra")
            .unwrap()
            .ability_variant(
                &LogicalSlot::from(slots::SECONDARY_FIRE),
                &AbilityVariant::new("nemesis")
            )
            .unwrap()
            .slot(),
        &LogicalSlot::from(slots::SECONDARY_FIRE)
    );
    assert_eq!(
        catalog
            .hero_by_id("brigitte")
            .unwrap()
            .ability(&LogicalSlot::from(slots::ABILITY_3))
            .unwrap()
            .slot()
            .as_str(),
        "ability3"
    );
}

#[test]
fn missing_and_unknown_data_are_explicit() {
    let catalog = GameplayCatalog::new(
        identity(),
        vec![hero("ana", vec![ability("ability1", None, "Sleep Dart")])],
    )
    .unwrap();
    assert!(catalog.hero_by_id("unknown-hero").is_none());
    assert!(
        catalog
            .hero_by_id("ana")
            .unwrap()
            .stat(&StatKey::new("health"))
            .is_none()
    );
    assert!(matches!(
        catalog
            .hero_by_id("ana")
            .unwrap()
            .ability(&LogicalSlot::from(slots::ULTIMATE)),
        Err(AbilityLookupError::Missing { .. })
    ));
}

#[test]
fn facts_carry_evidence_and_quantities_reject_non_finite_values() {
    let stat = Fact::new(
        StatValue::Quantity(Quantity::new(12.0, Unit::new("seconds")).unwrap()),
        vec![evidence("heroes.ana.ability1.cooldown")],
    );
    let sleep = ability("ability1", None, "Sleep Dart")
        .with_keyword("crowd-control")
        .with_stat(StatKey::new("cooldown"), stat);
    let catalog = GameplayCatalog::new(identity(), vec![hero("ana", vec![sleep])]).unwrap();
    let cooldown = catalog
        .hero_by_id("ana")
        .unwrap()
        .ability(&LogicalSlot::from(slots::ABILITY_1))
        .unwrap()
        .stat(&StatKey::new("cooldown"))
        .unwrap();
    assert_eq!(
        cooldown.evidence()[0].locator,
        "heroes.ana.ability1.cooldown"
    );
    assert!(Quantity::new(f64::NAN, Unit::new("seconds")).is_err());
}

#[test]
fn gameplay_serialization_preserves_dataset_identity_and_open_data() {
    let catalog = GameplayCatalog::new(identity(), vec![hero("dva", vec![])]).unwrap();
    let encoded = serde_json::to_string(catalog.identity()).unwrap();
    assert!(encoded.contains("datasetId"));
    assert!(encoded.contains("2026-08-12"));
    assert_eq!(
        catalog.identity().dataset_id,
        "overwatch-workshop-hero-gameplay"
    );
}

#[test]
fn duplicate_slot_variant_is_rejected() {
    let ana = hero(
        "ana",
        vec![
            ability("ability1", Some("default"), "Sleep Dart"),
            ability("ability1", Some("default"), "Sleep Dart"),
        ],
    );
    assert!(GameplayCatalog::new(identity(), vec![ana]).is_err());
}

#[test]
fn multiple_entries_require_variants_and_display_names_are_not_identity() {
    let missing_variant = hero(
        "ramattra",
        vec![
            ability("secondaryFire", None, "Void Barrier"),
            ability("secondaryFire", Some("nemesis"), "Void Barrier"),
        ],
    );
    assert!(matches!(
        GameplayCatalog::new(identity(), vec![missing_variant]),
        Err(GameplayDataError::VariantRequired { .. })
    ));
    let first = ability("ability1", None, "Sleep Dart").reference(&HeroId::new("ana"));
    let renamed = ability("ability1", None, "้บป้†‰้•–").reference(&HeroId::new("ana"));
    assert_eq!(first, renamed);
    let explicit = AbilityRef::new(
        HeroId::new("ana"),
        LogicalSlot::from(slots::ABILITY_1),
        None,
    );
    assert_eq!(explicit, first);
    let encoded = serde_json::to_value(&first).unwrap();
    assert_eq!(encoded["hero"], "ana");
    assert_eq!(encoded["slot"], "ability1");
    assert!(encoded.get("name").is_none());
    assert!(
        serde_json::from_value::<AbilityRef>(serde_json::json!({
            "hero": "ana",
            "slot": "ability1",
            "name": "Sleep Dart"
        }))
        .is_err()
    );
    assert!(
        serde_json::from_value::<AbilityRef>(serde_json::json!({
            "hero": "ana",
            "slot": "ability1",
            "abilityId": "sleepDart"
        }))
        .is_err()
    );
}

#[test]
fn empty_id_and_empty_evidence_are_rejected() {
    let empty_id = Hero::new(
        HeroId::new(""),
        names("Unnamed", "heroes.unnamed"),
        vec![],
        vec![evidence("heroes.unnamed")],
    );
    assert!(GameplayCatalog::new(identity(), vec![empty_id]).is_err());
    let empty_evidence = Hero::new(
        HeroId::new("ana"),
        Fact::new(
            LocalizedText::new([("en-US".to_string(), "Ana".to_string())]),
            vec![EvidenceRef {
                source: String::new(),
                locator: "heroes.ana".to_string(),
                note: None,
            }],
        ),
        vec![],
        vec![evidence("heroes.ana")],
    );
    assert!(GameplayCatalog::new(identity(), vec![empty_evidence]).is_err());
}