MapleStory Calculator for Best Equipment Set

The main feature here is to try every combination on the alternate equipments and determine which equipment set would produce the highest average damage rate. Although it's designed for TMS (Taiwan MapleStory), many things are similar to other areas. I designed it for generic purpose, the player abilities, skills, and equipments data are all from JSON files, so if there is anything missing, you can easily modify it, or feel free to ask me.

Prepare the Data

Fill the data in player.json, monster.json, alternates.json. See sample files for more details.

Run

Run the cells in sequential order.

Lint

Export this notebook to maplestory_calculator.py by clicking File > Download as > Python (.py).

  • Pylint:
    pylint maplestory_calculator.py --disable=invalid-name,missing-docstring,line-too-long,trailing-whitespace,too-many-arguments

References


In [1]:
import codecs
import itertools
import json
import math
import pickle
import time
import unittest
import warnings

import numpy as np
from scipy.special import binom

Utilities


In [2]:
ITEM_KEYWORD = {
    'equipment': ['ring', 'pocket',
                  'pendant', 'weapon', 'belt',
                  'cap', 'fore_head', 'eye_acc', 'clothes', 'pants', 'shoes',
                  'ear_acc', 'shoulder', 'gloves', 'android',
                  'emblem', 'badge', 'medal', 'sub_weapon', 'cape', 'heart',
                  'totem', 
                  'cash_ring',
                  'cash_weapon',
                  'cash_cap', 'cash_fore_head', 'cash_eye_acc', 'cash_clothes', 'cash_pants', 'cash_shoes',
                  'cash_ear_acc', 'cash_gloves',
                  'cash_sub_weapon', 'cash_cape',
                  'pet',
                  'additional',
                  '__set_effects'
                 ],
    'skill': ['blessing', 'alliance_will', 'link', 'permanent', 'buff'],
    'misc': ['ammunition', 'title', 'monster_familiar', 'bits']
}
MAX_EQUIP_SIZE = {
    'ring': 4,
    'pendant': 2,
    'totem': 3,
    'cash_ring': 4,
    'pet': 3
}
STAT_KEYWORDS = ['name', 'type', 'category', 'superior', 'required_level', 'set',
                 'upgrades_available', 'enchants_available', 'scroll_available', 'upgrades_use', 'enchants_use',
                 'primary_stat', 'secondary_stat', 'all_stat_pct', 'primary_stat_pct', 'secondary_stat_pct',
                 'attack', 'attack_pct', 'damage_pct', 'boss_damage_pct',
                 'ignore_defense_pct', 'ignore_resistance_pct',
                 'critical_rate', 'critical_damage',
                 'final_damage_list', 'final_damage_boost'
                ]
# May be inaccurate, the values of higher star levels are calculated by exponential data fitting.
ENCHANT_TABLE = {
    'non_superior': {
        'all': {
            'stat': ([2] * 5) + ([3] * 10) + ([11] * 10)
        },
        'weapon': {
            'attack': ([0] * 15) + [8, 9, 9, 10, 11, 12, 13, 14, 15, 16]
        },
        'non_weapon_150': {
            'attack': ([0] * 15) + [9, 10, 11, 12, 13, 14, 16, 17, 19, 21]
        }
    },
    'superior': {
        'armor': {
            'stat': [19, 20, 22, 25, 29] + ([0] * 10),
            'attack': ([0] * 5) + [9, 10, 11, 12, 13, 15, 17, 19, 21, 23]
        }
    }
}

def deepcopy(obj):
    return pickle.loads(pickle.dumps(obj, -1))

def get_sum_value_list(list1, list2):
    list1 = deepcopy(list1)
    list2 = deepcopy(list2)
    len1 = len(list1)
    len2 = len(list2)
    if len1 < len2:
        (len1, len2) = (len2, len1)
        (list1, list2) = (list2, list1)
    diff_len = len1 - len2
    for _ in range(diff_len):
        list2.append([])
    combined = [None] * len1
    for index in range(len1):
        sum_value = sum(list1[index]) + sum(list2[index])
        combined[index] = [sum_value]
    return combined

def get_combined_stat(orig_stat, ext_stat):
    combined_stat = deepcopy(orig_stat)
    for stat_keyword, stat_value in ext_stat.items():
        if stat_keyword not in combined_stat:
            if stat_keyword != 'ignore_defense_pct':
                combined_stat[stat_keyword] = 0
            else:
                combined_stat[stat_keyword] = []
        if stat_keyword != 'ignore_defense_pct':
            combined_stat[stat_keyword] += stat_value
        else:
            combined_stat[stat_keyword].extend(stat_value)
    return combined_stat

def get_obj_to_items(obj):
    if not isinstance(obj, list):
        return [obj]
    else:
        return obj

def get_player_equip_to_array(player, excluded_keywords):
    equip_array = []
    player_equip = player['equipment']
    for equip_keyword in ITEM_KEYWORD['equipment']:
        if equip_keyword not in excluded_keywords:
            obj = player_equip[equip_keyword]
            equips = get_obj_to_items(obj)
            equip_array.extend(equips)
    return equip_array

def is_default_value(value):
    return not value

