Item Plando Support

This commit is contained in:
Fabian Dill 2021-01-02 12:49:43 +01:00
parent 3d53adf45c
commit f3b6be2b20
9 changed files with 171 additions and 74 deletions

View File

@ -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

View File

@ -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:

10
Fill.py
View File

@ -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
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 world.mode[item.player] == 'standard' and
world.keyshuffle[item.player] else 0)
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)

48
Main.py
View File

@ -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,10 +173,49 @@ 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()) +
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:
@ -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)

View File

@ -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"

View File

@ -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']

112
Utils.py
View File

@ -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",
@ -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

View File

@ -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:

View File

@ -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"