diff --git a/BaseClasses.py b/BaseClasses.py index dfb7cded..60d18ac6 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -5,7 +5,7 @@ from enum import Enum, unique import logging import json from collections import OrderedDict, Counter, deque -from typing import Union, Optional, List, Set, Dict +from typing import Union, Optional, List, Set, Dict, NamedTuple import secrets import random @@ -16,7 +16,7 @@ from Items import item_name_groups class World(object): debug_types = False - player_names: list + player_names: Dict[int, List[str]] _region_cache: dict difficulty_requirements: dict required_medallions: dict @@ -135,6 +135,7 @@ class World(object): set_player_attr('sprite_pool', []) set_player_attr('dark_room_logic', "lamp") set_player_attr('restrict_dungeon_item_on_boss', False) + set_player_attr('plando_items', []) def secure(self): self.random = secrets.SystemRandom() @@ -1037,6 +1038,9 @@ class Item(object): self.world = None self.player = player + def __eq__(self, other): + return self.name == other.name and self.player == other.player + @property def crystal(self) -> bool: return self.type == 'Crystal' @@ -1452,3 +1456,10 @@ class Spoiler(object): path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines))) outfile.write('\n'.join(path_listings)) + + +class PlandoItem(NamedTuple): + item: str + location: str + world: Union[bool, str] = False # False -> own world, True -> not own world + from_pool: bool = True # if item should be removed from item pool diff --git a/EntranceRandomizer.py b/EntranceRandomizer.py index 5c9ab56c..6c94351e 100755 --- a/EntranceRandomizer.py +++ b/EntranceRandomizer.py @@ -355,6 +355,10 @@ def parse_arguments(argv, no_defaults=False): parser.add_argument(f'--p{player}', default=defval(''), help=argparse.SUPPRESS) ret = parser.parse_args(argv) + + # cannot be set through CLI currently + ret.plando_items = {} + ret.glitch_boots = not ret.disable_glitch_boots if ret.timer == "none": ret.timer = False @@ -382,9 +386,10 @@ def parse_arguments(argv, no_defaults=False): 'shufflebosses', 'enemy_shuffle', 'enemy_health', 'enemy_damage', 'shufflepots', 'ow_palettes', 'uw_palettes', 'sprite', 'disablemusic', 'quickswap', 'fastmenu', 'heartcolor', 'heartbeep', "skip_progression_balancing", "triforce_pieces_available", - "triforce_pieces_required", "shop_shuffle", + "triforce_pieces_required", "shop_shuffle", "plando_items", 'remote_items', 'progressive', 'dungeon_counters', 'glitch_boots', 'killable_thieves', - 'tile_shuffle', 'bush_shuffle', 'shuffle_prizes', 'sprite_pool', 'dark_room_logic', 'restrict_dungeon_item_on_boss', + 'tile_shuffle', 'bush_shuffle', 'shuffle_prizes', 'sprite_pool', 'dark_room_logic', + 'restrict_dungeon_item_on_boss', 'hud_palettes', 'sword_palettes', 'shield_palettes', 'link_palettes']: value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name) if player == 1: diff --git a/Fill.py b/Fill.py index 1212f6e0..a4ef61d9 100644 --- a/Fill.py +++ b/Fill.py @@ -54,7 +54,8 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si for location in region.locations: if location.item and not location.event: placements.append(location) - + # fill in name of world for item + item_to_place.world = world raise FillError(f'No more spots to place {item_to_place}, locations {locations} are invalid. ' f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}') @@ -128,9 +129,12 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None world.random.shuffle(fill_locations) # Make sure the escape small key is placed first in standard with key shuffle to prevent running out of spots - progitempool.sort( - key=lambda item: 1 if item.name == 'Small Key (Hyrule Castle)' and world.mode[item.player] == 'standard' and - world.keyshuffle[item.player] else 0) + standard_keyshuffle_players = {player for player, mode in world.mode.items() if mode == 'standard' and + world.keyshuffle[player] is True} + if standard_keyshuffle_players: + progitempool.sort( + key=lambda item: 1 if item.name == 'Small Key (Hyrule Castle)' and + item.player in standard_keyshuffle_players else 0) fill_restrictive(world, world.state, fill_locations, progitempool) diff --git a/Main.py b/Main.py index 39efede3..28218982 100644 --- a/Main.py +++ b/Main.py @@ -9,7 +9,7 @@ import time import zlib import concurrent.futures -from BaseClasses import World, CollectionState, Item, Region, Location, Shop +from BaseClasses import World, CollectionState, Item, Region, Location, PlandoItem from Items import ItemFactory, item_table, item_name_groups from Regions import create_regions, create_shops, mark_light_world_regions, lookup_vanilla_location_to_entrance from InvertedRegions import create_inverted_regions, mark_dark_world_regions @@ -87,6 +87,7 @@ def main(args, seed=None): world.shuffle_prizes = args.shuffle_prizes.copy() world.sprite_pool = args.sprite_pool.copy() world.dark_room_logic = args.dark_room_logic.copy() + world.plando_items = args.plando_items.copy() world.restrict_dungeon_item_on_boss = args.restrict_dungeon_item_on_boss.copy() world.rom_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in range(1, world.players + 1)} @@ -172,11 +173,50 @@ def main(args, seed=None): fill_prizes(world) + logger.info("Running Item Plando") + + world_name_lookup = {world.player_names[player_id][0]: player_id for player_id in world.player_ids} + + for player in world.player_ids: + placement: PlandoItem + for placement in world.plando_items[player]: + target_world: int = placement.world + if target_world is False or world.players == 1: + target_world = player # in own world + elif target_world is True: # in any other world + target_world = player + while target_world == player: + target_world = world.random.randint(1, world.players + 1) + elif target_world is None: # any random world + target_world = world.random.randint(1, world.players + 1) + elif type(target_world) == int: # target world by player id + pass + else: # find world by name + target_world = world_name_lookup[target_world] + + location = world.get_location(placement.location, target_world) + if location.item: + raise Exception(f"Cannot place item into already filled location {location}.") + item = ItemFactory(placement.item, player) + if placement.from_pool: + try: + world.itempool.remove(item) + except ValueError: + logger.warning(f"Could not remove {item} from pool as it's already missing from it.") + + if location.can_fill(world.state, item, False): + world.push_item(location, item, collect=False) + location.event = True # flag location to be checked during fill + location.locked = True + logger.debug(f"Plando placed {item} at {location}") + else: + raise Exception(f"Can't place {item} at {location} due to fill condition not met.") + logger.info('Placing Dungeon Items.') - shuffled_locations = None - if args.algorithm in ['balanced', 'vt26'] or any(list(args.mapshuffle.values()) + list(args.compassshuffle.values()) + - list(args.keyshuffle.values()) + list(args.bigkeyshuffle.values())): + if args.algorithm in ['balanced', 'vt26'] or any( + list(args.mapshuffle.values()) + list(args.compassshuffle.values()) + + list(args.keyshuffle.values()) + list(args.bigkeyshuffle.values())): fill_dungeons_restrictive(world) else: fill_dungeons(world) @@ -188,7 +228,7 @@ def main(args, seed=None): elif args.algorithm == 'vt25': distribute_items_restrictive(world, False) elif args.algorithm == 'vt26': - distribute_items_restrictive(world, True, shuffled_locations) + distribute_items_restrictive(world, True) elif args.algorithm == 'balanced': distribute_items_restrictive(world, True) diff --git a/MultiMystery.py b/MultiMystery.py index bc84dd57..237e432b 100644 --- a/MultiMystery.py +++ b/MultiMystery.py @@ -50,6 +50,7 @@ if __name__ == "__main__": player_files_path = multi_mystery_options["player_files_path"] target_player_count = multi_mystery_options["players"] race = multi_mystery_options["race"] + plando_options = multi_mystery_options["plando_options"] create_spoiler = multi_mystery_options["create_spoiler"] zip_roms = multi_mystery_options["zip_roms"] zip_diffs = multi_mystery_options["zip_diffs"] @@ -104,7 +105,7 @@ if __name__ == "__main__": command = f"{basemysterycommand} --multi {target_player_count} {player_string} " \ f"--rom \"{rom_file}\" --enemizercli \"{enemizer_path}\" " \ - f"--outputpath \"{output_path}\" --teams {teams}" + f"--outputpath \"{output_path}\" --teams {teams} --plando \"{plando_options}\"" if create_spoiler: command += " --create_spoiler" diff --git a/Mystery.py b/Mystery.py index e44a3e48..3e7ab91c 100644 --- a/Mystery.py +++ b/Mystery.py @@ -7,6 +7,7 @@ import typing import os import ModuleUpdate +from BaseClasses import PlandoItem ModuleUpdate.update() @@ -44,10 +45,13 @@ def mystery_argparse(): parser.add_argument('--create_diff', action="store_true") parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255), help='Output rolled mystery results to yaml up to specified number (made for async multiworld)') + parser.add_argument('--plando', default="bosses", + help='List of options that can be set manually. Can be combined, for example "bosses, items"') for player in range(1, multiargs.multi + 1): parser.add_argument(f'--p{player}', help=argparse.SUPPRESS) args = parser.parse_args() + args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")} return args @@ -144,7 +148,8 @@ def main(args=None, callback=ERmain): if args.enemizercli: erargs.enemizercli = args.enemizercli - settings_cache = {k: (roll_settings(v) if args.samesettings else None) for k, v in weights_cache.items()} + settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None) + for k, v in weights_cache.items()} player_path_cache = {} for player in range(1, args.multi + 1): player_path_cache[player] = getattr(args, f'p{player}') if getattr(args, f'p{player}') else args.weights @@ -167,7 +172,8 @@ def main(args=None, callback=ERmain): path = player_path_cache[player] if path: try: - settings = settings_cache[path] if settings_cache[path] else roll_settings(weights_cache[path]) + settings = settings_cache[path] if settings_cache[path] else \ + roll_settings(weights_cache[path], args.plando) if settings.sprite and not os.path.isfile(settings.sprite) and not Sprite.get_sprite_from_name( settings.sprite): logging.warning( @@ -275,7 +281,13 @@ boss_shuffle_options = {None: 'none', } -def roll_settings(weights): +def roll_percentage(percentage: typing.Union[int, float]) -> bool: + """Roll a percentage chance. + percentage is expected to be in range [0, 100]""" + return random.random() < (float(percentage) / 100) + + +def roll_settings(weights, plando_options: typing.Set[str] = frozenset(("bosses"))): ret = argparse.Namespace() if "linked_options" in weights: weights = weights.copy() # make sure we don't write back to other weights sets in same_settings @@ -283,7 +295,7 @@ def roll_settings(weights): if "name" not in option_set: raise ValueError("One of your linked options does not have a name.") try: - if random.random() < (float(option_set["percentage"]) / 100): + if roll_percentage(option_set["percentage"]): logging.debug(f"Linked option {option_set['name']} triggered.") logging.debug(f'Applying {option_set["options"]}') new_options = set(option_set["options"]) - set(weights) @@ -415,7 +427,7 @@ def roll_settings(weights): if boss_shuffle in boss_shuffle_options: ret.shufflebosses = boss_shuffle_options[boss_shuffle] - else: + elif "bosses" in plando_options: options = boss_shuffle.lower().split(";") remainder_shuffle = "none" # vanilla bosses = [] @@ -427,6 +439,8 @@ def roll_settings(weights): else: bosses.append(boss) ret.shufflebosses = ";".join(bosses + [remainder_shuffle]) + else: + raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.") ret.enemy_shuffle = {'none': False, 'shuffled': 'shuffled', @@ -534,6 +548,17 @@ def roll_settings(weights): ret.non_local_items = ",".join(ret.non_local_items) + ret.plando_items = [] + if "items" in plando_options: + options = weights.get("plando_items", []) + for placement in options: + if roll_percentage(get_choice("percentage", placement, 100)): + item = get_choice("item", placement) + location = get_choice("location", placement) + from_pool = get_choice("from_pool", placement, True) + location_world = get_choice("world", placement, False) + ret.plando_items.append(PlandoItem(item, location, location_world, from_pool)) + if 'rom' in weights: romweights = weights['rom'] @@ -571,7 +596,7 @@ def roll_settings(weights): ret.sword_palettes = get_choice('sword_palettes', romweights, "default") ret.shield_palettes = get_choice('shield_palettes', romweights, "default") ret.link_palettes = get_choice('link_palettes', romweights, "default") - + else: ret.quickswap = True ret.sprite = "Link" diff --git a/Utils.py b/Utils.py index 7d17da6f..1967b9d7 100644 --- a/Utils.py +++ b/Utils.py @@ -1,10 +1,12 @@ from __future__ import annotations + import typing def tuplize_version(version: str) -> typing.Tuple[int, ...]: return Version(*(int(piece, 10) for piece in version.split("."))) + class Version(typing.NamedTuple): major: int minor: int @@ -42,11 +44,11 @@ def int32_as_bytes(value): def pc_to_snes(value): - return ((value<<1) & 0x7F0000)|(value & 0x7FFF)|0x8000 + return ((value << 1) & 0x7F0000) | (value & 0x7FFF) | 0x8000 def snes_to_pc(value): - return ((value & 0x7F0000)>>1)|(value & 0x7FFF) + return ((value & 0x7F0000) >> 1) | (value & 0x7FFF) def parse_player_names(names, players, teams): @@ -87,6 +89,7 @@ def local_path(*path): return os.path.join(local_path.cached_path, *path) + local_path.cached_path = None @@ -98,8 +101,10 @@ def output_path(*path): os.makedirs(os.path.dirname(path), exist_ok=True) return path + output_path.cached_path = None + def open_file(filename): if sys.platform == 'win32': os.startfile(filename) @@ -107,9 +112,10 @@ def open_file(filename): open_command = 'open' if sys.platform == 'darwin' else 'xdg-open' subprocess.call([open_command, filename]) + def close_console(): if sys.platform == 'win32': - #windows + # windows import ctypes.wintypes try: ctypes.windll.kernel32.FreeConsole() @@ -143,6 +149,7 @@ class Hint(typing.NamedTuple): def __hash__(self): return hash((self.receiving_player, self.finding_player, self.location, self.item, self.entrance)) + def get_public_ipv4() -> str: import socket import urllib.request @@ -158,6 +165,7 @@ def get_public_ipv4() -> str: pass # we could be offline, in a local game, so no point in erroring out return ip + def get_public_ipv6() -> str: import socket import urllib.request @@ -173,56 +181,56 @@ def get_public_ipv6() -> str: def get_default_options() -> dict: if not hasattr(get_default_options, "options"): - options = dict() - # Refer to host.yaml for comments as to what all these options mean. - generaloptions = dict() - generaloptions["rom_file"] = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" - generaloptions["qusb2snes"] = "QUsb2Snes\\QUsb2Snes.exe" - generaloptions["rom_start"] = True - generaloptions["output_path"] = "output" - options["general_options"] = generaloptions + options = { + "general_options": { + "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", + "qusb2snes": "QUsb2Snes\\QUsb2Snes.exe", + "rom_start": True, + "output_path": "output", + }, + "server_options": { + "host": None, + "port": 38281, + "password": None, + "multidata": None, + "savefile": None, + "disable_save": False, + "loglevel": "info", + "server_password": None, + "disable_item_cheat": False, + "location_check_points": 1, + "hint_cost": 1000, + "forfeit_mode": "goal", + "remaining_mode": "goal", + "auto_shutdown": 0, + "compatibility": 2, + }, + "multi_mystery_options": { + "teams": 1, + "enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe", + "player_files_path": "Players", + "players": 0, + "weights_file_path": "weights.yaml", + "meta_file_path": "meta.yaml", + "player_name": "", + "create_spoiler": 1, + "zip_roms": 0, + "zip_diffs": 2, + "zip_spoiler": 0, + "zip_multidata": 1, + "zip_format": 1, + "race": 0, + "cpu_threads": 0, + "max_attempts": 0, + "take_first_working": False, + "keep_all_seeds": False, + "log_output_path": "Output Logs", + "log_level": None, + "plando_options": "bosses", + } + } - serveroptions = dict() - serveroptions["host"] = None - serveroptions["port"] = 38281 - serveroptions["password"] = None - serveroptions["multidata"] = None - serveroptions["savefile"] = None - serveroptions["disable_save"] = False - serveroptions["loglevel"] = "info" - serveroptions["server_password"] = None - serveroptions["disable_item_cheat"] = False - serveroptions["location_check_points"] = 1 - serveroptions["hint_cost"] = 1000 - serveroptions["forfeit_mode"] = "goal" - serveroptions["remaining_mode"] = "goal" - serveroptions["auto_shutdown"] = 0 - serveroptions["compatibility"] = 2 - options["server_options"] = serveroptions - - multimysteryoptions = dict() - multimysteryoptions["teams"] = 1 - multimysteryoptions["enemizer_path"] = "EnemizerCLI/EnemizerCLI.Core.exe" - multimysteryoptions["player_files_path"] = "Players" - multimysteryoptions["players"] = 0 - multimysteryoptions["weights_file_path"] = "weights.yaml" - multimysteryoptions["meta_file_path"] = "meta.yaml" - multimysteryoptions["player_name"] = "" - multimysteryoptions["create_spoiler"] = 1 - multimysteryoptions["zip_roms"] = 0 - multimysteryoptions["zip_diffs"] = 2 - multimysteryoptions["zip_spoiler"] = 0 - multimysteryoptions["zip_multidata"] = 1 - multimysteryoptions["zip_format"] = 1 - multimysteryoptions["race"] = 0 - multimysteryoptions["cpu_threads"] = 0 - multimysteryoptions["max_attempts"] = 0 - multimysteryoptions["take_first_working"] = False - multimysteryoptions["keep_all_seeds"] = False - multimysteryoptions["log_output_path"] = "Output Logs" - multimysteryoptions["log_level"] = None - options["multi_mystery_options"] = multimysteryoptions get_default_options.options = options return get_default_options.options @@ -254,6 +262,7 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict: dest[key] = update_options(value, dest[key], filename, new_keys) return dest + def get_options() -> dict: if not hasattr(get_options, "options"): locations = ("options.yaml", "host.yaml", @@ -326,7 +335,7 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]: if hasattr(get_adjuster_settings, "adjust_wanted"): adjust_wanted = getattr(get_adjuster_settings, "adjust_wanted") - elif persistent_load().get("adjuster", {}).get("never_adjust", False): # never adjust, per user request + elif persistent_load().get("adjuster", {}).get("never_adjust", False): # never adjust, per user request return romfile, False else: adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n" @@ -350,7 +359,6 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]: return romfile, False - class ReceivedItem(typing.NamedTuple): item: int location: int diff --git a/WebHostLib/check.py b/WebHostLib/check.py index a6aaa85c..86d79b02 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -73,7 +73,7 @@ def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[ results[filename] = f"Failed to parse YAML data in {filename}: {e}" else: try: - rolled_results[filename] = roll_settings(yaml_data) + rolled_results[filename] = roll_settings(yaml_data, plando_options={"bosses"}) except Exception as e: results[filename] = f"Failed to generate mystery in {filename}: {e}" else: diff --git a/host.yaml b/host.yaml index 13550f14..dc59c4ef 100644 --- a/host.yaml +++ b/host.yaml @@ -99,3 +99,6 @@ multi_mystery_options: zip_format: 1 # Create encrypted race roms race: 0 + # List of options that can be plando'd. Can be combined, for example "bosses, items" + # Available options: bosses + plando_options: "bosses"