From 6883ad4dadcfbb200eb313fd3157ba5ea856d46c Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 10 Oct 2026 04:47:33 +0800 Subject: [PATCH 1/4] fix(settings): apply hero settings keys per the pinned per-hero schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned OverPy compiler merges `__generalAndEachHero__` plus the `__eachHero__` keys a key's include/exclude filters admit into every hero's settings set, then writes authored keys missing from that set back verbatim. opy-rs translated through a hero-agnostic table, so catalogued keys the reference does not apply to a hero emitted canonical names and values (e.g. `ana.ability3Cooldown%` → `50%`). Gate the catalog definition on the merged per-hero applicability set recorded in `data/hero_applicability.json` (generated by `tools/overpy/gen_hero_applicability.cjs` from the pinned compiler's own schema merge), and emit inapplicable members fully verbatim. Also converge the pinned compiler's adjacent rewrites: `mccree`/`hammond` hero aliases in groups and hero lists, the `ability1KB%` → `ability1Kb%` member-key rewrite, and `enabledHeroes`/`disabledHeroes` emission after hero groups. A 53-hero x 102-key oracle differential now diverges only in workshop-rs name data, recorded on the issue and tracked in wrightkit/workshop-rs#414. Fixes #495 --- .../src/compiler/data/hero_applicability.json | 479 ++++++++++++++++++ crates/opy-rs/src/compiler/settings.rs | 150 +++++- crates/opy-rs/tests/tooling.rs | 125 +++++ docs/opy/tooling-api.md | 4 +- tools/overpy/gen_hero_applicability.cjs | 75 +++ 5 files changed, 824 insertions(+), 9 deletions(-) create mode 100644 crates/opy-rs/src/compiler/data/hero_applicability.json create mode 100644 tools/overpy/gen_hero_applicability.cjs 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..0568dbbe 100644 --- a/crates/opy-rs/src/compiler/settings.rs +++ b/crates/opy-rs/src/compiler/settings.rs @@ -40,6 +40,71 @@ 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`). +fn canonical_hero_name(name: &str) -> &str { + match name { + "mccree" => "cassidy", + "hammond" => "wreckingBall", + _ => 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 @@ -60,7 +125,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,7 +135,7 @@ 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); } } } @@ -91,13 +156,54 @@ fn pass_through_unknown_members( } } let team_path = [PathPart::Part("heroes"), PathPart::Team]; - pass_through_members(&mut general, &team_path, &mut unknown); + pass_through_members(&mut general, &team_path, &mut unknown, None); + // The pinned compiler emits `enabledHeroes`/`disabledHeroes` + // after every hero group regardless of authored position. + let (hero_lists, rest): (Vec<_>, Vec<_>) = + rest.into_iter().partition(|child| { + matches!(child, SettingsNode::List { name, .. } if matches!( + name.as_str(), + "enabledHeroes" | "disabledHeroes" + )) + }); general.extend(rest); + general.extend(hero_lists); *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); + match hero { + SettingsNode::Group { name, children, .. } => { + let canonical = canonical_hero_name(name).to_string(); + if canonical != *name { + *name = canonical.clone(); + } + // The pinned compiler rewrites `ability1KB%` + // to `ability1Kb%` inside hero settings + // before the schema lookup. + for member in children.iter_mut() { + if member.name() == "ability1KB%" { + rename_member(member, "ability1Kb%"); + } + } + 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(); + } + } + } + _ => {} } } } @@ -112,6 +218,7 @@ 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 +231,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 +327,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..b57a7264 100644 --- a/crates/opy-rs/tests/tooling.rs +++ b/crates/opy-rs/tests/tooling.rs @@ -502,6 +502,131 @@ 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 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`, + ); +}); From e18b7cb1806701a88a8c8f8c740c4d3539048b5c Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 10 Oct 2026 05:29:15 +0800 Subject: [PATCH 2/4] fix(settings): scope hero passes to authored hero members Address re-review findings on the per-hero schema gate: - The flattened team members took the hero-name and applicability passes, so a nested hero dict was canonicalized and a general-level roster list had its elements rewritten. Only authored hero groups and roster lists take the hero passes now. - renamed in place; upstream's assign+delete moves the member to the end, or collapses it onto an authored position with the source value. - A team carrying both and emitted both lists; upstream hard-errors with 'Cannot have both ... in team '. - A non-dict under a team now drops silently like upstream instead of failing emission. Two remaining verbatim-emission gaps need a canonical emitter escape and are routed to wrightkit/workshop-rs#417: inapplicable keys with non-scalar values, and general-nested groups/roster lists. --- crates/opy-rs/src/compiler/settings.rs | 62 ++++++++++--- crates/opy-rs/tests/tooling.rs | 123 +++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 13 deletions(-) diff --git a/crates/opy-rs/src/compiler/settings.rs b/crates/opy-rs/src/compiler/settings.rs index 0568dbbe..e4acc352 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)) } @@ -115,7 +116,7 @@ fn rename_member(member: &mut workshop_rs::settings::SettingsNode, name: &str) { /// 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(); @@ -141,7 +142,12 @@ fn pass_through_unknown_members( } "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. @@ -152,24 +158,43 @@ fn pass_through_unknown_members( SettingsNode::Group { name, children, .. } if name == "general" => { general.extend(children); } + // A non-dict `general` is dropped by upstream. + child if child.name() == "general" => {} child => rest.push(child), } } let team_path = [PathPart::Part("heroes"), PathPart::Team]; pass_through_members(&mut general, &team_path, &mut unknown, None); + let mut enabled = false; + let mut disabled = false; + for child in &rest { + if let SettingsNode::List { name, .. } = child { + enabled |= name == "enabledHeroes"; + disabled |= 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 (hero_lists, rest): (Vec<_>, Vec<_>) = + 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" )) }); - general.extend(rest); - general.extend(hero_lists); - *children = general; - for hero in children { + // 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(); @@ -177,11 +202,19 @@ fn pass_through_unknown_members( *name = canonical.clone(); } // The pinned compiler rewrites `ability1KB%` - // to `ability1Kb%` inside hero settings - // before the schema lookup. - for member in children.iter_mut() { - if member.name() == "ability1KB%" { - rename_member(member, "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 = @@ -206,12 +239,15 @@ fn pass_through_unknown_members( _ => {} } } + general.extend(rest); + general.extend(hero_lists); + *children = general; } } _ => {} } } - unknown + Ok(unknown) } fn pass_through_members( diff --git a/crates/opy-rs/tests/tooling.rs b/crates/opy-rs/tests/tooling.rs index b57a7264..dfd34a88 100644 --- a/crates/opy-rs/tests/tooling.rs +++ b/crates/opy-rs/tests/tooling.rs @@ -605,6 +605,129 @@ fn hero_roster_lists_emit_after_hero_groups_with_canonical_names() { } } +#[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 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_is_dropped() { + // opy-rs#495: upstream silently drops a non-dict `general` under a team. + 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:?} must still emit 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 From 7c169268822c57c31fd307362dd0c33eeb4c8396 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 10 Oct 2026 05:53:21 +0800 Subject: [PATCH 3/4] fix(compiler): apply upstream rename and general-value semantics Hero group aliases rename via the same assign+delete order as member keys: the renamed group moves to the end, or collapses onto the canonical group's position with the source members. A non-dict general emits index-keyed members for strings/lists and drops numbers/booleans. The enabled/disabled rosters conflict is detected by key presence regardless of node kind, and flattened general members are kept out of the hero pass. --- crates/opy-rs/src/compiler/settings.rs | 59 +++++++++++++++--- crates/opy-rs/tests/tooling.rs | 85 ++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/crates/opy-rs/src/compiler/settings.rs b/crates/opy-rs/src/compiler/settings.rs index e4acc352..fced5d21 100644 --- a/crates/opy-rs/src/compiler/settings.rs +++ b/crates/opy-rs/src/compiler/settings.rs @@ -158,8 +158,30 @@ fn pass_through_unknown_members( SettingsNode::Group { name, children, .. } if name == "general" => { general.extend(children); } - // A non-dict `general` is dropped by upstream. - child if child.name() == "general" => {} + // 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), } } @@ -168,10 +190,8 @@ fn pass_through_unknown_members( let mut enabled = false; let mut disabled = false; for child in &rest { - if let SettingsNode::List { name, .. } = child { - enabled |= name == "enabledHeroes"; - disabled |= name == "disabledHeroes"; - } + enabled |= child.name() == "enabledHeroes"; + disabled |= child.name() == "disabledHeroes"; } if enabled && disabled { return Err(IntegrationError::new( @@ -191,6 +211,30 @@ fn pass_through_unknown_members( "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. + let mut index = 0; + while index < rest.len() { + let SettingsNode::Group { name, .. } = &rest[index] else { + index += 1; + continue; + }; + let canonical = canonical_hero_name(name).to_string(); + if canonical == *name { + index += 1; + continue; + } + let mut moved = rest.remove(index); + if let SettingsNode::Group { name, .. } = &mut moved { + *name = canonical.clone(); + } + 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. @@ -198,9 +242,6 @@ fn pass_through_unknown_members( match hero { SettingsNode::Group { name, children, .. } => { let canonical = canonical_hero_name(name).to_string(); - if canonical != *name { - *name = canonical.clone(); - } // The pinned compiler rewrites `ability1KB%` // to `ability1Kb%` inside hero settings before // the schema lookup; the rewritten member takes diff --git a/crates/opy-rs/tests/tooling.rs b/crates/opy-rs/tests/tooling.rs index dfd34a88..9557be98 100644 --- a/crates/opy-rs/tests/tooling.rs +++ b/crates/opy-rs/tests/tooling.rs @@ -656,6 +656,53 @@ fn hero_kb_rename_uses_the_pinned_assign_delete_order() { ); } +#[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:?}" + ); +} + #[test] fn general_children_are_not_reprocessed_as_hero_members() { // opy-rs#495: the flattened `general` members take only the team-level @@ -685,8 +732,37 @@ fn general_children_are_not_reprocessed_as_hero_members() { } #[test] -fn a_non_dict_general_is_dropped() { - // opy-rs#495: upstream silently drops a non-dict `general` under a team. +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", @@ -698,8 +774,9 @@ fn a_non_dict_general_is_dropped() { "rule \"a\":\n @Event global\n wait(1)\n", )); assert!( - lines.contains(&"Health: 50%".to_string()), - "{lines:?} must still emit the hero members" + lines.contains(&"Health: 50%".to_string()) + && !lines.iter().any(|line| line == "general: 5"), + "{lines:?} must drop the number member and keep the hero members" ); } From 3148c91c7d7689d548cdd2a9de56e4c4d1279fb8 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 10 Oct 2026 06:16:13 +0800 Subject: [PATCH 4/4] fix(compiler): rename hero groups one alias per pass like upstream Upstream assigns mccree then hammond in separate steps, so appended groups follow alias order rather than authored order; a single left-to-right pass flipped the result when hammond was authored first. --- crates/opy-rs/src/compiler/settings.rs | 55 ++++++++++++++------------ crates/opy-rs/tests/tooling.rs | 25 ++++++++++++ 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/crates/opy-rs/src/compiler/settings.rs b/crates/opy-rs/src/compiler/settings.rs index fced5d21..21bc4e2a 100644 --- a/crates/opy-rs/src/compiler/settings.rs +++ b/crates/opy-rs/src/compiler/settings.rs @@ -82,13 +82,15 @@ fn applicability() -> &'static HeroApplicability { /// The pinned compiler rewrites these authored hero spellings under /// `heroes.` — group names and hero-list elements alike — before the -/// schema lookup (`compileCustomGameSettingsDict`). +/// 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 { - match name { - "mccree" => "cassidy", - "hammond" => "wreckingBall", - _ => name, - } + 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) { @@ -214,25 +216,28 @@ fn pass_through_unknown_members( // 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. - let mut index = 0; - while index < rest.len() { - let SettingsNode::Group { name, .. } = &rest[index] else { - index += 1; - continue; - }; - let canonical = canonical_hero_name(name).to_string(); - if canonical == *name { - index += 1; - continue; - } - let mut moved = rest.remove(index); - if let SettingsNode::Group { name, .. } = &mut moved { - *name = canonical.clone(); - } - match rest.iter_mut().find(|m| m.name() == canonical) { - Some(dest) => *dest = moved, - None => rest.push(moved), + // 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: diff --git a/crates/opy-rs/tests/tooling.rs b/crates/opy-rs/tests/tooling.rs index 9557be98..a475dd54 100644 --- a/crates/opy-rs/tests/tooling.rs +++ b/crates/opy-rs/tests/tooling.rs @@ -701,6 +701,31 @@ fn hero_group_rename_uses_the_pinned_assign_delete_order() { && !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]