Merge branch 'main' into breaking_changes

# Conflicts:
#	Main.py
#	Utils.py
This commit is contained in:
Fabian Dill 2021-03-07 22:04:06 +01:00
commit f7dc21ddcc
9 changed files with 142 additions and 72 deletions

15
Main.py
View File

@ -39,11 +39,12 @@ def get_seed(seed=None):
return seed return seed
seeds: Dict[tuple, str] = dict()
def get_same_seed(world: MultiWorld, seed_def: tuple) -> str: def get_same_seed(world: MultiWorld, seed_def: tuple) -> str:
seeds: Dict[tuple, str] = getattr(world, "__named_seeds", {})
if seed_def in seeds: if seed_def in seeds:
return seeds[seed_def] return seeds[seed_def]
seeds[seed_def] = str(world.random.randint(0, 2 ** 64)) seeds[seed_def] = str(world.random.randint(0, 2 ** 64))
world.__named_seeds = seeds
return seeds[seed_def] return seeds[seed_def]
@ -121,9 +122,15 @@ def main(args, seed=None):
world.shuffle[player] = shuffle world.shuffle[player] = shuffle
if shuffle == "vanilla": if shuffle == "vanilla":
world.er_seeds[player] = "vanilla" world.er_seeds[player] = "vanilla"
elif seed.startswith("team-"): elif seed.startswith("group-"): # renamed from team to group to not confuse with existing team name use
world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player])) world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
else: elif seed.startswith("team-"): # TODO: remove on breaking_changes
world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
elif not args.race:
world.er_seeds[player] = seed
elif seed: # race but with a set seed, ignore set seed and use group logic instead
world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
else: # race but without a set seed
world.er_seeds[player] = seed world.er_seeds[player] = seed
elif world.shuffle[player] == "vanilla": elif world.shuffle[player] == "vanilla":
world.er_seeds[player] = "vanilla" world.er_seeds[player] = "vanilla"
@ -307,7 +314,7 @@ def main(args, seed=None):
apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player], apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player],
args.fastmenu[player], args.disablemusic[player], args.sprite[player], args.fastmenu[player], args.disablemusic[player], args.sprite[player],
palettes_options, world, player, True, palettes_options, world, player, True,
reduceflashing=args.reduceflashing[player] if not args.race else True, reduceflashing=args.reduceflashing[player] or args.race,
triforcehud=args.triforcehud[player]) triforcehud=args.triforcehud[player])
mcsb_name = '' mcsb_name = ''

View File

