import collections
import itertools
import logging
import math
from collections import Counter
from contextlib import suppress

import RaceRandom as random
from BaseClasses import CollectionState, FillError, LocationType
from Items import ItemFactory
from Regions import retro_shops, shop_to_location_table
from source.item.FillUtil import (
    classify_major_items,
    filter_locations,
    filter_special_locations,
    replace_trash_item,
    valid_pot_items,
    vanilla_fallback,
)


def get_dungeon_item_pool(world):
    dungeon_items = [item for dungeon in world.dungeons for item in dungeon.all_items if item.location is None]
    for player in range(1, world.players+1):
        if world.prizeshuffle[player] != 'none':
            dungeon_items.extend(ItemFactory(['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6'], player))

    return dungeon_items


def promote_dungeon_items(world):
    world.itempool += get_dungeon_item_pool(world)

    for item in world.get_items():
        if item.smallkey or item.bigkey or item.prize:
            item.advancement = True
        elif item.map or item.compass:
            item.priority = True


def dungeon_tracking(world):
    for dungeon in world.dungeons:
        layout = world.dungeon_layouts[dungeon.player][dungeon.name]
        layout.dungeon_items = len([i for i in dungeon.all_items if i.is_inside_dungeon_item(world)])
        if world.prizeshuffle[dungeon.player] in ['dungeon', 'nearby'] and not dungeon.prize:
            from Dungeons import dungeon_table
            if dungeon_table[dungeon.name].prize:
                layout.dungeon_items += 1
        layout.free_items = layout.location_cnt - layout.dungeon_items


def fill_dungeons_restrictive(world, shuffled_locations):
    # with shuffled dungeon items they are distributed as part of the normal item pool
    for item in world.get_items():
        if ((item.prize and world.prizeshuffle[item.player] != 'none')
           or (item.smallkey and world.keyshuffle[item.player] != 'none')
           or (item.bigkey and world.bigkeyshuffle[item.player] != 'none')):
            item.advancement = True
        elif (item.map and world.mapshuffle[item.player] not in ['none', 'nearby']) or (item.compass and world.compassshuffle[item.player] not in ['none', 'nearby']):
            item.priority = True

    dungeon_items = [item for item in get_dungeon_item_pool(world) if item.is_inside_dungeon_item(world) or item.is_near_dungeon_item(world)]
    bigs, smalls, prizes, others = [], [], [], []
    for i in dungeon_items:
        (bigs if i.bigkey else smalls if i.smallkey else prizes if i.prize else others).append(i)
    unplaced_smalls = list(smalls)
    for i in world.itempool:
        if i.smallkey and world.keyshuffle[i.player] not in ['none', 'nearby']:
            unplaced_smalls.append(i)

    def fill(base_state, items, locations, key_pool=None):
        fill_restrictive(world, base_state, locations, items, key_pool, True)

    all_state_base = world.get_all_state()
    for player in range(1, world.players + 1):
        if world.logic[player] == 'hybridglitches' and world.keyshuffle[player] in ['none', 'nearby'] \
                and world.pottery[player] not in ['none', 'cave']:
            # remove 2 keys from main pool
            count_to_remove = 2
            to_remove = []
            for wix, wi in enumerate(smalls):
                if wi.name == 'Small Key (Swamp Palace)' and wi.player == player:
                    to_remove.append(wix)
                if count_to_remove == len(to_remove):
                    break
            for wix in reversed(to_remove):
                del smalls[wix]
            # remove 2 swamp locations from pool
            hybrid_locations = []
            to_remove = []
            for i, loc in enumerate(shuffled_locations):
                if loc.name in ['Swamp Palace - Trench 1 Pot Key', 'Swamp Palace - Pot Row Pot Key'] and loc.player == player:
                    to_remove.append(i)
                    hybrid_locations.append(loc)
                if count_to_remove == len(to_remove):
                    break
            for i in reversed(to_remove):
                shuffled_locations.pop(i)
            # place 2 HMG keys
            hybrid_state_base = all_state_base.copy()
            for x in bigs + smalls + prizes + others:
                hybrid_state_base.collect(x, True)
            hybrid_smalls = [ItemFactory('Small Key (Swamp Palace)', player)] * 2
            fill(hybrid_state_base, hybrid_smalls, hybrid_locations, unplaced_smalls)

    big_state_base = all_state_base.copy()
    for x in smalls + prizes + others:
        big_state_base.collect(x, True)
    fill(big_state_base, bigs, shuffled_locations, unplaced_smalls)
    random.shuffle(shuffled_locations)
    small_state_base = all_state_base.copy()
    for x in prizes + others:
        small_state_base.collect(x, True)
    fill(small_state_base, smalls, shuffled_locations, unplaced_smalls)

    prizes_copy = prizes.copy()
    for attempt in range(15):
        try:
            for player in range(1, world.players + 1):
                if world.prizeshuffle[player] == 'nearby' and world.algorithm != 'vanilla_fill':
                    dungeon_pool = []
                    for dungeon in world.dungeons:
                        from Dungeons import dungeon_table
                        if dungeon.player == player and dungeon_table[dungeon.name].prize:
                            dungeon_pool.append(dungeon)
                    random.shuffle(dungeon_pool)
                    for item in prizes:
                        if item.player == player:
                            dungeon = dungeon_pool.pop()
                            dungeon.prize = item
                            item.dungeon_object = dungeon
            random.shuffle(prizes)
            random.shuffle(shuffled_locations)
            prize_state_base = all_state_base.copy()
            for x in others:
                prize_state_base.collect(x, True)
            fill(prize_state_base, prizes, shuffled_locations)
        except FillError as e:
            logging.getLogger('').info("Failed to place dungeon prizes (%s). Will retry %s more times", e, 14 - attempt)
            prizes = prizes_copy.copy()
            for dungeon in world.dungeons:
                dungeon.prize = None
            for prize in prizes:
                if prize.location:
                    prize.location.item = None
                    prize.location = None
            continue
        break
    else:
        raise FillError(f'Unable to place dungeon prizes: {", ".join(list(map(lambda d: d.name, prizes)))}')

    if world.algorithm == 'vanilla_fill':
        for prize in prizes_copy:
            if prize.is_near_dungeon_item(world):
                if prize.location and prize.location.parent_region.dungeon:
                    dungeon = prize.location.parent_region.dungeon
                    dungeon.prize = prize
                    prize.dungeon_object = dungeon

    random.shuffle(shuffled_locations)
    fill(all_state_base, others, shuffled_locations)


