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. However, there are times when it finds NO fusions matching a criteria when they indeed could be possible. For example, if the user  tries to find a fire type fusion with the Adaptability ability, it should find that there are indeed fusions even if they score low. For example, it could fuse a Crawdaunt with a fire type pokemon and it would lead to a fire/water or fire/dark type pokemon with adaptability.

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))]

            # Add pokemon that have the desired abilities and moves to the filtered df
            if fusion_ability_inputs:
                filtered_df = filtered_df[filtered_df['Abilities'].apply(lambda abilities: any(ability.title() in fusion_ability_inputs for ability in abilities))]

            if fusion_move_inputs:
                filtered_df = filtered_df[filtered_df['Moves'].apply(lambda moves: any(move.title() in fusion_move_inputs for move in moves))]

            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 = 500000  # 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']] + [b.lower() for b in head_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()