def is_equal_equip(equip1, equip2):
    for stat_keyword in STAT_KEYWORDS:
        if stat_keyword in equip1:
            equip1_value = equip1[stat_keyword]
            if stat_keyword in equip2:
                equip2_value = equip2[stat_keyword]
            else:
                equip2_value = None
            if (not is_default_value(equip1_value) and not equip2_value) or (equip1_value != equip2_value):
                return False
        elif stat_keyword in equip2:
            equip2_value = equip2[stat_keyword]
            if stat_keyword in equip1:
                equip1_value = equip1[stat_keyword]
            else:
                equip1_value = None
            if (not is_default_value(equip2_value) and not equip1_value) or (equip2_value != equip1_value):
                return False
    return True

def get_diff_equip_group(equip_group_old, equip_group_new):
    unchanged = []
    addition = []
    deletion = []
    for equip_old in equip_group_old:
        existed = False
        for equip_new in equip_group_new:
            if is_equal_equip(equip_old, equip_new):
                existed = True
                break
        if existed:
            unchanged.append(equip_old)
        else:
            deletion.append(equip_old)
    for equip_new in equip_group_new:
        existed = False
        for equip_old in equip_group_old:
            if is_equal_equip(equip_old, equip_new):
                existed = True
                break
        if not existed:
            addition.append(equip_new)
    return {
        'unchanged': unchanged,
        'addition': addition,
        'deletion': deletion
    }

def read_json_file(filename):
    with codecs.open(filename, mode='r', encoding='utf-8') as data_file:    
        data = json.load(data_file)
    return data

Formulas

The formula here calculates the skill output and is mainly composed by:

  • Actual damage
  • Skill damage
  • Total damage and boss damage
  • Criticial damage
  • Ignore DEF
  • Ignore elemental resistance
  • List of final damage
  • Final damage boost

In addition to the damage components written in StreategyWiki, by analyzing WZ file shown in this topic, there are also 4 extra damage components used, so I improvised the new formula:

$$ [ AD \times Skill\% \times (1 + TD\% + BD\%) \times (1 + CD\%) ] \times \\ (1 - [MD\% \times (1 - ID\%)]) \times \\ (1 - [MR \times (1 - IR\%)]) \times \\ [(1 + FD\%_1) \times (1 + FD\%_2) \times (1 + FD\%_3) \times \cdots \times (1 + FD\%_n)] + \\ FDB $$

These functions are not aware of the file format.

Legend

  • $ FD\%_x \ $: Final damage percentage increase, x is the index started from 1 ended at n.

For other legend see StrategyWiki > Output > Legend.


In [3]:
def get_stat_value(primary_stat, secondary_stat):
    return primary_stat * 4 + secondary_stat

def get_one_stat(base, mw, bonus, pct, all_pct, ability, card, hyper):
    base_stat = math.floor(base * (1.0 + (mw / 100.0)) + bonus)
    pct_stat = 1.0 + ((pct + all_pct) / 100.0)
    other_stat = ability + card + hyper
    return math.floor(base_stat * pct_stat + other_stat)

def get_attack(bonus, pct):
    base_attack = bonus
    pct_attack = 1.0 + (pct / 100.0)
    return math.floor(base_attack * pct_attack)

def get_max_shown_damage(weapon_mul, stat, attack, total_damage_pct, final_damage_boost):
    base_damage = math.floor(weapon_mul * stat * attack)
    pct_damage = 1.0 + (total_damage_pct / 100.0)
    return math.floor(base_damage * pct_damage / 100.0 + final_damage_boost)

def get_min_shown_damage(weapon_mul, stat, attack, total_damage_pct, mastery, final_damage_boost):
    base_damage = math.floor(weapon_mul * stat * attack)
    pct_damage = 1.0 + (total_damage_pct / 100.0)
    return math.floor(base_damage * pct_damage / 100.0 * (mastery / 100.0) + final_damage_boost)

def get_max_actual_damage(weapon_mul, stat, attack):
    return math.floor(weapon_mul * stat * (attack / 100.0))

def get_min_actual_damage(weapon_mul, stat, attack, mastery):
    return math.floor(weapon_mul * stat * (attack / 100.0) * (mastery / 100.0))

def get_actual_skill_damage(actual_damage, skill_damage_pct, total_damage_pct, boss_damage_pct, crit_damage_pct, 
                            monster_def_pct, ignore_def_pct, monster_resist, ignore_resist_pct,
                            final_damage_list, final_damage_boost, is_boss=True):
    if not is_boss:
        boss_damage_pct = 0
    base_damage = math.floor(actual_damage * (skill_damage_pct / 100.0) * 
                             (1.0 + ((total_damage_pct + boss_damage_pct) / 100.0)) * 
                             (1.0 + (crit_damage_pct / 100.0)))
    defense_mul = (1.0 - ((monster_def_pct / 100.0) * (1.0 - (ignore_def_pct / 100.0))))
    resis_mul = (1.0 + (monster_resist * (1.0 - (ignore_resist_pct / 100.0))))
    final_damage_prod = [(1.0 + (damage / 100.0)) for damage in final_damage_list]
    final_damage = np.prod(final_damage_prod)
    return math.floor(base_damage * defense_mul * resis_mul * final_damage + final_damage_boost)

def get_damage_per_second(damage, attack_speed, attack_count):
    return damage * attack_speed * attack_count

def get_time_to_kill(hp, damage_per_second):
    return hp / damage_per_second

Stats

Functions here calculate stats of a player, these functions are aware of the player file format.