def fill_restrictive(world, base_state, locations, itempool, key_pool=None, single_player_placement=False,
                     vanilla=False):
    def sweep_from_pool(placing_items=None):
        new_state = base_state.copy()
        for item in itempool:
            new_state.collect(item, True)
        new_state.placing_items = placing_items
        new_state.sweep_for_events()
        new_state.placing_items = None
        return new_state

    unplaced_items = []

    no_access_checks = {}
    reachable_items = {}
    for item in itempool:
        if world.accessibility[item.player] == 'none':
            no_access_checks.setdefault(item.player, []).append(item)
        else:
            reachable_items.setdefault(item.player, []).append(item)

    for player_items in [no_access_checks, reachable_items]:
        while any(player_items.values()) and locations:
            items_to_place = [[itempool.remove(items[-1]), items.pop()][-1] for items in player_items.values() if items]

            maximum_exploration_state = sweep_from_pool(placing_items=items_to_place)
            has_beaten_game = world.has_beaten_game(maximum_exploration_state)

            for item_to_place in items_to_place:
                perform_access_check = True
                if world.accessibility[item_to_place.player] == 'none':
                    perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) if single_player_placement else not has_beaten_game

                spot_to_fill = None

                item_locations = filter_locations(item_to_place, locations, world, vanilla)
                if is_dungeon_item(item_to_place, world) and not (item_to_place.prize and world.prizeshuffle[item_to_place.player] == 'none'):
                    item_locations = [l for l in item_locations if valid_dungeon_placement(item_to_place, l, world)]
                verify(item_to_place, item_locations, maximum_exploration_state, single_player_placement,
                       perform_access_check, key_pool, world)
                for location in item_locations:
                    spot_to_fill = verify_spot_to_fill(location, item_to_place, maximum_exploration_state,
                                                       single_player_placement, perform_access_check, key_pool, world)
                    if spot_to_fill:
                        break
                if spot_to_fill is None:
                    if vanilla:
                        unplaced_items.insert(0, item_to_place)
                        continue
                    spot_to_fill = recovery_placement(item_to_place, locations, world, maximum_exploration_state,
                                                      base_state, itempool, perform_access_check, item_locations,
                                                      key_pool, single_player_placement)
                    if spot_to_fill is None:
                        # we filled all reachable spots. Maybe the game can be beaten anyway?
                        unplaced_items.insert(0, item_to_place)
                        if world.can_beat_game():
                            if world.accessibility[item_to_place.player] != 'none':
                                logging.getLogger('').warning('Not all items placed. Game beatable anyway.'
                                                              f' (Could not place {item_to_place})')
                            continue
                        raise FillError('No more spots to place %s' % item_to_place)

                world.push_item(spot_to_fill, item_to_place, False)
                track_outside_keys(item_to_place, spot_to_fill, world)
                track_dungeon_items(item_to_place, spot_to_fill, world)
                locations.remove(spot_to_fill)
                spot_to_fill.event = True

    itempool.extend(unplaced_items)


def verify_spot_to_fill(location, item_to_place, max_exp_state, single_player_placement, perform_access_check,
                        key_pool, world):
    if item_to_place.smallkey or item_to_place.bigkey or item_to_place.prize:  # a better test to see if a key can go there
        location.item = item_to_place
        location.event = True
        if item_to_place.smallkey:
            with suppress(ValueError):
                key_pool.remove(item_to_place)
        test_state = max_exp_state.copy()
        test_state.stale[item_to_place.player] = True
    else:
        test_state = max_exp_state
    if not single_player_placement or location.player == item_to_place.player:
        test_state.sweep_for_events()
        if location.can_fill(test_state, item_to_place, perform_access_check):
            if valid_key_placement(item_to_place, location, key_pool, test_state, world):
                if (item_to_place.prize and (world.prizeshuffle[item_to_place.player] == 'none' \
                        or (world.algorithm == 'vanilla_fill' and item_to_place.is_near_dungeon_item(world)))) \
                        or valid_dungeon_placement(item_to_place, location, world):
                    return location

    if item_to_place.smallkey or item_to_place.bigkey or item_to_place.prize:
        location.item = None
        location.event = False
        if item_to_place.smallkey:
            key_pool.append(item_to_place)
    return None


def valid_key_placement(item, location, key_pool, collection_state, world):
    if not valid_reserved_placement(item, location, world):
        return False
    if ((not item.smallkey and not item.bigkey) or item.player != location.player
       or world.keyshuffle[item.player] == 'universal' or world.logic[item.player] == 'nologic'):
        return True
    dungeon = location.parent_region.dungeon
    if not dungeon and item.is_near_dungeon_item(world):
        check_dungeon = world.get_dungeon(item.dungeon, item.player) if item.dungeon else item.dungeon_object
        if len([d for d in location.parent_region.districts if d in check_dungeon.districts]):
            dungeon = check_dungeon
    if dungeon:
        if dungeon.name not in item.name and (dungeon.name != 'Hyrule Castle' or 'Escape' not in item.name):
            return True
        # Small key and big key in Swamp and Hera are placed without logic
        if world.logic[item.player] == 'hybridglitches' and dungeon.name in ['Tower of Hera', 'Swamp Palace'] and dungeon.name in item.name:
            return True
        key_logic = world.key_logic[item.player][dungeon.name]
        unplaced_keys = len([x for x in key_pool if x.name == key_logic.small_key_name and x.player == item.player])
        prize_loc = None
        if key_logic.prize_location and dungeon.prize and dungeon.prize.location and dungeon.prize.location.player == item.player:
            prize_loc = dungeon.prize.location
        cr_count = world.crystals_needed_for_gt[location.player]
        wild_keys = world.keyshuffle[item.player] != 'none'
        if wild_keys:
            reached_keys = {x for x in collection_state.locations_checked
                            if x.item and x.item.name == key_logic.small_key_name and x.item.player == item.player}
        else:
            reached_keys = set()  # will be calculated using key logic in a moment
        self_locking_keys = sum(1 for d, rule in key_logic.door_rules.items() if rule.allow_small
                                and rule.small_location.item and rule.small_location.item.name == key_logic.small_key_name)
        return key_logic.check_placement(unplaced_keys, wild_keys, reached_keys, self_locking_keys,
                                         location if item.bigkey else None, prize_loc, cr_count)
    else:
        return not item.is_inside_dungeon_item(world)


def valid_reserved_placement(item, location, world):
    if item.player == location.player and item.is_inside_dungeon_item(world):
        return location.name not in world.item_pool_config.reserved_locations[location.player]
    return True


def valid_dungeon_placement(item, location, world):
    dungeon = location.parent_region.dungeon
    if not dungeon and item.is_near_dungeon_item(world):
        check_dungeon = world.get_dungeon(item.dungeon, item.player) if item.dungeon else item.dungeon_object
        if len([d for d in location.parent_region.districts if d in check_dungeon.districts]):
            dungeon = check_dungeon
    if dungeon:
        layout = world.dungeon_layouts[location.player][dungeon.name]
        if item.event:
            return True
        elif not is_dungeon_item(item, world) or item.player != location.player:
            if item.prize and item.is_near_dungeon_item(world):
                return item.dungeon_object == dungeon and layout.free_items > 0
            return layout.free_items > 0
        elif item.prize:
            return not dungeon.prize and layout.dungeon_items > 0
        else:
            # the second half probably doesn't matter much - should always return true
            return item.dungeon == dungeon.name and layout.dungeon_items > 0
    return not is_dungeon_item(item, world)


def track_outside_keys(item, location, world):
    if not item.smallkey:
        return
    item_dungeon = item.dungeon
    if location.player == item.player:
        loc_dungeon = location.parent_region.dungeon
        if loc_dungeon and loc_dungeon.name == item_dungeon:
            return  # this is an inside key
    world.key_logic[item.player][item_dungeon].outside_keys += 1
    world.key_logic[item.player][item_dungeon].outside_keys_locations.add(location)


def track_dungeon_items(item, location, world):
    if location.parent_region.dungeon and (not item.prize or world.prizeshuffle[item.player] == 'dungeon'):
        layout = world.dungeon_layouts[location.player][location.parent_region.dungeon.name]
        if is_dungeon_item(item, world) and item.player == location.player:
            layout.dungeon_items -= 1
        else:
            layout.free_items -= 1
        if item.prize:
            location.parent_region.dungeon.prize = item
            item.dungeon_object = location.parent_region.dungeon
    elif world.algorithm == 'vanilla_fill' and item.prize and location.parent_region.dungeon:
        dungeon = location.parent_region.dungeon
        dungeon.prize = item
        item.dungeon_object = dungeon