@ -294,6 +294,8 @@ def prefer_int(input_data: str) -> typing.Union[str, int]:
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}} {'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
Bosses.boss_location_table}
boss_shuffle_options = {None: 'none', boss_shuffle_options = {None: 'none',
'none': 'none', 'none': 'none',
@ -370,6 +372,42 @@ def roll_triggers(weights: dict) -> dict:
f"Please fix your triggers.") from e f"Please fix your triggers.") from e
return weights return weights
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss]
elif "-" in boss:
loc, boss_name = boss.split("-")
if boss_name not in available_boss_names:
raise ValueError(f"Unknown Boss name {boss_name}")
if loc not in available_boss_locations:
raise ValueError(f"Unknown Boss Location {loc}")
level = ''
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
# split off level
loc = loc.split(" ")
level = f" {loc[-1]}"
loc = " ".join(loc[:-1])
loc = loc.title().replace("Of", "of")
if not Bosses.can_place_boss(boss_name.title(), loc, level):
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
bosses.append(boss)
elif boss not in available_boss_names:
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
else:
bosses.append(boss)
return ";".join(bosses + [remainder_shuffle])
else:
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses"))): def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses"))):
if "pre_rolled" in weights: if "pre_rolled" in weights:
pre_rolled = weights["pre_rolled"] pre_rolled = weights["pre_rolled"]
@ -380,10 +418,25 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
item["world"], item["world"],
item["from_pool"], item["from_pool"],
item["force"]) for item in pre_rolled["plando_items"]] item["force"]) for item in pre_rolled["plando_items"]]
if "items" not in plando_options and pre_rolled["plando_items"]:
raise Exception("Item Plando is turned off. Reusing this pre-rolled setting not permitted.")
if "plando_connections" in pre_rolled: if "plando_connections" in pre_rolled:
pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"], pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"],
connection["exit"], connection["exit"],
connection["direction"]) for connection in pre_rolled["plando_connections"]] connection["direction"]) for connection in pre_rolled["plando_connections"]]
if "connections" not in plando_options and pre_rolled["plando_connections"]:
raise Exception("Connection Plando is turned off. Reusing this pre-rolled setting not permitted.")
if "bosses" not in plando_options:
try:
pre_rolled["shufflebosses"] = get_plando_bosses(pre_rolled["shufflebosses"], plando_options)
except Exception as ex:
raise Exception("Boss Plando is turned off. Reusing this pre-rolled setting not permitted.") from ex
if pre_rolled.get("plando_texts") and "texts" not in plando_options:
raise Exception("Text Plando is turned off. Reusing this pre-rolled setting not permitted.")
return argparse.Namespace(**pre_rolled) return argparse.Namespace(**pre_rolled)
if "linked_options" in weights: if "linked_options" in weights:
@ -514,23 +567,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
ret.item_functionality = get_choice('item_functionality', weights) ret.item_functionality = get_choice('item_functionality', weights)
boss_shuffle = get_choice('boss_shuffle', weights) boss_shuffle = get_choice('boss_shuffle', weights)
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
if boss_shuffle in boss_shuffle_options:
ret.shufflebosses = boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss]
elif boss not in available_boss_names and not "-" in boss:
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
else:
bosses.append(boss)
ret.shufflebosses = ";".join(bosses + [remainder_shuffle])
else:
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
ret.enemy_shuffle = {'none': False, ret.enemy_shuffle = {'none': False,
'shuffled': 'shuffled', 'shuffled': 'shuffled',
@ -601,7 +638,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"}\ ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"}\
.get(medallion.lower(), None) .get(medallion.lower(), None)
if not ret.required_medallions[index]: if not ret.required_medallions[index]:
raise Exception(f"unknown Medallion {medallion}") raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
inventoryweights = weights.get('startinventory', {}) inventoryweights = weights.get('startinventory', {})
startitems = [] startitems = []

View File

@ -52,7 +52,10 @@ def snes_to_pc(value):
def parse_player_names(names, players, teams): def parse_player_names(names, players, teams):
names = tuple(n for n in (n.strip() for n in names.split(",")) if n) names = tuple(n for n in (n.strip() for n in names.split(",")) if n)
if len(names) != len(set(names)): if len(names) != len(set(names)):
raise ValueError("Duplicate Player names is not supported.") import collections
name_counter = collections.Counter(names)
raise ValueError(f"Duplicate Player names is not supported, "
f'found multiple "{name_counter.most_common(1)[0][0]}".')
ret = [] ret = []
while names or len(ret) < teams: while names or len(ret) < teams:
team = [n[:16] for n in names[:players]] team = [n[:16] for n in names[:players]]

View File

@ -9,7 +9,7 @@
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
padding: 3px 3px 10px; padding: 3px 3px 10px;
width: 260px; width: 284px;
background-color: #42b149; background-color: #42b149;
} }
@ -37,7 +37,7 @@
} }
#location-table{ #location-table{
width: 260px; width: 284px;
border-left: 2px solid #000000; border-left: 2px solid #000000;
border-right: 2px solid #000000; border-right: 2px solid #000000;
border-bottom: 2px solid #000000; border-bottom: 2px solid #000000;
@ -61,7 +61,7 @@
} }
#location-table td.counter{ #location-table td.counter{
padding-right: 10px; padding-right: 8px;
text-align: right; text-align: right;
} }

View File