In [4]:
def get_property_sum(player, stat_keyword):
    total = 0
    categories = ['equipment', 'skill', 'misc']
    for category_name in categories:
        player_category = player[category_name]
        for item_name in ITEM_KEYWORD[category_name]:
            if not item_name in player_category:
                continue
            obj = player_category[item_name]
            items = get_obj_to_items(obj)
            for item in items:
                if stat_keyword in item:
                    total += item[stat_keyword]
    return total

def get_property_inv_mul(player, stat_keyword):
    product = 1.0
    categories = ['equipment', 'skill', 'misc']
    for category_name in categories:
        player_category = player[category_name]
        for item_name in ITEM_KEYWORD[category_name]:
            if not item_name in player_category:
                continue
            obj = player_category[item_name]
            items = get_obj_to_items(obj)
            for item in items:
                if stat_keyword in item:
                    values = get_obj_to_items(item[stat_keyword])
                    for value in values:
                        product *= (1.0 - (value / 100.0))
    return product

def get_property_list_sum(player, stat_keyword):
    list_sum = []
    categories = ['equipment', 'skill', 'misc']
    for category_name in categories:
        player_category = player[category_name]
        for item_name in ITEM_KEYWORD[category_name]:
            if not item_name in player_category:
                continue
            obj = player_category[item_name]
            items = get_obj_to_items(obj)
            for item in items:
                if stat_keyword in item:
                    list_sum = get_sum_value_list(list_sum, item[stat_keyword])
    list_sum = [value[0] for value in list_sum]
    return list_sum

def get_equip_set_stat(equips, set_effect):
    set_stat = {}
    record = {}
    for equip in equips:
        if 'set' in equip:
            set_name = equip['set']
            if set_name:
                if set_name in record:
                    record[set_name] += 1
                else:
                    record[set_name] = 1
    for set_name, count in record.items():
        effects = set_effect[set_name]
        for set_index in range(1, count+1):
            set_index_str = str(set_index)
            if set_index_str in effects:
                effect = effects[set_index_str]
                set_stat = get_combined_stat(set_stat, effect)
    return set_stat

def get_bonus_one_stat(player, stat_type):
    if stat_type == 'primary':
        stat_keyword = 'primary_stat'
    elif stat_type == 'secondary':
        stat_keyword = 'secondary_stat'
    else:
        raise ValueError('Unexpected stat type')
    return get_property_sum(player, stat_keyword)

def get_all_stat_pct(player):
    return get_property_sum(player, 'all_stat_pct')

def get_one_stat_pct(player, stat_type):
    if stat_type == 'primary':
        stat_keyword = 'primary_stat_pct'
    elif stat_type == 'secondary':
        stat_keyword = 'secondary_stat_pct'
    else:
        raise ValueError('Unexpected stat type')
    return get_property_sum(player, stat_keyword)

def get_total_damage_pct(player):
    return (player['ability']['inner_ability']['damage_pct'] +
            player['ability']['character_card']['damage_pct'] +
            player['ability']['hyper_stat']['damage_pct'] +
            get_property_sum(player, 'damage_pct'))

def get_boss_damage_pct(player):
    return (player['ability']['inner_ability']['boss_damage_pct'] +
            player['ability']['character_card']['boss_damage_pct'] +
            player['ability']['hyper_stat']['boss_damage_pct'] +
            get_property_sum(player, 'boss_damage_pct'))

def get_critical_damage(player):
    critical_damage = (player['ability']['base']['critical_damage'] +
                           player['ability']['hyper_stat']['critical_damage'] +
                           get_property_sum(player, 'critical_damage'))
    return critical_damage

def get_ignore_defense_pct(player):
    inv_mul = ((1.0 - (player['ability']['inner_ability']['ignore_defense_pct'] / 100.0)) *
               (1.0 - (player['ability']['character_card']['ignore_defense_pct'] / 100.0)) *
               (1.0 - (player['ability']['hyper_stat']['ignore_defense_pct'] / 100.0)) *
               (1.0 - (player['ability']['trait']['ignore_defense_pct'] / 100.0)) *
               get_property_inv_mul(player, 'ignore_defense_pct'))
    return 100.0 - (100.0 * inv_mul)

def get_ignore_resistance_pct(player):
    return math.floor(player['ability']['trait']['ignore_resistance_pct'] +
                      get_property_sum(player, 'ignore_resistance_pct'))

def get_final_damage_list(player):
    return get_property_list_sum(player, 'final_damage_list')

def get_final_damage_boost(player):
    return (player['ability']['inner_ability']['damage_conversion'] + 
            player['ability']['character_card']['bonus_damage'] +
            get_property_sum(player, 'final_damage_boost'))

def inject_equip_set(player):
    excluded_keywords = ['__set_effects']
    equips = get_player_equip_to_array(player, excluded_keywords)
    set_effect = player['set']
    equip_set_stat = get_equip_set_stat(equips, set_effect)
    player['equipment']['__set_effects'] = equip_set_stat

def calc_one_stat(player, stat_type):
    if stat_type == 'primary':
        stat_keyword = 'primary_stat'
    elif stat_type == 'secondary':
        stat_keyword = 'secondary_stat'
    else:
        raise ValueError('Unexpected stat type')
    base = player['ability']['base'][stat_keyword]
    mw = player['skill']['maple_warrior']
    bonus = get_bonus_one_stat(player, stat_type)
    pct = get_one_stat_pct(player, stat_type)
    all_pct = get_all_stat_pct(player)
    ability = player['ability']['inner_ability'][stat_keyword]
    card = player['ability']['character_card'][stat_keyword]
    hyper = player['ability']['hyper_stat'][stat_keyword]
    return get_one_stat(base, mw, bonus, pct, all_pct, ability, card, hyper)