def is_dungeon_item(item, world):
    return ((item.prize and world.prizeshuffle[item.player] in ['none', 'dungeon'])
            or (item.smallkey and world.keyshuffle[item.player] == 'none')
            or (item.bigkey and world.bigkeyshuffle[item.player] == 'none')
            or (item.compass and world.compassshuffle[item.player] == 'none')
            or (item.map and world.mapshuffle[item.player] == 'none'))


def recovery_placement(item_to_place, locations, world, state, base_state, itempool, perform_access_check, attempted,
                       key_pool=None, single_player_placement=False):
    logging.getLogger('').debug(f'Could not place {item_to_place} attempting recovery')
    if world.algorithm in ['balanced', 'equitable']:
        return last_ditch_placement(item_to_place, locations, world, state, base_state, itempool, key_pool,
                                    single_player_placement)
    elif world.algorithm == 'vanilla_fill':
        if item_to_place.prize:
            possible_swaps = [x for x in state.locations_checked if x.item.prize]
            return try_possible_swaps(possible_swaps, item_to_place, locations, world, base_state, itempool,
                                      key_pool, single_player_placement)
        else:
            i, config = 0, world.item_pool_config
            tried = set(attempted)
            if not item_to_place.is_inside_dungeon_item(world):
                while i < len(config.location_groups[item_to_place.player]):
                    fallback_locations = config.location_groups[item_to_place.player][i].locations
                    other_locs = [x for x in locations if x.name in fallback_locations]
                    for location in other_locs:
                        spot_to_fill = verify_spot_to_fill(location, item_to_place, state, single_player_placement,
                                                           perform_access_check, key_pool, world)
                        if spot_to_fill:
                            return spot_to_fill
                    i += 1
                    tried.update(other_locs)
            else:
               other_locations = vanilla_fallback(item_to_place, locations, world)
               for location in other_locations:
                   spot_to_fill = verify_spot_to_fill(location, item_to_place, state, single_player_placement,
                                                      perform_access_check, key_pool, world)
                   if spot_to_fill:
                       return spot_to_fill
               tried.update(other_locations)
            other_locations = [x for x in locations if x not in tried]
            for location in other_locations:
                spot_to_fill = verify_spot_to_fill(location, item_to_place, state, single_player_placement,
                                                   perform_access_check, key_pool, world)
                if spot_to_fill:
                    return spot_to_fill
            return None
    # explicitly fail these cases
    elif world.algorithm in ['dungeon_only', 'major_only', 'district']:
        raise FillError(f'Rare placement for {world.algorithm} detected. {item_to_place} unable to be placed.'
                        f' Try a different seed')
    # I don't think any algorithm uses fallback placement anymore, vanilla is special. Others simply fail.
    else:
        other_locations = [x for x in locations if x not in attempted]
        for location in other_locations:
            spot_to_fill = verify_spot_to_fill(location, item_to_place, state, single_player_placement,
                                               perform_access_check, key_pool, world)
            if spot_to_fill:
                return spot_to_fill
    return None


def last_ditch_placement(item_to_place, locations, world, state, base_state, itempool,
                         key_pool=None, single_player_placement=False):
    def location_preference(loc):
        if not loc.item.advancement:
            return 1
        if loc.item.type and loc.item.type != 'Sword':
            if loc.item.type in ['Map', 'Compass']:
                return 2
            else:
                return 3
        return 4

    # TODO: Verify correctness in using item player in multiworld situations
    if item_to_place.prize and world.prizeshuffle[item_to_place.player] == 'none':
        possible_swaps = [x for x in state.locations_checked if x.item.prize]
    else:
        ignored_types = ['Event']
        if world.prizeshuffle[item_to_place.player] == 'none':
            ignored_types.append('Prize')
        possible_swaps = [x for x in state.locations_checked
                          if x.item.type not in ignored_types and not x.forced_item and not x.locked]
    swap_locations = sorted(possible_swaps, key=location_preference)
    return try_possible_swaps(swap_locations, item_to_place, locations, world, base_state, itempool,
                              key_pool, single_player_placement)


def try_possible_swaps(swap_locations, item_to_place, locations, world, base_state, itempool,
                       key_pool=None, single_player_placement=False):
    for location in swap_locations:
        old_item = location.item
        new_pool = list(itempool) + [old_item]
        new_spot = find_spot_for_item(item_to_place, [location], world, base_state, new_pool,
                                      key_pool, single_player_placement)
        if new_spot:
            restore_item = new_spot.item
            new_spot.item = item_to_place
            swap_spot = find_spot_for_item(old_item, locations, world, base_state, itempool,
                                           key_pool, single_player_placement)
            if swap_spot:
                logging.getLogger('').debug(f'Swapping {old_item} for {item_to_place}')
                world.push_item(swap_spot, old_item, False)
                swap_spot.event = True
                locations.remove(swap_spot)
                locations.append(new_spot)
                return new_spot
            else:
                new_spot.item = restore_item
        else:
            location.item = old_item
    return None


def find_spot_for_item(item_to_place, locations, world, base_state, pool,
                       keys_in_itempool=None, single_player_placement=False):
    def sweep_from_pool():
        new_state = base_state.copy()
        for item in pool:
            new_state.collect(item, True)
        new_state.sweep_for_events()
        return new_state
    for location in locations:
        maximum_exploration_state = sweep_from_pool()
        perform_access_check = True
        old_item = None
        if world.accessibility[item_to_place.player] == 'none':
            perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) if single_player_placement else not world.has_beaten_game(maximum_exploration_state)

        if item_to_place.smallkey or item_to_place.bigkey:  # a better test to see if a key can go there
            old_item = location.item
            location.item = item_to_place
            test_state = maximum_exploration_state.copy()
            test_state.stale[item_to_place.player] = True
        else:
            test_state = maximum_exploration_state
        if (not single_player_placement or location.player == item_to_place.player) \
             and location.can_fill(test_state, item_to_place, perform_access_check) \
             and valid_key_placement(item_to_place, location,
                                     pool if (keys_in_itempool and keys_in_itempool[item_to_place.player]) else world.itempool,
                                     test_state, world):
            return location
        if item_to_place.smallkey or item_to_place.bigkey:
            location.item = old_item
    return None


