Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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)
163 changes: 138 additions & 25 deletions src/parser/parsers/street_brawl.py
Original file line number Diff line number Diff line change
@@ -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 = []

Expand Down Expand Up @@ -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):
Expand Down
Loading