├── .gitignore ├── DLF_icon.ico ├── Profiles ├── .gitignore └── __ProfilesReadme.txt ├── Readme.md ├── Resources ├── Test │ ├── Items │ │ ├── item_test_cases_generated.txt │ │ ├── item_test_cases_verified.txt │ │ ├── item_text_format.txt │ │ ├── normal_vs_alt_copied_items.txt │ │ ├── sample_items.txt │ │ ├── test_item_edge_cases.txt │ │ └── test_items.txt │ ├── OldMiscFilters │ │ ├── BrandMapping.filter │ │ ├── CustomAlertSoundTest.filter │ │ ├── NeversinkRegular.filter │ │ ├── NeversinkSemiStrict.filter │ │ └── NeversinkStrict.filter │ ├── SFH.filter │ └── TestNeversinkStrict.filter ├── convert_newlines.py ├── flask_base_types.txt └── splinter_base_types.txt ├── ahk_include ├── backend_interface.ahk ├── consts.ahk ├── general_helper.ahk ├── gui_build.ahk ├── gui_helper.ahk ├── gui_interaction.ahk ├── gui_placeholder.ahk ├── helper.ahk ├── language_util.ahk ├── poe_helper.ahk └── two_way_dict.ahk ├── backend ├── backend_cli.py ├── backend_cli_function_info.py ├── backend_cli_test.py ├── base_type_helper.py ├── consts.py ├── file_helper.py ├── file_helper_test.py ├── general_config.py ├── general_config_test.py ├── generate_item_test_cases.py ├── hash_linked_list.py ├── hash_linked_list_test.py ├── item.py ├── item_test.py ├── logger.py ├── loot_filter.py ├── loot_filter_rule.py ├── loot_filter_rule_test.py ├── loot_filter_test.py ├── multiset.py ├── multiset_test.py ├── parse_helper.py ├── parse_helper_test.py ├── profile.py ├── profile_changes.py ├── profile_changes_test.py ├── profile_test.py ├── resources.py ├── rule_matching_test.py ├── run_code_checks.py ├── run_unit_tests.py ├── simple_parser.py ├── simple_parser_test.py ├── socket_helper.py ├── socket_helper_test.py ├── string_helper.py ├── string_helper_test.py ├── test_assertions.py ├── test_consts.py ├── test_helper.py ├── type_checker.py └── type_checker_test.py ├── cache └── .gitignore ├── config └── .gitignore ├── dynamic_loot_filter.ahk └── resources ├── Icon ├── DLF_icon.png └── DLF_icon.xcf ├── LintStyle ├── pylintrc.yml └── yapf-style.yml └── Test └── TestProfile.rules /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore test-generated files 2 | TestWorkingDirectory/* 3 | 4 | # Ignore auto-generated backup files (by Notepad++, SCITE4 AHK Editor, etc) 5 | *.bak 6 | 7 | # --- The following is GitHub's base Python .gitignore --- 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | cover/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | .pybuilder/ 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | # For a library or package, you might want to ignore these files since the code is 95 | # intended to run in multiple environments; otherwise, check them in: 96 | # .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 106 | __pypackages__/ 107 | 108 | # Celery stuff 109 | celerybeat-schedule 110 | celerybeat.pid 111 | 112 | # SageMath parsed files 113 | *.sage.py 114 | 115 | # Environments 116 | .env 117 | .venv 118 | env/ 119 | venv/ 120 | ENV/ 121 | env.bak/ 122 | venv.bak/ 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | 142 | # pytype static type analyzer 143 | .pytype/ 144 | 145 | # Cython debug symbols 146 | cython_debug/ 147 | -------------------------------------------------------------------------------- /DLF_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apollys/PoEDynamicLootFilter/d819a92cc78b728b12eb59b6811c3b17b370a194/DLF_icon.ico -------------------------------------------------------------------------------- /Profiles/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all profile files 2 | *.config 3 | *.rules 4 | *.changes 5 | -------------------------------------------------------------------------------- /Profiles/__ProfilesReadme.txt: -------------------------------------------------------------------------------- 1 | Profiles 2 | 3 | Note: these files are generated automatically via Profile creation workflow, so you should 4 | not have to deal with them unless you want to add custom rules to the .rules file, or debug 5 | some weird issue. 6 | 7 | Each profile is defined by a required .config and optional .rules and .changes files: 8 | - The .config file specifies all configurable settings for the profile. 9 | - The .rules file lists additional rules to be added to the *start* of the downloaded filter. 10 | - The .changes file tracks all changes made to the filter, which are re-applied on filter import. 11 | 12 | In addition, there is a single general config file, "config/general.config", which contains 13 | the currently active profile and the UI hotkeys. 14 | 15 | All profile files must be located directly inside the directory "Profiles". 16 | 17 | For example, if you want to have a profile called "Leaguestart" and a profile called 18 | "Endgame", your Profiles directory should contain the following files: 19 | - Leaguestart.config 20 | - [optional] Leaguestart.rules 21 | - [optional] Leaguestart.changes 22 | - Endgame.config 23 | - [optional] Endgame.rules 24 | - [optional] Endgame.changes 25 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # PoE Dynamic Loot Filter 2 | 3 | ![DLF_UI_07_06_2022](https://user-images.githubusercontent.com/37650759/177500912-633690df-dc9a-4412-8427-75cb795476be.png) 4 | 5 | ## What is PoE Dynamic Loot Filter? 6 | 7 | **PoE Dynamic Loot Filter** (or **DLF**) is a tool to modify your loot filter seamlessly in real-time while playing Path of Exile. 8 | 9 | **Join our [Discord](https://discord.gg/gH9MzgCzMJ)** for help, to give suggestions, or just to discuss DLF! 10 | 11 | ## Quick Setup 12 | 13 | There are only two requirements for DLF to run (see [below](https://github.com/Apollys/PoEDynamicLootFilter#what-setup-is-required) for more details): 14 | 15 | 1. **[AutoHotkey](https://www.autohotkey.com/)** 16 | 2. **[Python 3](https://www.python.org/downloads/windows/)** 17 | 18 | Once you have the requirements, follow these steps to setup DLF: 19 | 20 | 1. Download any [FilterBlade](https://www.filterblade.xyz/) loot filter (you can leave it in your Downloads directory). 21 | 2. Download this repository anywhere on your PC (Click the green "Code" drop-down menu in the top right, then click "Download ZIP", and extract it anywhere). 22 | 3. Double click `dynamic_loot_filter.ahk` to launch PoE DLF (setup is integrated into the GUI). 23 | 24 | ## In-Game Usage 25 | 26 | 1. While playing Path of Exile, press the **Toggle UI** hotkey (default: `F7`). 27 | 2. Select your changes with just a couple mouse clicks. 28 | 3. Press the **Write Filter** hotkey (default: `F8`). Path of Exile will automatically be brought to the foreground. 29 | 4. Press the **Reload Filter** hotkey (default: `F9`) - your changes are now immediately visible in-game! 30 | 31 | ## Why Use DLF? 32 | 33 | Normally, in order to make a change to our filter, we need to: 34 | 1. Alt-tab out of PoE 35 | 2. Navigate to the Filterblade site and log in if not logged in 36 | 3. Load our Filterblade filter 37 | 4. Search through the Filterblade UI to find the section corresponding to the change we want to make 38 | 5. Make the change 39 | 6. Save the filter on Filterblade 40 | 7. Sync the changes to our Path of Exile account 41 | 8. Alt-tab back into PoE 42 | 9. Reload the filter 43 | 44 | With DLF, this can all be done in a couple seconds. No exaggeration here. 45 | 46 | Common highly valuable uses include: 47 | 48 | 1. I'm getting to mapping and no longer want to see single portal and wisdom scrolls. With PoE DLF, 49 | I can easily set the minimum visible stack size of currency by tier (portal and wisdom scrolls have their own tier). 50 | 2. I'm farming chaos recipes, and have way too many body armors, but not enough hats. With PoE DLF, 51 | I can easily show/hide chaos recipe rare items by item slot. 52 | 3. My loot filter is getting spammed by something random. DLF may have a specific option to fix it 53 | (for example, if it's a cheap div card, I could raise the minimum shown div card tier threshold), or 54 | I can use DLF's built-in rule-matcher to automatically detect and hide the rule that matches a given item. 55 | 56 | ## DLF Requirements 57 | 58 | PoE DLF has minimal requirements and setup to make it as widely accessible as possible. You need the following: 59 | * A **loot filter created from [Filterblade](https://www.filterblade.xyz/)** - using a builtin filter (e.g. Neversink Stable - Strict) is totally fine! 60 | In fact, the less you've moved things around or created custom rules in FilterBlade, the better (visual style changes are always okay though) 61 | * **[AutoHotkey](https://www.autohotkey.com/)** - If you get some error like `switch prog: command not found` when running DLF, 62 | update your version. 63 | * **[Python 3](https://www.python.org/downloads/windows/)** - the version is important here, it needs to be **Python 3** 64 | * To verify your python is set up as required, open a command prompt and type `python`: it should launch Python 3.X) 65 | * If `python` doesn't work, but one of `python3` or `py` work, then you're also okay! 66 | * No specific python packages are required, as long as Python 3 loads, you are good to go 67 | 68 | ## Under the Hood - How Does DLF Work? 69 | 70 | Firstly, since we are programmatically reading, analyzing, and writing filter files, note that this is all done *locally* 71 | - on the user's machine - rather than online. 72 | 73 | FilterBlade formats its loot filters in a certain way, and PoE DLF leverages that format to parse the input filter into 74 | a structure that it can understand and manipulate. For example, FilterBlade places `type` and `tier` tags on the first 75 | line of their rules, so the tier 1 currency rule can be found by looking for the tags `type->currency` and `tier->t1`. 76 | 77 | Whenever the user wants to make a change to the filter, the corresponding rules are modified, and the filter is re-saved. 78 | User profiles are also updated to save the changes the user has made to the filter, so the user can re-download a filter 79 | (if they want to make a change on the FilterBlade site), and all their DLF changes will be maintained. Users can also maintain 80 | separate profiles (e.g. for different characters, for SSF/Trade leagues, etc) if desired. 81 | 82 | All filter parsing, modification, and saving is done by a Python backend. An AHK frontend GUI presents the program's functionality 83 | in a clean and efficient manner to the user, and the AHK GUI then relays commands to the Python backend to execute. 84 | 85 | Rule matching is a somewhat complicated - the python backend has to reproduce Path of Exile's entire rule-item matching algorithm. 86 | In general, it works pretty well, but note that some aspects of it have been shortcut slightly, as they're not super important 87 | and the system takes a lot of work to get perfectly. Also, note that at new league starts, there may be significant bugs 88 | because of new items and loot filter attributes that are added to the game. 89 | 90 | - - - 91 | 92 | **End of user Readme: non-developers may safely ignore everything below.** 93 | 94 | - - - 95 | 96 | ## Developer Documentation 97 | 98 | ### Syntax for backend command-line interface calls 99 | 100 | ``` 101 | > python backend_cli.py 102 | ``` 103 | 104 | The `profile_name` parameter is required in all cases except for: 105 | - `is_first_launch` 106 | - `set_hotkey ` 107 | - `get_all_hotkeys` 108 | - `get_all_profile_names` 109 | 110 | - - - 111 | 112 | ### To-Do 113 | 114 | Feature suggestions and bug reports are now tracked in GitHub issues. 115 | I will just leave items that do not have a corresponding GitHub issue here for now. 116 | 117 | - [ ] Skip unrecognized commands in Profile.changes file (with warning) 118 | - Likely cause is depracated feature from version update 119 | - Better not to fail in this case 120 | 121 | - - - 122 | 123 | ### Frontend-Backend API Specification 124 | 125 | The AHK frontend calls the Python backend via: 126 | ``` 127 | > python backend_cli.py 128 | ``` 129 | The Python backend communicates output values to AHK by writing them to the file 130 | `backend_cli.output`. It writes an exit code to `backend_cli.exit_code`: `-1` indicates 131 | in-progress, `0` indicates exit success, and `1` indicates exit with error 132 | (`backend_cli.log` contains error details). 133 | 134 | See [`backend_cli.py`](https://github.com/Apollys/PoEDynamicLootFilter/blob/master/backend_cli.py) 135 | for the detailed documentation of all available function calls. 136 | 137 | **Currently Supported Functions:** 138 | - `is_first_launch` 139 | - `set_hotkey ` 140 | - `get_all_hotkeys` 141 | - `get_all_profile_names` 142 | - `create_new_profile ` 143 | - `rename_profile ` 144 | - `delete_profile ` 145 | - `set_active_profile` 146 | - `check_filters_exist` 147 | - `import_downloaded_filter` 148 | - `load_input_filter` 149 | - `run_batch` 150 | 151 | - `get_rule_matching_item` 152 | - `set_rule_visibility ` 153 | 154 | - `set_currency_to_tier ` 155 | - `get_all_currency_tiers` 156 | - `set_currency_tier_min_visible_stack_size ` 157 | - `get_currency_tier_min_visible_stack_size ` 158 | - `get_all_currency_tier_min_visible_stack_sizes` 159 | - (For test suite use: `get_tier_of_currency `) 160 | - `set_splinter_min_visible_stack_size ` 161 | - `get_splinter_min_visible_stack_size ` 162 | - `get_all_splinter_min_visible_stack_sizes` 163 | 164 | - `set_chaos_recipe_enabled_for ` 165 | - `is_chaos_recipe_enabled_for ` 166 | - `get_all_chaos_recipe_statuses` 167 | 168 | - `set_hide_maps_below_tier ` 169 | - `get_hide_maps_below_tier` 170 | 171 | - `get_all_essence_tier_visibilities` 172 | - `set_hide_essences_above_tier ` 173 | - `get_hide_essences_above_tier` 174 | - `get_all_div_card_tier_visibilities` 175 | - `set_hide_div_cards_above_tier ` 176 | - `get_hide_div_cards_above_tier` 177 | - `get_all_unique_item_tier_visibilities` 178 | - `set_hide_unique_items_above_tier ` 179 | - `get_hide_unique_items_above_tier` 180 | - `get_all_unique_map_tier_visibilities` 181 | - `set_hide_unique_maps_above_tier ` 182 | - `get_hide_unique_maps_above_tier` 183 | - `set_lowest_visible_oil ` 184 | - `get_lowest_visible_oil` 185 | 186 | - `show_basetype ` 187 | - `disable_basetype ` 188 | - `get_all_visible_basetypes` 189 | - `set_flask_visibility <(optional) high_ilvl_flag: int>` 190 | - `get_flask_visibility ` 191 | - `get_all_visible_flasks` 192 | - `add_remove_socket_rule <(optional) item_slot: str> ` 193 | - `get_all_added_socket_rules` 194 | 195 | - `set_gem_min_quality ` 196 | - `get_gem_min_quality` 197 | - `set_flask_min_quality ` 198 | - `get_flask_min_quality` 199 | - `set_rgb_item_max_size ` 200 | - `get_rgb_item_max_size` 201 | -------------------------------------------------------------------------------- /Resources/Test/Items/item_test_cases_generated.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apollys/PoEDynamicLootFilter/d819a92cc78b728b12eb59b6811c3b17b370a194/Resources/Test/Items/item_test_cases_generated.txt -------------------------------------------------------------------------------- /Resources/Test/Items/item_text_format.txt: -------------------------------------------------------------------------------- 1 | {Header Block} 2 | Item Class: 3 | Rarity: 4 | (Optional) Name: 5 | 6 | (*Caveat*: if Rarity is Magic, the BaseType line will look like: 7 | BaseType of 8 | -------- 9 | {Properties Block} 10 | (Depending on item type, may have) 11 | - Quality: +% 12 | - Armour/Energy Shield/Evasion Rating: 13 | - Damage/Crit/APS 14 | - Gem Tags 15 | - Level: (gem level only afaik) 16 | - Flask Duration 17 | - Stack Size: / 18 | - Map Tier: 19 | - Atlas Region: 20 | - "Alternate Quality" (exactly this line) 21 | -------- 22 | {Requirements Block} 23 | Requiremets: 24 | (Optional) Level: 25 | (Optional) Str: 26 | (Optional) Dex: 27 | (Optional) Int: 28 | -------- 29 | {Sockets Block} 30 | Sockets: (example: R R-B-B) 31 | -------- 32 | {Item Level Block} 33 | Item Level: (Optional: "(Max)") 34 | -------- 35 | {Enchant Block} 36 | (Example: Used when you Hit a Rare or Unique Enemy, if not already in effect (enchant)) 37 | -------- 38 | {Implicit Affixes Block} 39 | (Example: Creates Consecrated Ground on Use (implicit)) 40 | -------- 41 | {Explicit Affixes Block} 42 | -------- 43 | {Experience Block} 44 | Experience: / 45 | -------- 46 | {Flavour Text Block} 47 | (Example: A tourniquet for the soul, squeezing ethereal into physical.) 48 | -------- 49 | {Unidentified Block +} 50 | Unidentified 51 | Searing Exarch Item 52 | Eater of Worlds Item 53 | (Yes, these are all in the same block, for some reason) 54 | -------- 55 | {Instructional Text Block} 56 | (Example: Right click to drink. Can only hold charges while in belt. Refills as you kill monsters.) 57 | -------- 58 | {Influence Block} 59 | Shaper Item 60 | Redeemer Item 61 | -------- 62 | {Mirrored Block} 63 | Mirrored 64 | -------- 65 | {Corrupted Block} 66 | Corrupted 67 | -------- 68 | {Synthesized Block} 69 | Synthesized Item 70 | -------- 71 | {Fractured Block} 72 | Fractured Item 73 | -------- 74 | {Note Block} 75 | Note: (example: ~price 1 exalted) 76 | 77 | ================================================================================ 78 | 79 | Note: there are some other properties that are not relevant to drop filters, 80 | such as 'Split'', that I haven't included here. 81 | Also veiled is not included just for simplicity. 82 | 83 | ================================================================================ 84 | 85 | Parsing Algorithm: 86 | 87 | High Level Idea: overwrite the text lines so they can be parsed simply as: 88 | {keyword}: {value}, where any line that doesn't fit this format can be safely ignored. 89 | 90 | 1. Identify the header block, defined as the lines from start to the first horizontal break. 91 | - Prepend 'BaseType: ' to the last line 92 | - If there are four lines, prepend 'Name: ' to the second to last line 93 | 2. Find the requirements block, defined as block whose first line is 'Requirements:' 94 | - Note: Requirements block is optional 95 | - Rewrite the first line to the empty string 96 | - Prepend 'Required ' to each of the remaining lines 97 | - Example: 'Str: 50' -> 'Required Str: 50' 98 | 3. Re-traverse updated item text lines, attemping to parse each line from the template: 99 | {keyword}: {value} 100 | - If the line cannot be parsed, move on 101 | - If it can, save the keyword-value pair to a dictionary 102 | 4. Handle binary properties (mirrored, corrupted, etc). These properties only display 103 | their keyword when they are present. 104 | Build a set of binary keywords: 105 | - {Shaper Item, Redeemer Item, ..., Alternate Quality, Unidentified, Mirrored, Corrupted, 106 | Synthesized Item, Fractured Item} 107 | - (There are probably many more) 108 | - Add each of these binary keywords to the item dictionary, with an associated value of False 109 | - Traverse the list of text lines again, checking if each line is in the binary keywords set. 110 | If yes, set the associated value to True. 111 | 5. Apply specific modifications to some of the values: 112 | - Stack Size: value looks like '/' -> parse and keep only the numerator 113 | - Quality: value looks like '+%' -> parse and keep only the integer value 114 | - Level: verify that the Item Class is gem, and convert the key to "Gem Level" to avoid ambiguity 115 | - Unidentified: negate the value and store with key 'Identified' 116 | 6. TODO Parse Magic BaseType: 117 | - If rarity is Magic, current BaseType string looks like BaseType of 118 | - If we had a list of all BaseTypes, we could parse this exactly. 119 | - Without a list of BaseTypes, it's impossible to do from non-alt-copied text: 120 | - The Shaper's Coral Ring of the Lynx -> prefix = The Shaper's 121 | - Coral Ring of the Lynx -> prefix = None (not Coral) -------------------------------------------------------------------------------- /Resources/Test/Items/normal_vs_alt_copied_items.txt: -------------------------------------------------------------------------------- 1 | Normal copied text (Ctrl-C) versus alt-copied text (Ctrl-Alt-C) of same items. 2 | 3 | =============================================================================== 4 | 5 | Item Class: Helmets 6 | Rarity: Rare 7 | Bramble Dome 8 | Magistrate Crown 9 | -------- 10 | Quality: +20% (augmented) 11 | Armour: 192 (augmented) 12 | Energy Shield: 66 (augmented) 13 | -------- 14 | Requirements: 15 | Level: 70 16 | Str: 64 17 | Dex: 98 18 | Int: 68 19 | -------- 20 | Sockets: G-G-G-B 21 | -------- 22 | Item Level: 80 23 | -------- 24 | Barrage fires an additional Projectile (enchant) 25 | -------- 26 | 15% increased Global Accuracy Rating 27 | +22 to maximum Energy Shield 28 | +32 to maximum Life 29 | +60 to maximum Mana 30 | +42% to Cold Resistance 31 | 10% increased Light Radius 32 | Nearby Enemies have -9% to Cold Resistance 33 | +69 to maximum Life (crafted) 34 | 35 | • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • 36 | 37 | Item Class: Helmets 38 | Rarity: Rare 39 | Bramble Dome 40 | Magistrate Crown 41 | -------- 42 | Quality: +20% (augmented) 43 | Armour: 192 (augmented) 44 | Energy Shield: 66 (augmented) 45 | -------- 46 | Requirements: 47 | Level: 70 48 | Str: 64 49 | Dex: 98 50 | Int: 68 51 | -------- 52 | Sockets: G-G-G-B 53 | -------- 54 | Item Level: 80 55 | -------- 56 | Barrage fires an additional Projectile (enchant) 57 | -------- 58 | { Prefix Modifier "Abbot's" (Tier: 1) — Life, Defences } 59 | +22(16-25) to maximum Energy Shield 60 | +32(29-33) to maximum Life 61 | { Prefix Modifier "Mazarine" (Tier: 3) — Mana } 62 | +60(60-64) to maximum Mana 63 | { Master Crafted Prefix Modifier "Upgraded" (Rank: 4) — Life } 64 | +69(56-70) to maximum Life (crafted) 65 | { Suffix Modifier "of the Ice" (Tier: 2) — Elemental, Cold, Resistance } 66 | +42(42-45)% to Cold Resistance 67 | { Suffix Modifier "of the Underground" (Tier: 1) — Damage, Elemental, Cold, Resistance } 68 | Nearby Enemies have -9% to Cold Resistance — Unscalable Value 69 | { Suffix Modifier "of Light" (Tier: 2) — Attack } 70 | 15(12-15)% increased Global Accuracy Rating 71 | 10% increased Light Radius 72 | 73 | =============================================================================== 74 | 75 | Item Class: Support Skill Gems 76 | Rarity: Gem 77 | Enhance Support 78 | -------- 79 | Exceptional, Support 80 | Level: 4 (Max) 81 | Cost & Reservation Multiplier: 120% 82 | -------- 83 | Requirements: 84 | Level: 60 85 | Dex: 96 86 | -------- 87 | Supports any skill gem. Once this gem reaches level 2 or above, will raise the quality of supported gems. Cannot support skills that don't come from gems. 88 | -------- 89 | +24% to Quality of Supported Active Skill Gems 90 | -------- 91 | This is a Support Gem. It does not grant a bonus to your character, but to skills in sockets connected to it. Place into an item socket connected to a socket containing the Active Skill Gem you wish to augment. Right click to remove from a socket. 92 | -------- 93 | Corrupted 94 | 95 | • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • 96 | 97 | Item Class: Support Skill Gems 98 | Rarity: Gem 99 | Enhance Support 100 | -------- 101 | Exceptional, Support 102 | Level: 4 (Max) 103 | Cost & Reservation Multiplier: 120% 104 | -------- 105 | Requirements: 106 | Level: 60 107 | Dex: 96 108 | -------- 109 | Supports any skill gem. Once this gem reaches level 2 or above, will raise the quality of supported gems. Cannot support skills that don't come from gems. 110 | -------- 111 | +24% to Quality of Supported Active Skill Gems 112 | 113 | Additional Effects From Quality: 114 | This Gem gains 0(0-100)% increased Experience 115 | -------- 116 | This is a Support Gem. It does not grant a bonus to your character, but to skills in sockets connected to it. Place into an item socket connected to a socket containing the Active Skill Gem you wish to augment. Right click to remove from a socket. 117 | -------- 118 | Corrupted 119 | 120 | =============================================================================== 121 | 122 | Item Class: Utility Flasks 123 | Rarity: Magic 124 | Physician's Diamond Flask of the Impala 125 | -------- 126 | Quality: +20% (augmented) 127 | Lasts 7.20 (augmented) Seconds 128 | Consumes 20 of 40 Charges on use 129 | Currently has 40 Charges 130 | 100% increased Global Critical Strike Chance 131 | -------- 132 | Requirements: 133 | Level: 67 134 | -------- 135 | Item Level: 85 136 | -------- 137 | Reused at the end of this Flask's effect (enchant) 138 | -------- 139 | 19% chance to gain a Flask Charge when you deal a Critical Strike 140 | 58% increased Evasion Rating during Flask effect 141 | -------- 142 | Right click to drink. Can only hold charges while in belt. Refills as you kill monsters. 143 | 144 | • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • 145 | 146 | Item Class: Utility Flasks 147 | Rarity: Magic 148 | Physician's Diamond Flask of the Impala 149 | -------- 150 | Quality: +20% (augmented) 151 | Lasts 7.20 (augmented) Seconds 152 | Consumes 20 of 40 Charges on use 153 | Currently has 40 Charges 154 | 100% increased Global Critical Strike Chance 155 | -------- 156 | Requirements: 157 | Level: 67 158 | -------- 159 | Item Level: 85 160 | -------- 161 | Reused at the end of this Flask's effect (enchant) 162 | -------- 163 | { Prefix Modifier "Physician's" (Tier: 4) — Critical } 164 | 19(16-20)% chance to gain a Flask Charge when you deal a Critical Strike 165 | { Suffix Modifier "of the Impala" (Tier: 1) — Defences } 166 | 58(56-60)% increased Evasion Rating during Flask effect 167 | -------- 168 | Right click to drink. Can only hold charges while in belt. Refills as you kill monsters. 169 | 170 | =============================================================================== 171 | 172 | Item Class: Maps 173 | Rarity: Rare 174 | Spectre Refuge 175 | Beach Map 176 | -------- 177 | Map Tier: 6 178 | Item Quantity: +57% (augmented) 179 | Item Rarity: +31% (augmented) 180 | Monster Pack Size: +20% (augmented) 181 | Quality: +5% (augmented) 182 | -------- 183 | Item Level: 73 184 | -------- 185 | Monsters' skills Chain 2 additional times 186 | Monsters take 32% reduced Extra Damage from Critical Strikes 187 | Monsters have a 15% chance to cause Elemental Ailments on Hit 188 | Monsters have +45% chance to Suppress Spell Damage 189 | -------- 190 | Travel to this Map by using it in a personal Map Device. Maps can only be used once. 191 | 192 | • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • 193 | 194 | Item Class: Maps 195 | Rarity: Rare 196 | Spectre Refuge 197 | Beach Map 198 | -------- 199 | Map Tier: 6 200 | Item Quantity: +57% (augmented) 201 | Item Rarity: +31% (augmented) 202 | Monster Pack Size: +20% (augmented) 203 | Quality: +5% (augmented) 204 | -------- 205 | Item Level: 73 206 | -------- 207 | { Prefix Modifier "Empowered" (Tier: 1) — Elemental, Fire, Cold, Lightning, Ailment } 208 | Monsters have a 15% chance to cause Elemental Ailments on Hit 209 | (Elemental Ailments are Ignited, Scorched, Chilled, Frozen, Brittle, Shocked, and Sapped) 210 | { Prefix Modifier "Chaining" (Tier: 1) } 211 | Monsters' skills Chain 2 additional times 212 | { Prefix Modifier "Oppressive" (Tier: 1) } 213 | Monsters have +45% chance to Suppress Spell Damage 214 | (50% of Damage from Suppressed Hits and Ailments they inflict is prevented) 215 | { Suffix Modifier "of Toughness" (Tier: 1) — Damage, Critical } 216 | Monsters take 32(31-35)% reduced Extra Damage from Critical Strikes 217 | -------- 218 | Travel to this Map by using it in a personal Map Device. Maps can only be used once. 219 | 220 | =============================================================================== 221 | 222 | Item Class: Amulets 223 | Rarity: Unique 224 | Crystallised Omniscience 225 | Onyx Amulet 226 | -------- 227 | Quality (Attribute Modifiers): +20% (augmented) 228 | -------- 229 | Requirements: 230 | Level: 61 231 | -------- 232 | Item Level: 85 233 | -------- 234 | Allocates Utmost Intellect (enchant) 235 | -------- 236 | +19 to all Attributes (implicit) 237 | -------- 238 | Modifiers to Attributes instead apply to Omniscience 239 | +1% to all Elemental Resistances per 10 Omniscience 240 | Penetrate 1% Elemental Resistances per 10 Omniscience 241 | Attribute Requirements can be satisfied by 25% of Omniscience 242 | -------- 243 | That winter, scorched refugees emerged from the shrine, 244 | speaking only in strange tongues. They prayed to a new 245 | symbol of power, not out of love, but out of fear. 246 | 247 | • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • 248 | 249 | Item Class: Amulets 250 | Rarity: Unique 251 | Crystallised Omniscience 252 | Onyx Amulet 253 | -------- 254 | Quality (Attribute Modifiers): +20% (augmented) 255 | -------- 256 | Requirements: 257 | Level: 61 258 | -------- 259 | Item Level: 85 260 | -------- 261 | Allocates Utmost Intellect (enchant) 262 | -------- 263 | { Implicit Modifier — Attribute — 20% Increased } 264 | +16(10-16) to all Attributes (implicit) 265 | (Attributes are Strength, Dexterity, and Intelligence) (implicit) 266 | -------- 267 | { Unique Modifier — Attribute — 20% Increased } 268 | Modifiers to Attributes instead apply to Omniscience 269 | { Unique Modifier — Elemental, Resistance } 270 | +1% to all Elemental Resistances per 10 Omniscience 271 | { Unique Modifier — Damage, Elemental } 272 | Penetrate 1% Elemental Resistances per 10 Omniscience 273 | { Unique Modifier } 274 | Attribute Requirements can be satisfied by 25(15-25)% of Omniscience 275 | -------- 276 | That winter, scorched refugees emerged from the shrine, 277 | speaking only in strange tongues. They prayed to a new 278 | symbol of power, not out of love, but out of fear. 279 | 280 | =============================================================================== 281 | 282 | Item Class: Stackable Currency 283 | Rarity: Currency 284 | Orb of Fusing 285 | -------- 286 | Stack Size: 2,517/20 287 | -------- 288 | Reforges the links between sockets on an item 289 | -------- 290 | Right click this item then left click a socketed item to apply it. The item's quality increases the chances of obtaining more links. 291 | Shift click to unstack. 292 | 293 | • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • 294 | 295 | Item Class: Stackable Currency 296 | Rarity: Currency 297 | Orb of Fusing 298 | -------- 299 | Stack Size: 2,517/20 300 | -------- 301 | Right click this item then left click a socketed item to apply it. The item's quality increases the chances of obtaining more links. 302 | Shift click to unstack. 303 | 304 | =============================================================================== -------------------------------------------------------------------------------- /Resources/Test/Items/test_item_edge_cases.txt: -------------------------------------------------------------------------------- 1 | ================================================================================ 2 | If you cannot use an item, but it is "equipped" on your character, the header 3 | block is split into two and an extra line is added. This does not happen if the 4 | item is in your stash or inventory, so it should not be an issue. 5 | ================================================================================ 6 | 7 | Item Class: Wands 8 | Rarity: Rare 9 | You cannot use this item. Its stats will be ignored 10 | -------- 11 | Phoenix Song 12 | Imbued Wand 13 | -------- 14 | Wand 15 | Quality: +20% (augmented) 16 | Physical Damage: 35-64 (augmented) 17 | Elemental Damage: 60-103 (augmented), 49-85 (augmented), 9-164 (augmented) 18 | Critical Strike Chance: 9.66% (augmented) 19 | Attacks per Second: 1.68 (augmented) 20 | -------- 21 | Requirements: 22 | Level: 72 23 | Str: 73 24 | Int: 188 (unmet) 25 | -------- 26 | Sockets: B-B-R 27 | -------- 28 | Item Level: 85 29 | -------- 30 | 37% increased Spell Damage (implicit) 31 | -------- 32 | Adds 60 to 103 Fire Damage 33 | Adds 49 to 85 Cold Damage 34 | Adds 9 to 164 Lightning Damage 35 | 38% increased Critical Strike Chance 36 | +38% to Global Critical Strike Multiplier 37 | 12% increased Attack Speed (crafted) 38 | -------- 39 | Mirrored 40 | 41 | ================================================================================ 42 | When the current stack size reached 1000, a comma will be added in the stack 43 | size number. This does not happen with gem experience. Realistically this 44 | should never be an issue with dropped items. 45 | ================================================================================ 46 | 47 | Item Class: Stackable Currency 48 | Rarity: Currency 49 | Simulacrum Splinter 50 | -------- 51 | Stack Size: 1,112/300 52 | -------- 53 | Combine 300 Splinters to create a Simulacrum. 54 | Shift click to unstack. 55 | -------- 56 | Note: ~skip 57 | 58 | ================================================================================ 59 | Magic items' BaseType cannot be unambiguously parsed without a list of all 60 | BaseTypes. Maybe a list of all prefixes could suffice, assuming no BaseTypes 61 | also start with a prefix. Consider: 62 | - Coral Ring of the Lynx 63 | - The Shaper's Coral Ring of the Lynx 64 | Without additional information, we can't know if the first is: 65 | - (Coral) (Ring) (of the Lynx), or 66 | - (Coral Ring) (of the Lynx) 67 | ================================================================================ 68 | 69 | Item Class: Sentinel 70 | Rarity: Magic 71 | Reliable Obsidian Apex Sentinel 72 | -------- 73 | Duration: 51 (augmented) seconds 74 | Empowers: 3 enemies 75 | Empowerment: 42 76 | -------- 77 | Requirements: 78 | Level: 66 79 | -------- 80 | Item Level: 69 81 | -------- 82 | Can only empower Rare or Unique enemies (implicit) 83 | -------- 84 | +11 seconds to Duration 85 | -------- 86 | Charge: 7/8 87 | -------- 88 | Unmodifiable -------------------------------------------------------------------------------- /Resources/convert_newlines.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Python script to convert files using \r\n as their newline characters to only use \n. 3 | This is used to ensure parsing of resource files is not dependent on specific newline characters used. 4 | ''' 5 | 6 | kInputFilename = 'splinter_base_types.txt' 7 | kOutputFilename = 'splinter_base_types_no_carriage_return.txt' 8 | 9 | def main(): 10 | lines = [] 11 | with open(kInputFilename) as f: 12 | for line in f: 13 | lines.append(line.strip()) 14 | with open(kOutputFilename, 'w', newline='') as f: 15 | f.write('\n'.join(lines)) 16 | 17 | if (__name__ == '__main__'): 18 | main() -------------------------------------------------------------------------------- /Resources/flask_base_types.txt: -------------------------------------------------------------------------------- 1 | Divine Life Flask 2 | Eternal Life Flask 3 | Divine Mana Flask 4 | Eternal Mana Flask 5 | Hallowed Hybrid Flask 6 | Quicksilver Flask 7 | Bismuth Flask 8 | Amethyst Flask 9 | Ruby Flask 10 | Sapphire Flask 11 | Topaz Flask 12 | Aquamarine Flask 13 | Diamond Flask 14 | Granite Flask 15 | Jade Flask 16 | Quartz Flask 17 | Sulphur Flask 18 | Basalt Flask 19 | Silver Flask 20 | Stibnite Flask -------------------------------------------------------------------------------- /Resources/splinter_base_types.txt: -------------------------------------------------------------------------------- 1 | Splinter of Xoph 2 | Splinter of Tul 3 | Splinter of Esh 4 | Splinter of Uul-Netol 5 | Splinter of Chayula 6 | Timeless Karui Splinter 7 | Timeless Eternal Empire Splinter 8 | Timeless Vaal Splinter 9 | Timeless Templar Splinter 10 | Timeless Maraketh Splinter 11 | Simulacrum Splinter -------------------------------------------------------------------------------- /ahk_include/backend_interface.ahk: -------------------------------------------------------------------------------- 1 | #Include consts.ahk 2 | #Include general_helper.ahk 3 | 4 | kPossiblePythonCommands := ["python", "python3", "py"] 5 | 6 | ; Runs the given command in a hidden window, waits for it to complete, 7 | ; and then returns the received exit code: 0 = success, nonzero = failure. 8 | RunCommand(command_string) { 9 | ; ComSpec contains the path to the command-line exe 10 | ; The "/c" parameter tells cmd to run the command, then exit (otherwise it would never exit) 11 | RunWait, % ComSpec " /c " command_string, , Hide UseErrorLevel ; ErrorLevel now contains exit code 12 | return ErrorLevel 13 | } 14 | 15 | GetPythonCommand() { 16 | static python_command := "" 17 | global kPossiblePythonCommands, kCacheDirectory 18 | if (python_command != "") { 19 | return python_command 20 | } 21 | python_version_output_path := kCacheDirectory "\python_version_output.txt" 22 | for _, possible_python_command in kPossiblePythonCommands { 23 | command_string := possible_python_command " --version > " python_version_output_path 24 | exit_code := RunCommand(command_string) 25 | if (exit_code != 0) { 26 | continue 27 | } 28 | FileRead, python_version_output, %python_version_output_path% 29 | FileDelete, %python_version_output_path% 30 | if (StartsWith(python_version_output, "Python 3.")) { 31 | python_command := possible_python_command 32 | return python_command 33 | } 34 | } 35 | MsgBox, % "Error: The commands " Repr(kPossiblePythonCommands) " were all unable to launch Python 3." 36 | . " Either Python 3 is not installed, Python 3 is not in the path, or the commands are aliased to other programs." 37 | ExitApp 38 | } 39 | 40 | RunBackendCliFunction(function_call_string) { 41 | global kBackendCliPath, kBackendCliLogPath 42 | FileDelete, %kBackendCliLogPath% 43 | command_string := GetPythonCommand() " " kBackendCliPath " " function_call_string 44 | RunWait, %command_string%, , Hide UseErrorLevel 45 | exit_code := ErrorLevel ; other commands may modify ErrorLevel at any time 46 | ; Handle nonzero exit code 47 | if (exit_code != 0) { 48 | FileRead, error_log, %kBackendCliLogPath% 49 | DebugMessage(Traceback() "`n`nError running command: " command_string "`n`nbackend_cli log:`n" error_log) 50 | } 51 | return exit_code 52 | } 53 | 54 | AddFunctionCallToBatch(function_call_string) { 55 | global kBackendCliInputPath 56 | FileAppend, %function_call_string%`n, %kBackendCliInputPath% 57 | } 58 | 59 | ; Parses output_lines starting at the given line_index until next "@" line is reached. 60 | ; After function returns, line_index will point to the line *after* the terminating "@". 61 | ; Returns dict of (key, value) pairs formed by splitting lines on the separator. 62 | ; If the separator does not exist in a given line, the full line will be the key, and default_value the value. 63 | ParseBackendOutputLinesAsDict(output_lines, ByRef line_index:=1, separator=";", default_value:=True) { 64 | parsed_lines_dict := {} 65 | while ((line_index <= Length(output_lines)) and (output_lines[line_index] != "@")) { 66 | if (output_lines[line_index] != "") { 67 | if (InStr(output_lines[line_index], separator)) { 68 | split_result := StrSplit(output_lines[line_index], separator) 69 | parsed_lines_dict[split_result[1]] := split_result[2] 70 | } else { 71 | parsed_lines_dict[output_lines[line_index]] := default_value 72 | } 73 | } 74 | line_index += 1 75 | } 76 | line_index += 1 77 | return parsed_lines_dict 78 | } 79 | 80 | ; Queries the backend_cli for all filter data corresponding to the given profile. 81 | ; Returns a dict of keywords to corresponding data structures (or empty dict if failed): 82 | ; - "currency_to_tier_dict" -> dict: {base_type -> tier} 83 | ; - "currency_tier_min_visible_stack_sizes" -> dict {tier -> stack_size} 84 | ; - tier may be "tportal", "twisdom", stack_size may be "hide_all" 85 | ; - "splinter_min_visible_stack_sizes" -> dict: {base_type -> stack_size} 86 | ; - "chaos_recipe_statuses" -> dict: {item_slot -> enabled_flag} 87 | ; - "hide_maps_below_tier" -> tier 88 | ; - "hide_essences_above_tier" -> tier 89 | ; - "hide_div_cards_above_tier" -> tier 90 | ; - "hide_unique_items_above_tier" -> tier 91 | ; - "hide_unique_maps_above_tier" -> tier 92 | ; - "lowest_visible_oil" -> oil_name 93 | ; - "visible_basetypes" -> dict: {base_type;rare_only_flag;min_ilvl;max_ilvl -> True} (because I don't see a builtin set type) 94 | ; - "visible_flasks" -> dict: {base_type -> high_ilvl_only_flag} 95 | ; - "socket_rules" -> dict: {socket_string + ";" + item_slot -> True} (because I don't see a builtin set type) 96 | ; - "gem_min_quality" -> gem_min_quality 97 | ; - "flask_min_quality" -> flask_min_quality 98 | ; - "rgb_item_max_size" -> rgb_item_max_size 99 | ; 100 | ; Note: function below adds one more: "hotkeys" -> array: {;} 101 | QueryAllFilterData(profile) { 102 | success_flag := True 103 | ; Build backend_cli input query 104 | global kBackendCliInputPath, kBackendCliOutputPath, kNumCurrencyTiers 105 | FileDelete, %kBackendCliInputPath% 106 | AddFunctionCallToBatch("get_all_currency_tiers") 107 | AddFunctionCallToBatch("get_all_currency_tier_min_visible_stack_sizes") 108 | AddFunctionCallToBatch("get_all_splinter_min_visible_stack_sizes") 109 | AddFunctionCallToBatch("get_all_chaos_recipe_statuses") 110 | AddFunctionCallToBatch("get_hide_maps_below_tier") 111 | AddFunctionCallToBatch("get_hide_essences_above_tier") 112 | AddFunctionCallToBatch("get_hide_div_cards_above_tier") 113 | AddFunctionCallToBatch("get_hide_unique_items_above_tier") 114 | AddFunctionCallToBatch("get_hide_unique_maps_above_tier") 115 | AddFunctionCallToBatch("get_lowest_visible_oil") 116 | AddFunctionCallToBatch("get_all_visible_basetypes") 117 | AddFunctionCallToBatch("get_all_visible_flasks") 118 | AddFunctionCallToBatch("get_all_added_socket_rules") 119 | AddFunctionCallToBatch("get_gem_min_quality") 120 | AddFunctionCallToBatch("get_flask_min_quality") 121 | AddFunctionCallToBatch("get_rgb_item_max_size") 122 | ; Call run_batch 123 | exit_code := RunBackendCliFunction("run_batch " Quoted(profile)) 124 | if (exit_code != 0) { 125 | ExitApp 126 | } 127 | ; Parse backend_cli output 128 | output_lines := ReadFileLines(kBackendCliOutputPath) 129 | filter_data := {} 130 | line_index := 1 131 | filter_data["currency_to_tier_dict"] := ParseBackendOutputLinesAsDict(output_lines, line_index) 132 | filter_data["currency_tier_min_visible_stack_sizes"] := ParseBackendOutputLinesAsDict(output_lines, line_index) 133 | filter_data["splinter_min_visible_stack_sizes"] := ParseBackendOutputLinesAsDict(output_lines, line_index) 134 | filter_data["chaos_recipe_statuses"] := ParseBackendOutputLinesAsDict(output_lines, line_index) 135 | ; Various item tiering 136 | filter_data["hide_maps_below_tier"] := output_lines[line_index] 137 | line_index += 2 138 | filter_data["hide_essences_above_tier"] := output_lines[line_index] 139 | line_index += 2 140 | filter_data["hide_div_cards_above_tier"] := output_lines[line_index] 141 | line_index += 2 142 | filter_data["hide_unique_items_above_tier"] := output_lines[line_index] 143 | line_index += 2 144 | filter_data["hide_unique_maps_above_tier"] := output_lines[line_index] 145 | line_index += 2 146 | filter_data["lowest_visible_oil"] := output_lines[line_index] 147 | line_index += 2 148 | ; BaseTypes: use a separator we know doesn't exist to make full line the key 149 | filter_data["visible_basetypes"] := ParseBackendOutputLinesAsDict(output_lines, line_index, separator:="*") 150 | ; Flasks BaseTypes 151 | filter_data["visible_flasks"] := ParseBackendOutputLinesAsDict(output_lines, line_index) 152 | ; Socket patterns: use a separator we know doesn't exist to make full line the key 153 | filter_data["socket_rules"] := ParseBackendOutputLinesAsDict(output_lines, line_index, separator:="*") 154 | ; Quality thresholds 155 | filter_data["gem_min_quality"] := output_lines[line_index] 156 | line_index += 2 157 | filter_data["flask_min_quality"] := output_lines[line_index] 158 | line_index += 2 159 | filter_data["rgb_item_max_size"] := output_lines[line_index] 160 | line_index += 2 161 | return filter_data 162 | } 163 | 164 | QueryHotkeys(ByRef ui_data_dict) { 165 | global kBackendCliOutputPath 166 | ; Outputs a sequence of lines, each formatted as ; 167 | RunBackendCliFunction("get_all_hotkeys") 168 | output_lines := ReadFileLines(kBackendCliOutputPath) 169 | ui_data_dict["hotkeys"] := output_lines 170 | } 171 | 172 | ParseInfoMessages() { 173 | global kBackendCliInfoPath 174 | backend_cli_info_lines := ReadFileLines(kBackendCliInfoPath) 175 | if (Length(backend_cli_info_lines) > 0) { 176 | backend_cli_info_lines := " - " Join(backend_cli_info_lines, "`n - ") 177 | } 178 | return backend_cli_info_lines 179 | } 180 | -------------------------------------------------------------------------------- /ahk_include/consts.ahk: -------------------------------------------------------------------------------- 1 | #Include general_helper.ahk ; ReadFileLines 2 | 3 | kDlfIconPath := "DLF_icon.ico" 4 | 5 | ; Directories (must be synced with consts.py) 6 | kBackendDirectory := "backend" 7 | kCacheDirectory := "cache" 8 | kResourcesDirectory := "resources" 9 | 10 | ; Paths for backend cli target, input, and output 11 | kBackendCliPath := kBackendDirectory "\backend_cli.py" 12 | kBackendCliInputPath := kCacheDirectory "\backend_cli.input" 13 | kBackendCliOutputPath := kCacheDirectory "\backend_cli.output" 14 | kBackendCliInfoPath := kCacheDirectory "\backend_cli.info" 15 | kBackendCliLogPath := kCacheDirectory "\backend_cli.log" 16 | 17 | ; Currency 18 | kNumCurrencyTiers := 9 19 | 20 | ; Chaos Recipe (also used for Socket Patterns item slots) 21 | kChaosRecipeItemSlots := ["Weapons", "Body Armours", "Helmets", "Gloves", "Boots", "Belts", "Rings", "Amulets"] 22 | 23 | ; Flask BaseTypes 24 | kFlaskBaseTypesPath := kResourcesDirectory "\flask_base_types.txt" 25 | kFlaskBaseTypesList := ReadFileLines(kFlaskBaseTypesPath) 26 | ; Splinter BaseTypes 27 | kSplinterBaseTypesPath := kResourcesDirectory "\splinter_base_types.txt" 28 | kSplinterBaseTypesList := ReadFileLines(kSplinterBaseTypesPath) 29 | 30 | ; Oil names, ordered from highest to lowest value 31 | kOilBaseTypes := ["Tainted Oil", "Golden Oil", "Silver Oil", "Opalescent Oil", "Black Oil", "Crimson Oil", "Violet Oil", "Indigo Oil" 32 | , "Azure Oil", "Teal Oil", "Verdant Oil", "Amber Oil", "Sepia Oil", "Clear Oil"] -------------------------------------------------------------------------------- /ahk_include/general_helper.ahk: -------------------------------------------------------------------------------- 1 | #Include language_util.ahk 2 | 3 | ; ========================== Generic Util ========================== 4 | 5 | DebugMessage(message) { 6 | MsgBox, , Debug Message, %message% 7 | } 8 | 9 | ; Returns an array of consecutive integers from start to end (inclusive) 10 | ; If only one parameter is passed, yields the range from 1 to given value. 11 | RangeArray(start_or_end, optional_end:="") { 12 | start := start_or_end, end_inclusive := optional_end 13 | if (optional_end == "") { 14 | end_inclusive := start_or_end 15 | start := 1 16 | } 17 | result_list := [] 18 | Loop % end_inclusive - start + 1 { 19 | result_list.push(start + A_Index - 1) ; A_Index starts at 1 20 | } 21 | return result_list 22 | } 23 | 24 | ; ========================== String Util ========================== 25 | 26 | ; AHK's object.Length() function is broken - in some scenarios it 27 | ; returns 0 when the container has many elements. (I think it has 28 | ; to do with nested containers.) 29 | Length(container) { 30 | n := 0 31 | for key, value in container { 32 | n += 1 33 | } 34 | return n 35 | } 36 | 37 | ; Returns the concatenation of two arrays 38 | Concatenated(left_array, right_array) { 39 | concatenated_array := left_array 40 | for _, value in right_array { 41 | concatenated_array.push(value) 42 | } 43 | return concatenated_array 44 | } 45 | 46 | ; Accepts either an array or a dict. Returns the key associated with target_value if found. 47 | ; To get the index of a value in a dict, use return_index:=True. (AHK dicts are actually ordered.) 48 | Find(target_value, container, return_index:=False) { 49 | index := 1 50 | for key, value in container { 51 | if (value == target_value) { 52 | return return_index ? index : key 53 | } 54 | } 55 | DebugMessage(Traceback() "`n`nError: " Repr(target_value) " not found in container " Repr(container)) 56 | } 57 | 58 | Quoted(s) { 59 | quote := """" 60 | return quote s quote 61 | } 62 | 63 | StartsWith(s, prefix) { 64 | return (SubStr(s, 1, StrLen(prefix)) == prefix) 65 | } 66 | 67 | ; Returns values in the contained joined by delimeter, or keys joined by 68 | ; delimiter if join_keys is True. 69 | Join(array_or_dict, delimeter, join_keys:=False) { 70 | result_string := "" 71 | index := 1 72 | for key, value in array_or_dict { 73 | if (index > 1) { 74 | result_string .= delimeter 75 | } 76 | result_string .= join_keys ? key : value 77 | index += 1 78 | } 79 | return result_string 80 | } 81 | 82 | RemovedSpaces(s) { 83 | s := StrReplace(s, " ") 84 | return s 85 | } 86 | 87 | ; ========================== File Util ========================== 88 | 89 | ; Returns an array of strings containing the lines in the file (newlines removed) 90 | ReadFileLines(filepath) { 91 | FileRead, file_contents, %filepath% 92 | lines := StrSplit(file_contents, "`n") 93 | for i, line in lines { 94 | lines[i] := Trim(line, OmitChars:=" `t`r`n") 95 | } 96 | return lines 97 | } 98 | -------------------------------------------------------------------------------- /ahk_include/gui_helper.ahk: -------------------------------------------------------------------------------- 1 | ; Returns an id that can be used in GuiControl commands: 2 | ; - strips leading "v" off variable names 3 | ; - strips leading "hwnd" off hwnd ids, and applies one level of variable name "dereferencing" 4 | ; Displays a warning if a control with the returned ID does not exist. 5 | NormalizedId(hwnd_or_v_id) { 6 | StringLower normalized_id, hwnd_or_v_id 7 | if (StartsWith(normalized_id, "v")) { 8 | normalized_id := SubStr(normalized_id, 2) ; trim starting "v" 9 | } else if (StartsWith(normalized_id, "hwnd")) { 10 | normalized_id := SubStr(normalized_id, 5) ; trim starting "hwnd") 11 | normalized_id := %normalized_id% ; "dereference" one level of variable name wrapping 12 | } 13 | ; Check corresponding control exists 14 | GuiControlGet, _, , %normalized_id% 15 | if (ErrorLevel != 0) { 16 | DebugMessage("Warning: could not find control with id: " hwnd_or_v_id) 17 | } 18 | return normalized_id 19 | } 20 | 21 | ; Takes either a GUI element variable ("vMyGuiElement") or an HWND id ("HWNDhMyGuiElement"), 22 | ; and calls GuiGetControl with the appropriate syntax. 23 | ; Convention for AltSubmit: assumes AltSubmit is always initially off, and always ensures AltSubmit is 24 | ; off after this function call. (Can't always set AltSubmit off, some control types don't allow this option) 25 | GuiControlGetHelper(id, get_index:=False) { 26 | normalized_id := NormalizedId(id) 27 | if (get_index) { 28 | GuiControl, +AltSubmit, %normalized_id% 29 | } 30 | GuiControlGet, output_value, , %normalized_id% 31 | if (get_index) { 32 | GuiControl, -AltSubmit, %normalized_id% 33 | } 34 | return output_value 35 | } 36 | 37 | ; * I'm not sure which of the below functions require an hwnd_id, and which can 38 | ; be used with a v-Variable name. When in doubt, I named the parameter hwnd_id. 39 | 40 | GuiControlAddItem(hwnd_id, new_item_text) { 41 | normalized_id := NormalizedId(hwnd_id) 42 | GuiControl, , %normalized_id%, %new_item_text% 43 | } 44 | 45 | ; Note: this may only work for ListBox and ComboBoxes 46 | GuiControlRemoveItemAtIndex(hwnd_id, index) { 47 | normalized_id := NormalizedId(hwnd_id) 48 | ahk_id_string := "ahk_id " normalized_id 49 | Control, Delete, %index%, , %ahk_id_string% 50 | } 51 | 52 | ; Note: this may only work for ListBox and ComboBoxes 53 | ; Use ControlGet (Not GuiControlGet) to get full text of a ListBox, separated by "`n" 54 | GuiControlRemoveItem(hwnd_id, item) { 55 | normalized_id := NormalizedId(hwnd_id) 56 | ahk_id_string := "ahk_id " normalized_id 57 | ControlGet, all_items_text, List, , , %ahk_id_string% 58 | items_list := StrSplit(all_items_text, "`n") 59 | for index, current_item in items_list { 60 | if (current_item == item) { 61 | GuiControlRemoveItemAtIndex(hwnd_id, index) 62 | return 63 | } 64 | } 65 | } 66 | 67 | ; Note: this may only work for ListBox and ComboBoxes 68 | GuiControlRemoveSelectedItem(hwnd_id) { 69 | normalized_id := NormalizedId(hwnd_id) 70 | selected_item_index := GuiControlGetHelper(normalized_id, get_index:=True) 71 | GuiControlRemoveItemAtIndex(hwnd_id, selected_item_index) 72 | } 73 | 74 | ; Does *not* trigger associated gLabels 75 | GuiControlSelectItem(hwnd_id, item_text) { 76 | normalized_id := NormalizedId(hwnd_id) 77 | GuiControl, ChooseString, %normalized_id%, %item_text% 78 | } 79 | 80 | ; Does *not* trigger associated gLabels 81 | GuiControlSelectIndex(hwnd_id, index) { 82 | normalized_id := NormalizedId(hwnd_id) 83 | GuiControl, Choose, %normalized_id%, %index% 84 | } 85 | 86 | ; Does *not* trigger associated gLabels 87 | GuiControlDeselectAll(hwnd_id) { 88 | normalized_id := NormalizedId(hwnd_id) 89 | GuiControl, Choose, %normalized_id%, 0 90 | } 91 | 92 | GuiControlSetText(id, text) { 93 | normalized_id := NormalizedId(id) 94 | GuiControl, , %normalized_id%, %text% 95 | 96 | } 97 | 98 | GuiControlClear(id) { 99 | GuiControlSetText(id, "") 100 | } 101 | -------------------------------------------------------------------------------- /ahk_include/gui_placeholder.ahk: -------------------------------------------------------------------------------- 1 | ; Source: https://www.autohotkey.com/board/topic/72535-fnl-placeholder-placeholder-text-for-edit-controls/ 2 | ; 3 | ; Placeholder() - by infogulch for AutoHotkey v1.1.05+ 4 | ; 5 | ; to set up your edit control with a placeholder, call: 6 | ; Placeholder(hwnd_of_edit_control, "your placeholder text") 7 | ; 8 | ; If called with only the hwnd, the function returns True if a 9 | ; placeholder is being shown, and False if not. 10 | ; isPlc := Placeholder(hwnd_edit) 11 | ; 12 | ; to remove the placeholder call with a blank text param 13 | ; Placeholder(hwnd_edit, "") 14 | ; 15 | ; http://www.autohotkey.com/forum/viewtopic.php?p=482903#482903 16 | ; 17 | 18 | ; Example usage: 19 | ; Gui, Add, Edit, w300 hwndhEdit 20 | ; Gui, Add, Edit, wp r4 hwndhEdit2 21 | ; Placeholder(hEdit, "Enter some text") 22 | ; Placeholder(hEdit2, "Another text entry point") 23 | 24 | Placeholder(wParam, lParam = "`r", msg = "") { 25 | static init := OnMessage(0x111, "Placeholder"), list := [] 26 | 27 | if (msg != 0x111) { 28 | if (lParam == "`r") 29 | return list[wParam].shown 30 | list[wParam] := { placeholder: lParam, shown: false } 31 | if (lParam == "") 32 | return "", list.remove(wParam, "") 33 | lParam := wParam 34 | wParam := 0x200 << 16 35 | } 36 | if (wParam >> 16 == 0x200) && list.HasKey(lParam) && !list[lParam].shown ;EN_KILLFOCUS := 0x200 37 | { 38 | GuiControlGet, text, , %lParam% 39 | if (text == "") 40 | { 41 | ; Font line modified from original source to match UI 42 | Gui, Font, Ca0a0a0 s11 Norm, Segoe UI 43 | GuiControl, Font, %lParam% 44 | GuiControl, , %lParam%, % list[lParam].placeholder 45 | list[lParam].shown := true 46 | } 47 | } 48 | else if (wParam >> 16 == 0x100) && list.HasKey(lParam) && list[lParam].shown ;EN_SETFOCUS := 0x100 49 | { 50 | Gui Font, c0xe3fff9 s11 Norm, Segoe UI ; Modified from original source to match UI 51 | GuiControl, Font, %lParam% 52 | GuiControl, , %lParam% 53 | list[lParam].shown := false 54 | } 55 | } -------------------------------------------------------------------------------- /ahk_include/helper.ahk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apollys/PoEDynamicLootFilter/d819a92cc78b728b12eb59b6811c3b17b370a194/ahk_include/helper.ahk -------------------------------------------------------------------------------- /ahk_include/language_util.ahk: -------------------------------------------------------------------------------- 1 | /* 2 | * Defines general functionality related to types, tracebacks, and debugging. 3 | * - Defines functions None() and IsNone() to work with objects of type "none" 4 | * - Defines a function Type(var), which returns one of "integer", "float", "string", "object", "none" 5 | * - Defines a function Repr(var), which returns a python-style string representation of var 6 | * - Defines a function Traceback(), which returns a traceback message string 7 | * - Defines a function CheckType(value, required_type, allow_empty_string:=False), 8 | * which gives an error if value is not of requried type. 9 | * Note: integer counts as a float or a string, and float counts as a string. 10 | */ 11 | 12 | ; Ideally this would be a static class variable 13 | global kNoneObject := {} 14 | 15 | ; Get the Singleton "None" object 16 | None() { 17 | global kNoneObject 18 | return kNoneObject 19 | } 20 | 21 | ; Check if a value is None 22 | IsNone(value) { 23 | global kNoneObject 24 | return (value == kNoneObject) 25 | } 26 | 27 | ; Returns one of "integer", "float", "string", "object", or "none". 28 | ; Note that "arrays" are just syntactic sugar for objects. 29 | ; Also, note that an empty variable has type "string". Only the None object defined above has type "none". 30 | Type(value) { 31 | if (IsObject(value)) { 32 | return IsNone(value) ? "none" : "object" 33 | } 34 | ; Legacy syntax is required here 35 | if value is integer 36 | return "integer" 37 | else if value is float 38 | return "float" 39 | return "string" 40 | } 41 | 42 | ; Returns the AHK expression string representing the given value. 43 | ; Note that "arrays" are just syntactic sugar for objects, so curly braces will be used. 44 | Repr(value) { 45 | switch (Type(value)) { 46 | case "integer": 47 | return value 48 | case "float": 49 | return value 50 | case "string": 51 | return """" value """" 52 | case "none": 53 | return "{None}" 54 | } 55 | ; If it is none of the above, var is an object 56 | num_keys := 0 57 | repr_string := "{" 58 | for key, val in value { 59 | num_keys += 1 60 | if (num_keys > 1) { 61 | repr_string .= ", " 62 | } 63 | repr_string .= Repr(key) ": " Repr(val) 64 | } 65 | repr_string .= "}" 66 | return repr_string 67 | } 68 | 69 | ; Modified significantly from: https://www.autohotkey.com/boards/viewtopic.php?t=6001 70 | Traceback(levels_to_skip:=1) { 71 | traceback_array := [] 72 | Loop { 73 | ; offset: [...] or a negative offset from the top of the call stack 74 | offset := -(A_Index + levels_to_skip) 75 | e := Exception("", offset) 76 | if (e.What == offset) { 77 | break 78 | } 79 | ; e has fields: What (name of function being called), File, Line 80 | ; (e also has Message and Extra, but they seem to usually be empty) 81 | traceback_array.push(e) 82 | } 83 | traceback_message := "Traceback (most recent call last):" 84 | for i, e in traceback_array { 85 | SplitPath, % e.File, filename 86 | caller := (i < traceback_array.Length()) ? traceback_array[i + 1].What : "" 87 | traceback_message .= "`n file " filename ", in " caller ", line " e.Line ": " e.What "(...)" 88 | } 89 | return traceback_message 90 | } 91 | 92 | ; Note: integer counts as a float or a string, and float counts as a string. 93 | CheckType(value, required_type, allow_empty_string:=False) { 94 | value_type := Type(value) 95 | if ((value_type == "integer") and ((required_type == "float") or (required_type == "string"))) { 96 | return ; Okay - integer counts as float or string 97 | } else if ((value_type == "float") and (required_type == "string")) { 98 | return ; Okay - float counts as string 99 | } else if (value_type != required_type) { 100 | error_message := Traceback() 101 | error_message .= "`n`nError: required type <" required_type ">, but recieved: " Repr(value) " <" Type(value) ">" 102 | MsgBox, , CheckType Error, %error_message% 103 | ExitApp 104 | } else if ((value == "") and not allow_empty_string) { 105 | error_message := Traceback() 106 | error_message .= "`n`nError: nonempty string required, but recieved empty string" 107 | MsgBox, , CheckType Error, %error_message% 108 | ExitApp 109 | } 110 | } 111 | 112 | Test() { 113 | MsgBox % Repr(["hello", "goodbye", "fox"]) 114 | 115 | value := "Hello" 116 | ; CheckType(value, "object") ; should fail 117 | ; CheckType(nonexistent_variable, "string") ; should fail 118 | CheckType({}, "object") ; passes 119 | CheckType(5, "integer") ; passes 120 | CheckType(5, "float") ; passes 121 | CheckType(5, "string") ; passes 122 | CheckType(5.2, "float") ; passes 123 | CheckType(5.3, "string") ; passes 124 | ; CheckType("three", "integer") ; fails 125 | CheckType(None(), "none") 126 | ; CheckType("", "none") ; should fail 127 | } 128 | 129 | ; Test() -------------------------------------------------------------------------------- /ahk_include/poe_helper.ahk: -------------------------------------------------------------------------------- 1 | #Include general_helper.ahk 2 | 3 | ; Returns the HWND ID of the active window if it is owned by the PoE process, 4 | ; or 0 if it does not. Thus, the return value can be used in boolean contex 5 | ; to determine if PoE is the currently active window. 6 | IsPoeActive() { 7 | return (WinActive("ahk_exe PathOfExile.exe") or WinActive("ahk_exe PathOfExileSteam.exe")) 8 | } 9 | 10 | MakePoeActive() { 11 | if (WinExist("ahk_exe PathOfExileSteam.exe")) 12 | WinActivate, ahk_exe PathOfExileSteam.exe 13 | else 14 | WinActivate, ahk_exe PathOfExile.exe 15 | } 16 | 17 | IsDlfActive() { 18 | global kWindowTitle 19 | return WinActive(kWindowTitle " ahk_exe AutoHotkey.exe") != 0 20 | } 21 | 22 | MakeDlfActive() { 23 | global kWindowTitle 24 | WinActivate, %kWindowTitle% ahk_exe AutoHotkey.exe 25 | } 26 | 27 | ; Used for SendChatMessage below 28 | RestoreClipboard() { 29 | global backup_clipboard 30 | clipboard := backup_clipboard 31 | } 32 | 33 | ; Sends a chat message by saving it to the clipboard and sending: Enter, Ctrl-v, Enter 34 | ; Backs up the clipboard and restores it after a short delay on a separate thread. 35 | ; (The clipboard cannot be restored immediately, or it will happen before the paste operation occurs.) 36 | SendChatMessage(message_text, reply_flag := false) { 37 | global backup_clipboard 38 | backup_clipboard := clipboard 39 | command_flag := StartsWith(message_text, "/") 40 | clipboard := command_flag ? SubStr(message_text, 2) : message_text 41 | BlockInput, On 42 | if (command_flag) { 43 | Send {Enter}/^v{Enter} 44 | } else if (reply_flag) { 45 | Send, ^{Enter}^v{Enter} 46 | } else { 47 | Send, {Enter}^v{Enter} 48 | } 49 | BlockInput, Off 50 | SetTimer, RestoreClipboard, -50 51 | } -------------------------------------------------------------------------------- /ahk_include/two_way_dict.ahk: -------------------------------------------------------------------------------- 1 | Class TwoWayDict { 2 | forward_dict := {} 3 | reverse_dict := {} 4 | 5 | __New(input_dict_or_array) { 6 | this.forward_dict := input_dict_or_array 7 | for key, value in input_dict_or_array { 8 | this.reverse_dict[value] := key 9 | } 10 | } 11 | 12 | GetValue(key) { 13 | return this.forward_dict[key] 14 | } 15 | 16 | GetKey(value) { 17 | return this.reverse_dict[value] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/backend_cli_function_info.py: -------------------------------------------------------------------------------- 1 | # Map of function name -> dictionary of properties indexed by the following string keywords: 2 | # - NumParamsOptions: List[int] (excludes function name and profile param) 3 | # - HasProfileParam: bool 4 | # - ModifiesFilter: bool 5 | # - NumParamsForMatch: int, only used for functions that modify the filter. It 6 | # Tells how many parameters need to be the same for two functions of this name to be 7 | # reducible to a single function in the profile changes file. For example: 8 | # > adjust_currency_tier "Chromatic Orb" +1 9 | # > adjust_currency_tier "Chromatic Orb" +1 10 | # is reducible to 11 | # > adjust_currency_tier "Chromatic Orb" +2 12 | # so this value would be 1 for 'adjust_currency_tier'. 13 | kFunctionInfoMap = { 14 | 'is_first_launch' : { 15 | 'NumParamsOptions' : [0], 16 | 'HasProfileParam' : False, 17 | 'ModifiesFilter' : False, 18 | }, 19 | # General config 20 | 'set_hotkey' : { 21 | 'NumParamsOptions' : [2], 22 | 'HasProfileParam' : False, 23 | 'ModifiesFilter' : False, 24 | }, 25 | 'get_all_hotkeys' : { 26 | 'NumParamsOptions' : [0], 27 | 'HasProfileParam' : False, 28 | 'ModifiesFilter' : False, 29 | }, 30 | # Profiles 31 | 'get_all_profile_names' : { 32 | 'NumParamsOptions' : [0], 33 | 'HasProfileParam' : False, 34 | 'ModifiesFilter' : False, 35 | }, 36 | 'create_new_profile' : { 37 | 'NumParamsOptions' : [1], 38 | 'HasProfileParam' : False, 39 | 'ModifiesFilter' : False, 40 | }, 41 | 'rename_profile' : { 42 | 'NumParamsOptions' : [2], 43 | 'HasProfileParam' : False, 44 | 'ModifiesFilter' : False, 45 | }, 46 | 'delete_profile' : { 47 | 'NumParamsOptions' : [1], 48 | 'HasProfileParam' : False, 49 | 'ModifiesFilter' : False, 50 | }, 51 | 'set_active_profile' : { 52 | 'NumParamsOptions' : [1], 53 | 'HasProfileParam' : False, 54 | 'ModifiesFilter' : False, 55 | }, 56 | # Check Filters Exist 57 | 'check_filters_exist' : { 58 | 'NumParamsOptions' : [1], 59 | 'HasProfileParam' : False, 60 | 'ModifiesFilter' : False, 61 | }, 62 | # Import and Reload 63 | # These are *not* considered as mutator functions, 64 | # because they do not contribute to Profile.changes. 65 | 'import_downloaded_filter' : { 66 | 'NumParamsOptions' : [0], 67 | 'HasProfileParam' : True, 68 | 'ModifiesFilter' : False, 69 | }, 70 | 'load_input_filter' : { 71 | 'NumParamsOptions' : [0], 72 | 'HasProfileParam' : True, 73 | 'ModifiesFilter' : False, 74 | }, 75 | # Miscellaneous 76 | 'run_batch' : { 77 | 'NumParamsOptions' : [0], 78 | 'HasProfileParam' : True, 79 | 'ModifiesFilter' : False, 80 | }, 81 | 'get_rule_matching_item' : { 82 | 'NumParamsOptions' : [0], 83 | 'HasProfileParam' : True, 84 | 'ModifiesFilter' : False, 85 | }, 86 | 'set_rule_visibility' : { 87 | 'NumParamsOptions' : [3], 88 | 'HasProfileParam' : True, 89 | 'ModifiesFilter' : True, 90 | 'NumParamsForMatch' : 2, 91 | }, 92 | # Currency 93 | 'set_currency_to_tier' : { 94 | 'NumParamsOptions' : [2], 95 | 'HasProfileParam' : True, 96 | 'ModifiesFilter' : True, 97 | 'NumParamsForMatch' : 1, 98 | }, 99 | 'get_tier_of_currency' : { 100 | 'NumParamsOptions' : [1], 101 | 'HasProfileParam' : True, 102 | 'ModifiesFilter' : False, 103 | }, 104 | 'get_all_currency_tiers' : { 105 | 'NumParamsOptions' : [0], 106 | 'HasProfileParam' : True, 107 | 'ModifiesFilter' : False, 108 | }, 109 | 'set_currency_tier_min_visible_stack_size' : { 110 | 'NumParamsOptions' : [2], 111 | 'HasProfileParam' : True, 112 | 'ModifiesFilter' : True, 113 | 'NumParamsForMatch' : 1, 114 | }, 115 | 'get_currency_tier_min_visible_stack_size' : { 116 | 'NumParamsOptions' : [1], 117 | 'HasProfileParam' : True, 118 | 'ModifiesFilter' : False, 119 | }, 120 | 'get_all_currency_tier_min_visible_stack_sizes' : { 121 | 'NumParamsOptions' : [0], 122 | 'HasProfileParam' : True, 123 | 'ModifiesFilter' : False, 124 | }, 125 | # Splinters 126 | 'set_splinter_min_visible_stack_size' : { 127 | 'NumParamsOptions' : [2], 128 | 'HasProfileParam' : True, 129 | 'ModifiesFilter' : True, 130 | 'NumParamsForMatch' : 1, 131 | }, 132 | 'get_splinter_min_visible_stack_size' : { 133 | 'NumParamsOptions' : [1], 134 | 'HasProfileParam' : True, 135 | 'ModifiesFilter' : False, 136 | }, 137 | 'get_all_splinter_min_visible_stack_sizes' : { 138 | 'NumParamsOptions' : [0], 139 | 'HasProfileParam' : True, 140 | 'ModifiesFilter' : False, 141 | }, 142 | # Essences 143 | 'get_all_essence_tier_visibilities' : { 144 | 'NumParamsOptions' : [0], 145 | 'HasProfileParam' : True, 146 | 'ModifiesFilter' : False, 147 | }, 148 | 'set_hide_essences_above_tier' : { 149 | 'NumParamsOptions' : [1], 150 | 'HasProfileParam' : True, 151 | 'ModifiesFilter' : True, 152 | 'NumParamsForMatch' : 0, 153 | }, 154 | 'get_hide_essences_above_tier' : { 155 | 'NumParamsOptions' : [0], 156 | 'HasProfileParam' : True, 157 | 'ModifiesFilter' : False, 158 | }, 159 | # Divination Cards 160 | 'get_all_div_card_tier_visibilities' : { 161 | 'NumParamsOptions' : [0], 162 | 'HasProfileParam' : True, 163 | 'ModifiesFilter' : False, 164 | }, 165 | 'set_hide_div_cards_above_tier' : { 166 | 'NumParamsOptions' : [1], 167 | 'HasProfileParam' : True, 168 | 'ModifiesFilter' : True, 169 | 'NumParamsForMatch' : 0, 170 | }, 171 | 'get_hide_div_cards_above_tier' : { 172 | 'NumParamsOptions' : [0], 173 | 'HasProfileParam' : True, 174 | 'ModifiesFilter' : False, 175 | }, 176 | # Unique Items 177 | 'get_all_unique_item_tier_visibilities' : { 178 | 'NumParamsOptions' : [0], 179 | 'HasProfileParam' : True, 180 | 'ModifiesFilter' : False, 181 | }, 182 | 'set_hide_unique_items_above_tier' : { 183 | 'NumParamsOptions' : [1], 184 | 'HasProfileParam' : True, 185 | 'ModifiesFilter' : True, 186 | 'NumParamsForMatch' : 0, 187 | }, 188 | 'get_hide_unique_items_above_tier' : { 189 | 'NumParamsOptions' : [0], 190 | 'HasProfileParam' : True, 191 | 'ModifiesFilter' : False, 192 | }, 193 | # Unique Maps 194 | 'get_all_unique_map_tier_visibilities' : { 195 | 'NumParamsOptions' : [0], 196 | 'HasProfileParam' : True, 197 | 'ModifiesFilter' : False, 198 | }, 199 | 'set_hide_unique_maps_above_tier' : { 200 | 'NumParamsOptions' : [1], 201 | 'HasProfileParam' : True, 202 | 'ModifiesFilter' : True, 203 | 'NumParamsForMatch' : 0, 204 | }, 205 | 'get_hide_unique_maps_above_tier' : { 206 | 'NumParamsOptions' : [0], 207 | 'HasProfileParam' : True, 208 | 'ModifiesFilter' : False, 209 | }, 210 | # Oils 211 | 'set_lowest_visible_oil' : { 212 | 'NumParamsOptions' : [1], 213 | 'HasProfileParam' : True, 214 | 'ModifiesFilter' : True, 215 | 'NumParamsForMatch' : 0, 216 | }, 217 | 'get_lowest_visible_oil' : { 218 | 'NumParamsOptions' : [0], 219 | 'HasProfileParam' : True, 220 | 'ModifiesFilter' : False, 221 | }, 222 | # Quality Gems 223 | 'set_gem_min_quality' : { 224 | 'NumParamsOptions' : [1], 225 | 'HasProfileParam' : True, 226 | 'ModifiesFilter' : True, 227 | 'NumParamsForMatch' : 0, 228 | }, 229 | 'get_gem_min_quality' : { 230 | 'NumParamsOptions' : [0], 231 | 'HasProfileParam' : True, 232 | 'ModifiesFilter' : False, 233 | }, 234 | # Quality Flasks 235 | 'set_flask_min_quality' : { 236 | 'NumParamsOptions' : [1], 237 | 'HasProfileParam' : True, 238 | 'ModifiesFilter' : True, 239 | 'NumParamsForMatch' : 0, 240 | }, 241 | 'get_flask_min_quality' : { 242 | 'NumParamsOptions' : [0], 243 | 'HasProfileParam' : True, 244 | 'ModifiesFilter' : False, 245 | }, 246 | # Hide Maps Below Tier 247 | 'set_hide_maps_below_tier' : { 248 | 'NumParamsOptions' : [1], 249 | 'HasProfileParam' : True, 250 | 'ModifiesFilter' : True, 251 | 'NumParamsForMatch' : 0, 252 | }, 253 | 'get_hide_maps_below_tier' : { 254 | 'NumParamsOptions' : [0], 255 | 'HasProfileParam' : True, 256 | 'ModifiesFilter' : False, 257 | }, 258 | # Generic BaseTypes 259 | 'show_basetype' : { 260 | 'NumParamsOptions' : [4], 261 | 'HasProfileParam' : True, 262 | 'ModifiesFilter' : True, 263 | 'NumParamsForMatch' : 1, 264 | }, 265 | 'disable_basetype' : { 266 | 'NumParamsOptions' : [4], 267 | 'HasProfileParam' : True, 268 | 'ModifiesFilter' : True, 269 | 'NumParamsForMatch' : 1, 270 | }, 271 | 'get_basetype_visibility' : { 272 | 'NumParamsOptions' : [4], 273 | 'HasProfileParam' : True, 274 | 'ModifiesFilter' : False, 275 | }, 276 | 'get_all_visible_basetypes' : { 277 | 'NumParamsOptions' : [0], 278 | 'HasProfileParam' : True, 279 | 'ModifiesFilter' : False, 280 | }, 281 | # Flasks Types 282 | 'set_flask_visibility' : { 283 | 'NumParamsOptions' : [2, 3], 284 | 'HasProfileParam' : True, 285 | 'ModifiesFilter' : True, 286 | 'NumParamsForMatch' : 1, 287 | }, 288 | 'get_flask_visibility' : { 289 | 'NumParamsOptions' : [1], 290 | 'HasProfileParam' : True, 291 | 'ModifiesFilter' : False, 292 | }, 293 | 'get_all_visible_flasks' : { 294 | 'NumParamsOptions' : [0], 295 | 'HasProfileParam' : True, 296 | 'ModifiesFilter' : False, 297 | }, 298 | # Socket Rules 299 | 'add_remove_socket_rule' : { 300 | 'NumParamsOptions' : [2, 3], 301 | 'HasProfileParam' : True, 302 | 'ModifiesFilter' : True, 303 | 'NumParamsForMatch' : 2, 304 | }, 305 | 'get_all_added_socket_rules' : { 306 | 'NumParamsOptions' : [0], 307 | 'HasProfileParam' : True, 308 | 'ModifiesFilter' : False, 309 | }, 310 | # RGB Items 311 | 'set_rgb_item_max_size' : { 312 | 'NumParamsOptions' : [1], 313 | 'HasProfileParam' : True, 314 | 'ModifiesFilter' : True, 315 | 'NumParamsForMatch' : 0, 316 | }, 317 | 'get_rgb_item_max_size' : { 318 | 'NumParamsOptions' : [0], 319 | 'HasProfileParam' : True, 320 | 'ModifiesFilter' : False, 321 | }, 322 | # Chaos Recipe 323 | 'set_chaos_recipe_enabled_for' : { 324 | 'NumParamsOptions' : [2], 325 | 'HasProfileParam' : True, 326 | 'ModifiesFilter' : True, 327 | 'NumParamsForMatch' : 1, 328 | }, 329 | 'is_chaos_recipe_enabled_for' : { 330 | 'NumParamsOptions' : [1], 331 | 'HasProfileParam' : True, 332 | 'ModifiesFilter' : False, 333 | }, 334 | 'get_all_chaos_recipe_statuses' : { 335 | 'NumParamsOptions' : [0], 336 | 'HasProfileParam' : True, 337 | 'ModifiesFilter' : False, 338 | }, 339 | } 340 | -------------------------------------------------------------------------------- /backend/backend_cli_test.py: -------------------------------------------------------------------------------- 1 | from backend_cli import kInputFilename as kBackendCliInputFilename 2 | 3 | import os 4 | 5 | import file_helper 6 | import profile 7 | import test_consts 8 | from test_assertions import AssertEqual, AssertTrue, AssertFalse 9 | import test_helper 10 | from type_checker import CheckType 11 | 12 | # Expected input form: "set_min_gem_quality 18" (profile name omitted) 13 | def CallBackendCli(function_call: str, profile_name: str or None = None): 14 | CheckType(function_call, 'function_call', str) 15 | CheckType(profile_name, 'profile_name', (str, type(None))) 16 | full_command = 'python backend_cli.py ' + function_call 17 | if (profile_name != None): 18 | full_command += ' ' + test_consts.kTestProfileName 19 | print('Running: {}'.format(full_command)) 20 | return_value = os.system(full_command) 21 | AssertEqual(return_value, 0) 22 | # End CallBackendCli 23 | 24 | kTestNonBatchFunctionCalls = [ 25 | 'is_first_launch', 26 | 'set_hotkey "Toggle GUI Hotkey" F7', 27 | 'get_all_hotkeys', 28 | 'get_all_profile_names', 29 | ] 30 | 31 | # Functions not included in either set of backend calls: 32 | # - create_new/rename/delete_profile, set_active_profile - tested "manually" below, 33 | # with the exception of create_new_profile, which requires config data in input file 34 | # - import_downloaded_filter and load_input_filter - not runnable as part of batch 35 | # - get_rule_matching_item - requires writing item text to input file 36 | kTestBatchString = \ 37 | '''check_filters_exist 38 | set_rule_visibility "jewels->abyss" highrare disable 39 | set_rule_visibility "jewels->abyss" highrare hide 40 | set_currency_to_tier "Chromatic Orb" 2 41 | get_tier_of_currency "Chromatic Orb" 42 | get_all_currency_tiers 43 | set_currency_tier_min_visible_stack_size 2 4 44 | get_currency_tier_min_visible_stack_size 2 45 | set_currency_tier_min_visible_stack_size twisdom 1 46 | get_currency_tier_min_visible_stack_size twisdom 47 | get_all_currency_tier_min_visible_stack_sizes 48 | set_splinter_min_visible_stack_size "Splinter of Uul-Netol" 8 49 | set_splinter_min_visible_stack_size "Timeless Maraketh Splinter" 1 50 | get_splinter_min_visible_stack_size "Timeless Maraketh Splinter" 51 | get_splinter_min_visible_stack_size "Simulacrum Splinter" 52 | get_all_splinter_min_visible_stack_sizes 53 | get_all_essence_tier_visibilities 54 | set_hide_essences_above_tier 1 55 | get_hide_essences_above_tier 56 | get_all_div_card_tier_visibilities 57 | set_hide_div_cards_above_tier 1 58 | get_hide_div_cards_above_tier 59 | get_all_unique_item_tier_visibilities 60 | set_hide_unique_items_above_tier 1 61 | get_hide_unique_items_above_tier 62 | get_all_unique_map_tier_visibilities 63 | set_hide_unique_maps_above_tier 1 64 | get_hide_unique_maps_above_tier 65 | set_lowest_visible_oil "Golden Oil" 66 | get_lowest_visible_oil 67 | set_gem_min_quality 18 68 | get_gem_min_quality 69 | set_flask_min_quality 11 70 | get_flask_min_quality 71 | add_remove_socket_rule "r-g-b X" 1 72 | add_remove_socket_rule "R-g-b X" 0 73 | add_remove_socket_rule "B-G-X-X" "Body Armours" 0 74 | add_remove_socket_rule "B-G-X-X" "Body Armours" 1 75 | get_all_added_socket_rules 76 | set_hide_maps_below_tier 13 77 | get_hide_maps_below_tier 78 | set_basetype_visibility "Hubris Circlet" 1 0 79 | set_basetype_visibility "Hubris Circlet" 1 1 80 | get_basetype_visibility "Hubris Circlet" 81 | get_all_visible_basetypes 82 | set_flask_visibility "Quicksilver Flask" 1 0 83 | set_flask_visibility "Diamond Flask" 1 1 84 | get_flask_visibility "Diamond Flask" 85 | get_all_visible_flasks 86 | set_rgb_item_max_size small 87 | get_rgb_item_max_size 88 | set_chaos_recipe_enabled_for "Weapons" 0 89 | set_chaos_recipe_enabled_for "Body Armours" 0 90 | set_chaos_recipe_enabled_for "Helmets" 1 91 | set_chaos_recipe_enabled_for "Gloves" 1 92 | set_chaos_recipe_enabled_for "Boots" 1 93 | set_chaos_recipe_enabled_for "Rings" 0 94 | set_chaos_recipe_enabled_for "Amulets" 1 95 | set_chaos_recipe_enabled_for "Belts" 0 96 | is_chaos_recipe_enabled_for "Weapons" 97 | get_all_chaos_recipe_statuses''' 98 | 99 | # Just a simple test to see if the functions can run without error; 100 | # doesn't verify output is correct. 101 | def SimpleTest(): 102 | test_helper.SetUp(create_profile=False) 103 | # Run non-profile-dependent functions 104 | for function_call_line in kTestNonBatchFunctionCalls: 105 | CallBackendCli(function_call_line) 106 | # Create test profile directly, and test profile-related functions 107 | profile_name = test_consts.kTestProfileName 108 | other_profile_name = test_consts.kTestProfileNames[1] 109 | profile.CreateNewProfile(profile_name, test_consts.kTestProfileConfigValues) 110 | CallBackendCli('rename_profile {} {}'.format(profile_name, other_profile_name)) 111 | CallBackendCli('delete_profile {}'.format(other_profile_name)) 112 | profile.CreateNewProfile(profile_name, test_consts.kTestProfileConfigValues) 113 | profile.CreateNewProfile(other_profile_name, test_consts.kTestProfileConfigValues) 114 | CallBackendCli('set_active_profile {}'.format(profile_name)) 115 | profile.DeleteProfile(other_profile_name) 116 | CallBackendCli('import_downloaded_filter', profile_name) 117 | CallBackendCli('load_input_filter', profile_name) 118 | # Run batch commands one-by-one (to more easily see which one fails) 119 | for function_call_line in kTestBatchString.split('\n'): 120 | CallBackendCli(function_call_line, profile_name) 121 | # Run the batch via run_batch 122 | file_helper.WriteToFile(kTestBatchString, kBackendCliInputFilename) 123 | CallBackendCli('run_batch', profile_name) 124 | print('SimpleTest passed!') 125 | 126 | def main(): 127 | SimpleTest() 128 | test_helper.TearDown() 129 | print('All tests passed!') 130 | 131 | if (__name__ == '__main__'): 132 | main() -------------------------------------------------------------------------------- /backend/base_type_helper.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | from type_checker import CheckType 4 | 5 | # TODO: write unit tests. 6 | # Creates a normalized tier tag to be used for the rule corresponding to the given parameters. 7 | # Examples: 8 | # - sorcerers_boots__84__100__rare 9 | # - steel_ring__86__100__any 10 | # Case is converted to lower, non-alphanumeric characters are removed, and spaces are converted to 11 | # underscores. Properties are separated by double underscores. 12 | def NormalizedBaseTypeTierTag(base_type: str, min_ilvl: int, max_ilvl: int, rare_only_flag: bool) -> str: 13 | CheckType(base_type, 'base_type', str) 14 | CheckType(min_ilvl, 'min_ilvl', int) 15 | CheckType(max_ilvl, 'mimax_ilvlvl', int) 16 | CheckType(rare_only_flag, 'rare_only_flag', bool) 17 | normalized_base_type_tag = '' 18 | for c in base_type.replace(' ', '_'): 19 | if (c in string.ascii_letters + string.digits + '_'): 20 | normalized_base_type_tag += c.lower() 21 | normalized_base_type_tag += '__{}__{}__'.format(min_ilvl, max_ilvl) 22 | normalized_base_type_tag += 'rare' if rare_only_flag else 'any' 23 | return normalized_base_type_tag 24 | # End NormalizedBaseTypeTag -------------------------------------------------------------------------------- /backend/file_helper.py: -------------------------------------------------------------------------------- 1 | ''' 2 | The purpose of this file is to provide file-related functions that do not generate errors, 3 | but rather do what you would naturally expect them to do to fix whatever errors they encounter. 4 | For example, when copying a file from A to B, the standard file copy functions throw an error 5 | if the directory of B does not exist, but here we create the directory if it doesn't exist. 6 | 7 | Note: we will still generate an error if the source file does not exist, as there is no clear 8 | natural way to recover from such an error - the user must be notified their copy failed. 9 | 10 | File read/write functions: 11 | - ReadFile(filepath: str, *, strip=False, discard_empty_lines=False) -> List[str] 12 | - ReadFileToDict(filepath) -> dict 13 | - WriteToFile(data, filepath) 14 | - NumLines(filepath) -> int 15 | - IsFileEmpty(filepath) -> bool 16 | 17 | File manipulation functions: 18 | - CopyFile(source_path, destination_path) 19 | - MoveFile(source_path, destination_path) 20 | - RemoveFileIfExists(filepath) 21 | - ClearFileIfExists(filepath) 22 | - ClearAndRemoveDirectory(path) 23 | 24 | Path-related functions: 25 | - FilenameWithoutExtension(filepath) -> str 26 | - ListFilesInDirectory(directory_path, fullpath=False) -> List[str] 27 | ''' 28 | 29 | import os 30 | import os.path 31 | import shutil 32 | from typing import List 33 | 34 | from type_checker import CheckType 35 | 36 | # ========================== File Reading/Writing ========================== 37 | 38 | # Read lines of a file to a list of strings, such that joining this result with newlines 39 | # yields the exact file contents (as long as additional options are all False). 40 | # Safe against file not existing. 41 | def ReadFile(filepath: str, *, strip=False, discard_empty_lines=False) -> List[str]: 42 | CheckType(filepath, 'filepath', str) 43 | CheckType(strip, 'strip', bool) 44 | CheckType(discard_empty_lines, 'discard_empty_lines', bool) 45 | lines: List[str] = [] 46 | try: 47 | with open(filepath, encoding='utf-8') as input_file: 48 | ends_with_newline = True 49 | for line in input_file: 50 | ends_with_newline = line.endswith('\n') 51 | line = line.rstrip('\n') 52 | line = line.strip() if strip else line 53 | if (not discard_empty_lines or (line != '')): 54 | lines.append(line) 55 | # Append final blank line if file ends with newline 56 | if (ends_with_newline and not discard_empty_lines): 57 | lines.append('') 58 | return lines 59 | except FileNotFoundError: 60 | return [] 61 | # End ReadFile 62 | 63 | # Parses each line of the input file as :, or as . 64 | # Ignores empty lines, and lines whose first non-whitespace character is '#'. 65 | # Strips key and value before inserting into dict. Ignores lines missing separator. 66 | def ReadFileToDict(filepath: str, separator=':') -> dict: 67 | CheckType(filepath, 'filepath', str) 68 | try: 69 | result_dict = {} 70 | with open(filepath, encoding='utf-8') as input_file: 71 | for line in input_file: 72 | stripped_line = line.strip() 73 | if ((stripped_line != '') and not stripped_line.startswith('#') 74 | and (separator in stripped_line)): 75 | key, value = line.split(':', 1) # maxsplit = 1 76 | result_dict[key.strip()] = value.strip() 77 | return result_dict 78 | except FileNotFoundError: 79 | return {} 80 | # End ReadFile 81 | 82 | # Writes data to the file determined by filepath. 83 | # Overwrites the given file if it already exists. 84 | # If data is a non-string iterable type, then it is written as newline-separated items, 85 | # otherwise, str(data) is written directly to file. 86 | # Safe against directory not existing (creates directory if missing). 87 | def WriteToFile(data, filepath: str): 88 | CheckType(filepath, 'filepath', str) 89 | parent_directory = os.path.dirname(filepath) 90 | if (parent_directory != ''): 91 | os.makedirs(parent_directory, exist_ok=True) 92 | with open(filepath, 'w', encoding='utf-8') as f: 93 | if (isinstance(data, str)): 94 | f.write(data) 95 | else: 96 | try: 97 | iter(data) 98 | data_list = list(data) 99 | f.write('\n'.join(str(x) for x in data_list)) 100 | except TypeError: 101 | f.write(str(data)) 102 | # End WriteToFile 103 | 104 | # Appends data to the file determined by filepath. 105 | # If data is a non-string iterable type, then it is written as newline-separated items, 106 | # otherwise str(data) is written directly. 107 | # Safe against file or directory not existing (creates file or directory if missing). 108 | def AppendToFile(data, filepath): 109 | CheckType(filepath, 'filepath', str) 110 | parent_directory = os.path.dirname(filepath) 111 | if (parent_directory != ''): 112 | os.makedirs(parent_directory, exist_ok=True) 113 | with open(filepath, 'a', encoding='utf-8') as f: 114 | if (isinstance(data, str)): 115 | f.write(data) 116 | else: 117 | try: 118 | iter(data) 119 | data_list = list(data) 120 | f.write('\n'.join(str(x) for x in data_list)) 121 | except TypeError: 122 | f.write(str(data)) 123 | # End AppendToFile 124 | 125 | def NumLines(filepath) -> int: 126 | num_lines = 0 127 | with open(filepath) as f: 128 | for line in f: 129 | num_lines += 1 130 | return num_lines 131 | # End NumLines 132 | 133 | # Returns True if the file exists and is empty. 134 | # Returns False if the file is nonempty, or does not exist. 135 | def IsFileEmpty(filepath: str) -> bool: 136 | CheckType(filepath, 'filepath', str) 137 | return os.path.isfile(filepath) and (os.path.getsize(filepath) == 0) 138 | # End IsFileEmpty 139 | 140 | # ========================== File Manipulation ========================== 141 | 142 | # Makes directories on path to destination if not exists 143 | # Overwrites destination if exists 144 | def CopyFile(source_path: str, destination_path: str): 145 | CheckType(source_path, 'source_path', str) 146 | CheckType(destination_path, 'destination_path', str) 147 | destination_directory: str = os.path.dirname(destination_path) 148 | if (destination_directory != ''): 149 | os.makedirs(destination_directory, exist_ok=True) 150 | # Copies source file to destination, overwriting if destination exists 151 | shutil.copyfile(source_path, destination_path) 152 | # End CopyFile 153 | 154 | # Makes directories on path to destination if not exists 155 | # Overwrites destination if exists 156 | def MoveFile(source_path: str, destination_path: str): 157 | CheckType(source_path, 'source_path', str) 158 | CheckType(destination_path, 'destination_path', str) 159 | destination_directory: str = os.path.dirname(destination_path) 160 | os.makedirs(destination_directory, exist_ok=True) 161 | # Moves a file from source to destination, overwriting if destination exists 162 | os.replace(source_path, destination_path) 163 | # End MoveFile 164 | 165 | # Removes the file if it exists 166 | # Does nothing if the file or the directory to the file does not exist 167 | def RemoveFileIfExists(filepath: str): 168 | CheckType(filepath, 'filepath', str) 169 | if (os.path.isfile(filepath)): 170 | os.remove(filepath) 171 | # End RemoveFileIfExists 172 | 173 | # Rewrites the given file to an empty file if it exists 174 | # Does nothing if the file or the directory to the file does not exist 175 | def ClearFileIfExists(filepath: str): 176 | CheckType(filepath, 'filepath', str) 177 | if (os.path.isfile(filepath)): 178 | open(filepath, 'w').close() 179 | # End ClearFileIfExists 180 | 181 | # Removes the directory and deletes all its contents, if it exists. 182 | # Does nothing if the directory does not exist. 183 | def ClearAndRemoveDirectory(path: str): 184 | CheckType(path, 'path', str) 185 | shutil.rmtree(path, ignore_errors=True) 186 | # End ClearAndRemoveDirectory 187 | 188 | # ========================== Path-related Functionality ========================== 189 | 190 | # Example: given 'Some/Dir/Readme.md', returns 'Readme'. 191 | def FilenameWithoutExtension(filepath: str) -> str: 192 | CheckType(filepath, 'filepath', str) 193 | return os.path.splitext(os.path.basename(filepath))[0] 194 | # End FilenameWithoutExtension 195 | 196 | # Lists all the files in the given directory 197 | # By default, lists only filenames; if paths are desired, use fullpath=True 198 | def ListFilesInDirectory(directory_path: str, fullpath=False) -> List[str]: 199 | filenames = [f for f in os.listdir(directory_path) 200 | if os.path.isfile(os.path.join(directory_path, f))] 201 | if (not fullpath): 202 | return filenames 203 | return [os.path.join(directory_path, filename) for filename in filenames] 204 | # End ListFilesInDirectory -------------------------------------------------------------------------------- /backend/file_helper_test.py: -------------------------------------------------------------------------------- 1 | from file_helper import WriteToFile 2 | import file_helper 3 | 4 | import os.path 5 | 6 | import test_consts 7 | import test_helper 8 | from test_assertions import AssertEqual, AssertFailure 9 | 10 | kTestFilepath = os.path.join(test_consts.kTestWorkingDirectory, "test_file.txt") 11 | 12 | def TestWriteReadSimple(): 13 | test_helper.TearDown() 14 | write_lines_testcases = [['Hello', 'World'], ['Hello'], ['Hello', ''], ['Hello', '', 'world']] 15 | for write_lines in write_lines_testcases: 16 | # Test write list of lines 17 | file_helper.WriteToFile(write_lines, kTestFilepath) 18 | read_lines = file_helper.ReadFile(kTestFilepath) 19 | AssertEqual(read_lines, write_lines) 20 | # Test write strings 21 | write_string = '\n'.join(write_lines) 22 | file_helper.WriteToFile(write_string, kTestFilepath) 23 | read_string = '\n'.join(file_helper.ReadFile(kTestFilepath)) 24 | AssertEqual(read_string, write_string) 25 | print('TestWriteReadSimple passed!') 26 | 27 | def TestWriteRead(): 28 | test_helper.TearDown() 29 | write_string = "The quick brown fox\njumps\nover\n the lazy dog.\n\n" 30 | # Test Write and Read 31 | file_helper.WriteToFile(write_string, kTestFilepath) 32 | read_string = '\n'.join(file_helper.ReadFile(kTestFilepath)) 33 | AssertEqual(read_string, write_string) 34 | test_helper.TearDown() 35 | print('TestWriteRead passed!') 36 | 37 | def TestAppendRead(): 38 | test_helper.TearDown() 39 | write_string = "The quick brown fox\njumps\nover\n the lazy dog.\n\n" 40 | # Test Append and Read 41 | file_helper.AppendToFile(write_string, kTestFilepath) 42 | read_string = '\n'.join(file_helper.ReadFile(kTestFilepath)) 43 | AssertEqual(read_string, write_string) 44 | # Append more 45 | file_helper.AppendToFile(write_string, kTestFilepath) 46 | read_string = '\n'.join(file_helper.ReadFile(kTestFilepath)) 47 | AssertEqual(read_string, 2 * write_string) 48 | print('TestAppendRead passed!') 49 | 50 | def main(): 51 | TestWriteReadSimple() 52 | TestWriteRead() 53 | TestAppendRead() 54 | test_helper.TearDown() 55 | print('All tests passed!') 56 | 57 | if (__name__ == '__main__'): 58 | main() -------------------------------------------------------------------------------- /backend/general_config.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import consts 4 | import file_helper 5 | 6 | kGeneralConfigPath = os.path.join(consts.kConfigDirectory, 'general.config') 7 | 8 | class GeneralConfigKeywords: 9 | kActiveProfile = 'Active Profile' 10 | kToggleGuiHotkey = 'Toggle GUI Hotkey' 11 | kWriteFilterHotkey = 'Write Filter Hotkey' 12 | kReloadFilterHotkey = 'Reload Filter Hotkey' 13 | 14 | kKeywordsList = [kActiveProfile, kToggleGuiHotkey, kWriteFilterHotkey, kReloadFilterHotkey] 15 | # End class GeneralConfigKeywords 16 | 17 | kDefaultConfigValues = { 18 | GeneralConfigKeywords.kActiveProfile: None, 19 | GeneralConfigKeywords.kToggleGuiHotkey: 'F7', 20 | GeneralConfigKeywords.kWriteFilterHotkey: 'F8', 21 | GeneralConfigKeywords.kReloadFilterHotkey: 'F9' 22 | } 23 | 24 | kGeneralConfigTemplateTemplate = \ 25 | '''# Profile 26 | {0}: {4} 27 | 28 | # Hotkeys 29 | {1}: {4} 30 | {2}: {4} 31 | {3}: {4} 32 | ''' 33 | 34 | kGeneralConfigTemplate = kGeneralConfigTemplateTemplate.format( 35 | *(GeneralConfigKeywords.kKeywordsList + ['{}'])) 36 | 37 | class GeneralConfig: 38 | ''' 39 | This class manages the general.config file. 40 | 41 | Member variables: 42 | - self.path: str (default: kGeneralConfigPath defined above) 43 | - self.keyword_value_dict: dict[str, str] mapping keywords (defined above) to values 44 | - Allows keyword_value_dict to be manipulated directly via [] operator 45 | ''' 46 | 47 | def __init__(self, general_config_path=kGeneralConfigPath): 48 | self.path = general_config_path 49 | self.keyword_value_dict = file_helper.ReadFileToDict(self.path) 50 | # Fill in default values for missing keywords 51 | for keyword in GeneralConfigKeywords.kKeywordsList: 52 | if (keyword not in self.keyword_value_dict): 53 | self.keyword_value_dict[keyword] = kDefaultConfigValues[keyword] 54 | 55 | def SaveToFile(self): 56 | values = [self.keyword_value_dict[keyword] 57 | for keyword in GeneralConfigKeywords.kKeywordsList] 58 | general_config_string = kGeneralConfigTemplate.format(*values) 59 | file_helper.WriteToFile(general_config_string, self.path) 60 | 61 | def __contains__(self, key): 62 | return key in self.keyword_value_dict 63 | 64 | def __getitem__(self, key): 65 | return self.keyword_value_dict[key] 66 | 67 | def __setitem__(self, key, value): 68 | self.keyword_value_dict[key] = value 69 | 70 | # End class GeneralConfig -------------------------------------------------------------------------------- /backend/general_config_test.py: -------------------------------------------------------------------------------- 1 | from general_config import GeneralConfig, GeneralConfigKeywords, kGeneralConfigTemplate 2 | 3 | import os.path 4 | 5 | import file_helper 6 | from test_assertions import AssertEqual, AssertTrue, AssertFalse 7 | import test_consts 8 | 9 | kTestGeneralConfigPath = os.path.join(test_consts.kTestWorkingDirectory, 'general.config') 10 | 11 | # Tuples of (keyword, value, another_value) 12 | kTestGeneralConfigValues = [ 13 | (GeneralConfigKeywords.kActiveProfile, 'TestProfile', 'OtherProfile'), 14 | (GeneralConfigKeywords.kToggleGuiHotkey, '^F1', '!H'), 15 | (GeneralConfigKeywords.kWriteFilterHotkey, '+a', '+2'), 16 | (GeneralConfigKeywords.kReloadFilterHotkey, '!^F12', 'Tab')] 17 | 18 | def TestParseGeneralConfig(): 19 | general_config_string = kGeneralConfigTemplate.format( 20 | *(value for keyword, value, _ in kTestGeneralConfigValues)) 21 | file_helper.WriteToFile(general_config_string, kTestGeneralConfigPath) 22 | general_config_obj = GeneralConfig(kTestGeneralConfigPath) 23 | for keyword, value, _ in kTestGeneralConfigValues: 24 | AssertEqual(general_config_obj[keyword], value) 25 | print('TestParseGeneralConfig passed!') 26 | 27 | def TestSaveGeneralConfig(): 28 | # Generate and parse config as above 29 | general_config_string = kGeneralConfigTemplate.format( 30 | *(value for keyword, value, _ in kTestGeneralConfigValues)) 31 | file_helper.WriteToFile(general_config_string, kTestGeneralConfigPath) 32 | general_config_obj = GeneralConfig(kTestGeneralConfigPath) 33 | # Update values and save updated config 34 | for keyword, _, new_value in kTestGeneralConfigValues: 35 | general_config_obj[keyword] = new_value 36 | general_config_obj.SaveToFile() 37 | # Parse updated config and verify values are correct 38 | general_config_obj = GeneralConfig(kTestGeneralConfigPath) 39 | for keyword, _, new_value in kTestGeneralConfigValues: 40 | AssertEqual(general_config_obj[keyword], new_value) 41 | print('TestSaveGeneralConfig passed!') 42 | 43 | def TestMissingGeneralConfig(): 44 | file_helper.ClearAndRemoveDirectory(test_consts.kTestWorkingDirectory) 45 | general_config_obj = GeneralConfig(kTestGeneralConfigPath) 46 | for keyword in GeneralConfigKeywords.kKeywordsList: 47 | AssertTrue(keyword in general_config_obj) 48 | print('TestMissingGeneralConfig passed!') 49 | 50 | def main(): 51 | os.makedirs(test_consts.kTestWorkingDirectory, exist_ok=True) 52 | TestParseGeneralConfig() 53 | TestSaveGeneralConfig() 54 | file_helper.ClearAndRemoveDirectory(test_consts.kTestWorkingDirectory) 55 | TestMissingGeneralConfig() 56 | print('All tests passed!') 57 | 58 | if (__name__ == '__main__'): 59 | main() -------------------------------------------------------------------------------- /backend/generate_item_test_cases.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import file_helper 4 | from item import Item 5 | from loot_filter import InputFilterSource, LootFilter 6 | import test_consts 7 | import test_helper 8 | 9 | # Returns a list of strings, each of which is the item text for an item. 10 | def ParseSampleItemsTxt(input_filepath: str) -> List[str]: 11 | item_text_strings = [] 12 | current_item_text_lines = [] 13 | for line in file_helper.ReadFile(input_filepath, strip=True): 14 | if (line == ''): 15 | continue 16 | if (line.startswith(test_consts.kHorizontalSeparator[:8])): 17 | if (len(current_item_text_lines) > 0): 18 | item_text_strings.append('\n'.join(current_item_text_lines)) 19 | current_item_text_lines = [] 20 | else: 21 | current_item_text_lines.append(line) 22 | return item_text_strings 23 | # End ParseSampleItemsTxt 24 | 25 | def main(): 26 | test_helper.SetUp() 27 | loot_filter = LootFilter(test_consts.kTestProfileName, InputFilterSource.kDownload) 28 | item_text_strings = ParseSampleItemsTxt(test_consts.kTestItemsFullpath) 29 | with open(test_consts.kItemTestCasesGeneratedFullpath, 'w') as output_file: 30 | output_file.write(test_consts.kHorizontalSeparator) 31 | for item_text in item_text_strings: 32 | # Write item text 33 | output_file.write('\n\n' + item_text) 34 | output_file.write('\n\n' + test_consts.kHorizontalSeparatorThin + '\n\n') 35 | # Write item properties in sorted order for visual inspection and diff checking 36 | item = Item(item_text) 37 | for k, v in sorted(item.properties_map.items()): 38 | output_file.write('{} | {}\n'.format(k, v)) 39 | output_file.write('\n' + test_consts.kHorizontalSeparatorThin + '\n\n') 40 | # Write matched rule text lines 41 | matched_rule = loot_filter.GetRuleMatchingItem(item) 42 | output_file.write('\n'.join(matched_rule.rule_text_lines)) 43 | output_file.write('\n\n' + test_consts.kHorizontalSeparator) 44 | test_helper.TearDown() 45 | # End main 46 | 47 | ''' 48 | Manual review of results: 49 | - Super weird edge case if cannot use item. See Phoenix Song. 50 | - Weird edge case for StackSize > 999: "Stack Size: 1,112/300" 51 | - Issue with magic item BaseType parsing (known bug, need full BaseType list). 52 | ''' 53 | 54 | if (__name__ == '__main__'): 55 | main() -------------------------------------------------------------------------------- /backend/hash_linked_list.py: -------------------------------------------------------------------------------- 1 | class HllNode: 2 | def __init__(self, previous_node, next_node, key, value): 3 | self.previous_node = previous_node 4 | self.next_node = next_node 5 | self.key = key 6 | self.value = value 7 | # End class HllNode 8 | 9 | # Yields (key, value) pairs 10 | class HashLinkedListIterator: 11 | def __init__(self, hash_linked_list): 12 | self.hash_linked_list = hash_linked_list 13 | self.current_node = hash_linked_list.head 14 | 15 | def __next__(self): 16 | self.current_node = self.current_node.next_node 17 | if (self.current_node != self.hash_linked_list.tail): 18 | return self.current_node.key, self.current_node.value 19 | raise StopIteration 20 | # End class HashLinkedListIterator 21 | 22 | # Note: HashLinkedList currently has a bug that's acting as a "feature" 23 | # for rule parsing: multiple items with the same key can be inserted. 24 | # This inadvertently allows rules with conditions like 25 | # "ItemLevel >= 60", "ItemLevel < 75" to function correctly. 26 | class HashLinkedList: 27 | # Member variables: 28 | # - self.head: HllNode 29 | # - self.tail: HllNode 30 | # - self.size: int 31 | # - self.key_to_node_map: dict {key : node} 32 | def __init__(self): 33 | self.head = HllNode(None, None, None, None) 34 | self.tail = HllNode(None, None, None, None) 35 | self.head.next_node = self.tail 36 | self.tail.previous_node = self.head 37 | self.size = 0 38 | self.key_to_node_map = {} 39 | 40 | # A successor_key of None indicates to insert at the end 41 | def insert_before(self, key, value, successor_key): 42 | next_node = (self.key_to_node_map[successor_key] if successor_key != None 43 | else self.tail) 44 | previous_node = next_node.previous_node 45 | new_node = HllNode(previous_node, next_node, key, value) 46 | previous_node.next_node = new_node 47 | next_node.previous_node = new_node 48 | self.key_to_node_map[key] = new_node 49 | self.size += 1 50 | 51 | # A predecessor_key of None indicates to insert at the beginning 52 | def insert_after(self, key, value, predecessor_key): 53 | previous_node = (self.key_to_node_map[predecessor_key] if predecessor_key != None 54 | else self.head) 55 | self.insert_before(key, value, previous_node.next_node.key) 56 | 57 | # Time complexity: O(index) 58 | # If index > size, appends to the end of the list. 59 | def insert_at_index(self, key, value, index: int): 60 | # Get nth item of iterator: https://stackoverflow.com/a/54333239 61 | successor_key, _ = next((x for i, x in enumerate(self) if i==index), (None, None)) 62 | self.insert_before(key, value, successor_key) 63 | 64 | def append(self, key, value): 65 | self.insert_before(key, value, successor_key=None) 66 | 67 | def remove(self, key): 68 | if (key not in self.key_to_node_map): 69 | raise KeyError(key) 70 | node = self.key_to_node_map[key] 71 | node.previous_node.next_node = node.next_node 72 | node.next_node.previous_node = node.previous_node 73 | self.key_to_node_map.pop(key) 74 | self.size -= 1 75 | 76 | def get_node(self, key): 77 | return self.key_to_node_map[key] 78 | 79 | def __contains__(self, key) -> bool: 80 | return key in self.key_to_node_map 81 | 82 | def __getitem__(self, key): 83 | return self.key_to_node_map[key].value 84 | 85 | # Only to be used for changing the value of an existing node. 86 | # If node with specified key does not exist, raises a KeyError. 87 | def __setitem__(self, key, value): 88 | self.key_to_node_map[key].value = value 89 | 90 | def __iter__(self): 91 | return HashLinkedListIterator(self) 92 | # End class HashLinkedList -------------------------------------------------------------------------------- /backend/hash_linked_list_test.py: -------------------------------------------------------------------------------- 1 | from hash_linked_list import HashLinkedList 2 | 3 | from test_assertions import AssertEqual, AssertFailure 4 | 5 | def SimpleTest(): 6 | # Build HashLinkedList mapping int keys to their corresponding strings as values 7 | hll = HashLinkedList() 8 | hll.append(4, '4') 9 | hll.append(7, '7') 10 | hll.insert_before(6, '6', 7) 11 | hll.insert_before(8, '8', None) 12 | hll.insert_after(5, '5', 4) 13 | hll.insert_after(3, '3', None) 14 | # Create reference key list 15 | expected_key_list = list(range(3, 9)) 16 | # Verify the HashLinkedList matches the reference list 17 | for i, (key, value) in enumerate(hll): 18 | expected_key = expected_key_list[i] 19 | expected_value = str(expected_key) 20 | AssertEqual(expected_key, key) 21 | AssertEqual(expected_value, value) 22 | AssertEqual(hll.size, len(expected_key_list)) 23 | print('SimpleTest passed!') 24 | 25 | def LargerTest(): 26 | # Build HashLinkedList mapping int keys to their corresponding strings as values 27 | num_items = 1000 28 | hll = HashLinkedList() 29 | for key in range(num_items): 30 | hll.append(key, str(key)) 31 | for successor_key in range(num_items): 32 | hll.insert_before(-1, str(-1), successor_key) 33 | # Build equivalent list for reference 34 | reference_key_list = list(range(num_items)) 35 | for successor_key in range(num_items): 36 | successor_index = reference_key_list.index(successor_key) 37 | reference_key_list.insert(successor_index, -1) 38 | # Verify the HashLinkedList matches the reference list 39 | for i, (key, value) in enumerate(hll): 40 | expected_key = reference_key_list[i] 41 | expected_value = str(expected_key) 42 | AssertEqual(expected_key, key) 43 | AssertEqual(expected_value, value) 44 | AssertEqual(hll.size, len(reference_key_list)) 45 | print('LargerTest passed!') 46 | 47 | def TestInsertAtIndex(): 48 | hll = HashLinkedList() 49 | for i in range(10): 50 | hll.append(i, str(i)) 51 | hll.insert_at_index(-1, '-1', 0) 52 | hll.insert_at_index(11, '11', 11) 53 | hll.insert_at_index(12, '12', 100) 54 | reference_key_list = list(range(-1, 10)) + [11, 12] 55 | # Verify the HashLinkedList matches the reference list 56 | for i, (key, value) in enumerate(hll): 57 | expected_key = reference_key_list[i] 58 | expected_value = str(expected_key) 59 | AssertEqual(expected_key, key) 60 | AssertEqual(expected_value, value) 61 | AssertEqual(hll.size, len(reference_key_list)) 62 | print('TestInsertAtIndex passed!') 63 | 64 | def TestBracketAccess(): 65 | hll = HashLinkedList() 66 | for key in range(10): 67 | hll.append(key, str(key)) 68 | AssertEqual(hll[5], str(5)) 69 | hll[5] = 'five' 70 | AssertEqual(hll[5], 'five') 71 | # Expect error when trying to set value whose key is missing 72 | try: 73 | hll[-1] = 'x' 74 | except KeyError: # this should happen 75 | pass 76 | else: 77 | AssertFailure() 78 | print('TestBracketAccess passed!') 79 | 80 | def TestRemove(): 81 | hll = HashLinkedList() 82 | for key in range(10): 83 | hll.append(key, str(key)) 84 | hll.remove(5) 85 | hll.remove(0) 86 | hll.remove(9) 87 | reference_key_list = list(range(1, 5)) + list(range(6, 9)) 88 | # Verify the HashLinkedList matches the reference list 89 | for i, (key, value) in enumerate(hll): 90 | expected_key = reference_key_list[i] 91 | expected_value = str(expected_key) 92 | AssertEqual(expected_key, key) 93 | AssertEqual(expected_value, value) 94 | AssertEqual(hll.size, len(reference_key_list)) 95 | print('TestRemove passed!') 96 | 97 | def main(): 98 | SimpleTest() 99 | LargerTest() 100 | TestInsertAtIndex() 101 | TestBracketAccess() 102 | TestRemove() 103 | print('All tests passed!') 104 | 105 | if (__name__ == '__main__'): 106 | main() -------------------------------------------------------------------------------- /backend/item_test.py: -------------------------------------------------------------------------------- 1 | from item import Item 2 | 3 | from enum import Enum 4 | from typing import List, Tuple 5 | 6 | import file_helper 7 | from loot_filter import InputFilterSource, LootFilter 8 | import simple_parser 9 | import test_consts 10 | from test_assertions import AssertEqual, AssertTrue, AssertFalse 11 | 12 | class TestCaseBlock(Enum): 13 | kItemText = 0 14 | kItemProperties = 1 15 | kRuleText = 2 16 | # End TestCaseBlock 17 | 18 | # Parses test cases as list of triplets: item_text_string, item_properties_map, matching_rule_string 19 | # - item_text_string is a single string containing the item raw text 20 | # - item_properties_map is a dict containing the expected parsed item properties: 21 | # - key: str - Keyword 22 | # - value: str - associated value string 23 | # - matching_rule_string is a single string containing the matched rule raw text 24 | def ParseTestCases(input_filepath: str) -> List[Tuple[str, dict, str]]: 25 | test_cases = [] 26 | current_item_text_lines = [] 27 | current_item_properties_map = {} 28 | current_rule_text_lines = [] 29 | current_block = TestCaseBlock.kItemText 30 | for line in file_helper.ReadFile(input_filepath, strip=True): 31 | if (line == ''): 32 | continue 33 | # Start of new item text 34 | elif (line.startswith(test_consts.kHorizontalSeparator[:10])): 35 | if (len(current_item_text_lines) > 0): 36 | current_item_text_string = '\n'.join(current_item_text_lines) 37 | current_rule_text_string = '\n'.join(current_rule_text_lines) 38 | test_cases.append((current_item_text_string, 39 | current_item_properties_map, current_rule_text_string)) 40 | current_item_text_lines = [] 41 | current_item_properties_map = {} 42 | current_rule_text_lines = [] 43 | current_block = TestCaseBlock.kItemText 44 | # Start of next block within same item 45 | elif (line.startswith(test_consts.kHorizontalSeparatorThin[:10])): 46 | if (current_block == TestCaseBlock.kItemText): 47 | current_block = TestCaseBlock.kItemProperties 48 | elif (current_block == TestCaseBlock.kItemProperties): 49 | current_block = TestCaseBlock.kRuleText 50 | # Item text line 51 | elif (current_block == TestCaseBlock.kItemText): 52 | current_item_text_lines.append(line) 53 | # Item properties line 54 | elif (current_block == TestCaseBlock.kItemProperties): 55 | _, [keyword, value_string] = simple_parser.ParseFromTemplate(line, '{} | {}') 56 | current_item_properties_map[keyword] = value_string 57 | # Matching rule text line 58 | else: # (current_block == TestCaseBlock.kRuleText) 59 | current_rule_text_lines.append(line) 60 | return test_cases 61 | 62 | def TestParseItemText(): 63 | test_cases = ParseTestCases(test_consts.kItemTestCasesInputFullpath) 64 | for item_text, expected_item_properties_map, _ in test_cases: 65 | item = Item(item_text) 66 | # Compare item.properties_map and expected_item_properties_map: 67 | # Keys should be equal, values should be string-equal 68 | AssertEqual(set(item.properties_map.keys()), set(expected_item_properties_map.keys())) 69 | for keyword in item.properties_map: 70 | AssertEqual(str(item.properties_map[keyword]), expected_item_properties_map[keyword]) 71 | print('TestParseItemText passed!') 72 | 73 | # Note: item.RuleMatchesItem is tested in rule_matching_test.py. 74 | 75 | def main(): 76 | TestParseItemText() 77 | print('All tests passed!') 78 | 79 | if (__name__ == '__main__'): 80 | main() -------------------------------------------------------------------------------- /backend/logger.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from consts import kCacheDirectory 4 | 5 | kDefaultLogFilename = os.path.join(kCacheDirectory, 'log.log') 6 | 7 | g_log_filename = '' 8 | 9 | def InitializeLog(log_filename: str = kDefaultLogFilename): 10 | global g_log_filename 11 | g_log_filename = log_filename 12 | open(g_log_filename, 'w', encoding='utf-8').close() 13 | # End InitializeLog() 14 | 15 | # Used to log any errors, warnings, or debug information 16 | # item can be a string or anything convertible to string 17 | def Log(item): 18 | global g_log_filename 19 | if (g_log_filename == ''): 20 | InitializeLog(kDefaultLogFilename) 21 | message: str = item if isinstance(item, str) else str(item) 22 | with open(g_log_filename, 'a', encoding='utf-8') as log_file: 23 | log_file.write(message + '\n\n') 24 | # End Log() 25 | -------------------------------------------------------------------------------- /backend/loot_filter_rule_test.py: -------------------------------------------------------------------------------- 1 | from loot_filter_rule import RuleVisibility, LootFilterRule 2 | from test_assertions import AssertEqual, AssertTrue, AssertFalse 3 | 4 | import parse_helper 5 | 6 | kCommentBlockText = \ 7 | '''#=============================================================================================================== 8 | # NeverSink's Indepth Loot Filter - for Path of Exile 9 | #=============================================================================================================== 10 | # VERSION: 8.6.0 11 | # TYPE: 1-REGULAR 12 | # STYLE: DEFAULT 13 | # AUTHOR: NeverSink 14 | # BUILDNOTES: Filter generated with NeverSink's FilterpolishZ and the domainlanguage Exo. 15 | # xShow''' 16 | 17 | kInputRuleText = \ 18 | '''# Splinter stacks rule 19 | # Let's add a second comment line for testing 20 | Show # %HS4 $type->currency->stackedsplintershigh $tier->t3 21 | StackSize >= 2 22 | Class "Currency" 23 | BaseType "Splinter of Chayula" "Splinter of Uul-Netol" "Timeless Maraketh Splinter" "Timeless Templar Splinter" 24 | SetFontSize 45 25 | SetTextColor 0 255 255 255 26 | SetBorderColor 0 255 255 255 27 | SetBackgroundColor 45 0 108 255 28 | PlayEffect Purple 29 | MinimapIcon 0 Purple Kite 30 | PlayAlertSound 2 300''' 31 | 32 | kCommentedRuleText = \ 33 | '''#Hide # $type->jewels->clustereco $tier->n6_i75_t1 34 | # ItemLevel >= 75 35 | # Rarity <= Rare 36 | # EnchantmentPassiveNum 6 37 | # BaseType "Medium Cluster Jewel" 38 | # SetFontSize 45 39 | # SetTextColor 150 0 255 255 40 | # SetBorderColor 240 100 0 255 41 | # SetBackgroundColor 255 255 255 255 42 | # PlayEffect Red 43 | # MinimapIcon 0 Red Star 44 | #PlayAlertSound 1 300''' 45 | 46 | kEmptyBaseTypeRuleText = \ 47 | '''#Show # %HS3 $type->currency->stackedthree $tier->t6 48 | # StackSize >= 3 49 | # Class "Currency" 50 | # SetFontSize 45 51 | # SetTextColor 30 200 200 255 52 | # SetBorderColor 30 200 200 255 53 | # SetBackgroundColor 0 0 69 54 | # MinimapIcon 2 Cyan Raindrop 55 | #PlayAlertSound 2 300 56 | #DisableDropSound True''' 57 | 58 | kRepeatedKeywordRuleText = \ 59 | '''Show # $type->dlf_chaos_recipe_rares $tier->amulets 60 | ItemLevel >= 60 61 | ItemLevel < 75 62 | Rarity Rare 63 | Class "Amulets" 64 | Identified False 65 | SetBorderColor 0 255 255 255 66 | SetBackgroundColor 120 20 20 80 67 | SetFontSize 40 68 | MinimapIcon 0 Cyan Moon''' 69 | 70 | kCustomDropSoundRuleText = \ 71 | '''Show # $type->currency $tier->t5 72 | Class "Currency" 73 | SetFontSize 45 74 | SetTextColor 30 200 200 255 75 | SetBorderColor 30 200 200 255 76 | SetBackgroundColor 0 0 69 77 | MinimapIcon 2 Cyan Raindrop 78 | CustomAlertSound "Alteration.mp3" 300 79 | DisableDropSound True''' 80 | 81 | def TestIsParsableAsRule(): 82 | AssertFalse(LootFilterRule.IsParsableAsRule(kCommentBlockText)) 83 | AssertTrue(LootFilterRule.IsParsableAsRule(kInputRuleText)) 84 | AssertTrue(LootFilterRule.IsParsableAsRule(kCommentedRuleText)) 85 | print('TestIsParsableAsRule passed!') 86 | 87 | def TestBasicRuleParse(): 88 | rule = LootFilterRule(kInputRuleText) 89 | AssertEqual(rule.visibility, RuleVisibility.kShow) 90 | AssertEqual(rule.type_tag, 'currency->stackedsplintershigh') 91 | AssertEqual(rule.tier_tag, 't3') 92 | rule = LootFilterRule(kCommentedRuleText) 93 | AssertEqual(rule.visibility, RuleVisibility.kDisabledHide) 94 | AssertEqual(rule.type_tag, 'jewels->clustereco') 95 | AssertEqual(rule.tier_tag, 'n6_i75_t1') 96 | print('TestBasicRuleParse passed!') 97 | 98 | def TestAddRemoveBaseTypes(): 99 | rule = LootFilterRule(kInputRuleText) 100 | rule.AddBaseType('Simulacrum Splinter') 101 | rule.RemoveBaseType('Splinter of Uul-Netol') 102 | _, rule_base_type_list = rule.parsed_lines_hll['BaseType'] 103 | rule_text = '\n'.join(rule.rule_text_lines) 104 | AssertTrue('Simulacrum Splinter' in rule_base_type_list) 105 | AssertTrue('Simulacrum Splinter' in rule_text) 106 | AssertFalse('Splinter of Uul-Netol' in rule_base_type_list) 107 | AssertFalse('Splinter of Uul-Netol' in rule_text) 108 | print('TestAddRemoveBaseTypes passed!') 109 | 110 | def TestRemoveAllBaseTypes(): 111 | # Remove individually 112 | rule = LootFilterRule(kInputRuleText) 113 | rule.RemoveBaseType('Splinter of Chayula') 114 | rule.RemoveBaseType('Splinter of Uul-Netol') 115 | rule.RemoveBaseType('Timeless Maraketh Splinter') 116 | rule.RemoveBaseType('Timeless Templar Splinter') 117 | AssertTrue(RuleVisibility.IsDisabled(rule.visibility)) 118 | AssertTrue(all(line.strip().startswith('#') for line in rule.rule_text_lines)) 119 | # Use ClearBaseTypeList 120 | rule = LootFilterRule(kInputRuleText) 121 | rule.ClearBaseTypeList() 122 | AssertTrue(RuleVisibility.IsDisabled(rule.visibility)) 123 | AssertTrue(all(line.strip().startswith('#') for line in rule.rule_text_lines)) 124 | print('TestRemoveAllBaseTypes passed!') 125 | 126 | def TestChangeVisibility(): 127 | rule = LootFilterRule(kInputRuleText) 128 | # Use Show/Hide/Disable funtions 129 | # Hide 130 | rule.Hide() 131 | AssertEqual(rule.visibility, RuleVisibility.kHide) 132 | AssertTrue(rule.rule_text_lines[0].strip().startswith('Hide')) 133 | # Show 134 | rule.Show() 135 | AssertEqual(rule.visibility, RuleVisibility.kShow) 136 | AssertTrue(rule.rule_text_lines[0].strip().startswith('Show')) 137 | # Hide then Disable 138 | rule.Hide() 139 | rule.Disable() 140 | AssertEqual(rule.visibility, RuleVisibility.kDisabledHide) 141 | AssertTrue(all(line.strip().startswith('#') for line in rule.rule_text_lines)) 142 | AssertEqual('Hide' in rule.rule_text_lines[0], True) 143 | # Enable 144 | rule.Enable() 145 | AssertEqual(rule.visibility, RuleVisibility.kHide) 146 | AssertTrue(rule.rule_text_lines[0].strip().startswith('Hide')) 147 | # Use SetVisibility directly 148 | # RuleVisibility.kShow 149 | rule.SetVisibility(RuleVisibility.kShow) 150 | AssertEqual(rule.visibility, RuleVisibility.kShow) 151 | AssertTrue(rule.rule_text_lines[0].strip().startswith('Show')) 152 | # RuleVisibility.kDisabledAny 153 | rule.SetVisibility(RuleVisibility.kDisabledAny) 154 | AssertTrue(RuleVisibility.IsDisabled(rule.visibility)) 155 | AssertTrue(all(line.strip().startswith('#') for line in rule.rule_text_lines)) 156 | # RuleVisibility.kHide 157 | rule.SetVisibility(RuleVisibility.kHide) 158 | AssertEqual(rule.visibility, RuleVisibility.kHide) 159 | AssertTrue(rule.rule_text_lines[0].strip().startswith('Hide')) 160 | print('TestChangeVisibility passed!') 161 | 162 | def TestChangeTags(): 163 | rule = LootFilterRule(kInputRuleText) 164 | rule.SetTypeTierTags('hello', 'universe') 165 | AssertTrue(' $type->hello ' in rule.rule_text_lines[0]) 166 | AssertTrue(' $tier->universe' in rule.rule_text_lines[0]) 167 | print('TestChangeTags passed!') 168 | 169 | def TestModifyLine(): 170 | rule = LootFilterRule(kInputRuleText) 171 | rule.ModifyLine('MinimapIcon', '', ['0', 'Cyan', 'Moon']) 172 | rule_text = '\n'.join(rule.rule_text_lines) 173 | AssertTrue(rule.parsed_lines_hll['MinimapIcon'] == ('', ['0', 'Cyan', 'Moon'])) 174 | AssertTrue('MinimapIcon 0 Cyan Moon' in rule_text) 175 | print('TestModifyLine passed!') 176 | 177 | def TestEmptyBaseTypeList(): 178 | rule = LootFilterRule(kEmptyBaseTypeRuleText) 179 | rule.ClearBaseTypeList() 180 | added_base_type = 'Orb of Alteration' 181 | rule.AddBaseType(added_base_type) 182 | AssertTrue(added_base_type in rule.GetBaseTypeList()) 183 | AssertTrue(RuleVisibility.IsDisabled(rule.visibility)) 184 | print('TestEmptyBaseTypeList passed!') 185 | 186 | def TestRepeatedKeyword(): 187 | rule = LootFilterRule(kRepeatedKeywordRuleText) 188 | print('TestRepeatedKeyword passed!') 189 | 190 | def TestCustomDropSound(): 191 | rule = LootFilterRule(kCustomDropSoundRuleText) 192 | rule.AddBaseType('Orb of Alteration') 193 | rule.Hide() 194 | AssertTrue(parse_helper.IsSubstringInLines('"Alteration.mp3"', rule.GetTextLines())) 195 | print('TestCustomDropSound passed!') 196 | 197 | # TODO: 198 | # - Test header_comment_lines and rule_text_lines are parsed correctly 199 | # - Test GetConditions 200 | 201 | def main(): 202 | TestIsParsableAsRule() 203 | TestBasicRuleParse() 204 | TestAddRemoveBaseTypes() 205 | TestRemoveAllBaseTypes() 206 | TestChangeVisibility() 207 | TestChangeTags() 208 | TestModifyLine() 209 | TestEmptyBaseTypeList() 210 | TestRepeatedKeyword() 211 | TestCustomDropSound() 212 | print('All tests passed!') 213 | 214 | if (__name__ == '__main__'): 215 | main() -------------------------------------------------------------------------------- /backend/multiset.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Defines a very simple multiset class for Python 3. 3 | ''' 4 | 5 | class Multiset: 6 | ''' 7 | Stores the given items in a dictionary mapping values to counts. 8 | 9 | Member variables: 10 | - self.count_dict: dict mapping value to count 11 | - self.value_list: list containing the values in the multiset 12 | - value_list is used for __repr__ and instantiated in such 13 | a way that if items are never removed from the multiset, 14 | __repr__ always lists the items in order of insertion. 15 | - self.value_list_outdated: bool 16 | ''' 17 | 18 | def __init__(self, iterable_container): 19 | self.count_dict = {} 20 | self.value_list = [] 21 | self.value_list_outdated = False 22 | for value in iterable_container: 23 | self.insert(value) 24 | 25 | def insert(self, value): 26 | if (value in self.count_dict): 27 | self.count_dict[value] += 1 28 | else: 29 | self.count_dict[value] = 1 30 | self.value_list.append(value) 31 | 32 | # Removes one instance of given value, if it exists in the set 33 | def remove(self, value): 34 | if (value in self.count_dict): 35 | self.count_dict[value] -= 1 36 | if (self.count_dict[value] == 0): 37 | self.count_dict.pop(value) 38 | self.value_list_outdated = True 39 | 40 | def count(self, value): 41 | return self.count_dict.get(value, 0) 42 | 43 | # Note: __not_eq__ will automatically be defined if __eq__ is defined in Python3 44 | def __eq__(self, other): 45 | if isinstance(other, Multiset): 46 | return self.count_dict == other.count_dict 47 | return False 48 | 49 | def __contains__(self, value): 50 | return value in self.count_dict 51 | 52 | def __len__(self): 53 | self._update_value_list() 54 | return len(self.value_list) 55 | 56 | def __iter__(self): 57 | self._update_value_list() 58 | return iter(self.value_list) 59 | 60 | def __repr__(self): 61 | self._update_value_list() 62 | if (len(self.value_list) == 0): 63 | return '{}' 64 | s = '{' 65 | for value in self.value_list: 66 | s += repr(value) + ', ' 67 | return s[: -2] + '}' 68 | 69 | def _update_value_list(self): 70 | if (not self.value_list_outdated): 71 | return 72 | self.value_list = [] 73 | for value, count in self.count_dict.items(): 74 | self.value_list.extend([value for i in range(count)]) 75 | self.value_list_outdated = False 76 | 77 | def Test(): 78 | m = Multiset(['r', 'r', 'g', 'a', 'r']) 79 | print(m) 80 | m.remove('r') 81 | print(m) 82 | m.insert('b') 83 | print(m) 84 | m.insert('a') 85 | print(m) 86 | # Iterator test 87 | print([x for x in m]) 88 | 89 | #Test() 90 | 91 | -------------------------------------------------------------------------------- /backend/multiset_test.py: -------------------------------------------------------------------------------- 1 | from multiset import Multiset 2 | 3 | from test_assertions import AssertEqual, AssertFailure 4 | 5 | def TestConstructInsertRemoveLen(): 6 | # Initialize multiset from 2 copies of each integer in [0, 10) 7 | m = Multiset(2 * list(range(10))) 8 | m.remove(5) 9 | m.insert(11) 10 | m.insert(11) 11 | m.insert(11) 12 | AssertEqual(m.count(0), 2) 13 | AssertEqual(m.count(5), 1) 14 | AssertEqual(m.count(11), 3) 15 | expected_count_dict = {i: 2 for i in range(10)} 16 | expected_count_dict[5] = 1 17 | expected_count_dict[11] = 3 18 | AssertEqual(m.count_dict, expected_count_dict) 19 | AssertEqual(len(m), sum(count for count in expected_count_dict.values())) 20 | print('TestConstructInsertRemoveLen passed!') 21 | 22 | # If values are only inserted, never deleted, the order of elements 23 | # received when iterating through the container should be deterministic. 24 | def TestDeterministicOrder(): 25 | input_values = list(range(1000)) + 10 * list(range(10)) + list(range(1000000)) 26 | m1 = Multiset(input_values) 27 | m2 = Multiset(input_values) 28 | for i in range(100000): 29 | m1.insert(3 * i) 30 | m2.insert(3 * i) 31 | AssertEqual(list(m1), list(m2)) 32 | print('TestDeterministicOrder passed!') 33 | 34 | 35 | def main(): 36 | TestConstructInsertRemoveLen() 37 | TestDeterministicOrder() 38 | print('All tests passed!') 39 | 40 | if (__name__ == '__main__'): 41 | main() -------------------------------------------------------------------------------- /backend/parse_helper.py: -------------------------------------------------------------------------------- 1 | ''' 2 | General parsing functions: 3 | - FindElement(element, collection) -> int 4 | - IsSubstringInLines(s: str, lines: List[str] or str) -> bool 5 | - FindFirstMatchingPredicate(s: str, predicate) -> int 6 | - MakeUniqueId(new_id: str, used_ids) -> str 7 | - ParseNumberFromString(input_string: str, starting_index: int = 0) -> int 8 | 9 | Loot filter related functions: 10 | - CommentedLine(line: str) 11 | - UncommentedLine(line: str) 12 | - IsCommented(line: str) -> bool 13 | - IsSectionOrGroupDeclaration(line: str) -> bool 14 | - ParseSectionOrGroupDeclarationLine(line) -> Tuple[bool, str, str] 15 | - FindShowHideLineIndex(rule_text_lines: List[str]) -> int 16 | - ParseRuleLineGeneric(line: str) -> Tuple[str, str, List[str]] 17 | - ParseTypeTierTags(rule_text_lines: List[str]) -> Tuple(str, str) 18 | - ConvertValuesStringToList(values_string: str) -> List[str] 19 | - ConvertValuesListToString(values_list: List[str]) -> str 20 | ''' 21 | 22 | import re 23 | from typing import List, Tuple 24 | 25 | import consts 26 | import simple_parser 27 | from type_checker import CheckType 28 | 29 | kShowHideLinePattern = re.compile(r'^\s*#?\s*(Show|Hide)') 30 | 31 | # ========================== Generic Helper Methods ========================== 32 | 33 | # Returns the index of the element in the collection if present, 34 | # or None if the element is not present. 35 | # TODO: Write unit tests for this. 36 | def FindElement(element, collection) -> int: 37 | for i, value in enumerate(collection): 38 | if (value == element): 39 | return i 40 | return None 41 | # End FindElement 42 | 43 | # Returns true if s is a substring of any of the strings in lines. 44 | def IsSubstringInLines(s: str, lines: List[str] or str) -> bool: 45 | CheckType(s, 's', str) 46 | if (isinstance(lines, str)): 47 | return s in lines 48 | CheckType(lines, 'lines', list, str) 49 | for line in lines: 50 | if s in line: 51 | return True 52 | return False 53 | # End IsSubstringInLines 54 | 55 | # Given a string and a predicate (function mapping character to bool), 56 | # returns the index of the first character in the string for which 57 | # the predicate returns True. 58 | # Returns -1 if predicate returns False for all characters in the string. 59 | def FindFirstMatchingPredicate(s: str, predicate) -> int: 60 | CheckType(s, 's', str) 61 | for i in range(len(s)): 62 | if (predicate(s[i])): return i 63 | return -1 64 | # End FindFirstMatchingPredicate() 65 | 66 | # If new_id already exists in used_ids, appends "_" followed by increasing 67 | # numeric values until an id is found which does not exist in used_ids. 68 | # Here, used_ids can be any data type for which "some_id in used_ids" works. 69 | def MakeUniqueId(new_id: str, used_ids) -> str: 70 | CheckType(new_id, 'new_id', str) 71 | candidate_id: str = new_id 72 | id_suffix_counter = 0 73 | while (candidate_id in used_ids): 74 | candidate_id = new_id + '_' + str(id_suffix_counter) 75 | id_suffix_counter += 1 76 | return candidate_id 77 | # End MakeUniqueId() 78 | 79 | # Parses the first encountered sequence of consecutive digits as an int. 80 | # For example, 'hello 123 456 world' would yield 123. 81 | # Note: assumes number is purely composed of digits (importantly: non-negative) 82 | def ParseNumberFromString(input_string: str, starting_index: int = 0) -> int: 83 | number_string = '' 84 | for c in input_string[starting_index:]: 85 | if (c.isdigit()): 86 | number_string += c 87 | else: 88 | break 89 | return int(number_string) 90 | # End ParseNumberFromString 91 | 92 | # ==================== Loot Filter Specifc Helper Methods ==================== 93 | 94 | def CommentedLine(line: str) -> str: 95 | CheckType(line, 'line', str) 96 | if (line.strip().startswith('#')): 97 | return line 98 | return '# ' + line 99 | # End CommentedLine 100 | 101 | def UncommentedLine(line: str) -> str: 102 | CheckType(line, 'line', str) 103 | if (line.strip().startswith('# ')): 104 | return line.replace('# ', '', 1) # 3rd parameter is max number of replacements 105 | elif (line.strip().startswith('#')): 106 | return line.replace('#', '', 1) # 3rd parameter is max number of replacements 107 | return line # already uncommented 108 | # End UncommentedLine 109 | 110 | def IsCommented(line: str) -> bool: 111 | CheckType(line, 'line', str) 112 | return line.strip().startswith('#') 113 | # End IsCommented 114 | 115 | # Returns True if the given line is a section declaration or section group 116 | # declaration, and False otherwise. 117 | def IsSectionOrGroupDeclaration(line: str) -> bool: 118 | CheckType(line, 'line', str) 119 | return bool(consts.kSectionRePattern.search(line)) 120 | # End IsSectionOrGroupDeclaration 121 | 122 | # Returns (is_section_group, section_id, section_name) triplet 123 | # Example: "# [[1000]] High Level Crafting Bases" -> "1000", "High Level Crafting Bases" 124 | # Or: "# [1234] ILVL 86" -> "1234", "ILVL 86" 125 | def ParseSectionOrGroupDeclarationLine(line) -> Tuple[bool, str, str]: 126 | CheckType(line, 'line', str) 127 | first_opening_bracket_index = -1 128 | id_start_index = -1 129 | id_end_index = -1 130 | name_start_index = -1 131 | found_opening_bracket = False 132 | for i in range(len(line)): 133 | if (first_opening_bracket_index == -1): 134 | if (line[i] == '['): 135 | first_opening_bracket_index = i 136 | continue 137 | elif (id_start_index == -1): 138 | if (line[i].isdigit()): 139 | id_start_index = i 140 | continue 141 | elif (id_end_index == -1): 142 | if (line[i] == ']'): 143 | id_end_index = i 144 | continue 145 | else: # name_start_index == -1 146 | if ((line[i] != ']') and (not line[i].isspace())): 147 | name_start_index = i 148 | break; 149 | is_section_group = (id_start_index - first_opening_bracket_index) > 1 150 | section_id = line[id_start_index : id_end_index] 151 | section_name = line[name_start_index :] 152 | return is_section_group, section_id, section_name 153 | # End ParseSectionOrGroupDeclarationLine 154 | 155 | # Returns the index of the Show/Hide line, or None if no such line found. 156 | # Robust to the rule having a header comment starting with 'Show'/'Hide'. 157 | def FindShowHideLineIndex(rule_text_lines: List[str] or str) -> int: 158 | if (isinstance(rule_text_lines, str)): 159 | rule_text_lines = rule_text_lines.split('\n') 160 | CheckType(rule_text_lines, 'rule_text_lines', list, str) 161 | for i in reversed(range(len(rule_text_lines))): 162 | if (re.search(kShowHideLinePattern, rule_text_lines[i])): 163 | return i 164 | return None 165 | # End FindShowHideLineIndex 166 | 167 | # A generic rule line is of the form: 168 | # This will be parsed as the tuple (keyword, operator_string, values_list). 169 | def ParseRuleLineGeneric(line: str) -> Tuple[str, str, List[str]]: 170 | CheckType(line, 'line', str) 171 | # Ensure line is uncommented and strip 172 | line = UncommentedLine(line).strip() 173 | # Split into keyword, (optional) op_string, values_string 174 | if (' ' not in line): 175 | keyword = line 176 | return keyword, '', [] 177 | keyword, op_and_values = line.split(' ', maxsplit=1) 178 | op_string = '' 179 | values_string = '' 180 | # Parse and update op_string if input line contains an operator 181 | split_result = op_and_values.split(' ', maxsplit=1) 182 | if (split_result[0] in consts.kOperatorMap): 183 | op_string = split_result[0] 184 | if (len(split_result) > 1): 185 | values_string = split_result[1] 186 | else: # no operator 187 | values_string = op_and_values 188 | return keyword, op_string, ConvertValuesStringToList(values_string) 189 | # End ParseRuleLineGeneric 190 | 191 | # Returns type_tag, tier_tag if rule has tags, else None, None. 192 | def ParseTypeTierTags(rule_text_lines: List[str]) -> Tuple[str, str]: 193 | CheckType(rule_text_lines, 'rule_text_lines', list, str) 194 | tag_line_index = FindShowHideLineIndex(rule_text_lines) 195 | if (tag_line_index == -1): 196 | return None, None 197 | tag_line = rule_text_lines[tag_line_index] 198 | success, tag_list = simple_parser.ParseFromTemplate( 199 | tag_line + ' ', template='{~}$type->{} $tier->{} {~}') 200 | return tuple(tag_list) if success else None 201 | # End ParseTypeTierTags 202 | 203 | # Convert a string of values to a list of strings. For example: 204 | # - '"Orb of Chaos" "Orb of Alchemy"' -> ['Orb of Chaos', 'Orb of Alchemy'] 205 | # - 'Boots Gloves Helmets "Body Armours"' -> ['Boots', 'Gloves', 'Helmets', "Body Armours"] 206 | # - '"Alteration.mp3" 300' -> ['Alteration.mp3', '300'] 207 | def ConvertValuesStringToList(values_string: str) -> List[str]: 208 | CheckType(values_string, 'values_string', str) 209 | return simple_parser.ParseEnclosedByOrSplitBy(values_string, '"', ' ') 210 | # End ConvertValuesStringToList 211 | 212 | # Returns double-quoted string if s contrains any of period, single quote, or space. 213 | # Otherwise, returns s. 214 | def QuoteStringIfRequired(s: str) -> str: 215 | CheckType(s, 's', str) 216 | return '"' + s + '"' if any(c in s for c in ".' ") else s 217 | # End QuoteStringIfRequired 218 | 219 | # Convert a list of string values to a single string. For example: 220 | # - ['Orb of Chaos', 'Orb of Alchemy'] -> '"Orb of Chaos" "Orb of Alchemy"' 221 | # - ['Helmets', 'Body Armours'] -> 'Helmets "Body Armours"' 222 | # - ['Alteration.mp3', '300'] -> '"Alteration.mp3" 300' 223 | def ConvertValuesListToString(values_list: List[str]) -> str: 224 | return ' '.join(QuoteStringIfRequired(s) for s in values_list) 225 | # ConvertValuesListToString 226 | -------------------------------------------------------------------------------- /backend/parse_helper_test.py: -------------------------------------------------------------------------------- 1 | import parse_helper 2 | 3 | import consts 4 | from test_assertions import AssertEqual, AssertTrue, AssertFalse 5 | 6 | kLinesWithTableOfContentsStart = \ 7 | '''# GITHUB: NeverSinkDev 8 | 9 | #=============================================================================================================== 10 | # [WELCOME] TABLE OF CONTENTS + QUICKJUMP TABLE 11 | #=============================================================================================================== 12 | # [[0100]] OVERRIDE AREA 1 - Override ALL rules here 13 | # [[0100]] Global overriding rules 14 | # [[0200]] High tier influenced items''' 15 | 16 | kShowHideLineIndexTestCases = [ 17 | ('''Show 18 | Sockets >= 6 19 | Rarity <= Rare 20 | SetFontSize 45 21 | SetTextColor 255 255 255 255 22 | SetBorderColor 255 255 255 255 23 | SetBackgroundColor 59 59 59 255 24 | PlayEffect Grey 25 | MinimapIcon 2 Grey Hexagon''', 0), 26 | 27 | ('''# Show Desired T16 Maps 28 | # Hide 29 | Show 30 | Class Maps 31 | BaseType == "Tower Map"''', 2)] 32 | 33 | # TODO: Add tests for remaining functions - most missing! 34 | 35 | def TestIsSubstringInLines(): 36 | AssertTrue(parse_helper.IsSubstringInLines( 37 | consts.kTableOfContentsIdentifier, kLinesWithTableOfContentsStart)) 38 | text_lines = kLinesWithTableOfContentsStart.split('\n') 39 | AssertTrue(parse_helper.IsSubstringInLines( 40 | consts.kTableOfContentsIdentifier, text_lines)) 41 | print('IsSubstringInLines passed!') 42 | 43 | def TestFindShowHideLineIndex(): 44 | for i, (rule_string, expected_index) in enumerate(kShowHideLineIndexTestCases): 45 | rule_lines = rule_string.split('\n') if (i % 2 == 1) else rule_string 46 | show_hide_line_index = parse_helper.FindShowHideLineIndex(rule_lines) 47 | AssertEqual(show_hide_line_index, expected_index) 48 | print('TestFindShowHideLineIndex passed!') 49 | 50 | kValuesStringListTestCases = [ 51 | ('"Orb of Chaos" "Orb of Alchemy"', ['Orb of Chaos', 'Orb of Alchemy']), 52 | ('Boots Gloves Helmets "Body Armours"', ['Boots', 'Gloves', 'Helmets', "Body Armours"]), 53 | ('"Alteration.mp3" 300', ['Alteration.mp3', '300'])] 54 | 55 | def TestConvertValuesStringToList(): 56 | for values_string, values_list in kValuesStringListTestCases: 57 | result_values_list = parse_helper.ConvertValuesStringToList(values_string) 58 | AssertEqual(result_values_list, values_list) 59 | print('TestConvertValuesStringToList passed!') 60 | 61 | def TestConvertValuesListToString(): 62 | for values_string, values_list in kValuesStringListTestCases: 63 | result_values_string = parse_helper.ConvertValuesListToString(values_list) 64 | AssertEqual(result_values_string, values_string) 65 | print('TestConvertValuesListToString passed!') 66 | 67 | def TestParseRuleLineGeneric(): 68 | # Standard rule line 69 | rule_line = '# BaseType == "Hubris Circlet" "Sorcerer\'s Gloves"' 70 | expected_parse_result = ('BaseType', '==', ['Hubris Circlet', "Sorcerer's Gloves"]) 71 | AssertEqual(parse_helper.ParseRuleLineGeneric(rule_line), expected_parse_result) 72 | # Rule line without operator 73 | rule_line = 'BaseType "Splinter of Chayula" "Splinter of Uul-Netol"' 74 | expected_parse_result = ('BaseType', '', ['Splinter of Chayula', 'Splinter of Uul-Netol']) 75 | AssertEqual(parse_helper.ParseRuleLineGeneric(rule_line), expected_parse_result) 76 | # Strange case for Oil rules 77 | rule_line = '# BaseType ==' 78 | expected_parse_result = ('BaseType', '==', []) 79 | AssertEqual(parse_helper.ParseRuleLineGeneric(rule_line), expected_parse_result) 80 | # Keyword only 81 | rule_line = 'Hide' 82 | expected_parse_result = ('Hide', '', []) 83 | AssertEqual(parse_helper.ParseRuleLineGeneric(rule_line), expected_parse_result) 84 | # Mixed quoted and unquoted items 85 | rule_line = 'CustomAlertSound "Fusing.mp3" 300 "Alteration.mp3" 200 100 "Orb of Chaos.mp3"' 86 | expected_parse_result = ('CustomAlertSound', '', 87 | ['Fusing.mp3', '300', 'Alteration.mp3', '200', '100', 'Orb of Chaos.mp3']) 88 | AssertEqual(parse_helper.ParseRuleLineGeneric(rule_line), expected_parse_result) 89 | print('TestParseRuleLineGeneric passed!') 90 | 91 | def main(): 92 | TestIsSubstringInLines() 93 | TestFindShowHideLineIndex() 94 | TestParseRuleLineGeneric() 95 | print('All tests passed!') 96 | 97 | if (__name__ == '__main__'): 98 | main() -------------------------------------------------------------------------------- /backend/profile_changes.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import shlex 3 | from typing import List 4 | 5 | from backend_cli_function_info import kFunctionInfoMap 6 | import file_helper 7 | import profile 8 | from type_checker import CheckType 9 | 10 | # ============================= Helper Functions ============================= 11 | 12 | # Encloses the string in double quotes if it contains a space or single quote, otherwise just 13 | # returns the given string. Note: does not check for double quotes in string. 14 | def QuoteStringIfRequired(input_string: str) -> str: 15 | CheckType(input_string, 'input_string', str) 16 | if ((" " in input_string) or ("'" in input_string)): 17 | return '"' + input_string + '"' 18 | return input_string 19 | # End QuoteStringIfRequired 20 | 21 | # Rather than use shlex.join, we want to join using double quotes ("Hello world") in the profile 22 | # changes file. This improves readability, because Path of Exile items never have double quotes, 23 | # but may have single quotes ("Sorcerer's Gloves" vs 'Sorcerer\'s Gloves'). 24 | def JoinParamsDoubleQuotes(params_list: List[str]) -> str: 25 | CheckType(params_list, 'params_list', list, str) 26 | return ' '.join(QuoteStringIfRequired(param) for param in params_list) 27 | # End JoinParamsDoubleQuotes 28 | 29 | # ============================== Profile Changes ============================== 30 | 31 | # Updates the given changes_dict, adding the new function call and removing any previous calls that 32 | # are rendered obselete. 33 | # Note: changes_dict is a chain of ordered dicts 34 | def AddFunctionCallTokensToChangesDict(function_tokens: List[str], changes_dict: OrderedDict): 35 | CheckType(function_tokens, 'function_tokens', list, str) 36 | CheckType(changes_dict, 'changes_dict', OrderedDict) 37 | current_dict = changes_dict 38 | function_name = function_tokens[0] 39 | num_params_for_match = kFunctionInfoMap[function_name]['NumParamsForMatch'] 40 | for i, current_token in enumerate(function_tokens): 41 | # Final token, maps to None 42 | if ((i + 1) == len(function_tokens)): 43 | current_dict[current_token] = None 44 | # At number of params for match, overwrite any existing function here 45 | elif (i == num_params_for_match): 46 | current_dict[current_token] = OrderedDict() 47 | # Regardless of what happened, step one level deeper (ensure there is a level deeper first) 48 | if (current_token not in current_dict): 49 | current_dict[current_token] = OrderedDict() 50 | current_dict = current_dict[current_token] 51 | # End AddFunctionCallTokensToChangesDict 52 | 53 | # Same as above, but takes the function call as a single string 54 | def AddFunctionCallStringToChangesDict(function_call_string: str, changes_dict: OrderedDict): 55 | CheckType(function_call_string, 'function_call_string', str) 56 | CheckType(changes_dict, 'changes_dict', OrderedDict) 57 | AddFunctionCallTokensToChangesDict(shlex.split(function_call_string), changes_dict) 58 | # End AddFunctionCallStringToChangesDict 59 | 60 | # Returns a chain of OrderedDicts representing the given profile's changes file 61 | def ParseProfileChanges(profile_name) -> OrderedDict: 62 | changes_dict = OrderedDict() 63 | changes_lines = file_helper.ReadFile(profile.GetProfileChangesFullpath(profile_name), 64 | strip=True, discard_empty_lines=True) 65 | for function_call_string in changes_lines: 66 | AddFunctionCallStringToChangesDict(function_call_string, changes_dict) 67 | return changes_dict 68 | # End ParseProfileChanges 69 | 70 | # Returns list of lists of function tokens, for example: 71 | # [['adjust_currency_tier', 'Chromatic Orb', '1'], 72 | # ['hide_uniques_above_tier', '3']] 73 | def ConvertChangesDictToFunctionTokenListListRec( 74 | changes_dict: OrderedDict, current_prefix_list: List[str] = []) -> List[List[str]]: 75 | CheckType(changes_dict, 'changes_dict', OrderedDict) 76 | CheckType(current_prefix_list, 'current_prefix_list', list, str) 77 | result_list = [] 78 | for param, subdict in changes_dict.items(): 79 | new_prefix_list = current_prefix_list + [param] 80 | if (subdict == None): 81 | result_list.append(new_prefix_list) 82 | else: 83 | result_list.extend(ConvertChangesDictToFunctionTokenListListRec( 84 | subdict, new_prefix_list)) 85 | return result_list 86 | # End ConvertChangesDictToFunctionListRec 87 | 88 | # Returns list of lists of function call strings, for example: 89 | # ['adjust_currency_tier "Chromatic Orb" 1', 90 | # 'hide_uniques_above_tier 3'] 91 | def ConvertChangesDictToFunctionCallStringList(changes_dict: OrderedDict) -> List[str]: 92 | CheckType(changes_dict, 'changes_dict', OrderedDict) 93 | # Get list of lists of function tokens from recursive function above 94 | token_lists = ConvertChangesDictToFunctionTokenListListRec(changes_dict) 95 | function_list = [] 96 | for token_list in token_lists: 97 | function_list.append(JoinParamsDoubleQuotes(token_list)) 98 | return function_list 99 | # End ConvertChangesDictToFunctionList 100 | 101 | # Adds the given change to the given profile's changes file, removing any previous changes that this 102 | # function call renders obselete. 103 | def AddChangeToProfile(new_function_name: str, 104 | new_function_params: List[str], 105 | profile_name: str,): 106 | CheckType(new_function_name, 'new_function_name', str) 107 | CheckType(new_function_params, 'new_function_params', list, str) 108 | CheckType(profile_name, 'profile_name', str) 109 | # Generate changes_dict by parsing changes file and adding new function call 110 | changes_path = profile.GetProfileChangesFullpath(profile_name) 111 | changes_dict = ParseProfileChanges(profile_name) 112 | AddFunctionCallTokensToChangesDict([new_function_name] + new_function_params, changes_dict) 113 | # Write changes_dict to changes file 114 | changes_list = ConvertChangesDictToFunctionCallStringList(changes_dict) 115 | file_helper.WriteToFile(changes_list, changes_path) 116 | # End AddChangeToProfile 117 | -------------------------------------------------------------------------------- /backend/profile_changes_test.py: -------------------------------------------------------------------------------- 1 | import profile_changes 2 | 3 | from collections import OrderedDict 4 | import shlex 5 | from typing import List 6 | 7 | import file_helper 8 | import profile 9 | import test_consts 10 | import test_helper 11 | from test_assertions import AssertEqual, AssertFailure 12 | from type_checker import CheckType 13 | 14 | kProfileChangesTestCaseInput = [ 15 | 'set_currency_to_tier "Chromatic Orb" 5', 16 | 'set_hide_maps_below_tier 11', 17 | 'set_basetype_visibility "Hubris Circlet" 1 1', 18 | 'set_currency_to_tier "Chromatic Orb" 6', 19 | 'set_hide_maps_below_tier 16', 20 | 'set_basetype_visibility "Hubris Circlet" 0', 21 | 'set_currency_to_tier "Chromatic Orb" 7', 22 | 'set_hide_maps_below_tier 12', 23 | 'set_basetype_visibility "Hubris Circlet" 1 0'] 24 | 25 | kProfileChangesTestCaseFinal = kProfileChangesTestCaseInput[-3:] 26 | 27 | # ============================= Helper Functions ============================= 28 | 29 | # Creates a chain of OrderedDict->OrderedDict->...->None from a single function call string 30 | def CreateOrderedDictChain(function_call_string: str) -> OrderedDict: 31 | CheckType(function_call_string, 'function_call_string', str) 32 | tokens = shlex.split(function_call_string) 33 | root_ordered_dict = OrderedDict() 34 | current_ordered_dict = root_ordered_dict 35 | for token in tokens[:-1]: 36 | current_ordered_dict[token] = OrderedDict() 37 | current_ordered_dict = current_ordered_dict[token] 38 | current_ordered_dict[tokens[-1]] = None 39 | return root_ordered_dict 40 | 41 | # Creates an OrderedDict of OrderedDict chains from a list of function call strings, 42 | # assuming the function names are all distinct. 43 | def CreateOrderedDictChains(function_call_strings: List[str]) -> OrderedDict: 44 | CheckType(function_call_strings, 'function_call_strings', list, str) 45 | ordered_dict = OrderedDict() 46 | for function_call_string in function_call_strings: 47 | ordered_dict.update(CreateOrderedDictChain(function_call_string)) 48 | return ordered_dict 49 | 50 | def TestCreateOrderedDictChains(): 51 | function_call_strings = [ 52 | 'set_currency_to_tier "Chromatic Orb" 5', 53 | 'set_hide_maps_below_tier 11' 54 | ] 55 | expected_ordered_dict = OrderedDict([ 56 | ('set_currency_to_tier', OrderedDict([('Chromatic Orb', OrderedDict([('5', None)]))])), 57 | ('set_hide_maps_below_tier', OrderedDict([('11', None)]))]) 58 | AssertEqual(CreateOrderedDictChains(function_call_strings), expected_ordered_dict) 59 | print('TestCreateOrderedDictChain passed!') 60 | 61 | # =================================== Tests =================================== 62 | 63 | def TestJoinParamsDoubleQuotes(): 64 | tokens = ["The", "quick brown", "fox's", "fur", "is soft."] 65 | expected_joined_string = '''The "quick brown" "fox's" fur "is soft."''' 66 | AssertEqual(profile_changes.JoinParamsDoubleQuotes(tokens), expected_joined_string) 67 | print('TestJoinParamsDoubleQuotes passed!') 68 | 69 | def TestNonClashingAddFunctionToChangesDict(): 70 | function_call_strings = [ 71 | 'set_currency_to_tier "Chromatic Orb" 5', 72 | 'set_hide_maps_below_tier 11', 73 | 'set_lowest_visible_oil "Azure Oil"'] 74 | expected_changes_dict = CreateOrderedDictChains(function_call_strings) 75 | changes_dict = OrderedDict() 76 | for function_call_string in function_call_strings: 77 | profile_changes.AddFunctionCallStringToChangesDict(function_call_string, changes_dict) 78 | AssertEqual(changes_dict, expected_changes_dict) 79 | print('TestNonClashingAddFunctionToChangesDict passed!') 80 | 81 | # Tests AddFunctionCallTokensToChangesDict and AddFunctionCallStringToChangesDict. 82 | # The latter delegates to the former, so we expect their correctness to be tightly coupled. 83 | def TestAddFunctionToChangesDict(): 84 | expected_changes_dict = CreateOrderedDictChains(kProfileChangesTestCaseFinal) 85 | changes_dict_strings = OrderedDict() 86 | changes_dict_tokens = OrderedDict() 87 | for function_call_string in kProfileChangesTestCaseInput: 88 | profile_changes.AddFunctionCallStringToChangesDict( 89 | function_call_string, changes_dict_strings) 90 | function_name, *function_params = shlex.split(function_call_string) 91 | profile_changes.AddFunctionCallTokensToChangesDict( 92 | [function_name] + function_params, changes_dict_tokens) 93 | AssertEqual(changes_dict_tokens, expected_changes_dict) 94 | AssertEqual(changes_dict_strings, expected_changes_dict) 95 | print('TestAddFunctionToChangesDict passed!') 96 | 97 | def TestParseChangesFile(): 98 | test_helper.SetUp() 99 | changes_path = profile.GetProfileChangesFullpath(test_consts.kTestProfileName) 100 | file_helper.WriteToFile(kProfileChangesTestCaseInput, changes_path) 101 | parsed_changes_dict = profile_changes.ParseProfileChanges(test_consts.kTestProfileName) 102 | # Use previous test as expected result, since it's already passed by now 103 | expected_changes_dict = OrderedDict() 104 | for function_call_string in kProfileChangesTestCaseInput: 105 | profile_changes.AddFunctionCallStringToChangesDict( 106 | function_call_string, expected_changes_dict) 107 | AssertEqual(parsed_changes_dict, expected_changes_dict) 108 | print('TestParseChangesFile passed!') 109 | 110 | # Tests ConvertChangesDictToFunctionListRec and ConvertChangesDictToFunctionCallStringList. 111 | # The latter delegates to the former, so we expect their correctness to be tightly coupled. 112 | def TestConvertChangesDictTo(): 113 | # Generate input changes_dict 114 | changes_dict = OrderedDict() 115 | for function_call_string in kProfileChangesTestCaseInput: 116 | profile_changes.AddFunctionCallStringToChangesDict(function_call_string, changes_dict) 117 | # Test ConvertChangesDictToFunctionListRec 118 | expected_function_token_list_list = [shlex.split(s) for s in kProfileChangesTestCaseFinal] 119 | AssertEqual(profile_changes.ConvertChangesDictToFunctionTokenListListRec(changes_dict), 120 | expected_function_token_list_list) 121 | # Test ConvertChangesDictToFunctionCallStringList 122 | AssertEqual(profile_changes.ConvertChangesDictToFunctionCallStringList(changes_dict), 123 | kProfileChangesTestCaseFinal) 124 | print('TestConvertChangesDictTo passed!') 125 | 126 | def TestAddChangeToChangesFile(): 127 | test_helper.SetUp() 128 | expected_changes_dict = OrderedDict() 129 | for function_call_string in kProfileChangesTestCaseInput: 130 | function_name, *function_params = shlex.split(function_call_string) 131 | profile_changes.AddChangeToProfile( 132 | function_name, function_params, test_consts.kTestProfileName) 133 | # Update expected_changes_dict with matching change 134 | profile_changes.AddFunctionCallStringToChangesDict( 135 | function_call_string, expected_changes_dict) 136 | # Parse final result to check if it's correct 137 | parsed_changes_dict = profile_changes.ParseProfileChanges(test_consts.kTestProfileName) 138 | AssertEqual(parsed_changes_dict, expected_changes_dict) 139 | print('TestAddChangeToChangesFile passed!') 140 | 141 | def main(): 142 | TestCreateOrderedDictChains() 143 | TestJoinParamsDoubleQuotes() 144 | TestNonClashingAddFunctionToChangesDict() 145 | TestAddFunctionToChangesDict() 146 | TestParseChangesFile() 147 | TestConvertChangesDictTo() 148 | TestAddChangeToChangesFile() 149 | test_helper.TearDown() 150 | print('All tests passed!') 151 | 152 | if (__name__ == '__main__'): 153 | main() -------------------------------------------------------------------------------- /backend/profile_test.py: -------------------------------------------------------------------------------- 1 | import profile 2 | 3 | import file_helper 4 | import os.path 5 | from test_assertions import AssertEqual, AssertTrue, AssertFalse 6 | import test_consts 7 | import test_helper 8 | 9 | def SetUp(): 10 | TearDown() 11 | test_helper.SetUp(create_profile=False) 12 | 13 | def TearDown(): 14 | test_helper.TearDown() 15 | # Remove '.config', '.changes', and '.rules' files generated by test Profiles 16 | for profile_name in test_consts.kTestProfileNames: 17 | file_helper.RemoveFileIfExists(os.path.join( 18 | profile.kProfileDirectory, profile_name + '.config')) 19 | file_helper.RemoveFileIfExists(os.path.join( 20 | profile.kProfileDirectory, profile_name + '.changes')) 21 | file_helper.RemoveFileIfExists(os.path.join( 22 | profile.kProfileDirectory, profile_name + '.rules')) 23 | 24 | def TestConfigChangesRulesPaths(): 25 | profile_name = test_consts.kTestProfileName 26 | expected_fullpath_stem = os.path.join(profile.kProfileDirectory, profile_name) 27 | # Config 28 | config_fullpath = profile.GetProfileConfigFullpath(profile_name) 29 | AssertEqual(config_fullpath, expected_fullpath_stem + '.config') 30 | # Changes 31 | changes_fullpath = profile.GetProfileChangesFullpath(profile_name) 32 | AssertEqual(changes_fullpath, expected_fullpath_stem + '.changes') 33 | # Rules 34 | rules_fullpath = profile.GetProfileRulesFullpath(profile_name) 35 | AssertEqual(rules_fullpath, expected_fullpath_stem + '.rules') 36 | print('TestConfigChangesRulesPaths passed!') 37 | 38 | def TestListProfileNames(): 39 | expected_profile_names = [] 40 | for f in os.listdir(profile.kProfileDirectory): 41 | filestem, extension = os.path.splitext(f) 42 | if ((extension == '.config') and (filestem != 'general')): 43 | expected_profile_names.append(filestem) 44 | profile_names = profile.ListProfilesRaw() 45 | AssertEqual(sorted(profile_names), sorted(expected_profile_names)) 46 | print('TestListProfileNames passed!') 47 | 48 | def TestCreateRenameDeleteProfile(): 49 | print('TestCreateRenameDeleteProfile') 50 | SetUp() 51 | profile_name = test_consts.kTestProfileName 52 | AssertFalse(profile.ProfileExists(profile_name)) 53 | # Create profile 54 | created_profile = profile.CreateNewProfile(profile_name, test_consts.kTestProfileConfigValues) 55 | AssertTrue(created_profile != None) 56 | AssertTrue(created_profile.name == profile_name) 57 | AssertTrue(profile.ProfileExists(profile_name)) 58 | # Rename profile 59 | new_profile_name = 'TestProfile_EketPW7aflDMiJ220H7M' 60 | profile.RenameProfile(profile_name, new_profile_name) 61 | AssertFalse(profile.ProfileExists(profile_name)) 62 | AssertTrue(profile.ProfileExists(new_profile_name)) 63 | # Delete profile 64 | profile.DeleteProfile(new_profile_name) 65 | AssertFalse(profile.ProfileExists(new_profile_name)) 66 | print('TestCreateRenameDeleteProfile passed!') 67 | 68 | def TestSetGetActiveProfile(): 69 | SetUp() 70 | profile_name = test_consts.kTestProfileNames[1] 71 | other_profile_name = test_consts.kTestProfileNames[2] 72 | # Create profile and verify it is the active profile after creation 73 | profile.CreateNewProfile(profile_name, test_consts.kTestProfileConfigValues) 74 | AssertTrue(profile.ProfileExists(profile_name)) 75 | AssertEqual(profile.GetActiveProfileName(), profile_name) 76 | # Create other profile similarly 77 | profile.CreateNewProfile(other_profile_name, test_consts.kTestProfileConfigValues) 78 | AssertTrue(profile.ProfileExists(other_profile_name)) 79 | AssertEqual(profile.GetActiveProfileName(), other_profile_name) 80 | # Set/get active profile (profile_name) 81 | profile.SetActiveProfile(profile_name) 82 | active_profile_name = profile.GetActiveProfileName() 83 | AssertEqual(active_profile_name, profile_name) 84 | # Set/get active profile (other_profile_name) 85 | profile.SetActiveProfile(other_profile_name) 86 | active_profile_name = profile.GetActiveProfileName() 87 | AssertEqual(active_profile_name, other_profile_name) 88 | # Check GetAllProfileNames() returns active profile first 89 | get_all_profile_names_result = profile.GetAllProfileNames() 90 | AssertTrue(len(get_all_profile_names_result) >= 2) 91 | AssertEqual(get_all_profile_names_result[0], active_profile_name) 92 | # Verify that both profile names are present 93 | AssertTrue(profile_name in get_all_profile_names_result) 94 | AssertTrue(other_profile_name in get_all_profile_names_result) 95 | # Delete profiles and verify they are not still the active profile 96 | profile.DeleteProfile(profile_name) 97 | profile.DeleteProfile(other_profile_name) 98 | AssertTrue(profile.GetActiveProfileName() not in (profile_name, other_profile_name)) 99 | print('TestSetGetActiveProfile passed!') 100 | 101 | ''' 102 | Exampe Profile config_values: 103 | 104 | ProfileName : DefaultProfile (str) 105 | DownloadDirectory : FiltersDownload (str) 106 | InputLootFilterDirectory : FiltersInput (str) (derived) 107 | PathOfExileDirectory : FiltersPathOfExile (str) 108 | DownloadedLootFilterFilename : BrandLeaguestart.filter (str) 109 | OutputLootFilterFilename : DynamicLootFilter.filter (str) 110 | RemoveDownloadedFilter : False (bool) 111 | HideMapsBelowTier : 0 (int) # To be removed in future 112 | AddChaosRecipeRules : True (str) # To be removed in future 113 | ChaosRecipeWeaponClassesAnyHeight : "Daggers" "Rune Daggers" "Wands" (str) 114 | ChaosRecipeWeaponClassesMaxHeight3 : "Bows" (str) 115 | DownloadedLootFilterFullpath : FiltersDownload/BrandLeaguestart.filter (str) (derived) 116 | InputLootFilterFullpath : FiltersInput/BrandLeaguestart.filter (str) (derived) 117 | OutputLootFilterFullpath : FiltersPathOfExile/DynamicLootFilter.filter (str) (derived) 118 | ''' 119 | def TestParseProfile(): 120 | SetUp() 121 | profile_name = test_consts.kTestProfileName 122 | created_profile = profile.CreateNewProfile(profile_name, test_consts.kTestProfileConfigValues) 123 | AssertEqual(created_profile.config_values['ProfileName'], profile_name) 124 | # Check required config values match exactly 125 | AssertEqual(created_profile.config_values['DownloadDirectory'], 126 | test_consts.kTestProfileConfigValues['DownloadDirectory']) 127 | AssertEqual(created_profile.config_values['PathOfExileDirectory'], 128 | test_consts.kTestProfileConfigValues['PathOfExileDirectory']) 129 | AssertEqual(created_profile.config_values['DownloadedLootFilterFilename'], 130 | test_consts.kTestProfileConfigValues['DownloadedLootFilterFilename']) 131 | # Check input directory config value exists 132 | AssertTrue('InputLootFilterDirectory' in created_profile.config_values) 133 | AssertEqual(created_profile.config_values['OutputLootFilterFilename'], 'DynamicLootFilter.filter') 134 | AssertTrue(created_profile.config_values['RemoveDownloadedFilter'] in (True, False)) 135 | # Check Chaos params exist 136 | AssertTrue('ChaosRecipeWeaponClassesAnyHeight' in created_profile.config_values) 137 | AssertTrue('ChaosRecipeWeaponClassesMaxHeight3' in created_profile.config_values) 138 | # Check derived paths are correct 139 | expected_downloaded_filter_fullpath = os.path.join( 140 | test_consts.kTestProfileConfigValues['DownloadDirectory'], 141 | test_consts.kTestProfileConfigValues['DownloadedLootFilterFilename']) 142 | AssertEqual(created_profile.config_values['DownloadedLootFilterFullpath'], 143 | expected_downloaded_filter_fullpath) 144 | expected_input_filter_fullpath = os.path.join( 145 | created_profile.config_values['InputLootFilterDirectory'], 146 | test_consts.kTestProfileConfigValues['DownloadedLootFilterFilename']) 147 | AssertEqual(created_profile.config_values['InputLootFilterFullpath'], 148 | expected_input_filter_fullpath) 149 | expected_output_filter_fullpath = os.path.join( 150 | created_profile.config_values['PathOfExileDirectory'], 'DynamicLootFilter.filter') 151 | AssertEqual(created_profile.config_values['OutputLootFilterFullpath'], 152 | expected_output_filter_fullpath) 153 | # Cleanup: delete test profile 154 | profile.DeleteProfile(profile_name) 155 | print('TestParseProfile passed!') 156 | 157 | def TestWriteProfile(): 158 | SetUp() 159 | profile_name = test_consts.kTestProfileName 160 | created_profile = profile.CreateNewProfile(profile_name, test_consts.kTestProfileConfigValues) 161 | config_lines = file_helper.ReadFile( 162 | profile.GetProfileConfigFullpath(profile_name), strip=True) 163 | # Just verify the paths for simplicity (doesn't test everything): 164 | expected_download_directory_line = 'Download directory: {}'.format( 165 | test_consts.kTestProfileConfigValues['DownloadDirectory']) 166 | expected_poe_directory_line = 'Path of Exile directory: {}'.format( 167 | test_consts.kTestProfileConfigValues['PathOfExileDirectory']) 168 | expected_downloaded_filter_filename_line = 'Downloaded loot filter filename: {}'.format( 169 | test_consts.kTestProfileConfigValues['DownloadedLootFilterFilename']) 170 | expected_output_filter_filename_line = \ 171 | 'Output (Path of Exile) loot filter filename: DynamicLootFilter.filter' 172 | AssertTrue(expected_download_directory_line in config_lines) 173 | AssertTrue(expected_poe_directory_line in config_lines) 174 | AssertTrue(expected_downloaded_filter_filename_line in config_lines) 175 | AssertTrue(expected_output_filter_filename_line in config_lines) 176 | # Cleanup: delete test profile 177 | profile.DeleteProfile(profile_name) 178 | print('TestWriteProfile passed!') 179 | 180 | def main(): 181 | TestConfigChangesRulesPaths() 182 | TestListProfileNames() 183 | TestCreateRenameDeleteProfile() 184 | TestSetGetActiveProfile() 185 | TestParseProfile() 186 | TestWriteProfile() 187 | TearDown() 188 | print('All tests passed!') 189 | 190 | if (__name__ == '__main__'): 191 | main() -------------------------------------------------------------------------------- /backend/resources.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import consts 4 | import file_helper 5 | 6 | # List of all Flask BaseTypes 7 | kFlaskBaseTypesTxtFilepath = os.path.join(consts.kResourcesDirectory, 'flask_base_types.txt') 8 | kAllFlaskTypes = file_helper.ReadFile(kFlaskBaseTypesTxtFilepath, strip=True) -------------------------------------------------------------------------------- /backend/run_code_checks.py: -------------------------------------------------------------------------------- 1 | ''' 2 | [Implemented] 3 | This script runs plyint with the -E flag to report all static errors in the 4 | python code contained in the current directory. 5 | 6 | [TODO - NYI] 7 | This script also performs very minor code formatting for each .py file 8 | in the current directory, consisting of the following: 9 | - Strip any trailing whitespace from each line 10 | - Ensure each file ends with a single blank line 11 | ''' 12 | 13 | import os 14 | import os.path 15 | 16 | from consts import kBackendDirectory 17 | 18 | kPylintCommand = 'pylint -E {}'.format(kBackendDirectory) 19 | 20 | def main(): 21 | print('Running: "{}"'.format(kPylintCommand)) 22 | os.system(kPylintCommand) 23 | print('\nCode checks completetd.') 24 | 25 | if (__name__ == '__main__'): 26 | main() 27 | -------------------------------------------------------------------------------- /backend/run_unit_tests.py: -------------------------------------------------------------------------------- 1 | ''' 2 | By default, skips slow tests (tests that take more than a few seconds). 3 | 4 | Usage: python run_unit_tests <(optional) run_all_tests: int = 0> 5 | ''' 6 | 7 | import os 8 | import sys 9 | 10 | import consts 11 | import file_helper 12 | 13 | kSlowTestFilenames = ['backend_cli_test.py'] 14 | 15 | kHorizontalSeparator = '=' * 80 16 | 17 | def main(): 18 | print(sys.argv) 19 | run_all_tests = False 20 | if ((len(sys.argv) >= 2) and bool(int(sys.argv[1]))): 21 | run_all_tests = True 22 | fullpaths = file_helper.ListFilesInDirectory(consts.kBackendDirectory, fullpath=True) 23 | unit_test_fullpaths = [f for f in fullpaths if f.endswith('_test.py')] 24 | failed_unit_tests = [] 25 | print(kHorizontalSeparator) 26 | # Run tests 27 | for unit_test_fullpath in unit_test_fullpaths: 28 | unit_test_filename = os.path.basename(unit_test_fullpath) 29 | if (not run_all_tests and unit_test_filename in kSlowTestFilenames): 30 | continue 31 | command = 'python {}'.format(unit_test_fullpath) 32 | print('Running: {} ...\n'.format(unit_test_filename)) 33 | return_code = os.system(command) 34 | passed_flag = (return_code == 0) 35 | if (not passed_flag): 36 | failed_unit_tests.append(unit_test_filename) 37 | print('\n{} {}!'.format(unit_test_filename, 'passed' if passed_flag else 'FAILED')) 38 | print(kHorizontalSeparator) 39 | # Print a message indicating whether all tests passed, or some failed 40 | if (len(failed_unit_tests) > 0): 41 | print('Error: The following unit tests FAILED:') 42 | for failed_unit_test in failed_unit_tests: 43 | print(' • {}'.format(failed_unit_test)) 44 | else: 45 | print('Congratulations! All unit tests passed:') 46 | for passed_unit_test in unit_test_fullpaths: 47 | print(' • {}'.format(passed_unit_test)) 48 | print(kHorizontalSeparator) 49 | 50 | if (__name__ == '__main__'): 51 | main() -------------------------------------------------------------------------------- /backend/simple_parser.py: -------------------------------------------------------------------------------- 1 | ''' 2 | - ParseFromTemplate(s: str, template: str) -> Tuple[bool, List[str]] 3 | - ParseEnclosedBy(line: str, start_seq: str, end_seq: str = None) -> List[str] 4 | - IsInt(s: str or int) -> bool 5 | - ParseInts(line: str or int) -> List[int] 6 | - ParseValueDynamic(s: Any) -> Any 7 | ''' 8 | 9 | from typing import List, Tuple, Any 10 | 11 | from type_checker import CheckType 12 | 13 | kWildcardMatchString = '{}' 14 | kWildcardIgnoreString = '{~}' 15 | kTerminationChar = '*' 16 | 17 | # Returns (is_wildcard: bool, is_match: bool, wildcard_length: int) triplet 18 | def IsWildcard(template: str, index: int) -> Tuple[bool, bool, int]: 19 | CheckType(template, 'template', str) 20 | CheckType(index, 'index', int) 21 | if (template[index :].startswith(kWildcardMatchString)): 22 | return True, True, len(kWildcardMatchString) 23 | elif (template[index :].startswith(kWildcardIgnoreString)): 24 | return True, False, len(kWildcardIgnoreString) 25 | return False, False, 0 26 | # End IsWildcard 27 | 28 | # Input string to parse must not contain "wildcards": '{}' or '{~}'. 29 | # Neither input nor template string may contain '*' (reserved as a termination char). 30 | # Wildcards in template string indicate to match a sequence of characters: 31 | # - '{}' keeps the matched substring as part of the parse result 32 | # - '{~}' discards the matched substring 33 | # Wildcards can match the empty string. Consecutive wildcards are not permitted. 34 | # Returns the pair: parse_success, parse_result_list 35 | # Example: ParseFromTemplate("abc:xyz", "{}:{~}") -> true, ["abc"] 36 | def ParseFromTemplate(s: str, template: str) -> Tuple[bool, List[str]]: 37 | CheckType(s, 's', str) 38 | CheckType(template, 'template', str) 39 | # The logic is much simpler if we append an identical character to both strings, 40 | # so we don't have to add special conditions for end of string. 41 | s += kTerminationChar 42 | template += kTerminationChar 43 | s_index: int = 0 44 | template_index: int = 0 45 | token_list = [] 46 | in_token = False 47 | current_token = '' 48 | keep_token = False 49 | while (s_index < len(s)): 50 | is_wildcard, is_match, wc_length = IsWildcard(template, template_index) 51 | # Case 1: encountered new token start 52 | if (is_wildcard): 53 | # Start new token 54 | in_token = True 55 | keep_token = is_match 56 | template_index += wc_length 57 | # Case 2: characters match -> end token if in token 58 | elif ((template_index < len(template)) and (s[s_index] == template[template_index])): 59 | if (in_token): 60 | if (keep_token): 61 | token_list.append(current_token) 62 | in_token = False 63 | current_token = '' 64 | # Whether inside or outside of token, characters matched so we increment indices 65 | s_index += 1 66 | template_index += 1 67 | # Case 3: characters don't match and we're already in token -> swallow up character in s 68 | elif (in_token): 69 | current_token += s[s_index] 70 | s_index += 1 71 | # Case 4: not in token, characters don't match, and not starting new token - Error 72 | else: 73 | return False, [] 74 | # If we're not at the end of *both* strings here, it was a mismatch 75 | if (template_index < len(template)): 76 | return False, [] 77 | # Otherwise, we have a successful match - add last token if needed 78 | if (in_token and keep_token): 79 | token_list.append(current_token) 80 | return True, token_list 81 | # End ParseFromTemplate 82 | 83 | # Example: parsing the string 'BaseType "Leather Belt" "Two-Stone Ring" "Agate Amulet"' 84 | # with start_seq = '"' yields ['Leather Belt', 'Two-Stone Ring', 'Agate Amulet']. 85 | def ParseEnclosedBy(line: str, start_seq: str, end_seq: str = None) -> List[str]: 86 | if (end_seq == None): end_seq = start_seq 87 | CheckType(line, 'line', str) 88 | CheckType(start_seq, 'start_seq', str) 89 | CheckType(end_seq, 'end_seq', str) 90 | index = 0 91 | token_list = [] 92 | while (0 <= index < len(line)): 93 | start_find_result = line.find(start_seq, index) 94 | if (start_find_result == -1): break 95 | start_index = start_find_result + len(start_seq) 96 | end_find_result = line.find(end_seq, start_index + 1) 97 | if (end_find_result == -1): break 98 | end_index = end_find_result 99 | token_list.append(line[start_index : end_index]) 100 | index = end_index + 1 101 | return token_list 102 | # End ParseEnclosedBy 103 | 104 | # Example: parsing the string '"Leather Belt" Amulet Boots "Two-Stone Ring"' with 105 | # enclosed_by = '"', split_by = ' ' yields ['Leather Belt', 'Amulet', 'Boots', 'Two-Stone Ring']. 106 | # Note: each delimeter must be a single character. 107 | def ParseEnclosedByOrSplitBy(line: str, enclosed_by_char:str, split_by_char:str) -> List[str]: 108 | CheckType(line, 'line', str) 109 | CheckType(enclosed_by_char, 'enclosed_by_char', str) 110 | CheckType(split_by_char, 'split_by_char', str) 111 | if (line == ''): 112 | return [] 113 | split_result = line.split(split_by_char) 114 | token_list = [] 115 | in_token_flag = False 116 | for s in split_result: 117 | if (in_token_flag): 118 | if (s.endswith(enclosed_by_char)): 119 | s = s[:-1] 120 | in_token_flag = False 121 | token_list[-1] += split_by_char + s 122 | elif (s.startswith(enclosed_by_char)): 123 | s = s[1:] 124 | if (s.endswith(enclosed_by_char)): 125 | token_list.append(s[:-1]) 126 | else: 127 | token_list.append(s) 128 | in_token_flag = True 129 | else: 130 | token_list.append(s) 131 | return token_list 132 | # End ParseEnclosedByOrSplitBy 133 | 134 | def IsInt(s: str or int) -> bool: 135 | if (isinstance(s, int)): 136 | return s 137 | CheckType(s, 's', str) 138 | try: 139 | int(s) 140 | except: 141 | return False 142 | return True 143 | # End IsInt 144 | 145 | def ParseInts(line: str or int) -> List[int]: 146 | if (isinstance(line, int)): 147 | return [line] 148 | CheckType(line, 'line', str) 149 | parsed_ints = [] 150 | current_int_string = '' 151 | # Add non-digit to end of line to handle last integer uniformly 152 | for c in line + ' ': 153 | if ((len(current_int_string) > 0) and not c.isdigit()): 154 | parsed_ints.append(int(current_int_string)) 155 | current_int_string = '' 156 | elif c.isdigit(): 157 | current_int_string += c 158 | return parsed_ints 159 | # End ParseInts 160 | 161 | # Attempts to parse the string as a value of various types, and returns the parsed value: 162 | # - If the string's is True/False/true/false, returns bool 163 | # - If the string can be parsed as an int, returns int 164 | # - Otherwise, returns the input string 165 | # If the input value is not a string, returns the input value directly. 166 | def ParseValueDynamic(s: Any) -> Any: 167 | if (not isinstance(s, str)): 168 | return s 169 | if (s in ('True', 'False', 'true', 'false')): 170 | return s.lower() == 'true' 171 | elif (IsInt(s)): 172 | return int(s) 173 | return s 174 | # End ParseValueDynamic -------------------------------------------------------------------------------- /backend/simple_parser_test.py: -------------------------------------------------------------------------------- 1 | import simple_parser 2 | 3 | from test_assertions import AssertEqual, AssertTrue, AssertFalse 4 | 5 | def TestParseFromTemplate(): 6 | # Very simple parse: single item 7 | text = 'hello' 8 | template = '{}' 9 | success, result = simple_parser.ParseFromTemplate(text, template) 10 | AssertTrue(success) 11 | AssertEqual(result, ['hello']) 12 | # Very simple parse: single item enclosed by other characters 13 | text = '[(quick brown fox)]' 14 | template = '[({})]' 15 | success, result = simple_parser.ParseFromTemplate(text, template) 16 | AssertTrue(success) 17 | AssertEqual(result, ['quick brown fox']) 18 | # Multi item parse 19 | text = 'One (two three four) five' 20 | template = '{}({}){}' 21 | success, result = simple_parser.ParseFromTemplate(text, template) 22 | AssertTrue(success) 23 | AssertEqual(result, ['One ', 'two three four', ' five']) 24 | # Mismatch test - text missing item 25 | text = '123.456.789' 26 | template = '{}.{}.{}.' 27 | success, result = simple_parser.ParseFromTemplate(text, template) 28 | AssertFalse(success) 29 | # Mismatch test - text with extra item 30 | text = '123.456.789' 31 | template = '{}.{}.' 32 | success, result = simple_parser.ParseFromTemplate(text, template) 33 | AssertFalse(success) 34 | # Multi item parse with ignored portions 35 | text = 'The quick (brown fox, jumps, over the) lazy dog' 36 | template = '{~}({},{~},{}){~}' 37 | success, result = simple_parser.ParseFromTemplate(text, template) 38 | AssertTrue(success) 39 | AssertEqual(result, ['brown fox', ' over the']) 40 | # Match wildcard can match empty string 41 | text = '' 42 | template = '{}' 43 | success, result = simple_parser.ParseFromTemplate(text, template) 44 | AssertTrue(success) 45 | AssertEqual(result, ['']) 46 | # Ignore wildcard can match empty string 47 | text = '-Word-' 48 | template = '{~}-{}-{~}' 49 | success, result = simple_parser.ParseFromTemplate(text, template) 50 | AssertTrue(success) 51 | AssertEqual(result, ['Word']) 52 | # Rule tag parse 53 | type_tag = 'decorator->craftingrare' 54 | tier_tag = 'raredecoratorgear' 55 | text = 'Show # $type->{} $tier->{}'.format(type_tag, tier_tag) 56 | template = 'Show {~}$type->{} $tier->{} {~}' 57 | success, result = simple_parser.ParseFromTemplate(text + ' ', template) 58 | AssertTrue(success) 59 | AssertEqual(result, [type_tag, tier_tag]) 60 | text = 'Show # $type->{} $tier->{} $other_tag other text'.format(type_tag, tier_tag) 61 | template = 'Show {~}$type->{} $tier->{} {~}' 62 | success, result = simple_parser.ParseFromTemplate(text + ' ', template) 63 | AssertTrue(success) 64 | AssertEqual(result, [type_tag, tier_tag]) 65 | print('TestParseFromTemplate passed!') 66 | # End TestParseFromTemplate 67 | 68 | def TestParseEnclosedBy(): 69 | # Basic case 70 | text = 'BaseType "Leather Belt" "Two-Stone Ring" "Agate Amulet"' 71 | result = simple_parser.ParseEnclosedBy(text, '"') 72 | AssertEqual(result, ['Leather Belt', 'Two-Stone Ring', 'Agate Amulet']) 73 | # Last item missing closing sequence 74 | text = 'The "quick" "brown fox" jumps over the "lazy dog ' 75 | result = simple_parser.ParseEnclosedBy(text, '"', '"') 76 | AssertEqual(result, ['quick', 'brown fox']) 77 | # Multi-character enclosing sequence 78 | text = 'Hello ((beautiful)) universe' 79 | result = simple_parser.ParseEnclosedBy(text, '((', '))') 80 | AssertEqual(result, ['beautiful']) 81 | # Re-encounter opening sequence before closing sequence 82 | text = 'One [(two)] three [(four )[(five)]' 83 | result = simple_parser.ParseEnclosedBy(text, '[(', ')]') 84 | AssertEqual(result, ['two', 'four )[(five']) 85 | print('TestParseEnclosedBy passed!') 86 | # End TestParseEnclosedBy 87 | 88 | def TestParseEnclosedByOrSplitBy(): 89 | # Empty string case 90 | result = simple_parser.ParseEnclosedByOrSplitBy('', '%', ':') 91 | AssertEqual(result, []) 92 | # Mixed quotes and spaces case 93 | text = '"Leather Belt" Amulet Boots "Two-Stone Ring" "Orb of Alchemy"' 94 | result = simple_parser.ParseEnclosedByOrSplitBy(text, '"', ' ') 95 | AssertEqual(result, ['Leather Belt', 'Amulet', 'Boots', 'Two-Stone Ring', 'Orb of Alchemy']) 96 | print('ParseEnclosedByOrSplitBy passed!') 97 | # End TestParseEnclosedByOrSplitBy 98 | 99 | def TestIsInt(): 100 | AssertTrue(simple_parser.IsInt('-5')) 101 | AssertFalse(simple_parser.IsInt('1.2')) 102 | AssertFalse(simple_parser.IsInt('314 days')) 103 | print('TestIsInt passed!') 104 | # End TestIsInt 105 | 106 | def TestParseInts(): 107 | text = 'asdf45 re2 7432' 108 | result = simple_parser.ParseInts(text) 109 | AssertEqual(result, [45, 2, 7432]) 110 | print('TestParseInts passed!') 111 | # End TestParseInts 112 | 113 | def TestParseValueDynamic(): 114 | s = 'hello' 115 | AssertEqual(simple_parser.ParseValueDynamic('hello'), 'hello') 116 | AssertEqual(simple_parser.ParseValueDynamic('1234'), 1234) 117 | AssertEqual(simple_parser.ParseValueDynamic('hello 1234'), 'hello 1234') 118 | AssertEqual(simple_parser.ParseValueDynamic('True'), True) 119 | AssertEqual(simple_parser.ParseValueDynamic('False'), False) 120 | AssertEqual(simple_parser.ParseValueDynamic('True False'), 'True False') 121 | # Test non-string inputs are preserved 122 | AssertEqual(simple_parser.ParseValueDynamic(1234), 1234) 123 | AssertEqual(simple_parser.ParseValueDynamic(12.34), 12.34) 124 | AssertEqual(simple_parser.ParseValueDynamic(True), True) 125 | AssertEqual(simple_parser.ParseValueDynamic(False), False) 126 | AssertEqual(simple_parser.ParseValueDynamic([1, 2.0, 'three']), [1, 2.0, 'three']) 127 | print('TestParseValueDynamic passed!') 128 | # End TestParseValueDynamic 129 | 130 | def main(): 131 | TestParseFromTemplate() 132 | TestParseEnclosedBy() 133 | TestParseEnclosedByOrSplitBy() 134 | TestIsInt() 135 | TestParseInts() 136 | TestParseValueDynamic() 137 | print('All tests passed!') 138 | 139 | if (__name__ == '__main__'): 140 | main() -------------------------------------------------------------------------------- /backend/socket_helper.py: -------------------------------------------------------------------------------- 1 | import string_helper 2 | from typing import List, Tuple 3 | 4 | import consts 5 | import parse_helper 6 | from type_checker import CheckType 7 | 8 | kValidSocketColorCharacters = 'RGBWDAX' 9 | kSeparatorCharacters = [' ', '-'] 10 | 11 | def IsSocketStringValid(socket_string: str) -> bool: 12 | CheckType(socket_string, 'socket_string', str) 13 | return NormalizedSocketString(socket_string) != None 14 | # End IsSocketStringValid 15 | 16 | # Converts characters to upper case, and places spaces between socket groups. 17 | # Example: 'X-rA-xWw' -> 'X-R A-X W W' 18 | # Returns None if the socket string is invalid. 19 | def NormalizedSocketString(socket_string: str) -> str: 20 | CheckType(socket_string, 'socket_string', str) 21 | if ((len(socket_string) == 0) or any( 22 | (c in kSeparatorCharacters for c in (socket_string[0], socket_string[-1])))): 23 | return None 24 | socket_string = socket_string.upper() 25 | normalized_socket_string = socket_string[0] 26 | for c in socket_string[1:]: 27 | if (normalized_socket_string[-1] in kSeparatorCharacters): 28 | if (c in kValidSocketColorCharacters): 29 | normalized_socket_string += c 30 | else: 31 | return None 32 | elif (c in kValidSocketColorCharacters): 33 | normalized_socket_string += ' ' + c 34 | elif (c in kSeparatorCharacters): 35 | normalized_socket_string += c 36 | else: 37 | return None 38 | return normalized_socket_string 39 | # End NormalizedSocketString 40 | 41 | # Generates a tier tag from the given socket string, which is formed 42 | # by the concatenation of: 43 | # - Lower case normalized socket string with underscores replacing spaces 44 | # - Two underscores 45 | # - Lower case item slot string with underscores replacing spaces 46 | def GenerateTierTag(socket_string: str, item_slot: str) -> str: 47 | CheckType(socket_string, 'socket_string', str) 48 | CheckType(item_slot, 'item_slot', str) 49 | return (NormalizedSocketString(socket_string).lower().replace(' ', '_') + 50 | '__' + item_slot.lower().replace(' ', '_')) 51 | # End GenerateTierTag 52 | 53 | # Extracts the normalized socket string and item slot string from the given tier tag 54 | def DecodeTierTag(tier_tag: str) -> Tuple[str, str]: 55 | CheckType(tier_tag, 'tier_tag', str) 56 | socket_string, item_slot = tier_tag.split('__') 57 | socket_string = socket_string.replace('_', ' ') 58 | item_slot = item_slot.replace('_', ' ') 59 | return NormalizedSocketString(socket_string), string_helper.ToTitleCase(item_slot) 60 | # End DecodeTierTag 61 | 62 | # The input is a socket_string, item_slot string pair. 63 | # socket_string is case insensitive and has the following form: 64 | # - ... 65 | # - color is one of: 'R', 'G', 'B','W', 'D', 'A', 'X' (any socket) 66 | # - link is one of: '', ' ' (equivalent to ''), '-' 67 | # - sockets are order-insensitive, in so far as changing order maintains socket groups 68 | # item_slot is case insensitive, and is either 'any' or the name a specific item slot. 69 | # Returns a list of rules condition strings, which combine to match the given socket_string. 70 | # Note: 'RGB-X' is equivalent to 'RG B-X' is equivalent to 'R G B-X' 71 | def GenerateClassAndSocketConditions(socket_string: str, item_slot: str) -> List[str]: 72 | CheckType(socket_string, 'socket_string', str) 73 | normalized_socket_string = NormalizedSocketString(socket_string) 74 | if (normalized_socket_string == None): 75 | raise RuntimeError('Invalid socket string: {}'.format(socket_string)) 76 | # Now we have a string of the form: 'R-X-B G-X W', which translates to the conditions: 77 | # - Sockets >= 6RBGW 78 | # - SocketGroup >= 3RB 79 | # - SocketGroup >= 2G 80 | # - SocketGroup >= 1W # Can be omitted, group size of 1 is covered by Sockets condition 81 | # 82 | # Note that these generated conditions do not represent the original socket string 83 | # exactly. The above could have matched an item with sockets 'R-B-G R R W' 84 | # 85 | # This is an inherent result of design of PoE loot filter conditions. As a more 86 | # obvious example, consider: 'R-X-X B-X-X'. This translates to: 87 | # - Sockets >= 6RB 88 | # - SocketGroup >= 3R 89 | # - SocketGroup >= 3B 90 | # This matches an item with sockets 'R-B-G G G G', because we have no way of 91 | # eliminating a socket group from matching future conditions after it matches one. 92 | # Separate conditions are always evaluated independently. 93 | # This should be a seldom-encountered, trivial-impact issue, since by the time players 94 | # regularly encounter multi-socket-group items, they have the fusings to fix them. 95 | 96 | condition_lines = [] 97 | # First, create the item class condition from the item slot 98 | item_slot_title_case = string_helper.ToTitleCase(item_slot.lower()) 99 | if (item_slot_title_case != 'Any'): 100 | if (item_slot_title_case not in consts.kItemSlots): 101 | raise RuntimeError('Invalid item slot: {}'.format(item_slot)) 102 | # Use 'Weapon' to match 'One Handed Weapon' and 'Two Handed Weapon' (must be singular) 103 | item_classes = (consts.kWeaponItemClasses if (item_slot_title_case == 'Weapons') 104 | else [item_slot_title_case]) 105 | condition_lines += ['Class == {}'.format( 106 | parse_helper.ConvertValuesListToString(item_classes))] 107 | # Next, create the Sockets condition 108 | socket_colors = [c for c in normalized_socket_string if (c in kValidSocketColorCharacters)] 109 | socket_condition_socket_string = (str(len(socket_colors)) + 110 | ''.join(c for c in socket_colors if c != 'X')) 111 | condition_lines.append('Sockets >= ' + socket_condition_socket_string) 112 | # Finally, generate required socket groups 113 | socket_group_strings = normalized_socket_string.split(' ') 114 | for socket_group_string in socket_group_strings: 115 | socket_colors = socket_group_string.split('-') 116 | # Can skip condition if group size is 1 117 | if (len(socket_group_string) == 1): 118 | continue 119 | socket_group_condition_socket_string = (str(len(socket_colors)) + 120 | ''.join(c for c in socket_colors if c != 'X')) 121 | condition_lines.append('SocketGroup >= ' + socket_group_condition_socket_string) 122 | return condition_lines 123 | # End ParseSocketString 124 | -------------------------------------------------------------------------------- /backend/socket_helper_test.py: -------------------------------------------------------------------------------- 1 | import socket_helper 2 | 3 | import consts 4 | import string_helper 5 | import parse_helper 6 | 7 | from test_assertions import AssertEqual, AssertTrue, AssertFalse, AssertFailure 8 | 9 | # List of (socket_string, item_slot, normalized_socket_string, tier_tag, rule_condition_lines) 10 | kSocketStringTestCases = [ 11 | ('XX X XXX', 'any', 'X X X X X X', 'x_x_x_x_x_x__any', ['Sockets >= 6']), 12 | ('R-G-B', 'helmets', 'R-G-B', 'r-g-b__helmets', ['Class == Helmets', 'Sockets >= 3RGB', 'SocketGroup >= 3RGB']), 13 | ('x-x-x', 'boots', 'X-X-X', 'x-x-x__boots', ['Class == Boots', 'Sockets >= 3', 'SocketGroup >= 3']), 14 | ('R-r-r G-g B', 'any', 'R-R-R G-G B', 'r-r-r_g-g_b__any', 15 | ['Sockets >= 6RRRGGB', 'SocketGroup >= 3RRR', 'SocketGroup >= 2GG']), 16 | ('X-rA-x-D-w', 'Body Armours', 'X-R A-X-D-W', 'x-r_a-x-d-w__body_armours', 17 | ['Class == "Body Armours"', 'Sockets >= 6RADW', 'SocketGroup >= 2R', 'SocketGroup >= 4ADW']), 18 | ('r-g-x-x b-x', 'Weapons', 'R-G-X-X B-X', 'r-g-x-x_b-x__weapons', 19 | ['Class == {}'.format(parse_helper.ConvertValuesListToString(consts.kWeaponItemClasses)), 20 | 'Sockets >= 6RGB', 'SocketGroup >= 4RG', 'SocketGroup >= 2B']), 21 | ] 22 | 23 | kInvalidSocketStrings = [ 24 | 'ABC', 25 | 'R--G', 26 | 'R-B-G-', 27 | 'R B G ', 28 | '-W', 29 | '-', 30 | ] 31 | 32 | def TestNormalizeSocketString(): 33 | for socket_string, _, expected_normalized_socket_string, _, _ in kSocketStringTestCases: 34 | normalized_socket_string = socket_helper.NormalizedSocketString(socket_string) 35 | AssertEqual(normalized_socket_string, expected_normalized_socket_string) 36 | print('TestNormalizeSocketString passed!') 37 | 38 | def TestGenerateDecodeTierTag(): 39 | for socket_string, item_slot, normalized_socket_string, tier_tag, _ in kSocketStringTestCases: 40 | converted_tier_tag = socket_helper.GenerateTierTag(socket_string, item_slot) 41 | AssertEqual(converted_tier_tag, tier_tag) 42 | decoded_socket_string, decoded_item_slot = socket_helper.DecodeTierTag(tier_tag) 43 | AssertEqual(decoded_socket_string, normalized_socket_string) 44 | AssertEqual(decoded_item_slot, string_helper.ToTitleCase(item_slot)) 45 | print('TestGenerateDecodeTierTag passed!') 46 | 47 | def TestGenerateSocketConditions(): 48 | for socket_string, item_slot, _, _, expected_condition_lines in kSocketStringTestCases: 49 | condition_lines = socket_helper.GenerateClassAndSocketConditions(socket_string, item_slot) 50 | AssertEqual(condition_lines, expected_condition_lines) 51 | print('TestGenerateSocketConditions passed!') 52 | 53 | def TestGenerateSocketConditionsInvalidInput(): 54 | for socket_string in kInvalidSocketStrings: 55 | try: 56 | conditions_list = socket_helper.GenerateClassAndSocketConditions(socket_string, item_slot='any') 57 | except RuntimeError: 58 | # This should happen 59 | pass 60 | else: 61 | print('Unexpected sucessful parse of "{}"'.format(socket_string)) 62 | print('Result conditions_list: {}'.format(conditions_list)) 63 | AssertFailure() 64 | print('TestGenerateSocketConditionsInvalidInput passed!') 65 | 66 | def main(): 67 | TestNormalizeSocketString() 68 | TestGenerateDecodeTierTag() 69 | TestGenerateSocketConditions() 70 | TestGenerateSocketConditionsInvalidInput() 71 | print('All tests passed!') 72 | 73 | if (__name__ == '__main__'): 74 | main() -------------------------------------------------------------------------------- /backend/string_helper.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | from type_checker import CheckType 4 | 5 | # Note: does not convert uppercase characters to lowercase. 6 | # If that is desired, pass in s.lower() to this function. 7 | def ToTitleCase(s: str): 8 | CheckType(s, 's', str) 9 | result_string = '' 10 | word_start_flag = True 11 | for c in s: 12 | if (c in string.ascii_letters + '\'"'): 13 | result_string += c.upper() if word_start_flag else c 14 | word_start_flag = False 15 | else: 16 | result_string += c 17 | word_start_flag = True 18 | return result_string 19 | # End ToTitleCase -------------------------------------------------------------------------------- /backend/string_helper_test.py: -------------------------------------------------------------------------------- 1 | import string_helper 2 | 3 | from test_assertions import AssertEqual, AssertTrue, AssertFalse, AssertFailure 4 | 5 | def TestToTitleCase(): 6 | AssertEqual(string_helper.ToTitleCase('hello world'), 'Hello World') 7 | AssertEqual(string_helper.ToTitleCase('hello-world'), 'Hello-World') 8 | AssertEqual(string_helper.ToTitleCase('DLF = dynamic loot filter'), 'DLF = Dynamic Loot Filter') 9 | AssertEqual(string_helper.ToTitleCase("The fox's rival"), "The Fox's Rival") 10 | print('TestToTitleCase passed!') 11 | 12 | def main(): 13 | TestToTitleCase() 14 | print('All tests passed!') 15 | 16 | if (__name__ == '__main__'): 17 | main() -------------------------------------------------------------------------------- /backend/test_assertions.py: -------------------------------------------------------------------------------- 1 | def DictMismatchMessage(left: dict, right: dict) -> str: 2 | left_keys_set = set(left.keys()) 3 | right_keys_set = set(right.keys()) 4 | if (left_keys_set - right_keys_set): 5 | return ('the following keys are present in the left dict but not the right: ' 6 | '{}'.format(left_keys_set - right_keys_set)) 7 | elif (right_keys_set - left_keys_set): 8 | return ('the following keys are present in the right dict but not the left: ' 9 | '{}'.format(right_keys_set - left_keys_set)) 10 | else: 11 | for key in left: 12 | if (left[key] != right[key]): 13 | return ('Key "{}" has value "{}" in the left dict and value "{}" ' 14 | 'in the right dict'.format(key, left[key], right[key])) 15 | return "Something weird happened, you shouldn't see this" 16 | # End DictMismatchMessage 17 | 18 | def AssertEqual(left, right): 19 | if (left != right): 20 | # Display more detailed message for dict types 21 | if (isinstance(left, dict) and isinstance(right, dict)): 22 | raise AssertionError('AssertEqual failed:\n {}'.format( 23 | DictMismatchMessage(left, right))) 24 | else: 25 | raise AssertionError('AssertEqual failed:\n Left parameter is: {}\n' 26 | ' Right parameter is: {}'.format(left, right)) 27 | # End AssertEqual 28 | 29 | def AssertTrue(value): 30 | if (value != True): 31 | raise AssertionError('AssertTrue failed:\n Value is: {}'.format(value)) 32 | # End AssertTrue 33 | 34 | def AssertFalse(value): 35 | if (value != False): 36 | raise AssertionError('AssertFalse failed:\n Value is: {}'.format(value)) 37 | # End AssertFalse 38 | 39 | def AssertFailure(): 40 | raise AssertionError('AssertFailure reached: this code should be unreachable') 41 | # End AssertFalse -------------------------------------------------------------------------------- /backend/test_consts.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from consts import kRepositoryRootDirectory, kResourcesDirectory 4 | 5 | # ============================= Directory Consts ============================= 6 | 7 | kTestWorkingDirectory = os.path.join(kRepositoryRootDirectory, 'TestWorkingDirectory') 8 | kTestResourcesDirectory = os.path.join(kResourcesDirectory, 'Test') 9 | 10 | # =============================== Filter Consts =============================== 11 | 12 | kTestBaseFilterFilename = 'TestNeversinkStrict.filter' 13 | kTestBaseFilterFullpath = os.path.join(kTestResourcesDirectory, kTestBaseFilterFilename) 14 | kTestNonFilterBladeBaseFilterFilename = 'SFH.filter' 15 | 16 | # ============================ Test Profile Consts ============================ 17 | 18 | # Test profile parameters 19 | kTestProfileNames = ['TestProfile_jNG19OR2BASyGiKbgKvY', 20 | 'TestProfile_Xn5nxETrF3KOdUacyf8d', 'TestProfile_EketPW7aflDMiJ220H7M'] 21 | kTestProfileName = kTestProfileNames[0] 22 | kTestProfileDownloadDirectory = os.path.join(kTestWorkingDirectory, 'FiltersDownload') 23 | kTestProfilePathOfExileDirectory = os.path.join(kTestWorkingDirectory, 'FiltersPathOfExile') 24 | kTestProfileDownloadedFilterFilename = kTestBaseFilterFilename 25 | kTestProfileDownloadedFilterFullpath = os.path.join( 26 | kTestProfileDownloadDirectory, kTestBaseFilterFilename) 27 | # Config dict to construct profile 28 | kTestProfileConfigValues = { 29 | 'DownloadDirectory' : kTestProfileDownloadDirectory, 30 | 'PathOfExileDirectory' : kTestProfilePathOfExileDirectory, 31 | 'DownloadedLootFilterFilename' : kTestProfileDownloadedFilterFilename} 32 | # Profile config dict for non-FilterBlade filter 33 | kTestNonFilterBladeProfileConfigValues = { 34 | 'DownloadDirectory' : kTestProfileDownloadDirectory, 35 | 'PathOfExileDirectory' : kTestProfilePathOfExileDirectory, 36 | 'DownloadedLootFilterFilename' : kTestNonFilterBladeBaseFilterFilename} 37 | 38 | # Test profile custom rules 39 | kTestProfileRulesFullpath = os.path.join(kTestResourcesDirectory, 'TestProfile.rules') 40 | 41 | # ================================ Item Consts ================================ 42 | 43 | kTestItemDirectory = os.path.join(kTestResourcesDirectory, 'Items') 44 | kTestItemsFullpath = os.path.join(kTestItemDirectory, 'test_items.txt') 45 | kItemTestCasesInputFullpath = os.path.join(kTestItemDirectory, 'item_test_cases_verified.txt') 46 | kItemTestCasesGeneratedFullpath = os.path.join(kTestItemDirectory, 'item_test_cases_generated.txt') 47 | 48 | kHorizontalSeparator = '=' * 80 49 | kHorizontalSeparatorThin = '~' * 80 -------------------------------------------------------------------------------- /backend/test_helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import file_helper 4 | import profile 5 | import test_consts 6 | 7 | # Creates filter directories and downloaded filter file. 8 | # Optionally specify whether or not to create the profile. 9 | # Optionally pass profile config values. 10 | # Note: calls TearDown() first to ensure clean start. 11 | def SetUp(*, create_profile=True, profile_config_values=test_consts.kTestProfileConfigValues): 12 | TearDown() 13 | # Make dirs if missing 14 | os.makedirs(test_consts.kTestWorkingDirectory, exist_ok=True) 15 | os.makedirs(profile_config_values['DownloadDirectory'], exist_ok=True) 16 | os.makedirs(profile_config_values['PathOfExileDirectory'], exist_ok=True) 17 | # Copy test filter to download directory 18 | base_filter_filename = profile_config_values['DownloadedLootFilterFilename'] 19 | base_filter_fullpath = os.path.join(test_consts.kTestResourcesDirectory, base_filter_filename) 20 | file_helper.CopyFile(base_filter_fullpath, os.path.join( 21 | profile_config_values['DownloadDirectory'], base_filter_filename)) 22 | # Optionally, create test profile 23 | if (create_profile): 24 | profile_obj = profile.CreateNewProfile(test_consts.kTestProfileName, profile_config_values) 25 | file_helper.CopyFile(test_consts.kTestProfileRulesFullpath, profile_obj.rules_path) 26 | # End SetUp 27 | 28 | def TearDown(): 29 | # Delete test profile if it exists 30 | if (profile.ProfileExists(test_consts.kTestProfileName)): 31 | profile.DeleteProfile(test_consts.kTestProfileName) 32 | # Delete test working directory and all its contents 33 | file_helper.ClearAndRemoveDirectory(test_consts.kTestWorkingDirectory) 34 | # End TearDown -------------------------------------------------------------------------------- /backend/type_checker.py: -------------------------------------------------------------------------------- 1 | import logger 2 | 3 | # Logs error and raises exception if variable is not an instance of required type 4 | # Note: can use a tuple of types for required_type to give multiple options 5 | def CheckType(variable, variable_name: str, required_type, required_inner_type=None): 6 | if (required_inner_type != None): 7 | CheckType2(variable, variable_name, required_type, required_inner_type) 8 | elif (not isinstance(variable, required_type)): 9 | required_type_name = (' or '.join(t.__name__ for t in required_type) 10 | if isinstance(required_type, tuple) 11 | else required_type.__name__) 12 | error_message: str = '{} has type: {}; required type(s): {}'.format( 13 | variable_name, type(variable).__name__, required_type_name) 14 | logger.Log('TypeError: ' + error_message) 15 | raise TypeError(error_message) 16 | # End CheckType 17 | 18 | # Handle compound types, for example to check if something is a list of strings, use: 19 | # - required_outer_type = list 20 | # - required_inner_type = string 21 | # For efficiency, only checks the type of the first item, we don't want to add too much overhead 22 | def CheckType2(variable, variable_name: str, required_outer_type, required_inner_type): 23 | if (not isinstance(variable, required_outer_type)): 24 | required_type_name = (' or '.join(t.__name__ for t in required_outer_type) 25 | if isinstance(required_outer_type, tuple) 26 | else required_outer_type.__name__) 27 | error_message: str = '{} has type: {}; required type(s): {}'.format( 28 | variable_name, type(variable).__name__, required_type_name) 29 | logger.Log('TypeError: ' + error_message) 30 | raise TypeError(error_message) 31 | else: 32 | if (len(variable) == 0): 33 | return 34 | try: 35 | inner_value = next(iter(variable)) 36 | except: 37 | error_message: str = '{} has type: {}; which is not an iterable type'.format( 38 | variable_name, type(variable)) 39 | logger.Log('TypeError: ' + error_message) 40 | raise TypeError(error_message) 41 | else: 42 | if (not isinstance(inner_value, required_inner_type)): 43 | required_type_name = (' or '.join(t.__name__ for t in required_inner_type) 44 | if isinstance(required_inner_type, tuple) 45 | else required_inner_type.__name__) 46 | error_message: str = '{} has inner type: {}; required inner type(s): {}'.format( 47 | variable_name, type(inner_value).__name__, required_type_name) 48 | logger.Log('TypeError: ' + error_message) 49 | raise TypeError(error_message) 50 | # End CheckType 51 | 52 | # Only performs a shallow check, i.e. a list of strings and list of ints would return True 53 | def CheckTypesMatch(left, left_name: str, right, right_name: str): 54 | if (type(left) != type(right)): 55 | error_message: str = 'types do not match; {} is: {} ({}), {} is: {} ({})'.format( 56 | left_name, left, type(left).__name__, right_name, right, type(right).__name__) 57 | logger.Log('TypeError: ' + error_message) 58 | raise TypeError(error_message) 59 | # End CheckTypesMatch -------------------------------------------------------------------------------- /backend/type_checker_test.py: -------------------------------------------------------------------------------- 1 | from type_checker import CheckType, CheckTypesMatch 2 | 3 | from test_assertions import AssertEqual, AssertFailure 4 | 5 | def TestCorrectTypes(): 6 | # Single string 7 | s = 'hello' 8 | CheckType(s, 's', str) 9 | # List of ints 10 | int_list = [1, 2, 3] 11 | CheckType(int_list, 'int_list', list, int) 12 | print('TestCorrectTypes passed!') 13 | 14 | def TestIncorrectTypes(): 15 | str_list = ['a', 'b', 'c'] 16 | try: 17 | CheckType(str_list, 'str_list', list, int) 18 | except TypeError: # this should happen 19 | pass 20 | else: # this shouldn't happen 21 | AssertFailure() 22 | print('TestIncorrectTypes passed!') 23 | 24 | def TestTypesMatch(): 25 | an_integer = 5 26 | another_integer = 4509745487 27 | CheckTypesMatch(an_integer, 'an_integer', another_integer, 'another_integer') 28 | some_string = 'The quick brown fox' 29 | another_string = 'jumps over the lazy dog' 30 | CheckTypesMatch(some_string, 'some_string', another_string, 'another_string') 31 | # Expect mismatch between int and string 32 | try: 33 | CheckTypesMatch(an_integer, 'an_integer', some_string, 'some_string') 34 | except TypeError: # this should happen 35 | pass 36 | else: # this shouldn't happen 37 | AssertFailure() 38 | print('TestTypesMatch passed!') 39 | 40 | def main(): 41 | TestCorrectTypes() 42 | TestIncorrectTypes() 43 | TestTypesMatch() 44 | 45 | if (__name__ == '__main__'): 46 | main() -------------------------------------------------------------------------------- /cache/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all files here, but force the directory to exist 2 | # so AHK can use it without additional checks. 3 | *.* 4 | !.gitignore -------------------------------------------------------------------------------- /config/.gitignore: -------------------------------------------------------------------------------- 1 | *.config 2 | -------------------------------------------------------------------------------- /dynamic_loot_filter.ahk: -------------------------------------------------------------------------------- 1 | /* 2 | GUI construction code is labeled with headers for each section, 3 | so the code is searchable by looking for "[section-name]", where 4 | section-name is the title of the GroupBox dispalyed in the GUI. 5 | 6 | Example: search "[Chaos Recipe Rares]" to find the code that 7 | builds the chaos recipe rares section of the GUI. 8 | */ 9 | 10 | ; General AHK boilerplate settings 11 | #SingleInstance Force 12 | #NoEnv 13 | SetWorkingDir, %A_ScriptDir% 14 | SetBatchLines, -1 ; Run at full speed (do not sleep every n lines) 15 | FileEncoding, UTF-8-RAW 16 | 17 | ; TODO: For now, this doesn't work - also have to find out how to run python backend as admin. 18 | ; Uncomment this to run as Admin, if having problems accessing files 19 | /* 20 | if (not A_IsAdmin) { 21 | Run, *RunAs "%A_ScriptFullPath%" 22 | } 23 | */ 24 | 25 | ; Includes: note that we first include the ahk_include subdirectory 26 | #Include %A_ScriptDir%\ahk_include 27 | #Include backend_interface.ahk 28 | #Include consts.ahk 29 | #Include general_helper.ahk 30 | #Include gui_build.ahk 31 | #Include gui_helper.ahk 32 | #Include gui_interaction.ahk 33 | #Include gui_placeholder.ahk 34 | #Include poe_helper.ahk 35 | 36 | ; Set system tray icon and clear standard menu options 37 | Menu, Tray, Icon, %kDlfIconPath% 38 | Menu, Tray, NoStandard 39 | 40 | ; ============================= Global Variables ============================= 41 | 42 | ; TODO - Refactor dependent code and remove this 43 | python_command := GetPythonCommand() 44 | 45 | ; Profiles 46 | g_profiles := [] 47 | g_active_profile := "" 48 | 49 | ; List of backend function calls corresponding to changes that have 50 | ; occurred since last filter load or write. 51 | g_ui_changes := [] 52 | 53 | ; Local GUI state data (may contain changes not yet applied to filter). 54 | ; See backend_interface.ahk for detailed outline of keys and values. 55 | ; Note: for elements whose state is fully contained in the current state of the UI, 56 | ; for example Chaos Recipe items, this ui_data_dict will not be updated when UI changes. 57 | ; Added by gui_interaction: "matched_rule_tags" -> [type_tag, tier_tag] 58 | g_ui_data_dict := {} 59 | 60 | ; Run Main then wait for GUI input 61 | Main() 62 | Return 63 | 64 | ; ============================= Profile Helper Functions ============================= 65 | 66 | ; Returns the active profile, or None if there are no active profiles 67 | InitializeProfiles() { 68 | global kBackendCliOutputPath 69 | global g_profiles, g_active_profile 70 | exit_code := RunBackendCliFunction("get_all_profile_names") 71 | if (exit_code != 0) { 72 | ExitApp 73 | } 74 | g_profiles := ReadFileLines(kBackendCliOutputPath) 75 | if (Length(g_profiles) == 0) { 76 | BuildCreateProfileGui() 77 | return None() 78 | } 79 | g_active_profile := g_profiles[1] 80 | return g_active_profile 81 | } 82 | 83 | ; ============================= To Refactor - Profile Creation ============================= 84 | 85 | BuildCreateProfileGui() { 86 | ; Use global mode so vVariables and HWNDhIdentifiers created here end up global 87 | global 88 | Gui, 2: Color, 0x111122 89 | anchor_x := 8, anchor_y := 8 90 | h := 28, w := 574, button_w := 70, edit_w := w - button_w - 10 91 | spacing_y := h + 12 92 | x := anchor_x, y := anchor_y 93 | ; Title 94 | Gui, 2: Font, c0x00e8b2 s15 Bold, Segoe UI 95 | Gui, 2: Add, Text, x0 y%y% h%h% w%w% +Center, Create New Profile 96 | ; Profile Name 97 | y += spacing_y + 8 98 | Gui, 2: Font, c0x00e8b2 s14 Norm, Segoe UI 99 | Gui, 2: Add, Text, x%x% y%y% h%h% w%w%, Profile Name 100 | y += h 101 | Gui, 2: Font, cBlack s14 Norm, Segoe UI 102 | Gui, 2: Add, Edit, x%x% y%y% h32 w250 vNewProfileName 103 | ; Downloaded Filter Path 104 | y += spacing_y + 4 105 | Gui, 2: Font, c0x00e8b2 s14 Norm, Segoe UI 106 | Gui, 2: Add, Text, x%x% y%y% h%h% w%w%, Downloaded Filter Path 107 | y += h 108 | Gui, 2: Font, cBlack s12 Norm, Segoe UI 109 | placeholder_text := "Example: C:\Users\UserName\Downloads\NeversinkStrict.Filter" 110 | Gui, 2: Add, Edit, x%x% y%y% h%h% w%edit_w% vNewProfileDownloadedFilterPath HWNDhNewProfileDownloadedFilterEdit, %placeholder_text% 111 | button_x := x + w - button_w 112 | Gui, 2: Font, c0x00e8b2 s10 Bold, Segoe UI 113 | Gui, 2: Add, Button, x%button_x% y%y% h%h% w%button_w% gBrowseDownloadDirectory, Browse 114 | ; Path of Exile Filters Directory 115 | y += spacing_y 116 | Gui, 2: Font, c0x00e8b2 s14 Norm, Segoe UI 117 | Gui, 2: Add, Text, x%x% y%y% h%h%, Path of Exile Filters Directory 118 | y += h 119 | Gui, 2: Font, cBlack s12 Norm, Segoe UI 120 | placeholder_text := "Example: C:\Users\UserName\Documents\My Games\Path of Exile" 121 | Gui, 2: Add, Edit, x%x% y%y% h%h% w%edit_w% vNewProfilePoeFiltersDirectory HWNDhNewProfilePoeFiltersDirectoryEdit, %placeholder_text% 122 | button_x := x + w - button_w 123 | Gui, 2: Font, c0x00e8b2 s10 Bold, Segoe UI 124 | Gui, 2: Add, Button, x%button_x% y%y% h%h% w%button_w% gBrowsePoeDirectory, Browse 125 | ; Remove Downloaded Filter on Import 126 | y += spacing_y + 6 127 | Gui, 2: Font, c0x00e8b2 s14 Norm, Segoe UI 128 | Gui, 2: Add, Checkbox, vRemoveDownloadedFilter -Checked x%x% y%y% h26 w%w%, Remove downloaded filter on import 129 | ; Create Button 130 | y += spacing_y + 10 131 | Gui, 2: Font, s14 Bold, Segoe UI 132 | Gui, 2: Add, Button, x145 y%y% w250 h36 gCreateProfileSubmit, Create 133 | ; Show UI 134 | window_w := w + 16 135 | Gui, 2: -Border 136 | Gui, 2: Show, w%window_w% h378 137 | GUi, 1: Hide 138 | Return 139 | } 140 | 141 | BrowseDownloadDirectory() { 142 | FileSelectFile, selected_path, , , Select Downloaded Filter, Filter Files (*.filter) 143 | GuiControl, 2:, NewProfileDownloadedFilterPath, %selected_path% 144 | return 145 | } 146 | 147 | BrowsePoeDirectory() { 148 | FileSelectFolder, selected_directory, , , Select Path of Exile Filters Directory 149 | GuiControl, 2:, NewProfilePoeFiltersDirectory, %selected_directory% 150 | return 151 | } 152 | 153 | CreateProfileSubmit() { 154 | global kBackendCliInputPath 155 | global NewProfileName, NewProfileDownloadedFilterPath, NewProfilePoeFiltersDirectory, RemoveDownloadedFilter 156 | Gui, 2: Submit, NoHide 157 | if (NewProfileName == ""){ 158 | MsgBox, Missing profile name! 159 | return 160 | } 161 | if ((NewProfileDownloadedFilterPath == "") or InStr(NewProfileDownloadedFilterPath, "...")) { 162 | MsgBox, Missing downloaded filter path! 163 | return 164 | } 165 | if ((NewProfilePoeFiltersDirectory == "") or InStr(NewProfilePoeFiltersDirectory, "...")) { 166 | MsgBox, Missing Path of Exile filters directory! 167 | return 168 | } 169 | SplitPath, NewProfileDownloadedFilterPath, downloaded_filter_filename, download_directory 170 | FileDelete, %kBackendCliInputPath% 171 | FileAppend, % "DownloadDirectory:" download_directory "`n", %kBackendCliInputPath% 172 | FileAppend, % "DownloadedLootFilterFilename:" downloaded_filter_filename "`n", %kBackendCliInputPath% 173 | FileAppend, % "PathOfExileDirectory:" NewProfilePoeFiltersDirectory "`n", %kBackendCliInputPath% 174 | if (!RemoveDownloadedFilter){ 175 | FileAppend, % "RemoveDownloadedFilter:False`n", %kBackendCliInputPath% 176 | 177 | } 178 | Gui, 2: Destroy 179 | exit_code := RunBackendCliFunction("create_new_profile " Quoted(NewProfileName)) 180 | if (exit_code != 0){ 181 | UpdateStatusMessage("Profile Creation Failed") 182 | } 183 | Reload 184 | } 185 | 186 | 2GuiEscape() { 187 | 2GuiClose() 188 | } 189 | 190 | 2GuiClose() { 191 | Gui, 2: Destroy 192 | Gui, 1: Show 193 | return 194 | } 195 | 196 | GuiEscape() { 197 | MinimizeToTray() 198 | } 199 | 200 | GuiClose() { 201 | MinimizeToTray() 202 | } 203 | 204 | ; ============================= Load/Import Filter ============================= 205 | 206 | LoadOrImportFilter(active_profile) { 207 | global kBackendCliOutputPath 208 | CheckType(active_profile, "string") 209 | RunBackendCliFunction("check_filters_exist " Quoted(active_profile)) 210 | output_lines := ReadFileLines(kBackendCliOutputPath) 211 | downloaded_filter_exists := output_lines[1] 212 | input_filter_exists := output_lines[2] 213 | ; output_filter_exists := output_lines[3] ; unused for now 214 | if (input_filter_exists) { 215 | RunBackendCliFunction("load_input_filter " Quoted(active_profile)) 216 | } else if (downloaded_filter_exists) { 217 | RunBackendCliFunction("import_downloaded_filter " Quoted(active_profile)) 218 | } else { 219 | DebugMessage("Neither input nor downloaded filter found. " 220 | . "Please place your downloaded filter in your downloads directory.") 221 | ExitApp 222 | } 223 | } 224 | 225 | ; ============================= Main Thread ============================= 226 | 227 | Main() { 228 | global g_ui_data_dict 229 | active_profile := InitializeProfiles() 230 | ; If no profiles exist, return from this thread and wait for profile creation GUI 231 | if (IsNone(active_profile)) { 232 | return 233 | } 234 | ; Load input or import downloaded filter (displays error and exits if neither exist) 235 | LoadOrImportFilter(active_profile) 236 | ; Initialize GUI 237 | g_ui_data_dict := QueryAllFilterData(active_profile) 238 | QueryHotkeys(g_ui_data_dict) 239 | BuildGui(g_ui_data_dict) 240 | } 241 | 242 | ; ================================== Hotkeys ================================== 243 | 244 | ToggleGUIHotkey() { 245 | if (IsPoeActive()) { 246 | RestoreFromTray() 247 | MakeDlfActive() 248 | } else if (IsDlfActive()) { 249 | MinimizeToTray() 250 | MakePoeActive() 251 | } 252 | } 253 | 254 | WriteFilterHotkey() { 255 | UpdateFilter() 256 | MinimizeToTray() 257 | MakePoeActive() 258 | } 259 | 260 | ReloadFilterHotkey() { 261 | if (IsPoeActive()) { 262 | SendChatMessage("/itemfilter DynamicLootFilter") 263 | } 264 | } 265 | 266 | ; For efficient testing 267 | ; Numpad0::Reload 268 | -------------------------------------------------------------------------------- /resources/Icon/DLF_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apollys/PoEDynamicLootFilter/d819a92cc78b728b12eb59b6811c3b17b370a194/resources/Icon/DLF_icon.png -------------------------------------------------------------------------------- /resources/Icon/DLF_icon.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Apollys/PoEDynamicLootFilter/d819a92cc78b728b12eb59b6811c3b17b370a194/resources/Icon/DLF_icon.xcf -------------------------------------------------------------------------------- /resources/LintStyle/yapf-style.yml: -------------------------------------------------------------------------------- 1 | # Note: this is not yet used, just here for experimentation. 2 | # 3 | # Generated with `yapf --style=google --style-help` 4 | # 5 | # Changelist: 6 | # - column_limit=100 7 | # - indent_width=4 (not a change, just an important value) 8 | # - continuation_indent_width=8 9 | 10 | [style] 11 | # Align closing bracket with visual indentation. 12 | align_closing_bracket_with_visual_indent=False 13 | 14 | # Allow dictionary keys to exist on multiple lines. For example: 15 | # 16 | # x = { 17 | # ('this is the first element of a tuple', 18 | # 'this is the second element of a tuple'): 19 | # value, 20 | # } 21 | allow_multiline_dictionary_keys=False 22 | 23 | # Allow lambdas to be formatted on more than one line. 24 | allow_multiline_lambdas=False 25 | 26 | # Allow splitting before a default / named assignment in an argument list. 27 | allow_split_before_default_or_named_assigns=True 28 | 29 | # Allow splits before the dictionary value. 30 | allow_split_before_dict_value=True 31 | 32 | # Let spacing indicate operator precedence. For example: 33 | # 34 | # a = 1 * 2 + 3 / 4 35 | # b = 1 / 2 - 3 * 4 36 | # c = (1 + 2) * (3 - 4) 37 | # d = (1 - 2) / (3 + 4) 38 | # e = 1 * 2 - 3 39 | # f = 1 + 2 + 3 + 4 40 | # 41 | # will be formatted as follows to indicate precedence: 42 | # 43 | # a = 1*2 + 3/4 44 | # b = 1/2 - 3*4 45 | # c = (1+2) * (3-4) 46 | # d = (1-2) / (3+4) 47 | # e = 1*2 - 3 48 | # f = 1 + 2 + 3 + 4 49 | # 50 | arithmetic_precedence_indication=False 51 | 52 | # Number of blank lines surrounding top-level function and class 53 | # definitions. 54 | blank_lines_around_top_level_definition=2 55 | 56 | # Number of blank lines between top-level imports and variable 57 | # definitions. 58 | blank_lines_between_top_level_imports_and_variables=1 59 | 60 | # Insert a blank line before a class-level docstring. 61 | blank_line_before_class_docstring=False 62 | 63 | # Insert a blank line before a module docstring. 64 | blank_line_before_module_docstring=False 65 | 66 | # Insert a blank line before a 'def' or 'class' immediately nested 67 | # within another 'def' or 'class'. For example: 68 | # 69 | # class Foo: 70 | # # <------ this blank line 71 | # def method(): 72 | # ... 73 | blank_line_before_nested_class_or_def=True 74 | 75 | # Do not split consecutive brackets. Only relevant when 76 | # dedent_closing_brackets is set. For example: 77 | # 78 | # call_func_that_takes_a_dict( 79 | # { 80 | # 'key1': 'value1', 81 | # 'key2': 'value2', 82 | # } 83 | # ) 84 | # 85 | # would reformat to: 86 | # 87 | # call_func_that_takes_a_dict({ 88 | # 'key1': 'value1', 89 | # 'key2': 'value2', 90 | # }) 91 | coalesce_brackets=False 92 | 93 | # The column limit. 94 | column_limit=100 95 | 96 | # The style for continuation alignment. Possible values are: 97 | # 98 | # - SPACE: Use spaces for continuation alignment. This is default behavior. 99 | # - FIXED: Use fixed number (CONTINUATION_INDENT_WIDTH) of columns 100 | # (ie: CONTINUATION_INDENT_WIDTH/INDENT_WIDTH tabs or 101 | # CONTINUATION_INDENT_WIDTH spaces) for continuation alignment. 102 | # - VALIGN-RIGHT: Vertically align continuation lines to multiple of 103 | # INDENT_WIDTH columns. Slightly right (one tab or a few spaces) if 104 | # cannot vertically align continuation lines with indent characters. 105 | continuation_align_style=SPACE 106 | 107 | # Indent width used for line continuations. 108 | continuation_indent_width=8 109 | 110 | # Put closing brackets on a separate line, dedented, if the bracketed 111 | # expression can't fit in a single line. Applies to all kinds of brackets, 112 | # including function definitions and calls. For example: 113 | # 114 | # config = { 115 | # 'key1': 'value1', 116 | # 'key2': 'value2', 117 | # } # <--- this bracket is dedented and on a separate line 118 | # 119 | # time_series = self.remote_client.query_entity_counters( 120 | # entity='dev3246.region1', 121 | # key='dns.query_latency_tcp', 122 | # transform=Transformation.AVERAGE(window=timedelta(seconds=60)), 123 | # start_ts=now()-timedelta(days=3), 124 | # end_ts=now(), 125 | # ) # <--- this bracket is dedented and on a separate line 126 | dedent_closing_brackets=False 127 | 128 | # Disable the heuristic which places each list element on a separate line 129 | # if the list is comma-terminated. 130 | disable_ending_comma_heuristic=False 131 | 132 | # Place each dictionary entry onto its own line. 133 | each_dict_entry_on_separate_line=True 134 | 135 | # Require multiline dictionary even if it would normally fit on one line. 136 | # For example: 137 | # 138 | # config = { 139 | # 'key1': 'value1' 140 | # } 141 | force_multiline_dict=False 142 | 143 | # The regex for an i18n comment. The presence of this comment stops 144 | # reformatting of that line, because the comments are required to be 145 | # next to the string they translate. 146 | i18n_comment=#\..* 147 | 148 | # The i18n function call names. The presence of this function stops 149 | # reformattting on that line, because the string it has cannot be moved 150 | # away from the i18n comment. 151 | i18n_function_call=N_, _ 152 | 153 | # Indent blank lines. 154 | indent_blank_lines=False 155 | 156 | # Put closing brackets on a separate line, indented, if the bracketed 157 | # expression can't fit in a single line. Applies to all kinds of brackets, 158 | # including function definitions and calls. For example: 159 | # 160 | # config = { 161 | # 'key1': 'value1', 162 | # 'key2': 'value2', 163 | # } # <--- this bracket is indented and on a separate line 164 | # 165 | # time_series = self.remote_client.query_entity_counters( 166 | # entity='dev3246.region1', 167 | # key='dns.query_latency_tcp', 168 | # transform=Transformation.AVERAGE(window=timedelta(seconds=60)), 169 | # start_ts=now()-timedelta(days=3), 170 | # end_ts=now(), 171 | # ) # <--- this bracket is indented and on a separate line 172 | indent_closing_brackets=False 173 | 174 | # Indent the dictionary value if it cannot fit on the same line as the 175 | # dictionary key. For example: 176 | # 177 | # config = { 178 | # 'key1': 179 | # 'value1', 180 | # 'key2': value1 + 181 | # value2, 182 | # } 183 | indent_dictionary_value=True 184 | 185 | # The number of columns to use for indentation. 186 | indent_width=4 187 | 188 | # Join short lines into one line. E.g., single line 'if' statements. 189 | join_multiple_lines=False 190 | 191 | # Do not include spaces around selected binary operators. For example: 192 | # 193 | # 1 + 2 * 3 - 4 / 5 194 | # 195 | # will be formatted as follows when configured with "*,/": 196 | # 197 | # 1 + 2*3 - 4/5 198 | no_spaces_around_selected_binary_operators= 199 | 200 | # Use spaces around default or named assigns. 201 | spaces_around_default_or_named_assign=False 202 | 203 | # Adds a space after the opening '{' and before the ending '}' dict 204 | # delimiters. 205 | # 206 | # {1: 2} 207 | # 208 | # will be formatted as: 209 | # 210 | # { 1: 2 } 211 | spaces_around_dict_delimiters=False 212 | 213 | # Adds a space after the opening '[' and before the ending ']' list 214 | # delimiters. 215 | # 216 | # [1, 2] 217 | # 218 | # will be formatted as: 219 | # 220 | # [ 1, 2 ] 221 | spaces_around_list_delimiters=False 222 | 223 | # Use spaces around the power operator. 224 | spaces_around_power_operator=False 225 | 226 | # Use spaces around the subscript / slice operator. For example: 227 | # 228 | # my_list[1 : 10 : 2] 229 | spaces_around_subscript_colon=False 230 | 231 | # Adds a space after the opening '(' and before the ending ')' tuple 232 | # delimiters. 233 | # 234 | # (1, 2, 3) 235 | # 236 | # will be formatted as: 237 | # 238 | # ( 1, 2, 3 ) 239 | spaces_around_tuple_delimiters=False 240 | 241 | # The number of spaces required before a trailing comment. 242 | # This can be a single value (representing the number of spaces 243 | # before each trailing comment) or list of values (representing 244 | # alignment column values; trailing comments within a block will 245 | # be aligned to the first column value that is greater than the maximum 246 | # line length within the block). For example: 247 | # 248 | # With spaces_before_comment=5: 249 | # 250 | # 1 + 1 # Adding values 251 | # 252 | # will be formatted as: 253 | # 254 | # 1 + 1 # Adding values <-- 5 spaces between the end of the 255 | # # statement and comment 256 | # 257 | # With spaces_before_comment=15, 20: 258 | # 259 | # 1 + 1 # Adding values 260 | # two + two # More adding 261 | # 262 | # longer_statement # This is a longer statement 263 | # short # This is a shorter statement 264 | # 265 | # a_very_long_statement_that_extends_beyond_the_final_column # Comment 266 | # short # This is a shorter statement 267 | # 268 | # will be formatted as: 269 | # 270 | # 1 + 1 # Adding values <-- end of line comments in block 271 | # # aligned to col 15 272 | # two + two # More adding 273 | # 274 | # longer_statement # This is a longer statement <-- end of line 275 | # # comments in block aligned to col 20 276 | # short # This is a shorter statement 277 | # 278 | # a_very_long_statement_that_extends_beyond_the_final_column # Comment <-- the end of line comments are aligned based on the line length 279 | # short # This is a shorter statement 280 | # 281 | spaces_before_comment=2 282 | 283 | # Insert a space between the ending comma and closing bracket of a list, 284 | # etc. 285 | space_between_ending_comma_and_closing_bracket=False 286 | 287 | # Use spaces inside brackets, braces, and parentheses. For example: 288 | # 289 | # method_call( 1 ) 290 | # my_dict[ 3 ][ 1 ][ get_index( *args, **kwargs ) ] 291 | # my_set = { 1, 2, 3 } 292 | space_inside_brackets=False 293 | 294 | # Split before arguments 295 | split_all_comma_separated_values=False 296 | 297 | # Split before arguments, but do not split all subexpressions recursively 298 | # (unless needed). 299 | split_all_top_level_comma_separated_values=False 300 | 301 | # Split before arguments if the argument list is terminated by a 302 | # comma. 303 | split_arguments_when_comma_terminated=False 304 | 305 | # Set to True to prefer splitting before '+', '-', '*', '/', '//', or '@' 306 | # rather than after. 307 | split_before_arithmetic_operator=False 308 | 309 | # Set to True to prefer splitting before '&', '|' or '^' rather than 310 | # after. 311 | split_before_bitwise_operator=False 312 | 313 | # Split before the closing bracket if a list or dict literal doesn't fit on 314 | # a single line. 315 | split_before_closing_bracket=True 316 | 317 | # Split before a dictionary or set generator (comp_for). For example, note 318 | # the split before the 'for': 319 | # 320 | # foo = { 321 | # variable: 'Hello world, have a nice day!' 322 | # for variable in bar if variable != 42 323 | # } 324 | split_before_dict_set_generator=False 325 | 326 | # Split before the '.' if we need to split a longer expression: 327 | # 328 | # foo = ('This is a really long string: {}, {}, {}, {}'.format(a, b, c, d)) 329 | # 330 | # would reformat to something like: 331 | # 332 | # foo = ('This is a really long string: {}, {}, {}, {}' 333 | # .format(a, b, c, d)) 334 | split_before_dot=False 335 | 336 | # Split after the opening paren which surrounds an expression if it doesn't 337 | # fit on a single line. 338 | split_before_expression_after_opening_paren=False 339 | 340 | # If an argument / parameter list is going to be split, then split before 341 | # the first argument. 342 | split_before_first_argument=False 343 | 344 | # Set to True to prefer splitting before 'and' or 'or' rather than 345 | # after. 346 | split_before_logical_operator=False 347 | 348 | # Split named assignments onto individual lines. 349 | split_before_named_assigns=True 350 | 351 | # Set to True to split list comprehensions and generators that have 352 | # non-trivial expressions and multiple clauses before each of these 353 | # clauses. For example: 354 | # 355 | # result = [ 356 | # a_long_var + 100 for a_long_var in xrange(1000) 357 | # if a_long_var % 10] 358 | # 359 | # would reformat to something like: 360 | # 361 | # result = [ 362 | # a_long_var + 100 363 | # for a_long_var in xrange(1000) 364 | # if a_long_var % 10] 365 | split_complex_comprehension=True 366 | 367 | # The penalty for splitting right after the opening bracket. 368 | split_penalty_after_opening_bracket=300 369 | 370 | # The penalty for splitting the line after a unary operator. 371 | split_penalty_after_unary_operator=10000 372 | 373 | # The penalty of splitting the line around the '+', '-', '*', '/', '//', 374 | # ``%``, and '@' operators. 375 | split_penalty_arithmetic_operator=300 376 | 377 | # The penalty for splitting right before an if expression. 378 | split_penalty_before_if_expr=0 379 | 380 | # The penalty of splitting the line around the '&', '|', and '^' 381 | # operators. 382 | split_penalty_bitwise_operator=300 383 | 384 | # The penalty for splitting a list comprehension or generator 385 | # expression. 386 | split_penalty_comprehension=2100 387 | 388 | # The penalty for characters over the column limit. 389 | split_penalty_excess_character=7000 390 | 391 | # The penalty incurred by adding a line split to the logical line. The 392 | # more line splits added the higher the penalty. 393 | split_penalty_for_added_line_split=30 394 | 395 | # The penalty of splitting a list of "import as" names. For example: 396 | # 397 | # from a_very_long_or_indented_module_name_yada_yad import (long_argument_1, 398 | # long_argument_2, 399 | # long_argument_3) 400 | # 401 | # would reformat to something like: 402 | # 403 | # from a_very_long_or_indented_module_name_yada_yad import ( 404 | # long_argument_1, long_argument_2, long_argument_3) 405 | split_penalty_import_names=0 406 | 407 | # The penalty of splitting the line around the 'and' and 'or' 408 | # operators. 409 | split_penalty_logical_operator=300 410 | 411 | # Use the Tab character for indentation. 412 | use_tabs=False 413 | 414 | -------------------------------------------------------------------------------- /resources/Test/TestProfile.rules: -------------------------------------------------------------------------------- 1 | # Highlight Humility up through T5 maps 2 | Show 3 | Class "Divination" 4 | BaseType == "Humility" 5 | AreaLevel <= 72 6 | SetFontSize 45 7 | SetTextColor 0 255 255 255 8 | SetBorderColor 0 255 255 255 9 | SetBackgroundColor 0 0 118 255 10 | PlayEffect Cyan 11 | MinimapIcon 0 Cyan Star 12 | PlayAlertSound 1 300 13 | 14 | # 6 Socketed Items 15 | Show 16 | Sockets >= 6 17 | Rarity <= Rare 18 | SetFontSize 45 19 | SetTextColor 255 255 255 255 20 | SetBorderColor 255 255 255 255 21 | SetBackgroundColor 59 59 59 255 22 | PlayEffect Grey 23 | MinimapIcon 2 Grey Hexagon 24 | 25 | # Show Desired T16 Maps 26 | Show 27 | Class Maps 28 | BaseType == "Tower Map" 29 | MapTier == 16 30 | SetFontSize 45 31 | SetBackgroundColor 0 0 0 255 32 | MinimapIcon 1 Cyan Square 33 | PlayEffect Cyan 34 | SetBorderColor 0 224 255 255 35 | PlayAlertSound 3 300 36 | SetTextColor 59 215 245 255 --------------------------------------------------------------------------------