def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None):
    # If not passed in, then get a shuffled list of locations to fill in
    if not fill_locations:
        fill_locations = world.get_unfilled_locations()
        random.shuffle(fill_locations)

    # get items to distribute
    classify_major_items(world)
    # handle special case fast fill
    locations_used = False
    location_item_pool = collections.defaultdict(list)

    # guarantee one big magic in a bonk location
    for player in range(1, world.players + 1):
        if world.shuffle_bonk_drops[player]:
            for item in world.itempool:
                if item.name in ['Big Magic'] and item.player == player:
                    location_item_pool[player].append(item)
                    break
    from Regions import bonk_prize_table
    for player, magic_pool in location_item_pool.items():
        if len(magic_pool) > 0:
            world.itempool.remove(magic_pool[0])
            bonk_locations = [location for location in fill_locations if location.player == player
                    and location.name in [n for n, (_, _, aga, _, _, _) in bonk_prize_table.items() if not aga]]
            bonk_locations = filter_special_locations(bonk_locations, world, lambda l: l.name == 'Kakariko Portal Tree')
            fast_fill_helper(world, magic_pool, bonk_locations)
            locations_used = True
    
    if locations_used:
        fill_locations = world.get_unfilled_locations()
        random.shuffle(fill_locations)

    random.shuffle(world.itempool)
    config_sort(world)
    progitempool = [item for item in world.itempool if item.advancement]
    prioitempool = [item for item in world.itempool if not item.advancement and item.priority]
    restitempool = [item for item in world.itempool if not item.advancement and not item.priority]

    gftower_trash &= world.algorithm in ['balanced', 'equitable', 'dungeon_only']
    # dungeon only may fill up the dungeon... and push items out into the overworld

    # fill in gtower locations with trash first
    for player in range(1, world.players + 1):
        if (not gftower_trash or not world.ganonstower_vanilla[player]
           or world.logic[player] in ['owglitches', 'hybridglitches', 'nologic']):
            continue
        gt_count, total_count = calc_trash_locations(world, player)
        scale_factor = .75 * (world.crystals_needed_for_gt[player] / 7)
        if world.algorithm == 'dungeon_only':
            reserved_space = sum(1 for i in progitempool+prioitempool if i.player == player)
            max_trash = max(0, min(gt_count, total_count - reserved_space))
        else:
            max_trash = gt_count
        scaled_trash = math.floor(max_trash * scale_factor)
        if world.goal[player] in ['triforcehunt', 'trinity', 'ganonhunt'] or world.algorithm == 'dungeon_only':
            gftower_trash_count = random.randint(scaled_trash, max_trash)
        else:
            gftower_trash_count = random.randint(0, scaled_trash)

        gtower_locations = [location for location in fill_locations if location.parent_region.dungeon
                            and location.parent_region.dungeon.name == 'Ganons Tower' and location.player == player]
        random.shuffle(gtower_locations)
        trashcnt = 0
        while gtower_locations and restitempool and trashcnt < gftower_trash_count:
            spot_to_fill = gtower_locations.pop()
            item_to_place = restitempool.pop()
            world.push_item(spot_to_fill, item_to_place, False)
            fill_locations.remove(spot_to_fill)
            trashcnt += 1

    random.shuffle(fill_locations)
    fill_locations.reverse()

    # Make sure the escape keys ire placed first in standard to prevent running out of spots
    def std_item_sort(item):
        if world.mode[item.player] == 'standard':
            if item.name == 'Small Key (Escape)':
                return 1
            if item.name == 'Big Key (Escape)':
                return 2
        return 0

    progitempool.sort(key=std_item_sort)
    key_pool = [x for x in progitempool if x.smallkey]

    # sort maps and compasses to the back -- this may not be viable in equitable & ambrosia
    progitempool.sort(key=lambda item: 0 if item.map or item.compass else 1)
    if world.algorithm == 'vanilla_fill':
        fill_restrictive(world, world.state, fill_locations, progitempool, key_pool, vanilla=True)
    fill_restrictive(world, world.state, fill_locations, progitempool, key_pool)
    random.shuffle(fill_locations)
    if world.algorithm == 'vanilla_fill':
        fast_vanilla_fill(world, prioitempool, fill_locations)
    elif world.algorithm in ['balanced', 'major_only', 'dungeon_only', 'district']:
        filtered_fill(world, prioitempool, fill_locations)
    else:  # just need to ensure dungeon items still get placed in dungeons
        fast_equitable_fill(world, prioitempool, fill_locations)
    # placeholder work
    if world.algorithm == 'district':
        random.shuffle(fill_locations)
        placeholder_items = [item for item in world.itempool if item.name == 'Rupee (1)']
        num_ph_items = len(placeholder_items)
        if num_ph_items > 0:
            placeholder_locations = filter_locations('Placeholder', fill_locations, world)
            num_ph_locations = len(placeholder_locations)
            if num_ph_items < num_ph_locations < len(fill_locations):
                for _ in range(num_ph_locations - num_ph_items):
                    placeholder_items.append(replace_trash_item(restitempool, 'Rupee (1)'))
            assert len(placeholder_items) == len(placeholder_locations)
            for i in placeholder_items:
                restitempool.remove(i)
            for l in placeholder_locations:
                fill_locations.remove(l)
            filtered_fill(world, placeholder_items, placeholder_locations)

    if world.players > 1:
        fast_fill_pot_for_multiworld(world, restitempool, fill_locations)
        # todo: fast fill drops?
    if world.algorithm == 'vanilla_fill':
        fast_vanilla_fill(world, restitempool, fill_locations)
    else:
        fast_fill(world, restitempool, fill_locations)

    unplaced = [item.name for item in prioitempool + restitempool]
    unfilled = [location.name for location in fill_locations]
    if unplaced or unfilled:
        logging.warning('Unplaced items: %s - Unfilled Locations: %s', unplaced, unfilled)
    ensure_good_items(world)


def config_sort(world):
    if world.item_pool_config.verify:
        config_sort_helper(world, world.item_pool_config.verify)
    elif world.item_pool_config.preferred:
        config_sort_helper_random(world, world.item_pool_config.preferred)


def config_sort_helper(world, sort_dict):
    pref = list(sort_dict.keys())
    pref_len = len(pref)
    world.itempool.sort(key=lambda i: pref_len - pref.index((i.name, i.player))
                        if (i.name, i.player) in sort_dict else 0)


def config_sort_helper_random(world, sort_dict):
    world.itempool.sort(key=lambda i: 1 if (i.name, i.player) in sort_dict else 0)


def calc_trash_locations(world, player):
    total_count, gt_count = 0, 0
    for loc in world.get_locations():
        if (loc.player == player and loc.item is None
           and (loc.type not in {LocationType.Bonk, LocationType.Pot, LocationType.Drop, LocationType.Normal} or not loc.forced_item)
           and (loc.type != LocationType.Shop or world.shopsanity[player])
           and loc.parent_region.dungeon):
                total_count += 1
                if loc.parent_region.dungeon.name == 'Ganons Tower':
                    gt_count += 1
    return gt_count, total_count


def ensure_good_items(world, write_skips=False):
    for loc in world.get_locations():
        if loc.item is None:
            loc.item = ItemFactory('Nothing', loc.player)
        # convert Arrows 5 and Nothing when necessary
        if (loc.item.name in {'Arrows (5)', 'Nothing'}
           and (loc.type != LocationType.Pot or loc.item.player != loc.player)):
            loc.item = ItemFactory(invalid_location_replacement[loc.item.name], loc.item.player)
        # do the arrow retro check
        if world.bow_mode[loc.item.player].startswith('retro') and loc.item.name in {'Arrows (5)', 'Arrows (10)'}:
            loc.item = ItemFactory('Rupees (5)', loc.item.player)
        # don't write out all pots to spoiler
        # todo: skip uninteresting enemy drops
        if write_skips:
            if loc.type in [LocationType.Pot, LocationType.Bonk] and loc.item.name in valid_pot_items:
                loc.skip = True

    dungeon_pool = collections.defaultdict(list)
    prize_pool = collections.defaultdict(list)
    from Dungeons import dungeon_table
    from Items import prize_item_table
    for dungeon in world.dungeons:
        if dungeon_table[dungeon.name].prize:
            dungeon_pool[dungeon.player].append(dungeon)
    prize_set = set(prize_item_table.keys())
    for p in range(1, world.players + 1):
        prize_pool[p] = prize_set.copy()

    for player in dungeon_pool:
        dungeons = list(dungeon_pool[player])
        random.shuffle(dungeons)
        dungeon_pool[player] = dungeons
    for dungeon in world.dungeons:
        if dungeon.prize:
            dungeon_pool[dungeon.player].remove(dungeon)
            prize_pool[dungeon.prize.player].remove(dungeon.prize.name)
    for p in range(1, world.players + 1):
        for dungeon in dungeon_pool[p]:
            dungeon.prize = ItemFactory(prize_pool[p].pop(), p)