def calc_attack(player):
    bonus = (get_property_sum(player, 'attack') +
             player['ability']['inner_ability']['attack'])
    pct = get_property_sum(player, 'attack_pct')
    return get_attack(bonus, pct)

def calc_max_shown_damage(player, stat_value, attack):
    weapon_mul = player['ability']['weapon']['multiplier']
    total_damage_pct = get_total_damage_pct(player)
    final_damage_boost = get_final_damage_boost(player)
    return get_max_shown_damage(weapon_mul, stat_value, attack, total_damage_pct, final_damage_boost)

def calc_min_shown_damage(player, stat_value, attack):
    weapon_mul = player['ability']['weapon']['multiplier']
    total_damage_pct = get_total_damage_pct(player)
    mastery = player['skill']['mastery']
    final_damage_boost = get_final_damage_boost(player)
    return get_min_shown_damage(weapon_mul, stat_value, attack, total_damage_pct, mastery, final_damage_boost)

def calc_max_actual_damage(player, stat_value, attack):
    weapon_mul = player['ability']['weapon']['multiplier']
    return get_max_actual_damage(weapon_mul, stat_value, attack)

def calc_min_actual_damage(player, stat_value, attack):
    weapon_mul = player['ability']['weapon']['multiplier']
    mastery = player['skill']['mastery']
    return get_min_actual_damage(weapon_mul, stat_value, attack, mastery)

def calc_actual_skill_damage(player, monster, actual_damage, critical_damage_pct):
    skill_damage_pct = player['skill']['active_damage_pct']
    total_damage_pct = get_total_damage_pct(player)
    boss_damage_pct = get_boss_damage_pct(player)
    monster_def_pct = monster['ability']['defense']
    ignore_def_pct = get_ignore_defense_pct(player)
    monster_resist = monster['ability']['resistance']
    ignore_resist_pct = get_ignore_resistance_pct(player)
    final_damage_list = get_final_damage_list(player)
    final_damage_boost = get_final_damage_boost(player)
    is_boss = (monster['type'] == 'boss')
    return get_actual_skill_damage(actual_damage, skill_damage_pct, total_damage_pct, boss_damage_pct, 
                                   critical_damage_pct,
                                   monster_def_pct, ignore_def_pct, monster_resist, ignore_resist_pct,
                                   final_damage_list, final_damage_boost, is_boss)

def calc_critical_rate(player):
    critical_rate = (player['ability']['base']['critical_rate'] +
                     player['ability']['hyper_stat']['critical_rate'] +
                     get_property_sum(player, 'critical_rate'))
    return min(100, critical_rate)

def calc_damage_per_second(player, damage):
    attack_speed = player['ability']['weapon']['attack_speed']
    attack_count = player['skill']['active_attack_count']
    return get_damage_per_second(damage, attack_speed, attack_count)

def calc_output(player, monster, debug=False, copy=True):
    if copy:
        player_copy = deepcopy(player)
    else:
        player_copy = player
    inject_equip_set(player_copy)
    primary_stat = calc_one_stat(player_copy, 'primary')
    secondary_stat = calc_one_stat(player_copy, 'secondary')
    attack = calc_attack(player_copy)
    stat_value = get_stat_value(primary_stat, secondary_stat)
    max_actual_damage = calc_max_actual_damage(player_copy, stat_value, attack)
    min_actual_damage = calc_min_actual_damage(player_copy, stat_value, attack)
    avg_actual_damage = (max_actual_damage + min_actual_damage) / 2.0
    critical_rate = calc_critical_rate(player_copy)
    critical_damage = get_critical_damage(player_copy)
    actual_skill_damage_avg_crit = calc_actual_skill_damage(player_copy, monster, avg_actual_damage, critical_damage)
    actual_skill_damage_avg_no_crit = calc_actual_skill_damage(player_copy, monster, avg_actual_damage, 0)
    avg_skill_damage = ((critical_rate / 100.0) * actual_skill_damage_avg_crit +
                        (1.0 - (critical_rate / 100.0)) * actual_skill_damage_avg_no_crit)
    avg_damage_per_second = calc_damage_per_second(player_copy, avg_skill_damage)
    if debug:
        total_damage_pct = get_total_damage_pct(player_copy)
        boss_damage_pct = get_boss_damage_pct(player_copy)
        ignore_defense_pct = get_ignore_defense_pct(player_copy)
        ignore_resistance_pct = get_ignore_resistance_pct(player_copy)
        final_damage_list = get_final_damage_list(player_copy)
        final_damage_boost = get_final_damage_boost(player_copy)
        max_shown_damage = calc_max_shown_damage(player_copy, stat_value, attack)
        min_shown_damage = calc_min_shown_damage(player_copy, stat_value, attack)
        actual_skill_damage_max_crit = calc_actual_skill_damage(player_copy, monster, max_actual_damage, 
                                                                critical_damage)
        actual_skill_damage_min_crit = calc_actual_skill_damage(player_copy, monster, min_actual_damage, 
                                                                critical_damage)
        actual_skill_damage_max_no_crit = calc_actual_skill_damage(player_copy, monster, max_actual_damage, 0)
        actual_skill_damage_min_no_crit = calc_actual_skill_damage(player_copy, monster, min_actual_damage, 0)
        print('primary_stat={}, secondary_stat={}'.format(primary_stat, secondary_stat))
        print('attack={}'.format(attack))
        print('stat_value={}'.format(stat_value))
        print('total_damage_pct={}'.format(total_damage_pct))
        print('boss_damage_pct={}'.format(boss_damage_pct))
        print('ignore_defense_pct={}'.format(ignore_defense_pct))
        print('ignore_resistance_pct={}'.format(ignore_resistance_pct))
        print('final_damage_list={}'.format(final_damage_list))
        print('final_damage_boost={}'.format(final_damage_boost))
        print('critical_rate={}'.format(critical_rate))
        print('critical_damage={}'.format(critical_damage))
        print('max_shown_damage={}, min_shown_damage={}'.format(max_shown_damage, min_shown_damage))
        print('max_actual_damage={}, min_actual_damage={}'.format(max_actual_damage, min_actual_damage))
        print('actual_skill_damage_max_crit={}'.format(actual_skill_damage_max_crit))
        print('actual_skill_damage_min_crit={}'.format(actual_skill_damage_min_crit))
        print('actual_skill_damage_max_no_crit={}'.format(actual_skill_damage_max_no_crit))
        print('actual_skill_damage_min_no_crit={}'.format(actual_skill_damage_min_no_crit))
        print('actual_skill_damage_avg_crit={}'.format(actual_skill_damage_avg_crit))
        print('actual_skill_damage_avg_no_crit={}'.format(actual_skill_damage_avg_no_crit))
        print('avg_skill_damage={}'.format(avg_skill_damage))
        print('avg_damage_per_second={}'.format(avg_damage_per_second))
    return avg_damage_per_second

