Merge master into website-redesign

This commit is contained in:
Chris Wilson 2020-11-30 22:44:52 -05:00
commit ddfcef09ec
325 changed files with 615 additions and 431 deletions

2
.gitignore vendored
View File

@ -30,3 +30,5 @@ weights/
_persistent_storage.yaml
mystery_result_*.yaml
/db.db3
*-errors.txt
success.txt

View File

@ -65,7 +65,10 @@ def main():
logging.basicConfig(format='%(message)s', level=loglevel)
args, path = adjust(args=args)
from Utils import persistent_store
persistent_store("adjuster", "last_settings", args)
from Rom import Sprite
if isinstance(args.sprite, Sprite):
args.sprite = args.sprite.name
persistent_store("adjuster", "last_settings_3", args)
if __name__ == '__main__':
main()

View File

@ -27,7 +27,7 @@ def adjust(args):
palettes_options['hud']=args.hud_palettes
palettes_options['sword']=args.sword_palettes
palettes_options['shield']=args.shield_palettes
palettes_options['link']=args.link_palettes
# palettes_options['link']=args.link_palettesvera
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.fastmenu, args.disablemusic,
args.sprite, palettes_options)

View File

@ -127,6 +127,7 @@ class World(object):
set_player_attr('glitch_boots', True)
set_player_attr('progression_balancing', True)
set_player_attr('local_items', set())
set_player_attr('non_local_items', set())
set_player_attr('triforce_pieces_available', 30)
set_player_attr('triforce_pieces_required', 20)
set_player_attr('shop_shuffle', 'off')

View File

@ -153,27 +153,33 @@ boss_table = {
}
def can_place_boss(world, player: int, boss: str, dungeon_name: str, level: Optional[str] = None) -> bool:
if dungeon_name in ['Ganons Tower', 'Inverted Ganons Tower'] and level == 'top':
if boss in ["Armos Knights", "Arrghus", "Blind", "Trinexx", "Lanmolas"]:
def can_place_boss(boss: str, dungeon_name: str, level: Optional[str] = None) -> bool:
#blacklist approach
if boss in {"Agahnim", "Agahnim2", "Ganon"}:
return False
if dungeon_name in ['Ganons Tower', 'Inverted Ganons Tower'] and level == 'middle':
if boss in ["Blind"]:
if dungeon_name == 'Ganons Tower':
if level == 'top':
if boss in {"Armos Knights", "Arrghus", "Blind", "Trinexx", "Lanmolas"}:
return False
elif level == 'middle':
if boss == "Blind":
return False
if dungeon_name == 'Tower of Hera' and boss in ["Armos Knights", "Arrghus", "Blind", "Trinexx", "Lanmolas"]:
elif dungeon_name == 'Tower of Hera':
if boss in {"Armos Knights", "Arrghus", "Blind", "Trinexx", "Lanmolas"}:
return False
if dungeon_name == 'Skull Woods' and boss in ["Trinexx"]:
elif dungeon_name == 'Skull Woods' :
if boss == "Trinexx":
return False
if boss in ["Agahnim", "Agahnim2", "Ganon"]:
return False
return True
def place_boss(world, player: int, boss: str, location: str, level: Optional[str]):
if location == 'Ganons Tower' and world.mode[player] == 'inverted':
location = 'Inverted Ganons Tower'
logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else ''))
world.get_dungeon(location, player).bosses[level] = BossFactory(boss, player)
@ -182,7 +188,6 @@ def place_bosses(world, player: int):
if world.boss_shuffle[player] == 'none':
return
# Most to least restrictive order
if world.mode[player] != 'inverted':
boss_locations = [
['Ganons Tower', 'top'],
['Tower of Hera', None],
@ -198,27 +203,10 @@ def place_bosses(world, player: int):
['Turtle Rock', None],
['Ganons Tower', 'bottom'],
]
else:
boss_locations = [
['Inverted Ganons Tower', 'top'],
['Tower of Hera', None],
['Skull Woods', None],
['Inverted Ganons Tower', 'middle'],
['Eastern Palace', None],
['Desert Palace', None],
['Palace of Darkness', None],
['Swamp Palace', None],
['Thieves Town', None],
['Ice Palace', None],
['Misery Mire', None],
['Turtle Rock', None],
['Inverted Ganons Tower', 'bottom'],
]
all_bosses = sorted(boss_table.keys()) # sorted to be deterministic on older pythons
placeable_bosses = [boss for boss in all_bosses if boss not in ['Agahnim', 'Agahnim2', 'Ganon']]
anywhere_bosses = [boss for boss in placeable_bosses if all(
can_place_boss(world, player, boss, loc, level) for loc, level in boss_locations)]
if world.boss_shuffle[player] in ["basic", "normal"]:
if world.boss_shuffle[player] == "basic": # vanilla bosses shuffled
bosses = placeable_bosses + ['Armos Knights', 'Lanmolas', 'Moldorm']
@ -228,8 +216,8 @@ def place_bosses(world, player: int):
logging.debug('Bosses chosen %s', bosses)
world.random.shuffle(bosses)
for [loc, level] in boss_locations:
boss = next((b for b in bosses if can_place_boss(world, player, b, loc, level)), None)
for loc, level in boss_locations:
boss = next((b for b in bosses if can_place_boss(b, loc, level)), None)
if not boss:
loc_text = loc + (' (' + level + ')' if level else '')
raise FillError('Could not place boss for location %s' % loc_text)
@ -237,10 +225,10 @@ def place_bosses(world, player: int):
place_boss(world, player, boss, loc, level)
elif world.boss_shuffle[player] == "chaos": # all bosses chosen at random
for [loc, level] in boss_locations:
for loc, level in boss_locations:
try:
boss = world.random.choice(
[b for b in placeable_bosses if can_place_boss(world, player, b, loc, level)])
[b for b in placeable_bosses if can_place_boss(b, loc, level)])
except IndexError:
loc_text = loc + (' (' + level + ')' if level else '')
raise FillError('Could not place boss for location %s' % loc_text)
@ -252,14 +240,14 @@ def place_bosses(world, player: int):
remaining_boss_locations = []
for loc, level in boss_locations:
# place that boss where it can go
if can_place_boss(world, player, primary_boss, loc, level):
if can_place_boss(primary_boss, loc, level):
place_boss(world, player, primary_boss, loc, level)
else:
remaining_boss_locations.append((loc, level))
if remaining_boss_locations:
# pick a boss to go into the remaining locations
remaining_boss = world.random.choice([boss for boss in placeable_bosses if all(
can_place_boss(world, player, boss, loc, level) for loc, level in remaining_boss_locations)])
can_place_boss(boss, loc, level) for loc, level in remaining_boss_locations)])
for loc, level in remaining_boss_locations:
place_boss(world, player, remaining_boss, loc, level)
else:

View File