invalid_location_replacement = {'Arrows (5)': 'Arrows (10)', 'Nothing':  'Rupees (5)',
                                'Chicken': 'Rupees (5)', 'Big Magic': 'Small Magic', 'Fairy': 'Small Heart'}


def fast_fill_helper(world, item_pool, fill_locations):
    if world.algorithm == 'vanilla_fill':
        fast_vanilla_fill(world, item_pool, fill_locations)
    else:
        fast_fill(world, item_pool, fill_locations)
    # todo: other fast fill methods?


def fast_fill(world, item_pool, fill_locations):
    config = world.item_pool_config
    fast_pool = [x for x in item_pool if (x.name, x.player) not in config.restricted]
    filtered_pool = [x for x in item_pool if (x.name, x.player) in config.restricted]
    filtered_fill(world, filtered_pool, fill_locations)
    while fast_pool and fill_locations:
        spot_to_fill = fill_locations.pop()
        item_to_place = fast_pool.pop()
        world.push_item(spot_to_fill, item_to_place, False)
    item_pool.clear()
    item_pool.extend(filtered_pool)
    item_pool.extend(fast_pool)


def fast_fill_pot_for_multiworld(world, item_pool, fill_locations):
    pot_item_pool = collections.defaultdict(list)
    pot_fill_locations = collections.defaultdict(list)
    for item in item_pool:
        if item.name in valid_pot_items:
            pot_item_pool[item.player].append(item)
    for loc in fill_locations:
        if loc.type == LocationType.Pot:
            pot_fill_locations[loc.player].append(loc)
    for player in range(1, world.players+1):
        flex = 256 - world.data_tables[player].pot_secret_table.multiworld_count
        fill_count = len(pot_fill_locations[player]) - flex
        if fill_count > 0:
            first_count = min(fill_count, len(pot_item_pool[player]))
            fill_items = random.sample(pot_item_pool[player], first_count)
            remaining = fill_count - first_count
            if remaining > 0:
                other_items = [i for i in item_pool if i.player == player and i not in pot_item_pool[player]]
                fill_items += random.sample(other_items, min(remaining, len(other_items)))
            fill_spots = random.sample(pot_fill_locations[player], len(fill_items))
            for x in fill_items:
                item_pool.remove(x)
            for x in fill_spots:
                fill_locations.remove(x)
            fast_fill(world, fill_items, fill_spots)


def filtered_fill(world, item_pool, fill_locations):
    while item_pool and fill_locations:
        item_to_place = item_pool.pop()
        item_locations = filter_locations(item_to_place, fill_locations, world)
        spot_to_fill = next(iter(item_locations))
        fill_locations.remove(spot_to_fill)
        world.push_item(spot_to_fill, item_to_place, False)

    # sweep once to pick up preplaced items
    world.state.sweep_for_events()


def fast_vanilla_fill(world, item_pool, fill_locations):
    next_item_pool = []
    while item_pool and fill_locations:
        item_to_place = item_pool.pop()
        locations = filter_locations(item_to_place, fill_locations, world, vanilla_skip=True)
        if len(locations):
            spot_to_fill = locations.pop()
            fill_locations.remove(spot_to_fill)
            world.push_item(spot_to_fill, item_to_place, False)
        else:
            next_item_pool.append(item_to_place)
    while next_item_pool and fill_locations:
        item_to_place = next_item_pool.pop()
        spot_to_fill = next(iter(filter_locations(item_to_place, fill_locations, world)))
        fill_locations.remove(spot_to_fill)
        world.push_item(spot_to_fill, item_to_place, False)


def filtered_equitable_fill(world, item_pool, fill_locations):
    while item_pool and fill_locations:
        item_to_place = item_pool.pop()
        item_locations = filter_locations(item_to_place, fill_locations, world)
        spot_to_fill = next(l for l in item_locations if valid_dungeon_placement(item_to_place, l, world))
        fill_locations.remove(spot_to_fill)
        world.push_item(spot_to_fill, item_to_place, False)
        track_dungeon_items(item_to_place, spot_to_fill, world)


def fast_equitable_fill(world, item_pool, fill_locations):
    while item_pool and fill_locations:
        item_to_place = item_pool.pop()
        spot_to_fill = next(l for l in fill_locations if valid_dungeon_placement(item_to_place, l, world))
        fill_locations.remove(spot_to_fill)
        world.push_item(spot_to_fill, item_to_place, False)
        track_dungeon_items(item_to_place, spot_to_fill, world)


def lock_shop_locations(world, player):
    for shop, loc_names in shop_to_location_table.items():
        for loc in loc_names:
            world.get_location(loc, player).locked = True


def sell_potions(world, player):
    loc_choices = []
    for shop in world.shops[player]:
        # potions are excluded from the cap fairy due to visual problem
        if shop.region.name in shop_to_location_table and shop.region.name != 'Capacity Upgrade':
            loc_choices += [world.get_location(loc, player) for loc in shop_to_location_table[shop.region.name]]
    locations = [loc for loc in loc_choices if not loc.item]
    for potion in ['Green Potion', 'Blue Potion', 'Red Potion', 'Bee']:
        location = random.choice(filter_locations(ItemFactory(potion, player), locations, world, potion=True))
        locations.remove(location)
        p_item = next((item for item in world.itempool if item.name == potion and item.player == player), None)
        if p_item:
            world.push_item(location, p_item, collect=False)
            world.itempool.remove(p_item)


def sell_keys(world, player):
    # exclude the old man or take any caves because free keys are too good
    shop_names = {shop.region.name: shop for shop in world.shops[player] if shop.region.name in shop_to_location_table}
    choices = [(world.get_location(loc, player), shop) for shop in shop_names for loc in shop_to_location_table[shop]]
    locations = [l for l, shop in choices]
    locations = filter_locations(ItemFactory('Small Key (Universal)', player), locations, world)
    locations = [(loc, shop) for loc, shop in choices if not loc.item and loc in locations]
    location, shop = random.choice(locations)
    universal_key = next(i for i in world.itempool if i.name == 'Small Key (Universal)' and i.player == player)
    world.push_item(location, universal_key, collect=False)
    idx = shop_to_location_table[shop_names[shop].region.name].index(location.name)
    shop_names[shop].add_inventory(idx, 'Small Key (Universal)', 100)
    world.itempool.remove(universal_key)


