diff --git a/Additions.md b/Additions.md new file mode 100644 index 0000000..e69de29 diff --git a/Changes.md b/Changes.md new file mode 100644 index 0000000..8c7cd33 --- /dev/null +++ b/Changes.md @@ -0,0 +1,5 @@ +* Added abilty to paste in json from Seventy Upgrades +* Added ability to parse trinkets from Seventy Upgrades data + * Assumes trinkets from Seventy Upgrades unless explicitly picked from dropdowns +* Added sim results table to track results +* upgrades dash to 2.0.0+ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4fd2024 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +#Courtesy of Musho from druid discord + +FROM python:3.9-slim-bullseye + +ENV VIRTUAL_ENV=/opt/venv +RUN python3 -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# Install dependencies: +COPY requirements.txt . +RUN pip install -r requirements.txt + +# Run the application: +RUN apt update +RUN apt -y upgrade +COPY . . +EXPOSE 8080 +CMD ["python", "main.py"] \ No newline at end of file diff --git a/README.md b/README.md index c274a70..a549b5e 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,11 @@ Full rotation simulator for feral DPS in WoW TBC Classic. The file tbc_cat_sim.py contains all of the inner workings of the sim, including how character resources and actions are modeled, how attack tables are executed, and the simulation rotation logic. The file main.py contains the GUI used for the web application. The first file is well documented as it is the important part of the sim, and the second file is a bit of a mess currently. + + +## Docker +```shell script +docker build -t catsim . +docker run -it -p 8080:8080 catsim + +``` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..955977f --- /dev/null +++ b/app.py @@ -0,0 +1,3 @@ +import dash +import dash_bootstrap_components as dbc + diff --git a/main.py b/main.py index 9c2360a..2e5c33b 100755 --- a/main.py +++ b/main.py @@ -4,1835 +4,12 @@ # Run this app with `python app.py` and # visit http://127.0.0.1:8050/ in your web browser. -import dash -import dash_core_components as dcc -import dash_html_components as html -import plotly.graph_objects as go -import numpy as np -from dash.dependencies import Input, Output, State -import dash_bootstrap_components as dbc -import tbc_cat_sim as ccs import multiprocessing -import trinkets -import copy -import json -import base64 -import io +from ui.ui import UI - -app = dash.Dash(__name__, external_stylesheets=[dbc.themes.DARKLY]) -server = app.server - -default_input_stats = { - "agility": 656, - "armor": 5218, - "armorPen": 441, - "attackPower": 3304, - "crit": 42.14, - "critRating": 87, - "critReduction": 3, - "defense": 350, - "dodge": 47.74, - "expertise": 6, - "expertiseRating": 25, - "feralAttackPower": 973, - "health": 8854, - "hit": 4.06, - "hitRating": 64, - "intellect": 242, - "mainHandSpeed": 3, - "mana": 5720, - "natureResist": 10, - "parry": 5, - "resilience": 0.48, - "resilienceRating": 19, - "spellCrit": 4.88, - "spirit": 161, - "stamina": 542, - "strength": 314 -} - -stat_input = dbc.Col([ - html.H5('Seventy Upgrades Input'), - dcc.Markdown( - 'This simulator uses Seventy Upgrades as its gear selection UI. In ' - 'order to use it, create a Seventy Upgrades profile for your character' - ' and download the gear set using the "Export" button at the top right' - ' of the character sheet. Make sure that "Cat Form" is selected in the' - ' export window, and that "Talents" are checked (and set up in your ' - 'character sheet).', - style={'width': 300}, - ), - dcc.Markdown( - 'Consumables and party/raid buffs can be specified either in the ' - 'Seventy Upgrades "Buffs" tab, or in the "Consumables" and "Raid ' - 'Buffs " sections in the sim. If the "Buffs" option is checked in the ' - 'Seventy Upgrades export window, then the corresponding sections in ' - 'the sim input will be ignored.', - style={'width': 300}, - ), - dcc.Upload( - id='upload-data', - children=html.Div([ - 'Drag and Drop or ', - html.A('Select File') - ]), - style={ - 'width': '100%', - 'height': '60px', - 'lineHeight': '60px', - 'borderWidth': '1px', - 'borderStyle': 'dashed', - 'borderRadius': '5px', - 'textAlign': 'center', - 'margin': '0px' - }, - # Don't allow multiple files to be uploaded - multiple=False - ), - html.Br(), - html.Div( - 'No file uploaded, using default input stats instead.', - id='upload_status', style={'color': '#E59F3A'} - ), - html.Br(), - html.H5('Idols and Set Bonuses'), - dbc.Checklist( - options=[{'label': 'Idol of the Raven Goddess', 'value': 'raven'}], - value=['raven'], id='raven_idol' - ), - dbc.Checklist( - options=[ - {'label': 'Everbloom Idol', 'value': 'everbloom'}, - {'label': 'Idol of Terror', 'value': 'idol_of_terror'}, - {'label': 'Idol of the White Stag', 'value': 'stag_idol'}, - {'label': '2-piece Tier 4 bonus', 'value': 't4_bonus'}, - {'label': '4-piece Tier 5 bonus', 'value': 't5_bonus'}, - {'label': '2-piece Tier 6 bonus', 'value': 't6_2p'}, - {'label': '4-piece Tier 6 bonus', 'value': 't6_4p'}, - {'label': 'Wolfshead Helm', 'value': 'wolfshead'}, - {'label': 'Relentless Earthstorm Diamond', 'value': 'meta'}, - {'label': 'Band of the Eternal Champion', 'value': 'exalted_ring'}, - ], - value=['t6_2p', 't6_4p', 'wolfshead', 'exalted_ring'], - id='bonuses' - ), - ], width='auto', style={'marginBottom': '2.5%', 'marginLeft': '2.5%'}) - -buffs_1 = dbc.Col( - [dbc.Collapse([html.H5('Consumables'), - dbc.Checklist( - options=[{'label': 'Elixir of Major Agility', 'value': 'agi_elixir'}, - {'label': 'Elixir of Draenic Wisdom', 'value': 'draenic'}, - {'label': 'Warp Burger / Grilled Mudfish', 'value': 'food'}, - {'label': 'Scroll of Agility V', 'value': 'scroll_agi'}, - {'label': 'Scroll of Strength V', 'value': 'scroll_str'}, - {'label': 'Adamantite Weightstone', 'value': 'weightstone'}], - value=[ - 'agi_elixir', 'food', 'scroll_agi', 'scroll_str', 'weightstone', - ], - id='consumables' - ), - html.Br(), - html.H5('Raid Buffs'), - dbc.Checklist( - options=[{'label': 'Blessing of Kings', 'value': 'kings'}, - {'label': 'Blessing of Might', 'value': 'might'}, - {'label': 'Blessing of Wisdom', 'value': 'wisdom'}, - {'label': 'Mark of the Wild', 'value': 'motw'}, - {'label': 'Trueshot Aura', 'value': 'trueshot_aura'}, - {'label': 'Heroic Presence', 'value': 'heroic_presence'}, - {'label': 'Strength of Earth Totem', 'value': 'str_totem'}, - {'label': 'Grace of Air Totem', 'value': 'agi_totem'}, - {'label': 'Unleashed Rage', 'value': 'unleashed_rage'}, - {'label': 'Arcane Intellect', 'value': 'ai'}, - {'label': 'Prayer of Spirit', 'value': 'spirit'}, - {'label': 'Battle Shout', 'value': 'bshout'}], - value=[ - 'kings', 'might', 'motw', 'str_totem', 'agi_totem', - 'unleashed_rage', 'ai', 'bshout' - ], - id='raid_buffs' - ), - dbc.Checklist( - options=[{'label': 'Commanding Presence', 'value': 'talent'}, - {'label': "Solarian's Sapphire", 'value': 'trinket'}], - value=['talent'], id='bshout_options', - style={'marginLeft': '10%'}, - ), - html.Br()], id='buff_section', is_open=True), - html.H5('Other Buffs'), - dbc.Checklist( - options=[ - {'label': 'Omen of Clarity', 'value': 'omen'}, - {'label': 'Bogling Root', 'value': 'bogling_root'}, - {'label': 'Consecrated Sharpening Stone', 'value': 'consec'}, - {'label': 'Improved Sanctity Aura', 'value': 'sanc_aura'}, - {'label': 'Mana Spring Totem', 'value': 'mana_spring_totem'}, - {'label': 'Braided Eternium Chain', 'value': 'be_chain'}, - ], - value=['omen', 'sanc_aura', 'mana_spring_totem'], id='other_buffs' - ), - dbc.InputGroup( - [ - dbc.InputGroupAddon( - 'Ferocious Inspiration stacks:', addon_type='prepend' - ), - dbc.Input( - value=0, type='number', id='ferocious_inspiration', min=0, - max=4 - ) - ], - style={'width': '100%', 'marginTop': '2%'}, size='sm' - )], - width='auto', style={'marginBottom': '2.5%', 'marginLeft': '2.5%'} -) - -encounter_details = dbc.Col( - [html.H4('Encounter Details'), - dbc.InputGroup( - [ - dbc.InputGroupAddon('Fight Length:', addon_type='prepend'), - dbc.Input( - value=120.0, type='number', id='fight_length', - ), - dbc.InputGroupAddon('seconds', addon_type='append') - ], - style={'width': '50%'} - ), - dbc.InputGroup( - [ - dbc.InputGroupAddon('Boss Armor:', addon_type='prepend'), - dbc.Input(value=6193, type='number', id='boss_armor') - ], - style={'width': '50%'} - ), - html.Br(), - html.H5('Damage Debuffs'), - dbc.Checklist( - options=[ - {'label': 'Gift of Arthas', 'value': 'gift_of_arthas'}, - {'label': 'Sunder Armor', 'value': 'sunder'}, - {'label': 'Improved Expose Armor', 'value': 'imp_EA'}, - {'label': 'Curse of Recklessness', 'value': 'CoR'}, - {'label': 'Faerie Fire', 'value': 'faerie_fire'}, - {'label': 'Annihilator', 'value': 'annihilator'}, - {'label': 'Blood Frenzy', 'value': 'blood_frenzy'}, - ], - value=[ - 'gift_of_arthas', 'sunder', 'imp_EA', 'CoR', 'faerie_fire', - 'blood_frenzy' - ], - id='boss_debuffs' - ), - html.Br(), - html.H5('Stat Debuffs'), - dbc.Checklist( - options=[ - {'label': 'Improved Faerie Fire', 'value': 'imp_ff'}, - {'label': "Improved Hunter's Mark", 'value': 'hunters_mark'}, - {'label': 'Improved Judgment of the Crusader', 'value': 'jotc'}, - {'label': 'Judgment of Wisdom', 'value': 'jow'}, - {'label': 'Expose weakness', 'value': 'expose'}, - ], - value=['imp_ff', 'hunters_mark', 'jotc', 'jow', 'expose'], - id='stat_debuffs', - ), - dbc.InputGroup( - [ - dbc.InputGroupAddon( - 'Survival hunter Agility:', addon_type='prepend' - ), - dbc.Input(value=1000, type='number', id='surv_agi',), - ], - size='sm', - style={'width': '40%', 'marginTop': '1%', 'marginLeft': '5%'}, - ), - html.Br(), - html.H5('Cooldowns'), - dbc.Row([ - dbc.Col( - dbc.Checklist( - options=[ - { - 'label': 'Manual Crowd Pummeler - Maximum uses:', - 'value': 'mcp', - }, - {'label': 'Bloodlust', 'value': 'lust'}, - {'label': 'Drums of Battle', 'value': 'drums'}, - {'label': 'Dark / Demonic Rune', 'value': 'rune'}, - ], - value=['lust', 'drums', 'rune'], id='cooldowns', - ), - width='auto', - ), - dbc.Col( - dbc.Input( - value=2, type='number', id='num_mcp', - style={'width': '35%', 'marginTop': '-5%'}, - ) - ) - ]), - dbc.InputGroup( - [ - dbc.InputGroupAddon('Potion CD:', addon_type='prepend'), - dbc.Select( - options=[ - {'label': 'Super Mana Potion', 'value': 'super'}, - {'label': 'Fel Mana Potion', 'value': 'fel'}, - {'label': 'Haste Potion', 'value': 'haste'}, - {'label': 'None', 'value': 'none'}, - ], - value='haste', id='potion', - ), - ], - style={'width': '50%', 'marginTop': '1.5%'} - )], - width='auto', - style={ - 'marginLeft': '2.5%', 'marginBottom': '2.5%', 'marginRight': '-2.5%' - } -) - -# Sim replicates input -iteration_input = dbc.Col([ - html.H4('Sim Settings'), - dbc.InputGroup( - [ - dbc.InputGroupAddon('Number of replicates:', addon_type='prepend'), - dbc.Input(value=20000, type='number', id='num_replicates') - ], - style={'width': '35%'} - ), - dbc.InputGroup( - [ - dbc.InputGroupAddon('Modeled input delay:', addon_type='prepend'), - dbc.Input( - value=100, type='number', id='latency', min=1, step=1, - ), - dbc.InputGroupAddon('ms', addon_type='append') - ], - style={'width': '35%'} - ), - html.Br(), - html.H5('Talents'), - html.Div([ - html.Div( - 'Feral Aggression:', - style={ - 'width': '35%', 'display': 'inline-block', - 'fontWeight': 'bold' - } - ), - dbc.Select( - options=[ - {'label': '0', 'value': 0}, - {'label': '1', 'value': 1}, - {'label': '2', 'value': 2}, - {'label': '3', 'value': 3}, - {'label': '4', 'value': 4}, - {'label': '5', 'value': 5}, - ], - value='0', id='feral_aggression', - style={ - 'width': '20%', 'display': 'inline-block', - 'marginBottom': '2.5%', 'marginRight': '5%' - } - )]), - html.Div([ - html.Div( - 'Savage Fury:', - style={ - 'width': '35%', 'display': 'inline-block', - 'fontWeight': 'bold' - } - ), - dbc.Select( - options=[ - {'label': '0', 'value': 0}, - {'label': '1', 'value': 1}, - {'label': '2', 'value': 2}, - ], - value=2, id='savage_fury', - style={ - 'width': '20%', 'display': 'inline-block', - 'marginBottom': '2.5%', 'marginRight': '5%' - } - )]), - html.Div([ - html.Div( - 'Naturalist:', - style={ - 'width': '35%', 'display': 'inline-block', - 'fontWeight': 'bold' - } - ), - dbc.Select( - options=[ - {'label': '0', 'value': 0}, - {'label': '1', 'value': 1}, - {'label': '2', 'value': 2}, - {'label': '3', 'value': 3}, - {'label': '4', 'value': 4}, - {'label': '5', 'value': 5}, - ], - value=5, id='naturalist', - style={ - 'width': '20%', 'display': 'inline-block', - 'marginBottom': '2.5%', 'marginRight': '5%' - } - )]), - html.Div([ - html.Div( - 'Natural Shapeshifter:', - style={ - 'width': '35%', 'display': 'inline-block', - 'fontWeight': 'bold' - } - ), - dbc.Select( - options=[ - {'label': '0', 'value': 0}, - {'label': '1', 'value': 1}, - {'label': '2', 'value': 2}, - {'label': '3', 'value': 3}, - ], - value=3, id='natural_shapeshifter', - style={ - 'width': '20%', 'display': 'inline-block', - 'marginBottom': '2.5%', 'marginRight': '5%' - } - )]), - html.Div([ - html.Div( - 'Intensity:', - style={ - 'width': '35%', 'display': 'inline-block', - 'fontWeight': 'bold' - } - ), - dbc.Select( - options=[ - {'label': '0', 'value': 0}, - {'label': '1', 'value': 1}, - {'label': '2', 'value': 2}, - {'label': '3', 'value': 3}, - ], - value=3, id='intensity', - style={ - 'width': '20%', 'display': 'inline-block', - 'marginBottom': '2.5%', 'marginRight': '5%' - } - )]), - html.Br(), - html.H5('Player Strategy'), - dbc.InputGroup( - [ - dbc.InputGroupAddon('Finishing move:', addon_type='prepend'), - dbc.Select( - options=[ - {'label': 'Rip', 'value': 'rip'}, - {'label': 'Ferocious Bite', 'value': 'bite'}, - {'label': 'None', 'value': 'none'}, - ], - value='rip', id='finisher', - ), - ], - style={'width': '45%', 'marginBottom': '1.5%'} - ), - dbc.InputGroup( - [ - dbc.InputGroupAddon( - 'Minimum combo points for Rip:', addon_type='prepend' - ), - dbc.Select( - options=[ - {'label': '3', 'value': 3}, - {'label': '4', 'value': 4}, - {'label': '5', 'value': 5}, - ], - value=4, id='rip_cp', - ), - ], - style={'width': '48%', 'marginBottom': '1.5%'} - ), - dbc.InputGroup( - [ - dbc.InputGroupAddon( - 'Minimum combo points for Ferocious Bite:', - addon_type='prepend' - ), - dbc.Select( - options=[ - {'label': '3', 'value': 3}, - {'label': '4', 'value': 4}, - {'label': '5', 'value': 5}, - ], - value=4, id='bite_cp', - ), - ], - style={'width': '60%', 'marginBottom': '1.5%'} - ), - dbc.InputGroup( - [ - dbc.InputGroupAddon('Wait at most:', addon_type='prepend'), - dbc.Input( - value=1.0, min=0.0, max=2.0, step=0.1, type='number', - id='max_wait_time', - ), - dbc.InputGroupAddon( - 'seconds for an energy tick', addon_type='append' - ) - ], - style={'width': '63%', 'marginBottom': '1.5%'} - ), - dbc.InputGroup( - [ - dbc.InputGroupAddon('Wait', addon_type='prepend'), - dbc.Input( - value=15.0, min=0.0, step=0.5, type='number', id='cd_delay', - ), - dbc.InputGroupAddon( - 'seconds before using cooldowns', addon_type='append' - ), - ], - style={'width': '63%'}, - ), - html.Br(), - dbc.Row([ - dbc.Col(dbc.Checklist( - options=[{'label': " weave Ferocious Bite", 'value': 'bite'}], - value=['bite'], id='use_biteweave', - ), width='auto'), - dbc.Col('with', width='auto', id='biteweave_text_1'), - dbc.Col(dbc.Input( - type='number', value=0, id='bite_time', min=0.0, step=0.1, - style={'marginTop': '-3%', 'marginBottom': '7%', 'width': '40%'}, - ), width='auto'), - dbc.Col( - 'seconds left on Rip', width='auto', style={'marginLeft': '-15%'}, - id='biteweave_text_2' - ) - ],), - dbc.Row([ - dbc.Col(dbc.Checklist( - options=[{'label': " weave Rip", 'value': 'rip'}], value=[], - id='use_ripweave', - ), width='auto'), - dbc.Col('at', width='auto', id='ripweave_text_1'), - dbc.Col(dbc.Input( - type='number', value=52, id='ripweave_energy', min=30, step=1, - style={'marginTop': '-3%', 'marginBottom': '7%', 'width': '40%'}, - ), width='auto'), - dbc.Col( - 'energy or above', width='auto', style={'marginLeft': '-15%'}, - id='ripweave_text_2' - ) - ],), - dbc.Row([ - dbc.Col(dbc.Checklist( - options=[{'label': " pre-pop Tiger's Fury", 'value': 'prepop_TF'}], - value=[], id='prepop_TF', - ), width='auto'), - dbc.Col('at', width='auto'), - dbc.Col(dbc.Select( - options=[{'label': '1', 'value': 1}, {'label': '2', 'value': 2}], - value=2, id='prepop_numticks', - style={'marginTop': '-7%'}, - ), width='auto'), - dbc.Col('energy ticks before combat', width='auto') - ],), - dbc.Checklist( - options=[{'label': ' use Mangle trick', 'value': 'use_mangle_trick'}], - value=['use_mangle_trick'], id='use_mangle_trick' - ), - dbc.Checklist( - options=[{'label': ' use Rake trick', 'value': 'use_rake_trick'}], - value=[], id='use_rake_trick' - ), - dbc.Row([ - dbc.Col(dbc.Checklist( - options=[{'label': ' use Bite trick', 'value': 'use_bite_trick'}], - value=[], id='use_bite_trick' - ), width='auto'), - dbc.Col('with at least', width='auto', id='bite_trick_text_1'), - dbc.Col(dbc.Select( - options=[{'label': i, 'value': i} for i in range(1, 6)], - value=2, id='bite_trick_cp', - style={'marginTop': '-7%'}, - ), width='auto'), - dbc.Col( - 'combo points, and an energy range up to', width='auto', - id='bite_trick_text_2' - ), - dbc.Col(dbc.Input( - type='number', value=39, id='bite_trick_max', min=35, step=1, - style={'marginTop': '-7%', 'width': '40%'}, - ), width='auto'), - ]), - dbc.Checklist( - options=[{'label': ' use Innervate', 'value': 'use_innervate'}], - value=[], id='use_innervate' - ), - dbc.Checklist( - options=[{ - 'label': ' Mangle maintained by bear tank', 'value': 'bear_mangle' - }], value=[], id='bear_mangle' - ), - html.Br(), - html.H5('Trinkets'), - dbc.Row([ - dbc.Col(dbc.Select( - id='trinket_1', - options=[ - {'label': 'Empty', 'value': 'none'}, - {'label': 'Tsunami Talisman', 'value': 'tsunami'}, - {'label': 'Bloodlust Brooch', 'value': 'brooch'}, - {'label': 'Hourglass of the Unraveller', 'value': 'hourglass'}, - {'label': 'Dragonspine Trophy', 'value': 'dst'}, - {'label': 'Mark of the Champion', 'value': 'motc'}, - {'label': "Slayer's Crest", 'value': 'slayers'}, - {'label': 'Drake Fang Talisman', 'value': 'dft'}, - {'label': 'Icon of Unyielding Courage', 'value': 'icon'}, - {'label': 'Abacus of Violent Odds', 'value': 'abacus'}, - {'label': 'Badge of the Swarmguard', 'value': 'swarmguard'}, - {'label': 'Kiss of the Spider', 'value': 'kiss'}, - {'label': 'Badge of Tenacity', 'value': 'tenacity'}, - { - 'label': 'Living Root of the Wildheart', - 'value': 'wildheart', - }, - { - 'label': 'Ashtongue Talisman of Equilibrium', - 'value': 'ashtongue', - }, - {'label': 'Crystalforged Trinket', 'value': 'crystalforged'}, - {'label': 'Madness of the Betrayer', 'value': 'madness'}, - {'label': "Romulo's Poison Vial", 'value': 'vial'}, - { - 'label': 'Steely Naaru Sliver', - 'value': 'steely_naaru_sliver' - }, - {'label': 'Shard of Contempt', 'value': 'shard_of_contempt'}, - {'label': "Berserker's Call", 'value': 'berserkers_call'}, - {'label': "Alchemist's Stone", 'value': 'alch'}, - { - 'label': "Assassin's Alchemist Stone", - 'value': 'assassin_alch' - }, - {'label': 'Blackened Naaru Sliver', 'value': 'bns'}, - {'label': 'Darkmoon Card: Crusade', 'value': 'crusade'}, - ], - value='madness' - )), - dbc.Col(dbc.Select( - id='trinket_2', - options=[ - {'label': 'Empty', 'value': 'none'}, - {'label': 'Tsunami Talisman', 'value': 'tsunami'}, - {'label': 'Bloodlust Brooch', 'value': 'brooch'}, - {'label': 'Hourglass of the Unraveller', 'value': 'hourglass'}, - {'label': 'Dragonspine Trophy', 'value': 'dst'}, - {'label': 'Mark of the Champion', 'value': 'motc'}, - {'label': "Slayer's Crest", 'value': 'slayers'}, - {'label': 'Drake Fang Talisman', 'value': 'dft'}, - {'label': 'Icon of Unyielding Courage', 'value': 'icon'}, - {'label': 'Abacus of Violent Odds', 'value': 'abacus'}, - {'label': 'Badge of the Swarmguard', 'value': 'swarmguard'}, - {'label': 'Kiss of the Spider', 'value': 'kiss'}, - {'label': 'Badge of Tenacity', 'value': 'tenacity'}, - { - 'label': 'Living Root of the Wildheart', - 'value': 'wildheart', - }, - { - 'label': 'Ashtongue Talisman of Equilibrium', - 'value': 'ashtongue', - }, - {'label': 'Crystalforged Trinket', 'value': 'crystalforged'}, - {'label': 'Madness of the Betrayer', 'value': 'madness'}, - {'label': "Romulo's Poison Vial", 'value': 'vial'}, - { - 'label': 'Steely Naaru Sliver', - 'value': 'steely_naaru_sliver' - }, - {'label': 'Shard of Contempt', 'value': 'shard_of_contempt'}, - {'label': "Berserker's Call", 'value': 'berserkers_call'}, - {'label': "Alchemist's Stone", 'value': 'alch'}, - { - 'label': "Assassin's Alchemist Stone", - 'value': 'assassin_alch' - }, - {'label': 'Blackened Naaru Sliver', 'value': 'bns'}, - {'label': 'Darkmoon Card: Crusade', 'value': 'crusade'}, - ], - value='tsunami' - )), - ]), - html.Div( - 'Make sure not to include passive trinket stats in the sim input.', - style={ - 'marginTop': '2.5%', 'fontSize': 'large', 'fontWeight': 'bold' - }, - ), - html.Div([ - dbc.Button( - "Run", id='run_button', n_clicks=0, size='lg', color='success', - style={ - 'marginBottom': '10%', 'fontSize': 'large', 'marginTop': '10%', - 'display': 'inline-block' - } - ), - html.Div( - '', id='status', - style={ - 'display': 'inline-block', 'fontWeight': 'bold', - 'marginLeft': '10%', 'fontSize': 'large' - } - ) - ]), - dcc.Interval(id='interval', interval=500), -], width='auto', style={'marginBottom': '2.5%', 'marginLeft': '2.5%'}) - -input_layout = html.Div(children=[ - html.H1( - children='WoW Classic TBC Feral Cat Simulator', - style={'textAlign': 'center'} - ), - dbc.Row( - [stat_input, buffs_1, encounter_details, iteration_input], - style={'marginTop': '2.5%'} - ), -]) - -stats_output = dbc.Col( - [html.H4('Raid Buffed Stats'), - html.Div([ - html.Div( - 'Swing Timer:', - style={'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large'} - ), - html.Div( - '', - style={'width': '50%', 'display': 'inline-block', - 'fontSize': 'large'}, - id='buffed_swing_timer' - ) - ]), - html.Div([ - html.Div( - 'Attack Power:', - style={'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large'} - ), - html.Div( - '', - style={'width': '50%', 'display': 'inline-block', - 'fontSize': 'large'}, - id='buffed_attack_power' - ) - ]), - html.Div([ - html.Div( - 'Boss Crit Chance:', - style={'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large'} - ), - html.Div( - '', - style={'width': '50%', 'display': 'inline-block', - 'fontSize': 'large'}, - id='buffed_crit' - ) - ]), - html.Div([ - html.Div( - 'Boss Miss Chance:', - style={'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large'} - ), - html.Div( - '', - style={'width': '50%', 'display': 'inline-block', - 'fontSize': 'large'}, - id='buffed_miss' - ) - ]), - html.Div([ - html.Div( - 'Mana:', - style={'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large'} - ), - html.Div( - '', - style={'width': '50%', 'display': 'inline-block', - 'fontSize': 'large'}, - id='buffed_mana' - ) - ]), - html.Div([ - html.Div( - 'Intellect:', - style={'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large'} - ), - html.Div( - '', - style={'width': '50%', 'display': 'inline-block', - 'fontSize': 'large'}, - id='buffed_int' - ) - ]), - html.Div([ - html.Div( - 'Spirit:', - style={'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large'} - ), - html.Div( - '', - style={'width': '50%', 'display': 'inline-block', - 'fontSize': 'large'}, - id='buffed_spirit' - ) - ]), - html.Div([ - html.Div( - 'MP5:', - style={'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large'} - ), - html.Div( - '', - style={'width': '50%', 'display': 'inline-block', - 'fontSize': 'large'}, - id='buffed_mp5' - ) - ])], - width=4, xl=3, style={'marginLeft': '2.5%', 'marginBottom': '2.5%'} -) - -sim_output = dbc.Col([ - html.H4('Results'), - dcc.Loading(children=html.Div([ - html.Div( - 'Average DPS:', - style={ - 'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large' - } - ), - html.Div( - '', - style={ - 'width': '50%', 'display': 'inline-block', 'fontSize': 'large' - }, - id='mean_std_dps' - ), - ]), id='loading_1', type='default'), - dcc.Loading(children=html.Div([ - html.Div( - 'Median DPS:', - style={ - 'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large' - } - ), - html.Div( - '', - style={ - 'width': '50%', 'display': 'inline-block', 'fontSize': 'large' - }, - id='median_dps' - ), - ]), id='loading_2', type='default'), - dcc.Loading(children=html.Div([ - html.Div( - 'Time to oom:', - style={ - 'width': '50%', 'display': 'inline-block', - 'fontWeight': 'bold', 'fontSize': 'large' - } - ), - html.Div( - '', - style={ - 'width': '50%', 'display': 'inline-block', 'fontSize': 'large' - }, - id='time_to_oom' - ), - ]), id='loading_oom_time', type='default'), - html.Br(), - html.H5('DPS Breakdown'), - dcc.Loading(children=dbc.Table([ - html.Thead(html.Tr([ - html.Th('Ability'), html.Th('Number of Casts'), html.Th('CPM'), - html.Th('Damage per Cast'), html.Th('DPS Contribution') - ])), - html.Tbody(id='dps_breakdown_table') - ]), id='loading_3', type='default'), - html.Br(), - html.H5('Aura Statistics'), - dcc.Loading(children=dbc.Table([ - html.Thead(html.Tr([ - html.Th('Aura Name'), html.Th('Number of Procs'), - html.Th('Average Uptime'), - ])), - html.Tbody(id='aura_breakdown_table') - ]), id='loading_auras', type='default'), - html.Br(), - html.Br() -], style={'marginLeft': '2.5%', 'marginBottom': '2.5%'}, width=4, xl=3) - -weights_section = dbc.Col([ - html.H4('Stat Weights'), - html.Div([ - dbc.Row( - [ - dbc.Col(dbc.Button( - 'Calculate Weights', id='weight_button', n_clicks=0, - color='info' - ), width='auto'), - dbc.Col( - [ - dbc.FormGroup( - [ - dbc.Checkbox( - id='calc_mana_weights', - className='form-check-input', checked=False - ), - dbc.Label( - 'Include mana weights', - html_for='calc_mana_weights', - className='form-check-label' - ) - ], - check=True - ), - dbc.FormGroup( - [ - dbc.Checkbox( - id='epic_gems', - className='form-check-input', checked=True - ), - dbc.Label( - 'Assume Epic gems', - html_for='epic_gems', - className='form-check-label' - ) - ], - check=True - ), - ], - width='auto' - ) - ] - ), - html.Div( - 'Calculation will take several minutes.', - style={'fontWeight': 'bold'}, - ), - dcc.Loading( - children=[ - html.P( - children=[ - html.Strong( - 'Error: ', style={'fontSize': 'large'}, - id='error_str' - ), - html.Span( - 'Stat weight calculation requires the simulation ' - 'to be run with at least 20,000 replicates.', - style={'fontSize': 'large'}, id='error_msg' - ) - ], - style={'marginTop': '4%'}, - ), - dbc.Table([ - html.Thead(html.Tr([ - html.Th('Stat Increment'), html.Th('DPS Added'), - html.Th('Normalized Weight') - ])), - html.Tbody(id='stat_weight_table'), - ]), - html.Div( - html.A( - 'Seventy Upgrades Import Link', - href='https://seventyupgrades.com', target='_blank' - ), - id='import_link' - ) - ], - id='loading_4', type='default' - ), - ]), -], style={'marginLeft': '5%', 'marginBottom': '2.5%'}, width=4, xl=3) - -sim_section = dbc.Row( - [stats_output, sim_output, weights_section] -) - -graph_section = html.Div([ - dbc.Row( - [ - dbc.Col( - dbc.Button( - "Generate Example", id='graph_button', n_clicks=0, - color='info', - style={'marginLeft': '2.5%', 'fontSize': 'large'} - ), - width='auto' - ), - dbc.Col( - dbc.FormGroup( - [ - dbc.Checkbox( - id='show_whites', className='form-check-input' - ), - dbc.Label( - 'Show white damage', html_for='show_whites', - className='form-check-label' - ) - ], - check=True - ), - width='auto' - ) - ] - ), - html.H4( - 'Example of energy flow in a fight', style={'textAlign': 'center'} - ), - dcc.Graph(id='energy_flow'), - html.Br(), - dbc.Col( - [ - html.H5('Combat Log'), - dbc.Table([ - html.Thead(html.Tr([ - html.Th('Time'), html.Th('Event'), html.Th('Outcome'), - html.Th('Energy'), html.Th('Combo Points'), html.Th('Mana') - ])), - html.Tbody(id='combat_log') - ]) - ], - width=5, xl=4, style={'marginLeft': '2.5%'} - ) -]) - -app.layout = html.Div([ - input_layout, sim_section, graph_section -]) - - -# Helper functions used in master callback -def process_trinkets(trinket_1, trinket_2, player, ap_mod, stat_mod, cd_delay): - proc_trinkets = [] - all_trinkets = [] - - for trinket in [trinket_1, trinket_2]: - if trinket == 'none': - continue - - trinket_params = copy.deepcopy(trinkets.trinket_library[trinket]) - - for stat, increment in trinket_params['passive_stats'].items(): - if stat == 'intellect': - increment *= 1.2 # hardcode the HotW 20% increase - if stat in ['strength', 'agility', 'intellect', 'spirit']: - increment *= stat_mod - if stat == 'strength': - increment *= 2 - stat = 'attack_power' - if stat == 'agility': - stat = 'attack_power' - # additionally modify crit here - setattr( - player, 'crit_chance', - getattr(player, 'crit_chance') + increment / 25. / 100. - ) - if stat == 'attack_power': - increment *= ap_mod - if stat == 'haste_rating': - new_swing_timer = ccs.calc_swing_timer( - ccs.calc_haste_rating(player.swing_timer) + increment, - ) - player.swing_timer = new_swing_timer - continue - - setattr(player, stat, getattr(player, stat) + increment) - - if trinket_params['type'] == 'passive': - continue - - active_stats = trinket_params['active_stats'] - - if active_stats['stat_name'] == 'attack_power': - active_stats['stat_increment'] *= ap_mod - if active_stats['stat_name'] == 'Agility': - active_stats['stat_name'] = ['attack_power', 'crit_chance'] - agi_increment = active_stats['stat_increment'] - active_stats['stat_increment'] = np.array([ - stat_mod * agi_increment * ap_mod, - stat_mod * agi_increment/25./100. - ]) - if active_stats['stat_name'] == 'Strength': - active_stats['stat_name'] = 'attack_power' - active_stats['stat_increment'] *= 2 * stat_mod * ap_mod - - if trinket_params['type'] == 'activated': - # If this is the second trinket slot and the first trinket was also - # activated, then we need to enforce an activation delay due to the - # shared cooldown. For now we will assume that the shared cooldown - # is always equal to the duration of the first trinket's proc. - if all_trinkets and (not proc_trinkets): - delay = cd_delay + all_trinkets[-1].proc_duration - else: - delay = cd_delay - - all_trinkets.append( - trinkets.ActivatedTrinket(delay=delay, **active_stats) - ) - else: - proc_type = active_stats.pop('proc_type') - - if proc_type == 'chance_on_hit': - proc_chance = active_stats.pop('proc_rate') - active_stats['chance_on_hit'] = proc_chance - active_stats['chance_on_crit'] = proc_chance - elif proc_type == 'chance_on_crit': - active_stats['chance_on_hit'] = 0.0 - active_stats['chance_on_crit'] = active_stats.pop('proc_rate') - elif proc_type == 'ppm': - ppm = active_stats.pop('proc_rate') - active_stats['chance_on_hit'] = ppm/60. - active_stats['yellow_chance_on_hit'] = ( - ppm/60. * player.weapon_speed - ) - - if trinket == 'vial': - trinket_obj = trinkets.PoisonVial( - active_stats['chance_on_hit'], - active_stats['yellow_chance_on_hit'] - ) - elif trinket_params['type'] == 'refreshing_proc': - trinket_obj = trinkets.RefreshingProcTrinket(**active_stats) - elif trinket_params['type'] == 'stacking_proc': - trinket_obj = trinkets.StackingProcTrinket(**active_stats) - else: - trinket_obj = trinkets.ProcTrinket(**active_stats) - - all_trinkets.append(trinket_obj) - proc_trinkets.append(all_trinkets[-1]) - - player.proc_trinkets = proc_trinkets - return all_trinkets - - -def create_player( - buffed_attack_power, buffed_hit, buffed_crit, buffed_weapon_damage, - haste_rating, expertise_rating, armor_pen, buffed_mana_pool, - buffed_int, buffed_spirit, buffed_mp5, weapon_speed, unleashed_rage, - kings, raven_idol, other_buffs, stat_debuffs, cooldowns, num_mcp, - surv_agi, ferocious_inspiration, bonuses, naturalist, feral_aggression, - savage_fury, natural_shapeshifter, intensity, potion -): - """Takes in raid buffed player stats from Seventy Upgrades, modifies them - based on boss debuffs and miscellaneous buffs not captured by Seventy - Upgrades, and instantiates a Player object with those stats.""" - - # Swing timer calculation is independent of other buffs. First we add up - # the haste rating from all the specified haste buffs - use_mcp = ('mcp' in cooldowns) and (num_mcp > 0) - buffed_haste_rating = haste_rating + 500 * use_mcp - buffed_swing_timer = ccs.calc_swing_timer(buffed_haste_rating) - - # Augment secondary stats as needed - ap_mod = 1.1 * (1 + 0.1 * unleashed_rage) - debuff_ap = ( - 100 * ('consec' in other_buffs) - + 110 * ('hunters_mark' in stat_debuffs) - + 0.25 * surv_agi * ('expose' in stat_debuffs) - ) - encounter_crit = ( - buffed_crit + 3 * ('jotc' in stat_debuffs) - + (28 * ('be_chain' in other_buffs) + 20 * bool(raven_idol)) / 22.1 - ) - encounter_hit = buffed_hit + 3 * ('imp_ff' in stat_debuffs) - encounter_mp5 = buffed_mp5 + 50 * ('mana_spring_totem' in other_buffs) - - # Calculate bonus damage parameters - encounter_weapon_damage = ( - buffed_weapon_damage + ('bogling_root' in other_buffs) - ) - damage_multiplier = ( - (1 + 0.02 * int(naturalist)) * 1.03**ferocious_inspiration - * (1 + 0.02 * ('sanc_aura' in other_buffs)) - ) - shred_bonus = 88 * ('everbloom' in bonuses) + 75 * ('t5_bonus' in bonuses) - - # Create and return a corresponding Player object - player = ccs.Player( - attack_power=buffed_attack_power, hit_chance=encounter_hit / 100, - expertise_rating=expertise_rating, crit_chance=encounter_crit / 100, - swing_timer=buffed_swing_timer, mana=buffed_mana_pool, - intellect=buffed_int, spirit=buffed_spirit, mp5=encounter_mp5, - omen='omen' in other_buffs, feral_aggression=int(feral_aggression), - savage_fury=int(savage_fury), - natural_shapeshifter=int(natural_shapeshifter), - intensity=int(intensity), weapon_speed=weapon_speed, - bonus_damage=encounter_weapon_damage, multiplier=damage_multiplier, - jow='jow' in stat_debuffs, armor_pen=armor_pen, - t4_bonus='t4_bonus' in bonuses, t6_2p='t6_2p' in bonuses, - t6_4p='t6_4p' in bonuses, wolfshead='wolfshead' in bonuses, - meta='meta' in bonuses, rune='rune' in cooldowns, - pot=potion in ['super', 'fel'], cheap_pot=(potion == 'super'), - shred_bonus=shred_bonus, debuff_ap=debuff_ap - ) - return player, ap_mod, (1 + 0.1 * kings) * 1.03 - - -def apply_buffs( - unbuffed_ap, unbuffed_strength, unbuffed_agi, unbuffed_hit, - unbuffed_crit, unbuffed_mana, unbuffed_int, unbuffed_spirit, - unbuffed_mp5, weapon_damage, raid_buffs, consumables, bshout_options -): - """Takes in unbuffed player stats, and turns them into buffed stats based - on specified consumables and raid buffs. This function should only be - called if the "Buffs" option is not checked in the exported file from - Seventy Upgrades, or else the buffs will be double counted!""" - - # Determine "raw" AP, crit, and mana not from Str/Agi/Int - raw_ap_unbuffed = unbuffed_ap / 1.1 - 2 * unbuffed_strength - unbuffed_agi - raw_crit_unbuffed = unbuffed_crit - unbuffed_agi / 25 - raw_mana_unbuffed = unbuffed_mana - 15 * unbuffed_int - - # Augment all base stats based on specified buffs - stat_multiplier = 1 + 0.1 * ('kings' in raid_buffs) - added_stats = 18 * ('motw' in raid_buffs) - - buffed_strength = stat_multiplier * (unbuffed_strength + 1.03 * ( - added_stats + 98 * ('str_totem' in raid_buffs) - + 20 * ('scroll_str' in consumables) - )) - buffed_agi = stat_multiplier * (unbuffed_agi + 1.03 * ( - added_stats + 88 * ('agi_totem' in raid_buffs) - + 35 * ('agi_elixir' in consumables) + 20 * ('food' in consumables) - + 20 * ('scroll_agi' in consumables) - )) - buffed_int = stat_multiplier * (unbuffed_int + 1.2 * 1.03 * ( - added_stats + 40 * ('ai' in raid_buffs) - + 30 * ('draenic' in consumables) - )) - buffed_spirit = stat_multiplier * (unbuffed_spirit + 1.03 * ( - added_stats + 50 * ('spirit' in raid_buffs) - + 20 * ('food' in consumables) + 30 * ('draenic' in consumables) - )) - - # Now augment secondary stats - ap_mod = 1.1 * (1 + 0.1 * ('unleashed_rage' in raid_buffs)) - bshout_ap = ( - ('bshout' in raid_buffs) * (305 + 70 * ('trinket' in bshout_options)) - * (1. + 0.25 * ('talent' in bshout_options)) - ) - buffed_attack_power = ap_mod * ( - raw_ap_unbuffed + 2 * buffed_strength + buffed_agi - + 264 * ('might' in raid_buffs) + bshout_ap - + 125 * ('trueshot_aura' in raid_buffs) - ) - added_crit_rating = ( - 20 * ('agi_elixir' in consumables) - + 14 * ('weightstone' in consumables) - ) - buffed_crit = ( - raw_crit_unbuffed + buffed_agi / 25 + added_crit_rating / 22.1 - ) - buffed_hit = ( - unbuffed_hit + 1 * ('heroic_presence' in raid_buffs) - ) - buffed_mana_pool = raw_mana_unbuffed + buffed_int * 15 - buffed_mp5 = unbuffed_mp5 + 49 * ('wisdom' in raid_buffs) - buffed_weapon_damage = ( - 12 * ('weightstone' in consumables) + weapon_damage - ) - - return { - 'strength': buffed_strength, - 'agility': buffed_agi, - 'intellect': buffed_int, - 'spirit': buffed_spirit, - 'attackPower': buffed_attack_power, - 'crit': buffed_crit, - 'hit': buffed_hit, - 'weaponDamage': buffed_weapon_damage, - 'mana': buffed_mana_pool, - 'mp5': buffed_mp5 - } - - -def run_sim(sim, num_replicates): - # Run the sim for the specified number of replicates - dps_vals, dmg_breakdown, aura_stats, oom_times = sim.run_replicates( - num_replicates, detailed_output=True - ) - - # Consolidate DPS statistics - avg_dps = np.mean(dps_vals) - mean_dps_str = '%.1f +/- %.1f' % (avg_dps, np.std(dps_vals)) - median_dps_str = '%.1f' % np.median(dps_vals) - - # Consolidate mana statistics - avg_oom_time = np.mean(oom_times) - - if avg_oom_time > sim.fight_length - 1: - oom_time_str = 'none' - else: - oom_time_str = ( - '%d +/- %d seconds' % (avg_oom_time, np.std(oom_times)) - ) - - # Create DPS breakdown table - dps_table = [] - - for ability in dmg_breakdown: - if ability in ['Claw']: - continue - - ability_dps = dmg_breakdown[ability]['damage'] / sim.fight_length - ability_cpm = dmg_breakdown[ability]['casts'] / sim.fight_length * 60. - ability_dpct = ability_dps * 60. / ability_cpm if ability_cpm else 0. - dps_table.append(html.Tr([ - html.Td(ability), - html.Td('%.3f' % dmg_breakdown[ability]['casts']), - html.Td('%.1f' % ability_cpm), - html.Td('%.0f' % ability_dpct), - html.Td('%.1f%%' % (ability_dps / avg_dps * 100)) - ])) - - # Create Aura uptime table - aura_table = [] - - for row in aura_stats: - aura_table.append(html.Tr([ - html.Td(row[0]), - html.Td('%.3f' % row[1]), - html.Td('%.1f%%' % (row[2] * 100)) - ])) - - return ( - avg_dps, - (mean_dps_str, median_dps_str, oom_time_str, dps_table, aura_table), - ) - - -def append_mana_weights( - weights_table, sim, num_replicates, time_to_oom, avg_dps, dps_per_AP, - stat_multiplier -): - # Just set all mana weights to 0 if we didn't even go oom - if time_to_oom == 'none': - weights_table.append(html.Tr([ - html.Td('mana stats'), html.Td('0.0'), html.Td('0.0'), - ])) - return - - # Calculate DPS increases and weights - dps_deltas, stat_weights = sim.calc_mana_weights( - num_replicates, avg_dps, dps_per_AP - ) - - # Parse results - for stat in dps_deltas: - multiplier = 1.0 if stat in ['1 mana', '1 mp5'] else stat_multiplier - weights_table.append(html.Tr([ - html.Td(stat), - html.Td('%.3f' % (dps_deltas[stat] * multiplier)), - html.Td('%.3f' % (stat_weights[stat] * multiplier)), - ])) - - -def calc_weights( - sim, num_replicates, avg_dps, calc_mana_weights, time_to_oom, - kings, unleashed_rage, epic_gems -): - # Check that sufficient iterations are used for convergence. - if num_replicates < 20000: - error_msg = ( - 'Stat weight calculation requires the simulation to be run with at' - ' least 20,000 replicates.' - ) - return 'Error: ', error_msg, [], '' - - # Do fresh weights calculation - weights_table = [] - - # Calculate DPS increases and weights - dps_deltas, stat_weights = sim.calc_stat_weights( - num_replicates, base_dps=avg_dps, unleashed_rage=unleashed_rage - ) - - # Parse results - for stat in dps_deltas: - if stat == '1 AP': - weight = 1.0 - dps_per_AP = dps_deltas[stat] - else: - weight = stat_weights[stat] - - weights_table.append(html.Tr([ - html.Td(stat), - html.Td('%.2f' % dps_deltas[stat]), - html.Td('%.2f' % weight), - ])) - - # Generate 70upgrades import link for raw stats - stat_multiplier = (1 + 0.1 * kings) * 1.03 - url = ccs.gen_import_link( - stat_weights, multiplier=stat_multiplier, epic_gems=epic_gems - ) - link = html.A('Seventy Upgrades Import Link', href=url, target='_blank') - - # Only calculate mana stats if requested - if calc_mana_weights: - append_mana_weights( - weights_table, sim, num_replicates, time_to_oom, avg_dps, - dps_per_AP, stat_multiplier - ) - - return 'Stat Breakdown', '', weights_table, link - - -def plot_new_trajectory(sim, show_whites): - t_vals, _, energy_vals, cp_vals, _, _, log = sim.run(log=True) - t_fine = np.linspace(0, sim.fight_length, 10000) - fig = go.Figure() - fig.add_trace(go.Scatter( - x=t_fine, y=ccs.piecewise_eval(t_fine, t_vals, energy_vals), - line=dict(color="#d62728") - )) - fig.add_trace(go.Scatter( - x=t_fine, y=ccs.piecewise_eval(t_fine, t_vals, cp_vals), - line=dict(color="#9467bd", dash='dash'), yaxis='y2' - )) - fig.update_layout( - xaxis=dict(title='Time (seconds)'), - yaxis=dict( - title='Energy', titlefont=dict(color='#d62728'), - tickfont=dict(color='#d62728') - ), - yaxis2=dict( - title='Combo points', titlefont=dict(color='#9467bd'), - tickfont=dict(color='#9467bd'), anchor='x', overlaying='y', - side='right' - ), - showlegend=False, - ) - - # Create combat log table - log_table = [] - - if not show_whites: - parsed_log = [row for row in log if row[1] != 'melee'] - else: - parsed_log = log - - for row in parsed_log: - log_table.append(html.Tr([ - html.Td(entry) for entry in row - ])) - - return fig, log_table - - -# Master callback function -@app.callback( - Output('upload_status', 'children'), - Output('upload_status', 'style'), - Output('buff_section', 'is_open'), - Output('buffed_swing_timer', 'children'), - Output('buffed_attack_power', 'children'), - Output('buffed_crit', 'children'), - Output('buffed_miss', 'children'), - Output('buffed_mana', 'children'), - Output('buffed_int', 'children'), - Output('buffed_spirit', 'children'), - Output('buffed_mp5', 'children'), - Output('mean_std_dps', 'children'), - Output('median_dps', 'children'), - Output('time_to_oom', 'children'), - Output('dps_breakdown_table', 'children'), - Output('aura_breakdown_table', 'children'), - Output('error_str', 'children'), - Output('error_msg', 'children'), - Output('stat_weight_table', 'children'), - Output('import_link', 'children'), - Output('energy_flow', 'figure'), - Output('combat_log', 'children'), - Input('upload-data', 'contents'), - Input('consumables', 'value'), - Input('raid_buffs', 'value'), - Input('bshout_options', 'value'), - Input('num_mcp', 'value'), - Input('other_buffs', 'value'), - Input('raven_idol', 'value'), - Input('stat_debuffs', 'value'), - Input('surv_agi', 'value'), - Input('trinket_1', 'value'), - Input('trinket_2', 'value'), - Input('run_button', 'n_clicks'), - Input('weight_button', 'n_clicks'), - Input('graph_button', 'n_clicks'), - State('potion', 'value'), - State('ferocious_inspiration', 'value'), - State('bonuses', 'value'), - State('feral_aggression', 'value'), - State('savage_fury', 'value'), - State('naturalist', 'value'), - State('natural_shapeshifter', 'value'), - State('intensity', 'value'), - State('fight_length', 'value'), - State('boss_armor', 'value'), - State('boss_debuffs', 'value'), - State('cooldowns', 'value'), - State('finisher', 'value'), - State('rip_cp', 'value'), - State('bite_cp', 'value'), - State('max_wait_time', 'value'), - State('cd_delay', 'value'), - State('prepop_TF', 'value'), - State('prepop_numticks', 'value'), - State('use_mangle_trick', 'value'), - State('use_rake_trick', 'value'), - State('use_bite_trick', 'value'), - State('bite_trick_cp', 'value'), - State('bite_trick_max', 'value'), - State('use_innervate', 'value'), - State('use_biteweave', 'value'), - State('bite_time', 'value'), - State('use_ripweave', 'value'), - State('ripweave_energy', 'value'), - State('bear_mangle', 'value'), - State('num_replicates', 'value'), - State('latency', 'value'), - State('calc_mana_weights', 'checked'), - State('epic_gems', 'checked'), - State('show_whites', 'checked')) -def compute( - json_file, consumables, raid_buffs, bshout_options, num_mcp, - other_buffs, raven_idol, stat_debuffs, surv_agi, trinket_1, trinket_2, - run_clicks, weight_clicks, graph_clicks, potion, ferocious_inspiration, - bonuses, feral_aggression, savage_fury, naturalist, - natural_shapeshifter, intensity, fight_length, boss_armor, - boss_debuffs, cooldowns, finisher, rip_cp, bite_cp, max_wait_time, - cd_delay, prepop_TF, prepop_numticks, use_mangle_trick, use_rake_trick, - use_bite_trick, bite_trick_cp, bite_trick_max, use_innervate, - use_biteweave, bite_time, use_ripweave, ripweave_energy, bear_mangle, - num_replicates, latency, calc_mana_weights, epic_gems, show_whites -): - ctx = dash.callback_context - - # Parse input stats JSON - buffs_present = False - use_default_inputs = True - - if json_file is None: - upload_output = ( - 'No file uploaded, using default input stats instead.', - {'color': '#E59F3A', 'width': 300}, True - ) - else: - try: - content_type, content_string = json_file.split(',') - decoded = base64.b64decode(content_string) - input_json = json.load(io.StringIO(decoded.decode('utf-8'))) - buffs_present = input_json['exportOptions']['buffs'] - catform_checked = ( - ('form' in input_json['exportOptions']) - and (input_json['exportOptions']['form'] == 'cat') - ) - - if not catform_checked: - upload_output = ( - 'Error processing input file! "Cat Form" was not checked ' - 'in the export pop-up window. Using default input stats ' - 'instead.', - {'color': '#D35845', 'width': 300}, True - ) - elif buffs_present: - pot_present = False - - for entry in input_json['consumables']: - if 'Potion' in entry['name']: - pot_present = True - - if pot_present: - upload_output = ( - 'Error processing input file! Potions should not be ' - 'checked in the Seventy Upgrades buff tab, as they are' - ' temporary rather than permanent stat buffs. Using' - ' default input stats instead.', - {'color': '#D35845', 'width': 300}, True - ) - else: - upload_output = ( - 'Upload successful. Buffs detected in Seventy Upgrades' - ' export, so the "Consumables" and "Raid Buffs" ' - 'sections in the sim input will be ignored.', - {'color': '#5AB88F', 'width': 300}, False - ) - use_default_inputs = False - else: - upload_output = ( - 'Upload successful. No buffs detected in Seventy Upgrades ' - 'export, so use the "Consumables" and "Raid Buffs" ' - 'sections in the sim input for buff entry.', - {'color': '#5AB88F', 'width': 300}, True - ) - use_default_inputs = False - except Exception: - upload_output = ( - 'Error processing input file! Using default input stats ' - 'instead.', - {'color': '#D35845', 'width': 300}, True - ) - - if use_default_inputs: - input_stats = copy.copy(default_input_stats) - buffs_present = False - else: - input_stats = input_json['stats'] - - # If buffs are not specified in the input file, then interpret the input - # stats as unbuffed and calculate the buffed stats ourselves. - if not buffs_present: - input_stats.update(apply_buffs( - input_stats['attackPower'], input_stats['strength'], - input_stats['agility'], input_stats['hit'], input_stats['crit'], - input_stats['mana'], input_stats['intellect'], - input_stats['spirit'], input_stats.get('mp5', 0), - input_stats.get('weaponDamage', 0), raid_buffs, consumables, - bshout_options - )) - - # Determine whether Unleashed Rage and/or Blessing of Kings are present, as - # these impact stat weights and buff values. - if buffs_present: - unleashed_rage = False - kings = False - - for buff in input_json['buffs']: - if buff['name'] == 'Blessing of Kings': - kings = True - if buff['name'] == 'Unleashed Rage': - unleashed_rage = True - else: - unleashed_rage = 'unleashed_rage' in raid_buffs - kings = 'kings' in raid_buffs - - # Create Player object based on raid buffed stat inputs and talents - player, ap_mod, stat_mod = create_player( - input_stats['attackPower'], input_stats['hit'], input_stats['crit'], - input_stats.get('weaponDamage', 0), input_stats.get('hasteRating', 0), - input_stats.get('expertiseRating', 0), input_stats.get('armorPen', 0), - input_stats['mana'], input_stats['intellect'], input_stats['spirit'], - input_stats.get('mp5', 0), float(input_stats['mainHandSpeed']), - unleashed_rage, kings, raven_idol, other_buffs, stat_debuffs, - cooldowns, num_mcp, surv_agi, ferocious_inspiration, bonuses, - naturalist, feral_aggression, savage_fury, natural_shapeshifter, - intensity, potion - ) - - # Process trinkets - trinket_list = process_trinkets( - trinket_1, trinket_2, player, ap_mod, stat_mod, cd_delay - ) - - # Default output is just the buffed player stats with no further calcs - stats_output = ( - '%.3f seconds' % player.swing_timer, - '%d' % player.attack_power, - '%.2f %%' % (player.crit_chance * 100), - '%.2f %%' % (player.miss_chance * 100), - '%d' % player.mana_pool, '%d' % player.intellect, - '%d' % player.spirit, '%d' % player.mp5 - ) - - # Create Simulation object based on specified parameters - max_mcp = num_mcp if 'mcp' in cooldowns else 0 - bite = ( - (bool(use_biteweave) and (finisher == 'rip')) or (finisher == 'bite') - ) - rip_combos = 6 if finisher != 'rip' else int(rip_cp) - ripweave_combos = 6 if finisher != 'bite' else int(rip_cp) - - if 'lust' in cooldowns: - trinket_list.append(trinkets.Bloodlust(delay=cd_delay)) - if 'drums' in cooldowns: - trinket_list.append(trinkets.ActivatedTrinket( - 'haste_rating', 80, 'Drums of Battle', 30, 120, delay=cd_delay - )) - - if 'exalted_ring' in bonuses: - ring_ppm = 1.0 - ring = trinkets.ProcTrinket( - chance_on_hit=ring_ppm / 60., - yellow_chance_on_hit=ring_ppm / 60. * player.weapon_speed, - stat_name='attack_power', stat_increment=160 * ap_mod, - proc_duration=10, cooldown=60, - proc_name='Band of the Eternal Champion', - ) - trinket_list.append(ring) - player.proc_trinkets.append(ring) - if 'idol_of_terror' in bonuses: - idol = trinkets.ProcTrinket( - chance_on_hit=0.85, stat_name=['attack_power', 'crit_chance'], - stat_increment=np.array([ - 65. * stat_mod * ap_mod, - 65. * stat_mod / 25. / 100., - ]), - proc_duration=10, cooldown=10, proc_name='Primal Instinct', - mangle_only=True - ) - trinket_list.append(idol) - player.proc_trinkets.append(idol) - if 'stag_idol' in bonuses: - idol = trinkets.RefreshingProcTrinket( - chance_on_hit=1.0, stat_name='attack_power', - stat_increment=94 * ap_mod, proc_duration=20, cooldown=0, - proc_name='Idol of the White Stag', mangle_only=True - ) - trinket_list.append(idol) - player.proc_trinkets.append(idol) - - if potion == 'haste': - haste_pot = trinkets.HastePotion(delay=cd_delay) - else: - haste_pot = None - - sim = ccs.Simulation( - player, fight_length + 1e-9, 0.001 * latency, num_mcp=max_mcp, - boss_armor=boss_armor, prepop_TF=bool(prepop_TF), - prepop_numticks=int(prepop_numticks), min_combos_for_rip=rip_combos, - min_combos_for_bite=int(bite_cp), use_innervate=bool(use_innervate), - use_rake_trick=bool(use_rake_trick), - use_bite_trick=bool(use_bite_trick), bite_trick_cp=int(bite_trick_cp), - bite_trick_max=bite_trick_max, - use_mangle_trick=bool(use_mangle_trick), use_bite=bite, - bite_time=bite_time, use_rip_trick=bool(use_ripweave), - rip_trick_cp=ripweave_combos, rip_trick_min=ripweave_energy, - bear_mangle=bool(bear_mangle), trinkets=trinket_list, - max_wait_time=max_wait_time, haste_pot=haste_pot - ) - sim.set_active_debuffs(boss_debuffs) - player.calc_damage_params(**sim.params) - - # If either "Run" or "Stat Weights" button was pressed, then perform a - # sim run for the specified number of replicates. - if (ctx.triggered and - (ctx.triggered[0]['prop_id'] in - ['run_button.n_clicks', 'weight_button.n_clicks'])): - avg_dps, dps_output = run_sim(sim, num_replicates) - else: - dps_output = ('', '', '', [], []) - - # If "Stat Weights" button was pressed, then calculate weights. - if (ctx.triggered and - (ctx.triggered[0]['prop_id'] == 'weight_button.n_clicks')): - weights_output = calc_weights( - sim, num_replicates, avg_dps, calc_mana_weights, dps_output[2], - kings, unleashed_rage, epic_gems - ) - else: - weights_output = ('Stat Breakdown', '', [], '') - - # If "Generate Example" button was pressed, do it. - if (ctx.triggered and - (ctx.triggered[0]['prop_id'] == 'graph_button.n_clicks')): - example_output = plot_new_trajectory(sim, show_whites) - else: - example_output = ({}, []) - - return ( - upload_output + stats_output + dps_output + weights_output - + example_output - ) - - -# Callbacks for disabling rotation options when inappropriate -@app.callback( - Output('use_rake_trick', 'options'), - Output('use_bite_trick', 'options'), - Output('use_rake_trick', 'labelStyle'), - Output('use_bite_trick', 'labelStyle'), - Output('bite_trick_text_1', 'style'), - Output('bite_trick_text_2', 'style'), - Input('bonuses', 'value'), - Input('use_rake_trick', 'value'), - Input('use_bite_trick', 'value')) -def disable_tricks(bonuses, rake_trick_checked, bite_trick_checked): - rake_options = {'label': ' use Rake trick', 'value': 'use_rake_trick'} - bite_options = {'label': ' use Bite trick', 'value': 'use_bite_trick'} - rake_text_style = {} - bite_text_style = {} - - if 't6_2p' in bonuses: - if rake_trick_checked: - rake_text_style['color'] = '#D35845' - else: - rake_options['disabled'] = True - rake_text_style['color'] = '#888888' - - if bite_trick_checked: - bite_text_style['color'] = '#D35845' - else: - bite_options['disabled'] = True - bite_text_style['color'] = '#888888' - - return ( - [rake_options], [bite_options], rake_text_style, bite_text_style, - bite_text_style, bite_text_style - ) - - -@app.callback( - Output('use_biteweave', 'options'), - Output('use_biteweave', 'labelStyle'), - Output('biteweave_text_1', 'style'), - Output('biteweave_text_2', 'style'), - Output('use_ripweave', 'options'), - Output('use_ripweave', 'labelStyle'), - Output('ripweave_text_1', 'style'), - Output('ripweave_text_2', 'style'), - Input('finisher', 'value')) -def disable_weaves(finisher): - biteweave_options = {'label': ' weave Ferocious Bite', 'value': 'bite'} - ripweave_options = {'label': ' weave Rip', 'value': 'rip'} - biteweave_text_style_1 = {} - biteweave_text_style_2 = {'marginLeft': '-15%'} - ripweave_text_style_1 = {} - ripweave_text_style_2 = {'marginLeft': '-15%'} - - if finisher != 'rip': - biteweave_options['disabled'] = True - biteweave_text_style_1['color'] = '#888888' - biteweave_text_style_2['color'] = '#888888' - - if finisher != 'bite': - ripweave_options['disabled'] = True - ripweave_text_style_1['color'] = '#888888' - ripweave_text_style_2['color'] = '#888888' - - return ( - [biteweave_options], biteweave_text_style_1, biteweave_text_style_1, - biteweave_text_style_2, [ripweave_options], ripweave_text_style_1, - ripweave_text_style_1, ripweave_text_style_2 - ) +u = UI() if __name__ == '__main__': multiprocessing.freeze_support() - app.run_server( - host='0.0.0.0', port=8080, debug=True - ) + u.run_server() diff --git a/requirements.txt b/requirements.txt index e9c4fbc..6b0ac53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ Brotli==1.0.9 click==7.1.2 -dash==1.19.0 -dash-core-components==1.15.0 -dash-html-components==1.1.2 +dash==2.1.0 +dash-core-components>=2.0.0 +dash-html-components==2.0.0 dash-renderer==1.9.0 -dash-table==4.11.2 +dash-table>=5.0.0 Flask==1.1.2 Flask-Compress==1.9.0 future==0.18.2 @@ -13,9 +13,10 @@ itsdangerous==1.1.0 Jinja2==2.11.3 MarkupSafe==1.1.1 numpy==1.19.5 -plotly==4.14.3 +plotly>=5.0.0 retrying==1.3.3 six==1.15.0 Werkzeug==1.0.1 dash-bootstrap-components==0.12.0 psutil==5.8.0 +pandas==1.4.2 diff --git a/test.json b/test.json new file mode 100644 index 0000000..e69de29 diff --git a/ui/defaults.py b/ui/defaults.py new file mode 100644 index 0000000..145a6d4 --- /dev/null +++ b/ui/defaults.py @@ -0,0 +1,30 @@ + +default_input_stats = { + "agility": 656, + "armor": 5218, + "armorPen": 441, + "attackPower": 3304, + "crit": 42.14, + "critRating": 87, + "critReduction": 3, + "defense": 350, + "dodge": 47.74, + "expertise": 6, + "expertiseRating": 25, + "feralAttackPower": 973, + "health": 8854, + "hit": 4.06, + "hitRating": 64, + "intellect": 242, + "mainHandSpeed": 3, + "mana": 5720, + "natureResist": 10, + "parry": 5, + "resilience": 0.48, + "resilienceRating": 19, + "spellCrit": 4.88, + "spirit": 161, + "stamina": 542, + "strength": 314 +} + diff --git a/ui/graph.py b/ui/graph.py new file mode 100644 index 0000000..1a9cca5 --- /dev/null +++ b/ui/graph.py @@ -0,0 +1,62 @@ +import dash +import pandas as pd +from dash import dash_table, dcc, html +import plotly.graph_objects as go +import numpy as np +from dash.dependencies import Input, Output, State +import dash_bootstrap_components as dbc +import tbc_cat_sim as ccs +import multiprocessing +import trinkets +import copy +import json +import base64 +import io + +graph_section = html.Div([ + dbc.Row( + [ + dbc.Col( + dbc.Button( + "Generate Example", id='graph_button', n_clicks=0, + color='info', + style={'marginLeft': '2.5%', 'fontSize': 'large'} + ), + width='auto' + ), + dbc.Col( + dbc.FormGroup( + [ + dbc.Checkbox( + id='show_whites', className='form-check-input' + ), + dbc.Label( + 'Show white damage', html_for='show_whites', + className='form-check-label' + ) + ], + check=True + ), + width='auto' + ) + ] + ), + html.H4( + 'Example of energy flow in a fight', style={'textAlign': 'center'} + ), + dcc.Graph(id='energy_flow'), + html.Br(), + dbc.Col( + [ + html.H5('Combat Log'), + dbc.Table([ + html.Thead(html.Tr([ + html.Th('Time'), html.Th('Event'), html.Th('Outcome'), + html.Th('Energy'), html.Th('Combo Points'), html.Th('Mana') + ])), + html.Tbody(id='combat_log') + ]) + ], + width=5, xl=4, style={'marginLeft': '2.5%'} + ) +]) diff --git a/ui/inputs.py b/ui/inputs.py new file mode 100644 index 0000000..b396b83 --- /dev/null +++ b/ui/inputs.py @@ -0,0 +1,678 @@ +from dash import dash_table, dcc, html +import dash_bootstrap_components as dbc + + +## stats input +stat_input = dbc.Col([ + html.H5('Seventy Upgrades Input'), + dcc.Markdown( + 'This simulator uses Seventy Upgrades as its gear selection UI. In ' + 'order to use it, create a Seventy Upgrades profile for your character' + ' and download the gear set using the "Export" button at the top right' + ' of the character sheet. Make sure that "Cat Form" is selected in the' + ' export window, and that "Talents" are checked (and set up in your ' + 'character sheet).', + style={'width': 300}, + ), + dcc.Markdown( + 'Consumables and party/raid buffs can be specified either in the ' + 'Seventy Upgrades "Buffs" tab, or in the "Consumables" and "Raid ' + 'Buffs " sections in the sim. If the "Buffs" option is checked in the ' + 'Seventy Upgrades export window, then the corresponding sections in ' + 'the sim input will be ignored.', + style={'width': 300}, + ), + dcc.Input( + id='paste-data', + placeholder="input seventy upgrades json", + style={ + 'width': '100%', + 'minHeight': '60px', + 'height': 'auto', + 'borderWidth': '1px', + 'borderStyle': 'dashed', + 'borderRadius': '5px', + 'textAlign': 'center', + 'margin': '0px' + } + ), + dcc.Upload( + id='upload-data', + children=html.Div([ + 'Drag and Drop or ', + html.A('Select File') + ]), + style={ + 'width': '100%', + 'height': '60px', + 'lineHeight': '60px', + 'borderWidth': '1px', + 'borderStyle': 'dashed', + 'borderRadius': '5px', + 'textAlign': 'center', + 'margin': '0px' + }, + # Don't allow multiple files to be uploaded + multiple=False + ), + html.Br(), + html.Div( + 'No file uploaded, using default input stats instead.', + id='upload_status', style={'color': '#E59F3A'} + ), + html.Br(), + html.H5('Idols and Set Bonuses'), + dbc.Checklist( + options=[{'label': 'Idol of the Raven Goddess', 'value': 'raven'}], + value=['raven'], id='raven_idol' + ), + dbc.Checklist( + options=[ + {'label': 'Everbloom Idol', 'value': 'everbloom'}, + {'label': 'Idol of Terror', 'value': 'idol_of_terror'}, + {'label': 'Idol of the White Stag', 'value': 'stag_idol'}, + {'label': '2-piece Tier 4 bonus', 'value': 't4_bonus'}, + {'label': '4-piece Tier 5 bonus', 'value': 't5_bonus'}, + {'label': '2-piece Tier 6 bonus', 'value': 't6_2p'}, + {'label': '4-piece Tier 6 bonus', 'value': 't6_4p'}, + {'label': 'Wolfshead Helm', 'value': 'wolfshead'}, + {'label': 'Relentless Earthstorm Diamond', 'value': 'meta'}, + {'label': 'Band of the Eternal Champion', 'value': 'exalted_ring'}, + ], + value=['t6_2p', 't6_4p', 'wolfshead', 'exalted_ring'], + id='bonuses' + ), +], width='auto', style={'marginBottom': '2.5%', 'marginLeft': '2.5%'}) + + +## buffs input +buffs_1 = dbc.Col( + [dbc.Collapse([html.H5('Consumables'), + dbc.Checklist( + options=[{'label': 'Elixir of Major Agility', 'value': 'agi_elixir'}, + {'label': 'Elixir of Draenic Wisdom', 'value': 'draenic'}, + {'label': 'Warp Burger / Grilled Mudfish', 'value': 'food'}, + {'label': 'Scroll of Agility V', 'value': 'scroll_agi'}, + {'label': 'Scroll of Strength V', 'value': 'scroll_str'}, + {'label': 'Adamantite Weightstone', 'value': 'weightstone'}], + value=[ + 'agi_elixir', 'food', 'scroll_agi', 'scroll_str', 'weightstone', + ], + id='consumables' + ), + html.Br(), + html.H5('Raid Buffs'), + dbc.Checklist( + options=[{'label': 'Blessing of Kings', 'value': 'kings'}, + {'label': 'Blessing of Might', 'value': 'might'}, + {'label': 'Blessing of Wisdom', 'value': 'wisdom'}, + {'label': 'Mark of the Wild', 'value': 'motw'}, + {'label': 'Trueshot Aura', 'value': 'trueshot_aura'}, + {'label': 'Heroic Presence', 'value': 'heroic_presence'}, + {'label': 'Strength of Earth Totem', 'value': 'str_totem'}, + {'label': 'Grace of Air Totem', 'value': 'agi_totem'}, + {'label': 'Unleashed Rage', 'value': 'unleashed_rage'}, + {'label': 'Arcane Intellect', 'value': 'ai'}, + {'label': 'Prayer of Spirit', 'value': 'spirit'}, + {'label': 'Battle Shout', 'value': 'bshout'}], + value=[ + 'kings', 'might', 'motw', 'str_totem', 'agi_totem', + 'unleashed_rage', 'ai', 'bshout' + ], + id='raid_buffs' + ), + dbc.Checklist( + options=[{'label': 'Commanding Presence', 'value': 'talent'}, + {'label': "Solarian's Sapphire", 'value': 'trinket'}], + value=['talent'], id='bshout_options', + style={'marginLeft': '10%'}, + ), + html.Br()], id='buff_section', is_open=True), + html.H5('Other Buffs'), + dbc.Checklist( + options=[ + {'label': 'Omen of Clarity', 'value': 'omen'}, + {'label': 'Bogling Root', 'value': 'bogling_root'}, + {'label': 'Consecrated Sharpening Stone', 'value': 'consec'}, + {'label': 'Improved Sanctity Aura', 'value': 'sanc_aura'}, + {'label': 'Mana Spring Totem', 'value': 'mana_spring_totem'}, + {'label': 'Braided Eternium Chain', 'value': 'be_chain'}, + ], + value=['omen', 'sanc_aura', 'mana_spring_totem'], id='other_buffs' + ), + dbc.InputGroup( + [ + dbc.InputGroupAddon( + 'Ferocious Inspiration stacks:', addon_type='prepend' + ), + dbc.Input( + value=0, type='number', id='ferocious_inspiration', min=0, + max=4 + ) + ], + style={'width': '100%', 'marginTop': '2%'}, size='sm' + )], + width='auto', style={'marginBottom': '2.5%', 'marginLeft': '2.5%'} +) + +## encounter details +#TODO add boss fights here. + +encounter_details = dbc.Col( + [html.H4('Encounter Details'), + dbc.InputGroup( + [ + dbc.InputGroupAddon('Fight Length:', addon_type='prepend'), + dbc.Input( + value=120.0, type='number', id='fight_length', + ), + dbc.InputGroupAddon('seconds', addon_type='append') + ], + style={'width': '50%'} + ), + dbc.InputGroup( + [ + dbc.InputGroupAddon('Boss Armor:', addon_type='prepend'), + dbc.Input(value=6193, type='number', id='boss_armor') + ], + style={'width': '50%'} + ), + html.Br(), + html.H5('Damage Debuffs'), + dbc.Checklist( + options=[ + {'label': 'Gift of Arthas', 'value': 'gift_of_arthas'}, + {'label': 'Sunder Armor', 'value': 'sunder'}, + {'label': 'Improved Expose Armor', 'value': 'imp_EA'}, + {'label': 'Curse of Recklessness', 'value': 'CoR'}, + {'label': 'Faerie Fire', 'value': 'faerie_fire'}, + {'label': 'Annihilator', 'value': 'annihilator'}, + {'label': 'Blood Frenzy', 'value': 'blood_frenzy'}, + ], + value=[ + 'gift_of_arthas', 'sunder', 'imp_EA', 'CoR', 'faerie_fire', + 'blood_frenzy' + ], + id='boss_debuffs' + ), + html.Br(), + html.H5('Stat Debuffs'), + dbc.Checklist( + options=[ + {'label': 'Improved Faerie Fire', 'value': 'imp_ff'}, + {'label': "Improved Hunter's Mark", 'value': 'hunters_mark'}, + {'label': 'Improved Judgment of the Crusader', 'value': 'jotc'}, + {'label': 'Judgment of Wisdom', 'value': 'jow'}, + {'label': 'Expose weakness', 'value': 'expose'}, + ], + value=['imp_ff', 'hunters_mark', 'jotc', 'jow', 'expose'], + id='stat_debuffs', + ), + dbc.InputGroup( + [ + dbc.InputGroupAddon( + 'Survival hunter Agility:', addon_type='prepend' + ), + dbc.Input(value=1000, type='number', id='surv_agi',), + ], + size='sm', + style={'width': '40%', 'marginTop': '1%', 'marginLeft': '5%'}, + ), + html.Br(), + html.H5('Cooldowns'), + dbc.Row([ + dbc.Col( + dbc.Checklist( + options=[ + { + 'label': 'Manual Crowd Pummeler - Maximum uses:', + 'value': 'mcp', + }, + {'label': 'Bloodlust', 'value': 'lust'}, + {'label': 'Drums of Battle', 'value': 'drums'}, + {'label': 'Dark / Demonic Rune', 'value': 'rune'}, + ], + value=['lust', 'drums', 'rune'], id='cooldowns', + ), + width='auto', + ), + dbc.Col( + dbc.Input( + value=2, type='number', id='num_mcp', + style={'width': '35%', 'marginTop': '-5%'}, + ) + ) + ]), + dbc.InputGroup( + [ + dbc.InputGroupAddon('Potion CD:', addon_type='prepend'), + dbc.Select( + options=[ + {'label': 'Super Mana Potion', 'value': 'super'}, + {'label': 'Fel Mana Potion', 'value': 'fel'}, + {'label': 'Haste Potion', 'value': 'haste'}, + {'label': 'None', 'value': 'none'}, + ], + value='haste', id='potion', + ), + ], + style={'width': '50%', 'marginTop': '1.5%'} + )], + width='auto', + style={ + 'marginLeft': '2.5%', 'marginBottom': '2.5%', 'marginRight': '-2.5%' + } +) + +### itteration inputs +def generate_iteration_input(): + return dbc.Col([ + html.H4('Sim Settings'), + dbc.InputGroup( + [ + dbc.InputGroupAddon('Number of replicates:', addon_type='prepend'), + dbc.Input(value=20000, type='number', id='num_replicates') + ], + style={'width': '35%'} + ), + dbc.InputGroup( + [ + dbc.InputGroupAddon('Modeled input delay:', addon_type='prepend'), + dbc.Input( + value=100, type='number', id='latency', min=1, step=1, + ), + dbc.InputGroupAddon('ms', addon_type='append') + ], + style={'width': '35%'} + ), + html.Br(), + html.H5('Talents'), + html.Div([ + html.Div( + 'Feral Aggression:', + style={ + 'width': '35%', 'display': 'inline-block', + 'fontWeight': 'bold' + } + ), + dbc.Select( + options=[ + {'label': '0', 'value': 0}, + {'label': '1', 'value': 1}, + {'label': '2', 'value': 2}, + {'label': '3', 'value': 3}, + {'label': '4', 'value': 4}, + {'label': '5', 'value': 5}, + ], + value='0', id='feral_aggression', + style={ + 'width': '20%', 'display': 'inline-block', + 'marginBottom': '2.5%', 'marginRight': '5%' + } + )]), + html.Div([ + html.Div( + 'Savage Fury:', + style={ + 'width': '35%', 'display': 'inline-block', + 'fontWeight': 'bold' + } + ), + dbc.Select( + options=[ + {'label': '0', 'value': 0}, + {'label': '1', 'value': 1}, + {'label': '2', 'value': 2}, + ], + value=2, id='savage_fury', + style={ + 'width': '20%', 'display': 'inline-block', + 'marginBottom': '2.5%', 'marginRight': '5%' + } + )]), + html.Div([ + html.Div( + 'Naturalist:', + style={ + 'width': '35%', 'display': 'inline-block', + 'fontWeight': 'bold' + } + ), + dbc.Select( + options=[ + {'label': '0', 'value': 0}, + {'label': '1', 'value': 1}, + {'label': '2', 'value': 2}, + {'label': '3', 'value': 3}, + {'label': '4', 'value': 4}, + {'label': '5', 'value': 5}, + ], + value=5, id='naturalist', + style={ + 'width': '20%', 'display': 'inline-block', + 'marginBottom': '2.5%', 'marginRight': '5%' + } + )]), + html.Div([ + html.Div( + 'Natural Shapeshifter:', + style={ + 'width': '35%', 'display': 'inline-block', + 'fontWeight': 'bold' + } + ), + dbc.Select( + options=[ + {'label': '0', 'value': 0}, + {'label': '1', 'value': 1}, + {'label': '2', 'value': 2}, + {'label': '3', 'value': 3}, + ], + value=3, id='natural_shapeshifter', + style={ + 'width': '20%', 'display': 'inline-block', + 'marginBottom': '2.5%', 'marginRight': '5%' + } + )]), + html.Div([ + html.Div( + 'Intensity:', + style={ + 'width': '35%', 'display': 'inline-block', + 'fontWeight': 'bold' + } + ), + dbc.Select( + options=[ + {'label': '0', 'value': 0}, + {'label': '1', 'value': 1}, + {'label': '2', 'value': 2}, + {'label': '3', 'value': 3}, + ], + value=3, id='intensity', + style={ + 'width': '20%', 'display': 'inline-block', + 'marginBottom': '2.5%', 'marginRight': '5%' + } + )]), + html.Br(), + html.H5('Player Strategy'), + dbc.InputGroup( + [ + dbc.InputGroupAddon('Finishing move:', addon_type='prepend'), + dbc.Select( + options=[ + {'label': 'Rip', 'value': 'rip'}, + {'label': 'Ferocious Bite', 'value': 'bite'}, + {'label': 'None', 'value': 'none'}, + ], + value='rip', id='finisher', + ), + ], + style={'width': '45%', 'marginBottom': '1.5%'} + ), + dbc.InputGroup( + [ + dbc.InputGroupAddon( + 'Minimum combo points for Rip:', addon_type='prepend' + ), + dbc.Select( + options=[ + {'label': '3', 'value': 3}, + {'label': '4', 'value': 4}, + {'label': '5', 'value': 5}, + ], + value=4, id='rip_cp', + ), + ], + style={'width': '48%', 'marginBottom': '1.5%'} + ), + dbc.InputGroup( + [ + dbc.InputGroupAddon( + 'Minimum combo points for Ferocious Bite:', + addon_type='prepend' + ), + dbc.Select( + options=[ + {'label': '3', 'value': 3}, + {'label': '4', 'value': 4}, + {'label': '5', 'value': 5}, + ], + value=4, id='bite_cp', + ), + ], + style={'width': '60%', 'marginBottom': '1.5%'} + ), + dbc.InputGroup( + [ + dbc.InputGroupAddon('Wait at most:', addon_type='prepend'), + dbc.Input( + value=1.0, min=0.0, max=2.0, step=0.1, type='number', + id='max_wait_time', + ), + dbc.InputGroupAddon( + 'seconds for an energy tick', addon_type='append' + ) + ], + style={'width': '63%', 'marginBottom': '1.5%'} + ), + dbc.InputGroup( + [ + dbc.InputGroupAddon('Wait', addon_type='prepend'), + dbc.Input( + value=15.0, min=0.0, step=0.5, type='number', id='cd_delay', + ), + dbc.InputGroupAddon( + 'seconds before using cooldowns', addon_type='append' + ), + ], + style={'width': '63%'}, + ), + html.Br(), + dbc.Row([ + dbc.Col(dbc.Checklist( + options=[{'label': " weave Ferocious Bite", 'value': 'bite'}], + value=['bite'], id='use_biteweave', + ), width='auto'), + dbc.Col('with', width='auto', id='biteweave_text_1'), + dbc.Col(dbc.Input( + type='number', value=0, id='bite_time', min=0.0, step=0.1, + style={'marginTop': '-3%', 'marginBottom': '7%', 'width': '40%'}, + ), width='auto'), + dbc.Col( + 'seconds left on Rip', width='auto', style={'marginLeft': '-15%'}, + id='biteweave_text_2' + ) + ],), + dbc.Row([ + dbc.Col(dbc.Checklist( + options=[{'label': " weave Rip", 'value': 'rip'}], value=[], + id='use_ripweave', + ), width='auto'), + dbc.Col('at', width='auto', id='ripweave_text_1'), + dbc.Col(dbc.Input( + type='number', value=52, id='ripweave_energy', min=30, step=1, + style={'marginTop': '-3%', 'marginBottom': '7%', 'width': '40%'}, + ), width='auto'), + dbc.Col( + 'energy or above', width='auto', style={'marginLeft': '-15%'}, + id='ripweave_text_2' + ) + ],), + dbc.Row([ + dbc.Col(dbc.Checklist( + options=[{'label': " pre-pop Tiger's Fury", 'value': 'prepop_TF'}], + value=[], id='prepop_TF', + ), width='auto'), + dbc.Col('at', width='auto'), + dbc.Col(dbc.Select( + options=[{'label': '1', 'value': 1}, {'label': '2', 'value': 2}], + value=2, id='prepop_numticks', + style={'marginTop': '-7%'}, + ), width='auto'), + dbc.Col('energy ticks before combat', width='auto') + ],), + dbc.Checklist( + options=[{'label': ' use Mangle trick', 'value': 'use_mangle_trick'}], + value=['use_mangle_trick'], id='use_mangle_trick' + ), + dbc.Checklist( + options=[{'label': ' use Rake trick', 'value': 'use_rake_trick'}], + value=[], id='use_rake_trick' + ), + dbc.Row([ + dbc.Col(dbc.Checklist( + options=[{'label': ' use Bite trick', 'value': 'use_bite_trick'}], + value=[], id='use_bite_trick' + ), width='auto'), + dbc.Col('with at least', width='auto', id='bite_trick_text_1'), + dbc.Col(dbc.Select( + options=[{'label': i, 'value': i} for i in range(1, 6)], + value=2, id='bite_trick_cp', + style={'marginTop': '-7%'}, + ), width='auto'), + dbc.Col( + 'combo points, and an energy range up to', width='auto', + id='bite_trick_text_2' + ), + dbc.Col(dbc.Input( + type='number', value=39, id='bite_trick_max', min=35, step=1, + style={'marginTop': '-7%', 'width': '40%'}, + ), width='auto'), + ]), + dbc.Checklist( + options=[{'label': ' use Innervate', 'value': 'use_innervate'}], + value=[], id='use_innervate' + ), + dbc.Checklist( + options=[{ + 'label': ' Mangle maintained by bear tank', 'value': 'bear_mangle' + }], value=[], id='bear_mangle' + ), + html.Br(), + html.H5('Trinkets'), + dbc.Row([ + dbc.Col(dbc.Select( + id='trinket_1', + options=[ + {'label': 'Empty', 'value': 'none'}, + {'label': 'Tsunami Talisman', 'value': 'tsunami'}, + {'label': 'Bloodlust Brooch', 'value': 'brooch'}, + {'label': 'Hourglass of the Unraveller', 'value': 'hourglass'}, + {'label': 'Dragonspine Trophy', 'value': 'dst'}, + {'label': 'Mark of the Champion', 'value': 'motc'}, + {'label': "Slayer's Crest", 'value': 'slayers'}, + {'label': 'Drake Fang Talisman', 'value': 'dft'}, + {'label': 'Icon of Unyielding Courage', 'value': 'icon'}, + {'label': 'Abacus of Violent Odds', 'value': 'abacus'}, + {'label': 'Badge of the Swarmguard', 'value': 'swarmguard'}, + {'label': 'Kiss of the Spider', 'value': 'kiss'}, + {'label': 'Badge of Tenacity', 'value': 'tenacity'}, + { + 'label': 'Living Root of the Wildheart', + 'value': 'wildheart', + }, + { + 'label': 'Ashtongue Talisman of Equilibrium', + 'value': 'ashtongue', + }, + {'label': 'Crystalforged Trinket', 'value': 'crystalforged'}, + {'label': 'Madness of the Betrayer', 'value': 'madness'}, + {'label': "Romulo's Poison Vial", 'value': 'vial'}, + { + 'label': 'Steely Naaru Sliver', + 'value': 'steely_naaru_sliver' + }, + {'label': 'Shard of Contempt', 'value': 'shard_of_contempt'}, + {'label': "Berserker's Call", 'value': 'berserkers_call'}, + {'label': "Alchemist's Stone", 'value': 'alch'}, + { + 'label': "Assassin's Alchemist Stone", + 'value': 'assassin_alch' + }, + {'label': 'Blackened Naaru Sliver', 'value': 'bns'}, + {'label': 'Darkmoon Card: Crusade', 'value': 'crusade'}, + ], + value='none' + )), + dbc.Col(dbc.Select( + id='trinket_2', + options=[ + {'label': 'Empty', 'value': 'none'}, + {'label': 'Tsunami Talisman', 'value': 'tsunami'}, + {'label': 'Bloodlust Brooch', 'value': 'brooch'}, + {'label': 'Hourglass of the Unraveller', 'value': 'hourglass'}, + {'label': 'Dragonspine Trophy', 'value': 'dst'}, + {'label': 'Mark of the Champion', 'value': 'motc'}, + {'label': "Slayer's Crest", 'value': 'slayers'}, + {'label': 'Drake Fang Talisman', 'value': 'dft'}, + {'label': 'Icon of Unyielding Courage', 'value': 'icon'}, + {'label': 'Abacus of Violent Odds', 'value': 'abacus'}, + {'label': 'Badge of the Swarmguard', 'value': 'swarmguard'}, + {'label': 'Kiss of the Spider', 'value': 'kiss'}, + {'label': 'Badge of Tenacity', 'value': 'tenacity'}, + { + 'label': 'Living Root of the Wildheart', + 'value': 'wildheart', + }, + { + 'label': 'Ashtongue Talisman of Equilibrium', + 'value': 'ashtongue', + }, + {'label': 'Crystalforged Trinket', 'value': 'crystalforged'}, + {'label': 'Madness of the Betrayer', 'value': 'madness'}, + {'label': "Romulo's Poison Vial", 'value': 'vial'}, + { + 'label': 'Steely Naaru Sliver', + 'value': 'steely_naaru_sliver' + }, + {'label': 'Shard of Contempt', 'value': 'shard_of_contempt'}, + {'label': "Berserker's Call", 'value': 'berserkers_call'}, + {'label': "Alchemist's Stone", 'value': 'alch'}, + { + 'label': "Assassin's Alchemist Stone", + 'value': 'assassin_alch' + }, + {'label': 'Blackened Naaru Sliver', 'value': 'bns'}, + {'label': 'Darkmoon Card: Crusade', 'value': 'crusade'}, + ], + value='none' + )), + ]), + html.Div( + 'Make sure not to include passive trinket stats in the sim input.', + style={ + 'marginTop': '2.5%', 'fontSize': 'large', 'fontWeight': 'bold' + }, + ), + html.Div([ + dbc.Button( + "Run", id='run_button', n_clicks=0, size='lg', color='success', + style={ + 'marginBottom': '10%', 'fontSize': 'large', 'marginTop': '10%', + 'display': 'inline-block' + } + ), + html.Div( + '', id='status', + style={ + 'display': 'inline-block', 'fontWeight': 'bold', + 'marginLeft': '10%', 'fontSize': 'large' + } + ) + ]), + dcc.Interval(id='interval', interval=500), +], width='auto', style={'marginBottom': '2.5%', 'marginLeft': '2.5%'}) + +def generate_input_layout(): + return html.Div(children=[ + html.H1( + children='WoW Classic TBC Feral Cat Simulator', + style={'textAlign': 'center'} + ), + dbc.Row( + [stat_input, buffs_1, encounter_details, generate_iteration_input()], + style={'marginTop': '2.5%'} + ), +]) diff --git a/ui/outputs.py b/ui/outputs.py new file mode 100644 index 0000000..be3996b --- /dev/null +++ b/ui/outputs.py @@ -0,0 +1,236 @@ +from dash import dash_table, dcc, html +import dash_bootstrap_components as dbc + +sim_output = dbc.Col([ + html.H4('Results'), + dcc.Loading(children=html.Div([ + html.Div( + 'Average DPS:', + style={ + 'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large' + } + ), + html.Div( + '', + style={ + 'width': '50%', 'display': 'inline-block', 'fontSize': 'large' + }, + id='mean_std_dps' + ), + ]), id='loading_1', type='default'), + dcc.Loading(children=html.Div([ + html.Div( + 'Median DPS:', + style={ + 'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large' + } + ), + html.Div( + '', + style={ + 'width': '50%', 'display': 'inline-block', 'fontSize': 'large' + }, + id='median_dps' + ), + ]), id='loading_2', type='default'), + dcc.Loading(children=html.Div([ + html.Div( + 'Time to oom:', + style={ + 'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large' + } + ), + html.Div( + '', + style={ + 'width': '50%', 'display': 'inline-block', 'fontSize': 'large' + }, + id='time_to_oom' + ), + ]), id='loading_oom_time', type='default'), + html.Br(), + html.H5('DPS Breakdown'), + dcc.Loading(children=dbc.Table([ + html.Thead(html.Tr([ + html.Th('Ability'), html.Th('Number of Casts'), html.Th('CPM'), + html.Th('Damage per Cast'), html.Th('DPS Contribution') + ])), + html.Tbody(id='dps_breakdown_table') + ]), id='loading_3', type='default'), + html.Br(), + html.H5('Aura Statistics'), + dcc.Loading(children=dbc.Table([ + html.Thead(html.Tr([ + html.Th('Aura Name'), html.Th('Number of Procs'), + html.Th('Average Uptime'), + ])), + html.Tbody(id='aura_breakdown_table') + ]), id='loading_auras', type='default'), + html.Br(), + html.Br() +], style={'marginLeft': '2.5%', 'marginBottom': '2.5%'}, width=4, xl=3) + +## stats output + +stats_output = dbc.Col( + [html.H4('Raid Buffed Stats'), + html.Div([ + html.Div( + 'Swing Timer:', + style={'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large'} + ), + html.Div( + '', + style={'width': '50%', 'display': 'inline-block', + 'fontSize': 'large'}, + id='buffed_swing_timer' + ) + ]), + html.Div([ + html.Div( + 'Attack Power:', + style={'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large'} + ), + html.Div( + '', + style={'width': '50%', 'display': 'inline-block', + 'fontSize': 'large'}, + id='buffed_attack_power' + ) + ]), + html.Div([ + html.Div( + 'Boss Crit Chance:', + style={'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large'} + ), + html.Div( + '', + style={'width': '50%', 'display': 'inline-block', + 'fontSize': 'large'}, + id='buffed_crit' + ) + ]), + html.Div([ + html.Div( + 'Boss Miss Chance:', + style={'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large'} + ), + html.Div( + '', + style={'width': '50%', 'display': 'inline-block', + 'fontSize': 'large'}, + id='buffed_miss' + ) + ]), + html.Div([ + html.Div( + 'Mana:', + style={'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large'} + ), + html.Div( + '', + style={'width': '50%', 'display': 'inline-block', + 'fontSize': 'large'}, + id='buffed_mana' + ) + ]), + html.Div([ + html.Div( + 'Intellect:', + style={'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large'} + ), + html.Div( + '', + style={'width': '50%', 'display': 'inline-block', + 'fontSize': 'large'}, + id='buffed_int' + ) + ]), + html.Div([ + html.Div( + 'Spirit:', + style={'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large'} + ), + html.Div( + '', + style={'width': '50%', 'display': 'inline-block', + 'fontSize': 'large'}, + id='buffed_spirit' + ) + ]), + html.Div([ + html.Div( + 'MP5:', + style={'width': '50%', 'display': 'inline-block', + 'fontWeight': 'bold', 'fontSize': 'large'} + ), + html.Div( + '', + style={'width': '50%', 'display': 'inline-block', + 'fontSize': 'large'}, + id='buffed_mp5' + ) + ])], + width=4, xl=3, style={'marginLeft': '2.5%', 'marginBottom': '2.5%'} +) + +## gear output + +#gear_output = +generate_gear_output = html.Div([ + dbc.Row( + [ + dbc.Col([ + html.H4('Gear Sets'), + dash_table.DataTable( + data=[], # df.to_dict('results'), + columns=[ + # {'name': i, 'id': i, 'deletable': True} for i in sorted(df.columns) + ], + style_data_conditional=[ + { + 'if': {'row_index': 'odd'}, + 'backgroundColor': 'rgb(90, 90, 90)', + }, + ], + style_header={ + 'font_size': '20px', + 'font_weight': 'bold', + 'backgroundColor': 'rgb(13, 163, 57)', + 'color': 'white', + 'textAlign': 'left', + 'minWidth': 50 + }, + style_data={ + '' + 'backgroundColor': 'rgb(50, 50, 50)', + 'color': 'white', + 'textAlign': 'center', + 'minWidth': 50 + }, + # page_action='custom', + sort_action='custom', + sort_by=[], + id="gear_output" + ) + ], + style={'marginLeft': '2.5%', 'marginBottom': '2.5%'}, + width=4, xl=3, + align='center', + ) + ], + justify='left', + ) +]) + + diff --git a/ui/ui.py b/ui/ui.py new file mode 100644 index 0000000..bb55b28 --- /dev/null +++ b/ui/ui.py @@ -0,0 +1,759 @@ +import dash +import pandas as pd +from dash import dash_table, dcc, html +import plotly.graph_objects as go +import numpy as np +from dash.dependencies import Input, Output, State +import dash_bootstrap_components as dbc +import tbc_cat_sim as ccs +import multiprocessing +import trinkets +import copy +import json +import base64 +import io +from ui.graph import graph_section +from ui.weights import weights_section +from ui.defaults import default_input_stats +from ui.inputs import stat_input, encounter_details, buffs_1, generate_input_layout +from ui.outputs import sim_output, stats_output, generate_gear_output +from ui.util.buffs import apply_buffs +from ui.util.plot import plot_new_trajectory, calc_weights +from ui.util import trinkets + +app = dash.Dash(__name__, external_stylesheets=[dbc.themes.DARKLY]) + +def uses_wolfshead(input_json): + if input_json is None: + return False + for i in input_json['items']: + if i['slot'] == "HEAD": + if "Wolfshead" in i['name']: + return True + return False + +class UI: + df = None + bonuses = None + def __init__(self): + self.df = pd.DataFrame(columns=[ + "dps", + "name", + "trinket 1", + "trinket 2", + "boss armor", + "fight length", + "wolfshead", + "time to oom", + "crit", + "hit", + "ap", + "expertise", + "armorPen", + "link" + ]) + self.app = app + self.sim_section = dbc.Row( + [stats_output, sim_output, weights_section] + ) + self.app.callback( + Output('upload_status', 'children'), + Output('upload_status', 'style'), + Output('buff_section', 'is_open'), + Output('buffed_swing_timer', 'children'), + Output('buffed_attack_power', 'children'), + Output('buffed_crit', 'children'), + Output('buffed_miss', 'children'), + Output('buffed_mana', 'children'), + Output('buffed_int', 'children'), + Output('buffed_spirit', 'children'), + Output('buffed_mp5', 'children'), + Output('mean_std_dps', 'children'), + Output('median_dps', 'children'), + Output('time_to_oom', 'children'), + Output('dps_breakdown_table', 'children'), + Output('aura_breakdown_table', 'children'), + Output('error_str', 'children'), + Output('error_msg', 'children'), + Output('stat_weight_table', 'children'), + Output('import_link', 'children'), + Output('energy_flow', 'figure'), + Output('combat_log', 'children'), + Input('upload-data', 'contents'), + Input('paste-data', 'value'), + Input('consumables', 'value'), + Input('raid_buffs', 'value'), + Input('bshout_options', 'value'), + Input('num_mcp', 'value'), + Input('other_buffs', 'value'), + Input('raven_idol', 'value'), + Input('stat_debuffs', 'value'), + Input('surv_agi', 'value'), + Input('trinket_1', 'value'), + Input('trinket_2', 'value'), + Input('run_button', 'n_clicks'), + Input('weight_button', 'n_clicks'), + Input('graph_button', 'n_clicks'), + State('potion', 'value'), + State('ferocious_inspiration', 'value'), + State('bonuses', 'value'), + State('feral_aggression', 'value'), + State('savage_fury', 'value'), + State('naturalist', 'value'), + State('natural_shapeshifter', 'value'), + State('intensity', 'value'), + State('fight_length', 'value'), + State('boss_armor', 'value'), + State('boss_debuffs', 'value'), + State('cooldowns', 'value'), + State('finisher', 'value'), + State('rip_cp', 'value'), + State('bite_cp', 'value'), + State('max_wait_time', 'value'), + State('cd_delay', 'value'), + State('prepop_TF', 'value'), + State('prepop_numticks', 'value'), + State('use_mangle_trick', 'value'), + State('use_rake_trick', 'value'), + State('use_bite_trick', 'value'), + State('bite_trick_cp', 'value'), + State('bite_trick_max', 'value'), + State('use_innervate', 'value'), + State('use_biteweave', 'value'), + State('bite_time', 'value'), + State('use_ripweave', 'value'), + State('ripweave_energy', 'value'), + State('bear_mangle', 'value'), + State('num_replicates', 'value'), + State('latency', 'value'), + State('calc_mana_weights', 'checked'), + State('epic_gems', 'checked'), + State('show_whites', 'checked'))(self.compute) + + self.app.callback( + Output('use_rake_trick', 'options'), + Output('use_bite_trick', 'options'), + Output('use_rake_trick', 'labelStyle'), + Output('use_bite_trick', 'labelStyle'), + Output('bite_trick_text_1', 'style'), + Output('bite_trick_text_2', 'style'), + Input('bonuses', 'value'), + Input('use_rake_trick', 'value'), + Input('use_bite_trick', 'value'))(self.disable_tricks) + + self.app.callback( + Output('use_biteweave', 'options'), + Output('use_biteweave', 'labelStyle'), + Output('biteweave_text_1', 'style'), + Output('biteweave_text_2', 'style'), + Output('use_ripweave', 'options'), + Output('use_ripweave', 'labelStyle'), + Output('ripweave_text_1', 'style'), + Output('ripweave_text_2', 'style'), + Input('finisher', 'value'))(self.disable_weaves) + + self.app.callback( + Output("gear_output", "data"), + Output("gear_output", 'columns'), + Input("gear_output", "sort_by"), + Input("median_dps", "children"))(self.update_table) + + self.app.layout = html.Div([ + generate_input_layout(), self.sim_section, generate_gear_output, graph_section + ]) + + def process_trinkets(self, input_json, trinket_1, trinket_2, player, ap_mod, stat_mod, cd_delay): + print(trinket_1, trinket_2) + print(type(trinket_1)) + if trinket_1 == 'none': + if input_json is not None: + for i in input_json['items']: + if i['slot'] == 'TRINKET_1': + print("TRINKET_1: {}".format(i['name'])) + trinket_1 = trinkets.trinket_map[i['name']] + if trinket_2 == 'none': + if input_json is not None: + for i in input_json['items']: + if i['slot'] == 'TRINKET_2': + trinket_2 = trinkets.trinket_map[i['name']] + + proc_trinkets = [] + all_trinkets = [] + + print(trinket_1, trinket_2) + for trinket in [trinket_1, trinket_2]: + if trinket == 'none': + continue + + trinket_params = copy.deepcopy(trinkets.trinket_library[trinket]) + + for stat, increment in trinket_params['passive_stats'].items(): + if stat == 'intellect': + increment *= 1.2 # hardcode the HotW 20% increase + if stat in ['strength', 'agility', 'intellect', 'spirit']: + increment *= stat_mod + if stat == 'strength': + increment *= 2 + stat = 'attack_power' + if stat == 'agility': + stat = 'attack_power' + # additionally modify crit here + setattr( + player, 'crit_chance', + getattr(player, 'crit_chance') + increment / 25. / 100. + ) + if stat == 'attack_power': + increment *= ap_mod + if stat == 'haste_rating': + new_swing_timer = ccs.calc_swing_timer( + ccs.calc_haste_rating(player.swing_timer) + increment, + ) + player.swing_timer = new_swing_timer + continue + + setattr(player, stat, getattr(player, stat) + increment) + + if trinket_params['type'] == 'passive': + continue + + active_stats = trinket_params['active_stats'] + + if active_stats['stat_name'] == 'attack_power': + active_stats['stat_increment'] *= ap_mod + if active_stats['stat_name'] == 'Agility': + active_stats['stat_name'] = ['attack_power', 'crit_chance'] + agi_increment = active_stats['stat_increment'] + active_stats['stat_increment'] = np.array([ + stat_mod * agi_increment * ap_mod, + stat_mod * agi_increment / 25. / 100. + ]) + if active_stats['stat_name'] == 'Strength': + active_stats['stat_name'] = 'attack_power' + active_stats['stat_increment'] *= 2 * stat_mod * ap_mod + + if trinket_params['type'] == 'activated': + # If this is the second trinket slot and the first trinket was also + # activated, then we need to enforce an activation delay due to the + # shared cooldown. For now we will assume that the shared cooldown + # is always equal to the duration of the first trinket's proc. + if all_trinkets and (not proc_trinkets): + delay = cd_delay + all_trinkets[-1].proc_duration + else: + delay = cd_delay + + all_trinkets.append( + trinkets.ActivatedTrinket(delay=delay, **active_stats) + ) + else: + proc_type = active_stats.pop('proc_type') + + if proc_type == 'chance_on_hit': + proc_chance = active_stats.pop('proc_rate') + active_stats['chance_on_hit'] = proc_chance + active_stats['chance_on_crit'] = proc_chance + elif proc_type == 'chance_on_crit': + active_stats['chance_on_hit'] = 0.0 + active_stats['chance_on_crit'] = active_stats.pop('proc_rate') + elif proc_type == 'ppm': + ppm = active_stats.pop('proc_rate') + active_stats['chance_on_hit'] = ppm / 60. + active_stats['yellow_chance_on_hit'] = ( + ppm / 60. * player.weapon_speed + ) + + if trinket == 'vial': + trinket_obj = trinkets.PoisonVial( + active_stats['chance_on_hit'], + active_stats['yellow_chance_on_hit'] + ) + elif trinket_params['type'] == 'refreshing_proc': + trinket_obj = trinkets.RefreshingProcTrinket(**active_stats) + elif trinket_params['type'] == 'stacking_proc': + trinket_obj = trinkets.StackingProcTrinket(**active_stats) + else: + trinket_obj = trinkets.ProcTrinket(**active_stats) + + all_trinkets.append(trinket_obj) + proc_trinkets.append(all_trinkets[-1]) + + player.proc_trinkets = proc_trinkets + return all_trinkets + + def create_player(self, + buffed_attack_power, buffed_hit, buffed_crit, buffed_weapon_damage, + haste_rating, expertise_rating, armor_pen, buffed_mana_pool, + buffed_int, buffed_spirit, buffed_mp5, weapon_speed, unleashed_rage, + kings, raven_idol, other_buffs, stat_debuffs, cooldowns, num_mcp, + surv_agi, ferocious_inspiration, bonuses, naturalist, feral_aggression, + savage_fury, natural_shapeshifter, intensity, potion + ): + """Takes in raid buffed player stats from Seventy Upgrades, modifies them + based on boss debuffs and miscellaneous buffs not captured by Seventy + Upgrades, and instantiates a Player object with those stats.""" + + # Swing timer calculation is independent of other buffs. First we add up + # the haste rating from all the specified haste buffs + use_mcp = ('mcp' in cooldowns) and (num_mcp > 0) + buffed_haste_rating = haste_rating + 500 * use_mcp + buffed_swing_timer = ccs.calc_swing_timer(buffed_haste_rating) + + # Augment secondary stats as needed + ap_mod = 1.1 * (1 + 0.1 * unleashed_rage) + debuff_ap = ( + 100 * ('consec' in other_buffs) + + 110 * ('hunters_mark' in stat_debuffs) + + 0.25 * surv_agi * ('expose' in stat_debuffs) + ) + encounter_crit = ( + buffed_crit + 3 * ('jotc' in stat_debuffs) + + (28 * ('be_chain' in other_buffs) + 20 * bool(raven_idol)) / 22.1 + ) + encounter_hit = buffed_hit + 3 * ('imp_ff' in stat_debuffs) + encounter_mp5 = buffed_mp5 + 50 * ('mana_spring_totem' in other_buffs) + + # Calculate bonus damage parameters + encounter_weapon_damage = ( + buffed_weapon_damage + ('bogling_root' in other_buffs) + ) + damage_multiplier = ( + (1 + 0.02 * int(naturalist)) * 1.03**ferocious_inspiration + * (1 + 0.02 * ('sanc_aura' in other_buffs)) + ) + shred_bonus = 88 * ('everbloom' in bonuses) + 75 * ('t5_bonus' in bonuses) + + + # Create and return a corresponding Player object + player = ccs.Player( + attack_power=buffed_attack_power, hit_chance=encounter_hit / 100, + expertise_rating=expertise_rating, crit_chance=encounter_crit / 100, + swing_timer=buffed_swing_timer, mana=buffed_mana_pool, + intellect=buffed_int, spirit=buffed_spirit, mp5=encounter_mp5, + omen='omen' in other_buffs, feral_aggression=int(feral_aggression), + savage_fury=int(savage_fury), + natural_shapeshifter=int(natural_shapeshifter), + intensity=int(intensity), weapon_speed=weapon_speed, + bonus_damage=encounter_weapon_damage, multiplier=damage_multiplier, + jow='jow' in stat_debuffs, armor_pen=armor_pen, + t4_bonus='t4_bonus' in bonuses, t6_2p='t6_2p' in bonuses, + t6_4p='t6_4p' in bonuses, wolfshead='wolfshead' in bonuses, + meta='meta' in bonuses, rune='rune' in cooldowns, + pot=potion in ['super', 'fel'], cheap_pot=(potion == 'super'), + shred_bonus=shred_bonus, debuff_ap=debuff_ap + ) + return player, ap_mod, (1 + 0.1 * kings) * 1.03 + + def run_sim(self, sim, num_replicates): + # Run the sim for the specified number of replicates + dps_vals, dmg_breakdown, aura_stats, oom_times = sim.run_replicates( + num_replicates, detailed_output=True + ) + + # Consolidate DPS statistics + avg_dps = np.mean(dps_vals) + mean_dps_str = '%.1f +/- %.1f' % (avg_dps, np.std(dps_vals)) + median_dps_str = '%.1f' % np.median(dps_vals) + + # Consolidate mana statistics + avg_oom_time = np.mean(oom_times) + + if avg_oom_time > sim.fight_length - 1: + oom_time_str = 'none' + else: + oom_time_str = ( + '%d +/- %d seconds' % (avg_oom_time, np.std(oom_times)) + ) + + # Create DPS breakdown table + dps_table = [] + + for ability in dmg_breakdown: + if ability in ['Claw']: + continue + + ability_dps = dmg_breakdown[ability]['damage'] / sim.fight_length + ability_cpm = dmg_breakdown[ability]['casts'] / sim.fight_length * 60. + ability_dpct = ability_dps * 60. / ability_cpm if ability_cpm else 0. + dps_table.append(html.Tr([ + html.Td(ability), + html.Td('%.3f' % dmg_breakdown[ability]['casts']), + html.Td('%.1f' % ability_cpm), + html.Td('%.0f' % ability_dpct), + html.Td('%.1f%%' % (ability_dps / avg_dps * 100)) + ])) + + # Create Aura uptime table + aura_table = [] + + for row in aura_stats: + aura_table.append(html.Tr([ + html.Td(row[0]), + html.Td('%.3f' % row[1]), + html.Td('%.1f%%' % (row[2] * 100)) + ])) + + return ( + avg_dps, + (mean_dps_str, median_dps_str, oom_time_str, dps_table, aura_table), + ) + + def compute(self, + json_file, paste_data, consumables, raid_buffs, bshout_options, num_mcp, + other_buffs, raven_idol, stat_debuffs, surv_agi, trinket_1, trinket_2, + run_clicks, weight_clicks, graph_clicks, potion, ferocious_inspiration, + bonuses, feral_aggression, savage_fury, naturalist, + natural_shapeshifter, intensity, fight_length, boss_armor, + boss_debuffs, cooldowns, finisher, rip_cp, bite_cp, max_wait_time, + cd_delay, prepop_TF, prepop_numticks, use_mangle_trick, use_rake_trick, + use_bite_trick, bite_trick_cp, bite_trick_max, use_innervate, + use_biteweave, bite_time, use_ripweave, ripweave_energy, bear_mangle, + num_replicates, latency, calc_mana_weights, epic_gems, show_whites + ): + ctx = dash.callback_context + + # Parse input stats JSON + buffs_present = False + use_default_inputs = True + + input_json = None + if json_file is None and paste_data is None: + upload_output = ( + 'No file uploaded, using default input stats instead.', + {'color': '#E59F3A', 'width': 300}, True + ) + else: + try: + if paste_data: + input_json = json.loads(paste_data) + else: + content_type, content_string = json_file.split(',') + decoded = base64.b64decode(content_string) + input_json = json.load(io.StringIO(decoded.decode('utf-8'))) + buffs_present = input_json['exportOptions']['buffs'] + catform_checked = ( + ('form' in input_json['exportOptions']) + and (input_json['exportOptions']['form'] == 'cat') + ) + + + if not catform_checked: + upload_output = ( + 'Error processing input file! "Cat Form" was not checked ' + 'in the export pop-up window. Using default input stats ' + 'instead.', + {'color': '#D35845', 'width': 300}, True + ) + elif buffs_present: + pot_present = False + + for entry in input_json['consumables']: + if 'Potion' in entry['name']: + pot_present = True + + if pot_present: + upload_output = ( + 'Error processing input file! Potions should not be ' + 'checked in the Seventy Upgrades buff tab, as they are' + ' temporary rather than permanent stat buffs. Using' + ' default input stats instead.', + {'color': '#D35845', 'width': 300}, True + ) + else: + upload_output = ( + 'Upload successful. Buffs detected in Seventy Upgrades' + ' export, so the "Consumables" and "Raid Buffs" ' + 'sections in the sim input will be ignored.', + {'color': '#5AB88F', 'width': 300}, False + ) + use_default_inputs = False + else: + upload_output = ( + 'Upload successful. No buffs detected in Seventy Upgrades ' + 'export, so use the "Consumables" and "Raid Buffs" ' + 'sections in the sim input for buff entry.', + {'color': '#5AB88F', 'width': 300}, True + ) + use_default_inputs = False + except Exception: + upload_output = ( + 'Error processing input file! Using default input stats ' + 'instead.', + {'color': '#D35845', 'width': 300}, True + ) + + if use_default_inputs: + input_stats = copy.copy(default_input_stats) + buffs_present = False + else: + input_stats = input_json['stats'] + + + + # If buffs are not specified in the input file, then interpret the input + # stats as unbuffed and calculate the buffed stats ourselves. + if not buffs_present: + input_stats.update(apply_buffs( + input_stats['attackPower'], input_stats['strength'], + input_stats['agility'], input_stats['hit'], input_stats['crit'], + input_stats['mana'], input_stats['intellect'], + input_stats['spirit'], input_stats.get('mp5', 0), + input_stats.get('weaponDamage', 0), raid_buffs, consumables, + bshout_options + )) + + # Determine whether Unleashed Rage and/or Blessing of Kings are present, as + # these impact stat weights and buff values. + if buffs_present: + unleashed_rage = False + kings = False + + for buff in input_json['buffs']: + if buff['name'] == 'Blessing of Kings': + kings = True + if buff['name'] == 'Unleashed Rage': + unleashed_rage = True + else: + unleashed_rage = 'unleashed_rage' in raid_buffs + kings = 'kings' in raid_buffs + + if uses_wolfshead(input_json): + if 'wolfshead' not in bonuses: + print("added wolfshead to bonuses from json data") + bonuses.append("wolfshead") + + # Create Player object based on raid buffed stat inputs and talents + player, ap_mod, stat_mod = self.create_player( + input_stats['attackPower'], input_stats['hit'], input_stats['crit'], + input_stats.get('weaponDamage', 0), input_stats.get('hasteRating', 0), + input_stats.get('expertiseRating', 0), input_stats.get('armorPen', 0), + input_stats['mana'], input_stats['intellect'], input_stats['spirit'], + input_stats.get('mp5', 0), float(input_stats['mainHandSpeed']), + unleashed_rage, kings, raven_idol, other_buffs, stat_debuffs, + cooldowns, num_mcp, surv_agi, ferocious_inspiration, bonuses, + naturalist, feral_aggression, savage_fury, natural_shapeshifter, + intensity, potion + ) + + + # Process trinkets + trinket_list = self.process_trinkets(input_json, + trinket_1, trinket_2, player, ap_mod, stat_mod, cd_delay + ) + + # Default output is just the buffed player stats with no further calcs + stats_output = ( + '%.3f seconds' % player.swing_timer, + '%d' % player.attack_power, + '%.2f %%' % (player.crit_chance * 100), + '%.2f %%' % (player.miss_chance * 100), + '%d' % player.mana_pool, '%d' % player.intellect, + '%d' % player.spirit, '%d' % player.mp5 + ) + + # Create Simulation object based on specified parameters + max_mcp = num_mcp if 'mcp' in cooldowns else 0 + bite = ( + (bool(use_biteweave) and (finisher == 'rip')) or (finisher == 'bite') + ) + rip_combos = 6 if finisher != 'rip' else int(rip_cp) + ripweave_combos = 6 if finisher != 'bite' else int(rip_cp) + + if 'lust' in cooldowns: + trinket_list.append(trinkets.Bloodlust(delay=cd_delay)) + if 'drums' in cooldowns: + trinket_list.append(trinkets.ActivatedTrinket( + 'haste_rating', 80, 'Drums of Battle', 30, 120, delay=cd_delay + )) + + if 'exalted_ring' in bonuses: + ring_ppm = 1.0 + ring = trinkets.ProcTrinket( + chance_on_hit=ring_ppm / 60., + yellow_chance_on_hit=ring_ppm / 60. * player.weapon_speed, + stat_name='attack_power', stat_increment=160 * ap_mod, + proc_duration=10, cooldown=60, + proc_name='Band of the Eternal Champion', + ) + trinket_list.append(ring) + player.proc_trinkets.append(ring) + if 'idol_of_terror' in bonuses: + idol = trinkets.ProcTrinket( + chance_on_hit=0.85, stat_name=['attack_power', 'crit_chance'], + stat_increment=np.array([ + 65. * stat_mod * ap_mod, + 65. * stat_mod / 25. / 100., + ]), + proc_duration=10, cooldown=10, proc_name='Primal Instinct', + mangle_only=True + ) + trinket_list.append(idol) + player.proc_trinkets.append(idol) + if 'stag_idol' in bonuses: + idol = trinkets.RefreshingProcTrinket( + chance_on_hit=1.0, stat_name='attack_power', + stat_increment=94 * ap_mod, proc_duration=20, cooldown=0, + proc_name='Idol of the White Stag', mangle_only=True + ) + trinket_list.append(idol) + player.proc_trinkets.append(idol) + + if potion == 'haste': + haste_pot = trinkets.HastePotion(delay=cd_delay) + else: + haste_pot = None + + sim = ccs.Simulation( + player, fight_length + 1e-9, 0.001 * latency, num_mcp=max_mcp, + boss_armor=boss_armor, prepop_TF=bool(prepop_TF), + prepop_numticks=int(prepop_numticks), min_combos_for_rip=rip_combos, + min_combos_for_bite=int(bite_cp), use_innervate=bool(use_innervate), + use_rake_trick=bool(use_rake_trick), + use_bite_trick=bool(use_bite_trick), bite_trick_cp=int(bite_trick_cp), + bite_trick_max=bite_trick_max, + use_mangle_trick=bool(use_mangle_trick), use_bite=bite, + bite_time=bite_time, use_rip_trick=bool(use_ripweave), + rip_trick_cp=ripweave_combos, rip_trick_min=ripweave_energy, + bear_mangle=bool(bear_mangle), trinkets=trinket_list, + max_wait_time=max_wait_time, haste_pot=haste_pot + ) + sim.set_active_debuffs(boss_debuffs) + player.calc_damage_params(**sim.params) + + # If either "Run" or "Stat Weights" button was pressed, then perform a + # sim run for the specified number of replicates. + if (ctx.triggered and + (ctx.triggered[0]['prop_id'] in + ['run_button.n_clicks', 'weight_button.n_clicks'])): + avg_dps, dps_output = self.run_sim(sim, num_replicates) + oom_time = dps_output[2] + self.generate_set_entry(input_json, avg_dps, trinket_1, trinket_2, boss_armor, fight_length, oom_time) + else: + dps_output = ('', '', '', [], []) + + # If "Stat Weights" button was pressed, then calculate weights. + if (ctx.triggered and + (ctx.triggered[0]['prop_id'] == 'weight_button.n_clicks')): + weights_output = calc_weights( + sim, num_replicates, avg_dps, calc_mana_weights, dps_output[2], + kings, unleashed_rage, epic_gems + ) + else: + weights_output = ('Stat Breakdown', '', [], '') + + # If "Generate Example" button was pressed, do it. + if (ctx.triggered and + (ctx.triggered[0]['prop_id'] == 'graph_button.n_clicks')): + example_output = plot_new_trajectory(sim, show_whites) + else: + example_output = ({}, []) + + return ( + upload_output + stats_output + dps_output + weights_output + + example_output + ) + + # Callbacks for disabling rotation options when inappropriate + def disable_tricks(self, bonuses, rake_trick_checked, bite_trick_checked): + rake_options = {'label': ' use Rake trick', 'value': 'use_rake_trick'} + bite_options = {'label': ' use Bite trick', 'value': 'use_bite_trick'} + rake_text_style = {} + bite_text_style = {} + + if 't6_2p' in bonuses: + if rake_trick_checked: + rake_text_style['color'] = '#D35845' + else: + rake_options['disabled'] = True + rake_text_style['color'] = '#888888' + + if bite_trick_checked: + bite_text_style['color'] = '#D35845' + else: + bite_options['disabled'] = True + bite_text_style['color'] = '#888888' + + return ( + [rake_options], [bite_options], rake_text_style, bite_text_style, + bite_text_style, bite_text_style + ) + + + def disable_weaves(self, finisher): + biteweave_options = {'label': ' weave Ferocious Bite', 'value': 'bite'} + ripweave_options = {'label': ' weave Rip', 'value': 'rip'} + biteweave_text_style_1 = {} + biteweave_text_style_2 = {'marginLeft': '-15%'} + ripweave_text_style_1 = {} + ripweave_text_style_2 = {'marginLeft': '-15%'} + + if finisher != 'rip': + biteweave_options['disabled'] = True + biteweave_text_style_1['color'] = '#888888' + biteweave_text_style_2['color'] = '#888888' + + if finisher != 'bite': + ripweave_options['disabled'] = True + ripweave_text_style_1['color'] = '#888888' + ripweave_text_style_2['color'] = '#888888' + + return ( + [biteweave_options], biteweave_text_style_1, biteweave_text_style_1, + biteweave_text_style_2, [ripweave_options], ripweave_text_style_1, + ripweave_text_style_1, ripweave_text_style_2 + ) + + def generate_set_entry(self, input_json, dps, trinket_1, trinket_2, boss_armor, fight_length, oom_time): + if trinket_1 == 'none': + if input_json is not None: + for i in input_json['items']: + if i['slot'] == 'TRINKET_1': + print("TRINKET_1: {}".format(i['name'])) + trinket_1 = trinkets.trinket_map[i['name']] + if trinket_2 == 'none': + if input_json is not None: + for i in input_json['items']: + if i['slot'] == 'TRINKET_2': + trinket_2 = trinkets.trinket_map[i['name']] + # add info to the set table + stats = input_json['stats'] + set_input = { + "name": input_json['name'], + "wolfshead": uses_wolfshead(input_json), + "trinket 1": trinkets.trinket_friendly_name(trinket_1), + "trinket 2": trinkets.trinket_friendly_name(trinket_2), + "boss armor": boss_armor, + "fight length": fight_length, + "time to oom": oom_time, + "crit": stats['crit'], + "hit": stats['hit'], + "ap": stats["attackPower"], + "dps": dps} + if 'set' in input_json['links'].keys(): + #set_input['link'] = "'Link'".format(input_json['links']['set']) + set_input['link'] = input_json['links']['set'] + else: + set_input['link'] = "None" + if 'expertise' in stats: + set_input['expertise'] = stats['expertise'] + if 'armorPen' in stats: + set_input['armorPen'] = stats['armorPen'] + self.df = self.df.append(set_input, ignore_index=True) + print(self.df) + + def update_table(self, sort_by, median_dps): + print("sort by: {}".format(sort_by)) + if len(sort_by): + dff = self.df.sort_values( + sort_by[0]['column_id'], + ascending=sort_by[0]['direction'] == 'asc', + inplace=False, + ) + else: + dff = self.df + + return dff.to_dict('records'), [{'name': i, 'id': i} for i in dff.columns] + + + + def run_server(self, host="0.0.0.0", port=8080, debug=True): + multiprocessing.freeze_support() + self.app.run_server(host=host, port=port, debug=debug) \ No newline at end of file diff --git a/ui/util/__init__.py b/ui/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/util/__pycache__/__init__.cpython-39.pyc b/ui/util/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..84c0d6a Binary files /dev/null and b/ui/util/__pycache__/__init__.cpython-39.pyc differ diff --git a/ui/util/__pycache__/buffs.cpython-39.pyc b/ui/util/__pycache__/buffs.cpython-39.pyc new file mode 100644 index 0000000..f9129e7 Binary files /dev/null and b/ui/util/__pycache__/buffs.cpython-39.pyc differ diff --git a/ui/util/__pycache__/plot.cpython-39.pyc b/ui/util/__pycache__/plot.cpython-39.pyc new file mode 100644 index 0000000..2533d25 Binary files /dev/null and b/ui/util/__pycache__/plot.cpython-39.pyc differ diff --git a/ui/util/__pycache__/trinkets.cpython-39.pyc b/ui/util/__pycache__/trinkets.cpython-39.pyc new file mode 100644 index 0000000..cf2e2e3 Binary files /dev/null and b/ui/util/__pycache__/trinkets.cpython-39.pyc differ diff --git a/ui/util/buffs.py b/ui/util/buffs.py new file mode 100644 index 0000000..15b19c6 --- /dev/null +++ b/ui/util/buffs.py @@ -0,0 +1,78 @@ + +def apply_buffs( + unbuffed_ap, unbuffed_strength, unbuffed_agi, unbuffed_hit, + unbuffed_crit, unbuffed_mana, unbuffed_int, unbuffed_spirit, + unbuffed_mp5, weapon_damage, raid_buffs, consumables, bshout_options +): + """Takes in unbuffed player stats, and turns them into buffed stats based + on specified consumables and raid buffs. This function should only be + called if the "Buffs" option is not checked in the exported file from + Seventy Upgrades, or else the buffs will be double counted!""" + + # Determine "raw" AP, crit, and mana not from Str/Agi/Int + raw_ap_unbuffed = unbuffed_ap / 1.1 - 2 * unbuffed_strength - unbuffed_agi + raw_crit_unbuffed = unbuffed_crit - unbuffed_agi / 25 + raw_mana_unbuffed = unbuffed_mana - 15 * unbuffed_int + + # Augment all base stats based on specified buffs + stat_multiplier = 1 + 0.1 * ('kings' in raid_buffs) + added_stats = 18 * ('motw' in raid_buffs) + + buffed_strength = stat_multiplier * (unbuffed_strength + 1.03 * ( + added_stats + 98 * ('str_totem' in raid_buffs) + + 20 * ('scroll_str' in consumables) + )) + buffed_agi = stat_multiplier * (unbuffed_agi + 1.03 * ( + added_stats + 88 * ('agi_totem' in raid_buffs) + + 35 * ('agi_elixir' in consumables) + 20 * ('food' in consumables) + + 20 * ('scroll_agi' in consumables) + )) + buffed_int = stat_multiplier * (unbuffed_int + 1.2 * 1.03 * ( + added_stats + 40 * ('ai' in raid_buffs) + + 30 * ('draenic' in consumables) + )) + buffed_spirit = stat_multiplier * (unbuffed_spirit + 1.03 * ( + added_stats + 50 * ('spirit' in raid_buffs) + + 20 * ('food' in consumables) + 30 * ('draenic' in consumables) + )) + + # Now augment secondary stats + ap_mod = 1.1 * (1 + 0.1 * ('unleashed_rage' in raid_buffs)) + bshout_ap = ( + ('bshout' in raid_buffs) * (305 + 70 * ('trinket' in bshout_options)) + * (1. + 0.25 * ('talent' in bshout_options)) + ) + buffed_attack_power = ap_mod * ( + raw_ap_unbuffed + 2 * buffed_strength + buffed_agi + + 264 * ('might' in raid_buffs) + bshout_ap + + 125 * ('trueshot_aura' in raid_buffs) + ) + added_crit_rating = ( + 20 * ('agi_elixir' in consumables) + + 14 * ('weightstone' in consumables) + ) + buffed_crit = ( + raw_crit_unbuffed + buffed_agi / 25 + added_crit_rating / 22.1 + ) + buffed_hit = ( + unbuffed_hit + 1 * ('heroic_presence' in raid_buffs) + ) + buffed_mana_pool = raw_mana_unbuffed + buffed_int * 15 + buffed_mp5 = unbuffed_mp5 + 49 * ('wisdom' in raid_buffs) + buffed_weapon_damage = ( + 12 * ('weightstone' in consumables) + weapon_damage + ) + + return { + 'strength': buffed_strength, + 'agility': buffed_agi, + 'intellect': buffed_int, + 'spirit': buffed_spirit, + 'attackPower': buffed_attack_power, + 'crit': buffed_crit, + 'hit': buffed_hit, + 'weaponDamage': buffed_weapon_damage, + 'mana': buffed_mana_pool, + 'mp5': buffed_mp5 + } + diff --git a/ui/util/plot.py b/ui/util/plot.py new file mode 100644 index 0000000..54b9613 --- /dev/null +++ b/ui/util/plot.py @@ -0,0 +1,123 @@ +import numpy as np +import plotly.graph_objects as go +import tbc_cat_sim as ccs +from dash import dash_table, dcc, html + +def plot_new_trajectory(sim, show_whites): + t_vals, _, energy_vals, cp_vals, _, _, log = sim.run(log=True) + t_fine = np.linspace(0, sim.fight_length, 10000) + fig = go.Figure() + fig.add_trace(go.Scatter( + x=t_fine, y=ccs.piecewise_eval(t_fine, t_vals, energy_vals), + line=dict(color="#d62728") + )) + fig.add_trace(go.Scatter( + x=t_fine, y=ccs.piecewise_eval(t_fine, t_vals, cp_vals), + line=dict(color="#9467bd", dash='dash'), yaxis='y2' + )) + fig.update_layout( + xaxis=dict(title='Time (seconds)'), + yaxis=dict( + title='Energy', titlefont=dict(color='#d62728'), + tickfont=dict(color='#d62728') + ), + yaxis2=dict( + title='Combo points', titlefont=dict(color='#9467bd'), + tickfont=dict(color='#9467bd'), anchor='x', overlaying='y', + side='right' + ), + showlegend=False, + ) + + # Create combat log table + log_table = [] + + if not show_whites: + parsed_log = [row for row in log if row[1] != 'melee'] + else: + parsed_log = log + + for row in parsed_log: + log_table.append(html.Tr([ + html.Td(entry) for entry in row + ])) + + return fig, log_table + +def append_mana_weights( + weights_table, sim, num_replicates, time_to_oom, avg_dps, dps_per_AP, + stat_multiplier +): + # Just set all mana weights to 0 if we didn't even go oom + if time_to_oom == 'none': + weights_table.append(html.Tr([ + html.Td('mana stats'), html.Td('0.0'), html.Td('0.0'), + ])) + return + + # Calculate DPS increases and weights + dps_deltas, stat_weights = sim.calc_mana_weights( + num_replicates, avg_dps, dps_per_AP + ) + + # Parse results + for stat in dps_deltas: + multiplier = 1.0 if stat in ['1 mana', '1 mp5'] else stat_multiplier + weights_table.append(html.Tr([ + html.Td(stat), + html.Td('%.3f' % (dps_deltas[stat] * multiplier)), + html.Td('%.3f' % (stat_weights[stat] * multiplier)), + ])) + + + +def calc_weights( + sim, num_replicates, avg_dps, calc_mana_weights, time_to_oom, + kings, unleashed_rage, epic_gems +): + # Check that sufficient iterations are used for convergence. + if num_replicates < 20000: + error_msg = ( + 'Stat weight calculation requires the simulation to be run with at' + ' least 20,000 replicates.' + ) + return 'Error: ', error_msg, [], '' + + # Do fresh weights calculation + weights_table = [] + + # Calculate DPS increases and weights + dps_deltas, stat_weights = sim.calc_stat_weights( + num_replicates, base_dps=avg_dps, unleashed_rage=unleashed_rage + ) + + # Parse results + for stat in dps_deltas: + if stat == '1 AP': + weight = 1.0 + dps_per_AP = dps_deltas[stat] + else: + weight = stat_weights[stat] + + weights_table.append(html.Tr([ + html.Td(stat), + html.Td('%.2f' % dps_deltas[stat]), + html.Td('%.2f' % weight), + ])) + + # Generate 70upgrades import link for raw stats + stat_multiplier = (1 + 0.1 * kings) * 1.03 + url = ccs.gen_import_link( + stat_weights, multiplier=stat_multiplier, epic_gems=epic_gems + ) + link = html.A('Seventy Upgrades Import Link', href=url, target='_blank') + + # Only calculate mana stats if requested + if calc_mana_weights: + append_mana_weights( + weights_table, sim, num_replicates, time_to_oom, avg_dps, + dps_per_AP, stat_multiplier + ) + + return 'Stat Breakdown', '', weights_table, link + diff --git a/ui/util/trinkets.py b/ui/util/trinkets.py new file mode 100644 index 0000000..8b369f7 --- /dev/null +++ b/ui/util/trinkets.py @@ -0,0 +1,1040 @@ +"""Code for modeling non-static trinkets in feral DPS simulation.""" + +import numpy as np +import tbc_cat_sim as ccs + + +class Trinket(): + + """Keeps track of activation times and cooldowns for an equipped trinket, + updates Player and Simulation parameters when the trinket is active, and + determines when procs or trinket activations occur.""" + + def __init__( + self, stat_name, stat_increment, proc_name, proc_duration, cooldown + ): + """Initialize a generic trinket with key parameters. + + Arguments: + stat_name (str or list): Name of the Player attribute that will be + modified by the trinket activation. Must be a valid attribute + of the Player class that can be modified. The one exception is + haste_rating, which is separately handled by the Simulation + object when updating timesteps for the sim. A list of strings + can be provided instead, in which case every stat in the list + will be modified during the trinket activation. + stat_increment (float or np.ndarray): Amount by which the Player + attribute is changed when the trinket is active. If multiple + stat names are specified, then this must be a numpy array of + equal length to the number of stat names. + proc_name (str): Name of the buff that is applied when the trinket + is active. Used for combat logging. + proc_duration (int): Duration of the buff, in seconds. + cooldown (int): Internal cooldown before the trinket can be + activated again, either via player use or procs. + """ + self.stat_name = stat_name + self.stat_increment = stat_increment + self.proc_name = proc_name + self.proc_duration = proc_duration + self.cooldown = cooldown + self.reset() + + def reset(self): + """Set trinket to fresh inactive state with no cooldown remaining.""" + self.activation_time = -np.inf + self.active = False + self.can_proc = True + self.num_procs = 0 + self.uptime = 0.0 + self.last_update = 0.0 + + def modify_stat(self, time, player, sim, increment): + """Change a player stat when a trinket is activated or deactivated. + + Arguments: + time (float): Simulation time, in seconds, of activation. + player (tbc_cat_sim.Player): Player object whose attributes will be + modified. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + increment (float or np.ndarray): Quantity to add to the player's + existing stat value(s). + """ + # Convert stat name and stat increment to arrays if they are scalars + stat_names = np.atleast_1d(self.stat_name) + increments = np.atleast_1d(increment) + + for index, stat_name in enumerate(stat_names): + self._modify_stat(time, player, sim, stat_name, increments[index]) + + @staticmethod + def _modify_stat(time, player, sim, stat_name, increment): + """Contains the actual stat modification functionality for a single + stat. Called by the wrapper function, which handles potentially + iterating through multiple stats to be modified.""" + # Haste procs get handled separately from other raw stat buffs + if stat_name == 'haste_rating': + sim.apply_haste_buff(time, increment) + else: + old_value = getattr(player, stat_name) + setattr(player, stat_name, old_value + increment) + + # Recalculate damage parameters when player stats change + player.calc_damage_params(**sim.params) + + def activate(self, time, player, sim): + """Activate the trinket buff upon player usage or passive proc. + + Arguments: + time (float): Simulation time, in seconds, of activation. + player (tbc_cat_sim.Player): Player object whose attributes will be + modified by the trinket proc. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + + Returns: + damage_done (float): Any instant damage that is dealt when the + trinket is activated. Defaults to 0 for standard trinkets, but + custom subclasses can implement fixed damage procs that would + be calculated in this method. + """ + self.activation_time = time + self.deactivation_time = time + self.proc_duration + self.modify_stat(time, player, sim, self.stat_increment) + sim.proc_end_times.append(self.deactivation_time) + + # In the case of a second trinket being used, the proc end time can + # sometimes be earlier than that of the first trinket, so the list of + # end times needs to be sorted. + sim.proc_end_times.sort() + + # Mark trinket as active + self.active = True + self.can_proc = False + self.num_procs += 1 + + # Log if requested + if sim.log: + sim.combat_log.append(sim.gen_log(time, self.proc_name, 'applied')) + + # Return default damage dealt of 0 + return 0.0 + + def deactivate(self, player, sim, time=None): + """Deactivate the trinket buff when the duration has expired. + + Arguments: + player (tbc_cat_sim.Player): Player object whose attributes will be + restored to their original values. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + time (float): Time at which the trinket is deactivated. Defaults to + the stored time for automatic deactivation. + """ + if time is None: + time = self.deactivation_time + + self.modify_stat(time, player, sim, -self.stat_increment) + self.active = False + + if sim.log: + sim.combat_log.append( + sim.gen_log(time, self.proc_name, 'falls off') + ) + + def update(self, time, player, sim, allow_activation=True): + """Check for a trinket activation or deactivation at the specified + simulation time, and perform associated bookkeeping. + + Arguments: + time (float): Simulation time, in seconds. + player (tbc_cat_sim.Player): Player object whose attributes will be + modified by the trinket proc. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + allow_activation (bool): Allow the trinket to be activated + automatically if the appropriate conditions are met. Defaults + True, but can be set False if the user wants to control + trinket activations manually. + + Returns: + damage_done (float): Any instant damage that is dealt if the + trinket is activated at the specified time. Defaults to 0 for + standard trinkets, but custom subclasses can implement fixed + damage procs that would be returned on each update. + """ + # Update average proc uptime value + if time > self.last_update: + dt = time - self.last_update + self.uptime = ( + (self.uptime * self.last_update + dt * self.active) / time + ) + self.last_update = time + + # First check if an existing buff has fallen off + if self.active and (time > self.deactivation_time - 1e-9): + self.deactivate(player, sim) + + # Then check whether the trinket is off CD and can now proc + if (not self.can_proc + and (time - self.activation_time > self.cooldown - 1e-9)): + self.can_proc = True + + # Now decide whether a proc actually happens + if allow_activation and self.apply_proc(): + return self.activate(time, player, sim) + + # Return default damage dealt of 0 + return 0.0 + + def apply_proc(self): + """Determine whether or not the trinket is activated at the current + time. This method must be implemented by Trinket subclasses. + + Returns: + proc_applied (bool): Whether or not the activation occurs. + """ + return NotImplementedError( + 'Logic for trinket activation must be implemented by Trinket ' + 'subclasses.' + ) + + +class ActivatedTrinket(Trinket): + """Models an on-use trinket that is activated on cooldown as often as + possible.""" + + def __init__( + self, stat_name, stat_increment, proc_name, proc_duration, cooldown, + delay=0.0 + ): + """Initialize a generic activated trinket with key parameters. + + Arguments: + stat_name (str): Name of the Player attribute that will be + modified by the trinket activation. Must be a valid attribute + of the Player class that can be modified. The one exception is + haste_rating, which is separately handled by the Simulation + object when updating timesteps for the sim. + stat_increment (float): Amount by which the Player attribute is + changed when the trinket is active. + proc_name (str): Name of the buff that is applied when the trinket + is active. Used for combat logging. + proc_duration (int): Duration of the buff, in seconds. + cooldown (int): Internal cooldown before the trinket can be + activated again. + delay (float): Optional time delay (in seconds) before the first + trinket activation in the fight. Can be used to enforce a + shared cooldown between two activated trinkets, or to delay the + activation for armor debuffs etc. Defaults to 0.0 . + """ + self.delay = delay + Trinket.__init__( + self, stat_name, stat_increment, proc_name, proc_duration, + cooldown + ) + + def reset(self): + """Set trinket to fresh inactive state at the start of a fight.""" + if self.delay: + # We put in a hack to set the "activation time" such that the + # trinket is ready after precisely the delay + self.activation_time = self.delay - self.cooldown + else: + # Otherwise, the initial activation time is set infinitely in the + # past so that the trinket is immediately ready for activation. + self.activation_time = -np.inf + + self.active = False + self.can_proc = not self.delay + self.num_procs = 0 + self.uptime = 0.0 + self.last_update = 0.0 + + def apply_proc(self): + """Determine whether or not the trinket is activated at the current + time. + + Returns: + proc_applied (bool): Whether or not the activation occurs. + """ + # Activated trinkets follow the simple logic of being used as soon as + # they are available. + if self.can_proc: + return True + return False + + +class HastePotion(ActivatedTrinket): + """Haste pots can be easily modeled within the same trinket class structure + without the need for custom code.""" + + def __init__(self, delay=0.0): + """Initialize object at the start of a fight. + + Arguments: + delay (float): Minimum elapsed time in the fight before the potion + can be used. Can be used to delay the potion activation for + armor debuffs going up, etc. Note that the potion will *not* be + actually activated at the delay time, only on the subsequent + powershift. Defaults to 0.0 + """ + ActivatedTrinket.__init__( + self, 'haste_rating', 400, 'Haste Potion', 15, 120, delay=delay + ) + + def update(self, time, player, sim): + """Check for possible deactivation of the haste buff, or if the potion + has come off cooldown. + + Arguments: + time (float): Simulation time, in seconds. + player (tbc_cat_sim.Player): Player object executing the DPS + rotation. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + """ + return ActivatedTrinket.update( + self, time, player, sim, allow_activation=False + ) + + +class Bloodlust(ActivatedTrinket): + """Similar to haste pots, the trinket framework works perfectly for Lust as + well, just that the percentage haste buff is handled a bit differently.""" + + def __init__(self, delay=0.0): + """Initialize object at the start of a fight. + + Arguments: + delay (float): Minimum elapsed time in the fight before Lust is + used. Can be used to delay lusting for armor debuffs going up, + etc. Defaults to 0.0 + """ + ActivatedTrinket.__init__( + self, None, 0.0, 'Bloodlust', 40, 600, delay=delay + ) + + def modify_stat(self, time, player, sim, *args): + """Change swing timer when Bloodlust is applied or falls off. + + Arguments: + time (float): Simulation time, in seconds, of activation. + player (tbc_cat_sim.Player): Player object whose attributes will be + modified. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + """ + old_multiplier = 1.3 if self.active else 1.0 + haste_rating = ccs.calc_haste_rating( + sim.swing_timer, multiplier=old_multiplier + ) + new_multiplier = 1.0 if self.active else 1.3 + new_swing_timer = ccs.calc_swing_timer( + haste_rating, multiplier=new_multiplier + ) + sim.update_swing_times(time, new_swing_timer) + sim.haste_multiplier = new_multiplier + + +class ProcTrinket(Trinket): + """Models a passive trinket with a specified proc chance on hit or crit.""" + + def __init__( + self, stat_name, stat_increment, proc_name, chance_on_hit, + proc_duration, cooldown, chance_on_crit=0.0, yellow_chance_on_hit=None, + mangle_only=False + ): + """Initialize a generic proc trinket with key parameters. + + Arguments: + stat_name (str): Name of the Player attribute that will be + modified by the trinket activation. Must be a valid attribute + of the Player class that can be modified. The one exception is + haste_rating, which is separately handled by the Simulation + object when updating timesteps for the sim. + stat_increment (float): Amount by which the Player attribute is + changed when the trinket is active. + proc_name (str): Name of the buff that is applied when the trinket + is active. Used for combat logging. + chance_on_hit (float): Probability of a proc on a successful normal + hit, between 0 and 1. + chance_on_crit (float): Probability of a proc on a critical strike, + between 0 and 1. Defaults to 0. + yellow_chance_on_hit (float): If supplied, use a separate proc rate + for special abilities. In this case, chance_on_hit will be + interpreted as the proc rate for white attacks. Used for ppm + trinkets where white and yellow proc rates are normalized + differently. + mangle_only (bool): If True, then designate this trinket as being + able to proc exclusively on the Mangle ability. Defaults False. + proc_duration (int): Duration of the buff, in seconds. + cooldown (int): Internal cooldown before the trinket can proc + again. + """ + Trinket.__init__( + self, stat_name, stat_increment, proc_name, proc_duration, + cooldown + ) + + if yellow_chance_on_hit is not None: + self.rates = { + 'white': chance_on_hit, 'yellow': yellow_chance_on_hit + } + self.separate_yellow_procs = True + else: + self.chance_on_hit = chance_on_hit + self.chance_on_crit = chance_on_crit + self.separate_yellow_procs = False + + self.mangle_only = mangle_only + + def check_for_proc(self, crit, yellow): + """Perform random roll for a trinket proc upon a successful attack. + + Arguments: + crit (bool): Whether the attack was a critical strike. + yellow (bool): Whether the attack was a special ability rather + than a melee attack. + """ + if not self.can_proc: + self.proc_happened = False + return + + proc_roll = np.random.rand() + + if self.separate_yellow_procs: + rate = self.rates['yellow'] if yellow else self.rates['white'] + else: + rate = self.chance_on_crit if crit else self.chance_on_hit + + if proc_roll < rate: + self.proc_happened = True + else: + self.proc_happened = False + + def apply_proc(self): + """Determine whether or not the trinket is activated at the current + time. For a proc trinket, it is assumed that a check has already been + made for the proc when the most recent attack occurred. + + Returns: + proc_applied (bool): Whether or not the activation occurs. + """ + if self.can_proc and self.proc_happened: + self.proc_happened = False + return True + return False + + def reset(self): + """Set trinket to fresh inactive state with no cooldown remaining.""" + Trinket.reset(self) + self.proc_happened = False + + +class StackingProcTrinket(ProcTrinket): + """Models trinkets that provide temporary stacking buffs to the player + after an initial proc or activation.""" + + def __init__( + self, stat_name, stat_increment, max_stacks, aura_name, stack_name, + chance_on_hit, yellow_chance_on_hit, aura_duration, cooldown, + aura_type='activated', aura_proc_rates=None + ): + """Initialize a generic stacking proc trinket with key parameters. + + Arguments: + stat_name (str): Name of the Player attribute that will be + modified when stacks are accumulated. Must be a valid attribute + of the Player class that can be modified. The one exception is + haste_rating, which is separately handled by the Simulation + object when updating timesteps for the sim. + stat_increment (int): Amount by which the Player attribute is + changed from one additional stack of the trinket buff. + max_stacks (int): Maximum number of stacks that can be accumulated. + aura_name (str): Name of the aura that is applied when the trinket + is active, allowing for stack accumulation. Used for combat + logging. + stack_name (str): Name of the actual stacking buff that procs when + the above aura is active. + chance_on_hit (float): Probability of applying a new stack of the + buff when the aura is active upon a successful normal hit, + between 0 and 1. + yellow_chance_on_hit (float): Same as above, but for special + abilities. + aura_duration (float): Duration of the trinket aura as well as any + buff stacks that are accumulated when the aura is active. + cooldown (float): Internal cooldown before the aura can be applied + again once it falls off. + aura_type (str): Either "activated" or "proc", specifying whether + the overall stack accumulation aura is applied via player + activation of the trinket or via another proc mechanic. + aura_proc_rates (dict): Dictionary containing "white" and "yellow" + keys specifying the chance on hit for activating the aura and + enabling subsequent stack accumulation. Required and used only + when aura_type is "proc". + """ + self.stack_increment = stat_increment + self.max_stacks = max_stacks + self.aura_name = aura_name + self.stack_name = stack_name + self.stack_proc_rates = { + 'white': chance_on_hit, 'yellow': yellow_chance_on_hit + } + self.activated_aura = (aura_type == 'activated') + self.aura_proc_rates = aura_proc_rates + ProcTrinket.__init__( + self, stat_name=stat_name, stat_increment=0, proc_name=aura_name, + proc_duration=aura_duration, cooldown=cooldown, + chance_on_hit=self.stack_proc_rates['white'], + yellow_chance_on_hit=self.stack_proc_rates['yellow'] + ) + + def reset(self): + """Full reset of the trinket at the start of a fight.""" + self.activation_time = -np.inf + self._reset() + self.stat_increment = 0 + self.num_procs = 0 + self.uptime = 0.0 + self.last_update = 0.0 + + def _reset(self): + self.active = False + self.can_proc = False + self.proc_happened = False + self.num_stacks = 0 + self.proc_name = self.aura_name + + if not self.activated_aura: + self.rates = self.aura_proc_rates + + def deactivate(self, player, sim, time=None): + """Deactivate the trinket buff when the duration has expired. + + Arguments: + player (tbc_cat_sim.Player): Player object whose attributes will be + restored to their original values. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + time (float): Time at which the trinket is deactivated. Defaults to + the stored time for automatic deactivation. + """ + # Temporarily change the stat increment to the total value gained while + # the trinket was active + self.stat_increment = self.stack_increment * self.num_stacks + + # Reset trinket to inactive state + self._reset() + Trinket.deactivate(self, player, sim, time=time) + self.stat_increment = 0 + + def apply_proc(self): + """Determine whether a new trinket activation takes place, or whether + a new stack is applied to an existing activation.""" + # If can_proc is True but the stat increment is 0, it means that the + # last event was a trinket deactivation, so we activate the trinket. + if (self.activated_aura and (not self.active) and self.can_proc + and (self.stat_increment == 0)): + return True + + # Ignore procs when at max stacks, and prevent future proc checks + if self.num_stacks == self.max_stacks: + self.can_proc = False + return False + + return ProcTrinket.apply_proc(self) + + def activate(self, time, player, sim): + """Activate the trinket when off cooldown. If already active and a + trinket proc just occurred, then add a new stack of the buff. + + Arguments: + time (float): Simulation time, in seconds, of activation. + player (tbc_cat_sim.Player): Player object whose attributes will be + modified by the trinket proc. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + """ + if not self.active: + # Activate the trinket on a fresh use + Trinket.activate(self, time, player, sim) + self.can_proc = True + self.proc_name = self.stack_name + self.stat_increment = self.stack_increment + self.rates = self.stack_proc_rates + else: + # Apply a new buff stack. We do this "manually" rather than in the + # parent method because a new stack doesn't count as an actual + # activation. + self.modify_stat(time, player, sim, self.stat_increment) + self.num_stacks += 1 + + # Log if requested + if sim.log: + sim.combat_log.append( + sim.gen_log(time, self.proc_name, 'applied') + ) + + return 0.0 + + +class PoisonVial(ProcTrinket): + """Custom class to handle instant damage procs from the Romulo's Poison + Vial trinket.""" + + def __init__(self, white_chance_on_hit, yellow_chance_on_hit, *args): + """Initialize a Trinket object modeling RPV. Since RPV is a ppm + trinket, the user must pre-calculate the proc chances based on the + swing timer and equipped weapon speed. + + Arguments: + white_chance_on_hit (float): Probability of a proc on a successful + normal hit, between 0 and 1. + yellow_chance_on_hit (float): Separate proc rate for special + abilities. + """ + ProcTrinket.__init__( + self, stat_name=None, stat_increment=None, + proc_name="Romulo's Poison", proc_duration=0, + cooldown=0., chance_on_hit=white_chance_on_hit, + yellow_chance_on_hit=yellow_chance_on_hit + ) + + def activate(self, time, sim): + """Deal damage when the trinket procs. + + Arguments: + time (float): Simulation time, in seconds, of activation. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + + Returns: + damage_done (float): Damage dealt by the proc. + """ + self.num_procs += 1 + + # First roll for miss. Assume 0 spell hit, so miss chance is 17%. + miss_roll = np.random.rand() + + if miss_roll < 0.17: + if sim.log: + sim.combat_log.append( + sim.gen_log(time, self.proc_name, 'miss') + ) + + return 0.0 + + # Now roll the base damage done by the proc + base_damage = 222 + np.random.rand() * 110 + + # Now roll for partial resists. Assume that the boss has no nature + # resistance, so the only source of partials is the level based + # resistance of 24 for a boss mob. The partial resist table for this + # condition was taken from this calculator: + # https://royalgiraffe.github.io/legacy-sim/#/resistances + resist_roll = np.random.rand() + + if resist_roll < 0.84: + dmg_done = base_damage + elif resist_roll < 0.95: + dmg_done = 0.75 * base_damage + elif resist_roll < 0.99: + dmg_done = 0.5 * base_damage + else: + dmg_done = 0.25 * base_damage + + if sim.log: + sim.combat_log.append( + sim.gen_log(time, self.proc_name, '%d' % dmg_done) + ) + + return dmg_done + + def update(self, time, player, sim): + """Check if a trinket proc occurred on the player's last attack, and + perform associated bookkeeping. + + Arguments: + time (float): Simulation time, in seconds. + player (tbc_cat_sim.Player): Player object whose attributes can be + modified by trinket procs. Unused for RPV calculations, but + required by the Trinket API. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + + Returns: + damage_done (float): Damage dealt by the trinket since the last + check. + """ + if self.apply_proc(): + return self.activate(time, sim) + + return 0.0 + + +class RefreshingProcTrinket(ProcTrinket): + """Handles trinkets that can proc when already active to refresh the buff + duration.""" + + def activate(self, time, player, sim): + """Activate the trinket buff upon player usage or passive proc. + + Arguments: + time (float): Simulation time, in seconds, of activation. + player (tbc_cat_sim.Player): Player object whose attributes will be + modified by the trinket proc. + sim (tbc_cat_sim.Simulation): Simulation object controlling the + fight execution. + """ + # The only difference between a standard and repeating proc is that + # we want to make sure that the buff doesn't stack and merely + # refreshes. This is accomplished by deactivating the previous buff and + # then re-applying it. + if self.active: + self.deactivate(player, sim, time=time) + + return ProcTrinket.activate(self, time, player, sim) +trinket_map = { + "Bloodlust Brooch": "brooch", + "Berserker's Call": 'berserkers_call', + "Slayer's Crest": "slayers", + "Icon of Unyielding Courage": "icon", + "Abacus of Violent Odds": "abacus", + "Kiss of the Spider": "kiss", + "Badge of Tenacity": "tenacity", + "Crystalforged Trinket": "crystalforged", + "Hourglass of the Unraveller": "hourglass", + "Tsunami Talisman": 'tsunami', + "Dragonspine Trophy": "dst", + "Badge of the Swarmguard": "swarmguard", + "Romulo's Poison Vial": "vial", + "Living Root of the Wildheart": "wildheart", + "Ashtongue Talisman of Equilibrium": "ashtongue", + "Steely Naaru Sliver": "steely_naaru_sliver", + "Shard of Contempt": "shard_of_contempt", + "Madness of the Betrayer": "madness", + "Mark of the Champion": "motc", + "Drake Fang Talisman": "dft", + "Alchemist Stone": "alch", + "Assassin's Alchemist Stone": "assassin_alch", + "Blackened Naaru Sliver": "bns", + "Darkmoon Card: Crusade": "crusade" + +} + +# Library of recognized TBC trinkets and associated parameters +trinket_library = { + 'brooch': { + 'type': 'activated', + 'passive_stats': { + 'attack_power': 72, + }, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 278, + 'proc_name': 'Lust for Battle', + 'proc_duration': 20, + 'cooldown': 120, + }, + }, + 'berserkers_call': { + 'type': 'activated', + 'passive_stats': { + 'attack_power': 90, + }, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 360, + 'proc_name': 'Call of the Berserker', + 'proc_duration': 20, + 'cooldown': 120, + }, + }, + 'slayers': { + 'type': 'activated', + 'passive_stats': { + 'attack_power': 64, + }, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 260, + 'proc_name': "Slayer's Crest", + 'proc_duration': 20, + 'cooldown': 120, + }, + }, + 'icon': { + 'type': 'activated', + 'passive_stats': { + 'hit_chance': 30./15.77/100, + }, + 'active_stats': { + 'stat_name': 'armor_pen', + 'stat_increment': 600, + 'proc_name': 'Armor Penetration', + 'proc_duration': 20, + 'cooldown': 120, + }, + }, + 'abacus': { + 'type': 'activated', + 'passive_stats': { + 'attack_power': 64, + }, + 'active_stats': { + 'stat_name': 'haste_rating', + 'stat_increment': 260, + 'proc_name': 'Haste', + 'proc_duration': 10, + 'cooldown': 120, + }, + }, + 'kiss': { + 'type': 'activated', + 'passive_stats': { + 'crit_chance': 14./22.1/100, + 'hit_chance': 10./15.77/100, + }, + 'active_stats': { + 'stat_name': 'haste_rating', + 'stat_increment': 200, + 'proc_name': 'Kiss of the Spider', + 'proc_duration': 15, + 'cooldown': 120, + }, + }, + 'tenacity': { + 'type': 'activated', + 'passive_stats': {}, + 'active_stats': { + 'stat_name': 'Agility', + 'stat_increment': 150, + 'proc_name': 'Heightened Reflexes', + 'proc_duration': 20, + 'cooldown': 120, + }, + }, + 'crystalforged': { + 'type': 'activated', + 'passive_stats': { + 'bonus_damage': 7, + }, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 216, + 'proc_name': 'Valor', + 'proc_duration': 10, + 'cooldown': 60, + }, + }, + 'hourglass': { + 'type': 'proc', + 'passive_stats': { + 'crit_chance': 32./22.1/100, + }, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 300, + 'proc_name': 'Rage of the Unraveller', + 'proc_duration': 10, + 'cooldown': 50, + 'proc_type': 'chance_on_crit', + 'proc_rate': 0.1, + }, + }, + 'tsunami': { + 'type': 'proc', + 'passive_stats': { + 'crit_chance': 38./22.1/100, + 'hit_chance': 10./15.77/100, + }, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 340, + 'proc_name': 'Fury of the Crashing Waves', + 'proc_duration': 10, + 'cooldown': 45, + 'proc_type': 'chance_on_crit', + 'proc_rate': 0.1, + }, + }, + 'dst': { + 'type': 'proc', + 'passive_stats': { + 'attack_power': 40, + }, + 'active_stats': { + 'stat_name': 'haste_rating', + 'stat_increment': 325, + 'proc_name': 'Haste', + 'proc_duration': 10, + 'cooldown': 20, + 'proc_type': 'ppm', + 'proc_rate': 1., + }, + }, + 'swarmguard': { + 'type': 'stacking_proc', + 'passive_stats': {}, + 'active_stats': { + 'stat_name': 'armor_pen', + 'stat_increment': 200, + 'max_stacks': 6, + 'aura_name': 'Badge of the Swarmguard', + 'stack_name': 'Insight of the Qiraji', + 'proc_type': 'ppm', + 'proc_rate': 10., + 'aura_duration': 30, + 'cooldown': 180, + }, + }, + 'vial': { + 'type': 'proc', + 'passive_stats': { + 'hit_chance': 35./15.77/100, + }, + 'active_stats': { + 'stat_name': 'none', + 'proc_type': 'ppm', + 'proc_rate': 1., + }, + }, + 'wildheart': { + 'type': 'refreshing_proc', + 'passive_stats': {}, + 'active_stats': { + 'stat_name': 'Strength', + 'stat_increment': 64, + 'proc_name': 'Feline Blessing', + 'proc_duration': 15, + 'cooldown': 0, + 'proc_type': 'chance_on_hit', + 'proc_rate': 0.03, + }, + }, + 'ashtongue': { + 'type': 'refreshing_proc', + 'passive_stats': {}, + 'active_stats': { + 'stat_name': 'Strength', + 'stat_increment': 140, + 'proc_name': 'Ashtongue Talisman of Equilibrium', + 'proc_duration': 8, + 'cooldown': 0, + 'proc_type': 'chance_on_hit', + 'proc_rate': 0.4, + 'mangle_only': True, + }, + }, + 'steely_naaru_sliver': { + 'type': 'passive', + 'passive_stats': { + 'expertise_rating': 54, + }, + }, + 'shard_of_contempt': { + 'type': 'proc', + 'passive_stats': { + 'expertise_rating': 44, + }, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 230, + 'proc_name': 'Disdain', + 'proc_duration': 20, + 'cooldown': 45, + 'proc_type': 'chance_on_hit', + 'proc_rate': 0.1, + }, + }, + 'madness': { + 'type': 'refreshing_proc', + 'passive_stats': { + 'hit_chance': 20./15.77/100, + 'attack_power': 84, + }, + 'active_stats': { + 'stat_name': 'armor_pen', + 'stat_increment': 300, + 'proc_name': 'Forceful Strike', + 'proc_duration': 10, + 'cooldown': 0, + 'proc_type': 'ppm', + 'proc_rate': 1., + }, + }, + 'motc': { + 'type': 'passive', + 'passive_stats': { + 'attack_power': 150, + }, + }, + 'dft': { + 'type': 'passive', + 'passive_stats': { + 'attack_power': 56, + 'hit_chance': 20./15.77/100, + }, + }, + 'alch': { + 'type': 'passive', + 'passive_stats': { + 'strength': 15, + 'agility': 15, + 'intellect': 15, + 'spirit': 15, + 'mana_pot_multi': 0.4, + }, + }, + 'assassin_alch': { + 'type': 'passive', + 'passive_stats': { + 'attack_power': 108, + 'mana_pot_multi': 0.4, + }, + }, + 'bns': { + 'type': 'stacking_proc', + 'passive_stats': { + 'haste_rating': 54, + }, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 44, + 'max_stacks': 10, + 'aura_name': 'Battle Trance', + 'stack_name': 'Combat Insight', + 'proc_type': 'custom', + 'chance_on_hit': 1.0, + 'yellow_chance_on_hit': 1.0, + 'aura_duration': 20, + 'cooldown': 45, + 'aura_type': 'proc', + 'aura_proc_rates': {'white': 0.1, 'yellow': 0.1}, + }, + }, + 'crusade': { + 'type': 'stacking_proc', + 'passive_stats': {}, + 'active_stats': { + 'stat_name': 'attack_power', + 'stat_increment': 6, + 'max_stacks': 20, + 'aura_name': 'Aura of the Crusade', + 'stack_name': 'Aura of the Crusader', + 'proc_type': 'custom', + 'chance_on_hit': 1.0, + 'yellow_chance_on_hit': 1.0, + 'aura_duration': 1e9, + 'cooldown': 1e9, + }, + }, +} + +def trinket_friendly_name(name): + for item in trinket_map.items(): + if item[1] == name: + return item[0] + return None \ No newline at end of file diff --git a/ui/weights.py b/ui/weights.py new file mode 100644 index 0000000..62c6ad7 --- /dev/null +++ b/ui/weights.py @@ -0,0 +1,98 @@ +import dash +import pandas as pd +from dash import dash_table, dcc, html +import plotly.graph_objects as go +import numpy as np +from dash.dependencies import Input, Output, State +import dash_bootstrap_components as dbc +import tbc_cat_sim as ccs +import multiprocessing +import trinkets +import copy +import json +import base64 +import io + +weights_section = dbc.Col([ + html.H4('Stat Weights'), + html.Div([ + dbc.Row( + [ + dbc.Col(dbc.Button( + 'Calculate Weights', id='weight_button', n_clicks=0, + color='info' + ), width='auto'), + dbc.Col( + [ + dbc.FormGroup( + [ + dbc.Checkbox( + id='calc_mana_weights', + className='form-check-input', checked=False + ), + dbc.Label( + 'Include mana weights', + html_for='calc_mana_weights', + className='form-check-label' + ) + ], + check=True + ), + dbc.FormGroup( + [ + dbc.Checkbox( + id='epic_gems', + className='form-check-input', checked=True + ), + dbc.Label( + 'Assume Epic gems', + html_for='epic_gems', + className='form-check-label' + ) + ], + check=True + ), + ], + width='auto' + ) + ] + ), + html.Div( + 'Calculation will take several minutes.', + style={'fontWeight': 'bold'}, + ), + dcc.Loading( + children=[ + html.P( + children=[ + html.Strong( + 'Error: ', style={'fontSize': 'large'}, + id='error_str' + ), + html.Span( + 'Stat weight calculation requires the simulation ' + 'to be run with at least 20,000 replicates.', + style={'fontSize': 'large'}, id='error_msg' + ) + ], + style={'marginTop': '4%'}, + ), + dbc.Table([ + html.Thead(html.Tr([ + html.Th('Stat Increment'), html.Th('DPS Added'), + html.Th('Normalized Weight') + ])), + html.Tbody(id='stat_weight_table'), + ]), + html.Div( + html.A( + 'Seventy Upgrades Import Link', + href='https://seventyupgrades.com', target='_blank' + ), + id='import_link' + ) + ], + id='loading_4', type='default' + ), + ]), +], style={'marginLeft': '5%', 'marginBottom': '2.5%'}, width=4, xl=3)