The following code is for a streamlit website that optimizes pokemon fusions for a fan game called Pokemon Infinite Fusion, the idea is that the user can add certain weights to the base stats of the fusion, typing, ability and move filtering and then the optimizer will find the highest scoring fusions for that combination. There is, however a glaring issue with it, sometimes it will recommend fusions that use pre-evolved pokemon over their fully-evolved counterparts, and that should almost never happen, because fully-evolved pokemon have generally higher values on each stat. I think that when the optimizer is filtering out pokemons off the dataset to limit the number of fusions to be calculated, something has to be off to recommend a solution (using a pre-evolved pokemon form) over a mathematically higher one (using a fully-evolved pokemon form that has higher stats). The dataset doesn't include evolution information, but it should not be needed, as if the filter worked correctly, the higher stats of a fully-evolved pokemon should already appear over the pre-evolved forms anyways.

import requests
import json
import os
import streamlit as st
import pandas as pd
import numpy as np

# Function to get the list of all Pokémon
@st.cache_data
def get_pokemon_list():
    url = 'https://pokeapi.co/api/v2/pokemon?limit=809'  # Adjust the limit if needed
    response = requests.get(url)
    data = response.json()
    return data['results']

# Function to get detailed data for a single Pokémon
def get_pokemon_data(pokemon_url):
    response = requests.get(pokemon_url)
    pokemon_data = response.json()
    return pokemon_data

# Function to fetch and store Pokémon data locally
def fetch_and_store_pokemon_data(json_filename):
    st.info("Fetching Pokémon data from the API...")
    pokemon_list = get_pokemon_list()
    all_pokemon_data = []

    progress_bar = st.progress(0)
    total_pokemon = len(pokemon_list)

    # Fetch data for each Pokémon
    for index, pokemon in enumerate(pokemon_list, start=1):
        pokemon_name = pokemon['name']
        pokemon_url = pokemon['url']
        pokemon_data = get_pokemon_data(pokemon_url)

        # Get types
        types = [t['type']['name'] for t in pokemon_data['types']]

        # Get base stats
        stats = {stat['stat']['name']: stat['base_stat'] for stat in pokemon_data['stats']}

        # Get abilities
        abilities = [a['ability']['name'] for a in pokemon_data['abilities']]

        # Get moves
        moves = [move['move']['name'] for move in pokemon_data['moves']]

        # Create a Pokémon entry
        pokemon_entry = {
            'name': pokemon_name,
            'types': types,
            'stats': stats,
            'abilities': abilities,
            'moves': moves
        }

        all_pokemon_data.append(pokemon_entry)

        # Update progress bar
        progress_bar.progress(index / total_pokemon)

    # Save data to JSON file
    with open(json_filename, 'w') as json_file:
        json.dump(all_pokemon_data, json_file, indent=4)

    st.success(f"Data saved to '{json_filename}'")
    return all_pokemon_data

# Function to load data from the local JSON file
@st.cache_data
def load_pokemon_data(json_filename):
    with open(json_filename, 'r') as json_file:
        all_pokemon_data = json.load(json_file)
    return all_pokemon_data

# Function to load or fetch Pokémon data
def get_all_pokemon_data(json_filename):
    if os.path.exists(json_filename):
        all_pokemon_data = load_pokemon_data(json_filename)
        # Check if 'moves' is present in the data; if not, refetch data
        if 'moves' not in all_pokemon_data[0]:
            st.warning("Existing data does not include Pokémon moves. Refetching data...")
            all_pokemon_data = fetch_and_store_pokemon_data(json_filename)
    else:
        all_pokemon_data = fetch_and_store_pokemon_data(json_filename)
    return all_pokemon_data

