diff --git a/BaseClasses.py b/BaseClasses.py index 6816617279..cea1d48e6f 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -166,7 +166,7 @@ class MultiWorld(): self.player_types[new_id] = NetUtils.SlotType.group self._region_cache[new_id] = {} world_type = AutoWorld.AutoWorldRegister.world_types[game] - for option_key, option in world_type.options.items(): + for option_key, option in world_type.option_definitions.items(): getattr(self, option_key)[new_id] = option(option.default) for option_key, option in Options.common_options.items(): getattr(self, option_key)[new_id] = option(option.default) @@ -204,7 +204,7 @@ class MultiWorld(): for player in self.player_ids: self.custom_data[player] = {} world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] - for option_key in world_type.options: + for option_key in world_type.option_definitions: setattr(self, option_key, getattr(args, option_key, {})) self.worlds[player] = world_type(self, player) @@ -384,7 +384,6 @@ class MultiWorld(): return self.worlds[player].create_item(item_name) def push_precollected(self, item: Item): - item.world = self self.precollected_items[item.player].append(item) self.state.collect(item, True) @@ -392,7 +391,6 @@ class MultiWorld(): assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}." location.item = item item.location = location - item.world = self # try to not have this here anymore and create it with item? if collect: self.state.collect(item, location.event, location) @@ -1066,26 +1064,25 @@ class LocationProgressType(IntEnum): class Location: - # If given as integer, then this is the shop's inventory index - shop_slot: Optional[int] = None - shop_slot_disabled: bool = False + game: str = "Generic" + player: int + name: str + address: Optional[int] + parent_region: Optional[Region] event: bool = False locked: bool = False - game: str = "Generic" show_in_spoiler: bool = True - crystal: bool = False progress_type: LocationProgressType = LocationProgressType.DEFAULT always_allow = staticmethod(lambda item, state: False) access_rule = staticmethod(lambda state: True) item_rule = staticmethod(lambda item: True) item: Optional[Item] = None - parent_region: Optional[Region] - def __init__(self, player: int, name: str = '', address: int = None, parent=None): - self.name: str = name - self.address: Optional[int] = address + def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None): + self.player = player + self.name = name + self.address = address self.parent_region = parent - self.player: int = player def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state))) @@ -1102,7 +1099,6 @@ class Location: self.item = item item.location = self self.event = item.advancement - self.item.world = self.parent_region.world self.locked = True def __repr__(self): @@ -1147,39 +1143,28 @@ class ItemClassification(IntFlag): class Item: - location: Optional[Location] = None - world: Optional[MultiWorld] = None - code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata - name: str game: str = "Generic" - type: str = None + __slots__ = ("name", "classification", "code", "player", "location") + name: str classification: ItemClassification - - # need to find a decent place for these to live and to allow other games to register texts if they want. - pedestal_credit_text: str = "and the Unknown Item" - sickkid_credit_text: Optional[str] = None - magicshop_credit_text: Optional[str] = None - zora_credit_text: Optional[str] = None - fluteboy_credit_text: Optional[str] = None - - # hopefully temporary attributes to satisfy legacy LttP code, proper implementation in subclass ALttPItem - smallkey: bool = False - bigkey: bool = False - map: bool = False - compass: bool = False + code: Optional[int] + """an item with code None is called an Event, and does not get written to multidata""" + player: int + location: Optional[Location] def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int): self.name = name self.classification = classification self.player = player self.code = code + self.location = None @property - def hint_text(self): + def hint_text(self) -> str: return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " ")) @property - def pedestal_hint_text(self): + def pedestal_hint_text(self) -> str: return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " ")) @property @@ -1205,7 +1190,7 @@ class Item: def __eq__(self, other): return self.name == other.name and self.player == other.player - def __lt__(self, other: Item): + def __lt__(self, other: Item) -> bool: if other.player != self.player: return other.player < self.player return self.name < other.name @@ -1213,11 +1198,13 @@ class Item: def __hash__(self): return hash((self.name, self.player)) - def __repr__(self): + def __repr__(self) -> str: return self.__str__() - def __str__(self): - return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})' + def __str__(self) -> str: + if self.location and self.location.parent_region and self.location.parent_region.world: + return self.location.parent_region.world.get_name_string_for_object(self) + return f"{self.name} (Player {self.player})" class Spoiler(): @@ -1401,7 +1388,7 @@ class Spoiler(): outfile.write('Game: %s\n' % self.world.game[player]) for f_option, option in Options.per_game_common_options.items(): write_option(f_option, option) - options = self.world.worlds[player].options + options = self.world.worlds[player].option_definitions if options: for f_option, option in options.items(): write_option(f_option, option) diff --git a/CommonClient.py b/CommonClient.py index 76623ff3f2..0b2c22cfd8 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -562,18 +562,21 @@ async def process_server_cmd(ctx: CommonContext, args: dict): f" for each location checked. Use !hint for more information.") ctx.hint_cost = int(args['hint_cost']) ctx.check_points = int(args['location_check_points']) - players = args.get("players", []) - if len(players) < 1: - logger.info('No player connected') - else: - players.sort() - current_team = -1 - logger.info('Connected Players:') - for network_player in players: - if network_player.team != current_team: - logger.info(f' Team #{network_player.team + 1}') - current_team = network_player.team - logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) + + if "players" in args: # TODO remove when servers sending this are outdated + players = args.get("players", []) + if len(players) < 1: + logger.info('No player connected') + else: + players.sort() + current_team = -1 + logger.info('Connected Players:') + for network_player in players: + if network_player.team != current_team: + logger.info(f' Team #{network_player.team + 1}') + current_team = network_player.team + logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) + # update datapackage await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"]) @@ -723,7 +726,7 @@ if __name__ == '__main__': class TextContext(CommonContext): tags = {"AP", "IgnoreGame", "TextOnly"} game = "" # empty matches any game since 0.3.2 - items_handling = 0 # don't receive any NetworkItems + items_handling = 0b111 # receive all items for /received async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: diff --git a/FactorioClient.py b/FactorioClient.py index 2fa8ba9c15..6797578a3a 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -20,8 +20,7 @@ import Utils if __name__ == "__main__": Utils.init_logging("FactorioClient", exception_logger="Client") -from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \ - get_base_parser +from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser from MultiServer import mark_raw from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart diff --git a/Fill.py b/Fill.py index a5cf155ec1..e44c80e720 100644 --- a/Fill.py +++ b/Fill.py @@ -220,8 +220,8 @@ def distribute_items_restrictive(world: MultiWorld) -> None: world.push_item(defaultlocations.pop(i), item_to_place, False) break else: - logging.warning( - f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.") + raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. " + f"Too many non-local items for too few remaining locations.") world.random.shuffle(defaultlocations) diff --git a/Generate.py b/Generate.py index 7cfe7a504c..1cad836345 100644 --- a/Generate.py +++ b/Generate.py @@ -7,7 +7,7 @@ import urllib.request import urllib.parse from typing import Set, Dict, Tuple, Callable, Any, Union import os -from collections import Counter +from collections import Counter, ChainMap import string import enum @@ -133,12 +133,14 @@ def main(args=None, callback=ERmain): if args.meta_file_path and os.path.exists(args.meta_file_path): try: - weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path) + meta_weights = read_weights_yamls(args.meta_file_path)[-1] except Exception as e: raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e - meta_weights = weights_cache[args.meta_file_path][-1] print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") - del(meta_weights["meta_description"]) + try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file + del(meta_weights["meta_description"]) + except Exception as e: + raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e if args.samesettings: raise Exception("Cannot mix --samesettings with --meta") else: @@ -164,7 +166,7 @@ def main(args=None, callback=ERmain): player_files[player_id] = filename player_id += 1 - args.multi = max(player_id-1, args.multi) + args.multi = max(player_id - 1, args.multi) print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: " f"{args.plando}") @@ -186,26 +188,28 @@ def main(args=None, callback=ERmain): erargs.enemizercli = args.enemizercli settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ - {fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) - for fname, yamls in weights_cache.items()} - player_path_cache = {} - for player in range(1, args.multi + 1): - player_path_cache[player] = player_files.get(player, args.weights_file_path) + {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) + for fname, yamls in weights_cache.items()} if meta_weights: for category_name, category_dict in meta_weights.items(): for key in category_dict: - option = get_choice(key, category_dict) + option = roll_meta_option(key, category_name, category_dict) if option is not None: - for player, path in player_path_cache.items(): + for path in weights_cache: for yaml in weights_cache[path]: if category_name is None: - yaml[key] = option + for category in yaml: + if category in AutoWorldRegister.world_types and key in Options.common_options: + yaml[category][key] = option elif category_name not in yaml: logging.warning(f"Meta: Category {category_name} is not present in {path}.") else: - yaml[category_name][key] = option + yaml[category_name][key] = option + player_path_cache = {} + for player in range(1, args.multi + 1): + player_path_cache[player] = player_files.get(player, args.weights_file_path) name_counter = Counter() erargs.player_settings = {} @@ -387,6 +391,28 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di return weights +def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: + if not game: + return get_choice(option_key, category_dict) + if game in AutoWorldRegister.world_types: + game_world = AutoWorldRegister.world_types[game] + options = ChainMap(game_world.option_definitions, Options.per_game_common_options) + if option_key in options: + if options[option_key].supports_weighting: + return get_choice(option_key, category_dict) + return options[option_key] + if game == "A Link to the Past": # TODO wow i hate this + if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode", + "triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra", + "triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality", + "boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time", + "red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes", + "misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite", + "random_sprite_on_event"}: + return get_choice(option_key, category_dict) + raise Exception(f"Error generating meta option {option_key} for {game}.") + + def roll_linked_options(weights: dict) -> dict: weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings for option_set in weights["linked_options"]: @@ -531,7 +557,7 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) if ret.game in AutoWorldRegister.world_types: - for option_key, option in world_type.options.items(): + for option_key, option in world_type.option_definitions.items(): handle_option(ret, game_weights, option_key, option) for option_key, option in Options.per_game_common_options.items(): # skip setting this option if already set from common_options, defaulting to root option diff --git a/Main.py b/Main.py index 3912e65c99..48095e06bd 100644 --- a/Main.py +++ b/Main.py @@ -217,9 +217,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info("Running Item Plando") - for item in world.itempool: - item.world = world - distribute_planned(world) logger.info('Running Pre Main Fill.') @@ -426,7 +423,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) zipfilename = output_path(f"AP_{world.seed_name}.zip") - logger.info(f'Creating final archive at {zipfilename}.') + logger.info(f"Creating final archive at {zipfilename}") with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf: for file in os.scandir(temp_dir): diff --git a/MultiServer.py b/MultiServer.py index e8e1cc8d4c..8a1844bf92 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -30,13 +30,8 @@ except ImportError: OperationalError = ConnectionError import NetUtils -from worlds.AutoWorld import AutoWorldRegister - -proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()} -from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name import Utils -from Utils import get_item_name_from_id, get_location_name_from_id, \ - version_tuple, restricted_loads, Version +from Utils import version_tuple, restricted_loads, Version from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ SlotType @@ -126,6 +121,11 @@ class Context: stored_data: typing.Dict[str, object] stored_data_notification_clients: typing.Dict[str, typing.Set[Client]] + item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') + location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') + all_item_and_group_names: typing.Dict[str, typing.Set[str]] + forced_auto_forfeits: typing.Dict[str, bool] + def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, @@ -190,8 +190,43 @@ class Context: self.stored_data = {} self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet) - # General networking + # init empty to satisfy linter, I suppose + self.gamespackage = {} + self.item_name_groups = {} + self.all_item_and_group_names = {} + self.forced_auto_forfeits = collections.defaultdict(lambda: False) + self.non_hintable_names = {} + self._load_game_data() + self._init_game_data() + + # Datapackage retrieval + def _load_game_data(self): + import worlds + self.gamespackage = worlds.network_data_package["games"] + + self.item_name_groups = {world_name: world.item_name_groups for world_name, world in + worlds.AutoWorldRegister.world_types.items()} + for world_name, world in worlds.AutoWorldRegister.world_types.items(): + self.forced_auto_forfeits[world_name] = world.forced_auto_forfeit + self.non_hintable_names[world_name] = world.hint_blacklist + + def _init_game_data(self): + for game_name, game_package in self.gamespackage.items(): + for item_name, item_id in game_package["item_name_to_id"].items(): + self.item_names[item_id] = item_name + for location_name, location_id in game_package["location_name_to_id"].items(): + self.location_names[location_id] = location_name + self.all_item_and_group_names[game_name] = \ + set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) + + def item_names_for_game(self, game: str) -> typing.Dict[str, int]: + return self.gamespackage[game]["item_name_to_id"] + + def location_names_for_game(self, game: str) -> typing.Dict[str, int]: + return self.gamespackage[game]["location_name_to_id"] + + # General networking async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool: if not endpoint.socket or not endpoint.socket.open: return False @@ -544,12 +579,12 @@ class Context: finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \ f' has completed their goal.' self.notify_all(finished_msg) - if "auto" in self.forfeit_mode: - forfeit_player(self, client.team, client.slot) - elif proxy_worlds[self.games[client.slot]].forced_auto_forfeit: - forfeit_player(self, client.team, client.slot) if "auto" in self.collect_mode: collect_player(self, client.team, client.slot) + if "auto" in self.forfeit_mode: + forfeit_player(self, client.team, client.slot) + elif self.forced_auto_forfeits[self.games[client.slot]]: + forfeit_player(self, client.team, client.slot) def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False): @@ -642,9 +677,10 @@ async def on_client_connected(ctx: Context, client: Client): 'permissions': get_permissions(ctx), 'hint_cost': ctx.hint_cost, 'location_check_points': ctx.location_check_points, - 'datapackage_version': network_data_package["version"], + 'datapackage_version': sum(game_data["version"] for game_data in ctx.gamespackage.values()) + if all(game_data["version"] for game_data in ctx.gamespackage.values()) else 0, 'datapackage_versions': {game: game_data["version"] for game, game_data - in network_data_package["games"].items()}, + in ctx.gamespackage.items()}, 'seed_name': ctx.seed_name, 'time': time.time(), }]) @@ -822,8 +858,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi send_items_to(ctx, team, target_player, new_item) logging.info('(Team #%d) %s sent %s to %s (%s)' % ( - team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id), - ctx.player_names[(team, target_player)], get_location_name_from_id(location))) + team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id], + ctx.player_names[(team, target_player)], ctx.location_names[location])) info_text = json_format_send_event(new_item, target_player) ctx.broadcast_team(team, [info_text]) @@ -838,13 +874,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi ctx.save() -def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]: +def collect_hints(ctx: Context, team: int, slot: int, item_name: str) -> typing.List[NetUtils.Hint]: hints = [] slots: typing.Set[int] = {slot} for group_id, group in ctx.groups.items(): if slot in group: slots.add(group_id) - seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item] + + seeked_item_id = ctx.item_names_for_game(ctx.games[slot])[item_name] for finding_player, check_data in ctx.locations.items(): for location_id, (item_id, receiving_player, item_flags) in check_data.items(): if receiving_player in slots and item_id == seeked_item_id: @@ -857,7 +894,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[ def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]: - seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location] + seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location] return collect_hint_location_id(ctx, team, slot, seeked_location) @@ -874,8 +911,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str: text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ - f"{lookup_any_item_id_to_name[hint.item]} is " \ - f"at {get_location_name_from_id(hint.location)} " \ + f"{ctx.item_names[hint.item]} is " \ + f"at {ctx.location_names[hint.location]} " \ f"in {ctx.player_names[team, hint.finding_player]}'s World" if hint.entrance: @@ -1133,8 +1170,8 @@ class ClientMessageProcessor(CommonCommandProcessor): forfeit_player(self.ctx, self.client.team, self.client.slot) return True elif "disabled" in self.ctx.forfeit_mode: - self.output( - "Sorry, client item releasing has been disabled on this server. You can ask the server admin for a /release") + self.output("Sorry, client item releasing has been disabled on this server. " + "You can ask the server admin for a /release") return False else: # is auto or goal if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: @@ -1170,7 +1207,7 @@ class ClientMessageProcessor(CommonCommandProcessor): if self.ctx.remaining_mode == "enabled": remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) if remaining_item_ids: - self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item") + self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id] for item_id in remaining_item_ids)) else: self.output("No remaining items found.") @@ -1183,7 +1220,7 @@ class ClientMessageProcessor(CommonCommandProcessor): if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) if remaining_item_ids: - self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item") + self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id] for item_id in remaining_item_ids)) else: self.output("No remaining items found.") @@ -1199,7 +1236,7 @@ class ClientMessageProcessor(CommonCommandProcessor): locations = get_missing_checks(self.ctx, self.client.team, self.client.slot) if locations: - texts = [f'Missing: {get_location_name_from_id(location)}' for location in locations] + texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations] texts.append(f"Found {len(locations)} missing location checks") self.ctx.notify_client_multiple(self.client, texts) else: @@ -1212,7 +1249,7 @@ class ClientMessageProcessor(CommonCommandProcessor): locations = get_checked_checks(self.ctx, self.client.team, self.client.slot) if locations: - texts = [f'Checked: {get_location_name_from_id(location)}' for location in locations] + texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations] texts.append(f"Found {len(locations)} done location checks") self.ctx.notify_client_multiple(self.client, texts) else: @@ -1241,11 +1278,13 @@ class ClientMessageProcessor(CommonCommandProcessor): def _cmd_getitem(self, item_name: str) -> bool: """Cheat in an item, if it is enabled on this server""" if self.ctx.item_cheat: - world = proxy_worlds[self.ctx.games[self.client.slot]] - item_name, usable, response = get_intended_text(item_name, - world.item_names) + names = self.ctx.item_names_for_game(self.ctx.games[self.client.slot]) + item_name, usable, response = get_intended_text( + item_name, + names + ) if usable: - new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot) + new_item = NetworkItem(names[item_name], -1, self.client.slot) get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item) get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item) self.ctx.notify_all( @@ -1271,20 +1310,22 @@ class ClientMessageProcessor(CommonCommandProcessor): f"You have {points_available} points.") return True else: - world = proxy_worlds[self.ctx.games[self.client.slot]] - names = world.location_names if for_location else world.all_item_and_group_names + game = self.ctx.games[self.client.slot] + names = self.ctx.location_names_for_game(game) \ + if for_location else \ + self.ctx.all_item_and_group_names[game] hint_name, usable, response = get_intended_text(input_text, names) if usable: - if hint_name in world.hint_blacklist: + if hint_name in self.ctx.non_hintable_names[game]: self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") hints = [] - elif not for_location and hint_name in world.item_name_groups: # item group name + elif not for_location and hint_name in self.ctx.item_name_groups[game]: # item group name hints = [] - for item in world.item_name_groups[hint_name]: - if item in world.item_name_to_id: # ensure item has an ID - hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item)) - elif not for_location and hint_name in world.item_names: # item name + for item_name in self.ctx.item_name_groups[game][hint_name]: + if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID + hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name)) + elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) else: # location name hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) @@ -1346,12 +1387,12 @@ class ClientMessageProcessor(CommonCommandProcessor): return False @mark_raw - def _cmd_hint(self, item: str = "") -> bool: + def _cmd_hint(self, item_name: str = "") -> bool: """Use !hint {item_name}, for example !hint Lamp to get a spoiler peek for that item. If hint costs are on, this will only give you one new result, you can rerun the command to get more in that case.""" - return self.get_hints(item) + return self.get_hints(item_name) @mark_raw def _cmd_hint_location(self, location: str = "") -> bool: @@ -1477,23 +1518,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): elif cmd == "GetDataPackage": exclusions = args.get("exclusions", []) if "games" in args: - games = {name: game_data for name, game_data in network_data_package["games"].items() + games = {name: game_data for name, game_data in ctx.gamespackage.items() if name in set(args.get("games", []))} await ctx.send_msgs(client, [{"cmd": "DataPackage", "data": {"games": games}}]) # TODO: remove exclusions behaviour around 0.5.0 elif exclusions: exclusions = set(exclusions) - games = {name: game_data for name, game_data in network_data_package["games"].items() + games = {name: game_data for name, game_data in ctx.gamespackage.items() if name not in exclusions} - package = network_data_package.copy() - package["games"] = games + + package = {"games": games} await ctx.send_msgs(client, [{"cmd": "DataPackage", "data": package}]) else: await ctx.send_msgs(client, [{"cmd": "DataPackage", - "data": network_data_package}]) + "data": {"games": ctx.gamespackage}}]) elif client.auth: if cmd == "ConnectUpdate": @@ -1549,7 +1590,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): create_as_hint: int = int(args.get("create_as_hint", 0)) hints = [] for location in args["locations"]: - if type(location) is not int or location not in lookup_any_location_id_to_name: + if type(location) is not int: await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts', "original_cmd": cmd}]) @@ -1763,18 +1804,18 @@ class ServerCommandProcessor(CommonCommandProcessor): seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) if usable: team, slot = self.ctx.player_name_lookup[seeked_player] - item = " ".join(item_name) - world = proxy_worlds[self.ctx.games[slot]] - item, usable, response = get_intended_text(item, world.item_names) + item_name = " ".join(item_name) + names = self.ctx.item_names_for_game(self.ctx.games[slot]) + item_name, usable, response = get_intended_text(item_name, names) if usable: amount: int = int(amount) - new_items = [NetworkItem(world.item_name_to_id[item], -1, 0) for i in range(int(amount))] + new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))] send_items_to(self.ctx, team, slot, *new_items) send_new_items(self.ctx) self.ctx.notify_all( 'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') + - f'"{item}" to {self.ctx.get_aliased_name(team, slot)}') + f'"{item_name}" to {self.ctx.get_aliased_name(team, slot)}') return True else: self.output(response) @@ -1787,22 +1828,22 @@ class ServerCommandProcessor(CommonCommandProcessor): """Sends an item to the specified player""" return self._cmd_send_multiple(1, player_name, *item_name) - def _cmd_hint(self, player_name: str, *item: str) -> bool: + def _cmd_hint(self, player_name: str, *item_name: str) -> bool: """Send out a hint for a player's item to their team""" seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) if usable: team, slot = self.ctx.player_name_lookup[seeked_player] - item = " ".join(item) - world = proxy_worlds[self.ctx.games[slot]] - item, usable, response = get_intended_text(item, world.all_item_and_group_names) + item_name = " ".join(item_name) + game = self.ctx.games[slot] + item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game]) if usable: - if item in world.item_name_groups: + if item_name in self.ctx.item_name_groups[game]: hints = [] - for item in world.item_name_groups[item]: - if item in world.item_name_to_id: # ensure item has an ID - hints.extend(collect_hints(self.ctx, team, slot, item)) + for item_name_from_group in self.ctx.item_name_groups[game][item_name]: + if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID + hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group)) else: # item name - hints = collect_hints(self.ctx, team, slot, item) + hints = collect_hints(self.ctx, team, slot, item_name) if hints: notify_hints(self.ctx, team, hints) @@ -1818,16 +1859,16 @@ class ServerCommandProcessor(CommonCommandProcessor): self.output(response) return False - def _cmd_hint_location(self, player_name: str, *location: str) -> bool: + def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool: """Send out a hint for a player's location to their team""" seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) if usable: team, slot = self.ctx.player_name_lookup[seeked_player] - item = " ".join(location) - world = proxy_worlds[self.ctx.games[slot]] - item, usable, response = get_intended_text(item, world.location_names) + location_name = " ".join(location_name) + location_name, usable, response = get_intended_text(location_name, + self.ctx.location_names_for_game(self.ctx.games[slot])) if usable: - hints = collect_hint_location_name(self.ctx, team, slot, item) + hints = collect_hint_location_name(self.ctx, team, slot, location_name) if hints: notify_hints(self.ctx, team, hints) else: diff --git a/NetUtils.py b/NetUtils.py index 6cf3ab6d41..1e7d66d824 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -270,7 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser): color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43, - 'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47} + 'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47} def color_code(*args): diff --git a/Starcraft2Client.py b/Starcraft2Client.py index e9e06335ac..dc63e9a456 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -44,11 +44,38 @@ nest_asyncio.apply() class StarcraftClientProcessor(ClientCommandProcessor): ctx: SC2Context + def _cmd_difficulty(self, difficulty: str = "") -> bool: + """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal""" + options = difficulty.split() + num_options = len(options) + difficulty_choice = options[0].lower() + + if num_options > 0: + if difficulty_choice == "casual": + self.ctx.difficulty_override = 0 + elif difficulty_choice == "normal": + self.ctx.difficulty_override = 1 + elif difficulty_choice == "hard": + self.ctx.difficulty_override = 2 + elif difficulty_choice == "brutal": + self.ctx.difficulty_override = 3 + else: + self.output("Unable to parse difficulty '" + options[0] + "'") + return False + + self.output("Difficulty set to " + options[0]) + return True + + else: + self.output("Difficulty needs to be specified in the command.") + return False + def _cmd_disable_mission_check(self) -> bool: """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play the next mission in a chain the other player is doing.""" self.ctx.missions_unlocked = True sc2_logger.info("Mission check has been disabled") + return True def _cmd_play(self, mission_id: str = "") -> bool: """Start a Starcraft 2 mission""" @@ -64,6 +91,7 @@ class StarcraftClientProcessor(ClientCommandProcessor): else: sc2_logger.info( "Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.") + return False return True @@ -108,6 +136,7 @@ class SC2Context(CommonContext): missions_unlocked = False current_tooltip = None last_loc_list = None + difficulty_override = -1 async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -470,7 +499,10 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): game_state = 0 if iteration == 0: start_items = calculate_items(self.ctx.items_received) - difficulty = calc_difficulty(self.ctx.difficulty) + if self.ctx.difficulty_override >= 0: + difficulty = calc_difficulty(self.ctx.difficulty_override) + else: + difficulty = calc_difficulty(self.ctx.difficulty) await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format( difficulty, start_items[0], start_items[1], start_items[2], start_items[3], start_items[4], diff --git a/Utils.py b/Utils.py index 4d3d6b134b..bb29166f75 100644 --- a/Utils.py +++ b/Utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import shutil import typing import builtins import os @@ -12,12 +11,18 @@ import io import collections import importlib import logging -import decimal +from yaml import load, load_all, dump, SafeLoader + +try: + from yaml import CLoader as UnsafeLoader + from yaml import CDumper as Dumper +except ImportError: + from yaml import Loader as UnsafeLoader + from yaml import Dumper if typing.TYPE_CHECKING: - from tkinter import Tk -else: - Tk = typing.Any + import tkinter + import pathlib def tuplize_version(version: str) -> Version: @@ -30,21 +35,13 @@ class Version(typing.NamedTuple): build: int -__version__ = "0.3.3" +__version__ = "0.3.4" version_tuple = tuplize_version(__version__) -is_linux = sys.platform.startswith('linux') -is_macos = sys.platform == 'darwin' +is_linux = sys.platform.startswith("linux") +is_macos = sys.platform == "darwin" is_windows = sys.platform in ("win32", "cygwin", "msys") -import jellyfish -from yaml import load, load_all, dump, SafeLoader - -try: - from yaml import CLoader as Loader -except ImportError: - from yaml import Loader - def int16_as_bytes(value: int) -> typing.List[int]: value = value & 0xFFFF @@ -125,17 +122,18 @@ def home_path(*path: str) -> str: def user_path(*path: str) -> str: """Returns either local_path or home_path based on write permissions.""" - if hasattr(user_path, 'cached_path'): + if hasattr(user_path, "cached_path"): pass elif os.access(local_path(), os.W_OK): user_path.cached_path = local_path() else: user_path.cached_path = home_path() # populate home from local - TODO: upgrade feature - if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')): - for dn in ('Players', 'data/sprites'): + if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")): + import shutil + for dn in ("Players", "data/sprites"): shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) - for fn in ('manifest.json', 'host.yaml'): + for fn in ("manifest.json", "host.yaml"): shutil.copy2(local_path(fn), user_path(fn)) return os.path.join(user_path.cached_path, *path) @@ -150,11 +148,12 @@ def output_path(*path: str): return path -def open_file(filename): - if sys.platform == 'win32': +def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: + if is_windows: os.startfile(filename) else: - open_command = 'open' if sys.platform == 'darwin' else 'xdg-open' + from shutil import which + open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) subprocess.call([open_command, filename]) @@ -173,7 +172,9 @@ class UniqueKeyLoader(SafeLoader): parse_yaml = functools.partial(load, Loader=UniqueKeyLoader) parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader) -unsafe_parse_yaml = functools.partial(load, Loader=Loader) +unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader) + +del load, load_all # should not be used. don't leak their names def get_cert_none_ssl_context(): @@ -191,11 +192,12 @@ def get_public_ipv4() -> str: ip = socket.gethostbyname(socket.gethostname()) ctx = get_cert_none_ssl_context() try: - ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip() + ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip() except Exception as e: + # noinspection PyBroadException try: - ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip() - except: + ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip() + except Exception: logging.exception(e) pass # we could be offline, in a local game, so no point in erroring out return ip @@ -208,7 +210,7 @@ def get_public_ipv6() -> str: ip = socket.gethostbyname(socket.gethostname()) ctx = get_cert_none_ssl_context() try: - ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip() + ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip() except Exception as e: logging.exception(e) pass # we could be offline, in a local game, or ipv6 may not be available @@ -309,33 +311,19 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict: @cache_argsless def get_options() -> dict: - if not hasattr(get_options, "options"): - filenames = ("options.yaml", "host.yaml") - locations = [] - if os.path.join(os.getcwd()) != local_path(): - locations += filenames # use files from cwd only if it's not the local_path - locations += [user_path(filename) for filename in filenames] + filenames = ("options.yaml", "host.yaml") + locations = [] + if os.path.join(os.getcwd()) != local_path(): + locations += filenames # use files from cwd only if it's not the local_path + locations += [user_path(filename) for filename in filenames] - for location in locations: - if os.path.exists(location): - with open(location) as f: - options = parse_yaml(f.read()) + for location in locations: + if os.path.exists(location): + with open(location) as f: + options = parse_yaml(f.read()) + return update_options(get_default_options(), options, location, list()) - get_options.options = update_options(get_default_options(), options, location, list()) - break - else: - raise FileNotFoundError(f"Could not find {filenames[1]} to load options.") - return get_options.options - - -def get_item_name_from_id(code: int) -> str: - from worlds import lookup_any_item_id_to_name - return lookup_any_item_id_to_name.get(code, f'Unknown item (ID:{code})') - - -def get_location_name_from_id(code: int) -> str: - from worlds import lookup_any_location_id_to_name - return lookup_any_location_id_to_name.get(code, f'Unknown location (ID:{code})') + raise FileNotFoundError(f"Could not find {filenames[1]} to load options.") def persistent_store(category: str, key: typing.Any, value: typing.Any): @@ -344,10 +332,10 @@ def persistent_store(category: str, key: typing.Any, value: typing.Any): category = storage.setdefault(category, {}) category[key] = value with open(path, "wt") as f: - f.write(dump(storage)) + f.write(dump(storage, Dumper=Dumper)) -def persistent_load() -> typing.Dict[dict]: +def persistent_load() -> typing.Dict[str, dict]: storage = getattr(persistent_load, "storage", None) if storage: return storage @@ -365,8 +353,8 @@ def persistent_load() -> typing.Dict[dict]: return storage -def get_adjuster_settings(gameName: str): - adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {}) +def get_adjuster_settings(game_name: str): + adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {}) return adjuster_settings @@ -382,10 +370,10 @@ def get_unique_identifier(): return uuid -safe_builtins = { +safe_builtins = frozenset(( 'set', 'frozenset', -} +)) class RestrictedUnpickler(pickle.Unpickler): @@ -413,8 +401,7 @@ class RestrictedUnpickler(pickle.Unpickler): if issubclass(obj, self.options_module.Option): return obj # Forbid everything else. - raise pickle.UnpicklingError("global '%s.%s' is forbidden" % - (module, name)) + raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") def restricted_loads(s): @@ -423,6 +410,9 @@ def restricted_loads(s): class KeyedDefaultDict(collections.defaultdict): + """defaultdict variant that uses the missing key as argument to default_factory""" + default_factory: typing.Callable[[typing.Any], typing.Any] + def __missing__(self, key): self[key] = value = self.default_factory(key) return value @@ -493,11 +483,11 @@ def stream_input(stream, queue): return thread -def tkinter_center_window(window: Tk): +def tkinter_center_window(window: "tkinter.Tk") -> None: window.update() - xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2) - yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2) - window.geometry("+{}+{}".format(xPos, yPos)) + x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2) + y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2) + window.geometry(f"+{x}+{y}") class VersionException(Exception): @@ -514,24 +504,27 @@ def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str: # noinspection PyPep8Naming -def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str: +def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P", "E", "Z", "Y")) -> str: """Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix""" + import decimal n = 0 value = decimal.Decimal(value) - while value >= power: + limit = power - decimal.Decimal("0.005") + while value >= limit: value /= power n += 1 return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}" -def get_fuzzy_ratio(word1: str, word2: str) -> float: - return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) - / max(len(word1), len(word2))) - - def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \ -> typing.List[typing.Tuple[str, int]]: + import jellyfish + + def get_fuzzy_ratio(word1: str, word2: str) -> float: + return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) + / max(len(word1), len(word2))) + limit: int = limit if limit else len(wordlist) return list( map( @@ -549,18 +542,19 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \ -> typing.Optional[str]: def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None + return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None if is_linux: # prefer native dialog - kdialog = shutil.which('kdialog') + from shutil import which + kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters) - zenity = shutil.which('zenity') + return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters) + zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) - return run(zenity, f'--title={title}', '--file-selection', *z_filters) + return run(zenity, f"--title={title}", "--file-selection", *z_filters) # fall back to tk try: @@ -578,10 +572,10 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin def messagebox(title: str, text: str, error: bool = False) -> None: def run(*args: str): - return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None + return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None def is_kivy_running(): - if 'kivy' in sys.modules: + if "kivy" in sys.modules: from kivy.app import App return App.get_running_app() is not None return False @@ -591,14 +585,15 @@ def messagebox(title: str, text: str, error: bool = False) -> None: MessageBox(title, text, error).open() return - if is_linux and not 'tkinter' in sys.modules: + if is_linux and "tkinter" not in sys.modules: # prefer native dialog - kdialog = shutil.which('kdialog') + from shutil import which + kdialog = which("kdialog") if kdialog: - return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text) - zenity = shutil.which('zenity') + return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) + zenity = which("zenity") if zenity: - return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info') + return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") # fall back to tk try: @@ -613,3 +608,14 @@ def messagebox(title: str, text: str, error: bool = False) -> None: root.withdraw() showerror(title, text) if error else showinfo(title, text) root.update() + + +def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))): + """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" + def sorter(element: str) -> str: + parts = element.split(maxsplit=1) + if parts[0].lower() in ignore: + return parts[1] + else: + return element + return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i)) diff --git a/WebHost.py b/WebHost.py index eb575df3e9..3d3c8678e2 100644 --- a/WebHost.py +++ b/WebHost.py @@ -14,7 +14,7 @@ import Utils Utils.local_path.cached_path = os.path.dirname(__file__) -from WebHostLib import app as raw_app +from WebHostLib import register, app as raw_app from waitress import serve from WebHostLib.models import db @@ -22,14 +22,13 @@ from WebHostLib.autolauncher import autohost, autogen from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.options import create as create_options_files -from worlds.AutoWorld import AutoWorldRegister - configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home configpath = os.path.abspath(Utils.user_path('config.yaml')) def get_app(): + register() app = raw_app if os.path.exists(configpath): import yaml @@ -43,19 +42,40 @@ def get_app(): def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]: import json import shutil + import pathlib + import zipfile + + zfile: zipfile.ZipInfo + + from worlds.AutoWorld import AutoWorldRegister, __file__ worlds = {} data = [] for game, world in AutoWorldRegister.world_types.items(): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): worlds[game] = world + + base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs") for game, world in worlds.items(): # copy files from world's docs folder to the generated folder - source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs') - target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game) - files = os.listdir(source_path) - for file in files: - os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True) - shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file)) + target_path = os.path.join(base_target_path, game) + os.makedirs(target_path, exist_ok=True) + + if world.is_zip: + zipfile_path = pathlib.Path(world.__file__).parents[1] + + assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)." + assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile." + + with zipfile.ZipFile(zipfile_path) as zf: + for zfile in zf.infolist(): + if not zfile.is_dir() and "/docs/" in zfile.filename: + zf.extract(zfile, target_path) + else: + source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") + files = os.listdir(source_path) + for file in files: + shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file)) + # build a json tutorial dict per game game_data = {'gameTitle': game, 'tutorials': []} for tutorial in world.web.tutorials: @@ -85,7 +105,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] for games in data: if 'Archipelago' in games['gameTitle']: generic_data = data.pop(data.index(games)) - sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower()) + sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"].lower()) json.dump(sorted_data, json_target, indent=2, ensure_ascii=False) return sorted_data diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index a44afc744a..b7bf4e38d1 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -3,13 +3,13 @@ import uuid import base64 import socket -import jinja2.exceptions from pony.flask import Pony -from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory +from flask import Flask from flask_caching import Cache from flask_compress import Compress -from worlds.AutoWorld import AutoWorldRegister +from werkzeug.routing import BaseConverter +from Utils import title_sorted from .models import * UPLOAD_FOLDER = os.path.relpath('uploads') @@ -53,8 +53,6 @@ app.config["PATCH_TARGET"] = "archipelago.gg" cache = Cache(app) Compress(app) -from werkzeug.routing import BaseConverter - class B64UUIDConverter(BaseConverter): @@ -68,174 +66,18 @@ class B64UUIDConverter(BaseConverter): # short UUID app.url_map.converters["suuid"] = B64UUIDConverter app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') - -# has automatic patch integration -import Patch -app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types +app.jinja_env.filters["title_sorted"] = title_sorted -def get_world_theme(game_name: str): - if game_name in AutoWorldRegister.world_types: - return AutoWorldRegister.world_types[game_name].web.theme - return 'grass' +def register(): + """Import submodules, triggering their registering on flask routing. + Note: initializes worlds subsystem.""" + # has automatic patch integration + import Patch + app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types + from WebHostLib.customserver import run_server_process + # to trigger app routing picking up on it + from . import tracker, upload, landing, check, generate, downloads, api, stats, misc -@app.before_request -def register_session(): - session.permanent = True # technically 31 days after the last visit - if not session.get("_id", None): - session["_id"] = uuid4() # uniquely identify each session without needing a login - - -@app.errorhandler(404) -@app.errorhandler(jinja2.exceptions.TemplateNotFound) -def page_not_found(err): - return render_template('404.html'), 404 - - -# Start Playing Page -@app.route('/start-playing') -def start_playing(): - return render_template(f"startPlaying.html") - - -@app.route('/weighted-settings') -def weighted_settings(): - return render_template(f"weighted-settings.html") - - -# Player settings pages -@app.route('/games//player-settings') -def player_settings(game): - return render_template(f"player-settings.html", game=game, theme=get_world_theme(game)) - - -# Game Info Pages -@app.route('/games//info/') -def game_info(game, lang): - return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) - - -# List of supported games -@app.route('/games') -def games(): - worlds = {} - for game, world in AutoWorldRegister.world_types.items(): - if not world.hidden: - worlds[game] = world - return render_template("supportedGames.html", worlds=worlds) - - -@app.route('/tutorial///') -def tutorial(game, file, lang): - return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) - - -@app.route('/tutorial/') -def tutorial_landing(): - worlds = {} - for game, world in AutoWorldRegister.world_types.items(): - if not world.hidden: - worlds[game] = world - return render_template("tutorialLanding.html") - - -@app.route('/faq//') -def faq(lang): - return render_template("faq.html", lang=lang) - - -@app.route('/glossary//') -def terms(lang): - return render_template("glossary.html", lang=lang) - - -@app.route('/seed/') -def view_seed(seed: UUID): - seed = Seed.get(id=seed) - if not seed: - abort(404) - return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots)) - - -@app.route('/new_room/') -def new_room(seed: UUID): - seed = Seed.get(id=seed) - if not seed: - abort(404) - room = Room(seed=seed, owner=session["_id"], tracker=uuid4()) - commit() - return redirect(url_for("host_room", room=room.id)) - - -def _read_log(path: str): - if os.path.exists(path): - with open(path, encoding="utf-8-sig") as log: - yield from log - else: - yield f"Logfile {path} does not exist. " \ - f"Likely a crash during spinup of multiworld instance or it is still spinning up." - - -@app.route('/log/') -def display_log(room: UUID): - room = Room.get(id=room) - if room is None: - return abort(404) - if room.owner == session["_id"]: - return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8") - return "Access Denied", 403 - - -@app.route('/room/', methods=['GET', 'POST']) -def host_room(room: UUID): - room = Room.get(id=room) - if room is None: - return abort(404) - if request.method == "POST": - if room.owner == session["_id"]: - cmd = request.form["cmd"] - if cmd: - Command(room=room, commandtext=cmd) - commit() - - with db_session: - room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running - - return render_template("hostRoom.html", room=room) - - -@app.route('/favicon.ico') -def favicon(): - return send_from_directory(os.path.join(app.root_path, 'static/static'), - 'favicon.ico', mimetype='image/vnd.microsoft.icon') - - -@app.route('/discord') -def discord(): - return redirect("https://discord.gg/archipelago") - - -@app.route('/datapackage') -@cache.cached() -def get_datapackge(): - """A pretty print version of /api/datapackage""" - from worlds import network_data_package - import json - return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain") - - -@app.route('/index') -@app.route('/sitemap') -def get_sitemap(): - available_games = [] - for game, world in AutoWorldRegister.world_types.items(): - if not world.hidden: - available_games.append(game) - return render_template("siteMap.html", games=available_games) - - -from WebHostLib.customserver import run_server_process -from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it - -app.register_blueprint(api.api_endpoints) + app.register_blueprint(api.api_endpoints) diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index 3ca15b4acf..28319bb6c3 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -32,14 +32,14 @@ def room_info(room: UUID): @api_endpoints.route('/datapackage') @cache.cached() -def get_datapackge(): +def get_datapackage(): from worlds import network_data_package return network_data_package @api_endpoints.route('/datapackage_version') @cache.cached() -def get_datapackge_versions(): +def get_datapackage_versions(): from worlds import network_data_package, AutoWorldRegister version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()} version_package["version"] = network_data_package["version"] diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 6f978211fb..77445eadea 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -184,7 +184,7 @@ class MultiworldInstance(): logging.info(f"Spinning up {self.room_id}") process = multiprocessing.Process(group=None, target=run_server_process, - args=(self.room_id, self.ponyconfig), + args=(self.room_id, self.ponyconfig, get_static_server_data()), name="MultiHost") process.start() # bind after start to prevent thread sync issues with guardian. @@ -238,5 +238,5 @@ def run_guardian(): from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed -from .customserver import run_server_process +from .customserver import run_server_process, get_static_server_data from .generate import gen_game diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index f78a8eb24d..01f1fd25e5 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -9,12 +9,13 @@ import time import random import pickle import logging +import datetime import Utils -from .models import * +from .models import db_session, Room, select, commit, Command, db from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor -from Utils import get_public_ipv4, get_public_ipv6, restricted_loads +from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless class CustomClientMessageProcessor(ClientMessageProcessor): @@ -39,7 +40,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor): import MultiServer MultiServer.client_message_processor = CustomClientMessageProcessor -del (MultiServer) +del MultiServer class DBCommandProcessor(ServerCommandProcessor): @@ -48,12 +49,20 @@ class DBCommandProcessor(ServerCommandProcessor): class WebHostContext(Context): - def __init__(self): + def __init__(self, static_server_data: dict): + # static server data is used during _load_game_data to load required data, + # without needing to import worlds system, which takes quite a bit of memory + self.static_server_data = static_server_data super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2) + del self.static_server_data self.main_loop = asyncio.get_running_loop() self.video = {} self.tags = ["AP", "WebHost"] + def _load_game_data(self): + for key, value in self.static_server_data.items(): + setattr(self, key, value) + def listen_to_db_commands(self): cmdprocessor = DBCommandProcessor(self) @@ -107,14 +116,32 @@ def get_random_port(): return random.randint(49152, 65535) -def run_server_process(room_id, ponyconfig: dict): +@cache_argsless +def get_static_server_data() -> dict: + import worlds + data = { + "forced_auto_forfeits": {}, + "non_hintable_names": {}, + "gamespackage": worlds.network_data_package["games"], + "item_name_groups": {world_name: world.item_name_groups for world_name, world in + worlds.AutoWorldRegister.world_types.items()}, + } + + for world_name, world in worlds.AutoWorldRegister.world_types.items(): + data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit + data["non_hintable_names"][world_name] = world.hint_blacklist + + return data + + +def run_server_process(room_id, ponyconfig: dict, static_server_data: dict): # establish DB connection for multidata and multisave db.bind(**ponyconfig) db.generate_mapping(check_tables=False) async def main(): Utils.init_logging(str(room_id), write_mode="a") - ctx = WebHostContext() + ctx = WebHostContext(static_server_data) ctx.load(room_id) ctx.init_save() diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py index 0704f5d0ec..528cbe5ec0 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -36,14 +36,14 @@ def download_patch(room_id, patch_id): fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \ f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}" new_file.seek(0) - return send_file(new_file, as_attachment=True, attachment_filename=fname) + return send_file(new_file, as_attachment=True, download_name=fname) else: patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}") patch_data = BytesIO(patch_data) fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \ f"{preferred_endings[patch.game]}" - return send_file(patch_data, as_attachment=True, attachment_filename=fname) + return send_file(patch_data, as_attachment=True, download_name=fname) @app.route("/dl_spoiler/") @@ -66,7 +66,7 @@ def download_slot_file(room_id, player_id: int): from worlds.minecraft import mc_update_output fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc" data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port) - return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname) + return send_file(io.BytesIO(data), as_attachment=True, download_name=fname) elif slot_data.game == "Factorio": with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: for name in zf.namelist(): @@ -82,7 +82,7 @@ def download_slot_file(room_id, player_id: int): fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json" else: return "Game download not supported." - return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname) + return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname) @app.route("/templates") diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py new file mode 100644 index 0000000000..03cd03b624 --- /dev/null +++ b/WebHostLib/misc.py @@ -0,0 +1,173 @@ +import datetime +import os + +import jinja2.exceptions +from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory + +from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4 +from worlds.AutoWorld import AutoWorldRegister +from . import app, cache + + +def get_world_theme(game_name: str): + if game_name in AutoWorldRegister.world_types: + return AutoWorldRegister.world_types[game_name].web.theme + return 'grass' + + +@app.before_request +def register_session(): + session.permanent = True # technically 31 days after the last visit + if not session.get("_id", None): + session["_id"] = uuid4() # uniquely identify each session without needing a login + + +@app.errorhandler(404) +@app.errorhandler(jinja2.exceptions.TemplateNotFound) +def page_not_found(err): + return render_template('404.html'), 404 + + +# Start Playing Page +@app.route('/start-playing') +def start_playing(): + return render_template(f"startPlaying.html") + + +@app.route('/weighted-settings') +def weighted_settings(): + return render_template(f"weighted-settings.html") + + +# Player settings pages +@app.route('/games//player-settings') +def player_settings(game): + return render_template(f"player-settings.html", game=game, theme=get_world_theme(game)) + + +# Game Info Pages +@app.route('/games//info/') +def game_info(game, lang): + return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) + + +# List of supported games +@app.route('/games') +def games(): + worlds = {} + for game, world in AutoWorldRegister.world_types.items(): + if not world.hidden: + worlds[game] = world + return render_template("supportedGames.html", worlds=worlds) + + +@app.route('/tutorial///') +def tutorial(game, file, lang): + return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) + + +@app.route('/tutorial/') +def tutorial_landing(): + worlds = {} + for game, world in AutoWorldRegister.world_types.items(): + if not world.hidden: + worlds[game] = world + return render_template("tutorialLanding.html") + + +@app.route('/faq//') +def faq(lang): + return render_template("faq.html", lang=lang) + + +@app.route('/glossary//') +def terms(lang): + return render_template("glossary.html", lang=lang) + + +@app.route('/seed/') +def view_seed(seed: UUID): + seed = Seed.get(id=seed) + if not seed: + abort(404) + return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots)) + + +@app.route('/new_room/') +def new_room(seed: UUID): + seed = Seed.get(id=seed) + if not seed: + abort(404) + room = Room(seed=seed, owner=session["_id"], tracker=uuid4()) + commit() + return redirect(url_for("host_room", room=room.id)) + + +def _read_log(path: str): + if os.path.exists(path): + with open(path, encoding="utf-8-sig") as log: + yield from log + else: + yield f"Logfile {path} does not exist. " \ + f"Likely a crash during spinup of multiworld instance or it is still spinning up." + + +@app.route('/log/') +def display_log(room: UUID): + room = Room.get(id=room) + if room is None: + return abort(404) + if room.owner == session["_id"]: + return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8") + return "Access Denied", 403 + + +@app.route('/room/', methods=['GET', 'POST']) +def host_room(room: UUID): + room: Room = Room.get(id=room) + if room is None: + return abort(404) + if request.method == "POST": + if room.owner == session["_id"]: + cmd = request.form["cmd"] + if cmd: + Command(room=room, commandtext=cmd) + commit() + + now = datetime.datetime.utcnow() + # indicate that the page should reload to get the assigned port + should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3) + with db_session: + room.last_activity = now # will trigger a spinup, if it's not already running + + return render_template("hostRoom.html", room=room, should_refresh=should_refresh) + + +@app.route('/favicon.ico') +def favicon(): + return send_from_directory(os.path.join(app.root_path, 'static/static'), + 'favicon.ico', mimetype='image/vnd.microsoft.icon') + + +@app.route('/discord') +def discord(): + return redirect("https://discord.gg/archipelago") + + +@app.route('/datapackage') +@cache.cached() +def get_datapackage(): + """A pretty print version of /api/datapackage""" + from worlds import network_data_package + import json + return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain") + + +@app.route('/index') +@app.route('/sitemap') +def get_sitemap(): + available_games = [] + for game, world in AutoWorldRegister.world_types.items(): + if not world.hidden: + available_games.append(game) + return render_template("siteMap.html", games=available_games) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 2cab7728da..3c481be62b 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -60,7 +60,7 @@ def create(): for game_name, world in AutoWorldRegister.world_types.items(): - all_options = {**Options.per_game_common_options, **world.options} + all_options = {**Options.per_game_common_options, **world.option_definitions} res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render( options=all_options, __version__=__version__, game=game_name, yaml_dump=yaml.dump, @@ -110,7 +110,7 @@ def create(): if option.default == "random": this_option["defaultValue"] = "random" - elif hasattr(option, "range_start") and hasattr(option, "range_end"): + elif issubclass(option, Options.Range): game_options[option_name] = { "type": "range", "displayName": option.display_name if hasattr(option, "display_name") else option_name, @@ -121,7 +121,7 @@ def create(): "max": option.range_end, } - if hasattr(option, "special_range_names"): + if issubclass(option, Options.SpecialRange): game_options[option_name]["type"] = 'special_range' game_options[option_name]["value_names"] = {} for key, val in option.special_range_names.items(): @@ -141,7 +141,7 @@ def create(): "description": option.__doc__ if option.__doc__ else "Please document me!", } - elif hasattr(option, "valid_keys"): + elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet): if option.valid_keys: game_options[option_name] = { "type": "custom-list", diff --git a/WebHostLib/stats.py b/WebHostLib/stats.py index a647be5ee5..54f5e598d1 100644 --- a/WebHostLib/stats.py +++ b/WebHostLib/stats.py @@ -18,15 +18,16 @@ from .models import Room PLOT_WIDTH = 600 -def get_db_data() -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]: +def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]: games_played = defaultdict(Counter) total_games = Counter() cutoff = date.today()-timedelta(days=30) room: Room for room in select(room for room in Room if room.creation_time >= cutoff): for slot in room.seed.slots: - total_games[slot.game] += 1 - games_played[room.creation_time.date()][slot.game] += 1 + if slot.game in known_games: + total_games[slot.game] += 1 + games_played[room.creation_time.date()][slot.game] += 1 return total_games, games_played @@ -73,10 +74,12 @@ def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing. @app.route('/stats') @cache.memoize(timeout=60 * 60) # regen once per hour should be plenty def stats(): + from worlds import network_data_package + known_games = set(network_data_package["games"]) plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date", y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500) - total_games, games_played = get_db_data() + total_games, games_played = get_db_data(known_games) days = sorted(games_played) color_palette = get_color_palette(len(total_games)) diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 15429e7f8d..8981de9b7a 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -2,6 +2,7 @@ {% import "macros.html" as macros %} {% block head %} Multiworld {{ room.id|suuid }} + {% if should_refresh %}{% endif %} {% endblock %} diff --git a/WebHostLib/templates/supportedGames.html b/WebHostLib/templates/supportedGames.html index 125551fbb9..fe81463a46 100644 --- a/WebHostLib/templates/supportedGames.html +++ b/WebHostLib/templates/supportedGames.html @@ -10,7 +10,8 @@ {% include 'header/oceanHeader.html' %}

Currently Supported Games

- {% for game_name, world in worlds.items() | sort(attribute=0) %} + {% for game_name in worlds | title_sorted %} + {% set world = worlds[game_name] %}

{{ game_name }}

{{ world.__doc__ | default("No description provided.", true) }}
diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index 08de625da2..a637b6abf2 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -802,7 +802,7 @@ def getTracker(tracker: UUID): for (team, player), data in multisave.get("video", []): video[(team, player)] = data - return render_template("trackers/" + "multiworldTracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id, + return render_template("trackers/" + "multiworldTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name, lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names, tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons, multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas, diff --git a/docs/apworld specification.md b/docs/apworld specification.md new file mode 100644 index 0000000000..2dcc3f0bef --- /dev/null +++ b/docs/apworld specification.md @@ -0,0 +1,25 @@ +# apworld Specification + +Archipelago depends on worlds to provide game-specific details like items, locations and output generation. +Those are located in the `worlds/` folder (source) or `/lib/worlds/` (when installed). +See [world api.md](world api.md) for details. + +apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld` +file into the worlds folder. + + +## File Format + +apworld files are zip archives with the case-sensitive file ending `.apworld`. +The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in +the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`. + + +## Metadata + +No metadata is specified yet. + + +## Extra Data + +The zip can contain arbitrary files in addition what was specified above. diff --git a/docs/network diagram.jpg b/docs/network diagram.jpg deleted file mode 100644 index e738778d26..0000000000 Binary files a/docs/network diagram.jpg and /dev/null differ diff --git a/docs/network diagram.svg b/docs/network diagram.svg deleted file mode 100644 index f8bc7ef46d..0000000000 --- a/docs/network diagram.svg +++ /dev/null @@ -1 +0,0 @@ -
Factorio
Secret of Evermore
WebHost (archipelago.gg)
.NET
Java
Native
SMZ3
Super Metroid
Ocarina of Time
Final Fantasy 1
A Link to the Past
ChecksFinder
Starcraft 2
FNA/XNA
Unity
Minecraft
Secret of Evermore
WebSockets
WebSockets
Integrated
Integrated
Various, depending on SNES device
LuaSockets
Integrated
LuaSockets
Integrated
Integrated
WebSockets
Various, depending on SNES device
Various, depending on SNES device
The Witness Randomizer
Various, depending on SNES device
WebSockets
WebSockets
Mod the Spire
TCP
Forge Mod Loader
WebSockets
TsRandomizer
RogueLegacyRandomizer
BepInEx
QModLoader (BepInEx)
HK Modding API
WebSockets
SQL
Subprocesses
SQL
Deposit Generated Worlds
Provide Generation Instructions
Subprocesses
Subprocesses
RCON
UDP
Integrated
Factorio Server
FactorioClient
Factorio Games
Factorio Mod Generated by AP
Factorio Modding API
SNES
Configurable (waitress, gunicorn, flask)
AutoHoster
PonyORM DB
WebHost
Flask WebContent
AutoGenerator
Mod with Archipelago.MultiClient.Net
Risk of Rain 2
Subnautica
Hollow Knight
Raft
Timespinner
Rogue Legacy
Mod with Archipelago.MultiClient.Java
Slay the Spire
Minecraft Forge Server
Any Java Minecraft Clients
Game using apclientpp Client Library
Game using Apcpp Client Library
Super Mario 64 Ex
VVVVVV
Meritous
The Witness
Sonic Adventure 2: Battle
ap-soeclient
SNES
SNES
OoTClient
Lua Connector
BizHawk with Ocarina of Time Loaded
FF1Client
Lua Connector
BizHawk with Final Fantasy Loaded
SNES
ChecksFinderClient
ChecksFinder
Starcraft 2 Game Client
Starcraft2Client.py
apsc2 Python Package
Archipelago Server
CommonClient.py
Super Nintendo Interface (SNI)
SNIClient
\ No newline at end of file diff --git a/docs/network diagram/network diagram.jpg b/docs/network diagram/network diagram.jpg new file mode 100644 index 0000000000..15495e2724 Binary files /dev/null and b/docs/network diagram/network diagram.jpg differ diff --git a/docs/network diagram.md b/docs/network diagram/network diagram.md similarity index 95% rename from docs/network diagram.md rename to docs/network diagram/network diagram.md index fc04b87f32..2bffd9f295 100644 --- a/docs/network diagram.md +++ b/docs/network diagram/network diagram.md @@ -69,6 +69,12 @@ flowchart LR end SNI <-- Various, depending on SNES device --> SMZ + %% Donkey Kong Country 3 + subgraph Donkey Kong Country 3 + DK3[SNES] + end + SNI <-- Various, depending on SNES device --> DK3 + %% Native Clients or Games %% Games or clients which compile to native or which the client is integrated in the game. subgraph "Native" @@ -82,10 +88,12 @@ flowchart LR MT[Meritous] TW[The Witness] SA2B[Sonic Adventure 2: Battle] + DS3[Dark Souls 3] APCLIENTPP <--> SOE APCLIENTPP <--> MT APCLIENTPP <-- The Witness Randomizer --> TW + APCLIENTPP <--> DS3 APCPP <--> SM64 APCPP <--> V6 APCPP <--> SA2B diff --git a/docs/network diagram/network diagram.svg b/docs/network diagram/network diagram.svg new file mode 100644 index 0000000000..38d3cc0713 --- /dev/null +++ b/docs/network diagram/network diagram.svg @@ -0,0 +1 @@ +
Factorio
Secret of Evermore
WebHost (archipelago.gg)
.NET
Java
Native
Donkey Kong Country 3
Super Metroid/A Link to the Past Combo Randomizer
Super Metroid
Ocarina of Time
Final Fantasy 1
A Link to the Past
ChecksFinder
Starcraft 2
FNA/XNA
Unity
Minecraft
Secret of Evermore
WebSockets
WebSockets
Integrated
Integrated
Various, depending on SNES device
LuaSockets
Integrated
LuaSockets
Integrated
Integrated
WebSockets
Various, depending on SNES device
Various, depending on SNES device
Various, depending on SNES device
The Witness Randomizer
Various, depending on SNES device
WebSockets
WebSockets
Mod the Spire
TCP
Forge Mod Loader
WebSockets
TsRandomizer
RogueLegacyRandomizer
BepInEx
QModLoader (BepInEx)
HK Modding API
WebSockets
SQL
Subprocesses
SQL
Deposit Generated Worlds
Provide Generation Instructions
Subprocesses
Subprocesses
RCON
UDP
Integrated
Factorio Server
FactorioClient
Factorio Games
Factorio Mod Generated by AP
Factorio Modding API
SNES
Configurable (waitress, gunicorn, flask)
AutoHoster
PonyORM DB
WebHost
Flask WebContent
AutoGenerator
Mod with Archipelago.MultiClient.Net
Risk of Rain 2
Subnautica
Hollow Knight
Raft
Timespinner
Rogue Legacy
Mod with Archipelago.MultiClient.Java
Slay the Spire
Minecraft Forge Server
Any Java Minecraft Clients
Game using apclientpp Client Library
Game using Apcpp Client Library
Super Mario 64 Ex
VVVVVV
Meritous
The Witness
Sonic Adventure 2: Battle
Dark Souls 3
ap-soeclient
SNES
SNES
SNES
OoTClient
Lua Connector
BizHawk with Ocarina of Time Loaded
FF1Client
Lua Connector
BizHawk with Final Fantasy Loaded
SNES
ChecksFinderClient
ChecksFinder
Starcraft 2 Game Client
Starcraft2Client.py
apsc2 Python Package
Archipelago Server
CommonClient.py
Super Nintendo Interface (SNI)
SNIClient
\ No newline at end of file diff --git a/docs/network protocol.md b/docs/network protocol.md index 05a58d0598..b12768e2c9 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -501,7 +501,7 @@ Color options: * green_bg * yellow_bg * blue_bg -* purple_bg +* magenta_bg * cyan_bg * white_bg diff --git a/docs/world api.md b/docs/world api.md index 4fa81f4aab..ffc0749e8c 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -86,7 +86,7 @@ inside a World object. Players provide customized settings for their World in the form of yamls. Those are accessible through `self.world.[self.player]`. A dict -of valid options has to be provided in `self.options`. Options are automatically +of valid options has to be provided in `self.option_definitions`. Options are automatically added to the `World` object for easy access. ### World Options @@ -252,7 +252,7 @@ to describe it and a `display_name` property for display on the website and in spoiler logs. The actual name as used in the yaml is defined in a `dict[str, Option]`, that is -assigned to the world under `self.options`. +assigned to the world under `self.option_definitions`. Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`. For more see `Options.py` in AP's base directory. @@ -328,7 +328,7 @@ from .Options import mygame_options # import the options dict class MyGameWorld(World): #... - options = mygame_options # assign the options dict to the world + option_definitions = mygame_options # assign the options dict to the world #... ``` @@ -365,7 +365,7 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation class MyGameWorld(World): """Insert description of the world/game here.""" game: str = "My Game" # name of the game/world - options = mygame_options # options the player can set + option_definitions = mygame_options # options the player can set topology_present: bool = True # show path to required location checks in spoiler remote_items: bool = False # True if all items come from the server remote_start_inventory: bool = False # True if start inventory comes from the server diff --git a/test/dungeons/TestDungeon.py b/test/dungeons/TestDungeon.py index c44c090f6f..0568e799f2 100644 --- a/test/dungeons/TestDungeon.py +++ b/test/dungeons/TestDungeon.py @@ -16,7 +16,7 @@ class TestDungeon(unittest.TestCase): def setUp(self): self.world = MultiWorld(1) args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items(): + for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) self.world.set_default_common_options() diff --git a/test/general/TestFill.py b/test/general/TestFill.py index 5cec895221..189aafafb2 100644 --- a/test/general/TestFill.py +++ b/test/general/TestFill.py @@ -49,8 +49,7 @@ class PlayerDefinition(object): region_name = "player" + str(self.id) + region_tag region = Region("player" + str(self.id) + region_tag, RegionType.Generic, "Region Hint", self.id, self.world) - self.locations += generate_locations(size, - self.id, None, region, region_tag) + self.locations += generate_locations(size, self.id, None, region, region_tag) entrance = Entrance(self.id, region_name + "_entrance", parent) parent.exits.append(entrance) diff --git a/test/general/__init__.py b/test/general/__init__.py index 8b966c0e34..479f4af520 100644 --- a/test/general/__init__.py +++ b/test/general/__init__.py @@ -12,7 +12,7 @@ def setup_default_world(world_type) -> MultiWorld: world.player_name = {1: "Tester"} world.set_seed() args = Namespace() - for name, option in world_type.options.items(): + for name, option in world_type.option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) world.set_options(args) world.set_default_common_options() diff --git a/test/inverted/TestInverted.py b/test/inverted/TestInverted.py index 586eb57907..0c96f0b26d 100644 --- a/test/inverted/TestInverted.py +++ b/test/inverted/TestInverted.py @@ -16,7 +16,7 @@ class TestInverted(TestBase): def setUp(self): self.world = MultiWorld(1) args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items(): + for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) self.world.set_default_common_options() diff --git a/test/inverted/TestInvertedBombRules.py b/test/inverted/TestInvertedBombRules.py index cca252e3e1..f6afa9d0dc 100644 --- a/test/inverted/TestInvertedBombRules.py +++ b/test/inverted/TestInvertedBombRules.py @@ -17,7 +17,7 @@ class TestInvertedBombRules(unittest.TestCase): self.world = MultiWorld(1) self.world.mode[1] = "inverted" args = Namespace - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items(): + for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) self.world.set_default_common_options() diff --git a/test/inverted_minor_glitches/TestInvertedMinor.py b/test/inverted_minor_glitches/TestInvertedMinor.py index d737f21a07..42e7c942d6 100644 --- a/test/inverted_minor_glitches/TestInvertedMinor.py +++ b/test/inverted_minor_glitches/TestInvertedMinor.py @@ -17,7 +17,7 @@ class TestInvertedMinor(TestBase): def setUp(self): self.world = MultiWorld(1) args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items(): + for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) self.world.set_default_common_options() diff --git a/test/inverted_owg/TestInvertedOWG.py b/test/inverted_owg/TestInvertedOWG.py index 7192fcb08b..064dd9e083 100644 --- a/test/inverted_owg/TestInvertedOWG.py +++ b/test/inverted_owg/TestInvertedOWG.py @@ -18,7 +18,7 @@ class TestInvertedOWG(TestBase): def setUp(self): self.world = MultiWorld(1) args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items(): + for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) self.world.set_default_common_options() diff --git a/test/minor_glitches/TestMinor.py b/test/minor_glitches/TestMinor.py index db77ee919c..81c09cfb27 100644 --- a/test/minor_glitches/TestMinor.py +++ b/test/minor_glitches/TestMinor.py @@ -17,7 +17,7 @@ class TestMinor(TestBase): def setUp(self): self.world = MultiWorld(1) args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items(): + for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) self.world.set_default_common_options() diff --git a/test/owg/TestVanillaOWG.py b/test/owg/TestVanillaOWG.py index 68b10732bb..e5489117a7 100644 --- a/test/owg/TestVanillaOWG.py +++ b/test/owg/TestVanillaOWG.py @@ -18,7 +18,7 @@ class TestVanillaOWG(TestBase): def setUp(self): self.world = MultiWorld(1) args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items(): + for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) self.world.set_default_common_options() diff --git a/test/utils/TestSIPrefix.py b/test/utils/TestSIPrefix.py new file mode 100644 index 0000000000..81c7e0daf0 --- /dev/null +++ b/test/utils/TestSIPrefix.py @@ -0,0 +1,44 @@ +# Tests for SI prefix in Utils.py + +import unittest +from decimal import Decimal +from Utils import format_SI_prefix + + +class TestGenerateMain(unittest.TestCase): + """This tests SI prefix formatting in Utils.py""" + def assertEqual(self, first, second, msg=None): + # we strip spaces everywhere because that is an undefined implementation detail + super().assertEqual(first.replace(" ", ""), second.replace(" ", ""), msg) + + def test_rounding(self): + # we don't care if float(999.995) would fail due to error in precision + self.assertEqual(format_SI_prefix(999.999), "1.00k") + self.assertEqual(format_SI_prefix(1000.001), "1.00k") + self.assertEqual(format_SI_prefix(Decimal("999.995")), "1.00k") + self.assertEqual(format_SI_prefix(Decimal("1000.004")), "1.00k") + + def test_letters(self): + self.assertEqual(format_SI_prefix(0e0), "0.00") + self.assertEqual(format_SI_prefix(1e3), "1.00k") + self.assertEqual(format_SI_prefix(2e6), "2.00M") + self.assertEqual(format_SI_prefix(3e9), "3.00G") + self.assertEqual(format_SI_prefix(4e12), "4.00T") + self.assertEqual(format_SI_prefix(5e15), "5.00P") + self.assertEqual(format_SI_prefix(6e18), "6.00E") + self.assertEqual(format_SI_prefix(7e21), "7.00Z") + self.assertEqual(format_SI_prefix(8e24), "8.00Y") + + def test_multiple_letters(self): + self.assertEqual(format_SI_prefix(9e27), "9.00kY") + + def test_custom_power(self): + self.assertEqual(format_SI_prefix(1023.99, 1024), "1023.99") + self.assertEqual(format_SI_prefix(1034.24, 1024), "1.01k") + + def test_custom_labels(self): + labels = ("E", "da", "h", "k") + self.assertEqual(format_SI_prefix(1, 10, labels), "1.00E") + self.assertEqual(format_SI_prefix(10, 10, labels), "1.00da") + self.assertEqual(format_SI_prefix(100, 10, labels), "1.00h") + self.assertEqual(format_SI_prefix(1000, 10, labels), "1.00k") diff --git a/test/utils/__init__.py b/test/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/vanilla/TestVanilla.py b/test/vanilla/TestVanilla.py index 4ffddc0747..e5ee73406a 100644 --- a/test/vanilla/TestVanilla.py +++ b/test/vanilla/TestVanilla.py @@ -16,7 +16,7 @@ class TestVanilla(TestBase): def setUp(self): self.world = MultiWorld(1) args = Namespace() - for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items(): + for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items(): setattr(args, name, {1: option.from_any(option.default)}) self.world.set_options(args) self.world.set_default_common_options() diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index e28a48b24a..04a9d00f42 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -1,10 +1,14 @@ from __future__ import annotations import logging +import sys from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, TYPE_CHECKING -from BaseClasses import MultiWorld, Item, CollectionState, Location, Tutorial from Options import Option +from BaseClasses import CollectionState + +if TYPE_CHECKING: + from BaseClasses import MultiWorld, Item, Location, Tutorial class AutoWorldRegister(type): @@ -40,7 +44,11 @@ class AutoWorldRegister(type): # construct class new_class = super().__new__(mcs, name, bases, dct) if "game" in dct: + if dct["game"] in AutoWorldRegister.world_types: + raise RuntimeError(f"""Game {dct["game"]} already registered.""") AutoWorldRegister.world_types[dct["game"]] = new_class + new_class.__file__ = sys.modules[new_class.__module__].__file__ + new_class.is_zip = ".apworld" in new_class.__file__ return new_class @@ -60,12 +68,12 @@ class AutoLogicRegister(type): return new_class -def call_single(world: MultiWorld, method_name: str, player: int, *args: Any) -> Any: +def call_single(world: "MultiWorld", method_name: str, player: int, *args: Any) -> Any: method = getattr(world.worlds[player], method_name) return method(*args) -def call_all(world: MultiWorld, method_name: str, *args: Any) -> None: +def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None: world_types: Set[AutoWorldRegister] = set() for player in world.player_ids: world_types.add(world.worlds[player].__class__) @@ -77,7 +85,7 @@ def call_all(world: MultiWorld, method_name: str, *args: Any) -> None: stage_callable(world, *args) -def call_stage(world: MultiWorld, method_name: str, *args: Any) -> None: +def call_stage(world: "MultiWorld", method_name: str, *args: Any) -> None: world_types = {world.worlds[player].__class__ for player in world.player_ids} for world_type in world_types: stage_callable = getattr(world_type, f"stage_{method_name}", None) @@ -95,7 +103,7 @@ class WebWorld: # docs folder will also be scanned for tutorial guides given the relevant information in this list. Each Tutorial # class is to be used for one guide. - tutorials: List[Tutorial] + tutorials: List["Tutorial"] # Choose a theme for your /game/* pages # Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime @@ -119,7 +127,7 @@ class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. A Game should have its own subclass of World in which it defines the required data structures.""" - options: Dict[str, Option[Any]] = {} # link your Options mapping + option_definitions: Dict[str, Option[Any]] = {} # link your Options mapping game: str # name the game topology_present: bool = False # indicate if world type has any meaningful layout/pathing @@ -167,8 +175,11 @@ class World(metaclass=AutoWorldRegister): # Hide World Type from various views. Does not remove functionality. hidden: bool = False + # see WebWorld for options + web: WebWorld = WebWorld() + # autoset on creation: - world: MultiWorld + world: "MultiWorld" player: int # automatically generated @@ -178,9 +189,10 @@ class World(metaclass=AutoWorldRegister): item_names: Set[str] # set of all potential item names location_names: Set[str] # set of all potential location names - web: WebWorld = WebWorld() + is_zip: bool # was loaded from a .apworld ? + __file__: str # path it was loaded from - def __init__(self, world: MultiWorld, player: int): + def __init__(self, world: "MultiWorld", player: int): self.world = world self.player = player @@ -215,12 +227,12 @@ class World(metaclass=AutoWorldRegister): @classmethod def fill_hook(cls, - progitempool: List[Item], - nonexcludeditempool: List[Item], - localrestitempool: Dict[int, List[Item]], - nonlocalrestitempool: Dict[int, List[Item]], - restitempool: List[Item], - fill_locations: List[Location]) -> None: + progitempool: List["Item"], + nonexcludeditempool: List["Item"], + localrestitempool: Dict[int, List["Item"]], + nonlocalrestitempool: Dict[int, List["Item"]], + restitempool: List["Item"], + fill_locations: List["Location"]) -> None: """Special method that gets called as part of distribute_items_restrictive (main fill). This gets called once per present world type.""" pass @@ -258,7 +270,7 @@ class World(metaclass=AutoWorldRegister): # end of ordered Main.py calls - def create_item(self, name: str) -> Item: + def create_item(self, name: str) -> "Item": """Create an item for this world type and player. Warning: this may be called with self.world = None, for example by MultiServer""" raise NotImplementedError @@ -269,7 +281,7 @@ class World(metaclass=AutoWorldRegister): return self.world.random.choice(tuple(self.item_name_to_id.keys())) # decent place to implement progressive items, in most cases can stay as-is - def collect_item(self, state: CollectionState, item: Item, remove: bool = False) -> Optional[str]: + def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]: """Collect an item name into state. For speed reasons items that aren't logically useful get skipped. Collect None to skip item. :param state: CollectionState to collect into @@ -280,18 +292,18 @@ class World(metaclass=AutoWorldRegister): return None # called to create all_state, return Items that are created during pre_fill - def get_pre_fill_items(self) -> List[Item]: + def get_pre_fill_items(self) -> List["Item"]: return [] # following methods should not need to be overridden. - def collect(self, state: CollectionState, item: Item) -> bool: + def collect(self, state: "CollectionState", item: "Item") -> bool: name = self.collect_item(state, item) if name: state.prog_items[name, self.player] += 1 return True return False - def remove(self, state: CollectionState, item: Item) -> bool: + def remove(self, state: "CollectionState", item: "Item") -> bool: name = self.collect_item(state, item, True) if name: state.prog_items[name, self.player] -= 1 @@ -300,7 +312,7 @@ class World(metaclass=AutoWorldRegister): return True return False - def create_filler(self) -> Item: + def create_filler(self) -> "Item": return self.create_item(self.get_filler_item_name()) diff --git a/worlds/__init__.py b/worlds/__init__.py index b927083679..caa170d5c6 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -1,29 +1,57 @@ import importlib +import zipimport import os +import typing -__all__ = {"lookup_any_item_id_to_name", - "lookup_any_location_id_to_name", - "network_data_package", - "AutoWorldRegister"} +folder = os.path.dirname(__file__) + +__all__ = { + "lookup_any_item_id_to_name", + "lookup_any_location_id_to_name", + "network_data_package", + "AutoWorldRegister", + "world_sources", + "folder", +} + +if typing.TYPE_CHECKING: + from .AutoWorld import World + + +class WorldSource(typing.NamedTuple): + path: str # typically relative path from this module + is_zip: bool = False + + +# find potential world containers, currently folders and zip-importable .apworld's +world_sources: typing.List[WorldSource] = [] +file: os.DirEntry # for me (Berserker) at least, PyCharm doesn't seem to infer the type correctly +for file in os.scandir(folder): + if not file.name.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders + if file.is_dir(): + world_sources.append(WorldSource(file.name)) + elif file.is_file() and file.name.endswith(".apworld"): + world_sources.append(WorldSource(file.name, is_zip=True)) # import all submodules to trigger AutoWorldRegister -world_folders = [] -for file in os.scandir(os.path.dirname(__file__)): - if file.is_dir(): - world_folders.append(file.name) -world_folders.sort() -for world in world_folders: - if not world.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders - importlib.import_module(f".{world}", "worlds") +world_sources.sort() +for world_source in world_sources: + if world_source.is_zip: + + importer = zipimport.zipimporter(os.path.join(folder, world_source.path)) + importer.load_module(world_source.path.split(".", 1)[0]) + else: + importlib.import_module(f".{world_source.path}", "worlds") -from .AutoWorld import AutoWorldRegister lookup_any_item_id_to_name = {} lookup_any_location_id_to_name = {} games = {} +from .AutoWorld import AutoWorldRegister + for world_name, world in AutoWorldRegister.world_types.items(): games[world_name] = { - "item_name_to_id" : world.item_name_to_id, + "item_name_to_id": world.item_name_to_id, "location_name_to_id": world.location_name_to_id, "version": world.data_version, # seems clients don't actually want this. Keeping it here in case someone changes their mind. @@ -41,5 +69,6 @@ network_data_package = { if any(not world.data_version for world in AutoWorldRegister.world_types.values()): network_data_package["version"] = 0 import logging + logging.warning(f"Datapackage is in custom mode. Custom Worlds: " f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}") diff --git a/worlds/alttp/Dungeons.py b/worlds/alttp/Dungeons.py index 861dce9ac0..8850786be6 100644 --- a/worlds/alttp/Dungeons.py +++ b/worlds/alttp/Dungeons.py @@ -15,7 +15,6 @@ def create_dungeons(world, player): dungeon_items, player) for item in dungeon.all_items: item.dungeon = dungeon - item.world = world dungeon.boss = BossFactory(default_boss, player) if default_boss else None for region in dungeon.regions: world.get_region(region, player).dungeon = dungeon diff --git a/worlds/alttp/Items.py b/worlds/alttp/Items.py index d1d372bf4b..3663db5cf4 100644 --- a/worlds/alttp/Items.py +++ b/worlds/alttp/Items.py @@ -51,6 +51,11 @@ class ItemData(typing.NamedTuple): flute_boy_credit: typing.Optional[str] hint_text: typing.Optional[str] + def as_init_dict(self) -> typing.Dict[str, typing.Any]: + return {key: getattr(self, key) for key in + ('classification', 'type', 'item_code', 'pedestal_hint', 'hint_text')} + + # Format: Name: (Advancement, Type, ItemCode, Pedestal Hint Text, Pedestal Credit Text, Sick Kid Credit Text, Zora Credit Text, Witch Credit Text, Flute Boy Credit Text, Hint Text) item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'the Bow'), 'Progressive Bow': ItemData(IC.progression, None, 0x64, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'), @@ -218,7 +223,7 @@ item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\ 'Open Floodgate': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None), } -as_dict_item_table = {name: data._asdict() for name, data in item_table.items()} +item_init_table = {name: data.as_init_dict() for name, data in item_table.items()} progression_mapping = { "Golden Sword": ("Progressive Sword", 4), diff --git a/worlds/alttp/Rom.py b/worlds/alttp/Rom.py index c16bbf5322..dd5cc8c4dc 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -2091,7 +2091,9 @@ def write_string_to_rom(rom, target, string): def write_strings(rom, world, player): + from . import ALTTPWorld local_random = world.slot_seeds[player] + w: ALTTPWorld = world.worlds[player] tt = TextTable() tt.removeUnwantedText() @@ -2420,7 +2422,8 @@ def write_strings(rom, world, player): pedestal_text = 'Some Hot Air' if pedestalitem is None else hint_text(pedestalitem, True) if pedestalitem.pedestal_hint_text is not None else 'Unknown Item' tt['mastersword_pedestal_translated'] = pedestal_text - pedestal_credit_text = 'and the Hot Air' if pedestalitem is None else pedestalitem.pedestal_credit_text if pedestalitem.pedestal_credit_text is not None else 'and the Unknown Item' + pedestal_credit_text = 'and the Hot Air' if pedestalitem is None else \ + w.pedestal_credit_texts.get(pedestalitem.code, 'and the Unknown Item') etheritem = world.get_location('Ether Tablet', player).item ether_text = 'Some Hot Air' if etheritem is None else hint_text(etheritem, @@ -2448,20 +2451,24 @@ def write_strings(rom, world, player): credits = Credits() sickkiditem = world.get_location('Sick Kid', player).item - sickkiditem_text = local_random.choice( - SickKid_texts) if sickkiditem is None or sickkiditem.sickkid_credit_text is None else sickkiditem.sickkid_credit_text + sickkiditem_text = local_random.choice(SickKid_texts) \ + if sickkiditem is None or sickkiditem.code not in w.sickkid_credit_texts \ + else w.sickkid_credit_texts[sickkiditem.code] zoraitem = world.get_location('King Zora', player).item - zoraitem_text = local_random.choice( - Zora_texts) if zoraitem is None or zoraitem.zora_credit_text is None else zoraitem.zora_credit_text + zoraitem_text = local_random.choice(Zora_texts) \ + if zoraitem is None or zoraitem.code not in w.zora_credit_texts \ + else w.zora_credit_texts[zoraitem.code] magicshopitem = world.get_location('Potion Shop', player).item - magicshopitem_text = local_random.choice( - MagicShop_texts) if magicshopitem is None or magicshopitem.magicshop_credit_text is None else magicshopitem.magicshop_credit_text + magicshopitem_text = local_random.choice(MagicShop_texts) \ + if magicshopitem is None or magicshopitem.code not in w.magicshop_credit_texts \ + else w.magicshop_credit_texts[magicshopitem.code] fluteboyitem = world.get_location('Flute Spot', player).item - fluteboyitem_text = local_random.choice( - FluteBoy_texts) if fluteboyitem is None or fluteboyitem.fluteboy_credit_text is None else fluteboyitem.fluteboy_credit_text + fluteboyitem_text = local_random.choice(FluteBoy_texts) \ + if fluteboyitem is None or fluteboyitem.code not in w.fluteboy_credit_texts \ + else w.fluteboy_credit_texts[fluteboyitem.code] credits.update_credits_line('castle', 0, local_random.choice(KingsReturn_texts)) credits.update_credits_line('sanctuary', 0, local_random.choice(Sanctuary_texts)) diff --git a/worlds/alttp/Rules.py b/worlds/alttp/Rules.py index 7e16d6e566..36e16cf57c 100644 --- a/worlds/alttp/Rules.py +++ b/worlds/alttp/Rules.py @@ -935,7 +935,6 @@ def set_trock_key_rules(world, player): else: # A key is required in the Big Key Chest to prevent a possible softlock. Place an extra key to ensure 100% locations still works item = ItemFactory('Small Key (Turtle Rock)', player) - item.world = world location = world.get_location('Turtle Rock - Big Key Chest', player) location.place_locked_item(item) location.event = True diff --git a/worlds/alttp/Shops.py b/worlds/alttp/Shops.py index 5abbdd07bc..f6233286f0 100644 --- a/worlds/alttp/Shops.py +++ b/worlds/alttp/Shops.py @@ -207,10 +207,10 @@ def ShopSlotFill(world): shops_per_sphere.append(current_shops_slots) candidates_per_sphere.append(current_candidates) for location in sphere: - if location.shop_slot is not None: + if isinstance(location, ALttPLocation) and location.shop_slot is not None: if not location.shop_slot_disabled: current_shops_slots.append(location) - elif not location.locked and not location.item.name in blacklist_words: + elif not location.locked and location.item.name not in blacklist_words: current_candidates.append(location) if cumu_weights: x = cumu_weights[-1] @@ -335,7 +335,6 @@ def create_shops(world, player: int): else: loc.item = ItemFactory(GetBeemizerItem(world, player, 'Nothing'), player) loc.shop_slot_disabled = True - loc.item.world = world shop.region.locations.append(loc) world.clear_location_cache() diff --git a/worlds/alttp/SubClasses.py b/worlds/alttp/SubClasses.py index 87f10f48e0..f54ab16e92 100644 --- a/worlds/alttp/SubClasses.py +++ b/worlds/alttp/SubClasses.py @@ -6,31 +6,33 @@ from BaseClasses import Location, Item, ItemClassification class ALttPLocation(Location): game: str = "A Link to the Past" + crystal: bool + player_address: Optional[int] + _hint_text: Optional[str] + shop_slot: Optional[int] = None + """If given as integer, shop_slot is the shop's inventory index.""" + shop_slot_disabled: bool = False - def __init__(self, player: int, name: str = '', address=None, crystal: bool = False, - hint_text: Optional[str] = None, parent=None, - player_address=None): + def __init__(self, player: int, name: str, address: Optional[int] = None, crystal: bool = False, + hint_text: Optional[str] = None, parent=None, player_address: Optional[int] = None): super(ALttPLocation, self).__init__(player, name, address, parent) self.crystal = crystal self.player_address = player_address - self._hint_text: str = hint_text + self._hint_text = hint_text class ALttPItem(Item): game: str = "A Link to the Past" + type: Optional[str] + _pedestal_hint_text: Optional[str] + _hint_text: Optional[str] dungeon = None - def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None, pedestal_hint=None, - pedestal_credit=None, sick_kid_credit=None, zora_credit=None, witch_credit=None, - flute_boy_credit=None, hint_text=None): + def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None, + pedestal_hint=None, hint_text=None): super(ALttPItem, self).__init__(name, classification, item_code, player) self.type = type self._pedestal_hint_text = pedestal_hint - self.pedestal_credit_text = pedestal_credit - self.sickkid_credit_text = sick_kid_credit - self.zora_credit_text = zora_credit - self.magicshop_credit_text = witch_credit - self.fluteboy_credit_text = flute_boy_credit self._hint_text = hint_text @property diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 1ba5e9f941..f7bf18ca52 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -8,14 +8,14 @@ from BaseClasses import Item, CollectionState, Tutorial from .SubClasses import ALttPItem from ..AutoWorld import World, WebWorld, LogicMixin from .Options import alttp_options, smallkey_shuffle -from .Items import as_dict_item_table, item_name_groups, item_table, GetBeemizerItem +from .Items import item_init_table, item_name_groups, item_table, GetBeemizerItem from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions from .Rules import set_rules from .ItemPool import generate_itempool, difficulties from .Shops import create_shops, ShopSlotFill from .Dungeons import create_dungeons -from .Rom import LocalRom, patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, get_hash_string, \ - get_base_rom_path, LttPDeltaPatch +from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \ + get_hash_string, get_base_rom_path, LttPDeltaPatch import Patch from itertools import chain @@ -336,7 +336,7 @@ class ALTTPWorld(World): Ganon! """ game: str = "A Link to the Past" - options = alttp_options + option_definitions = alttp_options topology_present = True item_name_groups = item_name_groups hint_blacklist = {"Triforce"} @@ -350,6 +350,17 @@ class ALTTPWorld(World): required_client_version = (0, 3, 2) web = ALTTPWeb() + pedestal_credit_texts: typing.Dict[int, str] = \ + {data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit} + sickkid_credit_texts: typing.Dict[int, str] = \ + {data.item_code: data.sick_kid_credit for data in item_table.values() if data.sick_kid_credit} + zora_credit_texts: typing.Dict[int, str] = \ + {data.item_code: data.zora_credit for data in item_table.values() if data.zora_credit} + magicshop_credit_texts: typing.Dict[int, str] = \ + {data.item_code: data.witch_credit for data in item_table.values() if data.witch_credit} + fluteboy_credit_texts: typing.Dict[int, str] = \ + {data.item_code: data.flute_boy_credit for data in item_table.values() if data.flute_boy_credit} + set_rules = set_rules create_items = generate_itempool @@ -371,6 +382,9 @@ class ALTTPWorld(World): player = self.player world = self.world + if self.use_enemizer(): + check_enemizer(world.enemizer) + # system for sharing ER layouts self.er_seed = str(world.random.randint(0, 2 ** 64)) @@ -556,14 +570,19 @@ class ALTTPWorld(World): def stage_post_fill(cls, world): ShopSlotFill(world) + def use_enemizer(self): + world = self.world + player = self.player + return (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player] + or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default' + or world.pot_shuffle[player] or world.bush_shuffle[player] + or world.killable_thieves[player]) + def generate_output(self, output_directory: str): world = self.world player = self.player try: - use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player] - or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default' - or world.pot_shuffle[player] or world.bush_shuffle[player] - or world.killable_thieves[player]) + use_enemizer = self.use_enemizer() rom = LocalRom(get_base_rom_path()) @@ -626,7 +645,7 @@ class ALTTPWorld(World): multidata["connect_names"][new_name] = multidata["connect_names"][self.world.player_name[self.player]] def create_item(self, name: str) -> Item: - return ALttPItem(name, self.player, **as_dict_item_table[name]) + return ALttPItem(name, self.player, **item_init_table[name]) @classmethod def stage_fill_hook(cls, world, progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, diff --git a/worlds/archipidle/__init__.py b/worlds/archipidle/__init__.py index 0ddb8248fb..8b1061b5d1 100644 --- a/worlds/archipidle/__init__.py +++ b/worlds/archipidle/__init__.py @@ -47,13 +47,12 @@ class ArchipIDLEWorld(World): item_pool = [] for i in range(100): - item = Item( + item = ArchipIDLEItem( item_table_copy[i], ItemClassification.progression if i < 20 else ItemClassification.filler, self.item_name_to_id[item_table_copy[i]], self.player ) - item.game = 'ArchipIDLE' item_pool.append(item) self.world.itempool += item_pool @@ -93,6 +92,10 @@ def create_region(world: MultiWorld, player: int, name: str, locations=None, exi return region +class ArchipIDLEItem(Item): + game = "ArchipIDLE" + + class ArchipIDLELocation(Location): game: str = "ArchipIDLE" diff --git a/worlds/checksfinder/__init__.py b/worlds/checksfinder/__init__.py index d4a6f2aef3..ec9091c3d2 100644 --- a/worlds/checksfinder/__init__.py +++ b/worlds/checksfinder/__init__.py @@ -27,7 +27,7 @@ class ChecksFinderWorld(World): with the mines! You win when you get all your items and beat the board! """ game: str = "ChecksFinder" - options = checksfinder_options + option_definitions = checksfinder_options topology_present = True web = ChecksFinderWeb() diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index 0ff27acc43..1ded4203c5 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -16,14 +16,26 @@ from ..generic.Rules import set_rule class DarkSouls3Web(WebWorld): - tutorials = [Tutorial( + bug_report_page = "https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/issues" + setup_en = Tutorial( "Multiworld Setup Tutorial", "A guide to setting up the Archipelago Dark Souls III randomizer on your computer.", "English", "setup_en.md", "setup/en", ["Marech"] - )] + ) + + setup_fr = Tutorial( + setup_en.tutorial_name, + setup_en.description, + "Français", + "setup_fr.md", + "setup/fr", + ["Marech"] + ) + + tutorials = [setup_en, setup_fr] class DarkSouls3World(World): @@ -34,7 +46,7 @@ class DarkSouls3World(World): """ game: str = "Dark Souls III" - options = dark_souls_options + option_definitions = dark_souls_options topology_present: bool = True remote_items: bool = False remote_start_inventory: bool = False @@ -146,7 +158,7 @@ class DarkSouls3World(World): # For each region, add the associated locations retrieved from the corresponding location_table def create_region(self, region_name, location_table) -> Region: - new_region = Region(region_name, RegionType.Generic, region_name, self.player) + new_region = Region(region_name, RegionType.Generic, region_name, self.player, self.world) if location_table: for name, address in location_table.items(): location = DarkSouls3Location(self.player, name, self.location_name_to_id[name], new_region) diff --git a/worlds/dark_souls_3/docs/en_Dark Souls III.md b/worlds/dark_souls_3/docs/en_Dark Souls III.md index 5860073c37..2effa5f124 100644 --- a/worlds/dark_souls_3/docs/en_Dark Souls III.md +++ b/worlds/dark_souls_3/docs/en_Dark Souls III.md @@ -10,7 +10,10 @@ config file. In Dark Souls III, all unique items you can earn from a static corpse, a chest or the death of a Boss/NPC are randomized. This exclude the upgrade materials such as the titanite shards, the estus shards and the consumables which remain at the same location. I also added an option available from the settings page to randomize the level of the generated -weapons( from +0 to +10/+5 ) +weapons(from +0 to +10/+5) + +To beat the game you need to collect the 4 "Cinders of a Lord" randomized in the multiworld +and kill the final boss "Soul of Cinder" ## What Dark Souls III items can appear in other players' worlds? diff --git a/worlds/dark_souls_3/docs/setup_en.md b/worlds/dark_souls_3/docs/setup_en.md index e08029283a..3d8606a5cf 100644 --- a/worlds/dark_souls_3/docs/setup_en.md +++ b/worlds/dark_souls_3/docs/setup_en.md @@ -3,7 +3,7 @@ ## Required Software - [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/) -- [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client) +- [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases) ## General Concept @@ -14,22 +14,24 @@ The randomization is performed by the AP.json file, an output file generated by ## Installation Procedures -**This client has only been tested with the Official Steam version of the game (v1.15/1.35) not matter which DLCs are installed** + +**This mod can ban you permanently from the FromSoftware servers if used online.** + +This client has only been tested with the Official Steam version of the game (v1.15/1.35) not matter which DLCs are installed. -Get the dinput8.dll from the [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client). -Then you need to add the two following files at the root folder of your game -( e.g. "SteamLibrary\steamapps\common\DARK SOULS III\Game" ): +Get the dinput8.dll from the [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases). +Then you need to add the two following files at the root folder of your game (e.g. "SteamLibrary\steamapps\common\DARK SOULS III\Game"): - **dinput8.dll** -- **AP.json** (renamed from the generated file AP-{ROOM_ID}.json) +- **AP.json** : The .json file downloaded from the multiworld room or provided by the host, named AP-{ROOM_ID}.json, has to be renamed to AP.json. ## Joining a MultiWorld Game 1. Run DarkSoulsIII.exe or run the game through Steam -2. Type in /connect {SERVER_IP}:{SERVER_PORT} in the "Windows Command Prompt" that opened +2. Type in "/connect {SERVER_IP}:{SERVER_PORT}" in the "Windows Command Prompt" that opened 3. Once connected, create a new game, choose a class and wait for the others before starting 4. You can quit and launch at anytime during a game ## Where do I get a config file? The [Player Settings](/games/Dark%20Souls%20III/player-settings) page on the website allows you to -configure your personal settings and export them into a config file \ No newline at end of file +configure your personal settings and export them into a config file diff --git a/worlds/dark_souls_3/docs/setup_fr.md b/worlds/dark_souls_3/docs/setup_fr.md new file mode 100644 index 0000000000..f33b6951c5 --- /dev/null +++ b/worlds/dark_souls_3/docs/setup_fr.md @@ -0,0 +1,38 @@ +# Guide d'installation de Dark Souls III Randomizer + +## Logiciels requis + +- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/) +- [Client AP de Dark Souls III](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases) + +## Concept général + +Le client Archipelago de Dark Souls III est un fichier dinput8.dll. Cette .dll va lancer une invite de commande Windows +permettant de lire des informations de la partie et écrire des commandes pour intéragir avec le serveur Archipelago. + +Le mélange des objets est réalisé par le fichier AP.json, un fichier généré par le serveur Archipelago. + +## Procédures d'installation + + +**Il y a des risques de bannissement permanent des serveurs FromSoftware si ce mod est utilisé en ligne.** + +Ce client a été testé sur la version Steam officielle du jeu (v1.15/1.35), peu importe les DLCs actuellement installés. + +Télécharger le fichier dinput8.dll disponible dans le [Client AP de Dark Souls III](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases). +Vous devez ensuite ajouter les deux fichiers suivants à la racine du jeu +(ex: "SteamLibrary\steamapps\common\DARK SOULS III\Game"): +- **dinput8.dll** +- **AP.json** : Le fichier .json téléchargé depuis la room ou donné par l'hôte de la partie, nommé AP-{ROOM_ID}.json, doit être renommé en AP.json. + +## Rejoindre une partie Multiworld + +1. Lancer DarkSoulsIII.exe ou lancer le jeu depuis Steam +2. Ecrire "/connect {SERVER_IP}:{SERVER_PORT}" dans l'invite de commande Windows ouverte au lancement du jeu +3. Une fois connecté, créez une nouvelle partie, choisissez une classe et attendez que les autres soient prêts avant de lancer +4. Vous pouvez quitter et lancer le jeu n'importe quand pendant une partie + +## Où trouver le fichier de configuration ? + +La [Page de configuration](/games/Dark%20Souls%20III/player-settings) sur le site vous permez de configurer vos +paramètres et de les exporter sous la forme d'un fichier. diff --git a/worlds/dkc3/__init__.py b/worlds/dkc3/__init__.py index 423693470d..f5b01ff723 100644 --- a/worlds/dkc3/__init__.py +++ b/worlds/dkc3/__init__.py @@ -38,7 +38,7 @@ class DKC3World(World): mystery of why Donkey Kong and Diddy disappeared while on vacation. """ game: str = "Donkey Kong Country 3" - options = dkc3_options + option_definitions = dkc3_options topology_present = False data_version = 1 #hint_blacklist = {LocationName.rocket_rush_flag} diff --git a/worlds/dkc3/docs/setup_en.md b/worlds/dkc3/docs/setup_en.md index 34a297eab0..471248deb8 100644 --- a/worlds/dkc3/docs/setup_en.md +++ b/worlds/dkc3/docs/setup_en.md @@ -15,6 +15,11 @@ compatible hardware - Your legally obtained Donkey Kong Country 3 ROM file, probably named `Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc` +## Optional Software +- Donkey Kong Country 3 Tracker + - PopTracker from: [PopTracker Releases Page](https://github.com/black-sliver/PopTracker/releases/) + - Donkey Kong Country 3 Archipelago PopTracker pack from: [DKC3 AP Tracker Releases Page](https://github.com/PoryGone/DKC3_AP_Tracker/releases/) + ## Installation Procedures ### Windows Setup @@ -57,7 +62,6 @@ validator page: [YAML Validation page](/mysterycheck) 4. You will be presented with a server page, from which you can download your patch file. 5. Double-click on your patch file, and the Donkey Kong Country 3 Client will launch automatically, create your ROM from the patch file, and open your emulator for you. -6. Since this is a single-player game, you will no longer need the client, so feel free to close it. ## Joining a MultiWorld Game @@ -65,7 +69,7 @@ validator page: [YAML Validation page](/mysterycheck) When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that is done, the host will provide you with either a link to download your patch file, or with a zip file containing everyone's patch -files. Your patch file should have a `.apsm` extension. +files. Your patch file should have a `.apdkc3` extension. Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically launch the client, and will also create your ROM in the same place as your patch file. diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index 9dc1febcba..26e761d4d3 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -193,7 +193,7 @@ class Factorio(World): return super(Factorio, self).collect_item(state, item, remove) - options = factorio_options + option_definitions = factorio_options @classmethod def stage_write_spoiler(cls, world, spoiler_handle): diff --git a/worlds/factorio/data/mod_template/control.lua b/worlds/factorio/data/mod_template/control.lua index e7774fef00..63473808c6 100644 --- a/worlds/factorio/data/mod_template/control.lua +++ b/worlds/factorio/data/mod_template/control.lua @@ -286,6 +286,12 @@ end) -- hook into researches done script.on_event(defines.events.on_research_finished, function(event) local technology = event.research + if string.find(technology.force.name, "EE_TESTFORCE") == 1 then + --Don't acknowledge AP research as an Editor Extensions test force + --Also no need for free samples in the Editor extensions testing surfaces, as these testing surfaces + --are worked on exclusively in editor mode. + return + end if technology.researched and string.find(technology.name, "ap%-") == 1 then -- check if it came from the server anyway, then we don't need to double send. dumpInfo(technology.force) --is sendable diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index 29bfa7276a..cc813b2fff 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -211,8 +211,8 @@ copy_factorio_icon(new_tree_copy, "{{ progressive_technology_table[item_name][0] {%- endif -%} {#- connect Technology #} {%- if original_tech_name in tech_tree_layout_prerequisites %} -{%- for prerequesite in tech_tree_layout_prerequisites[original_tech_name] %} -table.insert(new_tree_copy.prerequisites, "ap-{{ tech_table[prerequesite] }}-") +{%- for prerequisite in tech_tree_layout_prerequisites[original_tech_name] %} +table.insert(new_tree_copy.prerequisites, "ap-{{ tech_table[prerequisite] }}-") {% endfor %} {% endif -%} {#- add new Technology to game #} diff --git a/worlds/ff1/__init__.py b/worlds/ff1/__init__.py index 9818bed974..0d731ace4b 100644 --- a/worlds/ff1/__init__.py +++ b/worlds/ff1/__init__.py @@ -27,7 +27,7 @@ class FF1World(World): Part puzzle and part speed-run, it breathes new life into one of the most influential games ever made. """ - options = ff1_options + option_definitions = ff1_options game = "Final Fantasy" topology_present = False remote_items = True @@ -54,6 +54,7 @@ class FF1World(World): locations = get_options(self.world, 'locations', self.player) rules = get_options(self.world, 'rules', self.player) menu_region = self.ff1_locations.create_menu_region(self.player, locations, rules) + menu_region.world = self.world terminated_event = Location(self.player, CHAOS_TERMINATED_EVENT, EventId, menu_region) terminated_item = Item(CHAOS_TERMINATED_EVENT, ItemClassification.progression, EventId, self.player) terminated_event.place_locked_item(terminated_item) diff --git a/worlds/generic/docs/plando_en.md b/worlds/generic/docs/plando_en.md index c73fa8d398..fa3edd1fa1 100644 --- a/worlds/generic/docs/plando_en.md +++ b/worlds/generic/docs/plando_en.md @@ -7,8 +7,7 @@ changes it up by allowing you to plan out certain aspects of the game by placing certain bosses in certain rooms, edit text for certain NPCs/signs, or even force certain region connections. Each of these options are going to be detailed separately as `item plando`, `boss plando`, `text plando`, and `connection plando`. Every game in archipelago supports item plando but the other plando options are only supported -by certain games. Currently, Minecraft and LTTP both support connection plando, but only LTTP supports text and boss -plando. +by certain games. Currently, only LTTP supports text and boss plando. Support for connection plando may vary. ### Enabling Plando diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index 0f4bec8205..fd8d036175 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -50,7 +50,7 @@ option_docstrings = { "pool and open their locations for randomization.", "RandomizeGrubs": "Randomize Grubs into the item pool and open their locations for randomization.", "RandomizeMimics": "Randomize Mimic Grubs into the item pool and open their locations for randomization." - "Mimic Grubs are always placed in your own game.", + "Mimic Grubs are always placed in your own game.", "RandomizeMaps": "Randomize Maps into the item pool. This causes Cornifer to give you a message allowing you to see" " and buy an item that is randomized into that location as well.", "RandomizeStags": "Randomize Stag Stations unlocks into the item pool as well as placing randomized items " diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py index bc9b29519e..1667ab81f7 100644 --- a/worlds/hk/__init__.py +++ b/worlds/hk/__init__.py @@ -142,7 +142,7 @@ class HKWorld(World): As the enigmatic Knight, you’ll traverse the depths, unravel its mysteries and conquer its evils. """ # from https://www.hollowknight.com game: str = "Hollow Knight" - options = hollow_knight_options + option_definitions = hollow_knight_options web = HKWeb() @@ -435,7 +435,7 @@ class HKWorld(World): slot_data = {} options = slot_data["options"] = {} - for option_name in self.options: + for option_name in self.option_definitions: option = getattr(self.world, option_name)[self.player] try: optionvalue = int(option.value) @@ -632,8 +632,9 @@ class HKLocation(Location): class HKItem(Item): game = "Hollow Knight" + type: str - def __init__(self, name, advancement, code, type, player: int = None): + def __init__(self, name, advancement, code, type: str, player: int = None): if name == "Mimic_Grub": classification = ItemClassification.trap elif type in ("Grub", "DreamWarrior", "Root", "Egg"): diff --git a/worlds/meritous/Items.py b/worlds/meritous/Items.py index 1b5228e5ce..9f28c5d178 100644 --- a/worlds/meritous/Items.py +++ b/worlds/meritous/Items.py @@ -6,14 +6,9 @@ import typing from BaseClasses import Item, ItemClassification +from worlds.alttp import ALTTPWorld -# pedestal_credit_text: str = "and the Unknown Item" -# sickkid_credit_text: Optional[str] = None -# magicshop_credit_text: Optional[str] = None -# zora_credit_text: Optional[str] = None -# fluteboy_credit_text: Optional[str] = None - class MeritousLttPText(typing.NamedTuple): pedestal: typing.Optional[str] sickkid: typing.Optional[str] @@ -143,6 +138,7 @@ LttPCreditsText = { class MeritousItem(Item): game: str = "Meritous" + type: str def __init__(self, name, advancement, code, player): super(MeritousItem, self).__init__(name, @@ -171,14 +167,6 @@ class MeritousItem(Item): self.type = "Artifact" self.classification = ItemClassification.useful - if name in LttPCreditsText: - lttp = LttPCreditsText[name] - self.pedestal_credit_text = f"and the {lttp.pedestal}" - self.sickkid_credit_text = lttp.sickkid - self.magicshop_credit_text = lttp.magicshop - self.zora_credit_text = lttp.zora - self.fluteboy_credit_text = lttp.fluteboy - offset = 593_000 @@ -217,3 +205,10 @@ item_groups = { "Important Artifacts": ["Shield Boost", "Circuit Booster", "Metabolism", "Dodge Enhancer"], "Crystals": ["Crystals x500", "Crystals x1000", "Crystals x2000"] } + +ALTTPWorld.pedestal_credit_texts.update({item_table[name]: f"and the {texts.pedestal}" + for name, texts in LttPCreditsText.items()}) +ALTTPWorld.sickkid_credit_texts.update({item_table[name]: texts.sickkid for name, texts in LttPCreditsText.items()}) +ALTTPWorld.magicshop_credit_texts.update({item_table[name]: texts.magicshop for name, texts in LttPCreditsText.items()}) +ALTTPWorld.zora_credit_texts.update({item_table[name]: texts.zora for name, texts in LttPCreditsText.items()}) +ALTTPWorld.fluteboy_credit_texts.update({item_table[name]: texts.fluteboy for name, texts in LttPCreditsText.items()}) diff --git a/worlds/meritous/__init__.py b/worlds/meritous/__init__.py index 3c29032aa5..d0d076da40 100644 --- a/worlds/meritous/__init__.py +++ b/worlds/meritous/__init__.py @@ -45,12 +45,11 @@ class MeritousWorld(World): item_name_groups = item_groups data_version = 2 - forced_auto_forfeit = False # NOTE: Remember to change this before this game goes live required_client_version = (0, 2, 4) - options = meritous_options + option_definitions = meritous_options def __init__(self, world: MultiWorld, player: int): super(MeritousWorld, self).__init__(world, player) diff --git a/worlds/minecraft/__init__.py b/worlds/minecraft/__init__.py index 8da3eb1a26..bbb463f224 100644 --- a/worlds/minecraft/__init__.py +++ b/worlds/minecraft/__init__.py @@ -131,7 +131,7 @@ class MinecraftWorld(World): victory! """ game: str = "Minecraft" - options = minecraft_options + option_definitions = minecraft_options topology_present = True web = MinecraftWebWorld() diff --git a/worlds/minecraft/requirements.txt b/worlds/minecraft/requirements.txt index a5590930fd..ddedb7c332 100644 --- a/worlds/minecraft/requirements.txt +++ b/worlds/minecraft/requirements.txt @@ -1 +1 @@ -requests >= 2.27.1 # used by client \ No newline at end of file +requests >= 2.28.1 # used by client \ No newline at end of file diff --git a/worlds/oot/Hints.py b/worlds/oot/Hints.py index 4250c5590e..b8ae7dfafc 100644 --- a/worlds/oot/Hints.py +++ b/worlds/oot/Hints.py @@ -10,6 +10,7 @@ from urllib.error import URLError, HTTPError import json from enum import Enum +from .Items import OOTItem from .HintList import getHint, getHintGroup, Hint, hintExclusions from .Messages import COLOR_MAP, update_message_by_id from .TextBox import line_wrap @@ -480,7 +481,7 @@ def get_specific_item_hint(world, checked): def get_random_location_hint(world, checked): locations = list(filter(lambda location: is_not_checked(location, checked) - and location.item.type not in ('Drop', 'Event', 'Shop', 'DungeonReward') + and not (isinstance(location.item, OOTItem) and location.item.type in ('Drop', 'Event', 'Shop', 'DungeonReward')) # and not (location.parent_region.dungeon and isRestrictedDungeonItem(location.parent_region.dungeon, location.item)) # AP already locks dungeon items and not location.locked and location.name not in world.hint_exclusions diff --git a/worlds/oot/Items.py b/worlds/oot/Items.py index f07d91a85e..b4c0719700 100644 --- a/worlds/oot/Items.py +++ b/worlds/oot/Items.py @@ -24,6 +24,7 @@ def ap_id_to_oot_data(ap_id): class OOTItem(Item): game: str = "Ocarina of Time" + type: str def __init__(self, name, player, data, event, force_not_advancement): (type, advancement, index, special) = data @@ -38,7 +39,6 @@ class OOTItem(Item): classification = ItemClassification.progression else: classification = ItemClassification.filler - adv = bool(advancement) and not force_not_advancement super(OOTItem, self).__init__(name, classification, oot_data_to_ap_id(data, event), player) self.type = type self.index = index @@ -46,25 +46,12 @@ class OOTItem(Item): self.looks_like_item = None self.price = special.get('price', None) if special else None self.internal = False - - # The playthrough calculation calls a function that uses "sweep_for_events(key_only=True)" - # This checks if the item it's looking for is a small key, using the small key property. - # Because of overlapping item fields, this means that OoT small keys are technically counted, unless we do this. - # This causes them to be double-collected during playthrough and generation. - @property - def smallkey(self) -> bool: - return False - - @property - def bigkey(self) -> bool: - return False @property def dungeonitem(self) -> bool: return self.type in ['SmallKey', 'HideoutSmallKey', 'BossKey', 'GanonBossKey', 'Map', 'Compass'] - # Progressive: True -> Advancement # False -> Priority # None -> Normal diff --git a/worlds/oot/Patches.py b/worlds/oot/Patches.py index 177a4c6165..91f656b4e9 100644 --- a/worlds/oot/Patches.py +++ b/worlds/oot/Patches.py @@ -5,6 +5,7 @@ import zlib from collections import defaultdict from functools import partial +from .Items import OOTItem from .LocationList import business_scrubs from .Hints import writeGossipStoneHints, buildAltarHints, \ buildGanonText, getSimpleHintNoPrefix @@ -1881,9 +1882,9 @@ def get_override_entry(player_id, location): type = 2 elif location.type == 'GS Token': type = 3 - elif location.type == 'Shop' and location.item.type != 'Shop': + elif location.type == 'Shop' and not (isinstance(location.item, OOTItem) and location.item.type == 'Shop'): type = 0 - elif location.type == 'GrottoNPC' and location.item.type != 'Shop': + elif location.type == 'GrottoNPC' and not (isinstance(location.item, OOTItem) and location.item.type == 'Shop'): type = 4 elif location.type in ['Song', 'Cutscene']: type = 5 diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index 5ce6b069d3..7f0e1aab38 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -222,7 +222,7 @@ class OOTWorld(World): to rescue the Seven Sages, and then confront Ganondorf to save Hyrule! """ game: str = "Ocarina of Time" - options: dict = oot_options + option_definitions: dict = oot_options topology_present: bool = True item_name_to_id = {item_name: oot_data_to_ap_id(data, False) for item_name, data in item_table.items() if data[2] is not None} diff --git a/worlds/oribf/__init__.py b/worlds/oribf/__init__.py index 33f8d4b07e..05d237659c 100644 --- a/worlds/oribf/__init__.py +++ b/worlds/oribf/__init__.py @@ -17,7 +17,7 @@ class OriBlindForest(World): item_name_to_id = item_table location_name_to_id = lookup_name_to_id - options = options + option_definitions = options hidden = True diff --git a/worlds/raft/__init__.py b/worlds/raft/__init__.py index cf4b7975e5..da4b58f24f 100644 --- a/worlds/raft/__init__.py +++ b/worlds/raft/__init__.py @@ -37,7 +37,7 @@ class RaftWorld(World): lastItemId = max(filter(lambda val: val is not None, item_name_to_id.values())) location_name_to_id = locations_lookup_name_to_id - options = raft_options + option_definitions = raft_options data_version = 2 required_client_version = (0, 3, 4) diff --git a/worlds/rogue_legacy/__init__.py b/worlds/rogue_legacy/__init__.py index af8fa9a791..ba58e133c1 100644 --- a/worlds/rogue_legacy/__init__.py +++ b/worlds/rogue_legacy/__init__.py @@ -30,7 +30,7 @@ class LegacyWorld(World): But that's OK, because no one is perfect, and you don't have to be to succeed. """ game: str = "Rogue Legacy" - options = legacy_options + option_definitions = legacy_options topology_present = False data_version = 3 required_client_version = (0, 2, 3) diff --git a/worlds/ror2/Rules.py b/worlds/ror2/Rules.py index 05c08c8803..64d741f99f 100644 --- a/worlds/ror2/Rules.py +++ b/worlds/ror2/Rules.py @@ -1,5 +1,5 @@ from BaseClasses import MultiWorld -from ..generic.Rules import set_rule, add_rule +from worlds.generic.Rules import set_rule, add_rule def set_rules(world: MultiWorld, player: int): diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index 1a7060786f..9d0d693b61 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -5,7 +5,7 @@ from .Rules import set_rules from BaseClasses import Region, RegionType, Entrance, Item, ItemClassification, MultiWorld, Tutorial from .Options import ror2_options -from ..AutoWorld import World, WebWorld +from worlds.AutoWorld import World, WebWorld client_version = 1 @@ -28,7 +28,7 @@ class RiskOfRainWorld(World): first crash landing. """ game: str = "Risk of Rain 2" - options = ror2_options + option_definitions = ror2_options topology_present = False item_name_to_id = item_table diff --git a/worlds/sa2b/Items.py b/worlds/sa2b/Items.py index bebfa44cd4..d11178f575 100644 --- a/worlds/sa2b/Items.py +++ b/worlds/sa2b/Items.py @@ -2,6 +2,7 @@ import typing from BaseClasses import Item, ItemClassification from .Names import ItemName +from worlds.alttp import ALTTPWorld class ItemData(typing.NamedTuple): @@ -18,9 +19,6 @@ class SA2BItem(Item): def __init__(self, name, classification: ItemClassification, code: int = None, player: int = None): super(SA2BItem, self).__init__(name, classification, code, player) - if self.name == ItemName.sonic_light_shoes or self.name == ItemName.shadow_air_shoes: - self.pedestal_credit_text = "and the Soap Shoes" - # Separate tables for each type of item. emblems_table = { @@ -94,3 +92,6 @@ item_table = { } lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code} + +ALTTPWorld.pedestal_credit_texts[item_table[ItemName.sonic_light_shoes].code] = "and the Soap Shoes" +ALTTPWorld.pedestal_credit_texts[item_table[ItemName.shadow_air_shoes].code] = "and the Soap Shoes" diff --git a/worlds/sa2b/__init__.py b/worlds/sa2b/__init__.py index ffff2a93ea..84a38f221c 100644 --- a/worlds/sa2b/__init__.py +++ b/worlds/sa2b/__init__.py @@ -49,7 +49,7 @@ class SA2BWorld(World): Sonic Adventure 2 Battle is an action platforming game. Play as Sonic, Tails, Knuckles, Shadow, Rogue, and Eggman across 31 stages and prevent the destruction of the earth. """ game: str = "Sonic Adventure 2 Battle" - options = sa2b_options + option_definitions = sa2b_options topology_present = False data_version = 2 diff --git a/worlds/sc2wol/Locations.py b/worlds/sc2wol/Locations.py index dc2ec74a4b..92dfb033c0 100644 --- a/worlds/sc2wol/Locations.py +++ b/worlds/sc2wol/Locations.py @@ -103,19 +103,19 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Moebius Factor", "The Moebius Factor: Victory", SC2WOL_LOC_ID_OFFSET + 1000, lambda state: state._sc2wol_has_air(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: 1st Data Core ", SC2WOL_LOC_ID_OFFSET + 1001, - lambda state: state._sc2wol_has_air(world, player) or True), + lambda state: True), LocationData("The Moebius Factor", "The Moebius Factor: 2nd Data Core", SC2WOL_LOC_ID_OFFSET + 1002, lambda state: state._sc2wol_has_air(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: South Rescue", SC2WOL_LOC_ID_OFFSET + 1003, - lambda state: state._sc2wol_able_to_rescue(world, player) or True), + lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Wall Rescue", SC2WOL_LOC_ID_OFFSET + 1004, - lambda state: state._sc2wol_able_to_rescue(world, player) or True), + lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Mid Rescue", SC2WOL_LOC_ID_OFFSET + 1005, - lambda state: state._sc2wol_able_to_rescue(world, player) or True), + lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Nydus Roof Rescue", SC2WOL_LOC_ID_OFFSET + 1006, - lambda state: state._sc2wol_able_to_rescue(world, player) or True), + lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Alive Inside Rescue", SC2WOL_LOC_ID_OFFSET + 1007, - lambda state: state._sc2wol_able_to_rescue(world, player) or True), + lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1008, lambda state: state._sc2wol_has_air(world, player)), LocationData("The Moebius Factor", "Beat The Moebius Factor", None, diff --git a/worlds/sc2wol/LogicMixin.py b/worlds/sc2wol/LogicMixin.py index 7e2fc2f0e8..baf77dc677 100644 --- a/worlds/sc2wol/LogicMixin.py +++ b/worlds/sc2wol/LogicMixin.py @@ -37,7 +37,7 @@ class SC2WoLLogic(LogicMixin): self.has_all({'Reaper', "G-4 Clusterbomb"}, player) or self.has_all({'Spectre', 'Psionic Lash'}, player)) def _sc2wol_able_to_rescue(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Medivac', 'Hercules', 'Raven', 'Orbital Strike'}, player) + return self.has_any({'Medivac', 'Hercules', 'Raven', 'Viking'}, player) def _sc2wol_has_protoss_common_units(self, world: MultiWorld, player: int) -> bool: return self.has_any({'Zealot', 'Immortal', 'Stalker', 'Dark Templar'}, player) diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index 5d48c9c0f4..33522569d5 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -37,7 +37,7 @@ class SC2WoLWorld(World): item_name_to_id = {name: data.code for name, data in item_table.items()} location_name_to_id = {location.name: location.code for location in get_locations(None, None)} - options = sc2wol_options + option_definitions = sc2wol_options item_name_groups = item_name_groups locked_locations: typing.List[str] diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index a7445c01a5..5da1c40f75 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -5,7 +5,7 @@ import copy import os import threading import base64 -from typing import Set, List, TextIO +from typing import Set, TextIO from worlds.sm.variaRandomizer.graph.graph_utils import GraphUtils @@ -79,7 +79,7 @@ class SMWorld(World): game: str = "Super Metroid" topology_present = True data_version = 1 - options = sm_options + option_definitions = sm_options item_names: Set[str] = frozenset(items_lookup_name_to_id) location_names: Set[str] = frozenset(locations_lookup_name_to_id) item_name_to_id = items_lookup_name_to_id @@ -293,15 +293,15 @@ class SMWorld(World): if itemLoc.player == self.player and locationsDict[itemLoc.name].Id != None: # this SM world can find this item: write full item data to tables and assign player data for writing romPlayerID = itemLoc.item.player if itemLoc.item.player <= ROM_PLAYER_LIMIT else 0 - if itemLoc.item.type in ItemManager.Items: - itemId = ItemManager.Items[itemLoc.item.type].Id + if isinstance(itemLoc.item, SMItem) and itemLoc.item.type in ItemManager.Items: + itemId = ItemManager.Items[itemLoc.item.type].Id else: itemId = ItemManager.Items['ArchipelagoItem'].Id + idx multiWorldItems.append({"sym": symbols["message_item_names"], "offset": (vanillaItemTypesCount + idx)*64, "values": self.convertToROMItemName(itemLoc.item.name)}) idx += 1 - + if (romPlayerID > 0 and romPlayerID not in self.playerIDMap.keys()): playerIDCount += 1 self.playerIDMap[romPlayerID] = playerIDCount @@ -488,7 +488,13 @@ class SMWorld(World): # commit all the changes we've made here to the ROM romPatcher.commitIPS() - itemLocs = [ItemLocation(ItemManager.Items[itemLoc.item.type if itemLoc.item.type in ItemManager.Items else 'ArchipelagoItem'], locationsDict[itemLoc.name], True) for itemLoc in self.world.get_locations() if itemLoc.player == self.player] + itemLocs = [ + ItemLocation(ItemManager.Items[itemLoc.item.type + if isinstance(itemLoc.item, SMItem) and itemLoc.item.type in ItemManager.Items else + 'ArchipelagoItem'], + locationsDict[itemLoc.name], True) + for itemLoc in self.world.get_locations() if itemLoc.player == self.player + ] romPatcher.writeItemsLocs(itemLocs) itemLocs = [ItemLocation(ItemManager.Items[itemLoc.item.type], locationsDict[itemLoc.name] if itemLoc.name in locationsDict and itemLoc.player == self.player else self.DummyLocation(self.world.get_player_name(itemLoc.player) + " " + itemLoc.name), True) for itemLoc in self.world.get_locations() if itemLoc.item.player == self.player] @@ -561,7 +567,7 @@ class SMWorld(World): def fill_slot_data(self): slot_data = {} if not self.world.is_race: - for option_name in self.options: + for option_name in self.option_definitions: option = getattr(self.world, option_name)[self.player] slot_data[option_name] = option.value @@ -735,7 +741,8 @@ class SMLocation(Location): class SMItem(Item): game = "Super Metroid" + type: str - def __init__(self, name, classification, type, code, player: int = None): + def __init__(self, name, classification, type: str, code, player: int): super(SMItem, self).__init__(name, classification, code, player) self.type = type diff --git a/worlds/sm64ex/__init__.py b/worlds/sm64ex/__init__.py index 401a2d683b..e0f911fbd9 100644 --- a/worlds/sm64ex/__init__.py +++ b/worlds/sm64ex/__init__.py @@ -35,13 +35,11 @@ class SM64World(World): location_name_to_id = location_table data_version = 6 - required_client_version = (0,3,0) - - forced_auto_forfeit = False + required_client_version = (0, 3, 0) area_connections: typing.Dict[int, int] - options = sm64_options + option_definitions = sm64_options def generate_early(self): self.topology_present = self.world.AreaRandomizer[self.player].value @@ -120,7 +118,7 @@ class SM64World(World): "AreaRando": self.area_connections, "FirstBowserDoorCost": self.world.FirstBowserStarDoorCost[self.player].value, "BasementDoorCost": self.world.BasementStarDoorCost[self.player].value, - "SecondFloorCost": self.world.SecondFloorStarDoorCost[self.player].value, + "SecondFloorDoorCost": self.world.SecondFloorStarDoorCost[self.player].value, "MIPS1Cost": self.world.MIPS1Cost[self.player].value, "MIPS2Cost": self.world.MIPS2Cost[self.player].value, "StarsToFinish": self.world.StarsToFinish[self.player].value, diff --git a/worlds/smz3/Options.py b/worlds/smz3/Options.py index ad59bc6d42..2bbddf7ab3 100644 --- a/worlds/smz3/Options.py +++ b/worlds/smz3/Options.py @@ -1,5 +1,5 @@ import typing -from Options import Choice, Option +from Options import Choice, Option, Toggle, DefaultOnToggle, Range class SMLogic(Choice): """This option selects what kind of logic to use for item placement inside @@ -45,6 +45,22 @@ class MorphLocation(Choice): option_Original = 2 default = 0 + +class Goal(Choice): + """This option decides what goal is required to finish the randomizer. + Defeat Ganon and Mother Brain - Find the required crystals and boss tokens kill both bosses. + Fast Ganon and Defeat Mother Brain - The hole to ganon is open without having to defeat Agahnim in + Ganon's Tower and Ganon can be defeat as soon you have the required + crystals to make Ganon vulnerable. For keysanity, this mode also removes + the Crateria Boss Key requirement from Tourian to allow faster access. + All Dungeons and Defeat Mother Brain - Similar to "Defeat Ganon and Mother Brain", but also requires all dungeons + to be beaten including Castle Tower and Agahnim.""" + display_name = "Goal" + option_DefeatBoth = 0 + option_FastGanonDefeatMotherBrain = 1 + option_AllDungeonsDefeatMotherBrain = 2 + default = 0 + class KeyShuffle(Choice): """This option decides how dungeon items such as keys are shuffled. None - A Link to the Past dungeon items can only be placed inside the @@ -55,9 +71,75 @@ class KeyShuffle(Choice): option_Keysanity = 1 default = 0 +class OpenTower(Range): + """The amount of crystals required to be able to enter Ganon's Tower. + If this is set to Random, the amount can be found in-game on a sign next to Ganon's Tower.""" + display_name = "Open Tower" + range_start = 0 + range_end = 7 + default = 7 + +class GanonVulnerable(Range): + """The amount of crystals required to be able to harm Ganon. The amount can be found + in-game on a sign near the top of the Pyramid.""" + display_name = "Ganon Vulnerable" + range_start = 0 + range_end = 7 + default = 7 + +class OpenTourian(Range): + """The amount of boss tokens required to enter Tourian. The amount can be found in-game + on a sign above the door leading to the Tourian entrance.""" + display_name = "Open Tourian" + range_start = 0 + range_end = 4 + default = 4 + +class SpinJumpsAnimation(Toggle): + """Enable separate space/screw jump animations""" + display_name = "Spin Jumps Animation" + +class HeartBeepSpeed(Choice): + """Sets the speed of the heart beep sound in A Link to the Past.""" + display_name = "Heart Beep Speed" + option_Off = 0 + option_Quarter = 1 + option_Half = 2 + option_Normal = 3 + option_Double = 4 + alias_false = 0 + default = 3 + +class HeartColor(Choice): + """Changes the color of the hearts in the HUD for A Link to the Past.""" + display_name = "Heart Color" + option_Red = 0 + option_Green = 1 + option_Blue = 2 + option_Yellow = 3 + default = 0 + +class QuickSwap(Toggle): + """When enabled, lets you switch items in ALTTP with L/R""" + display_name = "Quick Swap" + +class EnergyBeep(DefaultOnToggle): + """Toggles the low health energy beep in Super Metroid.""" + display_name = "Energy Beep" + + smz3_options: typing.Dict[str, type(Option)] = { "sm_logic": SMLogic, "sword_location": SwordLocation, "morph_location": MorphLocation, - "key_shuffle": KeyShuffle + "goal": Goal, + "key_shuffle": KeyShuffle, + "open_tower": OpenTower, + "ganon_vulnerable": GanonVulnerable, + "open_tourian": OpenTourian, + "spin_jumps_animation": SpinJumpsAnimation, + "heart_beep_speed": HeartBeepSpeed, + "heart_color": HeartColor, + "quick_swap": QuickSwap, + "energy_beep": EnergyBeep } diff --git a/worlds/smz3/TotalSMZ3/Config.py b/worlds/smz3/TotalSMZ3/Config.py index bfcd541b98..23dde1c88e 100644 --- a/worlds/smz3/TotalSMZ3/Config.py +++ b/worlds/smz3/TotalSMZ3/Config.py @@ -26,16 +26,42 @@ class MorphLocation(Enum): class Goal(Enum): DefeatBoth = 0 + FastGanonDefeatMotherBrain = 1 + AllDungeonsDefeatMotherBrain = 2 class KeyShuffle(Enum): Null = 0 Keysanity = 1 -class GanonInvincible(Enum): - Never = 0 - BeforeCrystals = 1 - BeforeAllDungeons = 2 - Always = 3 +class OpenTower(Enum): + Random = -1 + NoCrystals = 0 + OneCrystal = 1 + TwoCrystals = 2 + ThreeCrystals = 3 + FourCrystals = 4 + FiveCrystals = 5 + SixCrystals = 6 + SevenCrystals = 7 + +class GanonVulnerable(Enum): + Random = -1 + NoCrystals = 0 + OneCrystal = 1 + TwoCrystals = 2 + ThreeCrystals = 3 + FourCrystals = 4 + FiveCrystals = 5 + SixCrystals = 6 + SevenCrystals = 7 + +class OpenTourian(Enum): + Random = -1 + NoBosses = 0 + OneBoss = 1 + TwoBosses = 2 + ThreeBosses = 3 + FourBosses = 4 class Config: GameMode: GameMode = GameMode.Multiworld @@ -45,63 +71,20 @@ class Config: MorphLocation: MorphLocation = MorphLocation.Randomized Goal: Goal = Goal.DefeatBoth KeyShuffle: KeyShuffle = KeyShuffle.Null - Keysanity: bool = KeyShuffle != KeyShuffle.Null Race: bool = False - GanonInvincible: GanonInvincible = GanonInvincible.BeforeCrystals - def __init__(self, options: Dict[str, str]): - self.GameMode = self.ParseOption(options, GameMode.Multiworld) - self.Z3Logic = self.ParseOption(options, Z3Logic.Normal) - self.SMLogic = self.ParseOption(options, SMLogic.Normal) - self.SwordLocation = self.ParseOption(options, SwordLocation.Randomized) - self.MorphLocation = self.ParseOption(options, MorphLocation.Randomized) - self.Goal = self.ParseOption(options, Goal.DefeatBoth) - self.GanonInvincible = self.ParseOption(options, GanonInvincible.BeforeCrystals) - self.KeyShuffle = self.ParseOption(options, KeyShuffle.Null) - self.Keysanity = self.KeyShuffle != KeyShuffle.Null - self.Race = self.ParseOptionWith(options, "Race", False) + OpenTower: OpenTower = OpenTower.SevenCrystals + GanonVulnerable: GanonVulnerable = GanonVulnerable.SevenCrystals + OpenTourian: OpenTourian = OpenTourian.FourBosses - def ParseOption(self, options:Dict[str, str], defaultValue:Enum): - enumKey = defaultValue.__class__.__name__.lower() - if (enumKey in options): - return defaultValue.__class__[options[enumKey]] - return defaultValue + @property + def SingleWorld(self) -> bool: + return self.GameMode == GameMode.Normal + + @property + def Multiworld(self) -> bool: + return self.GameMode == GameMode.Multiworld - def ParseOptionWith(self, options:Dict[str, str], option:str, defaultValue:bool): - if (option.lower() in options): - return options[option.lower()] - return defaultValue - - """ public static RandomizerOption GetRandomizerOption(string description, string defaultOption = "") where T : Enum { - var enumType = typeof(T); - var values = Enum.GetValues(enumType).Cast(); - - return new RandomizerOption { - Key = enumType.Name.ToLower(), - Description = description, - Type = RandomizerOptionType.Dropdown, - Default = string.IsNullOrEmpty(defaultOption) ? GetDefaultValue().ToLString() : defaultOption, - Values = values.ToDictionary(k => k.ToLString(), v => v.GetDescription()) - }; - } - - public static RandomizerOption GetRandomizerOption(string name, string description, bool defaultOption = false) { - return new RandomizerOption { - Key = name.ToLower(), - Description = description, - Type = RandomizerOptionType.Checkbox, - Default = defaultOption.ToString().ToLower(), - Values = new Dictionary() - }; - } - - public static TEnum GetDefaultValue() where TEnum : Enum { - Type t = typeof(TEnum); - var attributes = (DefaultValueAttribute[])t.GetCustomAttributes(typeof(DefaultValueAttribute), false); - if ((attributes?.Length ?? 0) > 0) { - return (TEnum)attributes.First().Value; - } - else { - return default; - } - } """ + @property + def Keysanity(self) -> bool: + return self.KeyShuffle != KeyShuffle.Null \ No newline at end of file diff --git a/worlds/smz3/TotalSMZ3/Item.py b/worlds/smz3/TotalSMZ3/Item.py index bad16ce9d0..2aced8bfac 100644 --- a/worlds/smz3/TotalSMZ3/Item.py +++ b/worlds/smz3/TotalSMZ3/Item.py @@ -130,6 +130,11 @@ class ItemType(Enum): CardLowerNorfairL1 = 0xDE CardLowerNorfairBoss = 0xDF + SmMapBrinstar = 0xCA + SmMapWreckedShip = 0xCB + SmMapMaridia = 0xCC + SmMapLowerNorfair = 0xCD + Missile = 0xC2 Super = 0xC3 PowerBomb = 0xC4 @@ -174,6 +179,7 @@ class Item: map = re.compile("^Map") compass = re.compile("^Compass") keycard = re.compile("^Card") + smMap = re.compile("^SmMap") def IsDungeonItem(self): return self.dungeon.match(self.Type.name) def IsBigKey(self): return self.bigKey.match(self.Type.name) @@ -181,6 +187,7 @@ class Item: def IsMap(self): return self.map.match(self.Type.name) def IsCompass(self): return self.compass.match(self.Type.name) def IsKeycard(self): return self.keycard.match(self.Type.name) + def IsSmMap(self): return self.smMap.match(self.Type.name) def Is(self, type: ItemType, world): return self.Type == type and self.World == world @@ -313,7 +320,7 @@ class Item: Item.AddRange(itemPool, 4, Item(ItemType.BombUpgrade5)) Item.AddRange(itemPool, 2, Item(ItemType.OneRupee)) Item.AddRange(itemPool, 4, Item(ItemType.FiveRupees)) - Item.AddRange(itemPool, 25 if world.Config.Keysanity else 28, Item(ItemType.TwentyRupees)) + Item.AddRange(itemPool, 21 if world.Config.Keysanity else 28, Item(ItemType.TwentyRupees)) Item.AddRange(itemPool, 7, Item(ItemType.FiftyRupees)) Item.AddRange(itemPool, 5, Item(ItemType.ThreeHundredRupees)) @@ -421,6 +428,21 @@ class Item: return itemPool + @staticmethod + def CreateSmMaps(world): + itemPool = [ + Item(ItemType.SmMapBrinstar, world), + Item(ItemType.SmMapWreckedShip, world), + Item(ItemType.SmMapMaridia, world), + Item(ItemType.SmMapLowerNorfair, world) + ] + + for item in itemPool: + item.Progression = True + item.World = world + + return itemPool + @staticmethod def Get(items, itemType:ItemType): item = next((i for i in items if i.Type == itemType), None) @@ -725,7 +747,7 @@ class Progression: def CanAccessMiseryMirePortal(self, config: Config): if (config.SMLogic == SMLogic.Normal): - return (self.CardNorfairL2 or (self.SpeedBooster and self.Wave)) and self.Varia and self.Super and (self.Gravity and self.SpaceJump) and self.CanUsePowerBombs() + return (self.CardNorfairL2 or (self.SpeedBooster and self.Wave)) and self.Varia and self.Super and self.Gravity and self.SpaceJump and self.CanUsePowerBombs() else: return (self.CardNorfairL2 or self.SpeedBooster) and self.Varia and self.Super and \ (self.CanFly() or self.HiJump or self.SpeedBooster or self.CanSpringBallJump() or self.Ice) \ @@ -769,11 +791,11 @@ class Progression: if (world.Config.SMLogic == SMLogic.Normal): return self.MoonPearl and self.Flippers and \ self.Gravity and self.Morph and \ - (world.CanAquire(self, worlds.smz3.TotalSMZ3.Region.RewardType.Agahnim) or self.Hammer and self.CanLiftLight() or self.CanLiftHeavy()) + (world.CanAcquire(self, worlds.smz3.TotalSMZ3.Region.RewardType.Agahnim) or self.Hammer and self.CanLiftLight() or self.CanLiftHeavy()) else: return self.MoonPearl and self.Flippers and \ (self.CanSpringBallJump() or self.HiJump or self.Gravity) and self.Morph and \ - (world.CanAquire(self, worlds.smz3.TotalSMZ3.Region.RewardType.Agahnim) or self.Hammer and self.CanLiftLight() or self.CanLiftHeavy()) + (world.CanAcquire(self, worlds.smz3.TotalSMZ3.Region.RewardType.Agahnim) or self.Hammer and self.CanLiftLight() or self.CanLiftHeavy()) # Start of AP integration items_start_id = 84000 diff --git a/worlds/smz3/TotalSMZ3/Patch.py b/worlds/smz3/TotalSMZ3/Patch.py index d029e58473..2b8d278d49 100644 --- a/worlds/smz3/TotalSMZ3/Patch.py +++ b/worlds/smz3/TotalSMZ3/Patch.py @@ -6,7 +6,7 @@ import typing from BaseClasses import Location from worlds.smz3.TotalSMZ3.Item import Item, ItemType from worlds.smz3.TotalSMZ3.Location import LocationType -from worlds.smz3.TotalSMZ3.Region import IMedallionAccess, IReward, RewardType, SMRegion, Z3Region +from worlds.smz3.TotalSMZ3.Region import IReward, RewardType, SMRegion, Z3Region from worlds.smz3.TotalSMZ3.Regions.Zelda.EasternPalace import EasternPalace from worlds.smz3.TotalSMZ3.Regions.Zelda.DesertPalace import DesertPalace from worlds.smz3.TotalSMZ3.Regions.Zelda.TowerOfHera import TowerOfHera @@ -18,10 +18,14 @@ from worlds.smz3.TotalSMZ3.Regions.Zelda.IcePalace import IcePalace from worlds.smz3.TotalSMZ3.Regions.Zelda.MiseryMire import MiseryMire from worlds.smz3.TotalSMZ3.Regions.Zelda.TurtleRock import TurtleRock from worlds.smz3.TotalSMZ3.Regions.Zelda.GanonsTower import GanonsTower +from worlds.smz3.TotalSMZ3.Regions.SuperMetroid.Brinstar.Kraid import Kraid +from worlds.smz3.TotalSMZ3.Regions.SuperMetroid.WreckedShip import WreckedShip +from worlds.smz3.TotalSMZ3.Regions.SuperMetroid.Maridia.Inner import Inner +from worlds.smz3.TotalSMZ3.Regions.SuperMetroid.NorfairLower.East import East from worlds.smz3.TotalSMZ3.Text.StringTable import StringTable from worlds.smz3.TotalSMZ3.World import World -from worlds.smz3.TotalSMZ3.Config import Config, GameMode, GanonInvincible +from worlds.smz3.TotalSMZ3.Config import Config, OpenTourian, Goal from worlds.smz3.TotalSMZ3.Text.Texts import Texts from worlds.smz3.TotalSMZ3.Text.Dialog import Dialog @@ -30,6 +34,11 @@ class KeycardPlaque: Level2 = 0xe1 Boss = 0xe2 Null = 0x00 + Zero = 0xe3 + One = 0xe4 + Two = 0xe5 + Three = 0xe6 + Four = 0xe7 class KeycardDoors: Left = 0xd414 @@ -73,8 +82,8 @@ class DropPrize(Enum): Fairy = 0xE3 class Patch: - Major = 0 - Minor = 1 + Major = 11 + Minor = 3 allWorlds: List[World] myWorld: World seedGuid: str @@ -105,13 +114,16 @@ class Patch: self.WriteDiggingGameRng() - self.WritePrizeShuffle() + self.WritePrizeShuffle(self.myWorld.WorldState.DropPrizes) self.WriteRemoveEquipmentFromUncle( self.myWorld.GetLocation("Link's Uncle").APLocation.item.item if self.myWorld.GetLocation("Link's Uncle").APLocation.item.game == "SMZ3" else Item(ItemType.Something)) - self.WriteGanonInvicible(config.GanonInvincible) + self.WriteGanonInvicible(config.Goal) + self.WritePreOpenPyramid(config.Goal) + self.WriteCrystalsNeeded(self.myWorld.TowerCrystals, self.myWorld.GanonCrystals) + self.WriteBossesNeeded(self.myWorld.TourianBossTokens) self.WriteRngBlock() self.WriteSaveAndQuitFromBossRoom() @@ -135,26 +147,27 @@ class Patch: return {patch[0]:patch[1] for patch in self.patches} def WriteMedallions(self): + from worlds.smz3.TotalSMZ3.WorldState import Medallion turtleRock = next(region for region in self.myWorld.Regions if isinstance(region, TurtleRock)) miseryMire = next(region for region in self.myWorld.Regions if isinstance(region, MiseryMire)) turtleRockAddresses = [0x308023, 0xD020, 0xD0FF, 0xD1DE ] miseryMireAddresses = [ 0x308022, 0xCFF2, 0xD0D1, 0xD1B0 ] - if turtleRock.Medallion == ItemType.Bombos: + if turtleRock.Medallion == Medallion.Bombos: turtleRockValues = [0x00, 0x51, 0x10, 0x00] - elif turtleRock.Medallion == ItemType.Ether: + elif turtleRock.Medallion == Medallion.Ether: turtleRockValues = [0x01, 0x51, 0x18, 0x00] - elif turtleRock.Medallion == ItemType.Quake: + elif turtleRock.Medallion == Medallion.Quake: turtleRockValues = [0x02, 0x14, 0xEF, 0xC4] else: raise exception(f"Tried using {turtleRock.Medallion} in place of Turtle Rock medallion") - if miseryMire.Medallion == ItemType.Bombos: + if miseryMire.Medallion == Medallion.Bombos: miseryMireValues = [0x00, 0x51, 0x00, 0x00] - elif miseryMire.Medallion == ItemType.Ether: + elif miseryMire.Medallion == Medallion.Ether: miseryMireValues = [0x01, 0x13, 0x9F, 0xF1] - elif miseryMire.Medallion == ItemType.Quake: + elif miseryMire.Medallion == Medallion.Quake: miseryMireValues = [0x02, 0x51, 0x08, 0x00] else: raise exception(f"Tried using {miseryMire.Medallion} in place of Misery Mire medallion") @@ -174,12 +187,19 @@ class Patch: self.rnd.shuffle(pendantsBlueRed) pendantRewards = pendantsGreen + pendantsBlueRed + bossTokens = [ 1, 2, 3, 4 ] + regions = [region for region in self.myWorld.Regions if isinstance(region, IReward)] crystalRegions = [region for region in regions if region.Reward == RewardType.CrystalBlue] + [region for region in regions if region.Reward == RewardType.CrystalRed] pendantRegions = [region for region in regions if region.Reward == RewardType.PendantGreen] + [region for region in regions if region.Reward == RewardType.PendantNonGreen] + bossRegions = [region for region in regions if region.Reward == RewardType.BossTokenKraid] + \ + [region for region in regions if region.Reward == RewardType.BossTokenPhantoon] + \ + [region for region in regions if region.Reward == RewardType.BossTokenDraygon] + \ + [region for region in regions if region.Reward == RewardType.BossTokenRidley] self.patches += self.RewardPatches(crystalRegions, crystalRewards, self.CrystalValues) self.patches += self.RewardPatches(pendantRegions, pendantRewards, self.PendantValues) + self.patches += self.RewardPatches(bossRegions, bossTokens, self.BossTokenValues) def RewardPatches(self, regions: List[IReward], rewards: List[int], rewardValues: Callable): addresses = [self.RewardAddresses(region) for region in regions] @@ -189,17 +209,22 @@ class Patch: def RewardAddresses(self, region: IReward): regionType = { - EasternPalace : [ 0x2A09D, 0xABEF8, 0xABEF9, 0x308052, 0x30807C, 0x1C6FE ], - DesertPalace : [ 0x2A09E, 0xABF1C, 0xABF1D, 0x308053, 0x308078, 0x1C6FF ], - TowerOfHera : [ 0x2A0A5, 0xABF0A, 0xABF0B, 0x30805A, 0x30807A, 0x1C706 ], - PalaceOfDarkness : [ 0x2A0A1, 0xABF00, 0xABF01, 0x308056, 0x30807D, 0x1C702 ], - SwampPalace : [ 0x2A0A0, 0xABF6C, 0xABF6D, 0x308055, 0x308071, 0x1C701 ], - SkullWoods : [ 0x2A0A3, 0xABF12, 0xABF13, 0x308058, 0x30807B, 0x1C704 ], - ThievesTown : [ 0x2A0A6, 0xABF36, 0xABF37, 0x30805B, 0x308077, 0x1C707 ], - IcePalace : [ 0x2A0A4, 0xABF5A, 0xABF5B, 0x308059, 0x308073, 0x1C705 ], - MiseryMire : [ 0x2A0A2, 0xABF48, 0xABF49, 0x308057, 0x308075, 0x1C703 ], - TurtleRock : [ 0x2A0A7, 0xABF24, 0xABF25, 0x30805C, 0x308079, 0x1C708 ] + EasternPalace : [ 0x2A09D, 0xABEF8, 0xABEF9, 0x308052, 0x30807C, 0x1C6FE, 0x30D100], + DesertPalace : [ 0x2A09E, 0xABF1C, 0xABF1D, 0x308053, 0x308078, 0x1C6FF, 0x30D101 ], + TowerOfHera : [ 0x2A0A5, 0xABF0A, 0xABF0B, 0x30805A, 0x30807A, 0x1C706, 0x30D102 ], + PalaceOfDarkness : [ 0x2A0A1, 0xABF00, 0xABF01, 0x308056, 0x30807D, 0x1C702, 0x30D103 ], + SwampPalace : [ 0x2A0A0, 0xABF6C, 0xABF6D, 0x308055, 0x308071, 0x1C701, 0x30D104 ], + SkullWoods : [ 0x2A0A3, 0xABF12, 0xABF13, 0x308058, 0x30807B, 0x1C704, 0x30D105 ], + ThievesTown : [ 0x2A0A6, 0xABF36, 0xABF37, 0x30805B, 0x308077, 0x1C707, 0x30D106 ], + IcePalace : [ 0x2A0A4, 0xABF5A, 0xABF5B, 0x308059, 0x308073, 0x1C705, 0x30D107 ], + MiseryMire : [ 0x2A0A2, 0xABF48, 0xABF49, 0x308057, 0x308075, 0x1C703, 0x30D108 ], + TurtleRock : [ 0x2A0A7, 0xABF24, 0xABF25, 0x30805C, 0x308079, 0x1C708, 0x30D109 ], + Kraid : [ 0xF26002, 0xF26004, 0xF26005, 0xF26000, 0xF26006, 0xF26007, 0x82FD36 ], + WreckedShip : [ 0xF2600A, 0xF2600C, 0xF2600D, 0xF26008, 0xF2600E, 0xF2600F, 0x82FE26 ], + Inner : [ 0xF26012, 0xF26014, 0xF26015, 0xF26010, 0xF26016, 0xF26017, 0x82FE76 ], + East : [ 0xF2601A, 0xF2601C, 0xF2601D, 0xF26018, 0xF2601E, 0xF2601F, 0x82FDD6 ] } + result = regionType.get(type(region), None) if result is None: raise exception(f"Region {region} should not be a dungeon reward region") @@ -208,13 +233,13 @@ class Patch: def CrystalValues(self, crystal: int): crystalMap = { - 1 : [ 0x02, 0x34, 0x64, 0x40, 0x7F, 0x06 ], - 2 : [ 0x10, 0x34, 0x64, 0x40, 0x79, 0x06 ], - 3 : [ 0x40, 0x34, 0x64, 0x40, 0x6C, 0x06 ], - 4 : [ 0x20, 0x34, 0x64, 0x40, 0x6D, 0x06 ], - 5 : [ 0x04, 0x32, 0x64, 0x40, 0x6E, 0x06 ], - 6 : [ 0x01, 0x32, 0x64, 0x40, 0x6F, 0x06 ], - 7 : [ 0x08, 0x34, 0x64, 0x40, 0x7C, 0x06 ], + 1 : [ 0x02, 0x34, 0x64, 0x40, 0x7F, 0x06, 0x10 ], + 2 : [ 0x10, 0x34, 0x64, 0x40, 0x79, 0x06, 0x10 ], + 3 : [ 0x40, 0x34, 0x64, 0x40, 0x6C, 0x06, 0x10 ], + 4 : [ 0x20, 0x34, 0x64, 0x40, 0x6D, 0x06, 0x10 ], + 5 : [ 0x04, 0x32, 0x64, 0x40, 0x6E, 0x06, 0x11 ], + 6 : [ 0x01, 0x32, 0x64, 0x40, 0x6F, 0x06, 0x11 ], + 7 : [ 0x08, 0x34, 0x64, 0x40, 0x7C, 0x06, 0x10 ], } result = crystalMap.get(crystal, None) if result is None: @@ -224,15 +249,28 @@ class Patch: def PendantValues(self, pendant: int): pendantMap = { - 1 : [ 0x04, 0x38, 0x62, 0x00, 0x69, 0x01 ], - 2 : [ 0x01, 0x32, 0x60, 0x00, 0x69, 0x03 ], - 3 : [ 0x02, 0x34, 0x60, 0x00, 0x69, 0x02 ], + 1 : [ 0x04, 0x38, 0x62, 0x00, 0x69, 0x01, 0x12 ], + 2 : [ 0x01, 0x32, 0x60, 0x00, 0x69, 0x03, 0x14 ], + 3 : [ 0x02, 0x34, 0x60, 0x00, 0x69, 0x02, 0x13 ] } result = pendantMap.get(pendant, None) if result is None: raise exception(f"Tried using {pendant} as a pendant number") else: return result + + def BossTokenValues(self, token: int): + tokenMap = { + 1 : [ 0x01, 0x38, 0x40, 0x80, 0x69, 0x80, 0x15 ], + 2 : [ 0x02, 0x34, 0x42, 0x80, 0x69, 0x81, 0x16 ], + 3 : [ 0x04, 0x34, 0x44, 0x80, 0x69, 0x82, 0x17 ], + 4 : [ 0x08, 0x32, 0x46, 0x80, 0x69, 0x83, 0x18 ] + } + result = tokenMap.get(token, None) + if result is None: + raise exception(f"Tried using {token} as a boss token number") + else: + return result def WriteSMLocations(self, locations: List[Location]): def GetSMItemPLM(location:Location): @@ -259,7 +297,7 @@ class Patch: ItemType.SpaceJump : 0xEF1B, ItemType.ScrewAttack : 0xEF1F } - plmId = 0xEFE0 if self.myWorld.Config.GameMode == GameMode.Multiworld else \ + plmId = 0xEFE0 if self.myWorld.Config.Multiworld else \ itemMap.get(location.APLocation.item.item.Type, 0xEFE0) if (plmId == 0xEFE0): plmId += 4 if location.Type == LocationType.Chozo else 8 if location.Type == LocationType.Hidden else 0 @@ -268,7 +306,7 @@ class Patch: return plmId for location in locations: - if (self.myWorld.Config.GameMode == GameMode.Multiworld): + if (self.myWorld.Config.Multiworld): self.patches.append((Snes(location.Address), getWordArray(GetSMItemPLM(location)))) self.patches.append(self.ItemTablePatch(location, self.GetZ3ItemId(location))) else: @@ -283,18 +321,14 @@ class Patch: self.patches.append((Snes(0x9E3BB), [0xE4] if location.APLocation.item.game == "SMZ3" and location.APLocation.item.item.Type == ItemType.KeyTH else [0xEB])) elif (location.Type in [LocationType.Pedestal, LocationType.Ether, LocationType.Bombos]): text = Texts.ItemTextbox(location.APLocation.item.item if location.APLocation.item.game == "SMZ3" else Item(ItemType.Something)) - dialog = Dialog.Simple(text) if (location.Type == LocationType.Pedestal): self.stringTable.SetPedestalText(text) - self.patches.append((Snes(0x308300), dialog)) elif (location.Type == LocationType.Ether): self.stringTable.SetEtherText(text) - self.patches.append((Snes(0x308F00), dialog)) elif (location.Type == LocationType.Bombos): self.stringTable.SetBombosText(text) - self.patches.append((Snes(0x309000), dialog)) - if (self.myWorld.Config.GameMode == GameMode.Multiworld): + if (self.myWorld.Config.Multiworld): self.patches.append((Snes(location.Address), [(location.Id - 256)])) self.patches.append(self.ItemTablePatch(location, self.GetZ3ItemId(location))) else: @@ -305,11 +339,11 @@ class Patch: item = location.APLocation.item.item itemDungeon = None if item.IsKey(): - itemDungeon = ItemType.Key if (not item.World.Config.Keysanity or item.Type != ItemType.KeyHC) else ItemType.KeyHC + itemDungeon = ItemType.Key elif item.IsBigKey(): itemDungeon = ItemType.BigKey elif item.IsMap(): - itemDungeon = ItemType.Map if (not item.World.Config.Keysanity or item.Type != ItemType.MapHC) else ItemType.MapHC + itemDungeon = ItemType.Map elif item.IsCompass(): itemDungeon = ItemType.Compass @@ -327,15 +361,11 @@ class Patch: def WriteDungeonMusic(self, keysanity: bool): if (not keysanity): - regions = [region for region in self.myWorld.Regions if isinstance(region, IReward)] - music = [] + regions = [region for region in self.myWorld.Regions if isinstance(region, Z3Region) and isinstance(region, IReward) and + region.Reward != None and region.Reward != RewardType.Agahnim] pendantRegions = [region for region in regions if region.Reward in [RewardType.PendantGreen, RewardType.PendantNonGreen]] crystalRegions = [region for region in regions if region.Reward in [RewardType.CrystalBlue, RewardType.CrystalRed]] - regions = pendantRegions + crystalRegions - music = [ - 0x11, 0x11, 0x11, 0x16, 0x16, - 0x16, 0x16, 0x16, 0x16, 0x16, - ] + music = [0x11 if (region.Reward == RewardType.PendantGreen or region.Reward == RewardType.PendantNonGreen) else 0x16 for region in regions] self.patches += self.MusicPatches(regions, music) #IEnumerable RandomDungeonMusic() { @@ -366,51 +396,13 @@ class Patch: else: return result - def WritePrizeShuffle(self): - prizePackItems = 56 - treePullItems = 3 - - bytes = [] - drop = 0 - final = 0 - - pool = [ - DropPrize.Heart, DropPrize.Heart, DropPrize.Heart, DropPrize.Heart, DropPrize.Green, DropPrize.Heart, DropPrize.Heart, DropPrize.Green, #// pack 1 - DropPrize.Blue, DropPrize.Green, DropPrize.Blue, DropPrize.Red, DropPrize.Blue, DropPrize.Green, DropPrize.Blue, DropPrize.Blue, #// pack 2 - DropPrize.FullMagic, DropPrize.Magic, DropPrize.Magic, DropPrize.Blue, DropPrize.FullMagic, DropPrize.Magic, DropPrize.Heart, DropPrize.Magic, #// pack 3 - DropPrize.Bomb1, DropPrize.Bomb1, DropPrize.Bomb1, DropPrize.Bomb4, DropPrize.Bomb1, DropPrize.Bomb1, DropPrize.Bomb8, DropPrize.Bomb1, #// pack 4 - DropPrize.Arrow5, DropPrize.Heart, DropPrize.Arrow5, DropPrize.Arrow10, DropPrize.Arrow5, DropPrize.Heart, DropPrize.Arrow5, DropPrize.Arrow10,#// pack 5 - DropPrize.Magic, DropPrize.Green, DropPrize.Heart, DropPrize.Arrow5, DropPrize.Magic, DropPrize.Bomb1, DropPrize.Green, DropPrize.Heart, #// pack 6 - DropPrize.Heart, DropPrize.Fairy, DropPrize.FullMagic, DropPrize.Red, DropPrize.Bomb8, DropPrize.Heart, DropPrize.Red, DropPrize.Arrow10, #// pack 7 - DropPrize.Green, DropPrize.Blue, DropPrize.Red,#// from pull trees - DropPrize.Green, DropPrize.Red,#// from prize crab - DropPrize.Green, #// stunned prize - DropPrize.Red,#// saved fish prize - ] - - prizes = pool - self.rnd.shuffle(prizes) - - #/* prize pack drop order */ - (bytes, prizes) = SplitOff(prizes, prizePackItems) - self.patches.append((Snes(0x6FA78), [byte.value for byte in bytes])) - - #/* tree pull prizes */ - (bytes, prizes) = SplitOff(prizes, treePullItems) - self.patches.append((Snes(0x1DFBD4), [byte.value for byte in bytes])) - - #/* crab prizes */ - (drop, final, prizes) = (prizes[0], prizes[1], prizes[2:]) - self.patches.append((Snes(0x6A9C8), [ drop.value ])) - self.patches.append((Snes(0x6A9C4), [ final.value ])) - - #/* stun prize */ - (drop, prizes) = (prizes[0], prizes[1:]) - self.patches.append((Snes(0x6F993), [ drop.value ])) - - #/* fish prize */ - drop = prizes[0] - self.patches.append((Snes(0x1D82CC), [ drop.value ])) + def WritePrizeShuffle(self, dropPrizes): + self.patches.append((Snes(0x6FA78), [e.value for e in dropPrizes.Packs])) + self.patches.append((Snes(0x1DFBD4), [e.value for e in dropPrizes.TreePulls])) + self.patches.append((Snes(0x6A9C8), [dropPrizes.CrabContinous.value])) + self.patches.append((Snes(0x6A9C4), [dropPrizes.CrabFinal.value])) + self.patches.append((Snes(0x6F993), [dropPrizes.Stun.value])) + self.patches.append((Snes(0x1D82CC), [dropPrizes.Fish.value])) self.patches += self.EnemyPrizePackDistribution() @@ -524,46 +516,29 @@ class Patch: redCrystalDungeons = [region for region in regions if region.Reward == RewardType.CrystalRed] sahasrahla = Texts.SahasrahlaReveal(greenPendantDungeon) - self.patches.append((Snes(0x308A00), Dialog.Simple(sahasrahla))) self.stringTable.SetSahasrahlaRevealText(sahasrahla) bombShop = Texts.BombShopReveal(redCrystalDungeons) - self.patches.append((Snes(0x308E00), Dialog.Simple(bombShop))) self.stringTable.SetBombShopRevealText(bombShop) blind = Texts.Blind(self.rnd) - self.patches.append((Snes(0x308800), Dialog.Simple(blind))) self.stringTable.SetBlindText(blind) tavernMan = Texts.TavernMan(self.rnd) - self.patches.append((Snes(0x308C00), Dialog.Simple(tavernMan))) self.stringTable.SetTavernManText(tavernMan) ganon = Texts.GanonFirstPhase(self.rnd) - self.patches.append((Snes(0x308600), Dialog.Simple(ganon))) self.stringTable.SetGanonFirstPhaseText(ganon) - #// Todo: Verify these two are correct if ganon invincible patch is ever added - #// ganon_fall_in_alt in v30 - ganonFirstPhaseInvincible = "You think you\nare ready to\nface me?\n\nI will not die\n\nunless you\ncomplete your\ngoals. Dingus!" - self.patches.append((Snes(0x309100), Dialog.Simple(ganonFirstPhaseInvincible))) - - #// ganon_phase_3_alt in v30 - ganonThirdPhaseInvincible = "Got wax in\nyour ears?\nI cannot die!" - self.patches.append((Snes(0x309200), Dialog.Simple(ganonThirdPhaseInvincible))) - #// --- - silversLocation = [loc for world in self.allWorlds for loc in world.Locations if loc.ItemIs(ItemType.SilverArrows, self.myWorld)] if len(silversLocation) == 0: silvers = Texts.GanonThirdPhaseMulti(None, self.myWorld, self.silversWorldID, self.playerIDToNames[self.silversWorldID]) else: - silvers = Texts.GanonThirdPhaseMulti(silversLocation[0].Region, self.myWorld) if config.GameMode == GameMode.Multiworld else \ + silvers = Texts.GanonThirdPhaseMulti(silversLocation[0].Region, self.myWorld) if config.Multiworld else \ Texts.GanonThirdPhaseSingle(silversLocation[0].Region) - self.patches.append((Snes(0x308700), Dialog.Simple(silvers))) self.stringTable.SetGanonThirdPhaseText(silvers) triforceRoom = Texts.TriforceRoom(self.rnd) - self.patches.append((Snes(0x308400), Dialog.Simple(triforceRoom))) self.stringTable.SetTriforceRoomText(triforceRoom) def WriteStringTable(self): @@ -579,26 +554,32 @@ class Patch: return bytearray(name, 'utf8') def WriteSeedData(self): - configField = \ + configField1 = \ ((1 if self.myWorld.Config.Race else 0) << 15) | \ ((1 if self.myWorld.Config.Keysanity else 0) << 13) | \ - ((1 if self.myWorld.Config.GameMode == Config.GameMode.Multiworld else 0) << 12) | \ + ((1 if self.myWorld.Config.Multiworld else 0) << 12) | \ (self.myWorld.Config.Z3Logic.value << 10) | \ (self.myWorld.Config.SMLogic.value << 8) | \ (Patch.Major << 4) | \ (Patch.Minor << 0) + configField2 = \ + ((1 if self.myWorld.Config.SwordLocation else 0) << 14) | \ + ((1 if self.myWorld.Config.MorphLocation else 0) << 12) | \ + ((1 if self.myWorld.Config.Goal else 0) << 8) + self.patches.append((Snes(0x80FF50), getWordArray(self.myWorld.Id))) - self.patches.append((Snes(0x80FF52), getWordArray(configField))) + self.patches.append((Snes(0x80FF52), getWordArray(configField1))) self.patches.append((Snes(0x80FF54), getDoubleWordArray(self.seed))) + self.patches.append((Snes(0x80FF58), getWordArray(configField2))) #/* Reserve the rest of the space for future use */ - self.patches.append((Snes(0x80FF58), [0x00] * 8)) + self.patches.append((Snes(0x80FF5A), [0x00] * 6)) self.patches.append((Snes(0x80FF60), bytearray(self.seedGuid, 'utf8'))) self.patches.append((Snes(0x80FF80), bytearray(self.myWorld.Guid, 'utf8'))) def WriteCommonFlags(self): #/* Common Combo Configuration flags at [asm]/config.asm */ - if (self.myWorld.Config.GameMode == GameMode.Multiworld): + if (self.myWorld.Config.Multiworld): self.patches.append((Snes(0xF47000), getWordArray(0x0001))) if (self.myWorld.Config.Keysanity): self.patches.append((Snes(0xF47006), getWordArray(0x0001))) @@ -619,97 +600,104 @@ class Patch: if (self.myWorld.Config.Keysanity): self.patches.append((Snes(0x40003B), [ 1 ])) #// MapMode #$00 = Always On (default) - #$01 = Require Map Item self.patches.append((Snes(0x400045), [ 0x0f ])) #// display ----dcba a: Small Keys, b: Big Key, c: Map, d: Compass - self.patches.append((Snes(0x40016A), [ 0x01 ])) #// enable local item dialog boxes for dungeon and keycard items + self.patches.append((Snes(0x40016A), [ 0x01 ])) #// FreeItemText: db #$01 ; #00 = Off (default) - #$01 = On def WriteSMKeyCardDoors(self): - if (not self.myWorld.Config.Keysanity): - return - - plaquePLm = 0xd410 - - doorList = [ - #// RoomId Door Facing yyxx Keycard Event Type Plaque type yyxx, Address (if 0 a dynamic PLM is created) - #// Crateria - [ 0x91F8, KeycardDoors.Right, 0x2601, KeycardEvents.CrateriaLevel1, KeycardPlaque.Level1, 0x2400, 0x0000 ], #// Crateria - Landing Site - Door to gauntlet - [ 0x91F8, KeycardDoors.Left, 0x168E, KeycardEvents.CrateriaLevel1, KeycardPlaque.Level1, 0x148F, 0x801E ], #// Crateria - Landing Site - Door to landing site PB - [ 0x948C, KeycardDoors.Left, 0x062E, KeycardEvents.CrateriaLevel2, KeycardPlaque.Level2, 0x042F, 0x8222 ], #// Crateria - Before Moat - Door to moat (overwrite PB door) - [ 0x99BD, KeycardDoors.Left, 0x660E, KeycardEvents.CrateriaBoss, KeycardPlaque.Boss, 0x640F, 0x8470 ], #// Crateria - Before G4 - Door to G4 - [ 0x9879, KeycardDoors.Left, 0x062E, KeycardEvents.CrateriaBoss, KeycardPlaque.Boss, 0x042F, 0x8420 ], #// Crateria - Before BT - Door to Bomb Torizo - - #// Brinstar - [ 0x9F11, KeycardDoors.Left, 0x060E, KeycardEvents.BrinstarLevel1, KeycardPlaque.Level1, 0x040F, 0x8784 ], #// Brinstar - Blue Brinstar - Door to ceiling e-tank room - - [ 0x9AD9, KeycardDoors.Right, 0xA601, KeycardEvents.BrinstarLevel2, KeycardPlaque.Level2, 0xA400, 0x0000 ], #// Brinstar - Green Brinstar - Door to etecoon area - [ 0x9D9C, KeycardDoors.Down, 0x0336, KeycardEvents.BrinstarBoss, KeycardPlaque.Boss, 0x0234, 0x863A ], #// Brinstar - Pink Brinstar - Door to spore spawn - [ 0xA130, KeycardDoors.Left, 0x161E, KeycardEvents.BrinstarLevel2, KeycardPlaque.Level2, 0x141F, 0x881C ], #// Brinstar - Pink Brinstar - Door to wave gate e-tank - [ 0xA0A4, KeycardDoors.Left, 0x062E, KeycardEvents.BrinstarLevel2, KeycardPlaque.Level2, 0x042F, 0x0000 ], #// Brinstar - Pink Brinstar - Door to spore spawn super - - [ 0xA56B, KeycardDoors.Left, 0x161E, KeycardEvents.BrinstarBoss, KeycardPlaque.Boss, 0x141F, 0x8A1A ], #// Brinstar - Before Kraid - Door to Kraid - - #// Upper Norfair - [ 0xA7DE, KeycardDoors.Right, 0x3601, KeycardEvents.NorfairLevel1, KeycardPlaque.Level1, 0x3400, 0x8B00 ], #// Norfair - Business Centre - Door towards Ice - [ 0xA923, KeycardDoors.Right, 0x0601, KeycardEvents.NorfairLevel1, KeycardPlaque.Level1, 0x0400, 0x0000 ], #// Norfair - Pre-Crocomire - Door towards Ice - - [ 0xA788, KeycardDoors.Left, 0x162E, KeycardEvents.NorfairLevel2, KeycardPlaque.Level2, 0x142F, 0x8AEA ], #// Norfair - Lava Missile Room - Door towards Bubble Mountain - [ 0xAF72, KeycardDoors.Left, 0x061E, KeycardEvents.NorfairLevel2, KeycardPlaque.Level2, 0x041F, 0x0000 ], #// Norfair - After frog speedway - Door to Bubble Mountain - [ 0xAEDF, KeycardDoors.Down, 0x0206, KeycardEvents.NorfairLevel2, KeycardPlaque.Level2, 0x0204, 0x0000 ], #// Norfair - Below bubble mountain - Door to Bubble Mountain - [ 0xAD5E, KeycardDoors.Right, 0x0601, KeycardEvents.NorfairLevel2, KeycardPlaque.Level2, 0x0400, 0x0000 ], #// Norfair - LN Escape - Door to Bubble Mountain - - [ 0xA923, KeycardDoors.Up, 0x2DC6, KeycardEvents.NorfairBoss, KeycardPlaque.Boss, 0x2EC4, 0x8B96 ], #// Norfair - Pre-Crocomire - Door to Crocomire - - #// Lower Norfair - [ 0xB4AD, KeycardDoors.Left, 0x160E, KeycardEvents.LowerNorfairLevel1, KeycardPlaque.Level1, 0x140F, 0x0000 ], #// Lower Norfair - WRITG - Door to Amphitheatre - [ 0xAD5E, KeycardDoors.Left, 0x065E, KeycardEvents.LowerNorfairLevel1, KeycardPlaque.Level1, 0x045F, 0x0000 ], #// Lower Norfair - Exit - Door to "Reverse LN Entry" - [ 0xB37A, KeycardDoors.Right, 0x0601, KeycardEvents.LowerNorfairBoss, KeycardPlaque.Boss, 0x0400, 0x8EA6 ], #// Lower Norfair - Pre-Ridley - Door to Ridley - - #// Maridia - [ 0xD0B9, KeycardDoors.Left, 0x065E, KeycardEvents.MaridiaLevel1, KeycardPlaque.Level1, 0x045F, 0x0000 ], #// Maridia - Mt. Everest - Door to Pink Maridia - [ 0xD5A7, KeycardDoors.Right, 0x1601, KeycardEvents.MaridiaLevel1, KeycardPlaque.Level1, 0x1400, 0x0000 ], #// Maridia - Aqueduct - Door towards Beach - - [ 0xD617, KeycardDoors.Left, 0x063E, KeycardEvents.MaridiaLevel2, KeycardPlaque.Level2, 0x043F, 0x0000 ], #// Maridia - Pre-Botwoon - Door to Botwoon - [ 0xD913, KeycardDoors.Right, 0x2601, KeycardEvents.MaridiaLevel2, KeycardPlaque.Level2, 0x2400, 0x0000 ], #// Maridia - Pre-Colloseum - Door to post-botwoon - - [ 0xD78F, KeycardDoors.Right, 0x2601, KeycardEvents.MaridiaBoss, KeycardPlaque.Boss, 0x2400, 0xC73B ], #// Maridia - Precious Room - Door to Draygon - - [ 0xDA2B, KeycardDoors.BossLeft, 0x164E, 0x00f0, KeycardPlaque.Null, 0x144F, 0x0000 ], #// Maridia - Change Cac Alley Door to Boss Door (prevents key breaking) - - #// Wrecked Ship - [ 0x93FE, KeycardDoors.Left, 0x167E, KeycardEvents.WreckedShipLevel1, KeycardPlaque.Level1, 0x147F, 0x0000 ], #// Wrecked Ship - Outside Wrecked Ship West - Door to Reserve Tank Check - [ 0x968F, KeycardDoors.Left, 0x060E, KeycardEvents.WreckedShipLevel1, KeycardPlaque.Level1, 0x040F, 0x0000 ], #// Wrecked Ship - Outside Wrecked Ship West - Door to Bowling Alley - [ 0xCE40, KeycardDoors.Left, 0x060E, KeycardEvents.WreckedShipLevel1, KeycardPlaque.Level1, 0x040F, 0x0000 ], #// Wrecked Ship - Gravity Suit - Door to Bowling Alley - - [ 0xCC6F, KeycardDoors.Left, 0x064E, KeycardEvents.WreckedShipBoss, KeycardPlaque.Boss, 0x044F, 0xC29D ], #// Wrecked Ship - Pre-Phantoon - Door to Phantoon - ] - - doorId = 0x0000 + plaquePlm = 0xd410 plmTablePos = 0xf800 - for door in doorList: - doorArgs = doorId | door[3] if door[4] != KeycardPlaque.Null else door[3] - if (door[6] == 0): - #// Write dynamic door - doorData = [] - for x in door[0:3]: - doorData += getWordArray(x) - doorData += getWordArray(doorArgs) - self.patches.append((Snes(0x8f0000 + plmTablePos), doorData)) - plmTablePos += 0x08 - else: - #// Overwrite existing door - doorData = [] - for x in door[1:3]: - doorData += getWordArray(x) - doorData += getWordArray(doorArgs) - self.patches.append((Snes(0x8f0000 + door[6]), doorData)) - if((door[3] == KeycardEvents.BrinstarBoss and door[0] != 0x9D9C) or door[3] == KeycardEvents.LowerNorfairBoss or door[3] == KeycardEvents.MaridiaBoss or door[3] == KeycardEvents.WreckedShipBoss): - #// Overwrite the extra parts of the Gadora with a PLM that just deletes itself - self.patches.append((Snes(0x8f0000 + door[6] + 0x06), [ 0x2F, 0xB6, 0x00, 0x00, 0x00, 0x00, 0x2F, 0xB6, 0x00, 0x00, 0x00, 0x00 ])) - #// Plaque data - if (door[4] != KeycardPlaque.Null): - plaqueData = getWordArray(door[0]) + getWordArray(plaquePLm) + getWordArray(door[5]) + getWordArray(door[4]) - self.patches.append((Snes(0x8f0000 + plmTablePos), plaqueData)) - plmTablePos += 0x08 - doorId += 1 + if ( self.myWorld.Config.Keysanity): + doorList = [ + #// RoomId Door Facing yyxx Keycard Event Type Plaque type yyxx, Address (if 0 a dynamic PLM is created) + #// Crateria + [ 0x91F8, KeycardDoors.Right, 0x2601, KeycardEvents.CrateriaLevel1, KeycardPlaque.Level1, 0x2400, 0x0000 ], #// Crateria - Landing Site - Door to gauntlet + [ 0x91F8, KeycardDoors.Left, 0x168E, KeycardEvents.CrateriaLevel1, KeycardPlaque.Level1, 0x148F, 0x801E ], #// Crateria - Landing Site - Door to landing site PB + [ 0x948C, KeycardDoors.Left, 0x062E, KeycardEvents.CrateriaLevel2, KeycardPlaque.Level2, 0x042F, 0x8222 ], #// Crateria - Before Moat - Door to moat (overwrite PB door) + [ 0x99BD, KeycardDoors.Left, 0x660E, KeycardEvents.CrateriaBoss, KeycardPlaque.Boss, 0x640F, 0x8470 ], #// Crateria - Before G4 - Door to G4 + [ 0x9879, KeycardDoors.Left, 0x062E, KeycardEvents.CrateriaBoss, KeycardPlaque.Boss, 0x042F, 0x8420 ], #// Crateria - Before BT - Door to Bomb Torizo + + #// Brinstar + [ 0x9F11, KeycardDoors.Left, 0x060E, KeycardEvents.BrinstarLevel1, KeycardPlaque.Level1, 0x040F, 0x8784 ], #// Brinstar - Blue Brinstar - Door to ceiling e-tank room + + [ 0x9AD9, KeycardDoors.Right, 0xA601, KeycardEvents.BrinstarLevel2, KeycardPlaque.Level2, 0xA400, 0x0000 ], #// Brinstar - Green Brinstar - Door to etecoon area + [ 0x9D9C, KeycardDoors.Down, 0x0336, KeycardEvents.BrinstarBoss, KeycardPlaque.Boss, 0x0234, 0x863A ], #// Brinstar - Pink Brinstar - Door to spore spawn + [ 0xA130, KeycardDoors.Left, 0x161E, KeycardEvents.BrinstarLevel2, KeycardPlaque.Level2, 0x141F, 0x881C ], #// Brinstar - Pink Brinstar - Door to wave gate e-tank + [ 0xA0A4, KeycardDoors.Left, 0x062E, KeycardEvents.BrinstarLevel2, KeycardPlaque.Level2, 0x042F, 0x0000 ], #// Brinstar - Pink Brinstar - Door to spore spawn super + + [ 0xA56B, KeycardDoors.Left, 0x161E, KeycardEvents.BrinstarBoss, KeycardPlaque.Boss, 0x141F, 0x8A1A ], #// Brinstar - Before Kraid - Door to Kraid + + #// Upper Norfair + [ 0xA7DE, KeycardDoors.Right, 0x3601, KeycardEvents.NorfairLevel1, KeycardPlaque.Level1, 0x3400, 0x8B00 ], #// Norfair - Business Centre - Door towards Ice + [ 0xA923, KeycardDoors.Right, 0x0601, KeycardEvents.NorfairLevel1, KeycardPlaque.Level1, 0x0400, 0x0000 ], #// Norfair - Pre-Crocomire - Door towards Ice + + [ 0xA788, KeycardDoors.Left, 0x162E, KeycardEvents.NorfairLevel2, KeycardPlaque.Level2, 0x142F, 0x8AEA ], #// Norfair - Lava Missile Room - Door towards Bubble Mountain + [ 0xAF72, KeycardDoors.Left, 0x061E, KeycardEvents.NorfairLevel2, KeycardPlaque.Level2, 0x041F, 0x0000 ], #// Norfair - After frog speedway - Door to Bubble Mountain + [ 0xAEDF, KeycardDoors.Down, 0x0206, KeycardEvents.NorfairLevel2, KeycardPlaque.Level2, 0x0204, 0x0000 ], #// Norfair - Below bubble mountain - Door to Bubble Mountain + [ 0xAD5E, KeycardDoors.Right, 0x0601, KeycardEvents.NorfairLevel2, KeycardPlaque.Level2, 0x0400, 0x0000 ], #// Norfair - LN Escape - Door to Bubble Mountain + + [ 0xA923, KeycardDoors.Up, 0x2DC6, KeycardEvents.NorfairBoss, KeycardPlaque.Boss, 0x2EC4, 0x8B96 ], #// Norfair - Pre-Crocomire - Door to Crocomire + + #// Lower Norfair + [ 0xB4AD, KeycardDoors.Left, 0x160E, KeycardEvents.LowerNorfairLevel1, KeycardPlaque.Level1, 0x140F, 0x0000 ], #// Lower Norfair - WRITG - Door to Amphitheatre + [ 0xAD5E, KeycardDoors.Left, 0x065E, KeycardEvents.LowerNorfairLevel1, KeycardPlaque.Level1, 0x045F, 0x0000 ], #// Lower Norfair - Exit - Door to "Reverse LN Entry" + [ 0xB37A, KeycardDoors.Right, 0x0601, KeycardEvents.LowerNorfairBoss, KeycardPlaque.Boss, 0x0400, 0x8EA6 ], #// Lower Norfair - Pre-Ridley - Door to Ridley + + #// Maridia + [ 0xD0B9, KeycardDoors.Left, 0x065E, KeycardEvents.MaridiaLevel1, KeycardPlaque.Level1, 0x045F, 0x0000 ], #// Maridia - Mt. Everest - Door to Pink Maridia + [ 0xD5A7, KeycardDoors.Right, 0x1601, KeycardEvents.MaridiaLevel1, KeycardPlaque.Level1, 0x1400, 0x0000 ], #// Maridia - Aqueduct - Door towards Beach + + [ 0xD617, KeycardDoors.Left, 0x063E, KeycardEvents.MaridiaLevel2, KeycardPlaque.Level2, 0x043F, 0x0000 ], #// Maridia - Pre-Botwoon - Door to Botwoon + [ 0xD913, KeycardDoors.Right, 0x2601, KeycardEvents.MaridiaLevel2, KeycardPlaque.Level2, 0x2400, 0x0000 ], #// Maridia - Pre-Colloseum - Door to post-botwoon + + [ 0xD78F, KeycardDoors.Right, 0x2601, KeycardEvents.MaridiaBoss, KeycardPlaque.Boss, 0x2400, 0xC73B ], #// Maridia - Precious Room - Door to Draygon + + [ 0xDA2B, KeycardDoors.BossLeft, 0x164E, 0x00f0, KeycardPlaque.Null, 0x144F, 0x0000 ], #// Maridia - Change Cac Alley Door to Boss Door (prevents key breaking) + + #// Wrecked Ship + [ 0x93FE, KeycardDoors.Left, 0x167E, KeycardEvents.WreckedShipLevel1, KeycardPlaque.Level1, 0x147F, 0x0000 ], #// Wrecked Ship - Outside Wrecked Ship West - Door to Reserve Tank Check + [ 0x968F, KeycardDoors.Left, 0x060E, KeycardEvents.WreckedShipLevel1, KeycardPlaque.Level1, 0x040F, 0x0000 ], #// Wrecked Ship - Outside Wrecked Ship West - Door to Bowling Alley + [ 0xCE40, KeycardDoors.Left, 0x060E, KeycardEvents.WreckedShipLevel1, KeycardPlaque.Level1, 0x040F, 0x0000 ], #// Wrecked Ship - Gravity Suit - Door to Bowling Alley + + [ 0xCC6F, KeycardDoors.Left, 0x064E, KeycardEvents.WreckedShipBoss, KeycardPlaque.Boss, 0x044F, 0xC29D ], #// Wrecked Ship - Pre-Phantoon - Door to Phantoon + ] + + doorId = 0x0000 + for door in doorList: + #/* When "Fast Ganon" is set, don't place the G4 Boss key door to enable faster games */ + if (door[0] == 0x99BD and self.myWorld.Config.Goal == Goal.FastGanonDefeatMotherBrain): + continue + doorArgs = doorId | door[3] if door[4] != KeycardPlaque.Null else door[3] + if (door[6] == 0): + #// Write dynamic door + doorData = [] + for x in door[0:3]: + doorData += getWordArray(x) + doorData += getWordArray(doorArgs) + self.patches.append((Snes(0x8f0000 + plmTablePos), doorData)) + plmTablePos += 0x08 + else: + #// Overwrite existing door + doorData = [] + for x in door[1:3]: + doorData += getWordArray(x) + doorData += getWordArray(doorArgs) + self.patches.append((Snes(0x8f0000 + door[6]), doorData)) + if((door[3] == KeycardEvents.BrinstarBoss and door[0] != 0x9D9C) or door[3] == KeycardEvents.LowerNorfairBoss or door[3] == KeycardEvents.MaridiaBoss or door[3] == KeycardEvents.WreckedShipBoss): + #// Overwrite the extra parts of the Gadora with a PLM that just deletes itself + self.patches.append((Snes(0x8f0000 + door[6] + 0x06), [ 0x2F, 0xB6, 0x00, 0x00, 0x00, 0x00, 0x2F, 0xB6, 0x00, 0x00, 0x00, 0x00 ])) + + #// Plaque data + if (door[4] != KeycardPlaque.Null): + plaqueData = getWordArray(door[0]) + getWordArray(plaquePlm) + getWordArray(door[5]) + getWordArray(door[4]) + self.patches.append((Snes(0x8f0000 + plmTablePos), plaqueData)) + plmTablePos += 0x08 + doorId += 1 + + #/* Write plaque showing SM bosses that needs to be killed */ + if (self.myWorld.Config.OpenTourian != OpenTourian.FourBosses): + plaqueData = getWordArray(0xA5ED) + getWordArray(plaquePlm) + getWordArray(0x044F) + getWordArray(KeycardPlaque.Zero + self.myWorld.TourianBossTokens) + self.patches.append((Snes(0x8f0000 + plmTablePos), plaqueData)) + plmTablePos += 0x08 self.patches.append((Snes(0x8f0000 + plmTablePos), [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ])) @@ -745,20 +733,32 @@ class Patch: (Snes(0xDD313), [ 0x00, 0x00, 0xE4, 0xFF, 0x08, 0x0E ]), ] - def WriteGanonInvicible(self, invincible: GanonInvincible): + def WritePreOpenPyramid(self, goal: Goal): + if (goal == Goal.FastGanonDefeatMotherBrain): + self.patches.append((Snes(0x30808B), [0x01])) + + def WriteGanonInvicible(self, goal: Goal): #/* Defaults to $00 (never) at [asm]/z3/randomizer/tables.asm */ - invincibleMap = { - GanonInvincible.Never : 0x00, - GanonInvincible.Always : 0x01, - GanonInvincible.BeforeAllDungeons : 0x02, - GanonInvincible.BeforeCrystals : 0x03, - } - value = invincibleMap.get(invincible, None) + valueMap = { + Goal.DefeatBoth : 0x03, + Goal.FastGanonDefeatMotherBrain : 0x04, + Goal.AllDungeonsDefeatMotherBrain : 0x02 + } + value = valueMap.get(goal, None) if (value is None): - raise exception(f"Unknown Ganon invincible value {invincible}") + raise exception(f"Unknown Ganon invincible value {goal}") else: self.patches.append((Snes(0x30803E), [value])) + def WriteBossesNeeded(self, tourianBossTokens): + self.patches.append((Snes(0xF47200), getWordArray(tourianBossTokens))) + + def WriteCrystalsNeeded(self, towerCrystals, ganonCrystals): + self.patches.append((Snes(0x30805E), [towerCrystals])) + self.patches.append((Snes(0x30805F), [ganonCrystals])) + + self.stringTable.SetTowerRequirementText(f"You need {towerCrystals} crystals to enter Ganon's Tower.") + self.stringTable.SetGanonRequirementText(f"You need {ganonCrystals} crystals to defeat Ganon.") def WriteRngBlock(self): #/* Repoint RNG Block */ diff --git a/worlds/smz3/TotalSMZ3/Region.py b/worlds/smz3/TotalSMZ3/Region.py index f352247c80..00e209ce45 100644 --- a/worlds/smz3/TotalSMZ3/Region.py +++ b/worlds/smz3/TotalSMZ3/Region.py @@ -5,12 +5,19 @@ from worlds.smz3.TotalSMZ3.Item import Item, ItemType class RewardType(Enum): Null = 0 - Agahnim = 1 - PendantGreen = 2 - PendantNonGreen = 3 - CrystalBlue = 4 - CrystalRed = 5 - GoldenFourBoss = 6 + Agahnim = 1 << 0 + PendantGreen = 1 << 1 + PendantNonGreen = 1 << 2 + CrystalBlue = 1 << 3 + CrystalRed = 1 << 4 + BossTokenKraid = 1 << 5 + BossTokenPhantoon = 1 << 6 + BossTokenDraygon = 1 << 7 + BossTokenRidley = 1 << 8 + + AnyPendant = PendantGreen | PendantNonGreen + AnyCrystal = CrystalBlue | CrystalRed + AnyBossToken = BossTokenKraid | BossTokenPhantoon | BossTokenDraygon | BossTokenRidley class IReward: Reward: RewardType @@ -18,7 +25,7 @@ class IReward: pass class IMedallionAccess: - Medallion: object + Medallion = None class Region: import worlds.smz3.TotalSMZ3.Location as Location diff --git a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Brinstar/Kraid.py b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Brinstar/Kraid.py index fe3f804da9..2b99081dd3 100644 --- a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Brinstar/Kraid.py +++ b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Brinstar/Kraid.py @@ -7,7 +7,7 @@ class Kraid(SMRegion, IReward): Name = "Brinstar Kraid" Area = "Brinstar" - Reward = RewardType.GoldenFourBoss + Reward = RewardType.Null def __init__(self, world, config: Config): super().__init__(world, config) diff --git a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Brinstar/Pink.py b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Brinstar/Pink.py index 465f885b11..bb1036fb81 100644 --- a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Brinstar/Pink.py +++ b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Brinstar/Pink.py @@ -40,5 +40,5 @@ class Pink(SMRegion): else: return items.CanOpenRedDoors() and (items.CanDestroyBombWalls() or items.SpeedBooster) or \ items.CanUsePowerBombs() or \ - items.CanAccessNorfairUpperPortal() and items.Morph and (items.CanOpenRedDoors() or items.Wave) and \ + items.CanAccessNorfairUpperPortal() and items.Morph and (items.Missile or items.Super or items.Wave ) and \ (items.Ice or items.HiJump or items.CanSpringBallJump() or items.CanFly()) diff --git a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Crateria/East.py b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Crateria/East.py index d223fd82a2..72d10a4496 100644 --- a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Crateria/East.py +++ b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Crateria/East.py @@ -17,9 +17,9 @@ class East(SMRegion): self.world.CanEnter("Wrecked Ship", items)) if self.Logic == SMLogic.Normal else \ lambda items: items.Morph), Location(self, 2, 0x8F81EE, LocationType.Hidden, "Missile (outside Wrecked Ship top)", - lambda items: self.world.CanEnter("Wrecked Ship", items) and (not self.Config.Keysanity or items.CardWreckedShipBoss) and items.CanPassBombPassages()), + lambda items: self.world.CanEnter("Wrecked Ship", items) and items.CardWreckedShipBoss and items.CanPassBombPassages()), Location(self, 3, 0x8F81F4, LocationType.Visible, "Missile (outside Wrecked Ship middle)", - lambda items: self.world.CanEnter("Wrecked Ship", items) and (not self.Config.Keysanity or items.CardWreckedShipBoss) and items.CanPassBombPassages()), + lambda items: self.world.CanEnter("Wrecked Ship", items) and items.CardWreckedShipBoss and items.CanPassBombPassages()), Location(self, 4, 0x8F8248, LocationType.Visible, "Missile (Crateria moat)", lambda items: True) ] diff --git a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Maridia/Inner.py b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Maridia/Inner.py index 280f7e5b28..7de0798bae 100644 --- a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Maridia/Inner.py +++ b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/Maridia/Inner.py @@ -9,20 +9,17 @@ class Inner(SMRegion, IReward): def __init__(self, world, config: Config): super().__init__(world, config) - self.Reward = RewardType.GoldenFourBoss + self.Reward = RewardType.Null self.Locations = [ Location(self, 140, 0x8FC4AF, LocationType.Visible, "Super Missile (yellow Maridia)", - lambda items: items.CardMaridiaL1 and items.CanPassBombPassages() if self.Logic == SMLogic.Normal else \ - lambda items: items.CardMaridiaL1 and items.CanPassBombPassages() and - (items.Gravity or items.Ice or items.HiJump and items.CanSpringBallJump())), + lambda items: items.CardMaridiaL1 and items.CanPassBombPassages() and self.CanReachAqueduct(items) and + (items.Gravity or items.Ice or items.HiJump and items.SpringBall)), Location(self, 141, 0x8FC4B5, LocationType.Visible, "Missile (yellow Maridia super missile)", - lambda items: items.CardMaridiaL1 and items.CanPassBombPassages() if self.Logic == SMLogic.Normal else \ lambda items: items.CardMaridiaL1 and items.CanPassBombPassages() and - (items.Gravity or items.Ice or items.HiJump and items.CanSpringBallJump())), + (items.Gravity or items.Ice or items.HiJump and items.SpringBall)), Location(self, 142, 0x8FC533, LocationType.Visible, "Missile (yellow Maridia false wall)", - lambda items: items.CardMaridiaL1 and items.CanPassBombPassages() if self.Logic == SMLogic.Normal else \ lambda items: items.CardMaridiaL1 and items.CanPassBombPassages() and - (items.Gravity or items.Ice or items.HiJump and items.CanSpringBallJump())), + (items.Gravity or items.Ice or items.HiJump and items.SpringBall)), Location(self, 143, 0x8FC559, LocationType.Chozo, "Plasma Beam", lambda items: self.CanDefeatDraygon(items) and (items.ScrewAttack or items.Plasma) and (items.HiJump or items.CanFly()) if self.Logic == SMLogic.Normal else \ lambda items: self.CanDefeatDraygon(items) and @@ -31,17 +28,17 @@ class Inner(SMRegion, IReward): Location(self, 144, 0x8FC5DD, LocationType.Visible, "Missile (left Maridia sand pit room)", lambda items: self.CanReachAqueduct(items) and items.Super and items.CanPassBombPassages() if self.Logic == SMLogic.Normal else \ lambda items: self.CanReachAqueduct(items) and items.Super and - (items.HiJump and (items.SpaceJump or items.CanSpringBallJump()) or items.Gravity)), + (items.Gravity or items.HiJump and (items.SpaceJump or items.CanSpringBallJump()))), Location(self, 145, 0x8FC5E3, LocationType.Chozo, "Reserve Tank, Maridia", lambda items: self.CanReachAqueduct(items) and items.Super and items.CanPassBombPassages() if self.Logic == SMLogic.Normal else \ lambda items: self.CanReachAqueduct(items) and items.Super and - (items.HiJump and (items.SpaceJump or items.CanSpringBallJump()) or items.Gravity)), + (items.Gravity or items.HiJump and (items.SpaceJump or items.CanSpringBallJump()))), Location(self, 146, 0x8FC5EB, LocationType.Visible, "Missile (right Maridia sand pit room)", lambda items: self.CanReachAqueduct(items) and items.Super) if self.Logic == SMLogic.Normal else \ lambda items: self.CanReachAqueduct(items) and items.Super and (items.HiJump or items.Gravity), Location(self, 147, 0x8FC5F1, LocationType.Visible, "Power Bomb (right Maridia sand pit room)", lambda items: self.CanReachAqueduct(items) and items.Super) if self.Logic == SMLogic.Normal else \ - lambda items: self.CanReachAqueduct(items) and items.Super and (items.HiJump and items.CanSpringBallJump() or items.Gravity), + lambda items: self.CanReachAqueduct(items) and items.Super and (items.Gravity or items.HiJump and items.CanSpringBallJump()), Location(self, 148, 0x8FC603, LocationType.Visible, "Missile (pink Maridia)", lambda items: self.CanReachAqueduct(items) and items.SpeedBooster if self.Logic == SMLogic.Normal else \ lambda items: self.CanReachAqueduct(items) and items.Gravity), diff --git a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairLower/East.py b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairLower/East.py index 0cd577f78d..f1a325a12b 100644 --- a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairLower/East.py +++ b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairLower/East.py @@ -9,7 +9,7 @@ class East(SMRegion, IReward): def __init__(self, world, config: Config): super().__init__(world, config) - self.Reward = RewardType.GoldenFourBoss + self.Reward = RewardType.Null self.Locations = [ Location(self, 74, 0x8F8FCA, LocationType.Visible, "Missile (lower Norfair above fire flea room)", lambda items: self.CanExit(items)), @@ -30,11 +30,11 @@ class East(SMRegion, IReward): def CanExit(self, items:Progression): if self.Logic == SMLogic.Normal: # /*Bubble Mountain*/ - return items.CardNorfairL2 or ( + return items.Morph and (items.CardNorfairL2 or ( # /* Volcano Room and Blue Gate */ items.Gravity) and items.Wave and ( # /*Spikey Acid Snakes and Croc Escape*/ - items.Grapple or items.SpaceJump) + items.Grapple or items.SpaceJump)) else: # /*Vanilla LN Escape*/ return (items.Morph and (items.CardNorfairL2 or # /*Bubble Mountain*/ diff --git a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairLower/West.py b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairLower/West.py index 8740c545e3..4e44d28ca5 100644 --- a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairLower/West.py +++ b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairLower/West.py @@ -17,13 +17,13 @@ class West(SMRegion): items.CanAccessNorfairLowerPortal() and (items.CanFly() or items.CanSpringBallJump() or items.SpeedBooster) and items.Super)), Location(self, 71, 0x8F8E74, LocationType.Hidden, "Super Missile (Gold Torizo)", lambda items: items.CanDestroyBombWalls() and (items.Super or items.Charge) and - (items.CanAccessNorfairLowerPortal() or items.SpaceJump and items.CanUsePowerBombs()) if self.Logic == SMLogic.Normal else \ + (items.CanAccessNorfairLowerPortal() or items.CanUsePowerBombs() and items.SpaceJump) if self.Logic == SMLogic.Normal else \ lambda items: items.CanDestroyBombWalls() and items.Varia and (items.Super or items.Charge)), Location(self, 79, 0x8F9110, LocationType.Chozo, "Screw Attack", - lambda items: items.CanDestroyBombWalls() and (items.SpaceJump and items.CanUsePowerBombs() or items.CanAccessNorfairLowerPortal()) if self.Logic == SMLogic.Normal else \ - lambda items: items.CanDestroyBombWalls() and (items.Varia or items.CanAccessNorfairLowerPortal())), + lambda items: items.CanDestroyBombWalls() and (items.CanAccessNorfairLowerPortal() or items.CanUsePowerBombs() and items.SpaceJump) if self.Logic == SMLogic.Normal else \ + lambda items: items.CanDestroyBombWalls() and (items.CanAccessNorfairLowerPortal() or items.Varia)), Location(self, 73, 0x8F8F30, LocationType.Visible, "Missile (Mickey Mouse room)", - lambda items: items.CanFly() and items.Morph and items.Super and ( + lambda items: items.Morph and items.Super and items.CanFly() and items.CanUsePowerBombs() and ( # /*Exit to Upper Norfair*/ (items.CardLowerNorfairL1 or # /*Vanilla or Reverse Lava Dive*/ @@ -33,17 +33,20 @@ class West(SMRegion): # /* Volcano Room and Blue Gate */ items.Gravity and items.Wave and # /*Spikey Acid Snakes and Croc Escape*/ - (items.Grapple or items.SpaceJump) or + (items.Grapple or items.SpaceJump) or # /*Exit via GT fight and Portal*/ - (items.CanUsePowerBombs() and items.SpaceJump and (items.Super or items.Charge))) if self.Logic == SMLogic.Normal else \ + items.CanUsePowerBombs() and items.SpaceJump and (items.Super or items.Charge)) if self.Logic == SMLogic.Normal else \ lambda items: - items.Morph and items.Varia and items.Super and ((items.CanFly() or items.CanSpringBallJump() or - items.SpeedBooster and (items.HiJump and items.CanUsePowerBombs() or items.Charge and items.Ice)) and - # /*Exit to Upper Norfair*/ - (items.CardNorfairL2 or (items.SpeedBooster or items.CanFly() or items.Grapple or items.HiJump and - (items.CanSpringBallJump() or items.Ice))) or - # /*Return to Portal*/ - items.CanUsePowerBombs())) + items.Varia and items.Morph and items.Super and + #/* Climb worst room (from LN East CanEnter) */ + (items.CanFly() or items.HiJump or items.CanSpringBallJump() or items.Ice and items.Charge) and + (items.CanPassBombPassages() or items.ScrewAttack and items.SpaceJump) and ( + #/* Exit to Upper Norfair */ + items.CardNorfairL2 or items.SpeedBooster or items.CanFly() or items.Grapple or + items.HiJump and (items.CanSpringBallJump() or items.Ice) or + #/* Portal (with GGG) */ + items.CanUsePowerBombs() + )) ] # // Todo: account for Croc Speedway once Norfair Upper East also do so, otherwise it would be inconsistent to do so here diff --git a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairUpper/Crocomire.py b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairUpper/Crocomire.py index 914d07c3be..b38bbe70c6 100644 --- a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairUpper/Crocomire.py +++ b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/NorfairUpper/Crocomire.py @@ -45,11 +45,10 @@ class Crocomire(SMRegion): # /* Cathedral -> through the floor or Vulcano */ items.CanOpenRedDoors() and (items.CardNorfairL2 if self.Config.Keysanity else items.Super) and (items.CanFly() or items.HiJump or items.SpeedBooster) and - (items.CanPassBombPassages() or items.Gravity and items.Morph) and items.Wave - or + (items.CanPassBombPassages() or items.Gravity and items.Morph) and items.Wave) or ( # /* Reverse Lava Dive */ - items.CanAccessNorfairLowerPortal() and items.ScrewAttack and items.SpaceJump and items.Super and - items.Gravity and items.Wave and (items.CardNorfairL2 or items.Morph)) + items.Varia) and items.CanAccessNorfairLowerPortal() and items.ScrewAttack and items.SpaceJump and items.Super and ( + items.Gravity) and items.Wave and (items.CardNorfairL2 or items.Morph) else: return ((items.CanDestroyBombWalls() or items.SpeedBooster) and items.Super and items.Morph or items.CanAccessNorfairUpperPortal()) and ( # /* Ice Beam -> Croc Speedway */ @@ -65,5 +64,5 @@ class Crocomire(SMRegion): (items.Missile or items.Super or items.Wave) # /* Blue Gate */ ) or ( # /* Reverse Lava Dive */ - items.CanAccessNorfairLowerPortal()) and items.ScrewAttack and items.SpaceJump and items.Varia and items.Super and ( + items.Varia and items.CanAccessNorfairLowerPortal()) and items.ScrewAttack and items.SpaceJump and items.Super and ( items.HasEnergyReserves(2)) and (items.CardNorfairL2 or items.Morph) diff --git a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/WreckedShip.py b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/WreckedShip.py index 053de3d1a6..e83c6f539c 100644 --- a/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/WreckedShip.py +++ b/worlds/smz3/TotalSMZ3/Regions/SuperMetroid/WreckedShip.py @@ -9,12 +9,13 @@ class WreckedShip(SMRegion, IReward): def __init__(self, world, config: Config): super().__init__(world, config) - self.Reward = RewardType.GoldenFourBoss + self.Weight = 4 + self.Reward = RewardType.Null self.Locations = [ Location(self, 128, 0x8FC265, LocationType.Visible, "Missile (Wrecked Ship middle)", lambda items: items.CanPassBombPassages()), Location(self, 129, 0x8FC2E9, LocationType.Chozo, "Reserve Tank, Wrecked Ship", - lambda items: self.CanUnlockShip(items) and items.CardWreckedShipL1 and items.SpeedBooster and items.CanUsePowerBombs() and + lambda items: self.CanUnlockShip(items) and items.CardWreckedShipL1 and items.CanUsePowerBombs() and items.SpeedBooster and (items.Grapple or items.SpaceJump or items.Varia and items.HasEnergyReserves(2) or items.HasEnergyReserves(3)) if self.Logic == SMLogic.Normal else \ lambda items: self.CanUnlockShip(items) and items.CardWreckedShipL1 and items.CanUsePowerBombs() and items.SpeedBooster and (items.Varia or items.HasEnergyReserves(2))), @@ -27,7 +28,7 @@ class WreckedShip(SMRegion, IReward): Location(self, 132, 0x8FC337, LocationType.Visible, "Energy Tank, Wrecked Ship", lambda items: self.CanUnlockShip(items) and (items.HiJump or items.SpaceJump or items.SpeedBooster or items.Gravity) if self.Logic == SMLogic.Normal else \ - lambda items: self.CanUnlockShip(items) and (items.Bombs or items.PowerBomb or items.CanSpringBallJump() or + lambda items: self.CanUnlockShip(items) and (items.Morph and (items.Bombs or items.PowerBomb) or items.CanSpringBallJump() or items.HiJump or items.SpaceJump or items.SpeedBooster or items.Gravity)), Location(self, 133, 0x8FC357, LocationType.Visible, "Super Missile (Wrecked Ship left)", lambda items: self.CanUnlockShip(items)), diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/NorthEast.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/NorthEast.py index 5bc581c6d4..7ca34cb031 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/NorthEast.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/NorthEast.py @@ -14,15 +14,15 @@ class NorthEast(Z3Region): lambda items: items.MoonPearl and items.CanLiftLight()), Location(self, 256+79, 0x308147, LocationType.Regular, "Pyramid"), Location(self, 256+80, 0x1E980, LocationType.Regular, "Pyramid Fairy - Left", - lambda items: self.world.CanAquireAll(items, RewardType.CrystalRed) and items.MoonPearl and self.world.CanEnter("Dark World South", items) and - (items.Hammer or items.Mirror and self.world.CanAquire(items, RewardType.Agahnim))), + lambda items: self.world.CanAcquireAll(items, RewardType.CrystalRed) and items.MoonPearl and self.world.CanEnter("Dark World South", items) and + (items.Hammer or items.Mirror and self.world.CanAcquire(items, RewardType.Agahnim))), Location(self, 256+81, 0x1E983, LocationType.Regular, "Pyramid Fairy - Right", - lambda items: self.world.CanAquireAll(items, RewardType.CrystalRed) and items.MoonPearl and self.world.CanEnter("Dark World South", items) and - (items.Hammer or items.Mirror and self.world.CanAquire(items, RewardType.Agahnim))) + lambda items: self.world.CanAcquireAll(items, RewardType.CrystalRed) and items.MoonPearl and self.world.CanEnter("Dark World South", items) and + (items.Hammer or items.Mirror and self.world.CanAcquire(items, RewardType.Agahnim))) ] def CanEnter(self, items: Progression): - return self.world.CanAquire(items, RewardType.Agahnim) or items.MoonPearl and ( + return self.world.CanAcquire(items, RewardType.Agahnim) or items.MoonPearl and ( items.Hammer and items.CanLiftLight() or items.CanLiftHeavy() and items.Flippers or items.CanAccessDarkWorldPortal(self.Config) and items.Flippers) diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/NorthWest.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/NorthWest.py index 57b5ece194..28a318e80d 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/NorthWest.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/NorthWest.py @@ -25,7 +25,7 @@ class NorthWest(Z3Region): def CanEnter(self, items: Progression): return items.MoonPearl and (( - self.world.CanAquire(items, RewardType.Agahnim) or + self.world.CanAcquire(items, RewardType.Agahnim) or items.CanAccessDarkWorldPortal(self.Config) and items.Flippers ) and items.Hookshot and (items.Flippers or items.CanLiftLight() or items.Hammer) or items.Hammer and items.CanLiftLight() or diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/South.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/South.py index f43cb8886b..14f4515c6d 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/South.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/DarkWorld/South.py @@ -21,7 +21,7 @@ class South(Z3Region): def CanEnter(self, items: Progression): return items.MoonPearl and (( - self.world.CanAquire(items, RewardType.Agahnim) or + self.world.CanAcquire(items, RewardType.Agahnim) or items.CanAccessDarkWorldPortal(self.Config) and items.Flippers ) and (items.Hammer or items.Hookshot and (items.Flippers or items.CanLiftLight())) or items.Hammer and items.CanLiftLight() or diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py index 68fbd3b8db..0f21d7e284 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/GanonsTower.py @@ -34,33 +34,33 @@ class GanonsTower(Z3Region): self.GetLocation("Ganon's Tower - Randomizer Room - Bottom Left"), self.GetLocation("Ganon's Tower - Randomizer Room - Bottom Right") ]) or self.GetLocation("Ganon's Tower - Firesnake Room").ItemIs(ItemType.KeyGT, self.world) else 3)), - Location(self, 256+196, 0x1EAC4, LocationType.Regular, "Ganon's Tower - Randomizer Room - Top Left", + Location(self, 256+230, 0x1EAC4, LocationType.Regular, "Ganon's Tower - Randomizer Room - Top Left", lambda items: self.LeftSide(items, [ self.GetLocation("Ganon's Tower - Randomizer Room - Top Right"), self.GetLocation("Ganon's Tower - Randomizer Room - Bottom Left"), self.GetLocation("Ganon's Tower - Randomizer Room - Bottom Right") ])), - Location(self, 256+197, 0x1EAC7, LocationType.Regular, "Ganon's Tower - Randomizer Room - Top Right", + Location(self, 256+231, 0x1EAC7, LocationType.Regular, "Ganon's Tower - Randomizer Room - Top Right", lambda items: self.LeftSide(items, [ self.GetLocation("Ganon's Tower - Randomizer Room - Top Left"), self.GetLocation("Ganon's Tower - Randomizer Room - Bottom Left"), self.GetLocation("Ganon's Tower - Randomizer Room - Bottom Right") ])), - Location(self, 256+198, 0x1EACA, LocationType.Regular, "Ganon's Tower - Randomizer Room - Bottom Left", + Location(self, 256+232, 0x1EACA, LocationType.Regular, "Ganon's Tower - Randomizer Room - Bottom Left", lambda items: self.LeftSide(items, [ self.GetLocation("Ganon's Tower - Randomizer Room - Top Right"), self.GetLocation("Ganon's Tower - Randomizer Room - Top Left"), self.GetLocation("Ganon's Tower - Randomizer Room - Bottom Right") ])), - Location(self, 256+199, 0x1EACD, LocationType.Regular, "Ganon's Tower - Randomizer Room - Bottom Right", + Location(self, 256+233, 0x1EACD, LocationType.Regular, "Ganon's Tower - Randomizer Room - Bottom Right", lambda items: self.LeftSide(items, [ self.GetLocation("Ganon's Tower - Randomizer Room - Top Right"), self.GetLocation("Ganon's Tower - Randomizer Room - Top Left"), self.GetLocation("Ganon's Tower - Randomizer Room - Bottom Left") ])), - Location(self, 256+200, 0x1EAD9, LocationType.Regular, "Ganon's Tower - Hope Room - Left"), - Location(self, 256+201, 0x1EADC, LocationType.Regular, "Ganon's Tower - Hope Room - Right"), - Location(self, 256+202, 0x1EAE2, LocationType.Regular, "Ganon's Tower - Tile Room", + Location(self, 256+234, 0x1EAD9, LocationType.Regular, "Ganon's Tower - Hope Room - Left"), + Location(self, 256+235, 0x1EADC, LocationType.Regular, "Ganon's Tower - Hope Room - Right"), + Location(self, 256+236, 0x1EAE2, LocationType.Regular, "Ganon's Tower - Tile Room", lambda items: items.Somaria), Location(self, 256+203, 0x1EAE5, LocationType.Regular, "Ganon's Tower - Compass Room - Top Left", lambda items: self.RightSide(items, [ @@ -118,8 +118,9 @@ class GanonsTower(Z3Region): return items.Somaria and items.Firerod and items.KeyGT >= (3 if any(l.ItemIs(ItemType.BigKeyGT, self.world) for l in locations) else 4) def BigKeyRoom(self, items: Progression): - return items.KeyGT >= 3 and self.CanBeatArmos(items) \ - and (items.Hammer and items.Hookshot or items.Firerod and items.Somaria) + return items.KeyGT >= 3 and \ + (items.Hammer and items.Hookshot or items.Firerod and items.Somaria) \ + and self.CanBeatArmos(items) def TowerAscend(self, items: Progression): return items.BigKeyGT and items.KeyGT >= 3 and items.Bow and items.CanLightTorches() @@ -134,13 +135,14 @@ class GanonsTower(Z3Region): def CanEnter(self, items: Progression): return items.MoonPearl and self.world.CanEnter("Dark World Death Mountain East", items) and \ - self.world.CanAquireAll(items, RewardType.CrystalBlue, RewardType.CrystalRed, RewardType.GoldenFourBoss) + self.world.CanAcquireAtLeast(self.world.TowerCrystals, items, RewardType.AnyCrystal) and \ + self.world.CanAcquireAtLeast(self.world.TourianBossTokens * (self.world.TowerCrystals / 7), items, RewardType.AnyBossToken) def CanFill(self, item: Item): - if (self.Config.GameMode == GameMode.Multiworld): + if (self.Config.Multiworld): if (item.World != self.world or item.Progression): return False - if (self.Config.KeyShuffle == KeyShuffle.Keysanity and not ((item.Type == ItemType.BigKeyGT or item.Type == ItemType.KeyGT) and item.World == self.world) and (item.IsKey() or item.IsBigKey() or item.IsKeycard())): + if (self.Config.Keysanity and not ((item.Type == ItemType.BigKeyGT or item.Type == ItemType.KeyGT) and item.World == self.world) and (item.IsKey() or item.IsBigKey() or item.IsKeycard())): return False return super().CanFill(item) diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/IcePalace.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/IcePalace.py index 6543017c6f..9b16a08b4d 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/IcePalace.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/IcePalace.py @@ -10,6 +10,7 @@ class IcePalace(Z3Region, IReward): def __init__(self, world, config: Config): super().__init__(world, config) + self.Weight = 4 self.RegionItems = [ ItemType.KeyIP, ItemType.BigKeyIP, ItemType.MapIP, ItemType.CompassIP] self.Reward = RewardType.Null self.Locations = [ @@ -43,7 +44,7 @@ class IcePalace(Z3Region, IReward): ] def CanNotWasteKeysBeforeAccessible(self, items: Progression, locations: List[Location]): - return not items.BigKeyIP or any(l.ItemIs(ItemType.BigKeyIP, self.world) for l in locations) + return self.world.ForwardSearch or not items.BigKeyIP or any(l.ItemIs(ItemType.BigKeyIP, self.world) for l in locations) def CanEnter(self, items: Progression): return items.MoonPearl and items.Flippers and items.CanLiftHeavy() and items.CanMeltFreezors() diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/LightWorld/NorthEast.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/LightWorld/NorthEast.py index 0edf93f302..c111b07dfd 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/LightWorld/NorthEast.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/LightWorld/NorthEast.py @@ -24,5 +24,5 @@ class NorthEast(Z3Region): Location(self, 256+42, 0x1EA85, LocationType.Regular, "Sahasrahla's Hut - Middle").Weighted(sphereOne), Location(self, 256+43, 0x1EA88, LocationType.Regular, "Sahasrahla's Hut - Right").Weighted(sphereOne), Location(self, 256+44, 0x5F1FC, LocationType.Regular, "Sahasrahla", - lambda items: self.world.CanAquire(items, RewardType.PendantGreen)) + lambda items: self.world.CanAcquire(items, RewardType.PendantGreen)) ] diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/LightWorld/NorthWest.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/LightWorld/NorthWest.py index f35cbad33e..46f830dc8b 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/LightWorld/NorthWest.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/LightWorld/NorthWest.py @@ -11,11 +11,11 @@ class NorthWest(Z3Region): sphereOne = -14 self.Locations = [ Location(self, 256+14, 0x589B0, LocationType.Pedestal, "Master Sword Pedestal", - lambda items: self.world.CanAquireAll(items, RewardType.PendantGreen, RewardType.PendantNonGreen)), + lambda items: self.world.CanAcquireAll(items, RewardType.AnyPendant)), Location(self, 256+15, 0x308013, LocationType.Regular, "Mushroom").Weighted(sphereOne), Location(self, 256+16, 0x308000, LocationType.Regular, "Lost Woods Hideout").Weighted(sphereOne), Location(self, 256+17, 0x308001, LocationType.Regular, "Lumberjack Tree", - lambda items: self.world.CanAquire(items, RewardType.Agahnim) and items.Boots), + lambda items: self.world.CanAcquire(items, RewardType.Agahnim) and items.Boots), Location(self, 256+18, 0x1EB3F, LocationType.Regular, "Pegasus Rocks", lambda items: items.Boots), Location(self, 256+19, 0x308004, LocationType.Regular, "Graveyard Ledge", diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/MiseryMire.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/MiseryMire.py index 855c326f23..b1746184d3 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/MiseryMire.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/MiseryMire.py @@ -10,9 +10,10 @@ class MiseryMire(Z3Region, IReward, IMedallionAccess): def __init__(self, world, config: Config): super().__init__(world, config) + self.Weight = 2 self.RegionItems = [ ItemType.KeyMM, ItemType.BigKeyMM, ItemType.MapMM, ItemType.CompassMM] self.Reward = RewardType.Null - self.Medallion = ItemType.Nothing + self.Medallion = None self.Locations = [ Location(self, 256+169, 0x1EA5E, LocationType.Regular, "Misery Mire - Main Lobby", lambda items: items.BigKeyMM or items.KeyMM >= 1), @@ -34,8 +35,9 @@ class MiseryMire(Z3Region, IReward, IMedallionAccess): # // Need "CanKillManyEnemies" if implementing swordless def CanEnter(self, items: Progression): - return (items.Bombos if self.Medallion == ItemType.Bombos else ( - items.Ether if self.Medallion == ItemType.Ether else items.Quake)) and items.Sword and \ + from worlds.smz3.TotalSMZ3.WorldState import Medallion + return (items.Bombos if self.Medallion == Medallion.Bombos else ( + items.Ether if self.Medallion == Medallion.Ether else items.Quake)) and items.Sword and \ items.MoonPearl and (items.Boots or items.Hookshot) and \ self.world.CanEnter("Dark World Mire", items) diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/SwampPalace.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/SwampPalace.py index 1805e74dca..27b5a1db43 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/SwampPalace.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/SwampPalace.py @@ -10,6 +10,7 @@ class SwampPalace(Z3Region, IReward): def __init__(self, world, config: Config): super().__init__(world, config) + self.Weight = 3 self.RegionItems = [ ItemType.KeySP, ItemType.BigKeySP, ItemType.MapSP, ItemType.CompassSP] self.Reward = RewardType.Null self.Locations = [ diff --git a/worlds/smz3/TotalSMZ3/Regions/Zelda/TurtleRock.py b/worlds/smz3/TotalSMZ3/Regions/Zelda/TurtleRock.py index c4d19bcda1..45546e9e99 100644 --- a/worlds/smz3/TotalSMZ3/Regions/Zelda/TurtleRock.py +++ b/worlds/smz3/TotalSMZ3/Regions/Zelda/TurtleRock.py @@ -10,9 +10,10 @@ class TurtleRock(Z3Region, IReward, IMedallionAccess): def __init__(self, world, config: Config): super().__init__(world, config) + self.Weight = 6 self.RegionItems = [ ItemType.KeyTR, ItemType.BigKeyTR, ItemType.MapTR, ItemType.CompassTR] self.Reward = RewardType.Null - self.Medallion = ItemType.Nothing + self.Medallion = None self.Locations = [ Location(self, 256+177, 0x1EA22, LocationType.Regular, "Turtle Rock - Compass Chest"), Location(self, 256+178, 0x1EA1C, LocationType.Regular, "Turtle Rock - Roller Room - Left", @@ -46,8 +47,9 @@ class TurtleRock(Z3Region, IReward, IMedallionAccess): return items.Firerod and items.Icerod def CanEnter(self, items: Progression): - return (items.Bombos if self.Medallion == ItemType.Bombos else ( - items.Ether if self.Medallion == ItemType.Ether else items.Quake)) and items.Sword and \ + from worlds.smz3.TotalSMZ3.WorldState import Medallion + return (items.Bombos if self.Medallion == Medallion.Bombos else ( + items.Ether if self.Medallion == Medallion.Ether else items.Quake)) and items.Sword and \ items.MoonPearl and items.CanLiftHeavy() and items.Hammer and items.Somaria and \ self.world.CanEnter("Light World Death Mountain East", items) diff --git a/worlds/smz3/TotalSMZ3/Text/Dialog.py b/worlds/smz3/TotalSMZ3/Text/Dialog.py index d465e1e201..92e034af67 100644 --- a/worlds/smz3/TotalSMZ3/Text/Dialog.py +++ b/worlds/smz3/TotalSMZ3/Text/Dialog.py @@ -4,9 +4,7 @@ class Dialog: command = re.compile(r"^\{[^}]*\}") invalid = re.compile(r"(?[0-9])|(?P[A-Z])|(?P[a-z])") @staticmethod def Simple(text: str): @@ -29,19 +27,16 @@ class Dialog: lineIndex += 1 - if (lineIndex % 3 == 0 and lineIndex < len(lines)): - bytes.append(0x7E) - if (lineIndex >= 3 and lineIndex < len(lines)): - bytes.append(0x73) + if (lineIndex < len(lines)): + if (lineIndex % 3 == 0): + bytes.append(0x7E) # pause for input + if (lineIndex >= 3): + bytes.append(0x73) # scroll - bytes.append(0x7F) - if (len(bytes) > maxBytes): - return bytes[:maxBytes - 1].append(0x7F) - - return bytes + return bytes[:maxBytes - 1].append(0x7F) @staticmethod - def Compiled(text: str, pause = True): + def Compiled(text: str): maxBytes = 2046 wrap = 19 @@ -49,6 +44,7 @@ class Dialog: raise Exception("Dialog commands must be placed on separate lines", text) padOut = False + pause = True bytes = [ 0xFB ] lines = Dialog.Wordwrap(text, wrap) @@ -61,33 +57,11 @@ class Dialog: return [ 0xFB, 0xFE, 0x6E, 0x00, 0xFE, 0x6B, 0x04 ] if (match.string == "{INTRO}"): padOut = True + if (match.string == "{NOPAUSE}"): + pause = False + continue - bytesMap = { - "{SPEED0}" : [ 0xFC, 0x00 ], - "{SPEED2}" : [ 0xFC, 0x02 ], - "{SPEED6}" : [ 0xFC, 0x06 ], - "{PAUSE1}" : [ 0xFE, 0x78, 0x01 ], - "{PAUSE3}" : [ 0xFE, 0x78, 0x03 ], - "{PAUSE5}" : [ 0xFE, 0x78, 0x05 ], - "{PAUSE7}" : [ 0xFE, 0x78, 0x07 ], - "{PAUSE9}" : [ 0xFE, 0x78, 0x09 ], - "{INPUT}" : [ 0xFA ], - "{CHOICE}" : [ 0xFE, 0x68 ], - "{ITEMSELECT}" : [ 0xFE, 0x69 ], - "{CHOICE2}" : [ 0xFE, 0x71 ], - "{CHOICE3}" : [ 0xFE, 0x72 ], - "{C:GREEN}" : [ 0xFE, 0x77, 0x07 ], - "{C:YELLOW}" : [ 0xFE, 0x77, 0x02 ], - "{HARP}" : [ 0xFE, 0x79, 0x2D ], - "{MENU}" : [ 0xFE, 0x6D, 0x00 ], - "{BOTTOM}" : [ 0xFE, 0x6D, 0x01 ], - "{NOBORDER}" : [ 0xFE, 0x6B, 0x02 ], - "{CHANGEPIC}" : [ 0xFE, 0x67, 0xFE, 0x67 ], - "{CHANGEMUSIC}" : [ 0xFE, 0x67 ], - "{INTRO}" : [ 0xFE, 0x6E, 0x00, 0xFE, 0x77, 0x07, 0xFC, 0x03, 0xFE, 0x6B, 0x02, 0xFE, 0x67 ], - "{IBOX}" : [ 0xFE, 0x6B, 0x02, 0xFE, 0x77, 0x07, 0xFC, 0x03, 0xF7 ], - } - result = bytesMap.get(match.string, None) + result = Dialog.CommandBytesFor(match.string) if (result is None): raise Exception(f"Dialog text contained unknown command {match.string}", text) else: @@ -98,12 +72,10 @@ class Dialog: continue - if (lineIndex == 1): - bytes.append(0xF8); #// row 2 - elif (lineIndex >= 3 and lineIndex < lineCount): - bytes.append(0xF6); #// scroll - elif (lineIndex >= 2): - bytes.append(0xF9); #// row 3 + if (lineIndex > 0): + bytes.append(0xF8 if lineIndex == 1 else #// row 2 + 0xF9 if lineIndex == 2 else #// row 3 + 0xF6) #// scroll #// The first box needs to fill the full width with spaces as the palette is loaded weird. letters = line + (" " * wrap) if padOut and lineIndex < 3 else line @@ -113,10 +85,39 @@ class Dialog: lineIndex += 1 if (pause and lineIndex % 3 == 0 and lineIndex < lineCount): - bytes.append(0xFA) #// wait for input + bytes.append(0xFA) #// pause for input return bytes[:maxBytes] + @staticmethod + def CommandBytesFor(text: str): + bytesMap = { + "{SPEED0}" : [ 0xFC, 0x00 ], + "{SPEED2}" : [ 0xFC, 0x02 ], + "{SPEED6}" : [ 0xFC, 0x06 ], + "{PAUSE1}" : [ 0xFE, 0x78, 0x01 ], + "{PAUSE3}" : [ 0xFE, 0x78, 0x03 ], + "{PAUSE5}" : [ 0xFE, 0x78, 0x05 ], + "{PAUSE7}" : [ 0xFE, 0x78, 0x07 ], + "{PAUSE9}" : [ 0xFE, 0x78, 0x09 ], + "{INPUT}" : [ 0xFA ], + "{CHOICE}" : [ 0xFE, 0x68 ], + "{ITEMSELECT}" : [ 0xFE, 0x69 ], + "{CHOICE2}" : [ 0xFE, 0x71 ], + "{CHOICE3}" : [ 0xFE, 0x72 ], + "{C:GREEN}" : [ 0xFE, 0x77, 0x07 ], + "{C:YELLOW}" : [ 0xFE, 0x77, 0x02 ], + "{HARP}" : [ 0xFE, 0x79, 0x2D ], + "{MENU}" : [ 0xFE, 0x6D, 0x00 ], + "{BOTTOM}" : [ 0xFE, 0x6D, 0x01 ], + "{NOBORDER}" : [ 0xFE, 0x6B, 0x02 ], + "{CHANGEPIC}" : [ 0xFE, 0x67, 0xFE, 0x67 ], + "{CHANGEMUSIC}" : [ 0xFE, 0x67 ], + "{INTRO}" : [ 0xFE, 0x6E, 0x00, 0xFE, 0x77, 0x07, 0xFC, 0x03, 0xFE, 0x6B, 0x02, 0xFE, 0x67 ], + "{IBOX}" : [ 0xFE, 0x6B, 0x02, 0xFE, 0x77, 0x07, 0xFC, 0x03, 0xF7 ], + } + return bytesMap.get(text, None) + @staticmethod def Wordwrap(text: str, width: int): result = [] @@ -146,9 +147,13 @@ class Dialog: @staticmethod def LetterToBytes(c: str): - if Dialog.digit.match(c): return [(ord(c) - ord('0') + 0xA0) ] - elif Dialog.uppercaseLetter.match(c): return [ (ord(c) - ord('A') + 0xAA) ] - elif Dialog.lowercaseLetter.match(c): return [ (ord(c) - ord('a') + 0x30) ] + match = Dialog.character.match(c) + if match is None: + value = Dialog.letters.get(c, None) + return value if value else [ 0xFF ] + elif match.group("digit") != None: return [(ord(c) - ord('0') + 0xA0) ] + elif match.group("upper") != None: return [ (ord(c) - ord('A') + 0xAA) ] + elif match.group("lower") != None: return [ (ord(c) - ord('a') + 0x30) ] else: value = Dialog.letters.get(c, None) return value if value else [ 0xFF ] diff --git a/worlds/smz3/TotalSMZ3/Text/Scripts/General.yaml b/worlds/smz3/TotalSMZ3/Text/Scripts/General.yaml index 12b5271eab..065e7a9e93 100644 --- a/worlds/smz3/TotalSMZ3/Text/Scripts/General.yaml +++ b/worlds/smz3/TotalSMZ3/Text/Scripts/General.yaml @@ -377,9 +377,76 @@ Items: THE GREEN BOOMERANG IS THE FASTEST! - Keycard: |- - A key from - the future? + + CardCrateriaL1: |- + An Alien Key! + It says On top + of the world! + CardCrateriaL2: |- + An Alien Key! + It says Lower + the drawbridge + CardCrateriaBoss: |- + An Alien Key! + It says The First + and The Last + CardBrinstarL1: |- + An Alien Key! + It says But wait + there's more! + CardBrinstarL2: |- + An Alien Key! + It says + Green Monkeys + CardBrinstarBoss: |- + An Alien Key! + It says + Metroid DLC + CardNorfairL1: |- + An Alien Key! + It says ice? + In this heat? + CardNorfairL2: |- + An Alien Key! + It says + THE BUBBLES! + CardNorfairBoss: |- + An Alien Key! + It says + Place your bets + CardMaridiaL1: |- + An Alien Key! + It says A + Day at the Beach + CardMaridiaL2: |- + An Alien Key! + It says + That's a Moray + CardMaridiaBoss: |- + An Alien Key! + It says Shrimp + for dinner? + CardWreckedShipL1: |- + An Alien Key! + It says + Gutter Ball + CardWreckedShipBoss: |- + An Alien Key! + It says The + Ghost of Arrghus + CardLowerNorfairL1: |- + An Alien Key! + It says Worst + Key in the Game + CardLowerNorfairBoss: |- + An Alien Key! + It says + This guy again? + + SmMap: |- + You can now + find your way + to the stars! Something: |- A small victory! diff --git a/worlds/smz3/TotalSMZ3/Text/Scripts/StringTable.yaml b/worlds/smz3/TotalSMZ3/Text/Scripts/StringTable.yaml index d1e5b9c364..918ef3f63d 100644 --- a/worlds/smz3/TotalSMZ3/Text/Scripts/StringTable.yaml +++ b/worlds/smz3/TotalSMZ3/Text/Scripts/StringTable.yaml @@ -4,8 +4,8 @@ # The order of the dialog entries is significant - set_cursor: [0xFB, 0xFC, 0x00, 0xF9, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xE4, 0xFE, 0x68] - set_cursor2: [0xFB, 0xFC, 0x00, 0xF8, 0xFF, 0xFF, 0xFF, 0xF9, 0xFF, 0xFF, 0xE4, 0xFE, 0x68] -- game_over_menu: { NoPause: "{SPEED0}\nSave and Continue\nSave and Quit\nContinue" } -- var_test: { NoPause: "0= ᚋ, 1= ᚌ\n2= ᚍ, 3= ᚎ" } +- game_over_menu: "{NOPAUSE}\n{SPEED0}\nSave and Continue\nSave and Quit\nContinue" +- var_test: "{NOPAUSE}\n0= ᚋ, 1= ᚌ\n2= ᚍ, 3= ᚎ" - follower_no_enter: "Can't you take me some place nice." - choice_1_3: [0xFB, 0xFC, 0x00, 0xF7, 0xE4, 0xF8, 0xFF, 0xF9, 0xFF, 0xFE, 0x71] - choice_2_3: [0xFB, 0xFC, 0x00, 0xF7, 0xFF, 0xF8, 0xE4, 0xF9, 0xFF, 0xFE, 0x71] @@ -290,10 +290,10 @@ # $110 - magic_bat_wake: "You bum! I was sleeping! Where's my magic bolts?" - magic_bat_give_half_magic: "How you like me now?" -- intro_main: { NoPause: "{INTRO}\n Episode III\n{PAUSE3}\n A Link to\n the Past\n{PAUSE3}\n Randomizer\n{PAUSE3}\nAfter mostly disregarding what happened in the first two games.\n{PAUSE3}\nLink awakens to his uncle leaving the house.\n{PAUSE3}\nHe just runs out the door,\n{PAUSE3}\ninto the rainy night.\n{PAUSE3}\n{CHANGEPIC}\nGanon has moved around all the items in Hyrule.\n{PAUSE7}\nYou will have to find all the items necessary to beat Ganon.\n{PAUSE7}\nThis is your chance to be a hero.\n{PAUSE3}\n{CHANGEPIC}\nYou must get the 7 crystals to beat Ganon.\n{PAUSE9}\n{CHANGEPIC}" } -- intro_throne_room: { NoPause: "{IBOX}\nLook at this Stalfos on the throne." } -- intro_zelda_cell: { NoPause: "{IBOX}\nIt is your time to shine!" } -- intro_agahnim: { NoPause: "{IBOX}\nAlso, you need to defeat this guy!" } +- intro_main: "{NOPAUSE}\n{INTRO}\n Episode III\n{PAUSE3}\n A Link to\n the Past\n{PAUSE3}\n Randomizer\n{PAUSE3}\nAfter mostly disregarding what happened in the first two games.\n{PAUSE3}\nLink awakens to his uncle leaving the house.\n{PAUSE3}\nHe just runs out the door,\n{PAUSE3}\ninto the rainy night.\n{PAUSE3}\n{CHANGEPIC}\nGanon has moved around all the items in Hyrule.\n{PAUSE7}\nYou will have to find all the items necessary to beat Ganon.\n{PAUSE7}\nThis is your chance to be a hero.\n{PAUSE3}\n{CHANGEPIC}\nYou must get the 7 crystals to beat Ganon.\n{PAUSE9}\n{CHANGEPIC}" +- intro_throne_room: "{NOPAUSE}\n{IBOX}\nLook at this Stalfos on the throne." +- intro_zelda_cell: "{NOPAUSE}\n{IBOX}\nIt is your time to shine!" +- intro_agahnim: "{NOPAUSE}\n{IBOX}\nAlso, you need to defeat this guy!" - pickup_purple_chest: "A curious box. Let's take it with us!" - bomb_shop: "30 bombs for 100 rupees. Good deals all day!" - bomb_shop_big_bomb: "30 bombs for 100 rupees, 100 rupees 1 BIG bomb. Good deals all day!" @@ -341,7 +341,6 @@ # $140 - agahnim_defeated: "Arrrgggghhh. Well you're coming with me!" - agahnim_final_meeting: "You have done well to come this far. Now, die!" -# $142 - zora_meeting: "What do you want?\n ≥ Flippers\n _Nothin'\n{CHOICE}" - zora_tells_cost: "Fine! But they aren't cheap. You got 500 rupees?\n ≥ Duh\n _Oh carp\n{CHOICE}" - zora_get_flippers: "Here's some Flippers for you! Swim little fish, swim." @@ -396,14 +395,12 @@ - lost_woods_thief: "Have you seen Andy?\n\nHe was out looking for our prized Ether medallion.\nI wonder when he will be back?" - blinds_hut_dude: "I'm just some dude. This is Blind's hut." - end_triforce: "{SPEED2}\n{MENU}\n{NOBORDER}\n G G" -# $174 - toppi_fallen: "Ouch!\n\nYou Jerk!" - kakariko_tavern_fisherman: "Don't argue\nwith a frozen\nDeadrock.\nHe'll never\nchange his\nposition!" - thief_money: "It's a secret to everyone." - thief_desert_rupee_cave: "So you, like, busted down my door, and are being a jerk by talking to me? Normally I would be angry and make you pay for it, but I bet you're just going to break all my pots and steal my 50 rupees." - thief_ice_rupee_cave: "I'm a rupee pot farmer. One day I will take over the world with my skillz. Have you met my brother in the desert? He's way richer than I am." - telepathic_tile_south_east_darkworld_cave: "~~ Dev cave ~~\n No farming\n required" -# $17A - cukeman: "Did you hear that Veetorp beat ajneb174 in a 1 on 1 race at AGDQ?" - cukeman_2: "You found Shabadoo, huh?\nNiiiiice." - potion_shop_no_cash: "Yo! I'm not running a charity here." @@ -415,19 +412,25 @@ - game_chest_lost_woods: "Pay 100 rupees, open 1 chest. Are you lucky?\nSo, Play game?\n ≥ Play\n Never!\n{CHOICE}" - kakariko_flophouse_man_no_flippers: "I sure do have a lot of beds.\n\nZora is a cheapskate and will try to sell you his trash for 500 rupees…" - kakariko_flophouse_man: "I sure do have a lot of beds.\n\nDid you know if you played the flute in the center of town things could happen?" -- menu_start_2: { NoPause: "{MENU}\n{SPEED0}\n≥£'s House\n_Sanctuary\n{CHOICE3}" } -- menu_start_3: { NoPause: "{MENU}\n{SPEED0}\n≥£'s House\n_Sanctuary\n_Mountain Cave\n{CHOICE2}" } -- menu_pause: { NoPause: "{SPEED0}\n≥Continue Game\n_Save and Quit\n{CHOICE3}" } +- menu_start_2: "{NOPAUSE}\n{MENU}\n{SPEED0}\n≥£'s House\n_Sanctuary\n{CHOICE3}" +- menu_start_3: "{NOPAUSE}\n{MENU}\n{SPEED0}\n≥£'s House\n_Sanctuary\n_Mountain Cave\n{CHOICE2}" +- menu_pause: "{NOPAUSE}\n{SPEED0}\n≥Continue Game\n_Save and Quit\n{CHOICE3}" - game_digging_choice: "Have 80 Rupees? Want to play digging game?\n ≥Yes\n _No\n{CHOICE}" - game_digging_start: "Okay, use the shovel with Y!" - game_digging_no_cash: "Shovel rental is 80 rupees.\nI have all day" - game_digging_end_time: "Time's up!\nTime for you to go." - game_digging_come_back_later: "Come back later, I have to bury things." - game_digging_no_follower: "Something is following you. I don't like." -- menu_start_4: { NoPause: "{MENU}\n{SPEED0}\n≥£'s House\n_Mountain Cave\n{CHOICE3}" } +- menu_start_4: "{NOPAUSE}\n{MENU}\n{SPEED0}\n≥£'s House\n_Mountain Cave\n{CHOICE3}" - ganon_fall_in_alt: "You think you\nare ready to\nface me?\n\nI will not die\n\nunless you\ncomplete your\ngoals. Dingus!" - ganon_phase_3_alt: "Got wax in your ears? I cannot die!" # $190 - sign_east_death_mountain_bridge: "How did you get up here?" - fish_money: "It's a secret to everyone." +- sign_ganons_tower: "You need all 7 crystals to enter." +- sign_ganon: "You need all 7 crystals to beat Ganon." +- ganon_phase_3_no_bow: "You have no bow. Dingus!" +- ganon_phase_3_no_silvers_alt: "You can't best me without silver arrows!" +- ganon_phase_3_no_silvers: "You can't best me without silver arrows!" +- ganon_phase_3_silvers: "Oh no! Silver! My one true weakness!" - end_pad_data: "" diff --git a/worlds/smz3/TotalSMZ3/Text/StringTable.py b/worlds/smz3/TotalSMZ3/Text/StringTable.py index 4c1986993a..13f3f5edb5 100644 --- a/worlds/smz3/TotalSMZ3/Text/StringTable.py +++ b/worlds/smz3/TotalSMZ3/Text/StringTable.py @@ -3,7 +3,7 @@ from typing import Any, List import copy from worlds.smz3.TotalSMZ3.Text.Dialog import Dialog from worlds.smz3.TotalSMZ3.Text.Texts import text_folder -from yaml import load, Loader +from Utils import unsafe_parse_yaml class StringTable: @@ -11,7 +11,7 @@ class StringTable: def ParseEntries(resource: str): with open(resource, 'rb') as f: yaml = str(f.read(), "utf-8") - content = load(yaml, Loader) + content = unsafe_parse_yaml(yaml) result = [] for entryValue in content: @@ -20,8 +20,6 @@ class StringTable: result.append((key, value)) elif isinstance(value, str): result.append((key, Dialog.Compiled(value))) - elif isinstance(value, dict): - result.append((key, Dialog.Compiled(value["NoPause"], False))) else: raise Exception(f"Did not expect an object of type {type(value)}") return result @@ -47,9 +45,11 @@ class StringTable: def SetGanonThirdPhaseText(self, text: str): self.SetText("ganon_phase_3", text) + self.SetText("ganon_phase_3_no_silvers", text) + self.SetText("ganon_phase_3_no_silvers_alt", text) def SetTriforceRoomText(self, text: str): - self.SetText("end_triforce", "{NOBORDER}\n" + text) + self.SetText("end_triforce", f"{{NOBORDER}}\n{text}") def SetPedestalText(self, text: str): self.SetText("mastersword_pedestal_translated", text) @@ -60,6 +60,12 @@ class StringTable: def SetBombosText(self, text: str): self.SetText("tablet_bombos_book", text) + def SetTowerRequirementText(self, text: str): + self.SetText("sign_ganons_tower", text) + + def SetGanonRequirementText(self, text: str): + self.SetText("sign_ganon", text) + def SetText(self, name: str, text: str): count = 0 for key, value in self.entries: diff --git a/worlds/smz3/TotalSMZ3/Text/Texts.py b/worlds/smz3/TotalSMZ3/Text/Texts.py index 247ff92b1a..dfaeee06da 100644 --- a/worlds/smz3/TotalSMZ3/Text/Texts.py +++ b/worlds/smz3/TotalSMZ3/Text/Texts.py @@ -2,7 +2,7 @@ from worlds.smz3.TotalSMZ3.Region import Region from worlds.smz3.TotalSMZ3.Regions.Zelda.GanonsTower import GanonsTower from worlds.smz3.TotalSMZ3.Item import Item, ItemType -from yaml import load, Loader +from Utils import unsafe_parse_yaml import random import os @@ -13,7 +13,7 @@ class Texts: def ParseYamlScripts(resource: str): with open(resource, 'rb') as f: yaml = str(f.read(), "utf-8") - return load(yaml, Loader) + return unsafe_parse_yaml(yaml) @staticmethod def ParseTextScript(resource: str): @@ -75,7 +75,7 @@ class Texts: } if item.IsMap(): name = "Map" elif item.IsCompass(): name = "Compass" - elif item.IsKeycard(): name = "Keycard" + elif item.IsSmMap(): name = "SmMap" else: name = nameMap[item.Type] items = Texts.scripts["Items"] diff --git a/worlds/smz3/TotalSMZ3/World.py b/worlds/smz3/TotalSMZ3/World.py index 14d685167f..722d5858e6 100644 --- a/worlds/smz3/TotalSMZ3/World.py +++ b/worlds/smz3/TotalSMZ3/World.py @@ -54,10 +54,26 @@ class World: Player: str Guid: str Id: int + WorldState = None + + @property + def TowerCrystals(self): + return 7 if self.WorldState is None else self.WorldState.TowerCrystals + + @property + def GanonCrystals(self): + return 7 if self.WorldState is None else self.WorldState.GanonCrystals + + @property + def TourianBossTokens(self): + return 4 if self.WorldState is None else self.WorldState.TourianBossTokens def Items(self): return [l.Item for l in self.Locations if l.Item != None] + ForwardSearch: bool = False + + rewardLookup: Dict[int, List[Region.IReward]] locationLookup: Dict[str, Location.Location] regionLookup: Dict[str, Region.Region] @@ -95,22 +111,22 @@ class World: DarkWorldNorthEast(self, self.Config), DarkWorldSouth(self, self.Config), DarkWorldMire(self, self.Config), - Central(self, self.Config), CrateriaWest(self, self.Config), + Central(self, self.Config), CrateriaEast(self, self.Config), Blue(self, self.Config), Green(self, self.Config), - Kraid(self, self.Config), Pink(self, self.Config), Red(self, self.Config), + Kraid(self, self.Config), + WreckedShip(self, self.Config), Outer(self, self.Config), Inner(self, self.Config), NorfairUpperWest(self, self.Config), NorfairUpperEast(self, self.Config), Crocomire(self, self.Config), NorfairLowerWest(self, self.Config), - NorfairLowerEast(self, self.Config), - WreckedShip(self, self.Config) + NorfairLowerEast(self, self.Config) ] self.Locations = [] @@ -130,37 +146,32 @@ class World: raise Exception(f"World.CanEnter: Invalid region name {regionName}", f'{regionName=}'.partition('=')[0]) return region.CanEnter(items) - def CanAquire(self, items: Item.Progression, reward: Region.RewardType): + def CanAcquire(self, items: Item.Progression, reward: Region.RewardType): return next(iter([region for region in self.Regions if isinstance(region, Region.IReward) and region.Reward == reward])).CanComplete(items) - def CanAquireAll(self, items: Item.Progression, *rewards: Region.RewardType): - for region in self.Regions: - if issubclass(type(region), Region.IReward): - if (region.Reward in rewards): - if not region.CanComplete(items): - return False - return True + def CanAcquireAll(self, items: Item.Progression, rewardsMask: Region.RewardType): + return all(region.CanComplete(items) for region in self.rewardLookup[rewardsMask.value]) - # return all(region.CanComplete(items) for region in self.Regions if (isinstance(region, Region.IReward) and region.Reward in rewards)) + def CanAcquireAtLeast(self, amount, items: Item.Progression, rewardsMask: Region.RewardType): + return len([region for region in self.rewardLookup[rewardsMask.value] if region.CanComplete(items)]) >= amount - def Setup(self, rnd: random): - self.SetMedallions(rnd) - self.SetRewards(rnd) + def Setup(self, state): + self.WorldState = state + self.SetMedallions(state.Medallions) + self.SetRewards(state.Rewards) + self.SetRewardLookup() - def SetMedallions(self, rnd: random): - medallionMap = {0: Item.ItemType.Bombos, 1: Item.ItemType.Ether, 2: Item.ItemType.Quake} - regionList = [region for region in self.Regions if isinstance(region, Region.IMedallionAccess)] - for region in regionList: - region.Medallion = medallionMap[rnd.randint(0, 2)] + def SetRewards(self, rewards: List): + regions = [region for region in self.Regions if isinstance(region, Region.IReward) and region.Reward == Region.RewardType.Null] + for (region, reward) in zip(regions, rewards): + region.Reward = reward - def SetRewards(self, rnd: random): - rewards = [ - Region.RewardType.PendantGreen, Region.RewardType.PendantNonGreen, Region.RewardType.PendantNonGreen, Region.RewardType.CrystalRed, Region.RewardType.CrystalRed, - Region.RewardType.CrystalBlue, Region.RewardType.CrystalBlue, Region.RewardType.CrystalBlue, Region.RewardType.CrystalBlue, Region.RewardType.CrystalBlue - ] - rnd.shuffle(rewards) - regionList = [region for region in self.Regions if isinstance(region, Region.IReward) and region.Reward == Region.RewardType.Null] - for region in regionList: - region.Reward = rewards[0] - rewards.remove(region.Reward) + def SetMedallions(self, medallions: List): + self.GetRegion("Misery Mire").Medallion = medallions[0] + self.GetRegion("Turtle Rock").Medallion = medallions[1] + def SetRewardLookup(self): + #/* Generate a lookup of all possible regions for any given reward combination for faster lookup later */ + self.rewardLookup: Dict[int, Region.IReward] = {} + for i in range(0, 512): + self.rewardLookup[i] = [region for region in self.Regions if isinstance(region, Region.IReward) and (region.Reward.value & i) != 0] diff --git a/worlds/smz3/TotalSMZ3/WorldState.py b/worlds/smz3/TotalSMZ3/WorldState.py new file mode 100644 index 0000000000..c857b539d1 --- /dev/null +++ b/worlds/smz3/TotalSMZ3/WorldState.py @@ -0,0 +1,170 @@ +from enum import Enum +from typing import List +from copy import copy + +from worlds.smz3.TotalSMZ3.Patch import DropPrize +from worlds.smz3.TotalSMZ3.Region import RewardType +from worlds.smz3.TotalSMZ3.Config import OpenTower, GanonVulnerable, OpenTourian + +class Medallion(Enum): + Bombos = 0 + Ether = 1 + Quake = 2 + +class DropPrizeRecord: + Packs: List[DropPrize] + TreePulls: List[DropPrize] + CrabContinous: DropPrize + CrabFinal: DropPrize + Stun: DropPrize + Fish: DropPrize + + def __init__(self, Packs, TreePulls, CrabContinous, CrabFinal, Stun, Fish): + self.Packs = Packs + self.TreePulls = TreePulls + self.CrabContinous = CrabContinous + self.CrabFinal = CrabFinal + self.Stun = Stun + self.Fish = Fish + +class WorldState: + Rewards: List[RewardType] + Medallions: List[Medallion] + + TowerCrystals: int + GanonCrystals: int + TourianBossTokens: int + + DropPrizes: DropPrizeRecord + + def __init__(self, config, rnd): + self.Rewards = self.DistributeRewards(rnd) + self.Medallions = self.GenerateMedallions(rnd) + self.TowerCrystals = rnd.randint(0, 7) if config.OpenTower == OpenTower.Random else config.OpenTower.value + self.GanonCrystals = rnd.randint(0, 7) if config.GanonVulnerable == GanonVulnerable.Random else config.GanonVulnerable.value + self.TourianBossTokens = rnd.randint(0, 4) if config.OpenTourian == OpenTourian.Random else config.OpenTourian.value + self.DropPrizes = self.ShuffleDropPrizes(rnd) + + @staticmethod + def Generate(config, rnd): + return WorldState(config, rnd) + + BaseRewards = [ + RewardType.PendantGreen, RewardType.PendantNonGreen, RewardType.PendantNonGreen, RewardType.CrystalRed, RewardType.CrystalRed, + RewardType.CrystalBlue, RewardType.CrystalBlue, RewardType.CrystalBlue, RewardType.CrystalBlue, RewardType.CrystalBlue, + RewardType.AnyBossToken, RewardType.AnyBossToken, RewardType.AnyBossToken, RewardType.AnyBossToken, + ] + + BossTokens = [ + RewardType.BossTokenKraid, RewardType.BossTokenPhantoon, RewardType.BossTokenDraygon, RewardType.BossTokenRidley + ] + + @staticmethod + def DistributeRewards(rnd): + #// Assign four rewards for SM using a "loot table", randomized result + gen = WorldState.Distribution().Generate(lambda dist: dist.Hit(rnd.randrange(dist.Sum))) + smRewards = [next(gen) for x in range(4)] + + #// Exclude the SM rewards to get the Z3 lineup + z3Rewards = WorldState.BaseRewards[:] + for reward in smRewards: + z3Rewards.remove(reward) + + rnd.shuffle(z3Rewards) + #// Replace "any token" with random specific tokens + rewards = z3Rewards + smRewards + tokens = WorldState.BossTokens[:] + rnd.shuffle(tokens) + rewards = [tokens.pop() if reward == RewardType.AnyBossToken else reward for reward in rewards] + + return rewards + + + class Distribution: + factor = 3 + + def __init__(self, distribution = None, boss = None, blue = None, red = None, pend = None, green = None): + self.Boss = 4 * self.factor + self.Blue = 5 * self.factor + self.Red = 2 * self.factor + self.Pend = 2 + self.Green = 1 + + if (distribution is not None): + self = copy(distribution) + if (boss is not None): + self.Boss = boss + if (blue is not None): + self.Blue = blue + if (red is not None): + self.Red = red + if (pend is not None): + self.Pend = pend + if (green is not None): + self.Green = green + + @property + def Sum(self): + return self.Boss + self.Blue + self.Red + self.Pend + self.Green + + def Hit(self, p): + p -= self.Boss + if (p < 0): return (RewardType.AnyBossToken, WorldState.Distribution(self, boss = self.Boss - WorldState.Distribution.factor)) + p -= self.Blue + if (p - self.Blue < 0): return (RewardType.CrystalBlue, WorldState.Distribution(self, blue = self.Blue - WorldState.Distribution.factor)) + p -= self.Red + if (p - self.Red < 0): return (RewardType.CrystalRed, WorldState.Distribution(self, red = self.Red - WorldState.Distribution.factor)) + p -= self.Pend + if (p - self.Pend < 0): return (RewardType.PendantNonGreen, WorldState.Distribution(self, pend = self.Pend - 1)) + return (RewardType.PendantGreen, WorldState.Distribution(self, green = self.Green - 1)) + + def Generate(self, func): + result = None + while (True): + (result, newSelf) = func(self) + self.Boss = newSelf.Boss + self.Blue = newSelf.Blue + self.Red = newSelf.Red + self.Pend = newSelf.Pend + self.Green = newSelf.Green + yield result + + @staticmethod + def GenerateMedallions(rnd): + return [ + Medallion(rnd.randrange(3)), + Medallion(rnd.randrange(3)), + ] + + BaseDropPrizes = [ + DropPrize.Heart, DropPrize.Heart, DropPrize.Heart, DropPrize.Heart, DropPrize.Green, DropPrize.Heart, DropPrize.Heart, DropPrize.Green, #// pack 1 + DropPrize.Blue, DropPrize.Green, DropPrize.Blue, DropPrize.Red, DropPrize.Blue, DropPrize.Green, DropPrize.Blue, DropPrize.Blue, #// pack 2 + DropPrize.FullMagic, DropPrize.Magic, DropPrize.Magic, DropPrize.Blue, DropPrize.FullMagic, DropPrize.Magic, DropPrize.Heart, DropPrize.Magic, #// pack 3 + DropPrize.Bomb1, DropPrize.Bomb1, DropPrize.Bomb1, DropPrize.Bomb4, DropPrize.Bomb1, DropPrize.Bomb1, DropPrize.Bomb8, DropPrize.Bomb1, #// pack 4 + DropPrize.Arrow5, DropPrize.Heart, DropPrize.Arrow5, DropPrize.Arrow10, DropPrize.Arrow5, DropPrize.Heart, DropPrize.Arrow5, DropPrize.Arrow10,#// pack 5 + DropPrize.Magic, DropPrize.Green, DropPrize.Heart, DropPrize.Arrow5, DropPrize.Magic, DropPrize.Bomb1, DropPrize.Green, DropPrize.Heart, #// pack 6 + DropPrize.Heart, DropPrize.Fairy, DropPrize.FullMagic, DropPrize.Red, DropPrize.Bomb8, DropPrize.Heart, DropPrize.Red, DropPrize.Arrow10, #// pack 7 + DropPrize.Green, DropPrize.Blue, DropPrize.Red,#// from pull trees + DropPrize.Green, DropPrize.Red,#// from prize crab + DropPrize.Green, #// stunned prize + DropPrize.Red,#// saved fish prize + ] + + @staticmethod + def ShuffleDropPrizes(rnd): + nrPackDrops = 8 * 7 + nrTreePullDrops = 3 + + prizes = WorldState.BaseDropPrizes[:] + rnd.shuffle(prizes) + + (packs, prizes) = (prizes[:nrPackDrops], prizes[nrPackDrops:]) + (treePulls, prizes) = (prizes[:nrTreePullDrops], prizes[nrTreePullDrops:]) + (crabContinous, crabFinalDrop, prizes) = (prizes[0], prizes[1], prizes[2:]) + (stun, prizes) = (prizes[0], prizes[1:]) + fish = prizes[0] + return DropPrizeRecord(packs, treePulls, crabContinous, crabFinalDrop, stun, fish) + + @staticmethod + def SplitOff(source, count): + return (source[:count], source[count:]) \ No newline at end of file diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index 7c519ec068..732a8b5548 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -12,13 +12,15 @@ from worlds.smz3.TotalSMZ3.Item import ItemType import worlds.smz3.TotalSMZ3.Item as TotalSMZ3Item from worlds.smz3.TotalSMZ3.World import World as TotalSMZ3World from worlds.smz3.TotalSMZ3.Regions.Zelda.GanonsTower import GanonsTower -from worlds.smz3.TotalSMZ3.Config import Config, GameMode, GanonInvincible, Goal, KeyShuffle, MorphLocation, SMLogic, SwordLocation, Z3Logic +from worlds.smz3.TotalSMZ3.Config import Config, GameMode, Goal, KeyShuffle, MorphLocation, SMLogic, SwordLocation, Z3Logic, OpenTower, GanonVulnerable, OpenTourian from worlds.smz3.TotalSMZ3.Location import LocationType, locations_start_id, Location as TotalSMZ3Location from worlds.smz3.TotalSMZ3.Patch import Patch as TotalSMZ3Patch, getWord, getWordArray +from worlds.smz3.TotalSMZ3.WorldState import WorldState from ..AutoWorld import World, AutoLogicRegister, WebWorld from .Rom import get_base_rom_bytes, SMZ3DeltaPatch from .ips import IPS_Patch from .Options import smz3_options +from Options import Accessibility world_folder = os.path.dirname(__file__) logger = logging.getLogger("SMZ3") @@ -59,12 +61,12 @@ class SMZ3World(World): """ game: str = "SMZ3" topology_present = False - data_version = 1 - options = smz3_options + data_version = 2 + option_definitions = smz3_options item_names: Set[str] = frozenset(TotalSMZ3Item.lookup_name_to_id) location_names: Set[str] item_name_to_id = TotalSMZ3Item.lookup_name_to_id - location_name_to_id: Dict[str, int] = {key : locations_start_id + value.Id for key, value in TotalSMZ3World(Config({}), "", 0, "").locationLookup.items()} + location_name_to_id: Dict[str, int] = {key : locations_start_id + value.Id for key, value in TotalSMZ3World(Config(), "", 0, "").locationLookup.items()} web = SMZ3Web() remote_items: bool = False @@ -77,7 +79,7 @@ class SMZ3World(World): def __init__(self, world: MultiWorld, player: int): self.rom_name_available_event = threading.Event() - self.locations = {} + self.locations: Dict[str, Location] = {} self.unreachable = [] super().__init__(world, player) @@ -179,30 +181,32 @@ class SMZ3World(World): base_combined_rom = get_base_rom_bytes() def generate_early(self): - config = Config({}) - config.GameMode = GameMode.Multiworld - config.Z3Logic = Z3Logic.Normal - config.SMLogic = SMLogic(self.world.sm_logic[self.player].value) - config.SwordLocation = SwordLocation(self.world.sword_location[self.player].value) - config.MorphLocation = MorphLocation(self.world.morph_location[self.player].value) - config.Goal = Goal.DefeatBoth - config.KeyShuffle = KeyShuffle(self.world.key_shuffle[self.player].value) - config.Keysanity = config.KeyShuffle != KeyShuffle.Null - config.GanonInvincible = GanonInvincible.BeforeCrystals + self.config = Config() + self.config.GameMode = GameMode.Multiworld + self.config.Z3Logic = Z3Logic.Normal + self.config.SMLogic = SMLogic(self.world.sm_logic[self.player].value) + self.config.SwordLocation = SwordLocation(self.world.sword_location[self.player].value) + self.config.MorphLocation = MorphLocation(self.world.morph_location[self.player].value) + self.config.Goal = Goal(self.world.goal[self.player].value) + self.config.KeyShuffle = KeyShuffle(self.world.key_shuffle[self.player].value) + self.config.OpenTower = OpenTower(self.world.open_tower[self.player].value) + self.config.GanonVulnerable = GanonVulnerable(self.world.ganon_vulnerable[self.player].value) + self.config.OpenTourian = OpenTourian(self.world.open_tourian[self.player].value) self.local_random = random.Random(self.world.random.randint(0, 1000)) - self.smz3World = TotalSMZ3World(config, self.world.get_player_name(self.player), self.player, self.world.seed_name) + self.smz3World = TotalSMZ3World(self.config, self.world.get_player_name(self.player), self.player, self.world.seed_name) self.smz3DungeonItems = [] SMZ3World.location_names = frozenset(self.smz3World.locationLookup.keys()) self.world.state.smz3state[self.player] = TotalSMZ3Item.Progression([]) def generate_basic(self): - self.smz3World.Setup(self.world.random) + self.smz3World.Setup(WorldState.Generate(self.config, self.world.random)) self.dungeon = TotalSMZ3Item.Item.CreateDungeonPool(self.smz3World) self.dungeon.reverse() self.progression = TotalSMZ3Item.Item.CreateProgressionPool(self.smz3World) self.keyCardsItems = TotalSMZ3Item.Item.CreateKeycards(self.smz3World) + self.SmMapsItems = TotalSMZ3Item.Item.CreateSmMaps(self.smz3World) niceItems = TotalSMZ3Item.Item.CreateNicePool(self.smz3World) junkItems = TotalSMZ3Item.Item.CreateJunkPool(self.smz3World) @@ -210,7 +214,7 @@ class SMZ3World(World): self.junkItemsNames = [item.Type.name for item in junkItems] if (self.smz3World.Config.Keysanity): - progressionItems = self.progression + self.dungeon + self.keyCardsItems + progressionItems = self.progression + self.dungeon + self.keyCardsItems + self.SmMapsItems else: progressionItems = self.progression for item in self.keyCardsItems: @@ -351,6 +355,49 @@ class SMZ3World(World): return patch + def SnesCustomization(self, addr: int): + addr = (0x400000 if addr < 0x800000 else 0)| (addr & 0x3FFFFF) + return addr + + def apply_customization(self): + patch = {} + + # smSpinjumps + if (self.world.spin_jumps_animation[self.player].value == 1): + patch[self.SnesCustomization(0x9B93FE)] = bytearray([0x01]) + + # z3HeartBeep + values = [ 0x00, 0x80, 0x40, 0x20, 0x10] + index = self.world.heart_beep_speed[self.player].value + patch[0x400033] = bytearray([values[index if index < len(values) else 2]]) + + # z3HeartColor + values = [ + [0x24, [0x18, 0x00]], + [0x3C, [0x04, 0x17]], + [0x2C, [0xC9, 0x69]], + [0x28, [0xBC, 0x02]] + ] + index = self.world.heart_color[self.player].value + (hud, fileSelect) = values[index if index < len(values) else 0] + for i in range(0, 20, 2): + patch[self.SnesCustomization(0xDFA1E + i)] = bytearray([hud]) + patch[self.SnesCustomization(0x1BD6AA)] = bytearray(fileSelect) + + # z3QuickSwap + patch[0x40004B] = bytearray([0x01 if self.world.quick_swap[self.player].value else 0x00]) + + # smEnergyBeepOff + if (self.world.energy_beep[self.player].value == 0): + for ([addr, value]) in [ + [0x90EA9B, 0x80], + [0x90F337, 0x80], + [0x91E6D5, 0x80] + ]: + patch[self.SnesCustomization(addr)] = bytearray([value]) + + return patch + def generate_output(self, output_directory: str): try: base_combined_rom = get_base_rom_bytes() @@ -367,6 +414,7 @@ class SMZ3World(World): patches = patcher.Create(self.smz3World.Config) patches.update(self.apply_sm_custom_sprite()) patches.update(self.apply_item_names()) + patches.update(self.apply_customization()) for addr, bytes in patches.items(): offset = 0 for byte in bytes: @@ -462,7 +510,7 @@ class SMZ3World(World): item.item.Progression = False item.location.event = False self.unreachable.append(item.location) - self.JunkFillGT() + self.JunkFillGT(0.5) def get_pre_fill_items(self): if (not self.smz3World.Config.Keysanity): @@ -476,23 +524,34 @@ class SMZ3World(World): def write_spoiler(self, spoiler_handle: TextIO): self.world.spoiler.unreachables.update(self.unreachable) - def JunkFillGT(self): + def JunkFillGT(self, factor): + poolLength = len(self.world.itempool) + junkPoolIdx = [i for i in range(0, poolLength) + if self.world.itempool[i].classification in (ItemClassification.filler, ItemClassification.trap) and + self.world.itempool[i].player == self.player] + toRemove = [] for loc in self.locations.values(): + # commenting this for now since doing a partial GT pre fill would allow for non SMZ3 progression in GT + # which isnt desirable (SMZ3 logic only filters for SMZ3 items). Having progression in GT can only happen in Single Player. + # if len(toRemove) >= int(len(self.locationNamesGT) * factor * self.smz3World.TowerCrystals / 7): + # break if loc.name in self.locationNamesGT and loc.item is None: - poolLength = len(self.world.itempool) + poolLength = len(junkPoolIdx) # start looking at a random starting index and loop at start if no match found - for i in range(self.world.random.randint(0, poolLength), poolLength): - if not self.world.itempool[i].advancement: - itemFromPool = self.world.itempool.pop(i) + start = self.world.random.randint(0, poolLength) + for off in range(0, poolLength): + i = (start + off) % poolLength + candidate = self.world.itempool[junkPoolIdx[i]] + if junkPoolIdx[i] not in toRemove and loc.can_fill(self.world.state, candidate, False): + itemFromPool = candidate + toRemove.append(junkPoolIdx[i]) break - else: - for i in range(0, poolLength): - if not self.world.itempool[i].advancement: - itemFromPool = self.world.itempool.pop(i) - break self.world.push_item(loc, itemFromPool, False) loc.event = False - + toRemove.sort(reverse = True) + for i in toRemove: + self.world.itempool.pop(i) + def FillItemAtLocation(self, itemPool, itemType, location): itemToPlace = TotalSMZ3Item.Item.Get(itemPool, itemType, self.smz3World) if (itemToPlace == None): @@ -525,6 +584,8 @@ class SMZ3World(World): def InitialFillInOwnWorld(self): self.FillItemAtLocation(self.dungeon, TotalSMZ3Item.ItemType.KeySW, self.smz3World.GetLocation("Skull Woods - Pinball Room")) + if (not self.smz3World.Config.Keysanity): + self.FillItemAtLocation(self.dungeon, TotalSMZ3Item.ItemType.KeySP, self.smz3World.GetLocation("Swamp Palace - Entrance")) # /* Check Swords option and place as needed */ if self.smz3World.Config.SwordLocation == SwordLocation.Uncle: @@ -573,8 +634,10 @@ class SMZ3Location(Location): class SMZ3Item(Item): game = "SMZ3" + type: ItemType + item: Item - def __init__(self, name, classification, type, code, player: int = None, item=None): + def __init__(self, name, classification, type: ItemType, code, player: int, item: Item): + super(SMZ3Item, self).__init__(name, classification, code, player) self.type = type self.item = item - super(SMZ3Item, self).__init__(name, classification, code, player) diff --git a/worlds/smz3/data/zsm.ips b/worlds/smz3/data/zsm.ips index 6faeeaa2fb..2d6027d5e5 100644 Binary files a/worlds/smz3/data/zsm.ips and b/worlds/smz3/data/zsm.ips differ diff --git a/worlds/soe/__init__.py b/worlds/soe/__init__.py index d708d6d7d3..f86fc48e93 100644 --- a/worlds/soe/__init__.py +++ b/worlds/soe/__init__.py @@ -151,7 +151,7 @@ class SoEWorld(World): space station where the final boss must be defeated. """ game: str = "Secret of Evermore" - options = soe_options + option_definitions = soe_options topology_present = False remote_items = False data_version = 3 @@ -162,7 +162,7 @@ class SoEWorld(World): location_name_to_id, location_id_to_raw = _get_location_mapping() item_name_groups = _get_item_grouping() - trap_types = [name[12:] for name in options if name.startswith('trap_chance_')] + trap_types = [name[12:] for name in option_definitions if name.startswith('trap_chance_')] evermizer_seed: int connect_name: str @@ -339,7 +339,7 @@ class SoEWorld(World): placement_file = out_base + '.txt' patch_file = out_base + '.apsoe' flags = 'l' # spoiler log - for option_name in self.options: + for option_name in self.option_definitions: option = getattr(self.world, option_name)[self.player] if hasattr(option, 'to_flag'): flags += option.to_flag() diff --git a/worlds/spire/__init__.py b/worlds/spire/__init__.py index 594605cd34..476afad8d9 100644 --- a/worlds/spire/__init__.py +++ b/worlds/spire/__init__.py @@ -22,7 +22,12 @@ class SpireWeb(WebWorld): class SpireWorld(World): - options = spire_options + """ + A deck-building roguelike where you must craft a unique deck, encounter bizarre creatures, discover relics of + immense power, and Slay the Spire! + """ + + option_definitions = spire_options game = "Slay the Spire" topology_present = False data_version = 1 diff --git a/worlds/subnautica/Locations.py b/worlds/subnautica/Locations.py index 2ce8cc1190..3effd1eac3 100644 --- a/worlds/subnautica/Locations.py +++ b/worlds/subnautica/Locations.py @@ -555,7 +555,7 @@ location_table: Dict[int, LocationDict] = { 'position': {'x': 348.7, 'y': -1443.5, 'z': -291.9}}, 33128: {'can_slip_through': False, 'name': 'Grassy Plateaus West Wreck - Beam PDA', - 'need_laser_cutter': True, + 'need_laser_cutter': False, 'position': {'x': -641.8, 'y': -111.3, 'z': -19.7}}, 33129: {'can_slip_through': False, 'name': 'Floating Island - Cave Entrance PDA', @@ -564,7 +564,7 @@ location_table: Dict[int, LocationDict] = { 33130: {'can_slip_through': False, 'name': 'Degasi Seabase - Jellyshroom Cave - Outside PDA', 'need_laser_cutter': False, - 'position': {'x': -83.2, 'y': -276.4, 'z': -345.5}}, + 'position': {'x': 83.2, 'y': -276.4, 'z': -345.5}}, } if False: # turn to True to export for Subnautica mod payload = {location_id: location_data["position"] for location_id, location_data in location_table.items()} diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index f36149b5ad..6fa064d53a 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -39,10 +39,10 @@ class SubnauticaWorld(World): item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()} location_name_to_id = all_locations - options = Options.options + option_definitions = Options.options data_version = 5 - required_client_version = (0, 3, 3) + required_client_version = (0, 3, 4) prefill_items: List[Item] creatures_to_scan: List[str] diff --git a/worlds/subnautica/docs/en_Subnautica.md b/worlds/subnautica/docs/en_Subnautica.md index f71e14b7fe..9a112aa596 100644 --- a/worlds/subnautica/docs/en_Subnautica.md +++ b/worlds/subnautica/docs/en_Subnautica.md @@ -16,8 +16,12 @@ The goal remains unchanged. Cure the plague, build the Neptune Escape Rocket, an ## What items and locations get shuffled? -Most of the technologies the player will need throughout the game will be shuffled. Location checks in Subnautica are -data pads and technology lockers. +Most of the technologies the player will need throughout the game will be shuffled. +Location checks in Subnautica are data pads and technology lockers. + +Optionally up to 50 Creatures to scan can be included as well, for each one added a random duplicate item is created. + +As playing without Seaglide can be daunting, 2 of your Fragments of it can always be found in these locations: Grassy Plateaus South Wreck - Databox, Grassy Plateaus South Wreck - PDA, Grassy Plateaus West Wreck - Locker PDA, Grassy Plateaus West Wreck - Data Terminal, Safe Shallows Wreck - PDA, Kelp Forest Wreck - Databox, Kelp Forest Wreck - PDA, Lifepod 3 - Databox, Lifepod 3 - PDA, Lifepod 17 - PDA, Grassy Plateaus West Wreck - Beam PDA. ## Which items can be in another player's world? diff --git a/worlds/subnautica/docs/setup_en.md b/worlds/subnautica/docs/setup_en.md index 39d292938d..665cb8b336 100644 --- a/worlds/subnautica/docs/setup_en.md +++ b/worlds/subnautica/docs/setup_en.md @@ -18,20 +18,26 @@ 4. Start Subnautica. You should see a Connect Menu in the topleft of your main Menu. +## Connecting + +Using the Connect Menu in Subnautica's Main Menu you enter your connection info to connect to an Archipelago Multiworld. +Menu points: + - Host: the full url that you're trying to connect to, such as `archipelago.gg:38281`. + - PlayerName: your name in the multiworld. Can also be called Slot Name and is the name you entered when creating your settings. + - Password: optional password, leave blank if no password was set. + +After the connection is made, start a new game. You should start to see Archipelago chat messages to appear, such as a message announcing that you joined the multiworld. + +## Resuming + +When loading a savegame it will automatically attempt to resume the connection that was active when the savegame was made. +If that connection information is no longer valid, such as if the server's IP and/or port has changed, the Connect Menu will reappear after loading. Use the Connect Menu before or after loading the savegame to connect to the new instance. + +Warning: Currently it is not checked if this is the correct multiworld belonging to that savegame, please ensure that yourself beforehand. + ## Troubleshooting -If you don't see the connect window check that you see a qmodmanager_log-Subnautica.txt in Subnautica, if not +If you don't see the Connect Menu within the Main Menu, check that you see a file named `qmodmanager_log-Subnautica.txt` in the Subnautica game directory. If not, QModManager4 is not correctly installed, otherwise open it and look -for `[Info : BepInEx] Loading [Archipelago 1.0.0.0]`, version number doesn't matter. If it doesn't show this, then +for `[Info : BepInEx] Loading [Archipelago`. If it doesn't show this, then QModManager4 didn't find the Archipelago mod, so check your paths. - -## Joining a MultiWorld Game - -1. In Host, enter the address of the server, such as archipelago.gg:38281, your server host should be able to tell you - this. - -2. In Password enter the server password if one exists, otherwise leave blank. - -3. In PlayerName enter your "name" field from the yaml, or website config. - -4. Hit Connect. If it says successfully authenticated you can now create a new savegame or resume the correct savegame. \ No newline at end of file diff --git a/worlds/timespinner/__init__.py b/worlds/timespinner/__init__.py index d789e9ddef..c8b94a2763 100644 --- a/worlds/timespinner/__init__.py +++ b/worlds/timespinner/__init__.py @@ -40,7 +40,7 @@ class TimespinnerWorld(World): Travel back in time to change fate itself. Join timekeeper Lunais on her quest for revenge against the empire that killed her family. """ - options = timespinner_options + option_definitions = timespinner_options game = "Timespinner" topology_present = True remote_items = False diff --git a/worlds/v6/__init__.py b/worlds/v6/__init__.py index 04947716d3..38690e5a00 100644 --- a/worlds/v6/__init__.py +++ b/worlds/v6/__init__.py @@ -35,14 +35,13 @@ class V6World(World): location_name_to_id = location_table data_version = 1 - forced_auto_forfeit = False area_connections: typing.Dict[int, int] area_cost_map: typing.Dict[int,int] music_map: typing.Dict[int,int] - options = v6_options + option_definitions = v6_options def create_regions(self): create_regions(self.world,self.player) diff --git a/worlds/witness/WitnessLogic.txt b/worlds/witness/WitnessLogic.txt index 71ca7c819f..c98257fb73 100644 --- a/worlds/witness/WitnessLogic.txt +++ b/worlds/witness/WitnessLogic.txt @@ -691,7 +691,7 @@ Treehouse Second Purple Bridge (Treehouse) - Treehouse Left Orange Bridge - 0x17 158367 - 0x17D91 (Second Purple Bridge 6) - 0x17BDF - Stars & Squares & Colored Squares 158368 - 0x17DC6 (Second Purple Bridge 7) - 0x17D91 - Stars & Squares & Colored Squares -Treehouse Left Orange Bridge (Treehouse) - Treehouse Laser Room - 0x0C323: +Treehouse Left Orange Bridge (Treehouse) - Treehouse Laser Room Front Platform - 0x17DDB - Treehouse Laser Room Back Platform - 0x17DDB: 158376 - 0x17DB3 (Left Orange Bridge 1) - True - Stars & Squares & Black/White Squares & Stars + Same Colored Symbol 158377 - 0x17DB5 (Left Orange Bridge 2) - 0x17DB3 - Stars & Squares & Black/White Squares & Stars + Same Colored Symbol 158378 - 0x17DB6 (Left Orange Bridge 3) - 0x17DB5 - Stars & Squares & Black/White Squares & Stars + Same Colored Symbol @@ -707,8 +707,6 @@ Treehouse Left Orange Bridge (Treehouse) - Treehouse Laser Room - 0x0C323: 158388 - 0x17DAE (Left Orange Bridge 13) - 0x17DEC - Stars & Squares & Black/White Squares & Stars + Same Colored Symbol 158389 - 0x17DB0 (Left Orange Bridge 14) - 0x17DAE - Stars & Squares & Black/White Squares & Stars + Same Colored Symbol 158390 - 0x17DDB (Left Orange Bridge 15) - 0x17DB0 - Stars & Squares & Black/White Squares & Stars + Same Colored Symbol -158611 - 0x17FA0 (Burnt House Discard) - 0x17DDB - Triangles -Door - 0x0C323 (Door to Laser House) - 0x17DDB & 0x17DA2 & 0x2700B Treehouse Green Bridge (Treehouse): 158369 - 0x17E3C (Green Bridge 1) - True - Stars & Shapers @@ -720,6 +718,12 @@ Treehouse Green Bridge (Treehouse): 158375 - 0x17E61 (Green Bridge 7) - 0x17E5F - Stars & Shapers & Rotated Shapers 158610 - 0x17FA9 (Green Bridge Discard) - 0x17E61 - Triangles +Treehouse Laser Room Front Platform (Treehouse) - Treehouse Laser Room - 0x0C323: +Door - 0x0C323 (Door to Laser House) - 0x17DA2 & 0x2700B & 0x17DDB + +Treehouse Laser Room Back Platform (Treehouse): +158611 - 0x17FA0 (Burnt House Discard) - True - Triangles + Treehouse Laser Room (Treehouse): 158712 - 0x03613 (Laser Panel) - True - True 158403 - 0x17CBC (Laser House Door Timer Inside Control) - True - True diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index da6683b51c..19c9b97240 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -41,7 +41,7 @@ class WitnessWorld(World): static_locat = StaticWitnessLocations() static_items = StaticWitnessItems() web = WitnessWebWorld() - options = the_witness_options + option_definitions = the_witness_options item_name_to_id = { name: data.code for name, data in static_items.ALL_ITEM_TABLE.items() diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index 3639e836df..efbb177f00 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -355,6 +355,7 @@ class WitnessPlayerLogic: "0x19B24": "Shadows Lower Avoid Patterns Visible", "0x2700B": "Open Door to Treehouse Laser House", "0x00055": "Orchard Apple Trees 4 Turns On", + "0x17DDB": "Left Orange Bridge Fully Extended", } self.ALWAYS_EVENT_NAMES_BY_HEX = {