@ -181,7 +181,7 @@ def parse_arguments(argv, no_defaults=False):
slightly biased to placing progression items with
less restrictions.
''')
parser.add_argument('--shuffle', default=defval('full'), const='full', nargs='?', choices=['vanilla', 'simple', 'restricted', 'full', 'crossed', 'insanity', 'restricted_legacy', 'full_legacy', 'madness_legacy', 'insanity_legacy', 'dungeonsfull', 'dungeonssimple'],
parser.add_argument('--shuffle', default=defval('vanilla'), const='vanilla', nargs='?', choices=['vanilla', 'simple', 'restricted', 'full', 'crossed', 'insanity', 'restricted_legacy', 'full_legacy', 'madness_legacy', 'insanity_legacy', 'dungeonsfull', 'dungeonssimple'],
help='''\
Select Entrance Shuffling Algorithm. (default: %(default)s)
Full: Mix cave and dungeon entrances freely while limiting
@ -266,6 +266,8 @@ def parse_arguments(argv, no_defaults=False):
help='Specifies a list of items that will be in your starting inventory (separated by commas)')
parser.add_argument('--local_items', default=defval(''),
help='Specifies a list of items that will not spread across the multiworld (separated by commas)')
parser.add_argument('--non_local_items', default=defval(''),
help='Specifies a list of items that will spread across the multiworld (separated by commas)')
parser.add_argument('--custom', default=defval(False), help='Not supported.')
parser.add_argument('--customitemarray', default=defval(False), help='Not supported.')
parser.add_argument('--accessibility', default=defval('items'), const='items', nargs='?', choices=['items', 'locations', 'none'], help='''\
@ -376,7 +378,7 @@ def parse_arguments(argv, no_defaults=False):
'shuffle', 'crystals_ganon', 'crystals_gt', 'open_pyramid', 'timer',
'countdown_start_time', 'red_clock_time', 'blue_clock_time', 'green_clock_time',
'mapshuffle', 'compassshuffle', 'keyshuffle', 'bigkeyshuffle', 'startinventory',
'local_items', 'retro', 'accessibility', 'hints', 'beemizer',
'local_items', 'non_local_items', 'retro', 'accessibility', 'hints', 'beemizer',
'shufflebosses', 'enemy_shuffle', 'enemy_health', 'enemy_damage', 'shufflepots',
'ow_palettes', 'uw_palettes', 'sprite', 'disablemusic', 'quickswap', 'fastmenu', 'heartcolor',
'heartbeep', "skip_progression_balancing", "triforce_pieces_available",

View File

@ -54,8 +54,9 @@ 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)
raise FillError(f'No more spots to place {item_to_place}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(placements)}')
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
world.push_item(spot_to_fill, item_to_place, False)
locations.remove(spot_to_fill)

314
Gui.py
View File

@ -34,7 +34,7 @@ def guiMain(args=None):
customWindow = ttk.Frame(notebook)
notebook.add(randomizerWindow, text='Randomize')
notebook.add(adjustWindow, text='Adjust')
notebook.add(customWindow, text='Custom')
notebook.add(customWindow, text='Custom Items')
notebook.pack()
# Shared Controls
@ -96,8 +96,7 @@ def guiMain(args=None):
hintsVar = IntVar()
hintsVar.set(1) # set default
hintsCheckbutton = Checkbutton(checkBoxFrame, text="Include Helpful Hints", variable=hintsVar)
customVar = IntVar()
customCheckbutton = Checkbutton(checkBoxFrame, text="Use custom item pool", variable=customVar)
balancingVar = IntVar()
balancingVar.set(1) # set default
balancingCheckbutton = Checkbutton(checkBoxFrame, text="Multiworld Progression Balancing", variable=balancingVar)
@ -116,60 +115,11 @@ def guiMain(args=None):
retroCheckbutton.pack(expand=True, anchor=W)
shuffleGanonCheckbutton.pack(expand=True, anchor=W)
hintsCheckbutton.pack(expand=True, anchor=W)
customCheckbutton.pack(expand=True, anchor=W)
balancingCheckbutton.pack(expand=True, anchor=W)
patchesCheckbutton.pack(expand=True, anchor=W)
timerOptionsFrame = LabelFrame(rightHalfFrame, text="Timer options")
for i in range(3):
timerOptionsFrame.columnconfigure(i, weight=1)
timerOptionsFrame.rowconfigure(i, weight=1)
timerModeFrame = Frame(timerOptionsFrame)
timerModeFrame.grid(row=0, column=0, columnspan=3, sticky=E, padx=3)
timerVar = StringVar()
timerVar.set('none')
timerModeMenu = OptionMenu(timerModeFrame, timerVar, 'none', 'display', 'timed', 'timed-ohko', 'ohko', 'timed-countdown')
timerLabel = Label(timerModeFrame, text='Timer setting')
timerLabel.pack(side=LEFT)
timerModeMenu.pack(side=LEFT)
timerCountdownFrame = Frame(timerOptionsFrame)
timerCountdownFrame.grid(row=1, column=0, columnspan=3, sticky=E, padx=3)
timerCountdownLabel = Label(timerCountdownFrame, text='Countdown starting time')
timerCountdownLabel.pack(side=LEFT)
timerCountdownVar = IntVar(value=10)
timerCountdownSpinbox = Spinbox(timerCountdownFrame, from_=0, to=480, width=3, textvariable=timerCountdownVar)
timerCountdownSpinbox.pack(side=LEFT)
timerRedFrame = Frame(timerOptionsFrame)
timerRedFrame.grid(row=2, column=0, sticky=E, padx=3)
timerRedLabel = Label(timerRedFrame, text='Clock adjustments: Red')
timerRedLabel.pack(side=LEFT)
timerRedVar = IntVar(value=-2)
timerRedSpinbox = Spinbox(timerRedFrame, from_=-60, to=60, width=3, textvariable=timerRedVar)
timerRedSpinbox.pack(side=LEFT)
timerBlueFrame = Frame(timerOptionsFrame)
timerBlueFrame.grid(row=2, column=1, sticky=E, padx=3)
timerBlueLabel = Label(timerBlueFrame, text='Blue')
timerBlueLabel.pack(side=LEFT)
timerBlueVar = IntVar(value=2)
timerBlueSpinbox = Spinbox(timerBlueFrame, from_=-60, to=60, width=3, textvariable=timerBlueVar)
timerBlueSpinbox.pack(side=LEFT)
timerGreenFrame = Frame(timerOptionsFrame)
timerGreenFrame.grid(row=2, column=2, sticky=E, padx=3)
timerGreenLabel = Label(timerGreenFrame, text='Green')
timerGreenLabel.pack(side=LEFT)
timerGreenVar = IntVar(value=4)
timerGreenSpinbox = Spinbox(timerGreenFrame, from_=-60, to=60, width=3, textvariable=timerGreenVar)
timerGreenSpinbox.pack(side=LEFT)
romOptionsFrame = LabelFrame(rightHalfFrame, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
romOptionsFrame.columnconfigure(1, weight=1)
@ -316,7 +266,7 @@ def guiMain(args=None):
romSelectButton.pack(side=LEFT)
checkBoxFrame.pack(side=TOP, anchor=W, padx=5, pady=10)
timerOptionsFrame.pack(expand=True, fill=BOTH, padx=3)
romOptionsFrame.pack(expand=True, fill=BOTH, padx=3)
drowDownFrame = Frame(topFrame)
@ -844,7 +794,10 @@ def guiMain(args=None):
else:
messagebox.showinfo(title="Success", message="Rom patched successfully")
from Utils import persistent_store
persistent_store("adjuster", "last_settings", guiargs)
from Rom import Sprite
if isinstance(guiargs.sprite, Sprite):
guiargs.sprite = guiargs.sprite.name
persistent_store("adjuster", "last_settings_3", guiargs)
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
@ -866,12 +819,65 @@ def guiMain(args=None):
return False
vcmd=(topFrame3.register(validation), '%P')
timerOptionsFrame = LabelFrame(topFrame3, text="Timer options")
for i in range(3):
timerOptionsFrame.columnconfigure(i, weight=1)
timerOptionsFrame.rowconfigure(i, weight=1)
timerModeFrame = Frame(timerOptionsFrame)
timerModeFrame.grid(row=0, column=0, columnspan=3, sticky=E, padx=3)
timerVar = StringVar()
timerVar.set('none')
timerModeMenu = OptionMenu(timerModeFrame, timerVar, 'none', 'display', 'timed', 'timed-ohko', 'ohko', 'timed-countdown')
timerLabel = Label(timerModeFrame, text='Timer setting')
timerLabel.pack(side=LEFT)
timerModeMenu.pack(side=LEFT)
timerCountdownFrame = Frame(timerOptionsFrame)
timerCountdownFrame.grid(row=1, column=0, columnspan=3, sticky=E, padx=3)
timerCountdownLabel = Label(timerCountdownFrame, text='Countdown starting time')
timerCountdownLabel.pack(side=LEFT)
timerCountdownVar = IntVar(value=10)
timerCountdownSpinbox = Spinbox(timerCountdownFrame, from_=0, to=480, width=3, textvariable=timerCountdownVar)
timerCountdownSpinbox.pack(side=LEFT)
timerRedFrame = Frame(timerOptionsFrame)
timerRedFrame.grid(row=2, column=0, sticky=E, padx=3)
timerRedLabel = Label(timerRedFrame, text='Clock adjustments: Red')
timerRedLabel.pack(side=LEFT)
timerRedVar = IntVar(value=-2)
timerRedSpinbox = Spinbox(timerRedFrame, from_=-60, to=60, width=3, textvariable=timerRedVar)
timerRedSpinbox.pack(side=LEFT)
timerBlueFrame = Frame(timerOptionsFrame)
timerBlueFrame.grid(row=2, column=1, sticky=E, padx=3)
timerBlueLabel = Label(timerBlueFrame, text='Blue')
timerBlueLabel.pack(side=LEFT)
timerBlueVar = IntVar(value=2)
timerBlueSpinbox = Spinbox(timerBlueFrame, from_=-60, to=60, width=3, textvariable=timerBlueVar)
timerBlueSpinbox.pack(side=LEFT)
timerGreenFrame = Frame(timerOptionsFrame)
timerGreenFrame.grid(row=2, column=2, sticky=E, padx=3)
timerGreenLabel = Label(timerGreenFrame, text='Green')
timerGreenLabel.pack(side=LEFT)
timerGreenVar = IntVar(value=4)
timerGreenSpinbox = Spinbox(timerGreenFrame, from_=-60, to=60, width=3, textvariable=timerGreenVar)
timerGreenSpinbox.pack(side=LEFT)
timerOptionsFrame.pack(expand=True, fill=BOTH, padx=3)
itemList1 = Frame(topFrame3)
itemList2 = Frame(topFrame3)
itemList3 = Frame(topFrame3)
itemList4 = Frame(topFrame3)
itemList5 = Frame(topFrame3)
customVar = IntVar()
customCheckbutton = Checkbutton(topFrame3, text="Use custom item pool", variable=customVar)
customCheckbutton.pack(expand=True, anchor=W)
bowFrame = Frame(itemList1)
bowLabel = Label(bowFrame, text='Bow')
bowVar = StringVar(value='0')
@ -1492,7 +1498,8 @@ def guiMain(args=None):
mainWindow.mainloop()
class SpriteSelector(object):
class SpriteSelector():
def __init__(self, parent, callback, adjuster=False):
if is_bundled():
self.deploy_icons()
@ -1615,93 +1622,15 @@ class SpriteSelector(object):
self.window.destroy()
self.parent.update()
def work(task):
resultmessage = ""
successful = True
def finished():
task.close_window()
def on_finish(successful, resultmessage):
if successful:
messagebox.showinfo("Sprite Updater", resultmessage)
else:
logging.error(resultmessage)
messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster)
try:
task.update_status("Downloading alttpr sprites list")
with urlopen('https://alttpr.com/sprites') as response:
sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e:
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
try:
task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(self.alttpr_sprite_dir + '/*')]
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) for sprite in sprites_arr]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if filename not in current_sprites]
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
def dl(sprite_url, filename):
target = os.path.join(self.alttpr_sprite_dir, filename)
with urlopen(sprite_url) as response, open(target, 'wb') as out:
shutil.copyfileobj(response, out)
def rem(sprite):
os.remove(os.path.join(self.alttpr_sprite_dir, sprite))
with ThreadPoolExecutor() as pool:
dl_tasks = []
rem_tasks = []
for (sprite_url, filename) in needed_sprites:
dl_tasks.append(pool.submit(dl, sprite_url, filename))
for sprite in obsolete_sprites:
rem_tasks.append(pool.submit(rem, sprite))
deleted = 0
updated = 0
for dl_task in as_completed(dl_tasks):
updated += 1
task.update_status("Downloading needed sprite %g/%g" % (updated, len(needed_sprites)))
try:
dl_task.result()
except Exception as e:
logging.exception(e)
resultmessage = "Error downloading sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
for rem_task in as_completed(rem_tasks):
deleted += 1
task.update_status("Removing obsolete sprite %g/%g" % (deleted, len(obsolete_sprites)))
try:
rem_task.result()
except Exception as e:
logging.exception(e)
resultmessage = "Error removing obsolete sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
if successful:
resultmessage = "alttpr sprites updated successfully"
task.queue_event(finished)
BackgroundTaskProgress(self.parent, work, "Updating Sprites")
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
def browse_for_sprite(self):
@ -1745,34 +1674,104 @@ class SpriteSelector(object):
self.callback(spritename)
self.window.destroy()
def deploy_icons(self):
if not os.path.exists(self.custom_sprite_dir):
os.makedirs(self.custom_sprite_dir)
if not os.path.exists(self.alttpr_sprite_dir):
shutil.copytree(self.local_alttpr_sprite_dir, self.alttpr_sprite_dir)
@property
def alttpr_sprite_dir(self):
if is_bundled():
return output_path("sprites", "alttpr")
return self.local_alttpr_sprite_dir
@property
def local_alttpr_sprite_dir(self):
return local_path("data", "sprites", "alttpr")
@property
def custom_sprite_dir(self):
if is_bundled():
return output_path("sprites", "custom")
return self.local_custom_sprite_dir
@property
def local_custom_sprite_dir(self):
return local_path("data", "sprites", "custom")
def update_sprites(task, on_finish=None):
resultmessage = ""
successful = True
sprite_dir = local_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True)
def finished():
task.close_window()
if on_finish:
on_finish(successful, resultmessage)
try:
task.update_status("Downloading alttpr sprites list")
with urlopen('https://alttpr.com/sprites') as response:
sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e:
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
try:
task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) for sprite in sprites_arr]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if filename not in current_sprites]
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
def dl(sprite_url, filename):
target = os.path.join(sprite_dir, filename)
with urlopen(sprite_url) as response, open(target, 'wb') as out:
shutil.copyfileobj(response, out)
def rem(sprite):
os.remove(os.path.join(sprite_dir, sprite))
with ThreadPoolExecutor() as pool:
dl_tasks = []
rem_tasks = []
for (sprite_url, filename) in needed_sprites:
dl_tasks.append(pool.submit(dl, sprite_url, filename))
for sprite in obsolete_sprites:
rem_tasks.append(pool.submit(rem, sprite))
deleted = 0
updated = 0
for dl_task in as_completed(dl_tasks):
updated += 1
task.update_status("Downloading needed sprite %g/%g" % (updated, len(needed_sprites)))
try:
dl_task.result()
except Exception as e:
logging.exception(e)
resultmessage = "Error downloading sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
for rem_task in as_completed(rem_tasks):
deleted += 1
task.update_status("Removing obsolete sprite %g/%g" % (deleted, len(obsolete_sprites)))
try:
rem_task.result()
except Exception as e:
logging.exception(e)
resultmessage = "Error removing obsolete sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
if successful:
resultmessage = "alttpr sprites updated successfully"
task.queue_event(finished)
def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid:
return None
@ -1877,5 +1876,16 @@ def get_image_for_sprite(sprite, gif_only: bool = False):
return image.zoom(2)
if __name__ == '__main__':
import sys
if "update_sprites" in sys.argv:
import threading
done = threading.Event()
top = Tk()
top.withdraw()
BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.isSet():
top.update()
print("Done updating sprites")
else:
logging.basicConfig(format='%(message)s', level=logging.INFO)
guiMain()

View File

@ -14,12 +14,12 @@ def set_icon(window):
# some which may be platform specific, or depend on if the TCL library was compiled without
# multithreading support. Therefore I will assume it is not thread safe to avoid any possible problems
class BackgroundTask(object):
def __init__(self, window, code_to_run):
def __init__(self, window, code_to_run, *args):
self.window = window
self.queue = queue.Queue()
self.running = True
self.process_queue()
self.task = threading.Thread(target=code_to_run, args=(self,))
self.task = threading.Thread(target=code_to_run, args=(self, *args))
self.task.start()
def stop(self):
@ -45,7 +45,7 @@ class BackgroundTask(object):
self.window.after(100, self.process_queue)
class BackgroundTaskProgress(BackgroundTask):
def __init__(self, parent, code_to_run, title):
def __init__(self, parent, code_to_run, title, *args):
self.parent = parent
self.window = tk.Toplevel(parent)
self.window['padx'] = 5
@ -65,7 +65,7 @@ class BackgroundTaskProgress(BackgroundTask):
set_icon(self.window)
self.window.focus()
super().__init__(self.window, code_to_run)
super().__init__(self.window, code_to_run, *args)
#safe to call from worker thread
def update_status(self, text):

10
Main.py
View File

@ -10,7 +10,7 @@ import zlib
import concurrent.futures
from BaseClasses import World, CollectionState, Item, Region, Location, Shop
from Items import ItemFactory
from Items import ItemFactory, item_table
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
from EntranceShuffle import link_entrances, link_inverted_entrances
@ -110,7 +110,13 @@ def main(args, seed=None):
item = ItemFactory(tok.strip(), player)
if item:
world.push_precollected(item)
world.local_items[player] = {item.strip() for item in args.local_items[player].split(',')}
# item in item_table gets checked in mystery, but not CLI - so we double-check here
world.local_items[player] = {item.strip() for item in args.local_items[player].split(',') if
item.strip() in item_table}
world.non_local_items[player] = {item.strip() for item in args.non_local_items[player].split(',') if
item.strip() in item_table}
# items can't be both local and non-local, prefer local
world.non_local_items[player] -= world.local_items[player]
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player], world.triforce_pieces_required[player])

View File

@ -73,7 +73,6 @@ class Context():
self.snes_reconnect_address = None
self.snes_recv_queue = asyncio.Queue()
self.snes_request_lock = asyncio.Lock()
self.is_sd2snes = False
self.snes_write_buffer = []
self.server_task = None
@ -522,15 +521,6 @@ async def snes_connect(ctx: Context, address):
ctx.snes_attached_device = (devices.index(device), device)
ctx.ui_node.send_connection_status(ctx)
if 'sd2snes' in device.lower() or (len(device) == 4 and device[:3] == 'COM'):
ctx.ui_node.log_info("SD2SNES Detected")
ctx.is_sd2snes = True
await ctx.snes_socket.send(json.dumps({"Opcode" : "Info", "Space" : "SNES"}))
reply = json.loads(await ctx.snes_socket.recv())
if reply and 'Results' in reply:
ctx.ui_node.log_info(reply['Results'])
else:
ctx.is_sd2snes = False
ctx.snes_reconnect_address = address
recv_task = asyncio.create_task(snes_recv_loop(ctx))
@ -645,37 +635,8 @@ async def snes_write(ctx : Context, write_list):
if ctx.snes_state != SNES_ATTACHED or ctx.snes_socket is None or not ctx.snes_socket.open or ctx.snes_socket.closed:
return False
PutAddress_Request = {
"Opcode" : "PutAddress",
"Operands" : []
}
PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
if ctx.is_sd2snes:
cmd = b'\x00\xE2\x20\x48\xEB\x48'
for address, data in write_list:
if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)):
ctx.ui_node.log_error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data)))
return False
for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START):
cmd += b'\xA9' # LDA
cmd += bytes([byte])
cmd += b'\x8F' # STA.l
cmd += bytes([ptr & 0xFF, (ptr >> 8) & 0xFF, (ptr >> 16) & 0xFF])
cmd += b'\xA9\x00\x8F\x00\x2C\x00\x68\xEB\x68\x28\x6C\xEA\xFF\x08'
PutAddress_Request['Space'] = 'CMD'
PutAddress_Request['Operands'] = ["2C00", hex(len(cmd)-1)[2:], "2C00", "1"]
try:
if ctx.snes_socket is not None:
await ctx.snes_socket.send(json.dumps(PutAddress_Request))
if ctx.snes_socket is not None:
await ctx.snes_socket.send(cmd)
except websockets.ConnectionClosed:
return False
else:
PutAddress_Request['Space'] = 'SNES'
try:
#will pack those requests as soon as qusb2snes actually supports that for real
for address, data in write_list:
@ -685,6 +646,7 @@ async def snes_write(ctx : Context, write_list):
if ctx.snes_socket is not None:
await ctx.snes_socket.send(data)
except websockets.ConnectionClosed:
logging.warning("Could not write data to SNES")
return False
return True
@ -811,8 +773,8 @@ async def process_server_cmd(ctx: Context, cmd, args):
if args['password']:
ctx.ui_node.log_info('Password required')
if "forfeit_mode" in args: # could also be version > 2.2.1, but going with implicit content here
logging.info("Forfeit setting: "+args["forfeit_mode"])
logging.info("Remaining setting: "+args["remaining_mode"])
logging.info(f"Forfeit setting: {args['forfeit_mode']}")
logging.info(f"Remaining setting: {args['remaining_mode']}")
logging.info(f"A !hint costs {args['hint_cost']} points and you get {args['location_check_points']}"
f" for each location checked.")
ctx.hint_cost = int(args['hint_cost'])

View File

@ -1,4 +1,4 @@
__author__ = "Berserker55" # you can find me on the ALTTP Randomizer Discord
__author__ = "Berserker55" # you can find me on discord.gg/8Z65BR2
"""
This script launches a Multiplayer "Multiworld" Mystery Game
@ -18,16 +18,18 @@ import sys
import threading
import concurrent.futures
import argparse
import logging
def feedback(text: str):
print(text)
logging.info(text)
input("Press Enter to ignore and probably crash.")
if __name__ == "__main__":
logging.basicConfig(format='%(message)s', level=logging.INFO)
try:
print(f"{__author__}'s MultiMystery Launcher")
logging.info(f"{__author__}'s MultiMystery Launcher")
import ModuleUpdate
ModuleUpdate.update()
@ -46,6 +48,7 @@ if __name__ == "__main__":
output_path = options["general_options"]["output_path"]
enemizer_path = multi_mystery_options["enemizer_path"]
player_files_path = multi_mystery_options["player_files_path"]
target_player_count = multi_mystery_options["players"]
race = multi_mystery_options["race"]
create_spoiler = multi_mystery_options["create_spoiler"]
zip_roms = multi_mystery_options["zip_roms"]
@ -56,36 +59,31 @@ if __name__ == "__main__":
# zip_password = multi_mystery_options["zip_password"] not at this time
player_name = multi_mystery_options["player_name"]
meta_file_path = multi_mystery_options["meta_file_path"]
weights_file_path = multi_mystery_options["weights_file_path"]
teams = multi_mystery_options["teams"]
rom_file = options["general_options"]["rom_file"]
host = options["server_options"]["host"]
port = options["server_options"]["port"]
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
if not os.path.exists(enemizer_path):
feedback(f"Enemizer not found at {enemizer_path}, please adjust the path in MultiMystery.py's config or put Enemizer in the default location.")
feedback(
f"Enemizer not found at {enemizer_path}, please adjust the path in MultiMystery.py's config or put Enemizer in the default location.")
if not os.path.exists(rom_file):
feedback(f"Base rom is expected as {rom_file} in the Multiworld root folder please place/rename it there.")
player_files = []
os.makedirs(player_files_path, exist_ok=True)
for file in os.listdir(player_files_path):
lfile = file.lower()
if lfile.endswith(".yaml") and lfile != meta_file_path.lower():
if lfile.endswith(".yaml") and lfile != meta_file_path.lower() and lfile != weights_file_path.lower():
player_files.append(file)
print(f"Found player's file {file}.")
player_count = len(player_files)
if player_count == 0:
feedback(f"No player files found. Please put them in a {player_files_path} folder.")
else:
print(player_count, "Players found.")
logging.info(f"Found player's file {file}.")
player_string = ""
for i, file in enumerate(player_files, 1):
player_string += f"--p{i} \"{os.path.join(player_files_path, file)}\" "
if os.path.exists("BerserkerMultiServer.exe"):
basemysterycommand = "BerserkerMystery.exe" # compiled windows
elif os.path.exists("BerserkerMultiServer"):
@ -93,7 +91,18 @@ if __name__ == "__main__":
else:
basemysterycommand = f"py -{py_version} Mystery.py" # source
command = f"{basemysterycommand} --multi {len(player_files)} {player_string} " \
weights_file_path = os.path.join(player_files_path, weights_file_path)
if os.path.exists(weights_file_path):
target_player_count = max(len(player_files), target_player_count)
else:
target_player_count = len(player_files)
if target_player_count == 0:
feedback(f"No player files found. Please put them in a {player_files_path} folder.")
else:
logging.info(f"{target_player_count} Players found.")
command = f"{basemysterycommand} --multi {target_player_count} {player_string} " \
f"--rom \"{rom_file}\" --enemizercli \"{enemizer_path}\" " \
f"--outputpath \"{output_path}\" --teams {teams}"
@ -107,13 +116,15 @@ if __name__ == "__main__":
command += " --race"
if os.path.exists(os.path.join(player_files_path, meta_file_path)):
command += f" --meta {os.path.join(player_files_path, meta_file_path)}"
if os.path.exists(weights_file_path):
command += f" --weights {weights_file_path}"
print(command)
logging.info(command)
import time
start = time.perf_counter()
text = subprocess.check_output(command, shell=True).decode()
print(f"Took {time.perf_counter() - start:.3f} seconds to generate multiworld.")
logging.info(f"Took {time.perf_counter() - start:.3f} seconds to generate multiworld.")
seedname = ""
for segment in text.split():
@ -136,6 +147,7 @@ if __name__ == "__main__":
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs)):
import zipfile
compression = {1: zipfile.ZIP_DEFLATED,
2: zipfile.ZIP_LZMA,
3: zipfile.ZIP_BZIP2}[zip_format]
@ -150,17 +162,17 @@ if __name__ == "__main__":
def pack_file(file: str):
with ziplock:
zf.write(os.path.join(output_path, file), file)
print(f"Packed {file} into zipfile {zipname}")
logging.info(f"Packed {file} into zipfile {zipname}")
def remove_zipped_file(file: str):
os.remove(os.path.join(output_path, file))
print(f"Removed {file} which is now present in the zipfile")
logging.info(f"Removed {file} which is now present in the zipfile")
zipname = os.path.join(output_path, f"BM_{seedname}.{typical_zip_ending}")
print(f"Creating zipfile {zipname}")
logging.info(f"Creating zipfile {zipname}")
ipv4 = (host if host else get_public_ipv4()) + ":" + str(port)
@ -214,5 +226,6 @@ if __name__ == "__main__":
subprocess.call(f"{baseservercommand} --multidata {os.path.join(output_path, multidataname)}")
except:
import traceback
traceback.print_exc()
input("Press enter to close")

View File

@ -58,6 +58,15 @@ class Client(Endpoint):
class Context(Node):
simple_options = {"hint_cost": int,
"location_check_points": int,
"server_password": str,
"password": str,
"forfeit_mode": str,
"remaining_mode": str,
"item_cheat": bool,
"compatibility": int}
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", remaining_mode: str = "disabled",
auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2):
@ -131,15 +140,23 @@ class Context(Node):
self._set_options(server_options)
def _set_options(self, server_options: dict):
sentinel = object()
for key, value in server_options.items():
if key not in self.embedded_blacklist:
current = getattr(self, key, sentinel)
if current is not sentinel:
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
try:
value = data_type(value)
except Exception as e:
try:
raise Exception(f"Could not set server option {key}, skipping.") from e
except Exception as e:
logging.exception(e)
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
setattr(self, key, value)
self.item_cheat = not server_options.get("disable_item_cheat", True)
elif key == "disable_item_cheat":
self.item_cheat = not bool(value)
else:
logging.debug(f"Unrecognized server option {key}")
def save(self, now=False) -> bool:
if self.saving:
@ -651,15 +668,6 @@ class CommandProcessor(metaclass=CommandMeta):
class CommonCommandProcessor(CommandProcessor):
ctx: Context
simple_options = {"hint_cost": int,
"location_check_points": int,
"server_password": str,
"password": str,
"forfeit_mode": str,
"item_cheat": bool,
"auto_save_interval": int,
"compatibility": int}
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
@ -672,7 +680,7 @@ class CommonCommandProcessor(CommandProcessor):
def _cmd_options(self):
"""List all current options. Warning: lists password."""
self.output("Current options:")
for option in self.simple_options:
for option in self.ctx.simple_options:
if option == "server_password" and self.marker == "!": #Do not display the server password to the client.
self.output(f"Option server_password is set to {('*' * random.randint(4,16))}")
else:
@ -1231,7 +1239,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_option(self, option_name: str, option: str):
"""Set options for the server. Warning: expires on restart"""
attrtype = self.simple_options.get(option_name, None)
attrtype = self.ctx.simple_options.get(option_name, None)
if attrtype:
if attrtype == bool:
def attrtype(input_text: str):
@ -1245,7 +1253,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
return True
else:
known = (f"{option}:{otype}" for option, otype in self.simple_options.items())
known = (f"{option}:{otype}" for option, otype in self.ctx.simple_options.items())
self.output(f"Unrecognized Option {option_name}, known: "
f"{', '.join(known)}")
return False

View File

@ -238,6 +238,8 @@ def convert_to_on_off(value):
def get_choice(option, root, value=None) -> typing.Any:
if option not in root:
return value
if type(root[option]) is list:
return interpret_on_off(random.choices(root[option])[0])
if type(root[option]) is not dict:
return interpret_on_off(root[option])
if not root[option]:
@ -488,6 +490,17 @@ def roll_settings(weights):
ret.local_items = ",".join(ret.local_items)
ret.non_local_items = set()
for item_name in weights.get('non_local_items', []):
items = item_name_groups.get(item_name, {item_name})
for item in items:
if item in item_table:
ret.non_local_items.add(item)
else:
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
ret.non_local_items = ",".join(ret.non_local_items)
if 'rom' in weights:
romweights = weights['rom']

26
Rom.py
View File

@ -1,7 +1,7 @@
from __future__ import annotations
JAP10HASH = '03a63945398191337e896e5771f77173'
RANDOMIZERBASEHASH = 'e3714804e3fae1c6ac6100b94d1aee62'
RANDOMIZERBASEHASH = '5a607e36a82bbd14180536c8ec3ae49b'
import io
import json
@ -697,12 +697,12 @@ def patch_rom(world, rom, player, team, enemized):
# Thanks to Zarby89 for originally finding these values
# todo fix screen scrolling
if world.shuffle[player] not in ['insanity', 'insanity_legacy', 'madness_legacy'] and \
exit.name in ['Eastern Palace Exit', 'Tower of Hera Exit', 'Thieves Town Exit',
if world.shuffle[player] not in {'insanity', 'insanity_legacy', 'madness_legacy'} and \
exit.name in {'Eastern Palace Exit', 'Tower of Hera Exit', 'Thieves Town Exit',
'Skull Woods Final Section Exit', 'Ice Palace Exit', 'Misery Mire Exit',
'Palace of Darkness Exit', 'Swamp Palace Exit', 'Ganons Tower Exit',
'Desert Palace Exit (North)', 'Agahnims Tower Exit', 'Spiral Cave Exit (Top)',
'Superbunny Cave Exit (Bottom)', 'Turtle Rock Ledge Exit (East)']:
'Superbunny Cave Exit (Bottom)', 'Turtle Rock Ledge Exit (East)'}:
# For exits that connot be reached from another, no need to apply offset fixes.
rom.write_int16(0x15DB5 + 2 * offset, link_y) # same as final else
elif room_id == 0x0059 and world.fix_skullwoods_exit[player]:
@ -1407,20 +1407,19 @@ def patch_rom(world, rom, player, team, enemized):
rom.write_bytes(0x180185, [0, 0, 0]) # Uncle respawn refills (magic, bombs, arrows)
rom.write_bytes(0x180188, [0, 0, 0]) # Zelda respawn refills (magic, bombs, arrows)
rom.write_bytes(0x18018B, [0, 0, 0]) # Mantle respawn refills (magic, bombs, arrows)
if world.mode[player] == 'standard':
if uncle_location.item is not None and uncle_location.item.name in ['Bow', 'Progressive Bow']:
if world.mode[player] == 'standard' and uncle_location.item and uncle_location.item.player == player:
if uncle_location.item.name in {'Bow', 'Progressive Bow'}:
rom.write_byte(0x18004E, 1) # Escape Fill (arrows)
rom.write_int16(0x180183, 300) # Escape fill rupee bow
rom.write_bytes(0x180185, [0, 0, 70]) # Uncle respawn refills (magic, bombs, arrows)
rom.write_bytes(0x180188, [0, 0, 10]) # Zelda respawn refills (magic, bombs, arrows)
rom.write_bytes(0x18018B, [0, 0, 10]) # Mantle respawn refills (magic, bombs, arrows)
elif uncle_location.item is not None and uncle_location.item.name in ['Bombs (10)']:
elif uncle_location.item.name in {'Bombs (10)'}:
rom.write_byte(0x18004E, 2) # Escape Fill (bombs)
rom.write_bytes(0x180185, [0, 50, 0]) # Uncle respawn refills (magic, bombs, arrows)
rom.write_bytes(0x180188, [0, 3, 0]) # Zelda respawn refills (magic, bombs, arrows)
rom.write_bytes(0x18018B, [0, 3, 0]) # Mantle respawn refills (magic, bombs, arrows)
elif uncle_location.item is not None and uncle_location.item.name in ['Cane of Somaria', 'Cane of Byrna',
'Fire Rod']:
elif uncle_location.item.name in {'Cane of Somaria', 'Cane of Byrna', 'Fire Rod'}:
rom.write_byte(0x18004E, 4) # Escape Fill (magic)
rom.write_bytes(0x180185, [0x80, 0, 0]) # Uncle respawn refills (magic, bombs, arrows)
rom.write_bytes(0x180188, [0x20, 0, 0]) # Zelda respawn refills (magic, bombs, arrows)
@ -1629,7 +1628,7 @@ def apply_rom_settings(rom, beep, color, quickswap, fastmenu, disable_music, spr
hud_palettes = palettes_options['hud']
sword_palettes = palettes_options['sword']
shield_palettes = palettes_options['shield']
link_palettes = palettes_options['link']
# link_palettes = palettes_options['link']
buildAndRandomize("randomize_dungeon", uw_palettes)
buildAndRandomize("randomize_overworld", ow_palettes)
buildAndRandomize("randomize_hud", hud_palettes)
@ -2062,9 +2061,10 @@ def write_strings(rom, world, player, team):
greenpendant = world.find_items('Green Pendant', player)[0]
tt['sahasrahla_bring_courage'] = 'I lost my family heirloom in %s' % greenpendant.hint_text
tt['sign_ganons_tower'] = ('You need %d crystal to enter.' if world.crystals_needed_for_gt[
player] == 1 else 'You need %d crystals to enter.') % \
world.crystals_needed_for_gt[player]
if world.crystals_needed_for_gt[player] == 1:
tt['sign_ganons_tower'] = 'You need a crystal to enter.'
else:
tt['sign_ganons_tower'] = f'You need {world.crystals_needed_for_gt[player]} crystals to enter.'
if world.goal[player] == 'dungeons':
tt['sign_ganon'] = 'You need to complete all the dungeons.'

View File

@ -104,7 +104,7 @@ def mirrorless_path_to_castle_courtyard(world, player):
else:
queue.append((entrance.connected_region, new_path))
raise Exception(f"Could not find mirrorless path to castle courtyard for Player {player}")
raise Exception(f"Could not find mirrorless path to castle courtyard for Player {player} ({world.get_player_names(player)})")
def set_rule(spot, rule):
spot.access_rule = rule
@ -179,6 +179,10 @@ def locality_rules(world, player):
for location in world.get_locations():
if location.player != player:
forbid_items_for_player(location, world.local_items[player], player)
if world.non_local_items[player]:
for location in world.get_locations():
if location.player == player:
forbid_items_for_player(location, world.non_local_items[player], player)
non_crossover_items = (item_name_groups["Small Keys"] | item_name_groups["Big Keys"] | progression_items) - {

View File

@ -267,7 +267,7 @@ junk_texts = [
"{C:GREEN}\n>Secret power\nis said to be\nin the arrow.",
"{C:GREEN}\nAim at the\neyes of Gohma.\n >",
"{C:GREEN}\nGrumble,\ngrumble…\n >",
"{C:GREEN}\n10th enemy\nhas the bomb.\n >",
# "{C:GREEN}\n10th enemy\nhas the bomb.\n >", removed as people may assume it applies to this game
"{C:GREEN}\nGo to the\nnext room.\n >",
"{C:GREEN}\n>Thanks, @\nYoure the\nhero of Hyrule",
"{C:GREEN}\nTheres always\nmoney in the\nBanana Stand>",

106
Utils.py
View File

@ -6,7 +6,7 @@ def tuplize_version(version: str) -> typing.Tuple[int, ...]:
return tuple(int(piece, 10) for piece in version.split("."))
__version__ = "3.3.0"
__version__ = "3.4.1"
_version_tuple = tuplize_version(__version__)
import os
@ -165,6 +165,90 @@ def get_public_ipv6() -> str:
pass # we could be offline, in a local game, or ipv6 may not be available
return ip
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
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
blacklisted_options = {"multi_mystery_options.cpu_threads",
"multi_mystery_options.max_attempts",
"multi_mystery_options.take_first_working",
"multi_mystery_options.keep_all_seeds",
"multi_mystery_options.log_output_path",
"multi_mystery_options.log_level"}
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
import logging
for key, value in src.items():
new_keys = keys.copy()
new_keys.append(key)
option_name = '.'.join(new_keys)
if key not in dest:
dest[key] = value
if filename.endswith("options.yaml") and option_name not in blacklisted_options:
logging.info(f"Warning: {filename} is missing {option_name}")
elif isinstance(value, dict):
if not isinstance(dest.get(key, None), dict):
if filename.endswith("options.yaml") and option_name not in blacklisted_options:
logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.")
dest[key] = value
else:
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",
@ -173,7 +257,9 @@ def get_options() -> dict:
for location in locations:
if os.path.exists(location):
with open(location) as f:
get_options.options = parse_yaml(f.read())
options = parse_yaml(f.read())
get_options.options = update_options(get_default_options(), options, location, list())
break
else:
raise FileNotFoundError(f"Could not find {locations[1]} to load options.")
@ -222,28 +308,32 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
if hasattr(get_adjuster_settings, "adjuster_settings"):
adjuster_settings = getattr(get_adjuster_settings, "adjuster_settings")
else:
adjuster_settings = persistent_load().get("adjuster", {}).get("last_settings", {})
adjuster_settings = persistent_load().get("adjuster", {}).get("last_settings_3", {})
if adjuster_settings:
import pprint
import Patch
adjuster_settings.rom = romfile
adjuster_settings.baserom = Patch.get_base_rom_path()
whitelist = {"disablemusic", "fastmenu", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes"}
"uw_palettes", "sprite"}
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
sprite = getattr(adjuster_settings, "sprite", None)
if sprite:
printed_options["sprite"] = adjuster_settings.sprite.name
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
return romfile, False
else:
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
f"{pprint.pformat(printed_options)}\n"
f"Enter yes or no: ")
f"Enter yes, no or never: ")
if adjust_wanted and adjust_wanted.startswith("y"):
adjusted = True
import AdjusterMain
_, romfile = AdjusterMain.adjust(adjuster_settings)
elif adjust_wanted and "never" in adjust_wanted:
persistent_store("adjuster", "never_adjust", True)
return romfile, False
else:
adjusted = False
import logging

View File

@ -47,6 +47,8 @@ app.config["PONY"] = {
}
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "simple"
app.config["JSON_AS_ASCII"] = False
app.autoversion = True
av = Autoversion(app)
cache = Cache(app)
@ -145,4 +147,5 @@ def favicon():
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads # to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)

View File

@ -0,0 +1,23 @@
"""API endpoints package."""
from uuid import UUID
from flask import Blueprint, abort
from ..models import Room
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
from . import generate
# unsorted/misc endpoints
@api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
return {"tracker": room.tracker,
"players": room.seed.multidata["names"],
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout}

View File

@ -0,0 +1,73 @@
import pickle
from uuid import UUID
from . import api_endpoints
from flask import request, session, url_for
from pony.orm import commit
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
from WebHostLib.check import get_yaml_data, roll_options
@api_endpoints.route('/generate', methods=['POST'])
def generate_api():
try:
options = {}
race = False
if 'file' in request.files:
file = request.files['file']
options = get_yaml_data(file)
if type(options) == str:
return {"text": options}, 400
if "race" in request.form:
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
json_data = request.get_json()
if json_data:
if 'weights' in json_data:
# example: options = {"player1weights" : {<weightsdata>}}
options = json_data["weights"]
if "race" in json_data:
race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"]))
if not options:
return {"text": "No options found. Expected file attachment or json weights."
}, 400
if len(options) > app.config["MAX_ROLL"]:
return {"text": "Max size of multiworld exceeded",
"detail": app.config["MAX_ROLL"]}, 409
results, gen_options = roll_options(options)
if any(type(result) == str for result in results.values()):
return {"text": str(results),
"detail": results}, 400
else:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
owner=session["_id"])
commit()
return {"text": f"Generation of seed {gen.id} started successfully.",
"detail": gen.id,
"encoded": app.url_map.converters["suuid"].to_url(None, gen.id),
"wait_api_url": url_for("wait_seed_api", seed=gen.id, _external=True),
"url": url_for("wait_seed", seed=gen.id, _external=True)}, 201
except Exception as e:
return {"text": "Uncaught Exception:" + str(e)}, 500
@api_endpoints.route('/status/<suuid:seed>')
def wait_seed_api(seed: UUID):
seed_id = seed
seed = Seed.get(id=seed_id)
if seed:
return {"text": "Generation done"}, 201
generation = Generation.get(id=seed_id)
if not generation:
return {"text": "Generation not found"}, 404
elif generation.state == STATE_ERROR:
return {"text": "Generation failed"}, 500
return {"text": "Generation running"}, 202

View File

@ -47,7 +47,7 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context):
def __init__(self):
super(WebHostContext, self).__init__("", 0, "", 1, 40, True, "enabled", "enabled", 0, 2)
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", 0, 2)
self.main_loop = asyncio.get_running_loop()
self.video = {}
self.tags = ["Berserker", "WebHost"]

View File

@ -52,55 +52,6 @@ def generate(race=False):
return render_template("generate.html", race=race)
@app.route('/api/generate', methods=['POST'])
def generate_api():
try:
options = {}
race = False
if 'file' in request.files:
file = request.files['file']
options = get_yaml_data(file)
if type(options) == str:
return {"text": options}, 400
if "race" in request.form:
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
json_data = request.get_json()
if json_data:
if 'weights' in json_data:
# example: options = {"player1weights" : {<weightsdata>}}
options = json_data["weights"]
if "race" in json_data:
race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"]))
if not options:
return {"text": "No options found. Expected file attachment or json weights."
}, 400
if len(options) > app.config["MAX_ROLL"]:
return {"text": "Max size of multiworld exceeded",
"detail": app.config["MAX_ROLL"]}, 409
results, gen_options = roll_options(options)
if any(type(result) == str for result in results.values()):
return {"text": str(results),
"detail": results}, 400
else:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
owner=session["_id"])
commit()
return {"text": f"Generation of seed {gen.id} started successfully.",
"detail": gen.id,
"encoded": app.url_map.converters["suuid"].to_url(None, gen.id),
"wait_api_url": url_for("wait_seed_api", seed=gen.id),
"url": url_for("wait_seed", seed=gen.id)}, 201
except Exception as e:
return {"text": "Uncaught Exception:" + str(e)}, 500
def gen_game(gen_options, race=False, owner=None, sid=None):
try:
target = tempfile.TemporaryDirectory()
@ -142,12 +93,13 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
ERmain(erargs, seed)
return upload_to_db(target.name, owner, sid, race)
except BaseException:
except BaseException as e:
if sid:
with db_session:
gen = Generation.get(id=sid)
if gen is not None:
gen.state = STATE_ERROR
gen.meta = (e.__class__.__name__ + ": "+ str(e)).encode()
raise
@ -162,25 +114,11 @@ def wait_seed(seed: UUID):
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
return "Generation failed, please retry."
import html
return f"Generation failed, please retry. <br> {html.escape(generation.meta.decode())}"
return render_template("waitSeed.html", seed_id=seed_id)
@app.route('/api/status/<suuid:seed>')
def wait_seed_api(seed: UUID):
seed_id = seed
seed = Seed.get(id=seed_id)
if seed:
return {"text": "Generation done"}, 201
generation = Generation.get(id=seed_id)
if not generation:
return {"text": "Generation not found"}, 404
elif generation.state == STATE_ERROR:
return {"text": "Generation failed"}, 500
return {"text": "Generation running"}, 202
def upload_to_db(folder, owner, sid, race:bool):
patches = set()
spoiler = ""

View File

@ -50,5 +50,5 @@ class Generation(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
owner = Required(UUID)
options = Required(bytes, lazy=True) # these didn't work as JSON on mariaDB, so they're getting pickled now
meta = Required(bytes, lazy=True)
meta = Required(bytes, lazy=True) # if state is -1 (error) this will contain an utf-8 encoded error message
state = Required(int, default=0, index=True)

View File

@ -1,7 +1,7 @@
flask>=1.1.2
pony>=0.7.13
pony>=0.7.14
waitress>=1.4.4
flask-caching>=1.9.0
Flask-Autoversion>=0.2.0
Flask-Compress>=1.7.0
Flask-Compress>=1.8.0
Flask-Limiter>=1.4

View File

@ -791,12 +791,6 @@
"friendlyName": "Expert",
"description": "Minimum upgrade availability (max: 8 hearts, green mail, master sword, fighter shield, no silvers unless swordless).",
"defaultValue": 0
},
"crowd_control": {
"keyString": "item_pool.crowd_control",
"friendlyName": "Crowd Control",
"description": "Configures the item pool for the crowd control extension. Do not use this unless you are using crowd control.",
"defaultValue": 0
}
}
},
@ -1387,6 +1381,26 @@
"defaultValue": 0
}
}
},
"key_drop_shuffle": {
"keyString": "key_drop_shuffle",
"friendlyName": "Key Drop Shuffle",
"description": "Allows the small/big keys dropped by enemies/pots to be shuffled into the item pool. This extends the number of checks from 216 to 249",
"inputType": "range",
"subOptions": {
"on": {
"keyString": "key_drop_shuffle.on",
"friendlyName": "Enabled",
"description": "Enables key drop shuffle",
"defaultValue": 0
},
"off": {
"keyString": "key_drop_shuffle.off",
"friendlyName": "Disabled",
"description": "Disables key drop shuffle",
"defaultValue": 50
}
}
}
},
"romOptions": {

View File

@ -165,7 +165,6 @@ item_pool:
normal: 50 # Item availability remains unchanged from vanilla game
hard: 0 # Reduced upgrade availability (max: 14 hearts, blue mail, tempered sword, fire shield, no silvers unless swordless)
expert: 0 # Minimum upgrade availability (max: 8 hearts, green mail, master sword, fighter shield, no silvers unless swordless)
crowd_control: 0 # Sets up the item pool for the crowd control extension. Do not use it without crowd control
item_functionality:
easy: 0 # Allow Hammer to damage ganon, Allow Hammer tablet collection, Allow swordless medallion use everywhere.
normal: 50 # Vanilla item functionality
@ -305,6 +304,9 @@ intensity: # Only available if the host uses the doors branch, it is ignored oth
2: 0 # And shuffles open edges and straight staircases
3: 0 # And shuffles dungeon lobbies
random: 0 # Picks one of those at random
key_drop_shuffle: # Only available if the host uses the doors branch, it is ignored otherwise
on: 0 # Enables the small keys dropped by enemies or under pots, and the big key dropped by the Ball & Chain guard to be shuffled into the pool. This extends the number of checks to 249.
off: 50
experimental: # Only available if the host uses the doors branch, it is ignored otherwise
on: 0 # Enables experimental features. Currently, this is just the dungeon keys in chest counter.
off: 50

View File

@ -55,6 +55,7 @@ html{
#player-settings #settings-wrapper #sprite-picker .sprite-img-wrapper{
cursor: pointer;
margin: 10px;
image-rendering: pixelated;
}
/* Center tooltip text for sprite images */

Binary file not shown.

2
data/sprites/alttpr/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More