def main():
    st.title("Pokémon Fusion Optimizer")

    # Load data
    json_filename = 'pokemon_data.json'
    all_pokemon_data = get_all_pokemon_data(json_filename)

    # Convert data to DataFrame
    data_rows = []
    for pkmn in all_pokemon_data:
        row = {
            'Name': pkmn['name'],
            'Types': pkmn['types'],
            'Abilities': pkmn['abilities'],
            'Moves': pkmn['moves'],
        }
        # Add stats
        for stat_name, stat_value in pkmn['stats'].items():
            row[stat_name] = stat_value
        # Calculate Base Stat Total (BST)
        row['BST'] = sum(pkmn['stats'].values())
        data_rows.append(row)
    df = pd.DataFrame(data_rows)
    df.set_index('Name', inplace=True)

    # User input for stat weights
    st.sidebar.header("Stat Weights")
    stat_columns = ['hp', 'attack', 'defense', 'special-attack', 'special-defense', 'speed']
    stat_weights = {}
    for stat in stat_columns:
        stat_weights[stat] = st.sidebar.slider(f"Weight for {stat.replace('-', ' ').title()}:", min_value=0.0, max_value=1.0, value=0.5, step=0.01)

    # Max BST filter for base Pokémon
    st.sidebar.header("Base Pokémon Filters")
    max_bst = st.sidebar.number_input("Maximum BST for Base Pokémon:", min_value=0, max_value=720, value=720, step=1)

    # Stat filters for base Pokémon
    st.sidebar.write("## Base Stat Filters")
    stat_filters = {}
    for stat in stat_columns:
        min_val = st.sidebar.number_input(f"Minimum {stat.replace('-', ' ').title()}:", min_value=0, max_value=255, value=0, step=1, key=f"min_{stat}")
        max_val = st.sidebar.number_input(f"Maximum {stat.replace('-', ' ').title()}:", min_value=0, max_value=255, value=255, step=1, key=f"max_{stat}")
        stat_filters[stat] = (min_val, max_val)

    # Type filters for base Pokémon
    st.sidebar.write("## Type Filters for Base Pokémon")
    types_list = sorted({t.title() for types in df['Types'] for t in types})
    selected_types = st.sidebar.multiselect("Select Base Pokémon Types:", types_list)

    # Number of top fusions to display
    st.sidebar.header("Fusion Options")
    top_k = st.sidebar.number_input("Number of Top Fusions to Display:", min_value=1, max_value=500, value=50, step=1)

    # Type filters for fusions (optional)
    st.sidebar.write("## Fusion Type Filters (Optional)")
    fusion_type_inputs = st.sidebar.multiselect("Select Desired Fusion Types (leave empty for no filter):", types_list)

    # Ability filters for fusions (optional)
    st.sidebar.write("## Fusion Ability Filters (Optional)")
    # Collect all abilities from the dataset
    abilities_list = sorted({ability.title() for abilities in df['Abilities'] for ability in abilities})
    fusion_ability_inputs = st.sidebar.multiselect("Select Desired Fusion Abilities (leave empty for no filter):", abilities_list)

    # Move filters for fusions (optional)
    st.sidebar.write("## Fusion Moveset Filters (Optional)")
    # Collect moves from the dataset
    moves_list = sorted({move.title() for moves in df['Moves'] for move in moves})
    fusion_move_inputs = st.sidebar.multiselect("Select Required Moves (leave empty for no filter):", moves_list)

    # Button to compute fusions
    if st.sidebar.button("Compute Best Fusions"):
        with st.spinner("Computing best fusions..."):
            # Filter base Pokémon based on BST
            filtered_df = df[df['BST'] <= max_bst]

            # Apply stat filters to base Pokémon
            for stat in stat_columns:
                min_val, max_val = stat_filters[stat]
                filtered_df = filtered_df[(filtered_df[stat] >= min_val) & (filtered_df[stat] <= max_val)]

            # Apply type filters to base Pokémon
            if selected_types:
                filtered_df = filtered_df[filtered_df['Types'].apply(lambda types: any(t.title() in selected_types for t in types))]

            if filtered_df.empty:
                st.warning("No base Pokémon match the specified filters.")
                return

            # Precompute head and body contributions for all filtered Pokémon
            head_contributions = {}
            body_contributions = {}
            for name, row in filtered_df.iterrows():
                stats = row[stat_columns]
                types = row['Types']
                abilities = row['Abilities']
                moves = set(row['Moves'])

                # Head contributions
                head_stats = {
                    'hp': (2/3) * stats['hp'],
                    'attack': (1/3) * stats['attack'],
                    'defense': (1/3) * stats['defense'],
                    'special-attack': (2/3) * stats['special-attack'],
                    'special-defense': (1/3) * stats['special-defense'],
                    'speed': (2/3) * stats['speed']
                }
                # Compute weighted head score
                head_score = sum(head_stats[stat] * stat_weights[stat] for stat in stat_columns)
                head_contributions[name] = {'stats': head_stats, 'score': head_score, 'types': types, 'abilities': abilities, 'moves': moves}

                # Body contributions
                body_stats = {
                    'hp': (1/3) * stats['hp'],
                    'attack': (2/3) * stats['attack'],
                    'defense': (2/3) * stats['defense'],
                    'special-attack': (1/3) * stats['special-attack'],
                    'special-defense': (2/3) * stats['special-defense'],
                    'speed': (1/3) * stats['speed']
                }
                # Compute weighted body score
                body_score = sum(body_stats[stat] * stat_weights[stat] for stat in stat_columns)
                body_contributions[name] = {'stats': body_stats, 'score': body_score, 'types': types, 'abilities': abilities, 'moves': moves}

            # Generate all possible head-body pairs
            heads_list = list(head_contributions.keys())
            bodies_list = list(body_contributions.keys())

            # Limit the number of pairs to process to prevent overloading
            max_pairs = 50000  # Adjust as needed
            total_pairs = len(heads_list) * len(bodies_list)
            if total_pairs > max_pairs:
                st.info(f"Processing a sample of {max_pairs} out of {total_pairs} possible fusion pairs to maintain performance.")
                np.random.seed(42)  # For reproducibility
                sample_heads = np.random.choice(heads_list, size=min(len(heads_list), int(np.sqrt(max_pairs))), replace=False)
                sample_bodies = np.random.choice(bodies_list, size=min(len(bodies_list), int(np.sqrt(max_pairs))), replace=False)
            else:
                sample_heads = heads_list
                sample_bodies = bodies_list

            possible_pairs = []

            # Prepare desired fusion types (if any)
            desired_fusion_types = set([t.title() for t in fusion_type_inputs])

            # Prepare desired fusion abilities (if any)
            desired_fusion_abilities = set([a.lower() for a in fusion_ability_inputs])

            # Prepare desired fusion moves (if any)
            desired_fusion_moves = set([m.lower() for m in fusion_move_inputs])

            # Generate all possible head-body pairs
            for head_name in sample_heads:
                head_data = head_contributions[head_name]
                head_types = head_data['types']
                fusion_t1 = head_types[0].title() if head_types else ''
                for body_name in sample_bodies:
                    body_data = body_contributions[body_name]
                    body_types = body_data['types']
                    # Fusion T2 calculation
                    if len(body_types) > 1:
                        fusion_t2 = body_types[1].title()
                    else:
                        fusion_t2 = body_types[0].title() if body_types else ''
                    # Avoid duplicate types
                    if fusion_t1 == fusion_t2:
                        fusion_types_set = {fusion_t1}
                        fusion_t2 = ''
                    else:
                        fusion_types_set = {fusion_t1, fusion_t2}

                    # Apply fusion type filters (if any)
                    if desired_fusion_types:
                        if desired_fusion_types.issubset(fusion_types_set):
                            pass
                        else:
                            continue  # Skip if the desired types are not a subset of fusion types
                    # Else: No fusion type filters applied

                    # Fusion Ability (inherited from body Pokémon)
                    fusion_abilities = [a.lower() for a in body_data['abilities']]

                    # Apply fusion ability filters (if any)
                    if desired_fusion_abilities:
                        if not desired_fusion_abilities.intersection(set(fusion_abilities)):
                            continue  # Skip if fusion abilities do not match desired abilities
                    # Else: No fusion ability filters applied

                    # Fusion Moveset (combined moves of head and body)
                    fusion_moves = head_data['moves'].union(body_data['moves'])

                    # Apply fusion move filters (if any)
                    if desired_fusion_moves:
                        if not desired_fusion_moves.issubset(set([m.lower() for m in fusion_moves])):
                            continue  # Skip if fusion moves do not include all desired moves
                    # Else: No fusion move filters applied

                    # Compute total fusion score
                    total_score = head_data['score'] + body_data['score']

                    # Collect the pair
                    possible_pairs.append((total_score, head_name, body_name, fusion_t1, fusion_t2, fusion_abilities))

            if not possible_pairs:
                st.warning("No fusions match the specified filters.")
                return

            # Sort possible pairs by total score
            possible_pairs.sort(reverse=True)

            # Get top K fusions
            top_fusions = []
            for idx, (fusion_score, head_name, body_name, fusion_t1, fusion_t2, fusion_abilities) in enumerate(possible_pairs):
                if idx >= top_k:
                    break

                # Compute fusion stats
                fusion_stats = {}
                for stat in stat_columns:
                    hs = head_contributions[head_name]['stats'][stat]
                    bs = body_contributions[body_name]['stats'][stat]
                    fusion_stats[stat] = hs + bs

                # Format abilities for display
                abilities_display = ', '.join([a.title() for a in fusion_abilities])

                fusion_entry = {
                    'Head': head_name.title(),
                    'Body': body_name.title(),
                    'Fusion Score': fusion_score,
                    'Type 1': fusion_t1,
                    'Type 2': fusion_t2,
                    'Abilities': abilities_display,
                }
                for stat in stat_columns:
                    fusion_entry[stat.replace('-', ' ').title()] = int(fusion_stats[stat])
                top_fusions.append(fusion_entry)

            if top_fusions:
                # Apply stat filters to fusions
                fusion_df = pd.DataFrame(top_fusions)
                for stat in stat_columns:
                    min_val, max_val = stat_filters[stat]
                    fusion_df = fusion_df[(fusion_df[stat.replace('-', ' ').title()] >= min_val) & (fusion_df[stat.replace('-', ' ').title()] <= max_val)]

                if fusion_df.empty:
                    st.warning("No fusions match the specified filters.")
                else:
                    fusion_df = fusion_df[['Head', 'Body', 'Type 1', 'Type 2', 'Abilities', 'Fusion Score'] + [stat.replace('-', ' ').title() for stat in stat_columns]]
                    st.write(f"### Top {len(fusion_df)} Fusions:")
                    st.dataframe(fusion_df.reset_index(drop=True))
            else:
                st.warning("No fusions match the specified filters.")
    else:
        st.write("Adjust the settings and click 'Compute Best Fusions' to find the optimal Pokémon fusions based on your preferences.")

if __name__ == "__main__":
    main()