318 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			318 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
 | 
						|
 | 
						|
    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")
 |