mirror of
				https://github.com/MarioSpore/Grinch-AP.git
				synced 2025-10-21 20:21:32 -06:00 
			
		
		
		
	
		
			
				
	
	
		
			319 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			319 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| 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
 | |
|     data_version = 1
 | |
| 
 | |
|     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.multiworld.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")
 | 