def calc_time_to_kill(monster, damage_per_second, efficiency=1.0):
    hp = monster['ability']['hp']
    return get_time_to_kill(hp, damage_per_second * efficiency)

Enhancement

Functions here calculate upgraded and enchanted equips and are used in calculating best equipment set.


In [5]:
def get_upgraded_equip(equip, scroll):
    if 'upgrades_available' not in equip or not equip['scroll_available']:
        return equip
    
    upgrades_use = equip['upgrades_use']
    upgrades_available = equip['upgrades_available']
    if upgrades_use > upgrades_available:
        raise ValueError('Exceeded range of upgrades')
    
    scroll_available = equip['scroll_available']
    scroll_use = scroll[scroll_available]
    for _ in range(upgrades_use):
        equip = get_combined_stat(equip, scroll_use)
        equip['upgrades_available'] -= 1
    return equip

def get_enchanted_result(equip, enchant_level):
    if equip['category'] not in ['weapon', 'armor', 'accessory']:
        raise ValueError('Unrecognized equipment type')
    
    enchant_result = {
        'primary_stat': 0,
        'secondary_stat': 0,
        'attack': 0
    }
    equip_category = equip['category']
    is_superior = equip['superior']
    req_level = equip['required_level']
    attack = equip['attack']
    for level in range(1, enchant_level+1):
        if not is_superior:
            if level >= 1 and level <= 15:
                enchant_result['primary_stat'] += ENCHANT_TABLE['non_superior']['all']['stat'][(level - 1)]
                enchant_result['secondary_stat'] += ENCHANT_TABLE['non_superior']['all']['stat'][(level - 1)]
                if equip_category == 'weapon':
                    enchant_result['attack'] += (math.floor(attack / 50.0) + 1)
            elif level >= 16 and level <= 25:
                enchant_result['primary_stat'] += ENCHANT_TABLE['non_superior']['all']['stat'][(level - 1)]
                enchant_result['secondary_stat'] += ENCHANT_TABLE['non_superior']['all']['stat'][(level - 1)]
                if equip_category == 'armor' or equip_category == 'accessory':
                    increase_attack = ENCHANT_TABLE['non_superior']['non_weapon_150']['attack'][(level - 1)]
                    adjust_by_level = (math.floor(req_level / 10) - 15)
                    enchant_result['attack'] += (increase_attack + adjust_by_level)
                elif equip_category == 'weapon':
                    enchant_result['attack'] += ENCHANT_TABLE['non_superior']['weapon']['attack'][(level - 1)]
            else:
                raise ValueError('Enchant level out of range')
        else:
            if level >= 1 and level <= 15:
                enchant_result['primary_stat'] += ENCHANT_TABLE['superior']['armor']['stat'][(level - 1)]
                enchant_result['secondary_stat'] += ENCHANT_TABLE['superior']['armor']['stat'][(level - 1)]
                enchant_result['attack'] += ENCHANT_TABLE['superior']['armor']['attack'][(level - 1)]
            else:
                raise ValueError('Enchant level out of range')
    return enchant_result

def get_enchanted_equip(equip):
    if 'enchants_available' not in equip:
        return equip
    
    enchant_level = equip['enchants_use']
    if enchant_level > equip['enchants_available']:
        raise ValueError('Exceeded range of enchant')
    
    enchant_result = get_enchanted_result(equip, enchant_level)
    return get_combined_stat(equip, enchant_result)

Best Equipment Set

Functions here replace player equipment set by another, try to determine which equipment set would produce the highest damage.