def verify(item_to_place, item_locations, state, spp, pac, key_pool, world):
    if world.item_pool_config.verify:
        logger = logging.getLogger('')
        item_name = 'Bottle' if item_to_place.name.startswith('Bottle') else item_to_place.name
        item_player = item_to_place.player
        config = world.item_pool_config
        if (item_name, item_player) in config.verify:
            tests = config.verify[(item_name, item_player)]
            issues = []
            for location in item_locations:
                if location.name in tests:
                    expected = tests[location.name]
                    spot = verify_spot_to_fill(location, item_to_place, state, spp, pac, key_pool, world)
                    if spot and (item_to_place.smallkey or item_to_place.bigkey):
                        location.item = None
                        location.event = False
                        if item_to_place.smallkey:
                            key_pool.append(item_to_place)
                    if (expected and spot) or (not expected and spot is None):
                        logger.debug(f'Placing {item_name} ({item_player}) at {location.name} was {expected}')
                        config.verify_count += 1
                        if config.verify_count >= config.verify_target:
                            exit()
                    else:
                        issues.append((item_name, item_player, location.name, expected))
            if len(issues) > 0:
                for name, player, loc, expected in issues:
                    if expected:
                        logger.error(f'Could not place {name} ({player}) at {loc}')
                    else:
                        logger.error(f'{name} ({player}) should not be allowed at {loc}')
                raise Exception(f'Test failed placing {name}')


def balance_multiworld_progression(world):
    state = CollectionState(world)
    checked_locations = set()
    unchecked_locations = set(world.get_locations())

    total_locations_count = Counter(location.player for location in world.get_locations() if not location.locked and not location.forced_item)

    reachable_locations_count = {}
    for player in range(1, world.players + 1):
        reachable_locations_count[player] = 0
    sphere_num = 1
    moved_item_count = 0

    def get_sphere_locations(sphere_state, locations):
        sphere_state.sweep_for_events(key_only=True, locations=locations)
        return {loc for loc in locations if sphere_state.can_reach(loc) and sphere_state.not_flooding_a_key(sphere_state.world, loc)}

    def item_percentage(player, num):
        return num / total_locations_count[player]

    while True:
        sphere_locations = get_sphere_locations(state, unchecked_locations)
        for location in sphere_locations:
            unchecked_locations.remove(location)
            if not location.locked and not location.forced_item:
                reachable_locations_count[location.player] += 1

        logging.debug(f'Sphere {sphere_num}')
        logging.debug(f'Reachable locations: {reachable_locations_count}')
        debug_percentages = {
            player: round(item_percentage(player, num), 2)
            for player, num in reachable_locations_count.items()
        }
        logging.debug(f'Reachable percentages: {debug_percentages}\n')
        sphere_num += 1

        if checked_locations:
            max_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]), reachable_locations_count))
            threshold_percentages = {player: max_percentage * .8 for player in range(1, world.players + 1)}
            logging.debug(f'Thresholds: {threshold_percentages}')

            balancing_players = {player for player, reachables in reachable_locations_count.items()
                                 if item_percentage(player, reachables) < threshold_percentages[player]}
            if balancing_players:
                balancing_state = state.copy()
                balancing_unchecked_locations = unchecked_locations.copy()
                balancing_reachables = reachable_locations_count.copy()
                balancing_sphere = sphere_locations.copy()
                candidate_items = collections.defaultdict(set)
                while True:
                    for location in balancing_sphere:
                        if location.event and (world.keyshuffle[location.item.player] != 'none' or not location.item.smallkey) \
                                and (world.bigkeyshuffle[location.item.player] != 'none' or not location.item.bigkey):
                            balancing_state.collect(location.item, True, location)
                            player = location.item.player
                            if player in balancing_players and not location.locked and location.player != player:
                                candidate_items[player].add(location)
                    balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations)
                    for location in balancing_sphere:
                        balancing_unchecked_locations.remove(location)
                        balancing_reachables[location.player] += 1
                    if world.has_beaten_game(balancing_state) or all(item_percentage(player, reachables) >= threshold_percentages[player]
                                                                     for player, reachables in balancing_reachables.items()):
                        break
                    elif not balancing_sphere:
                        raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')

                unlocked_locations = collections.defaultdict(set)
                for l in unchecked_locations:
                    if l not in balancing_unchecked_locations:
                        unlocked_locations[l.player].add(l)
                items_to_replace = []
                for player in balancing_players:
                    locations_to_test = unlocked_locations[player]
                    items_to_test = candidate_items[player]
                    while items_to_test:
                        testing = items_to_test.pop()
                        reducing_state = state.copy()
                        for location in itertools.chain((l for l in items_to_replace if l.item.player == player),
                                                        items_to_test):
                            reducing_state.collect(location.item, True, location)

                        reducing_state.sweep_for_events(locations=locations_to_test)

                        if world.has_beaten_game(balancing_state):
                            if not world.has_beaten_game(reducing_state):
                                items_to_replace.append(testing)
                        else:
                            reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
                            p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
                            if p < threshold_percentages[player]:
                                items_to_replace.append(testing)

                replaced_items = False
                # sort then shuffle to maintain deterministic behaviour,
                # while allowing use of set for better algorithm growth behaviour elsewhere
                replacement_locations = sorted((l for l in checked_locations if not l.event and not l.locked),
                                               key=lambda loc: (loc.name, loc.player))
                random.shuffle(replacement_locations)
                items_to_replace.sort(key=lambda item: (item.name, item.player))
                random.shuffle(items_to_replace)
                while replacement_locations and items_to_replace:
                    old_location = items_to_replace.pop()
                    for new_location in replacement_locations:
                        if (new_location.can_fill(state, old_location.item, False) and
                           old_location.can_fill(state, new_location.item, False)):
                            replacement_locations.remove(new_location)
                            new_location.item, old_location.item = old_location.item, new_location.item
                            if world.shopsanity[new_location.player]:
                                check_shop_swap(new_location)
                            if world.shopsanity[old_location.player]:
                                check_shop_swap(old_location)
                            new_location.event, old_location.event = True, False
                            logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
                                          f"displacing {old_location.item} into {old_location}")
                            moved_item_count += 1
                            state.collect(new_location.item, True, new_location)
                            replaced_items = True
                            break
                    else:
                        logging.warning(f"Could not Progression Balance {old_location.item}")

                if replaced_items:
                    logging.debug(f'Moved {moved_item_count} items so far\n')
                    unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
                    for location in get_sphere_locations(state, unlocked):
                        unchecked_locations.remove(location)
                        reachable_locations_count[location.player] += 1
                        sphere_locations.add(location)

        for location in sphere_locations:
            if location.event and (world.keyshuffle[location.item.player] != 'none' or not location.item.smallkey) \
                    and (world.bigkeyshuffle[location.item.player] != 'none' or not location.item.bigkey):
                state.collect(location.item, True, location)
        checked_locations |= sphere_locations

        if world.has_beaten_game(state):
            break
        elif not sphere_locations:
            logging.warning('Progression Balancing ran out of paths.')
            break


def check_shop_swap(l, make_item_free=False):
    if l.parent_region.name in shop_to_location_table:
        if l.name in shop_to_location_table[l.parent_region.name]:
            idx = shop_to_location_table[l.parent_region.name].index(l.name)
            inv_slot = l.parent_region.shop.inventory[idx]
            inv_slot['item'] = l.item.name
            if make_item_free:
                inv_slot['price'] = 0
    elif l.parent_region in retro_shops:
        idx = retro_shops[l.parent_region.name].index(l.name)
        inv_slot = l.parent_region.shop.inventory[idx]
        inv_slot['item'] = l.item.name


