diff --git a/src/parser/parser.py b/src/parser/parser.py index 43b882df..f33e480d 100644 --- a/src/parser/parser.py +++ b/src/parser/parser.py @@ -153,7 +153,7 @@ def run(self): self._parse_item_investments() self._parse_misc() self._parse_convars() - self._parse_street_brawl() + self._parse_street_brawl(parsed_abilities) self._parse_map() logger.trace('Done parsing') @@ -334,8 +334,13 @@ def _parse_convars(self): json_utils.write(self.OUTPUT_DIR + '/json/convars.json', json_utils.sort_dict(parsed_convars)) - def _parse_street_brawl(self): + def _parse_street_brawl(self, parsed_abilities): logger.trace('Parsing Street Brawl...') - parsed_street_brawl = street_brawl.StreetBrawlParser(self.data['scripts']['heroes']).run() + parsed_street_brawl = street_brawl.StreetBrawlParser( + self.data['scripts']['heroes'], + self.data['scripts']['abilities'], + parsed_abilities, + self.localizations[self.language], + ).run() json_utils.write(self.OUTPUT_DIR + '/json/street-brawl-data.json', parsed_street_brawl) diff --git a/src/parser/parsers/street_brawl.py b/src/parser/parsers/street_brawl.py index ea53d8ef..3095307f 100644 --- a/src/parser/parsers/street_brawl.py +++ b/src/parser/parsers/street_brawl.py @@ -1,37 +1,152 @@ from collections import Counter from loguru import logger +from utils import json_utils +from .abilities import AbilityParser + class StreetBrawlParser: """ - Parses Street Brawl item draft bucketing from hero data into a flat, - deviation-only structure under "item-buckets". - - In the Street Brawl game mode players draft items from a random selection - instead of buying them. Every hero shares the same pool of draftable items - (AvailableItems), and by default each item sits in the "Normal" bucket at - weight 1.0. Heroes deviate from that default for a subset of items: - - a different bucket (e.g. "Good", offered more often), - - a reduced weight (e.g. 0.4, offered less often within its bucket), - - a counter weight (a separate per-hero multiplier for specific items). - - Only these deviations are stored, as a flat list of (Hero, Item) rows that - each carry whichever of Bucket/Weight/Counter differ from the default. The - full per-hero bucketing is reconstructed as: every AvailableItem is - "Normal" @ 1.0 unless a matching outlier row overrides it. - - Source fields (per hero, in heroes.json): - m_mapItemDraftBucketing: item -> {m_strBucket, m_flWeight} - m_mapItemDraftCounterWeights: item -> weight (a raw float) + Parses Street Brawl deviations from the normal game into "street-brawl-data.json". + + "ability-changes" + Some ability stats have a Street-Brawl-only value e.g. a longer cooldown or a + smaller radius. + We re-run the ability parser on a copy of the data with those swaps + applied, then keep only the leaves that differ from the normal parse, so + an entry lists exactly the stats that change. Each kept + leaf sits at the same key path as in ability-data.json, so a Street + Brawl value can be referenced the same way as its normal counterpart, + e.g. {{data|streetbrawl|A|AbilityCooldown}} vs {{data|abilities|A|AbilityCooldown}}. + + "item-buckets" + Every hero shares the same pool of draftable items + (AvailableItems), and by default each item sits in the "Normal" bucket + at weight 1.0. Heroes deviate from that default for a subset of items: + - a different bucket (e.g. "Good", offered more often), + - a reduced weight (e.g. 0.4, offered less often within its bucket), + - a counter weight (a separate per-hero multiplier for specific items). + Only these deviations are stored, as a flat list of (Hero, Item) rows + that each carry whichever of Bucket/Weight/Counter differ from the + default. The full per-hero bucketing is reconstructed as: every + AvailableItem is "Normal" @ 1.0 unless a matching outlier row overrides it. """ DEFAULT_BUCKET = 'Normal' DEFAULT_WEIGHT = 1.0 - def __init__(self, heroes_data): + # Street-Brawl-only override -> the base ability-property key it replaces. + OVERRIDE_KEYS = { + 'm_strStreetBrawlValue': 'm_strValue', + 'm_flStreetBrawlStatScale': 'm_flStatScale', + 'm_strStreetBrawlBonus': 'm_strBonus', + } + + # Sentinel meaning "these two nodes are identical", distinct from a real + # None/empty value that could legitimately appear in the parsed data. + _NO_DIFF = object() + + def __init__(self, heroes_data, abilities_data, base_abilities, localizations): self.heroes_data = heroes_data + self.abilities_data = abilities_data + self.base_abilities = base_abilities + self.localizations = localizations def run(self): + return { + 'ability-changes': self._parse_ability_changes(), + 'item-buckets': self._parse_item_buckets(), + } + + # --- Ability changes ------------------------------------------------- + + def _parse_ability_changes(self): + """ + Re-parse abilities with Street Brawl overrides swapped in, then keep + only the values that differ from the normal parse. + """ + brawl_raw = json_utils.wrap_case_insensitive(self._apply_overrides(self.abilities_data)) + brawl_abilities = AbilityParser(brawl_raw, self.heroes_data, self.localizations).run() + + changes = {} + for key, brawl_ability in brawl_abilities.items(): + base_ability = self.base_abilities.get(key) + if base_ability is None: + continue + + delta = self._diff(base_ability, brawl_ability) + if not delta: + continue + + # Name is unchanged so the diff drops it; re-attach so editors can + # see which ability an entry is without a second lookup. Its changed + # stats sit as siblings, keyed exactly as in ability-data.json. + changes[key] = {'Name': brawl_ability.get('Name'), **delta} + + return json_utils.sort_dict(changes) + + def _apply_overrides(self, node): + """ + Deep-copy the raw ability tree into plain dicts, replacing each base + value with its Street Brawl override where one is present. Returns + plain dicts so the result can be re-wrapped case-insensitively without + depending on deepcopy semantics of CaseInsensitiveDict. + """ + if isinstance(node, dict): + out = {key: self._apply_overrides(value) for key, value in node.items()} + for override_key, base_key in self.OVERRIDE_KEYS.items(): + if override_key in node: + override = node[override_key] + # An empty/None override is a placeholder ("unset in Street + # Brawl"), not a real value. Applying it would emit a useless + # "" that can't back a {{data}} reference, so leave the base + # value in place and let this property fall out of the diff. + if override is None or override == '': + continue + out[base_key] = self._apply_overrides(override) + return out + if isinstance(node, list): + return [self._apply_overrides(item) for item in node] + return node + + def _diff(self, base, brawl): + result = self._diff_node(base, brawl) + return {} if result is self._NO_DIFF else result + + def _diff_node(self, base, brawl): + """ + Return the parts of ``brawl`` that differ from ``base``, preserving the + original nesting, or ``_NO_DIFF`` when the two are identical. + """ + if isinstance(base, dict) and isinstance(brawl, dict): + out = {} + for key, brawl_value in brawl.items(): + if key in base: + sub = self._diff_node(base[key], brawl_value) + if sub is not self._NO_DIFF: + out[key] = sub + else: + out[key] = brawl_value + return out or self._NO_DIFF + + # Same-length lists are positional (e.g. Upgrades tiers). Collapse to an + # object keyed by the changed positions only, so unchanged tiers vanish + # instead of leaving empty placeholders the editor has to count past. + # Keys are 1-based to line up with Lua's 1-indexed arrays, so the same + # index addresses this tier in ability-data.json's Upgrades list. + if isinstance(base, list) and isinstance(brawl, list) and len(base) == len(brawl): + changed = {} + for index, (base_item, brawl_item) in enumerate(zip(base, brawl)): + sub = self._diff_node(base_item, brawl_item) + if sub is not self._NO_DIFF: + changed[str(index + 1)] = sub + return changed or self._NO_DIFF + + return brawl if base != brawl else self._NO_DIFF + + # --- Item draft bucketing -------------------------------------------- + + def _parse_item_buckets(self): available_items = set() outliers = [] @@ -70,11 +185,9 @@ def run(self): outliers.sort(key=lambda row: (row['Hero'], row['Item'])) return { - 'item-buckets': { - 'AvailableItems': sorted(available_items), - 'Defaults': {'Bucket': self.DEFAULT_BUCKET, 'Weight': self.DEFAULT_WEIGHT}, - 'Outliers': outliers, - } + 'AvailableItems': sorted(available_items), + 'Defaults': {'Bucket': self.DEFAULT_BUCKET, 'Weight': self.DEFAULT_WEIGHT}, + 'Outliers': outliers, } def _warn_if_default_shifted(self, hero_key, bucketing):