Source code for PyPoE.poe.sim.item

"""
Utilities for dealing with items

Overview
===============================================================================

+----------+------------------------------------------------------------------+
| Path     | PyPoE/poe/sim/item.py                                            |
+----------+------------------------------------------------------------------+
| Version  | 1.0.0a0                                                          |
+----------+------------------------------------------------------------------+
| Revision | $Id: e8796c371be35ba52629a0d6a4d6b939c7d3edc1 $                  |
+----------+------------------------------------------------------------------+
| Author   | Omega_K2                                                         |
+----------+------------------------------------------------------------------+

Description
===============================================================================

Utilities for dealing with items.

Agreement
===============================================================================

See PyPoE/LICENSE

.. todo::

    * ValueError -> ParserError?

Documentation
===============================================================================

.. autoclass:: ItemParser

.. autoclass:: ItemSocket

.. autoclass:: ITEM_TYPES
"""

# =============================================================================
# Imports
# =============================================================================

# Python
import re
from enum import Enum

# 3rd-party

# self
from PyPoE.poe.constants import RARITY, SOCKET_COLOUR
from PyPoE.poe.file.dat import RelationalReader

# =============================================================================
# Globals
# =============================================================================

__all__ = ['ItemParser']

# =============================================================================
# Functions
# =============================================================================


def _regex_build_single_re(k, v):
    if 're' in v:
        return r'%s: (?P<%s>.*)$' % (v['re'], k)
    elif 're2' in v:
        return v['re2']


def _regex_update_singular_dict(singular_dict):
    for k, v in singular_dict.items():
        v['re_compiled'] = re.compile(_regex_build_single_re(k, v), re.UNICODE)


def _regex_build_from_handler_dict(handler_dict):
    conditionals = []
    for k, v in handler_dict.items():
        conditionals.append(_regex_build_single_re(k, v))

    return re.compile('|'.join(conditionals), re.MULTILINE | re.UNICODE)

# =============================================================================
# Classes
# =============================================================================