In [6]:
def prepare_alternates_calc(player_copy, alternates_copy, remove_orig_equips):
    orig_player_equip = player_copy['equipment']
    if remove_orig_equips:
        for equip_keyword in ITEM_KEYWORD['equipment']:
            orig_player_equip[equip_keyword] = []
        player_copy['set'] = {}
    else:
        equip_alternates = alternates_copy['equipment_alternates']
        excluded_keywords = ['__set_effects']
        for equip_keyword in ITEM_KEYWORD['equipment']:
            if not equip_keyword in excluded_keywords:
                if equip_keyword not in equip_alternates:
                    equip_alternates[equip_keyword] = []
                orig_equip_group = get_obj_to_items(orig_player_equip[equip_keyword])
                equip_alternates[equip_keyword].extend(orig_equip_group)
    player_copy['set'].update(alternates_copy['set'])

def get_equip_select_size(player, equip_category):
    if equip_category in MAX_EQUIP_SIZE:
        player_equip_size = player['equipment_size']
        if equip_category in player_equip_size:
            max_player_equip_size = player_equip_size[equip_category]
            select_size = min(MAX_EQUIP_SIZE[equip_category], max_player_equip_size)
        else:
            select_size = MAX_EQUIP_SIZE[equip_category]
    else:
        select_size = 1
    return select_size

def get_size_of_all_equip_set(player, alternates, remove_orig_equips):
    size = 1
    player_copy = deepcopy(player)
    alternates_copy = deepcopy(alternates)
    prepare_alternates_calc(player_copy, alternates_copy, remove_orig_equips)
    equip_alternates = alternates_copy['equipment_alternates']
    for equip_category, available_equips in equip_alternates.items():
        available_equips_size = len(available_equips)
        if available_equips_size == 0:
            continue
        
        select_size = get_equip_select_size(player, equip_category)
        if available_equips_size < select_size:
            available_equips_size = select_size
        combination_size = int(binom(available_equips_size, select_size))
        size *= combination_size
    return size

def get_all_equip_set(player, alternates, copy=False):
    equip_list_of_groups = []
    equip_alternates = alternates['equipment_alternates']
    for equip_category, available_equips in equip_alternates.items():
        available_equips_size = len(available_equips)
        if available_equips_size == 0:
            continue
        
        select_size = get_equip_select_size(player, equip_category)
        if copy:
            available_equips_copy = deepcopy(available_equips)
        else:
            available_equips_copy = available_equips
        empty_size = max(0, select_size - available_equips_size)
        empty_equip = {
            '__equip_category': equip_category
        }
        for _ in range(empty_size):
            available_equips_copy.append(empty_equip)
        combinations = itertools.combinations(available_equips_copy, select_size)
        equip_groups = [list(combination) for combination in combinations]
        equip_list_of_groups.append(equip_groups)
    return itertools.product(*equip_list_of_groups)

def check_valid_equip_set(equip_set):
    record_clothes_pants = []
    for equip_group in equip_set:
        for equip in equip_group:
            equip_category = equip['__equip_category']
            if equip_category == 'clothes':
                if 'type' in equip:
                    equip_type = equip['type']
                    record_clothes_pants.append(equip_type)
            elif equip_category == 'pants':
                if 'name' in equip and equip['name']:
                    record_clothes_pants.append('pants')
    if 'overall' in record_clothes_pants and 'pants' in record_clothes_pants:
        return False
    return True

def get_enhanced_equip(equip, scroll):
    equip = get_upgraded_equip(equip, scroll)
    equip = get_enchanted_equip(equip)
    return equip

def inject_equip_category(alternates):
    equip_alternates = alternates['equipment_alternates']
    for equip_category, available_equips in equip_alternates.items():
        for available_equip in available_equips:
            items = get_obj_to_items(available_equip)
            for item in items:
                item['__equip_category'] = equip_category

def enhance_equip_set(player_equip, equip_set, scroll):
    for equip_group in equip_set:
        if not equip_group:
            continue
        enhanced_equip_group = []
        equip_category = equip_group[0]['__equip_category']
        for equip in equip_group:
            enhanced_equip = get_enhanced_equip(equip, scroll)
            enhanced_equip_group.append(enhanced_equip)
        player_equip[equip_category] = enhanced_equip_group

def calc_best_equip_set(player, monster, alternates, remove_orig_equips):
    best_player = None
    best_equip_set = None
    best_avg_damage_per_second = 0
    player_orig_copy = deepcopy(player)
    alternates_copy = deepcopy(alternates)
    prepare_alternates_calc(player_orig_copy, alternates_copy, remove_orig_equips)
    inject_equip_category(alternates_copy)
    all_equip_set = get_all_equip_set(player_orig_copy, alternates_copy)
    scroll = alternates_copy['scroll']
    
    for equip_set in all_equip_set:
        if not check_valid_equip_set(equip_set):
            continue
        player_copy = deepcopy(player_orig_copy)
        player_equip = player_copy['equipment']
        enhance_equip_set(player_equip, equip_set, scroll)
        avg_damage_per_second = calc_output(player_copy, monster, copy=False)
        if avg_damage_per_second > best_avg_damage_per_second or not best_equip_set:
            best_player = player_copy
            best_equip_set = equip_set
            best_avg_damage_per_second = avg_damage_per_second
    return (best_player, best_equip_set, best_avg_damage_per_second)

Unit Test

This simple unit test checks if the old player still produce the same damage rate in case that I suddenly screw something up.