def balance_money_progression(world):
    logger = logging.getLogger('')
    state = CollectionState(world)
    unchecked_locations = world.get_locations().copy()
    wallet = {player: 0 for player in range(1, world.players+1)}
    kiki_check = {player: False for player in range(1, world.players+1)}
    kiki_paid = {player: False for player in range(1, world.players+1)}
    rooms_visited = {player: set() for player in range(1, world.players+1)}
    balance_locations = {player: set() for player in range(1, world.players+1)}

    pay_for_locations = {'Bottle Merchant': 100, 'Chest Game': 30, 'Digging Game': 80,
                         'King Zora': 500, 'Blacksmith': 10}
    rupee_chart = {'Rupee (1)': 1, 'Rupees (5)': 5, 'Rupees (20)': 20, 'Rupees (50)': 50,
                   'Rupees (100)': 100, 'Rupees (300)': 300}
    rupee_rooms = {'Eastern Rupees': 90, 'Mire Key Rupees': 45, 'Mire Shooter Rupees': 90,
                   'TR Rupees': 270, 'PoD Dark Basement': 270}
    acceptable_balancers = ['Single Bomb', 'Bombs (3)', 'Bombs (10)',
                            'Single Arrow', 'Arrows (5)', 'Arrows (10)',
                            'Small Magic', 'Big Magic', 'Small Heart',
                            'Fairy', 'Chicken', 'Nothing']

    base_value = sum(rupee_rooms.values())
    available_money = {player: base_value for player in range(1, world.players+1)}
    for loc in world.get_locations():
        if loc.item and loc.item.name in rupee_chart:
            available_money[loc.item.player] += rupee_chart[loc.item.name]

    total_price = {player: 0 for player in range(1, world.players+1)}
    for player in range(1, world.players+1):
        for shop, loc_list in shop_to_location_table.items():
            for loc in loc_list:
                loc = world.get_location(loc, player)
                slot = shop_to_location_table[loc.parent_region.name].index(loc.name)
                shop = loc.parent_region.shop
                shop_item = shop.inventory[slot]
                if shop_item:
                    total_price[player] += shop_item['price']
        total_price[player] += 110 + sum(pay_for_locations.values())
    # base needed: 830
    # base available: 765

    for player in range(1, world.players+1):
        logger.debug(f'Money balance for P{player}: Needed: {total_price[player]} Available: {available_money[player]}')

    def get_sphere_locations(sphere_state, locations):
        sphere_state.sweep_for_events(key_only=True, locations=locations)
        return [loc for loc in locations if sphere_state.can_reach(loc) and sphere_state.not_flooding_a_key(sphere_state.world, loc)]

    def interesting_item(location, item, world, player):
        if location.event or location.locked:
            return True
        if item.advancement:
            return True
        if item.type is not None or item.name.startswith('Rupee'):
            return True
        if item.name in ['Progressive Armor', 'Blue Mail', 'Red Mail']:
            return True
        if world.keyshuffle[player] == 'universal' and item.name == 'Small Key (Universal)':
            return True
        if world.bow_mode[player].startswith('retro') and item.name == 'Single Arrow':
            return True
        if location.name in pay_for_locations:
            return True
        return False

    def kiki_required(state, location):
        path = state.path[location.parent_region]
        if path:
            while path[1]:
                if path[0] == 'Palace of Darkness':
                    return True
                path = path[1]
        return False

    done = False
    attempts = world.players * 20 + 20
    while not done:
        attempts -= 1
        if attempts < 0:
            from DungeonGenerator import GenerationException
            raise GenerationException(f'Infinite loop detected at "balance_money_progression"')
        sphere_costs = {player: 0 for player in range(1, world.players+1)}
        locked_by_money = {player: set() for player in range(1, world.players+1)}
        sphere_locations = get_sphere_locations(state, unchecked_locations)
        checked_locations = []
        for player in range(1, world.players+1):
            kiki_payable = state.prog_items[('Moon Pearl', player)] > 0 or world.is_tile_swapped(0x1e, player)
            if kiki_payable and world.get_region('Palace of Darkness Area', player) in state.reachable_regions[player]:
                if not kiki_paid[player]:
                    kiki_check[player] = True
                    sphere_costs[player] += 110
                    locked_by_money[player].add('Kiki')
        for location in sphere_locations:
            location_free, loc_player = True, location.player
            if location.parent_region.name in shop_to_location_table and location.name != 'Potion Shop':
                slot = shop_to_location_table[location.parent_region.name].index(location.name)
                shop = location.parent_region.shop
                shop_item = shop.inventory[slot]
                if shop_item and location.item and interesting_item(location, location.item, world, location.item.player):
                    if location.item.name.startswith('Rupee') and loc_player == location.item.player:
                        if shop_item['price'] < rupee_chart[location.item.name]:
                            wallet[loc_player] -= shop_item['price']  # will get picked up in the location_free block
                        else:
                            location_free = False
                    else:
                        location_free = False
                        sphere_costs[loc_player] += shop_item['price']
                        locked_by_money[loc_player].add(location)
            elif location.name in pay_for_locations:
                sphere_costs[loc_player] += pay_for_locations[location.name]
                location_free = False
                locked_by_money[loc_player].add(location)
            if kiki_check[loc_player] and not kiki_paid[loc_player] and kiki_required(state, location):
                locked_by_money[loc_player].add(location)
                location_free = False
            if location_free and location.item:
                state.collect(location.item, True, location)
                unchecked_locations.remove(location)
                if location.item:
                    if location.item.name.startswith('Rupee'):
                        if not (location.item.name == 'Rupee (1)' and world.algorithm != 'district'):
                            wallet[location.item.player] += rupee_chart[location.item.name]
                            if location.item.name != 'Rupees (300)':
                                balance_locations[location.item.player].add(location)
                    elif interesting_item(location, location.item, world, location.item.player):
                        checked_locations.append(location)
                    elif location.item.name in acceptable_balancers:
                        balance_locations[location.item.player].add(location)
        for room, income in rupee_rooms.items():
            for player in range(1, world.players+1):
                if room not in rooms_visited[player] and world.get_region(room, player) in state.reachable_regions[player]:
                    wallet[player] += income
                    rooms_visited[player].add(room)
        if checked_locations or len(unchecked_locations) == 0:
            if world.has_beaten_game(state):
                done = True
                continue
            # else go to next sphere
        else:
            # check for solvent players
            solvent = set()
            insolvent = set()
            for player in range(1, world.players+1):
                modifier = world.money_balance[player]/100
                if wallet[player] >= sphere_costs[player] * modifier >= 0:
                    solvent.add(player)
                if sphere_costs[player] > 0 and sphere_costs[player] * modifier > wallet[player]:
                    insolvent.add(player)
            if len([p for p in solvent if len(locked_by_money[p]) > 0]) == 0:
                if len(insolvent) > 0:
                    target_player = min(insolvent, key=lambda p: sphere_costs[p]-wallet[p])
                    target_modifier = world.money_balance[target_player]/100
                    difference = sphere_costs[target_player] * target_modifier - wallet[target_player]
                    logger.debug(f'Money balancing needed: Player {target_player} short {difference}')
                else:
                    difference = 0
                    target_player = next(p for p in solvent)
                while difference > 0:
                    swap_targets = [x for x in unchecked_locations if x not in sphere_locations and x.item.name.startswith('Rupees') and x.item.player == target_player]
                    if len(swap_targets) == 0:
                        best_swap, best_value = None, 300
                    else:
                        best_swap = max(swap_targets, key=lambda t: rupee_chart[t.item.name])
                        best_value = rupee_chart[best_swap.item.name]
                    increase_targets = [x for x in balance_locations[target_player] if x.item.name in rupee_chart and rupee_chart[x.item.name] < best_value]
                    if len(increase_targets) == 0:
                        increase_targets = [x for x in balance_locations[target_player] if (rupee_chart[x.item.name] if x.item.name in rupee_chart else 0) < best_value]
                    if len(increase_targets) == 0:
                        if state.can_farm_rupees(target_player):
                            logger.warning(f'No more swap targets available. Short by {difference} rupees, but continuing (player can farm)')
                            break
                        else:
                            raise Exception(f'No early sphere swaps for rupees - money grind would be required - bailing for now')
                    best_target = min(increase_targets, key=lambda t: rupee_chart[t.item.name] if t.item.name in rupee_chart else 0)
                    make_item_free = wallet[target_player] < 20
                    old_value = 0 if make_item_free else (rupee_chart[best_target.item.name] if best_target.item.name in rupee_chart else 0)
                    if best_swap is None:
                        logger.debug(f'Upgrading {best_target.item.name} @ {best_target.name} for 300 Rupees')
                        best_target.item = ItemFactory('Rupees (300)', best_target.item.player)
                        best_target.item.location = best_target
                        check_shop_swap(best_target.item.location, make_item_free)
                    else:
                        old_item = best_target.item
                        logger.debug(f'Swapping {best_target.item.name} @ {best_target.name} for {best_swap.item.name} @ {best_swap.name}')
                        best_target.item = best_swap.item
                        best_target.item.location = best_target
                        best_swap.item = old_item
                        best_swap.item.location = best_swap
                        check_shop_swap(best_target.item.location, make_item_free)
                        check_shop_swap(best_swap.item.location)
                    increase = best_value - old_value
                    difference -= increase
                    wallet[target_player] += increase
                solvent.add(target_player)
            # apply solvency
            for player in solvent:
                modifier = world.money_balance[player]/100
                wallet[player] -= sphere_costs[player] * modifier
                for location in locked_by_money[player]:
                    if isinstance(location, str) and location == 'Kiki':
                        kiki_paid[player] = True
                    else:
                        state.collect(location.item, True, location)
                        unchecked_locations.remove(location)
                        if location.item and location.item.name.startswith('Rupee'):
                            wallet[location.item.player] += rupee_chart[location.item.name]

def set_prize_drops(world, player):
    prizes = [0xD8, 0xD8, 0xD8, 0xD8, 0xD9, 0xD8, 0xD8, 0xD9,
              0xDA, 0xD9, 0xDA, 0xDB, 0xDA, 0xD9, 0xDA, 0xDA,
              0xE0, 0xDF, 0xDF, 0xDA, 0xE0, 0xDF, 0xD8, 0xDF,
              0xDC, 0xDC, 0xDC, 0xDD, 0xDC, 0xDC, 0xDE, 0xDC,
              0xE1, 0xD8, 0xE1, 0xE2, 0xE1, 0xD8, 0xE1, 0xE2,
              0xDF, 0xD9, 0xD8, 0xE1, 0xDF, 0xDC, 0xD9, 0xD8,
              0xD8, 0xE3, 0xE0, 0xDB, 0xDE, 0xD8, 0xDB, 0xE2,
              0xD9, 0xDA, 0xDB, 0xD9, 0xDB, 0xD9, 0xDB]
    dig_prizes = [0xB2, 0xD8, 0xD8, 0xD8, 0xD8, 0xD8, 0xD8, 0xD8, 0xD8,
                  0xD9, 0xD9, 0xD9, 0xD9, 0xD9, 0xDA, 0xDA, 0xDA, 0xDA, 0xDA,
                  0xDB, 0xDB, 0xDB, 0xDB, 0xDB, 0xDC, 0xDC, 0xDC, 0xDC, 0xDC,
                  0xDD, 0xDD, 0xDD, 0xDD, 0xDD, 0xDE, 0xDE, 0xDE, 0xDE, 0xDE,
                  0xDF, 0xDF, 0xDF, 0xDF, 0xDF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0,
                  0xE1, 0xE1, 0xE1, 0xE1, 0xE1, 0xE2, 0xE2, 0xE2, 0xE2, 0xE2,
                  0xE3, 0xE3, 0xE3, 0xE3, 0xE3]

    def chunk(l,n):
        return [l[i:i+n] for i in range(0, len(l), n)]

    possible_prizes = {
        'Small Heart': 0xD8, 'Fairy': 0xE3,
        'Rupee (1)': 0xD9, 'Rupees (5)': 0xDA, 'Rupees (20)': 0xDB,
        'Big Magic': 0xE0, 'Small Magic': 0xDF,
        'Single Bomb': 0xDC, 'Bombs (4)': 0xDD,
        'Bombs (8)': 0xDE, 'Arrows (5)': 0xE1, 'Arrows (10)': 0xE2
    }  #weights, if desired 13, 1, 9, 7, 6, 3, 6, 7, 1, 2, 5, 3
    uniform_prizes = list(possible_prizes.values())
    prizes[-7:] = random.sample(prizes, 7)

    #shuffle order of 7 main packs
    packs = chunk(prizes[:56], 8)
    random.shuffle(packs)
    prizes[:56] = [drop for pack in packs for drop in pack]

    if world.customizer:
        drops = world.customizer.get_drops()
        if drops:
            for player, drop_config in drops.items():
                for pack_num in range(1, 8):
                    if f'Pack {pack_num}' in drop_config:
                        for idx, prize in enumerate(drop_config[f'Pack {pack_num}']):
                            chosen = random.choice(uniform_prizes) if prize == 'Random' else possible_prizes[prize]
                            prizes[(pack_num-1)*8 + idx] = chosen
                for tree_pull_tier in range(1, 4):
                    if f'Tree Pull Tier {tree_pull_tier}' in drop_config:
                        prize = drop_config[f'Tree Pull Tier {tree_pull_tier}']
                        chosen = random.choice(uniform_prizes) if prize == 'Random' else possible_prizes[prize]
                        prizes[63-tree_pull_tier] = chosen  # (62 through 60 in reverse)
                for key, pos in {'Crab Normal': 59, 'Crab Special': 58, 'Stun Prize': 57, 'Fish': 56}.items():
                    if key in drop_config:
                        prize = drop_config[key]
                        chosen = random.choice(uniform_prizes) if prize == 'Random' else possible_prizes[prize]
                        prizes[pos] = chosen

    if world.difficulty_adjustments[player] in ['hard', 'expert']:
        prize_replacements = {0xE0: 0xDF, # Fairy -> heart
                              0xE3: 0xD8} # Big magic -> small magic
        prizes = [prize_replacements.get(prize, prize) for prize in prizes]
        dig_prizes = [prize_replacements.get(prize, prize) for prize in dig_prizes]

    if world.bow_mode[player].startswith('retro'):
        prize_replacements = {0xE1: 0xDA, #5 Arrows -> Blue Rupee
                              0xE2: 0xDB} #10 Arrows -> Red Rupee
        prizes = [prize_replacements.get(prize, prize) for prize in prizes]
        dig_prizes = [prize_replacements.get(prize, prize) for prize in dig_prizes]

    # write tree pull prizes
    world.prizes[player]['dig'] = dig_prizes

    # write tree pull prizes
    world.prizes[player]['pull'] = [ prizes.pop(), prizes.pop(), prizes.pop() ]

    # rupee crab prizes
    world.prizes[player]['crab'] = [ prizes.pop(), prizes.pop() ]

    # stunned enemy prize
    world.prizes[player]['stun'] = prizes.pop()

    # saved fish prize
    world.prizes[player]['fish'] = prizes.pop()

    world.prizes[player]['enemies'] = prizes