[docs]class ITEM_TYPES(Enum): ITEM = 0 GEM = 1 CURRENCY = 2
[docs]class ItemSocket(object): """ Attributes ---------- 'index' : int Index (position) of the socket 'colour' : SOCKET_COLOUR Colour of the socket """ __slots__ = ('index', 'colour') def __init__(self, index, colour): self.index = index self.colour = colour def __repr__(self): return 'ItemSocket(%s, %s)' % (self.index, repr(self.colour)) def __eq__(self, other): if not isinstance(other, ItemSocket): return False return self.index == other.index and self.colour == other.colour
[docs]class ItemParser(object): """ Class to parse/handle the string provided by items when using CTRL-C on items in game. .. warning:: The available attributes may vary depending on what was given on the string. It is recommended to use this together with the game data to get accurate representation of items. The stat texts can be reversed into stats with the Translation class. Attributes ---------- 'base_item_name' : str Name of the base item 'name' : str Name of the item. This may equal the base item name (i.e white items) or be different (e.x. unique items) 'description' : str Description of the item if any 'flavour_text' : str Flavour text (orange text) if any 'help_text' : str Help text (grey text, "tooltip") if any 'implicit_stats' : list[str] List of implicit stat text lines 'stats' : list[str] List of explicit stat text lines 'prefix' : str Prefix name of this item if any 'suffix' : str Suffix name of this item if any 'rarity' : RARITY rarity of the item 'sockets' : list[ItemSocket] list of item sockets 'links' : list[ItemSocket] list of linked sockets 'is_corrupted' : bool Whether the item is corrupted 'required_level' : int Required level to use the item 'required_str' : int Required strength to use the item 'required_int' : int Required intelligence to use the item 'required_dex' : int Required dexterity to use the item 'map_tier' : int Map tier (found on maps) 'item_quantity' : int How much item quantity the item grants (found on maps) 'item_rarity' : int How much item rarity the item grants (found on maps) 'pack_size' : int How much pack size the item grants (found on maps) 'quality' : int Quality of the item. (found on all kinds of equipment and maps) 'armour' : int Armour rating of the item (found on armour) 'evasion' : int Evasion rating of the item (found on armour) 'energy_shield' : int Energy shield of the item (found on armour) 'physical_damage' : list[int, int] 2 element list containing the minimum/maximum physical damage of the item (found on weapons) 'elemental_damage' : list[[int, int], [int, int], [int, int]] A 3 element list containing lists with 2 elements each. The top level list with 3 elements represents the elemental damage types as provided in the description. The bottom level list with 2 elements represents the minimum and maximum damage. (found on weapons) 'chaos_damage' : list[int, int] 2 element list containing the minimum/maximum chaos damage of the item (found on weapons) 'critical_strike_chance' : float Critical strike chance of the item (found on weapons) 'attacks_per_second' : float Attacks per second of the item (found on weapons) 'item_class' : str The item class of the item. .. warning:: even though all items have an item class, this is only present on weapons 'gem_level' : int Current skill gem level 'mana_cost' : int Mana cost of the skill gem 'mana_reserved' : int Mana reservation cost of the skill gem 'mana_multiplier' : int mana multiplier of the skill gem 'souls_per_use' : int Souls used when he skill gem is triggered (found on vaal skill gems) 'stored_uses' : int Number of stored uses (found on vaal skills, traps, mines, etc) 'cooldown_time' : float Cooldown in seconds of the skill gem (found on traps, mines, etc) 'cast_time' : float Cast time in second of the skill gem critical_stike_chance: float Critical strike chance in percent of the skill gem 'damage_effectiveness' : int Damage effectiveness in percent of the skill gem 'experience' : list[int, int] 2 Elemental list containing the current gem experience and the required experience for the next level respectively 'stack_size' : int Current stack size of the item (found on currency and other stackables) """ _re_split = re.compile( r'^\-{8}$', re.UNICODE | re.MULTILINE ) _re_split_newline = re.compile( '(?:(?:\r|)\n)', re.UNICODE ) _re_replace = re.compile( r' \((augmented|unmet)\)', re.UNICODE ) _re_rarity = re.compile( r'^Rarity: (?P<rarity>.*)$', re.UNICODE | re.MULTILINE ) _stat_handlers = { ITEM_TYPES.ITEM: { 'map_tier': { 're': 'Map Tier', 'func': int, }, 'item_quantity': { 're': 'Item Quantity', 'func': lambda s: int(s.strip('%')), }, 'item_rarity': { 're': 'Item Rarity', 'func': lambda s: int(s.strip('%')), }, 'pack_size': { 're': 'Monster Pack Size', 'func': lambda s: int(s.strip('%')), }, 'quality': { 're': 'Quality', 'func': lambda s: int(s.strip('%')), }, 'armour': { 're': 'Armour', 'func': int, }, 'evasion': { 're': 'Evasion Rating', 'func': int, }, 'energy_shield': { 're': 'Energy Shield', 'func': int, }, 'physical_damage': { 're': 'Physical Damage', 'func': lambda s: [int(s2) for s2 in s.split('-')], }, 'elemental_damage': { 're': 'Elemental Damage', 'func': lambda s: [ [int(s3) for s3 in s2.split('-')] for s2 in s.split(', ') ], }, 'chaos_damage': { 're': 'Chaos Damage', 'func': lambda s: [int(s2) for s2 in s.split('-')], }, 'critical_strike_chance': { 're': 'Critical Strike Chance', 'func': lambda s: float(s.strip('%')), }, 'attacks_per_second': { 're': 'Attacks per Second', 'func': lambda s: float(s), }, 'item_class': { 're2': r'^(?P<item_class>[^:]+)$', 'func': lambda s: s, }, }, ITEM_TYPES.GEM: { 'gem_level': { 're': 'Level', 'func': int, }, 'mana_cost': { 're': 'Mana Cost', 'func': int, }, 'mana_reserved': { 're': 'Mana Reserved', 'func': lambda s: int(s.strip('%')), }, 'mana_multiplier': { 're': 'Mana Multiplier', 'func': lambda s: int(s.strip('%')), }, 'souls_per_use': { 're': 'Souls Per Use', 'func': int, }, 'stored_uses': { 're2': r'^Can Store (?P<stored_uses>[0-9]+) Use(?:|s)$', 'func': int, }, 'cooldown_time': { 're': 'Cooldown Time', 'func': lambda s: float(s.strip(' sec')), }, 'cast_time': { 're': 'Cast Time', 'func': lambda s: float(s.strip(' sec')), }, 'critical_strike_chance': { 're': 'Critical Strike Chance', 'func': lambda s: float(s.strip('%')), }, 'damage_effectiveness': { 're': 'Damage Effectiveness', 'func': lambda s: int(s.strip('%')) / 100, }, 'quality': { 're': 'Quality', 'func': lambda s: int(s.strip('%')), }, 'experience': { 're': 'Experience', 'func': lambda s: [int(s2.replace('.', '')) for s2 in s.split('/')], } }, ITEM_TYPES.CURRENCY: { 'stack_size': { 're': 'Stack Size', 'func': lambda s: [int(s2) for s2 in s.split('/')], }, }, } _re_stat_handlers = { ITEM_TYPES.ITEM: _regex_build_from_handler_dict( _stat_handlers[ITEM_TYPES.ITEM] ), ITEM_TYPES.GEM: _regex_build_from_handler_dict( _stat_handlers[ITEM_TYPES.GEM] ), ITEM_TYPES.CURRENCY: _regex_build_from_handler_dict( _stat_handlers[ITEM_TYPES.CURRENCY] ), } _requirement_handlers = { 'required_level': { 're': 'Level', 'func': int, }, 'required_str': { 're': 'Str', 'func': int, }, 'required_dex': { 're': 'Dex', 'func': int, }, 'required_int': { 're': 'Int', 'func': int, }, } _re_requirement_handlers = _regex_build_from_handler_dict(_requirement_handlers) _re_requirement = re.compile( '^Requirements:', # No multi line, match start of section re.UNICODE ) # Uses similar syntax, but is built for each value separately _re_singular = { 'limit': { 're': 'Limited to', 'func': int, }, 'item_level': { 're': 'Item Level', 'func': int, }, 'gem_tags': { 're2': r'^(?P<gem_tags>[^:]+)$', 'func': lambda s: s.split(', '), }, } _regex_update_singular_dict(_re_singular) _re_sockets = _re_sockets_split = re.compile( r'^Sockets: (?P<sockets>.+)$', re.UNICODE ) _re_sockets_split = re.compile( '( |-)', re.UNICODE ) _re_help_text_item_name = re.compile( '(Jewel|Map)', re.UNICODE ) _re_is_map = re.compile( '(' 'Map' ')', re.UNICODE ) _re_is_vaal_fragment = re.compile( '(' 'Sacrifice at (Dawn|Midnight|Noon|Dusk)|' 'Mortal (Rage|Hope|Ignorance|Grief)' ')', re.UNICODE ) _re_is_jewel = re.compile( '(' '(Viridian|Cobalt|Crimson) Jewel' ')', re.UNICODE ) _re_prefix = re.compile( '^(?P<prefix>[\S]+) .+$', re.UNICODE, ) _re_suffix = re.compile( '^.+ (?P<suffix>of [\S]+)$', re.UNICODE, )
[docs] def __init__(self, item_info_string): """ Creates a new ItemParser instance and attempts to parse the given item string from the CTRL-C command in game. Parameters ---------- item_info_string : str The complete string to parse """ self._type = None sections = self._re_split.split(item_info_string) if not sections: raise ValueError('No description sections found - malformed input?') current_sec = -len(sections) def increment_sec(value=True): # Python 3 is neat nonlocal current_sec if value: current_sec += 1 def section(index=None, offset=0): return sections[index or (current_sec+offset)].strip('\r\n') # Header section header = self._split(section()) self.base_item_name = header[-1] if len(header) == 3: self.name = header[1] elif len(header) in (1, 2): self.name = header[-1] else: raise ValueError('Header section is of unsupported length: %s' % len(header)) self.name = self.name if len(header) != 1: rarity = self._re_rarity.match(header[0]) if rarity is None: raise ValueError('No rarity found in the item header') rarity = rarity.group('rarity') for rarity_const in RARITY: if rarity_const.name_upper == rarity: self.rarity = rarity_const self._type = ITEM_TYPES.ITEM break if self._type is None: if rarity == 'Gem': self._type = ITEM_TYPES.GEM elif rarity == 'Currency': self._type = ITEM_TYPES.CURRENCY else: raise ValueError('Unsupported value for "Rarity": %s' % rarity) elif self.rarity == RARITY.MAGIC: self.prefix = None self.suffix = None match = self._re_suffix.match(self.base_item_name) if match: self.suffix = match.group('suffix') self.base_item_name = self.base_item_name.replace(' ' + self.suffix, '') # Can't reliably detect the prefix yet. Will have to do based on # stats else: # case for MTX items self._type = ITEM_TYPES.CURRENCY is_map = self._re_is_map.search(self.base_item_name) is_jewel = self._re_is_jewel.search(self.base_item_name) is_vaal_fragment = self._re_is_vaal_fragment.search(self.base_item_name) increment_sec() # Base stats sections (not from mods) next = section() if self._type == ITEM_TYPES.GEM: next = self._re_split_newline.split(next, 1) self._handle_singular(next[0], 'gem_tags') next = next[1] if self._type in self._stat_handlers: increment_sec( self._handle_handlers( next, self._re_stat_handlers[self._type], self._stat_handlers[self._type] ) ) # Requirements section if self._re_requirement.match(section()): increment_sec( self._handle_handlers( section(), self._re_requirement_handlers, self._requirement_handlers, ) ) # Sockets section match = self._re_sockets.match(section()) if match: self.sockets = [] self.links = [] last_linked = False for i, char in enumerate( self._re_sockets_split.split(match.group('sockets')) ): if i % 2 == 0: found = False for socket_colour in SOCKET_COLOUR: if socket_colour.char == char: found = True break if not found: raise ValueError('Unsupported socket colour: %s' % char) self.sockets.append(ItemSocket(i//2, socket_colour)) if last_linked: self.links[-1].append(self.sockets[-1]) else: if char == ' ': # No links last_linked = False elif char == '-': if not last_linked: self.links.append([self.sockets[-1], ]) last_linked = True else: raise ValueError('Unsupported link character: %s' % char) increment_sec() else: self.sockets = None self.links = None # Limited to section increment_sec(self._handle_singular(section(), 'limit')) # Item level section increment_sec(self._handle_singular(section(), 'item_level')) # Stack size #if self._type == ITEM_TYPES.CURRENCY: # increment_sec(self._handle_singular(section(), 'stack_size')) # stats, flavour text and help text would be here. # Below this point we're going backwards # Corrupted section last_sec = -1 if section(index=last_sec) == 'Corrupted': last_sec -= 1 self.is_corrupted = True else: self.is_corrupted = False # Help text if self._type in (ITEM_TYPES.GEM, ITEM_TYPES.CURRENCY) or is_jewel or is_map or is_vaal_fragment: self.help_text = section(index=last_sec) last_sec -= 1 # Flavour text if (self._type == ITEM_TYPES.ITEM and self.rarity == RARITY.UNIQUE) or is_vaal_fragment: self.flavour_text = section(index=last_sec) # Unidentified uniques don't have a flavour text, I think setting # "Unidentifed" is appropriate, but still have to make sure not to # adjust the pointer so stats parsing works. if self.flavour_text != 'Unidentified': last_sec -= 1 # Implicit section & stats section # We should be left at between 0 or 2 sections remaining = abs((last_sec + 1) - current_sec) if self._type == ITEM_TYPES.ITEM: if remaining == 0: self.implicit_stats = [] self.stats = [] elif remaining == 2: self.implicit_stats = self._re_split_newline.split(section()) self.stats = self._re_split_newline.split(section(offset=1)) elif remaining == 1: # Normal items can't have explicit stats if self.rarity == RARITY.NORMAL: self.implicit_stats = self._re_split_newline.split(section()) self.stats = [] # And magic/rare/unique items MUST have stats else: self.implicit_stats = [] self.stats = self._re_split_newline.split(section()) else: raise ValueError('Too many sections (%s) left for item stat parsing.' % remaining) elif self._type == ITEM_TYPES.GEM: if remaining == 0: self.stats = [] elif remaining == 1: self.stats = self._re_split_newline.split(section()) else: raise ValueError('Too many sections (%s) left for gem stat parsing.' % remaining) elif self._type == ITEM_TYPES.CURRENCY and remaining: if remaining == 1: self.description = section() else: raise ValueError('All sections (%s) should be parsed now.' % remaining) # Do a final pass on the prefix for magic items if self._type == ITEM_TYPES.ITEM and self.rarity == RARITY.MAGIC and ((self.suffix is None and len(self.stats) >= 1) or (self.suffix is not None and len(self.stats) >= 2)): match = self._re_prefix.match(self.base_item_name) self.prefix = match.group('prefix') self.base_item_name = self.base_item_name.replace(self.prefix + ' ', '')
def _split(self, section): return self._re_split_newline.split(section) def _handle_singular(self, string, key): match = self._re_singular[key]['re_compiled'].match(string) if match: setattr(self, key, self._re_singular[key]['func'](match.group(key))) return True setattr(self, key, None) return False def _handle_handlers(self, string, regex, handlers): for k in handlers: setattr(self, k, None) found = False for match in regex.finditer(string): found = True setattr( self, match.lastgroup, handlers[match.lastgroup]['func']( self._re_replace.sub('', match.group(match.lastgroup)) ) ) return found