In [7]:
class OldPlayerTestCase(unittest.TestCase):
    def test_player_damage(self):
        my_player = read_json_file('player_old.json')
        the_monster = read_json_file('monster_chaos_vellum.json')
        avg_damage_per_second = calc_output(my_player, the_monster)
        efficiency = 1.0
        time_to_kill = calc_time_to_kill(the_monster, avg_damage_per_second, efficiency)
        self.assertAlmostEqual(avg_damage_per_second, 389241624.38, places=2)
        self.assertAlmostEqual(time_to_kill, 308.29, places=2)
    def test_best_equip_set(self):
        my_player = read_json_file('player_old.json')
        the_monster = read_json_file('monster_chaos_vellum.json')
        alternates = read_json_file('alternates_small.json')
        remove_orig_equips = True
        (best_player, best_equip_set, best_avg_damage_per_second) = calc_best_equip_set(my_player, the_monster, alternates, 
                                                                                        remove_orig_equips)
        new_avg_damage_per_second = calc_output(best_player, the_monster)
        self.assertAlmostEqual(best_avg_damage_per_second, new_avg_damage_per_second, places=2)
        self.assertAlmostEqual(best_avg_damage_per_second, 40616480.24, places=2)
        for equip_group in best_equip_set:
            for equip in equip_group:
                equip_category = equip['__equip_category']
                self.assertIn(equip_category, ITEM_KEYWORD['equipment'])

suite = unittest.TestLoader().loadTestsFromTestCase(OldPlayerTestCase)
unittest.TextTestRunner().run(suite)


..
----------------------------------------------------------------------
Ran 2 tests in 0.154s

OK
Out[7]:
<unittest.runner.TextTestResult run=2 errors=0 failures=0>

Calculate Player Damage Rate


In [8]:
def calc_my_player():
    '''Calculate player's average damage rate against the target monster.'''
    my_player = read_json_file('player.json')
    the_monster = read_json_file('monster.json')
    avg_damage_per_second = calc_output(my_player, the_monster, debug=True)
    efficiency = 1.0
    time_to_kill = calc_time_to_kill(the_monster, avg_damage_per_second, efficiency)
    print()
    print('name = {}, job = {}, level = {}'.format(my_player['info']['name'], my_player['info']['job'], 
                                                   my_player['info']['level']))
    print('average damage per second = {:.2f} (damage/second)'.format(avg_damage_per_second))
    print('time to kill = {:.2f} (seconds)'.format(time_to_kill))

calc_my_player()


primary_stat=13921, secondary_stat=3143
attack=3472
stat_value=58827
total_damage_pct=134
boss_damage_pct=291
ignore_defense_pct=94.454478066412
ignore_resistance_pct=2
final_damage_list=[8, 0, 300, 0]
final_damage_boost=0
critical_rate=100
critical_damage=71
max_shown_damage=7169081, min_shown_damage=6093719
max_actual_damage=3063710, min_actual_damage=2604153
actual_skill_damage_max_crit=50516421
actual_skill_damage_min_crit=42938949
actual_skill_damage_max_no_crit=29541766
actual_skill_damage_min_no_crit=25110497
actual_skill_damage_avg_crit=46727686
actual_skill_damage_avg_no_crit=27326131
avg_skill_damage=46727686.0
avg_damage_per_second=389241624.38

name = ShawnChang, job = 槍神 (Corsair), level = 215
average damage per second = 389241624.38 (damage/second)
time to kill = 308.29 (seconds)

Calculate Best Equipment Set


In [9]:
def get_equip_set_to_dict(equip_set):
    if not equip_set:
        return {}
    
    equip = {}
    excluded_keywords = ['__set_effects']
    for equip_keyword in ITEM_KEYWORD['equipment']:
        if equip_keyword in excluded_keywords:
            continue
        for equip_group in equip_set:
            equip_category = equip_group[0]['__equip_category']
            if equip_category == equip_keyword:
                equip[equip_keyword] = equip_group
                break
    return equip

def get_removed_orig_equips_player(player, remove_orig_equips):
    if remove_orig_equips:
        player_copy = deepcopy(player)
        for equip_keyword in ITEM_KEYWORD['equipment']:
            player_copy['equipment'][equip_keyword] = []
    else:
        player_copy = player
    return player_copy

def print_equip_set_diff(player, best_equip_dict):
    player_equip = player['equipment']
    excluded_keywords = ['__set_effects']
    for equip_keyword in ITEM_KEYWORD['equipment']:
        if equip_keyword in excluded_keywords:
            continue
        if equip_keyword in best_equip_dict:
            best_equip_group = best_equip_dict[equip_keyword]
            orig_equip_group = get_obj_to_items(player_equip[equip_keyword])
            diff = get_diff_equip_group(orig_equip_group, best_equip_group)
            for deleted_equip in diff['deletion']:
                equip_name = deleted_equip['name'] if 'name' in deleted_equip else None
                print('- {} = {}'.format(equip_keyword, equip_name))
            for added_equip in diff['addition']:
                equip_name = added_equip['name'] if 'name' in added_equip else None
                print('+ {} = {}'.format(equip_keyword, equip_name))
            for unchanged_equip in diff['unchanged']:
                equip_name = unchanged_equip['name'] if 'name' in unchanged_equip else None
                print('  {} = {}'.format(equip_keyword, equip_name))

