diff --git a/crates/opy-rs/src/compiler/data/hero_applicability.json b/crates/opy-rs/src/compiler/data/hero_applicability.json new file mode 100644 index 00000000..2b13cd9d --- /dev/null +++ b/crates/opy-rs/src/compiler/data/hero_applicability.json @@ -0,0 +1,479 @@ +{ + "schemaVersion": 1, + "pinned": "overpy@9.7.10", + "derivation": "heroes.values merge (__generalAndEachHero__ + filtered __eachHero__ + per-hero values) computed by the pinned compiler's own post-load task", + "generalKeys": [ + "abilityCooldown%", + "ammoClipSize%", + "combatUltGen%", + "damageDealt%", + "damageReceived%", + "enableHeadshotsOnly", + "enableInfiniteAmmo", + "enableInfiniteUlt", + "enableMelee", + "enablePrimaryFire", + "enableRolePassive", + "enableSpawningWithUlt", + "enableUlt", + "healingDealt%", + "healingReceived%", + "health%", + "jumpVerticalSpeed%", + "movementGravity%", + "movementSpeed%", + "passiveHealthRegen", + "passiveUltGen%", + "projectileGravity%", + "projectileSpeed%", + "ultDuration%", + "ultGen%" + ], + "all": [ + "combatUltGen%", + "damageDealt%", + "damageReceived%", + "enableAbility1", + "enableHeadshotsOnly", + "enableMelee", + "enablePrimaryFire", + "enableRolePassive", + "enableSpawningWithUlt", + "enableUlt", + "healingDealt%", + "healingReceived%", + "health%", + "jumpVerticalSpeed%", + "movementGravity%", + "movementSpeed%", + "passiveHealthRegen", + "passiveUltGen%", + "ultGen%" + ], + "only": { + "ability1Acceleration%": [ + "pharah", + "winston" + ], + "ability1ChargeRate%": [ + "doomfist" + ], + "ability1Distance%": [ + "freja", + "kiriko", + "shion", + "hazard", + "vendetta" + ], + "ability1Duration%": [ + "emre", + "mizuki", + "venture", + "juno", + "wuyang" + ], + "ability1EnemyKb%": [ + "ashe" + ], + "ability1Health%": [ + "lifeweaver" + ], + "ability1Heat%": [ + "emre" + ], + "ability1Kb%": [ + "brigitte", + "domina", + "dva", + "wreckingBall", + "junkrat", + "mauga", + "reinhardt", + "winston" + ], + "ability1RefuelScalar": [ + "pharah" + ], + "ability1SelfKb%": [ + "ashe" + ], + "ability2Distance%": [ + "vendetta" + ], + "ability2Duration%": [ + "shion", + "torbjorn" + ], + "ability2FuseTime%": [ + "ashe" + ], + "ability2Healing%": [ + "mauga" + ], + "ability2Health%": [ + "hazard" + ], + "ability2Height%": [ + "freja" + ], + "ability2Kb%": [ + "emre", + "pharah", + "shion", + "sierra", + "sigma", + "hazard", + "wuyang" + ], + "ability2MaxDamage%": [ + "moira" + ], + "ability2MaxHealing%": [ + "moira" + ], + "ability2Quantity%": [ + "hanzo" + ], + "ability2Speed%": [ + "shion" + ], + "ability3Cooldown%": [ + "wreckingBall", + "hanzo" + ], + "ability3Distance%": [ + "hanzo" + ], + "ammoRegenerationTime%": [ + "doomfist" + ], + "callMechKb%": [ + "dva" + ], + "enableAbility3": [ + "brigitte", + "wreckingBall", + "hanzo" + ], + "enableAutomaticFire": [ + "ana", + "ashe", + "widowmaker" + ], + "enableInfiniteUlt": [ + "ashe", + "baptiste", + "bastion", + "doomfist", + "emre", + "genji", + "wreckingBall", + "junkrat", + "moira", + "shion", + "soldier", + "torbjorn", + "widowmaker", + "winston", + "ramattra", + "venture" + ], + "enablePassive": [ + "echo", + "mercy", + "pharah", + "juno" + ], + "enablePassiveUnlimitedFuel": [ + "jetpackCat", + "pharah" + ], + "enablePrimaryFireFreezeStack": [ + "mei" + ], + "enableRollOnly": [ + "wreckingBall" + ], + "enableScoping": [ + "ana", + "ashe", + "widowmaker" + ], + "passiveExtraFuel%": [ + "pharah" + ], + "passiveHeal%": [ + "domina" + ], + "passiveMaxTime%": [ + "pharah" + ], + "passiveMaximumTime%": [ + "pharah" + ], + "passiveRechargeRate%": [ + "pharah" + ], + "passiveVerticalSpeed%": [ + "pharah" + ], + "primaryFireFreezeDuration%": [ + "mei" + ], + "primaryFireFreezeMinimum%": [ + "mei" + ], + "primaryFireFreezeRate%": [ + "mei" + ], + "primaryFireIgniteDamage": [ + "mauga" + ], + "primaryFireIgniteDuration": [ + "mauga" + ], + "primaryFireIgniteRate": [ + "mauga" + ], + "primaryFireKb%": [ + "junkrat", + "pharah", + "reinhardt" + ], + "primaryFireMaximumTime%": [ + "moira" + ], + "primaryFireOrbTurnRate%": [ + "wuyang" + ], + "primaryFireRange%": [ + "jetpackCat", + "lifeweaver" + ], + "primaryFireRechargeRate%": [ + "moira" + ], + "secondaryFireAlternateForm": [ + "ramattra" + ], + "secondaryFireCooldown%": [ + "bastion", + "brigitte", + "doomfist", + "domina", + "echo", + "freja", + "wreckingBall", + "junkerQueen", + "lucio", + "mizuki", + "orisa", + "pharah", + "reinhardt", + "roadhog", + "shion", + "sierra", + "sigma", + "soldier", + "sombra", + "ramattra", + "lifeweaver", + "venture", + "juno" + ], + "secondaryFireCost%": [ + "hazard", + "wuyang" + ], + "secondaryFireDuration%": [ + "freja" + ], + "secondaryFireEnergyChargeRate%": [ + "sojourn" + ], + "secondaryFireHealth%": [ + "lifeweaver" + ], + "secondaryFireKb%": [ + "bastion", + "doomfist", + "junkerQueen", + "lucio", + "soldier", + "zarya" + ], + "secondaryFireMaximumTime%": [ + "dva", + "jetpackCat", + "roadhog" + ], + "secondaryFireMovementSpeedPenalty%": [ + "hazard" + ], + "secondaryFireRecallDelay%": [ + "junkerQueen" + ], + "secondaryFireRechargeRate%": [ + "brigitte", + "dva", + "jetpackCat", + "reinhardt", + "roadhog", + "sigma", + "wuyang", + "vendetta" + ], + "secondaryFireRegen%": [ + "hazard" + ], + "selfDestructKb%": [ + "dva" + ], + "shieldBashCooldown%": [ + "brigitte" + ], + "shieldBashKb%": [ + "brigitte" + ], + "solarEnergyMax%": [ + "illari" + ], + "solarEnergyRecharge%": [ + "illari" + ], + "spawnWithoutMech": [ + "dva" + ], + "ultBarrierHealth%": [ + "domina" + ], + "ultDuration%": [ + "ashe", + "baptiste", + "bastion", + "doomfist", + "emre", + "genji", + "wreckingBall", + "junkrat", + "mauga", + "moira", + "soldier", + "torbjorn", + "widowmaker", + "winston", + "ramattra", + "venture" + ], + "ultFreezeMinimum%": [ + "mei" + ], + "ultFreezeRate%": [ + "mei" + ], + "ultHealth%": [ + "lifeweaver" + ], + "ultKb%": [ + "doomfist", + "emre", + "wreckingBall", + "roadhog", + "winston" + ], + "weaponsEnabled": [ + "mercy", + "torbjorn", + "lifeweaver" + ] + }, + "except": { + "ability1Cooldown%": [ + "wreckingBall", + "lucio", + "soldier", + "zenyatta" + ], + "ability2Cooldown%": [ + "bastion", + "zenyatta", + "venture" + ], + "ammoClipSize%": [ + "brigitte", + "dva", + "freja", + "hanzo", + "kiriko", + "moira", + "reinhardt", + "sigma", + "hazard" + ], + "enableAbility2": [ + "bastion", + "venture" + ], + "enableInfiniteAmmo": [ + "brigitte", + "dva", + "freja", + "hanzo", + "kiriko", + "moira", + "reinhardt", + "sigma", + "sojourn", + "hazard" + ], + "enableSecondaryFire": [ + "ana", + "ashe", + "dmon", + "domina", + "emre", + "hanzo", + "jetpackCat", + "junkrat", + "mizuki", + "reaper", + "shion", + "tracer", + "widowmaker", + "wuyang", + "vendetta" + ], + "projectileGravity%": [ + "ana", + "bastion", + "brigitte", + "cassidy", + "doomfist", + "dva", + "echo", + "freja", + "genji", + "kiriko", + "lucio", + "mercy", + "moira", + "pharah", + "reaper", + "reinhardt", + "roadhog", + "sojourn", + "soldier", + "symmetra", + "winston", + "zenyatta", + "venture", + "juno", + "hazard" + ], + "projectileSpeed%": [ + "brigitte", + "reaper", + "winston" + ] + } +} diff --git a/crates/opy-rs/src/compiler/settings.rs b/crates/opy-rs/src/compiler/settings.rs index 6c3eb06d..21bc4e2a 100644 --- a/crates/opy-rs/src/compiler/settings.rs +++ b/crates/opy-rs/src/compiler/settings.rs @@ -27,6 +27,7 @@ pub(crate) fn workshop_settings( let unknown = settings .as_mut() .map(pass_through_unknown_members) + .transpose()? .unwrap_or_default(); Ok((settings, unknown)) } @@ -40,6 +41,73 @@ pub(crate) struct UnknownSetting { pub(crate) span: Option, } +/// Whether the pinned OverPy schema applies a hero settings key to a +/// specific hero. Its post-load schema merge expands +/// `heroes.values.__generalAndEachHero__` plus the `__eachHero__` keys whose +/// `include`/`exclude` hero filters admit the hero into that hero's `values` +/// set; `compileCustomGameSettingsDict` then looks authored keys up in the +/// merged set and writes the ones it cannot find back verbatim. The +/// `data/hero_applicability.json` artifact records each merged key's +/// applying heroes in the smaller of include/exclude form and is generated +/// from the pinned compiler's own merge by +/// `tools/overpy/gen_hero_applicability.cjs`. +fn hero_setting_applies(hero: &str, key: &str) -> bool { + let data = applicability(); + if data.all.iter().any(|candidate| candidate == key) { + return true; + } + if let Some(heroes) = data.only.get(key) { + return heroes.iter().any(|candidate| candidate == hero); + } + if let Some(heroes) = data.except.get(key) { + return !heroes.iter().any(|candidate| candidate == hero); + } + false +} + +#[derive(serde::Deserialize)] +struct HeroApplicability { + all: Vec, + only: std::collections::HashMap>, + except: std::collections::HashMap>, +} + +fn applicability() -> &'static HeroApplicability { + static DATA: std::sync::OnceLock = std::sync::OnceLock::new(); + DATA.get_or_init(|| { + serde_json::from_str(include_str!("data/hero_applicability.json")) + .expect("hero applicability data is valid JSON") + }) +} + +/// The pinned compiler rewrites these authored hero spellings under +/// `heroes.` — group names and hero-list elements alike — before the +/// schema lookup (`compileCustomGameSettingsDict`). The order matters: group +/// renames run as one pass per alias in this order. +const HERO_NAME_ALIASES: &[(&str, &str)] = &[("mccree", "cassidy"), ("hammond", "wreckingBall")]; + +fn canonical_hero_name(name: &str) -> &str { + HERO_NAME_ALIASES + .iter() + .find_map(|(alias, canonical)| (*alias == name).then_some(*canonical)) + .unwrap_or(name) +} + +fn rename_member(member: &mut workshop_rs::settings::SettingsNode, name: &str) { + use workshop_rs::settings::SettingsNode; + match member { + SettingsNode::Group { name: slot, .. } + | SettingsNode::Number { name: slot, .. } + | SettingsNode::Bool { name: slot, .. } + | SettingsNode::Flag { name: slot, .. } + | SettingsNode::String { name: slot, .. } + | SettingsNode::List { name: slot, .. } + | SettingsNode::Raw { name: slot, .. } + | SettingsNode::RawValue { name: slot, .. } => *slot = name.to_string(), + SettingsNode::Workshop { .. } => {} + } +} + /// The pinned OverPy writes a `main`, `lobby`, mode, team `general`, or hero /// member it cannot translate as authored instead of rejecting it: an unknown /// key with its value (`key: value`, a list or object as a block), and a @@ -50,7 +118,7 @@ pub(crate) struct UnknownSetting { /// members ahead of its hero groups as the pinned OverPy writes them. fn pass_through_unknown_members( settings: &mut workshop_rs::settings::Settings, -) -> Vec { +) -> Result, IntegrationError> { use workshop_rs::settings::{PathPart, SettingsNode}; let mut unknown = Vec::new(); @@ -60,7 +128,7 @@ fn pass_through_unknown_members( }; match name.as_str() { "main" | "lobby" => { - pass_through_members(children, &[PathPart::Part(name)], &mut unknown); + pass_through_members(children, &[PathPart::Part(name)], &mut unknown, None); } "gamemodes" => { for mode in children { @@ -70,13 +138,18 @@ fn pass_through_unknown_members( || matches!(member, SettingsNode::Bool { .. }) }); let path = [PathPart::Part("gamemodes"), PathPart::Part(name)]; - pass_through_members(children, &path, &mut unknown); + pass_through_members(children, &path, &mut unknown, None); } } } "heroes" => { for team in children { - let SettingsNode::Group { children, .. } = team else { + let SettingsNode::Group { + name: team_name, + children, + .. + } = team + else { continue; }; // Any other team member is a hero name to upstream. @@ -87,31 +160,147 @@ fn pass_through_unknown_members( SettingsNode::Group { name, children, .. } if name == "general" => { general.extend(children); } + // Upstream iterates the value's `Object.keys`: a + // non-dict `general` yields index-keyed members for + // strings and lists, and drops numbers/booleans. + child if child.name() == "general" => match child { + SettingsNode::String { value, span, .. } => { + general.extend(value.chars().enumerate().map(|(index, ch)| { + SettingsNode::Raw { + name: index.to_string(), + value: ch.to_string(), + span, + } + })); + } + SettingsNode::List { elements, .. } => { + general.extend(elements.iter().enumerate().map( + |(index, element)| SettingsNode::Raw { + name: index.to_string(), + value: element.value.clone(), + span: element.span, + }, + )); + } + _ => {} + }, child => rest.push(child), } } let team_path = [PathPart::Part("heroes"), PathPart::Team]; - pass_through_members(&mut general, &team_path, &mut unknown); - general.extend(rest); - *children = general; - for hero in children { - if let SettingsNode::Group { children, .. } = hero { - let path = [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero]; - pass_through_members(children, &path, &mut unknown); + pass_through_members(&mut general, &team_path, &mut unknown, None); + let mut enabled = false; + let mut disabled = false; + for child in &rest { + enabled |= child.name() == "enabledHeroes"; + disabled |= child.name() == "disabledHeroes"; + } + if enabled && disabled { + return Err(IntegrationError::new( + "settings-hero-lists", + format!( + "Cannot have both 'enabledHeroes' and 'disabledHeroes' in team '{team_name}'" + ), + None, + )); + } + // The pinned compiler emits `enabledHeroes`/`disabledHeroes` + // after every hero group regardless of authored position. + let (mut hero_lists, mut rest): (Vec<_>, Vec<_>) = + rest.into_iter().partition(|child| { + matches!(child, SettingsNode::List { name, .. } if matches!( + name.as_str(), + "enabledHeroes" | "disabledHeroes" + )) + }); + // Canonical hero-group names take upstream's assign+delete + // rename: the source group lands at the destination's + // position, collapsing a duplicate, or at the end of the + // hero groups when the destination is absent. Upstream + // runs one pass per alias in `HERO_NAME_ALIASES` order, so + // appended groups follow that order, not authored order. + for (alias, canonical) in HERO_NAME_ALIASES { + let mut index = 0; + while index < rest.len() { + let SettingsNode::Group { name, .. } = &rest[index] else { + index += 1; + continue; + }; + if name != alias { + index += 1; + continue; + } + let mut moved = rest.remove(index); + if let SettingsNode::Group { name, .. } = &mut moved { + *name = (*canonical).to_string(); + } + match rest.iter_mut().find(|m| m.name() == *canonical) { + Some(dest) => *dest = moved, + None => rest.push(moved), + } + } + } + // The flattened `general` members are not reprocessed: + // only authored hero groups and hero rosters take the + // hero-name and applicability passes. + for hero in rest.iter_mut().chain(hero_lists.iter_mut()) { + match hero { + SettingsNode::Group { name, children, .. } => { + let canonical = canonical_hero_name(name).to_string(); + // The pinned compiler rewrites `ability1KB%` + // to `ability1Kb%` inside hero settings before + // the schema lookup; the rewritten member takes + // the source value at the destination key's + // position, collapsing a duplicate, and moves to + // the end when the destination is absent. + if let Some(source) = + children.iter().position(|m| m.name() == "ability1KB%") + { + let mut moved = children.remove(source); + rename_member(&mut moved, "ability1Kb%"); + match children.iter_mut().find(|m| m.name() == "ability1Kb%") { + Some(dest) => *dest = moved, + None => children.push(moved), + } + } + let path = + [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero]; + pass_through_members( + children, + &path, + &mut unknown, + Some(canonical.as_str()), + ); + } + SettingsNode::List { name, elements, .. } + if matches!(name.as_str(), "enabledHeroes" | "disabledHeroes") => + { + for element in elements { + let canonical = canonical_hero_name(&element.value); + if canonical != element.value { + element.value = canonical.to_string(); + } + } + } + _ => {} } } + general.extend(rest); + general.extend(hero_lists); + *children = general; } } _ => {} } } - unknown + Ok(unknown) } fn pass_through_members( members: &mut [workshop_rs::settings::SettingsNode], path: &[workshop_rs::settings::PathPart<'_>], unknown: &mut Vec, + hero: Option<&str>, ) { use workshop_rs::settings::{PathPart, SettingValueDomain, SettingsNode}; @@ -124,12 +313,23 @@ fn pass_through_members( let span = member.span(); let mut full = path.to_vec(); full.push(PathPart::Part(&name)); - match workshop_rs::settings::definition(&full) { + let definition = workshop_rs::settings::definition(&full); + // A catalogued key the pinned schema does not apply to this hero is + // unknown for it and passes through like any other unknown key, but + // fully verbatim: a `RawValue` at the catalogued path would still + // emit the canonical name. + let inapplicable = definition.is_some() + && hero.is_some_and(|hero| !hero_setting_applies(hero, name.as_str())); + match definition.filter(|_| !inapplicable) { None => { if matches!(member, SettingsNode::Raw { .. }) { continue; } - *member = written_form(member.clone()); + *member = if inapplicable { + verbatim_form(member.clone()) + } else { + written_form(member.clone()) + }; unknown.push(UnknownSetting { name, value: None, @@ -209,6 +409,22 @@ fn written_form(node: workshop_rs::settings::SettingsNode) -> workshop_rs::setti } } +/// The fully verbatim form of a member: like [`written_form`], but a scalar +/// becomes `Raw` so its name is written as authored even when the key +/// resolves in the catalog. +fn verbatim_form(node: workshop_rs::settings::SettingsNode) -> workshop_rs::settings::SettingsNode { + use workshop_rs::settings::SettingsNode; + + match scalar_text(&node) { + Some(value) => SettingsNode::Raw { + name: node.name().to_string(), + value, + span: node.span(), + }, + None => written_form(node), + } +} + /// A scalar value as JavaScript's `String(value)` writes it. fn scalar_text(node: &workshop_rs::settings::SettingsNode) -> Option { use workshop_rs::settings::SettingsNode; diff --git a/crates/opy-rs/tests/tooling.rs b/crates/opy-rs/tests/tooling.rs index d8f46fdc..a475dd54 100644 --- a/crates/opy-rs/tests/tooling.rs +++ b/crates/opy-rs/tests/tooling.rs @@ -502,6 +502,356 @@ fn settings_numbers_render_like_the_pinned_oracle() { ); } +#[test] +fn hero_settings_apply_the_pinned_per_hero_schema() { + // opy-rs#495: the pinned schema merges hero settings per hero, so a + // catalogued key is only translated where it applies; elsewhere it is + // written fully verbatim. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"ana\": {\"ability3Cooldown%\": 50},\n", + " \"wreckingBall\": {\"ability3Cooldown%\": 50},\n", + " \"reinhardt\": {\"ammoClipSize%\": 50},\n", + " \"mercy\": {\"ammoClipSize%\": 50},\n", + " \"dva\": {\"ability2Height%\": 50},\n", + " \"freja\": {\"ability2Height%\": 50}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + for line in [ + "ability3Cooldown%: 50", + "ammoClipSize%: 50", + "ability2Height%: 50", + "Piledriver Cooldown Time: 50%", + "Ammunition Clip Size Scalar: 50%", + "Updraft Height: 50%", + ] { + assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}"); + } + for translated in [ + "Sleep Dart Cooldown Time", + "Dynamite Cooldown Time", + "Biotic Grenade", + ] { + assert!( + !lines.iter().any(|line| line.contains(translated)), + "{translated:?} unexpectedly in {lines:?}" + ); + } +} + +#[test] +fn hero_settings_rewrite_the_pinned_name_and_key_aliases() { + // opy-rs#495: the pinned compiler rewrites `mccree`/`hammond` hero names + // and `ability1KB%` member keys before its schema lookup. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"mccree\": {\"ability1Cooldown%\": 50},\n", + " \"dva\": {\"ability1KB%\": 50},\n", + " \"ana\": {\"ability1KB%\": 50},\n", + " \"hammond\": {\"ability3Cooldown%\": 50}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + for line in [ + "Cassidy {", + "Combat Roll Cooldown Time: 50%", + "Boosters Knockback Scalar: 50%", + "ability1Kb%: 50", + "Piledriver Cooldown Time: 50%", + ] { + assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}"); + } + assert!( + !lines.iter().any(|line| line.contains("ability1KB%")), + "authored spelling must not survive in {lines:?}" + ); +} + +#[test] +fn hero_roster_lists_emit_after_hero_groups_with_canonical_names() { + // opy-rs#495: the pinned compiler emits `enabledHeroes`/`disabledHeroes` + // last inside a team block, and rewrites the same hero aliases in them. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"enabledHeroes\": [\"mccree\", \"hammond\"],\n", + " \"dva\": {\"health%\": 50}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + let enabled = lines + .iter() + .position(|line| line == "enabled heroes {") + .expect("hero list"); + let dva = lines + .iter() + .position(|line| line == "D.Va {") + .expect("hero group"); + assert!( + dva < enabled, + "hero list must follow hero groups: {lines:?}" + ); + for line in ["Cassidy", "Wrecking Ball"] { + assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}"); + } +} + +#[test] +fn hero_kb_rename_uses_the_pinned_assign_delete_order() { + // opy-rs#495: upstream rewrites `ability1KB%` via assign+delete, so the + // renamed member moves to the end, or collapses onto an authored + // `ability1Kb%` position with the source value. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"dva\": {\"ability1KB%\": 20, \"health%\": 150}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + let health = lines + .iter() + .position(|line| line == "Health: 150%") + .expect("health member"); + let kb = lines + .iter() + .position(|line| line == "Boosters Knockback Scalar: 20%") + .expect("renamed member"); + assert!(health < kb, "renamed member must move last: {lines:?}"); + + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"dva\": {\"ability1Kb%\": 10, \"health%\": 150, \"ability1KB%\": 20}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + let health = lines + .iter() + .position(|line| line == "Health: 150%") + .expect("health member"); + let kb = lines + .iter() + .position(|line| line == "Boosters Knockback Scalar: 20%") + .expect("renamed member"); + assert!( + kb < health, + "collapsed member keeps the destination position: {lines:?}" + ); + assert!( + !lines.iter().any(|line| line.contains("10%")), + "the source value wins over the authored destination: {lines:?}" + ); +} + +#[test] +fn hero_group_rename_uses_the_pinned_assign_delete_order() { + // opy-rs#495: upstream renames alias hero groups via assign+delete, so a + // renamed group moves to the end, or collapses onto the canonical + // group's position with the source members. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"mccree\": {\"health%\": 50},\n", + " \"ana\": {\"health%\": 150}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + let ana = lines + .iter() + .position(|line| line == "Ana {") + .expect("ana group"); + let cassidy = lines + .iter() + .position(|line| line == "Cassidy {") + .expect("renamed group"); + assert!(ana < cassidy, "renamed group must move last: {lines:?}"); + + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"mccree\": {\"health%\": 50},\n", + " \"cassidy\": {\"health%\": 10}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + assert_eq!( + lines.iter().filter(|line| **line == "Cassidy {").count(), + 1, + "the canonical group collapses to one: {lines:?}" + ); + assert!( + lines.contains(&"Health: 50%".to_string()) + && !lines.iter().any(|line| line.contains("10%")), + "the source members win: {lines:?}" + ); + + // Both aliases in one team: upstream renames per alias in its fixed + // order, so appended groups follow that order, not authored order. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"hammond\": {\"health%\": 50},\n", + " \"mccree\": {\"health%\": 60}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + let cassidy = lines + .iter() + .position(|line| line == "Cassidy {") + .expect("cassidy group"); + let wrecking_ball = lines + .iter() + .position(|line| line == "Wrecking Ball {") + .expect("wrecking ball group"); + assert!( + cassidy < wrecking_ball, + "the mccree pass appends before the hammond pass: {lines:?}" + ); +} + +#[test] +fn general_children_are_not_reprocessed_as_hero_members() { + // opy-rs#495: the flattened `general` members take only the team-level + // pass-through — hero-name and applicability passes must not reach them. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"general\": {\n", + " \"dva\": {\"ability1KB%\": 50},\n", + " \"damageDealt%\": 50\n", + " },\n", + " \"ana\": {\"health%\": 50}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + for line in ["ability1KB%: 50", "Damage Dealt: 50%", "Health: 50%"] { + assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}"); + } + assert!( + !lines + .iter() + .any(|line| line.contains("Boosters Knockback Scalar")), + "a nested hero dict stays verbatim: {lines:?}" + ); +} + +#[test] +fn a_non_dict_general_yields_index_members_or_drops() { + // opy-rs#495: upstream iterates the value's `Object.keys`, so a string or + // list `general` emits index-keyed members while numbers/booleans drop. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"general\": \"ab\",\n", + " \"ana\": {\"health%\": 50}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + for line in ["0: a", "1: b", "Health: 50%"] { + assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}"); + } + + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"general\": [10, 20],\n", + " \"ana\": {\"health%\": 50}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + for line in ["0: 10", "1: 20"] { + assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}"); + } + + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"general\": 5,\n", + " \"ana\": {\"health%\": 50}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + assert!( + lines.contains(&"Health: 50%".to_string()) + && !lines.iter().any(|line| line == "general: 5"), + "{lines:?} must drop the number member and keep the hero members" + ); +} + +#[test] +fn both_hero_lists_in_one_team_error() { + // opy-rs#495: upstream rejects a team carrying both rosters. + let error = opy_rs::compile( + concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"enabledHeroes\": [\"ana\"],\n", + " \"disabledHeroes\": [\"genji\"]\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + ), + "main.opy", + Path::new(""), + ) + .unwrap_err(); + assert_eq!(error.code, "settings-hero-lists"); + assert_eq!( + error.message, + "Cannot have both 'enabledHeroes' and 'disabledHeroes' in team 'allTeams'" + ); +} + +#[test] +fn inapplicable_hero_key_with_enum_value_stays_verbatim() { + // opy-rs#495: a non-applicable key is unknown for that hero, so both the + // name and the value keep their authored spelling. + let lines = compiled_lines(concat!( + "settings {\n", + " \"gamemodes\": {\"ffa\": {\"enabled\": true}},\n", + " \"heroes\": {\"allTeams\": {\n", + " \"ana\": {\"enableSecondaryFire\": false},\n", + " \"genji\": {\"enableGenericSecondaryFire\": true}\n", + " }}\n", + "}\n", + "rule \"a\":\n @Event global\n wait(1)\n", + )); + for line in [ + "enableSecondaryFire: false", + "enableGenericSecondaryFire: true", + ] { + assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}"); + } +} + #[test] fn misspelled_settings_key_warns_with_a_single_candidate_suffix() { // A near-miss settings key compiles unchanged like upstream; `check` warns diff --git a/docs/opy/tooling-api.md b/docs/opy/tooling-api.md index d91a1f36..3a02e9fa 100644 --- a/docs/opy/tooling-api.md +++ b/docs/opy/tooling-api.md @@ -14,7 +14,9 @@ settings keys cannot pass `check` and fail only inside the emitter. As in the pinned OverPy, a `main`, `lobby`, mode, team `general`, or hero member the table does not declare compiles as written: an unknown key as `key: value` (a list or object value as a block), and an enum key with an undeclared value -as its localized key followed by the value. `check` reports each as a +as its localized key followed by the value. A hero member the pinned schema +does not apply to that hero is unknown for it and compiles fully verbatim. +`check` reports each as a `unknown-setting` warning. A mode's non-Boolean `enabled` is dropped, as upstream does. `compile` continues from that resolved model through canonical WIR lowering, diff --git a/tools/overpy/gen_hero_applicability.cjs b/tools/overpy/gen_hero_applicability.cjs new file mode 100644 index 00000000..29454683 --- /dev/null +++ b/tools/overpy/gen_hero_applicability.cjs @@ -0,0 +1,75 @@ +// Records crates/opy-rs/src/compiler/data/hero_applicability.json from the +// pinned OverPy 9.7.10 customGameSettingsSchema hero merge: the post-load +// `computeCustomGameSettingsSchema` task expands +// `heroes.values.__generalAndEachHero__` + `__eachHero__` (filtered by each +// key's include/exclude hero lists) into every hero's `values` block, and +// `heroes.values.general` gains `__generalAndEachHero__` + +// `__generalButNotEachHero__`. `compileCustomGameSettingsDict` then looks each +// authored hero key up in that merged per-hero set and writes keys it cannot +// find back verbatim, so set membership is the applicability contract. +// +// pnpm install --dir tools/overpy/oracle +// node tools/overpy/gen_hero_applicability.cjs +// +// The artifact records each merged key's applying hero set in the smaller of +// two spellings: `only` (the heroes the key applies to) or `except` (the +// heroes it does not). Keys applying to every hero sit in `all`. Team-level +// pseudo entries (`general`, `enabledHeroes`, `disabledHeroes`) are excluded; +// the general key set is emitted under `generalKeys` for auditing only. + +const fs = require("fs"); +const path = require("path"); + +const overpy = require(path.join(__dirname, "oracle", "node_modules", "overpy")); + +const OUT = path.join( + __dirname, + "..", + "..", + "crates", + "opy-rs", + "src", + "compiler", + "data", + "hero_applicability.json", +); + +overpy.readyPromise.then(() => { + const heroKw = overpy.heroKw; + const values = overpy.customGameSettingsSchema.heroes.values; + const heroes = Object.keys(heroKw); + const union = new Set(); + for (const hero of heroes) { + for (const key of Object.keys(values[hero]?.values ?? {})) { + union.add(key); + } + } + const all = []; + const only = {}; + const except = {}; + for (const key of [...union].sort()) { + const have = heroes.filter((hero) => key in (values[hero]?.values ?? {})); + if (have.length === heroes.length) { + all.push(key); + } else if (have.length <= heroes.length - have.length) { + only[key] = have; + } else { + except[key] = heroes.filter((hero) => !(key in (values[hero]?.values ?? {}))); + } + } + const artifact = { + schemaVersion: 1, + pinned: "overpy@9.7.10", + derivation: + "heroes.values merge (__generalAndEachHero__ + filtered __eachHero__ + per-hero values) computed by the pinned compiler's own post-load task", + generalKeys: Object.keys(values.general?.values ?? {}).sort(), + all, + only, + except, + }; + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, JSON.stringify(artifact, null, 1) + "\n"); + console.log( + `wrote ${OUT}: ${all.length} universal, ${Object.keys(only).length} only, ${Object.keys(except).length} except`, + ); +});