@ -13,6 +13,12 @@ html{
width: calc(100% - 1rem); width: calc(100% - 1rem);
} }
#tracker-wrapper a{
color: #234ae4;
text-decoration: none;
cursor: pointer;
}
.table-wrapper{ .table-wrapper{
overflow-y: auto; overflow-y: auto;
overflow-x: auto; overflow-x: auto;

View File

@ -44,7 +44,8 @@
<tbody> <tbody>
{%- for player, items in players.items() -%} {%- for player, items in players.items() -%}
<tr> <tr>
<td><a href="{{ room.tracker|suuid }}/{{ team + 1 }}/{{ player }}">{{ loop.index }}</a></td> <td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
{%- if (team, loop.index) in video -%} {%- if (team, loop.index) in video -%}
{%- if video[(team, loop.index)][0] == "Twitch" -%} {%- if video[(team, loop.index)][0] == "Twitch" -%}
<td> <td>
@ -120,7 +121,8 @@
<tbody> <tbody>
{%- for player, checks in players.items() -%} {%- for player, checks in players.items() -%}
<tr> <tr>
<td><a href="{{ room.tracker|suuid }}/{{ team + 1 }}/{{ player }}">{{ loop.index }}</a></td> <td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td> <td>{{ player_names[(team, loop.index)]|e }}</td>
{%- for area in ordered_areas -%} {%- for area in ordered_areas -%}
{%- set checks_done = checks[area] -%} {%- set checks_done = checks[area] -%}

View File

@ -164,7 +164,7 @@ tracking_names = ["Progressive Sword", "Progressive Bow", "Book of Mudora", "Ham
"Red Boomerang", "Bug Catching Net", "Cape", "Shovel", "Lamp", "Red Boomerang", "Bug Catching Net", "Cape", "Shovel", "Lamp",
"Mushroom", "Magic Powder", "Mushroom", "Magic Powder",
"Cane of Somaria", "Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake", "Cane of Somaria", "Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake",
"Bottle", "Triforce"] # TODO make sure this list has what we need and sort it better "Bottle", "Triforce"]
default_locations = { default_locations = {
'Light World': {1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175, 'Light World': {1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175,
@ -240,14 +240,18 @@ for item in tracking_names:
small_key_ids = {} small_key_ids = {}
big_key_ids = {} big_key_ids = {}
ids_small_key = {}
ids_big_key = {}
for item_name, data in Items.item_table.items(): for item_name, data in Items.item_table.items():
if "Key" in item_name: if "Key" in item_name:
area = item_name.split("(")[1][:-1] area = item_name.split("(")[1][:-1]
if "Small" in item_name: if "Small" in item_name:
small_key_ids[area] = data[2] small_key_ids[area] = data[2]
ids_small_key[data[2]] = area
else: else:
big_key_ids[area] = data[2] big_key_ids[area] = data[2]
ids_big_key[data[2]] = area
from MultiServer import get_item_name_from_id from MultiServer import get_item_name_from_id
@ -318,16 +322,25 @@ def get_static_room_data(room: Room):
for playernumber in range(1, len(names[0]) + 1)} for playernumber in range(1, len(names[0]) + 1)}
player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][f'{playernumber}']) player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][f'{playernumber}'])
for playernumber in range(1, len(names[0]) + 1)} for playernumber in range(1, len(names[0]) + 1)}
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
for _, (item_id, item_player) in multidata["locations"]:
if item_id in ids_big_key:
player_big_key_locations[item_player].add(ids_big_key[item_id])
if item_id in ids_small_key:
player_small_key_locations[item_player].add(ids_small_key[item_id])
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, player_big_key_locations, player_small_key_locations
_multidata_cache[room.seed.id] = result _multidata_cache[room.seed.id] = result
return result return result
@app.route('/tracker/<suuid:tracker>/<int:team>/<int:player>') @app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
@cache.memoize(timeout=15) @cache.memoize(timeout=15)
def getPlayerTracker(tracker: UUID, team: int, player: int): def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
# Team and player must be positive and greater than zero # Team and player must be positive and greater than zero
if team < 1 or player < 1: if tracked_team < 0 or tracked_player < 1:
abort(404) abort(404)
room = Room.get(tracker=tracker) room = Room.get(tracker=tracker)
@ -335,15 +348,15 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
abort(404) abort(404)
# Collect seed information and pare it down to a single player # Collect seed information and pare it down to a single player
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area = get_static_room_data(room) locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, player_big_key_locations, player_small_key_locations = get_static_room_data(room)
player_name = names[team - 1][player - 1] player_name = names[tracked_team][tracked_player - 1]
seed_checks_in_area = seed_checks_in_area[player] seed_checks_in_area = seed_checks_in_area[tracked_player]
player_location_to_area = player_location_to_area[player] location_to_area = player_location_to_area[tracked_player]
inventory = collections.Counter() inventory = collections.Counter()
checks_done = {loc_name: 0 for loc_name in default_locations} checks_done = {loc_name: 0 for loc_name in default_locations}
# Add starting items to inventory # Add starting items to inventory
starting_items = room.seed.multidata.get("precollected_items", None)[player - 1] starting_items = room.seed.multidata.get("precollected_items", None)[tracked_player - 1]
if starting_items: if starting_items:
for item_id in starting_items: for item_id in starting_items:
attribute_item_solo(inventory, item_id) attribute_item_solo(inventory, item_id)
@ -352,26 +365,24 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
for (ms_team, ms_player), locations_checked in room.multisave.get("location_checks", {}): for (ms_team, ms_player), locations_checked in room.multisave.get("location_checks", {}):
# logging.info(f"{ms_team}, {ms_player}, {locations_checked}") # logging.info(f"{ms_team}, {ms_player}, {locations_checked}")
# Skip teams and players not matching the request # Skip teams and players not matching the request
if ms_team != (team - 1):
continue
# If the player does not have the item, do nothing if ms_team == tracked_team:
for location in locations_checked: # If the player does not have the item, do nothing
if (location, ms_player) not in locations or location not in player_location_to_area: for location in locations_checked:
continue if (location, ms_player) not in locations:
continue
item, recipient = locations[location, ms_player] item, recipient = locations[location, ms_player]
if recipient == player: if recipient == tracked_player: # a check done for the tracked player
attribute_item_solo(inventory, item) attribute_item_solo(inventory, item)
if ms_player != player: if ms_player == tracked_player: # a check done by the tracked player
continue checks_done[location_to_area[location]] += 1
checks_done[player_location_to_area[location]] += 1 checks_done["Total"] += 1
checks_done["Total"] += 1
# Note the presence of the triforce item # Note the presence of the triforce item
for (ms_team, ms_player), game_state in room.multisave.get("client_game_state", []): for (ms_team, ms_player), game_state in room.multisave.get("client_game_state", []):
# Skip teams and players not matching the request # Skip teams and players not matching the request
if ms_team != (team - 1) or ms_player != player: if ms_team != tracked_team or ms_player != tracked_player:
continue continue
if game_state: if game_state:
@ -462,8 +473,9 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
sword_url=sword_url, sword_acquired=sword_acquired, gloves_url=gloves_url, sword_url=sword_url, sword_acquired=sword_acquired, gloves_url=gloves_url,
gloves_acquired=gloves_acquired, bow_url=bow_url, bow_acquired=bow_acquired, gloves_acquired=gloves_acquired, bow_url=bow_url, bow_acquired=bow_acquired,
small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas, small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas,
key_locations=key_locations, big_key_locations=big_key_locations, mail_url=mail_url, key_locations=player_small_key_locations[tracked_player],
shield_url=shield_url, shield_acquired=shield_acquired) big_key_locations=player_big_key_locations[tracked_player],
mail_url=mail_url, shield_url=shield_url, shield_acquired=shield_acquired)
@app.route('/tracker/<suuid:tracker>') @app.route('/tracker/<suuid:tracker>')
@ -472,7 +484,7 @@ def getTracker(tracker: UUID):
room = Room.get(tracker=tracker) room = Room.get(tracker=tracker)
if not room: if not room:
abort(404) abort(404)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area = get_static_room_data(room) locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, player_big_key_locations, player_small_key_locations = get_static_room_data(room)
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1)} inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1)}
for teamnumber, team in enumerate(names)} for teamnumber, team in enumerate(names)}

View File

@ -90,6 +90,7 @@ entrance_shuffle:
# you can also define entrance shuffle seed, like so: # you can also define entrance shuffle seed, like so:
crossed-1000: 0 # using this method, you can have the same layout as another player and share entrance information crossed-1000: 0 # using this method, you can have the same layout as another player and share entrance information
# however, many other settings like logic, world state, retro etc. may affect the shuffle result as well. # however, many other settings like logic, world state, retro etc. may affect the shuffle result as well.
crossed-group-myfriends: 0 # using this method, everyone with "group-myfriends" will share the same seed
goals: goals:
ganon: 50 # Climb GT, defeat Agahnim 2, and then kill Ganon ganon: 50 # Climb GT, defeat Agahnim 2, and then kill Ganon
fast_ganon: 0 # Only killing Ganon is required. However, items may still be placed in GT fast_ganon: 0 # Only killing Ganon is required. However, items may still be placed in GT

View File

@ -147,6 +147,22 @@ boss_table = {
'Agahnim2': ('Agahnim2', AgahnimDefeatRule) 'Agahnim2': ('Agahnim2', AgahnimDefeatRule)
} }
boss_location_table = [
['Ganons Tower', 'top'],
['Tower of Hera', None],
['Skull Woods', None],
['Ganons Tower', 'middle'],
['Eastern Palace', None],
['Desert Palace', None],
['Palace of Darkness', None],
['Swamp Palace', None],
['Thieves Town', None],
['Ice Palace', None],
['Misery Mire', None],
['Turtle Rock', None],
['Ganons Tower', 'bottom'],
]
def can_place_boss(boss: str, dungeon_name: str, level: Optional[str] = None) -> bool: def can_place_boss(boss: str, dungeon_name: str, level: Optional[str] = None) -> bool:
# blacklist approach # blacklist approach
@ -183,21 +199,7 @@ def place_bosses(world, player: int):
if world.boss_shuffle[player] == 'none': if world.boss_shuffle[player] == 'none':
return return
# Most to least restrictive order # Most to least restrictive order
boss_locations = [ boss_locations = boss_location_table.copy()
['Ganons Tower', 'top'],
['Tower of Hera', None],
['Skull Woods', None],
['Ganons Tower', 'middle'],
['Eastern Palace', None],
['Desert Palace', None],
['Palace of Darkness', None],
['Swamp Palace', None],
['Thieves Town', None],
['Ice Palace', None],
['Misery Mire', None],
['Turtle Rock', None],
['Ganons Tower', 'bottom'],
]
all_bosses = sorted(boss_table.keys()) # sorted to be deterministic on older pythons all_bosses = sorted(boss_table.keys()) # sorted to be deterministic on older pythons
placeable_bosses = [boss for boss in all_bosses if boss not in ['Agahnim', 'Agahnim2', 'Ganon']] placeable_bosses = [boss for boss in all_bosses if boss not in ['Agahnim', 'Agahnim2', 'Ganon']]
@ -217,13 +219,13 @@ def place_bosses(world, player: int):
loc = loc.split(" ") loc = loc.split(" ")
level = loc[-1] level = loc[-1]
loc = " ".join(loc[:-1]) loc = " ".join(loc[:-1])
loc = loc.title() loc = loc.title().replace("Of", "of")
if can_place_boss(boss, loc, level) and [loc, level] in boss_locations: if can_place_boss(boss, loc, level) and [loc, level] in boss_locations:
place_boss(world, player, boss, loc, level) place_boss(world, player, boss, loc, level)
already_placed_bosses.append(boss) already_placed_bosses.append(boss)
boss_locations.remove([loc, level]) boss_locations.remove([loc, level])
else: else:
Exception("Cannot place", boss, "at", loc, level, "for player", player) raise Exception("Cannot place", boss, "at", loc, level, "for player", player)
else: else:
boss = boss.title() boss = boss.title()
boss_locations, already_placed_bosses = place_where_possible(world, player, boss, boss_locations) boss_locations, already_placed_bosses = place_where_possible(world, player, boss, boss_locations)