def find_my_best_equip_set():
    '''Find the best alternate equipment set with highest average damage rate.'''
    start_time = time.perf_counter()
    
    # Calculate player's average damage with the original equipment set
    my_player = read_json_file('player.json')
    the_monster = read_json_file('monster.json')
    orig_avg_damage_per_second = calc_output(my_player, the_monster)
    
    # Find the best alternate equipment set
    alternates = read_json_file('alternates.json')
    remove_orig_equips = True
    warn_size_of_all_equip_set = 10 ** 4
    size_of_all_equip_set = get_size_of_all_equip_set(my_player, alternates, remove_orig_equips)
    if size_of_all_equip_set > warn_size_of_all_equip_set:
        warnings.warn('Run time might be very long: {} sets of equipments'.format(size_of_all_equip_set))
    (best_player, best_equip_set, best_avg_damage_per_second) = calc_best_equip_set(my_player, the_monster, alternates, 
                                                                                    remove_orig_equips)
    print('description = {}'.format(alternates['info']['description']))
    print('best average damage per second = {:.2f} (damage/second)'.format(best_avg_damage_per_second))
    
    # Calculate the increase ratio with the best alternate equipment set
    increase_pct = ((best_avg_damage_per_second / orig_avg_damage_per_second) - 1.0) * 100.0
    print('increase={:.2f}%'.format(increase_pct))
    
    # Print equipment set difference
    my_player = get_removed_orig_equips_player(my_player, remove_orig_equips)
    best_equip = get_equip_set_to_dict(best_equip_set)
    print()
    print_equip_set_diff(my_player, best_equip)
    
    # Print player with the new equipment set debug info
    print()
    calc_output(best_player, the_monster, debug=True)
    
    # Print elapsed time
    elapsed_time = time.perf_counter() - start_time
    print()
    print('run time={:.2f} (seconds)'.format(elapsed_time))

find_my_best_equip_set()


C:\Users\Shawn\Anaconda3\lib\site-packages\ipykernel\__main__.py:61: UserWarning: Run time might be very long: 414720 sets of equipments
description = 
 Possible best equips.
 Potentials: 
 - Normal equips: 30% primary stat. 
 - Weapons: 13% (151up), 12% (71up) attack, 40% boss damage, 40% ignore DEF. 
 - Gloves: 16% (151up), 15% (71up) min and max critical damage. 
 - Emblems: 12% attack, 40% ignore DEF, 40% ignore DEF. 
 - Sub weapons: 12% attack, 40% boss damage, 40% ignore DEF. 
 No additional potentials. Use the best scrolls. Non-superior equips enchanted to 20 stars. Superior equips enchanted to 10 stars. Apply 1 hammers.
best average damage per second = 1015275102.45 (damage/second)
increase=160.83%

+ ring = 天上的氣息
+ ring = 頂級培羅德戒指 (Superior Gollux Ring)
+ ring = 強力的魔性戒指
+ ring = 魔性的戒指
+ pocket = 時間逆行
+ pendant = 頂級培羅德烙印墜飾 (Superior Engraved Gollux Pendant)
+ weapon = 航海師手槍 (AbsoLab Point Gun)
+ belt = 頂級培羅德烙印皮帶 (Superior Engraved Gollux Belt)
+ cap = 航海師海盜帽 (AbsoLab Pirate Fedora)
+ fore_head = 波賽頓紋身 (Sweetwater Tattoo)
+ eye_acc = 波賽頓眼鏡 (Sweetwater Glasses)
+ clothes = 滅龍騎士盔甲 (Perfected Dragon Slayer Knight Armor)
+ pants = None
+ shoes = 航海師海盜鞋 (AbsoLab Pirate Shoes)
+ ear_acc = 頂級培羅德耳環 (Superior Gollux Earrings)
+ shoulder = 航海師海盜肩膀 (AbsoLab Pirate Shoulder)
+ gloves = 航海師海盜手套 (AbsoLab Pirate Gloves)
+ emblem = 黃金楓葉腰帶 (Gold Maple Leaf Emblem)
+ badge = 黑翼胸章
+ medal = 飛龍在天派門徒 (Gold Dragon Student)
+ sub_weapon = 獵鷹眼 (Falcon Eye)
+ cape = 塔蘭特亞泰爾斗篷 (Tyrant Altair Cloak)
+ heart = 女武神之心 (Outlaw Heart)
+ totem = 小筱精靈圖騰
+ totem = 小筱精靈圖騰
+ totem = 小筱精靈圖騰
+ pet = 10捲寵物裝備 (Pet Equip, 10 Updates Available)
+ pet = 10捲寵物裝備 (Pet Equip, 10 Updates Available)
+ pet = 10捲寵物裝備 (Pet Equip, 10 Updates Available)

primary_stat=21194, secondary_stat=2601
attack=4964
stat_value=87377
total_damage_pct=122
boss_damage_pct=296
ignore_defense_pct=98.29893575717632
ignore_resistance_pct=2
final_damage_list=[8, 0, 300, 0]
final_damage_boost=0
critical_rate=100
critical_damage=87
max_shown_damage=14443522, min_shown_damage=12276994
max_actual_damage=6506091, min_actual_damage=5530177
actual_skill_damage_max_crit=131764076
actual_skill_damage_min_crit=111999457
actual_skill_damage_max_no_crit=70462072
actual_skill_damage_min_no_crit=59892756
actual_skill_damage_avg_crit=121881765
actual_skill_damage_avg_no_crit=65177415
avg_skill_damage=121881765.0
avg_damage_per_second=1015275102.45

run time=719.02 (seconds)

In [ ]: