import os import typing import settings import base64 import logging from BaseClasses import Item, Region, Tutorial, ItemClassification from .items import CV64Item, filler_item_names, get_item_info, get_item_names_to_ids, get_item_counts from .locations import CV64Location, get_location_info, verify_locations, get_location_names_to_ids, base_id from .entrances import verify_entrances, get_warp_entrances from .options import CV64Options, cv64_option_groups, CharacterStages, DraculasCondition, SubWeaponShuffle from .stages import get_locations_from_stage, get_normal_stage_exits, vanilla_stage_order, \ shuffle_stages, generate_warps, get_region_names from .regions import get_region_info from .rules import CV64Rules from .data import iname, rname, ename from worlds.AutoWorld import WebWorld, World from .aesthetics import randomize_lighting, shuffle_sub_weapons, rom_empty_breakables_flags, rom_sub_weapon_flags, \ randomize_music, get_start_inventory_data, get_location_data, randomize_shop_prices, get_loading_zone_bytes, \ get_countdown_numbers from .rom import RomData, write_patch, get_base_rom_path, CV64ProcedurePatch, CV64_US_10_HASH from .client import Castlevania64Client class CV64Settings(settings.Group): class RomFile(settings.UserFilePath): """File name of the CV64 US 1.0 rom""" copy_to = "Castlevania (USA).z64" description = "CV64 (US 1.0) ROM File" md5s = [CV64_US_10_HASH] rom_file: RomFile = RomFile(RomFile.copy_to) class CV64Web(WebWorld): theme = "stone" tutorials = [Tutorial( "Multiworld Setup Guide", "A guide to setting up the Archipleago Castlevania 64 randomizer on your computer and connecting it to a " "multiworld.", "English", "setup_en.md", "setup/en", ["Liquid Cat"] )] option_groups = cv64_option_groups class CV64World(World): """ Castlevania for the Nintendo 64 is the first 3D game in the Castlevania franchise. As either whip-wielding Belmont descendant Reinhardt Schneider or powerful sorceress Carrie Fernandez, brave many terrifying traps and foes as you make your way to Dracula's chamber and stop his rule of terror! """ game = "Castlevania 64" item_name_groups = { "Bomb": {iname.magical_nitro, iname.mandragora}, "Ingredient": {iname.magical_nitro, iname.mandragora}, } location_name_groups = {stage: set(get_locations_from_stage(stage)) for stage in vanilla_stage_order} options_dataclass = CV64Options options: CV64Options settings: typing.ClassVar[CV64Settings] topology_present = True item_name_to_id = get_item_names_to_ids() location_name_to_id = get_location_names_to_ids() active_stage_exits: typing.Dict[str, typing.Dict] active_stage_list: typing.List[str] active_warp_list: typing.List[str] # Default values to possibly be updated in generate_early reinhardt_stages: bool = True carrie_stages: bool = True branching_stages: bool = False starting_stage: str = rname.forest_of_silence total_s1s: int = 7 s1s_per_warp: int = 1 total_s2s: int = 0 required_s2s: int = 0 drac_condition: int = 0 auth: bytearray web = CV64Web() def generate_early(self) -> None: # Generate the player's unique authentication self.auth = bytearray(self.random.getrandbits(8) for _ in range(16)) self.total_s1s = self.options.total_special1s.value self.s1s_per_warp = self.options.special1s_per_warp.value self.drac_condition = self.options.draculas_condition.value # If there are more S1s needed to unlock the whole warp menu than there are S1s in total, drop S1s per warp to # something manageable. if self.s1s_per_warp * 7 > self.total_s1s: self.s1s_per_warp = self.total_s1s // 7 logging.warning(f"[{self.multiworld.player_name[self.player]}] Too many required Special1s " f"({self.options.special1s_per_warp.value * 7}) for Special1s Per Warp setting: " f"{self.options.special1s_per_warp.value} with Total Special1s setting: " f"{self.options.total_special1s.value}. Lowering Special1s Per Warp to: " f"{self.s1s_per_warp}") self.options.special1s_per_warp.value = self.s1s_per_warp # Set the total and required Special2s to 1 if the drac condition is the Crystal, to the specified YAML numbers # if it's Specials, or to 0 if it's None or Bosses. The boss totals will be figured out later. if self.drac_condition == DraculasCondition.option_crystal: self.total_s2s = 1 self.required_s2s = 1 elif self.drac_condition == DraculasCondition.option_specials: self.total_s2s = self.options.total_special2s.value self.required_s2s = int(self.options.percent_special2s_required.value / 100 * self.total_s2s) # Enable/disable character stages and branching paths accordingly if self.options.character_stages == CharacterStages.option_reinhardt_only: self.carrie_stages = False elif self.options.character_stages == CharacterStages.option_carrie_only: self.reinhardt_stages = False elif self.options.character_stages == CharacterStages.option_both: self.branching_stages = True self.active_stage_exits = get_normal_stage_exits(self) stage_1_blacklist = [] # Prevent Clock Tower from being Stage 1 if more than 4 S1s are needed to warp out of it. if self.s1s_per_warp > 4 and not self.options.multi_hit_breakables: stage_1_blacklist.append(rname.clock_tower) # Shuffle the stages if the option is on. if self.options.stage_shuffle: self.active_stage_exits, self.starting_stage, self.active_stage_list = \ shuffle_stages(self, stage_1_blacklist) else: self.active_stage_list = [stage for stage in vanilla_stage_order if stage in self.active_stage_exits] # Create a list of warps from the active stage list. They are in a random order by default and will never # include the starting stage. self.active_warp_list = generate_warps(self) def create_regions(self) -> None: # Add the Menu Region. created_regions = [Region("Menu", self.player, self.multiworld)] # Add every stage Region by checking to see if that stage is active. created_regions.extend([Region(name, self.player, self.multiworld) for name in get_region_names(self.active_stage_exits)]) # Add the Renon's shop Region if shopsanity is on. if self.options.shopsanity: created_regions.append(Region(rname.renon, self.player, self.multiworld)) # Add the Dracula's chamber (the end) Region. created_regions.append(Region(rname.ck_drac_chamber, self.player, self.multiworld)) # Set up the Regions correctly. self.multiworld.regions.extend(created_regions) # Add the warp Entrances to the Menu Region (the one always at the start of the Region list). created_regions[0].add_exits(get_warp_entrances(self.active_warp_list)) for reg in created_regions: # Add the Entrances to all the Regions. ent_names = get_region_info(reg.name, "entrances") if ent_names is not None: reg.add_exits(verify_entrances(self.options, ent_names, self.active_stage_exits)) # Add the Locations to all the Regions. loc_names = get_region_info(reg.name, "locations") if loc_names is None: continue verified_locs, events = verify_locations(self.options, loc_names) reg.add_locations(verified_locs, CV64Location) # Place event Items on all of their associated Locations. for event_loc, event_item in events.items(): self.get_location(event_loc).place_locked_item(self.create_item(event_item, "progression")) # If we're looking at a boss kill trophy, increment the total S2s and, if we're not already at the # set number of required bosses, the total required number. This way, we can prevent gen failures # should the player set more bosses required than there are total. if event_item == iname.trophy: self.total_s2s += 1 if self.required_s2s < self.options.bosses_required.value: self.required_s2s += 1 # If Dracula's Condition is Bosses and there are less calculated required S2s than the value specified by the # player (meaning there weren't enough bosses to reach the player's setting), throw a warning and lower the # option value. if self.options.draculas_condition == DraculasCondition.option_bosses and self.required_s2s < \ self.options.bosses_required.value: logging.warning(f"[{self.multiworld.player_name[self.player]}] Not enough bosses for Bosses Required " f"setting: {self.options.bosses_required.value}. Lowering to: {self.required_s2s}") self.options.bosses_required.value = self.required_s2s def create_item(self, name: str, force_classification: typing.Optional[str] = None) -> Item: if force_classification is not None: classification = getattr(ItemClassification, force_classification) else: classification = getattr(ItemClassification, get_item_info(name, "default classification")) code = get_item_info(name, "code") if code is not None: code += base_id created_item = CV64Item(name, classification, code, self.player) return created_item def create_items(self) -> None: item_counts = get_item_counts(self) # Set up the items correctly self.multiworld.itempool += [self.create_item(item, classification) for classification in item_counts for item in item_counts[classification] for _ in range(item_counts[classification][item])] def set_rules(self) -> None: # Set all the Entrance rules properly. CV64Rules(self).set_cv64_rules() def pre_fill(self) -> None: # If we need more Special1s to warp out of Sphere 1 than there are locations available, then AP's fill # algorithm may try placing the Special1s anyway despite placing the stage's single key always being an option. # To get around this problem in the fill algorithm, the keys will be forced early in these situations to ensure # the algorithm will pick them over the Special1s. if self.starting_stage == rname.tower_of_science: if self.s1s_per_warp > 3: self.multiworld.local_early_items[self.player][iname.science_key2] = 1 elif self.starting_stage == rname.clock_tower: if (self.s1s_per_warp > 2 and not self.options.multi_hit_breakables) or \ (self.s1s_per_warp > 8 and self.options.multi_hit_breakables): self.multiworld.local_early_items[self.player][iname.clocktower_key1] = 1 elif self.starting_stage == rname.castle_wall: if self.s1s_per_warp > 5 and not self.options.hard_logic and \ not self.options.multi_hit_breakables: self.multiworld.local_early_items[self.player][iname.left_tower_key] = 1 def generate_output(self, output_directory: str) -> None: active_locations = self.multiworld.get_locations(self.player) # Location data and shop names, descriptions, and colors offset_data, shop_name_list, shop_colors_list, shop_desc_list = \ get_location_data(self, active_locations) # Shop prices if self.options.shop_prices: offset_data.update(randomize_shop_prices(self)) # Map lighting if self.options.map_lighting: offset_data.update(randomize_lighting(self)) # Sub-weapons if self.options.sub_weapon_shuffle == SubWeaponShuffle.option_own_pool: offset_data.update(shuffle_sub_weapons(self)) elif self.options.sub_weapon_shuffle == SubWeaponShuffle.option_anywhere: offset_data.update(rom_sub_weapon_flags) # Empty breakables if self.options.empty_breakables: offset_data.update(rom_empty_breakables_flags) # Music if self.options.background_music: offset_data.update(randomize_music(self)) # Loading zones offset_data.update(get_loading_zone_bytes(self.options, self.starting_stage, self.active_stage_exits)) # Countdown if self.options.countdown: offset_data.update(get_countdown_numbers(self.options, active_locations)) # Start Inventory offset_data.update(get_start_inventory_data(self.player, self.options, self.multiworld.precollected_items[self.player])) patch = CV64ProcedurePatch(player=self.player, player_name=self.multiworld.player_name[self.player]) write_patch(self, patch, offset_data, shop_name_list, shop_desc_list, shop_colors_list, active_locations) rom_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}" f"{patch.patch_file_ending}") patch.write(rom_path) def get_filler_item_name(self) -> str: return self.random.choice(filler_item_names) def extend_hint_information(self, hint_data: typing.Dict[int, typing.Dict[int, str]]): # Attach each location's stage's position to its hint information if Stage Shuffle is on. if not self.options.stage_shuffle: return stage_pos_data = {} for loc in list(self.multiworld.get_locations(self.player)): stage = get_region_info(loc.parent_region.name, "stage") if stage is not None and loc.address is not None: num = str(self.active_stage_exits[stage]["position"]).zfill(2) path = self.active_stage_exits[stage]["path"] stage_pos_data[loc.address] = f"Stage {num}" if path != " ": stage_pos_data[loc.address] += path hint_data[self.player] = stage_pos_data def modify_multidata(self, multidata: typing.Dict[str, typing.Any]): # Put the player's unique authentication in connect_names. multidata["connect_names"][base64.b64encode(self.auth).decode("ascii")] = \ multidata["connect_names"][self.multiworld.player_name[self.player]] def write_spoiler(self, spoiler_handle: typing.TextIO) -> None: # Write the stage order to the spoiler log spoiler_handle.write(f"\nCastlevania 64 stage & warp orders for {self.multiworld.player_name[self.player]}:\n") for stage in self.active_stage_list: num = str(self.active_stage_exits[stage]["position"]).zfill(2) path = self.active_stage_exits[stage]["path"] spoiler_handle.writelines(f"Stage {num}{path}:\t{stage}\n") # Write the warp order to the spoiler log spoiler_handle.writelines(f"\nStart :\t{self.active_stage_list[0]}\n") for i in range(1, len(self.active_warp_list)): spoiler_handle.writelines(f"Warp {i}:\t{self.active_warp_list[i]}\n")