Merge branch 'main' into breaking_changes

# Conflicts:
#	Adjuster.py
#	AdjusterMain.py
#	BaseClasses.py
#	MultiClient.py
#	MultiServer.py
#	Mystery.py
#	Utils.py
#	WebHostLib/downloads.py
#	WebHostLib/generate.py
#	dumpSprites.py
#	test/TestBase.py
#	worlds/alttp/EntranceRandomizer.py
#	worlds/alttp/Main.py
#	worlds/alttp/Rom.py
This commit is contained in:
Fabian Dill 2021-01-03 13:13:59 +01:00
commit 08ca4245c1
558 changed files with 13839 additions and 3095 deletions

View File

@ -3,11 +3,7 @@
name: unittests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
on: [push, pull_request]
jobs:
build:

4
.gitignore vendored
View File

@ -30,3 +30,7 @@ weights/
_persistent_storage.yaml
mystery_result_*.yaml
/db.db3
*-errors.txt
success.txt
output/
Output Logs/

View File

@ -6,7 +6,7 @@ import textwrap
import sys
from AdjusterMain import adjust
from worlds.alttp.Rom import get_sprite_from_name
from worlds.alttp.Rom import Sprite
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
@ -34,8 +34,12 @@ def main():
''')
parser.add_argument('--heartcolor', default='red', const='red', nargs='?', choices=['red', 'blue', 'green', 'yellow', 'random'],
help='Select the color of Link\'s heart meter. (default: %(default)s)')
parser.add_argument('--ow_palettes', default='default', choices=['default', 'random', 'blackout'])
parser.add_argument('--uw_palettes', default='default', choices=['default', 'random', 'blackout'])
parser.add_argument('--ow_palettes', default='default', choices=['default', 'random', 'blackout','puke','classic','grayscale','negative','dizzy','sick'])
parser.add_argument('--link_palettes', default='default', choices=['default', 'random', 'blackout','puke','classic','grayscale','negative','dizzy','sick'])
parser.add_argument('--shield_palettes', default='default', choices=['default', 'random', 'blackout','puke','classic','grayscale','negative','dizzy','sick'])
parser.add_argument('--sword_palettes', default='default', choices=['default', 'random', 'blackout','puke','classic','grayscale','negative','dizzy','sick'])
parser.add_argument('--hud_palettes', default='default', choices=['default', 'random', 'blackout','puke','classic','grayscale','negative','dizzy','sick'])
parser.add_argument('--uw_palettes', default='default', choices=['default', 'random', 'blackout','puke','classic','grayscale','negative','dizzy','sick'])
parser.add_argument('--sprite', help='''\
Path to a sprite sheet to use for Link. Needs to be in
binary format and have a length of 0x7000 (28672) bytes,
@ -51,7 +55,7 @@ def main():
input(
'Could not find valid rom for patching at expected path %s. Please run with -h to see help for further information. \nPress Enter to exit.' % args.rom)
sys.exit(1)
if args.sprite is not None and not os.path.isfile(args.sprite) and not get_sprite_from_name(args.sprite):
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
sys.exit(1)
@ -61,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

@ -10,19 +10,27 @@ def adjust(args):
start = time.perf_counter()
logger = logging.getLogger('Adjuster')
logger.info('Patching ROM.')
vanillaRom = args.baserom
if os.path.splitext(args.rom)[-1].lower() == '.apbp':
import Patch
meta, args.rom = Patch.create_rom_file(args.rom)
if os.stat(args.rom).st_size in (0x200000, 0x400000) and os.path.splitext(args.rom)[-1].lower() == '.sfc':
rom = LocalRom(args.rom, patch=False)
rom = LocalRom(args.rom, patch=False, vanillaRom=vanillaRom)
else:
raise RuntimeError(
'Provided Rom is not a valid Link to the Past Randomizer Rom. Please provide one for adjusting.')
palettes_options={}
palettes_options['dungeon']=args.uw_palettes
palettes_options['overworld']=args.ow_palettes
palettes_options['hud']=args.hud_palettes
palettes_options['sword']=args.sword_palettes
palettes_options['shield']=args.shield_palettes
# palettes_options['link']=args.link_palettesvera
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.fastmenu, args.disablemusic,
args.sprite, args.ow_palettes, args.uw_palettes)
args.sprite, palettes_options)
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
rom.write_to_file(path)

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, Dict
from typing import Union, Optional, List, Dict, NamedTuple
import secrets
import random
@ -20,12 +20,15 @@ class World():
class MultiWorld():
debug_types = False
player_names: list
player_names: Dict[int, List[str]]
_region_cache: dict
difficulty_requirements: dict
required_medallions: dict
dark_room_logic: Dict[int, str]
restrict_dungeon_item_on_boss: Dict[int, bool]
plando_texts: List[Dict[str, str]]
plando_items: List[PlandoItem]
plando_connections: List[PlandoConnection]
def __init__(self, players: int, shuffle, logic, mode, swords, difficulty, difficulty_adjustments, timer,
progressive,
@ -115,10 +118,15 @@ class MultiWorld():
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
set_player_attr('treasure_hunt_count', 0)
set_player_attr('clock_mode', False)
set_player_attr('countdown_start_time', 10)
set_player_attr('red_clock_time', -2)
set_player_attr('blue_clock_time', 2)
set_player_attr('green_clock_time', 4)
set_player_attr('can_take_damage', True)
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')
@ -126,6 +134,9 @@ class MultiWorld():
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', [])
set_player_attr('plando_texts', {})
set_player_attr('plando_connections', [])
self.worlds = []
#for i in range(players):
@ -578,7 +589,7 @@ class CollectionState(object):
def can_retrieve_tablet(self, player:int) -> bool:
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
((self.world.swords[player] == "swordless" or self.world.difficulty_adjustments[player] == "easy") and
(self.world.swords[player] == "swordless" and
self.has("Hammer", player)))
def has_sword(self, player: int) -> bool:
@ -612,7 +623,7 @@ class CollectionState(object):
def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and
(self.world.difficulty_adjustments[player] == "easy" or self.world.swords[player] == "swordless" or
(self.world.swords[player] == "swordless" or
self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool:
@ -987,6 +998,12 @@ class Item(object):
self.world = None
self.player = player
def __eq__(self, other):
return self.name == other.name and self.player == other.player
def __hash__(self):
return hash((self.name, self.player))
@property
def crystal(self) -> bool:
return self.type == 'Crystal'
@ -1402,3 +1419,16 @@ 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
class PlandoConnection(NamedTuple):
entrance: str
exit: str
direction: str # entrance, exit or both

13
Fill.py
View File

@ -54,8 +54,10 @@ 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(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)
@ -127,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)

429
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,14 +96,10 @@ 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)
patchesVar = IntVar()
patchesVar.set(1) # set default
patchesCheckbutton = Checkbutton(checkBoxFrame, text="Create Delta Patches", variable=patchesVar)
tileShuffleVar = IntVar()
tileShuffleButton = Checkbutton(checkBoxFrame, text="Tile shuffle", variable=tileShuffleVar)
createSpoilerCheckbutton.pack(expand=True, anchor=W)
suppressRomCheckbutton.pack(expand=True, anchor=W)
openpyramidCheckbutton.pack(expand=True, anchor=W)
@ -116,9 +112,8 @@ 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)
tileShuffleButton.pack(expand=True, anchor=W)
romOptionsFrame = LabelFrame(rightHalfFrame, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
@ -198,7 +193,7 @@ def guiMain(args=None):
owPalettesLabel.pack(side=LEFT)
owPalettesVar = StringVar()
owPalettesVar.set('default')
owPalettesOptionMenu = OptionMenu(owPalettesFrame, owPalettesVar, 'default', 'random', 'blackout')
owPalettesOptionMenu = OptionMenu(owPalettesFrame, owPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
owPalettesOptionMenu.pack(side=LEFT)
uwPalettesFrame = Frame(romOptionsFrame)
@ -207,11 +202,41 @@ def guiMain(args=None):
uwPalettesLabel.pack(side=LEFT)
uwPalettesVar = StringVar()
uwPalettesVar.set('default')
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, uwPalettesVar, 'default', 'random', 'blackout')
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, uwPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
uwPalettesOptionMenu.pack(side=LEFT)
hudPalettesFrame = Frame(romOptionsFrame)
hudPalettesFrame.grid(row=4, column=0, sticky=E)
hudPalettesLabel = Label(hudPalettesFrame, text='HUD palettes')
hudPalettesLabel.pack(side=LEFT)
hudPalettesVar = StringVar()
hudPalettesVar.set('default')
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, hudPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
hudPalettesOptionMenu.pack(side=LEFT)
swordPalettesFrame = Frame(romOptionsFrame)
swordPalettesFrame.grid(row=4, column=1, sticky=E)
swordPalettesLabel = Label(swordPalettesFrame, text='Sword palettes')
swordPalettesLabel.pack(side=LEFT)
swordPalettesVar = StringVar()
swordPalettesVar.set('default')
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, swordPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
swordPalettesOptionMenu.pack(side=LEFT)
shieldPalettesFrame = Frame(romOptionsFrame)
shieldPalettesFrame.grid(row=5, column=0, sticky=E)
shieldPalettesLabel = Label(shieldPalettesFrame, text='Shield palettes')
shieldPalettesLabel.pack(side=LEFT)
shieldPalettesVar = StringVar()
shieldPalettesVar.set('default')
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, shieldPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
shieldPalettesOptionMenu.pack(side=LEFT)
romDialogFrame = Frame(romOptionsFrame)
romDialogFrame.grid(row=4, column=0, columnspan=2, sticky=W+E)
romDialogFrame.grid(row=6, column=0, columnspan=2, sticky=W+E)
baseRomLabel = Label(romDialogFrame, text='Base Rom: ')
romVar = StringVar(value="Zelda no Densetsu - Kamigami no Triforce (Japan).sfc")
@ -236,6 +261,7 @@ def guiMain(args=None):
romSelectButton.pack(side=LEFT)
checkBoxFrame.pack(side=TOP, anchor=W, padx=5, pady=10)
romOptionsFrame.pack(expand=True, fill=BOTH, padx=3)
drowDownFrame = Frame(topFrame)
@ -314,14 +340,6 @@ def guiMain(args=None):
itemfunctionLabel = Label(itemfunctionFrame, text='Difficulty: item functionality')
itemfunctionLabel.pack(side=LEFT)
timerFrame = Frame(drowDownFrame)
timerVar = StringVar()
timerVar.set('none')
timerOptionMenu = OptionMenu(timerFrame, timerVar, 'none', 'display', 'timed', 'timed-ohko', 'ohko', 'timed-countdown')
timerOptionMenu.pack(side=RIGHT)
timerLabel = Label(timerFrame, text='Timer setting')
timerLabel.pack(side=LEFT)
dungeonCounterFrame = Frame(drowDownFrame)
dungeonCounterVar = StringVar()
dungeonCounterVar.set('auto')
@ -382,7 +400,6 @@ def guiMain(args=None):
swordFrame.pack(expand=True, anchor=E)
difficultyFrame.pack(expand=True, anchor=E)
itemfunctionFrame.pack(expand=True, anchor=E)
timerFrame.pack(expand=True, anchor=E)
dungeonCounterFrame.pack(expand=True, anchor=E)
progressiveFrame.pack(expand=True, anchor=E)
accessibilityFrame.pack(expand=True, anchor=E)
@ -449,17 +466,13 @@ def guiMain(args=None):
potShuffleButton = Checkbutton(enemizerFrame, text="Pot shuffle", variable=potShuffleVar)
potShuffleButton.grid(row=2, column=0, sticky=W)
tileShuffleVar = IntVar()
tileShuffleButton = Checkbutton(enemizerFrame, text="Tile shuffle", variable=tileShuffleVar)
tileShuffleButton.grid(row=2, column=1, sticky=W)
bushShuffleVar = IntVar()
bushShuffleButton = Checkbutton(enemizerFrame, text="Bush shuffle", variable=bushShuffleVar)
bushShuffleButton.grid(row=2, column=2, sticky=W)
bushShuffleButton.grid(row=2, column=1, sticky=W)
killableThievesVar = IntVar()
killable_thievesShuffleButton = Checkbutton(enemizerFrame, text="Killable Thieves", variable=killableThievesVar)
killable_thievesShuffleButton.grid(row=2, column=3, sticky=W)
killable_thievesShuffleButton.grid(row=2, column=2, sticky=W)
shopframe = LabelFrame(randomizerWindow, text="Shops", padx=5, pady=2)
@ -477,7 +490,7 @@ def guiMain(args=None):
multiworldframe = LabelFrame(randomizerWindow, text="Multiworld", padx=5, pady=2)
worldLabel = Label(multiworldframe, text='Worlds')
worldLabel = Label(multiworldframe, text='Players per Team')
worldVar = StringVar()
worldSpinbox = Spinbox(multiworldframe, from_=1, to=255, width=5, textvariable=worldVar)
namesLabel = Label(multiworldframe, text='Player names')
@ -486,10 +499,17 @@ def guiMain(args=None):
seedLabel = Label(multiworldframe, text='Seed #')
seedVar = StringVar()
seedEntry = Entry(multiworldframe, width=20, textvariable=seedVar)
countLabel = Label(multiworldframe, text='Count')
countLabel = Label(multiworldframe, text='Amount of Multiworlds')
countVar = StringVar()
countSpinbox = Spinbox(multiworldframe, from_=1, to=100, width=5, textvariable=countVar)
balancingVar = IntVar()
balancingVar.set(1) # set default
balancingCheckbutton = Checkbutton(multiworldframe, text="Progression Balancing", variable=balancingVar)
patchesVar = IntVar()
patchesVar.set(1) # set default
patchesCheckbutton = Checkbutton(multiworldframe, text="Create Delta Patches", variable=patchesVar)
def generateRom():
guiargs = Namespace()
guiargs.multi = int(worldVar.get())
@ -508,6 +528,10 @@ def guiMain(args=None):
guiargs.difficulty = difficultyVar.get()
guiargs.item_functionality = itemfunctionVar.get()
guiargs.timer = timerVar.get()
guiargs.countdown_start_time = timerCountdownVar.get()
guiargs.red_clock_time = timerRedVar.get()
guiargs.blue_clock_time = timerBlueVar.get()
guiargs.green_clock_time = timerGreenVar.get()
guiargs.skip_progression_balancing = not balancingVar.get()
if guiargs.timer == "none":
guiargs.timer = False
@ -538,6 +562,9 @@ def guiMain(args=None):
guiargs.disablemusic = bool(disableMusicVar.get())
guiargs.ow_palettes = owPalettesVar.get()
guiargs.uw_palettes = uwPalettesVar.get()
guiargs.hud_palettes = hudPalettesVar.get()
guiargs.sword_palettes = swordPalettesVar.get()
guiargs.shield_palettes = shieldPalettesVar.get()
guiargs.shuffleganon = bool(shuffleGanonVar.get())
guiargs.hints = bool(hintsVar.get())
guiargs.enemizercli = enemizerCLIpathVar.get()
@ -614,16 +641,20 @@ def guiMain(args=None):
else:
messagebox.showinfo(title="Success", message="Multiworld created successfully")
generateButton = Button(farBottomFrame, text='Generate Patched Rom', command=generateRom)
generateButton = Button(farBottomFrame, text='Generate Multiworld', command=generateRom)
worldLabel.grid(row=0, column=0, sticky=W)
worldSpinbox.grid(row=0, column=1, sticky=W)
namesLabel.grid(row=0, column=2, sticky=W)
namesEntry.grid(row=0, column=3, sticky=W + E)
multiworldframe.grid_columnconfigure(3, weight=1) # stretch name field
seedLabel.grid(row=0, column=4, sticky=W)
seedEntry.grid(row=0, column=5, sticky=W)
countLabel.grid(row=1, column=0, sticky=W)
countSpinbox.grid(row=1, column=1, sticky=W)
balancingCheckbutton.grid(row=1, column=2, sticky=W, columnspan=2)
patchesCheckbutton.grid(row=1, column=4, sticky=W, columnspan=2)
worldLabel.pack(side=LEFT)
worldSpinbox.pack(side=LEFT)
namesLabel.pack(side=LEFT)
namesEntry.pack(side=LEFT, expand=True, fill=X)
seedLabel.pack(side=LEFT, padx=(5, 0))
seedEntry.pack(side=LEFT)
countLabel.pack(side=LEFT, padx=(5, 0))
countSpinbox.pack(side=LEFT)
generateButton.pack(side=RIGHT, padx=(5, 0))
openOutputButton.pack(side=LEFT)
@ -702,22 +733,43 @@ def guiMain(args=None):
fastMenuLabel2.pack(side=LEFT)
owPalettesFrame2 = Frame(drowDownFrame2)
owPalettesOptionMenu2 = OptionMenu(owPalettesFrame2, owPalettesVar, 'default', 'random', 'blackout')
owPalettesOptionMenu2 = OptionMenu(owPalettesFrame2, owPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
owPalettesOptionMenu2.pack(side=RIGHT)
owPalettesLabel2 = Label(owPalettesFrame2, text='Overworld palettes')
owPalettesLabel2.pack(side=LEFT)
uwPalettesFrame2 = Frame(drowDownFrame2)
uwPalettesOptionMenu2 = OptionMenu(uwPalettesFrame2, uwPalettesVar, 'default', 'random', 'blackout')
uwPalettesOptionMenu2 = OptionMenu(uwPalettesFrame2, uwPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
uwPalettesOptionMenu2.pack(side=RIGHT)
uwPalettesLabel2 = Label(uwPalettesFrame2, text='Dungeon palettes')
uwPalettesLabel2.pack(side=LEFT)
hudPalettesFrame2 = Frame(drowDownFrame2)
hudPalettesOptionMenu2 = OptionMenu(hudPalettesFrame2, hudPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
hudPalettesOptionMenu2.pack(side=RIGHT)
hudPalettesLabel2 = Label(hudPalettesFrame2, text='HUD palettes')
hudPalettesLabel2.pack(side=LEFT)
swordPalettesFrame2 = Frame(drowDownFrame2)
swordPalettesOptionMenu2 = OptionMenu(swordPalettesFrame2, swordPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
swordPalettesOptionMenu2.pack(side=RIGHT)
swordPalettesLabel2 = Label(swordPalettesFrame2, text='Sword palettes')
swordPalettesLabel2.pack(side=LEFT)
shieldPalettesFrame2 = Frame(drowDownFrame2)
shieldPalettesOptionMenu2 = OptionMenu(shieldPalettesFrame2, shieldPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
shieldPalettesOptionMenu2.pack(side=RIGHT)
shieldPalettesLabel2 = Label(shieldPalettesFrame2, text='Shield palettes')
shieldPalettesLabel2.pack(side=LEFT)
heartbeepFrame2.pack(expand=True, anchor=E)
heartcolorFrame2.pack(expand=True, anchor=E)
fastMenuFrame2.pack(expand=True, anchor=E)
owPalettesFrame2.pack(expand=True, anchor=E)
uwPalettesFrame2.pack(expand=True, anchor=E)
hudPalettesFrame2.pack(expand=True, anchor=E)
swordPalettesFrame2.pack(expand=True, anchor=E)
shieldPalettesFrame2.pack(expand=True, anchor=E)
bottomFrame2 = Frame(topFrame2)
@ -728,6 +780,9 @@ def guiMain(args=None):
guiargs.fastmenu = fastMenuVar.get()
guiargs.ow_palettes = owPalettesVar.get()
guiargs.uw_palettes = uwPalettesVar.get()
guiargs.hud_palettes = hudPalettesVar.get()
guiargs.sword_palettes = swordPalettesVar.get()
guiargs.shield_palettes = shieldPalettesVar.get()
guiargs.quickswap = bool(quickSwapVar.get())
guiargs.disablemusic = bool(disableMusicVar.get())
guiargs.rom = romVar2.get()
@ -741,7 +796,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)
@ -763,12 +821,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')
@ -1367,6 +1478,10 @@ def guiMain(args=None):
difficultyVar.set(args.difficulty)
itemfunctionVar.set(args.item_functionality)
timerVar.set(args.timer)
timerCountdownVar.set(args.countdown_start_time)
timerRedVar.set(args.red_clock_time)
timerBlueVar.set(args.blue_clock_time)
timerGreenVar.set(args.green_clock_time)
progressiveVar.set(args.progressive)
accessibilityVar.set(args.accessibility)
goalVar.set(args.goal)
@ -1385,7 +1500,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()
@ -1411,8 +1527,8 @@ class SpriteSelector(object):
title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir + '/*', 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir + '/*', 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir, 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir, 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
frame = Frame(self.window)
frame.pack(side=BOTTOM, fill=X, pady=5)
@ -1471,19 +1587,21 @@ class SpriteSelector(object):
sprites = []
for file in glob(output_path(path)):
sprites.append(Sprite(file))
for file in os.listdir(path):
sprites.append((file, Sprite(os.path.join(path, file))))
sprites.sort(key=lambda s: str.lower(s.name or "").strip())
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
frame.buttons = []
for sprite in sprites:
for file, sprite in sprites:
image = get_image_for_sprite(sprite)
if image is None:
continue
self.all_sprites.append(sprite)
button = Button(frame, image=image, command=lambda spr=sprite: self.select_sprite(spr))
ToolTips.register(button, sprite.name + ("\nBy: %s" % sprite.author_name if sprite.author_name else ""))
ToolTips.register(button, sprite.name +
("\nBy: %s" % sprite.author_name if sprite.author_name else "") +
f"\nFrom: {file}")
button.image = image
frame.buttons.append(button)
@ -1508,93 +1626,15 @@ class SpriteSelector(object):
self.window.destroy()
self.parent.update()
def work(task):
resultmessage = ""
successful = True
def finished():
task.close_window()
if successful:
messagebox.showinfo("Sprite Updater", resultmessage)
else:
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
def on_finish(successful, resultmessage):
if successful:
resultmessage = "alttpr sprites updated successfully"
messagebox.showinfo("Sprite Updater", resultmessage)
else:
logging.error(resultmessage)
messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster)
task.queue_event(finished)
BackgroundTaskProgress(self.parent, work, "Updating Sprites")
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
def browse_for_sprite(self):
@ -1638,34 +1678,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
@ -1770,5 +1880,16 @@ def get_image_for_sprite(sprite, gif_only: bool = False):
return image.zoom(2)
if __name__ == '__main__':
logging.basicConfig(format='%(message)s', level=logging.INFO)
guiMain()
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):

View File

@ -22,3 +22,8 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Subdirectories of this repository may contain their own LICENSE file. Files within
subdirectories containing their own LICENSE file are exempt from the above license
and are subject to the license contained within the LICENSE file in their containing
directory.

View File

@ -86,14 +86,17 @@ class Context():
self.slot = None
self.player_names: typing.Dict[int: str] = {}
self.locations_checked = set()
self.unsafe_locations_checked = set()
self.locations_scouted = set()
self.items_received = []
self.items_missing = []
self.locations_info = {}
self.awaiting_rom = False
self.rom = None
self.prev_rom = None
self.auth = None
self.found_items = found_items
self.send_unsafe = False
self.finished_game = False
self.slow_mode = False
@ -196,23 +199,34 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Desert Palace - Map Chest': (0x74, 0x10),
'Desert Palace - Compass Chest': (0x85, 0x10),
'Desert Palace - Big Key Chest': (0x75, 0x10),
'Desert Palace - Desert Tiles 1 Pot Key': (0x63, 0x400),
'Desert Palace - Beamos Hall Pot Key': (0x53, 0x400),
'Desert Palace - Desert Tiles 2 Pot Key': (0x43, 0x400),
'Desert Palace - Boss': (0x33, 0x800),
'Eastern Palace - Compass Chest': (0xa8, 0x10),
'Eastern Palace - Big Chest': (0xa9, 0x10),
'Eastern Palace - Dark Square Pot Key': (0xba, 0x400),
'Eastern Palace - Dark Eyegore Key Drop': (0x99, 0x400),
'Eastern Palace - Cannonball Chest': (0xb9, 0x10),
'Eastern Palace - Big Key Chest': (0xb8, 0x10),
'Eastern Palace - Map Chest': (0xaa, 0x10),
'Eastern Palace - Boss': (0xc8, 0x800),
'Hyrule Castle - Boomerang Chest': (0x71, 0x10),
'Hyrule Castle - Boomerang Guard Key Drop': (0x71, 0x400),
'Hyrule Castle - Map Chest': (0x72, 0x10),
'Hyrule Castle - Map Guard Key Drop': (0x72, 0x400),
"Hyrule Castle - Zelda's Chest": (0x80, 0x10),
'Hyrule Castle - Big Key Drop': (0x80, 0x400),
'Sewers - Dark Cross': (0x32, 0x10),
'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400),
'Sewers - Secret Room - Left': (0x11, 0x10),
'Sewers - Secret Room - Middle': (0x11, 0x20),
'Sewers - Secret Room - Right': (0x11, 0x40),
'Sanctuary': (0x12, 0x10),
'Castle Tower - Room 03': (0xe0, 0x10),
'Castle Tower - Dark Maze': (0xd0, 0x10),
'Castle Tower - Dark Archer Key Drop': (0xc0, 0x400),
'Castle Tower - Circle of Pots Key Drop': (0xb0, 0x400),
'Spectacle Rock Cave': (0xea, 0x400),
'Paradox Cave Lower - Far Left': (0xef, 0x10),
'Paradox Cave Lower - Left': (0xef, 0x20),
@ -251,18 +265,25 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Mimic Cave': (0x10c, 0x10),
'Swamp Palace - Entrance': (0x28, 0x10),
'Swamp Palace - Map Chest': (0x37, 0x10),
'Swamp Palace - Pot Row Pot Key': (0x38, 0x400),
'Swamp Palace - Trench 1 Pot Key': (0x37, 0x400),
'Swamp Palace - Hookshot Pot Key': (0x36, 0x400),
'Swamp Palace - Big Chest': (0x36, 0x10),
'Swamp Palace - Compass Chest': (0x46, 0x10),
'Swamp Palace - Trench 2 Pot Key': (0x35, 0x400),
'Swamp Palace - Big Key Chest': (0x35, 0x10),
'Swamp Palace - West Chest': (0x34, 0x10),
'Swamp Palace - Flooded Room - Left': (0x76, 0x10),
'Swamp Palace - Flooded Room - Right': (0x76, 0x20),
'Swamp Palace - Waterfall Room': (0x66, 0x10),
'Swamp Palace - Waterway Pot Key': (0x16, 0x400),
'Swamp Palace - Boss': (0x6, 0x800),
"Thieves' Town - Big Key Chest": (0xdb, 0x20),
"Thieves' Town - Map Chest": (0xdb, 0x10),
"Thieves' Town - Compass Chest": (0xdc, 0x10),
"Thieves' Town - Ambush Chest": (0xcb, 0x10),
"Thieves' Town - Hallway Pot Key": (0xbc, 0x400),
"Thieves' Town - Spike Switch Pot Key": (0xab, 0x400),
"Thieves' Town - Attic": (0x65, 0x10),
"Thieves' Town - Big Chest": (0x44, 0x10),
"Thieves' Town - Blind's Cell": (0x45, 0x10),
@ -273,28 +294,39 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Skull Woods - Pot Prison': (0x57, 0x20),
'Skull Woods - Pinball Room': (0x68, 0x10),
'Skull Woods - Big Key Chest': (0x57, 0x10),
'Skull Woods - West Lobby Pot Key': (0x56, 0x400),
'Skull Woods - Bridge Room': (0x59, 0x10),
'Skull Woods - Spike Corner Key Drop': (0x39, 0x400),
'Skull Woods - Boss': (0x29, 0x800),
'Ice Palace - Jelly Key Drop': (0x0e, 0x400),
'Ice Palace - Compass Chest': (0x2e, 0x10),
'Ice Palace - Conveyor Key Drop': (0x3e, 0x400),
'Ice Palace - Freezor Chest': (0x7e, 0x10),
'Ice Palace - Big Chest': (0x9e, 0x10),
'Ice Palace - Iced T Room': (0xae, 0x10),
'Ice Palace - Many Pots Pot Key': (0x9f, 0x400),
'Ice Palace - Spike Room': (0x5f, 0x10),
'Ice Palace - Big Key Chest': (0x1f, 0x10),
'Ice Palace - Hammer Block Key Drop': (0x3f, 0x400),
'Ice Palace - Map Chest': (0x3f, 0x10),
'Ice Palace - Boss': (0xde, 0x800),
'Misery Mire - Big Chest': (0xc3, 0x10),
'Misery Mire - Map Chest': (0xc3, 0x20),
'Misery Mire - Main Lobby': (0xc2, 0x10),
'Misery Mire - Bridge Chest': (0xa2, 0x10),
'Misery Mire - Spikes Pot Key': (0xb3, 0x400),
'Misery Mire - Spike Chest': (0xb3, 0x10),
'Misery Mire - Fishbone Pot Key': (0xa1, 0x400),
'Misery Mire - Conveyor Crystal Key Drop': (0xc1, 0x400),
'Misery Mire - Compass Chest': (0xc1, 0x10),
'Misery Mire - Big Key Chest': (0xd1, 0x10),
'Misery Mire - Boss': (0x90, 0x800),
'Turtle Rock - Compass Chest': (0xd6, 0x10),
'Turtle Rock - Roller Room - Left': (0xb7, 0x10),
'Turtle Rock - Roller Room - Right': (0xb7, 0x20),
'Turtle Rock - Pokey 1 Key Drop': (0xb6, 0x400),
'Turtle Rock - Chain Chomps': (0xb6, 0x10),
'Turtle Rock - Pokey 2 Key Drop': (0x13, 0x400),
'Turtle Rock - Big Key Chest': (0x14, 0x10),
'Turtle Rock - Big Chest': (0x24, 0x10),
'Turtle Rock - Crystaroller Room': (0x4, 0x10),
@ -317,6 +349,7 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Palace of Darkness - Big Chest': (0x1a, 0x10),
'Palace of Darkness - Harmless Hellway': (0x1a, 0x40),
'Palace of Darkness - Boss': (0x5a, 0x800),
'Ganons Tower - Conveyor Cross Pot Key': (0x8b, 0x400),
"Ganons Tower - Bob's Torch": (0x8c, 0x400),
'Ganons Tower - Hope Room - Left': (0x8c, 0x20),
'Ganons Tower - Hope Room - Right': (0x8c, 0x40),
@ -325,11 +358,13 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Ganons Tower - Compass Room - Top Right': (0x9d, 0x20),
'Ganons Tower - Compass Room - Bottom Left': (0x9d, 0x40),
'Ganons Tower - Compass Room - Bottom Right': (0x9d, 0x80),
'Ganons Tower - Conveyor Star Pits Pot Key': (0x7b, 0x400),
'Ganons Tower - DMs Room - Top Left': (0x7b, 0x10),
'Ganons Tower - DMs Room - Top Right': (0x7b, 0x20),
'Ganons Tower - DMs Room - Bottom Left': (0x7b, 0x40),
'Ganons Tower - DMs Room - Bottom Right': (0x7b, 0x80),
'Ganons Tower - Map Chest': (0x8b, 0x10),
'Ganons Tower - Double Switch Pot Key': (0x9b, 0x400),
'Ganons Tower - Firesnake Room': (0x7d, 0x10),
'Ganons Tower - Randomizer Room - Top Left': (0x7c, 0x10),
'Ganons Tower - Randomizer Room - Top Right': (0x7c, 0x20),
@ -342,6 +377,7 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Ganons Tower - Big Key Chest': (0x1c, 0x10),
'Ganons Tower - Mini Helmasaur Room - Left': (0x3d, 0x10),
'Ganons Tower - Mini Helmasaur Room - Right': (0x3d, 0x20),
'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400),
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
location_table_npc = {'Mushroom': 0x1000,
@ -487,8 +523,8 @@ 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")
if 'sd2snes' in device.lower() or 'COM' in device:
ctx.ui_node.log_info("SD2SNES/FXPAK Detected")
ctx.is_sd2snes = True
await ctx.snes_socket.send(dumps({"Opcode" : "Info", "Space" : "SNES"}))
reply = loads(await ctx.snes_socket.recv())
@ -609,10 +645,7 @@ 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'
@ -634,8 +667,9 @@ async def snes_write(ctx : Context, write_list):
try:
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
if ctx.snes_socket is not None:
await ctx.snes_socket.send(cmd)
else:
logging.warning(f"Could not send data to SNES: {cmd}")
except websockets.ConnectionClosed:
return False
else:
@ -646,8 +680,9 @@ async def snes_write(ctx : Context, write_list):
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
if ctx.snes_socket is not None:
await ctx.snes_socket.send(data)
else:
logging.warning(f"Could not send data to SNES: {data}")
except websockets.ConnectionClosed:
return False
@ -657,7 +692,8 @@ async def snes_write(ctx : Context, write_list):
def snes_buffered_write(ctx : Context, address, data):
if len(ctx.snes_write_buffer) > 0 and (ctx.snes_write_buffer[-1][0] + len(ctx.snes_write_buffer[-1][1])) == address:
if ctx.snes_write_buffer and (ctx.snes_write_buffer[-1][0] + len(ctx.snes_write_buffer[-1][1])) == address:
# append to existing write command, bundling them
ctx.snes_write_buffer[-1] = (ctx.snes_write_buffer[-1][0], ctx.snes_write_buffer[-1][1] + data)
else:
ctx.snes_write_buffer.append((address, data))
@ -667,8 +703,9 @@ async def snes_flush_writes(ctx : Context):
if not ctx.snes_write_buffer:
return
await snes_write(ctx, ctx.snes_write_buffer)
ctx.snes_write_buffer = []
# swap buffers
ctx.snes_write_buffer, writes = [], ctx.snes_write_buffer
await snes_write(ctx, writes)
async def send_msgs(websocket, msgs):
@ -773,8 +810,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'])
@ -796,35 +833,47 @@ async def process_server_cmd(ctx: Context, cmd, args):
await server_auth(ctx, args['password'])
elif cmd == 'ConnectionRefused':
if 'InvalidPassword' in args:
ctx.ui_node.log_error('Invalid password')
ctx.password = None
await server_auth(ctx, True)
if 'InvalidRom' in args:
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
asyncio.create_task(ctx.snes_socket.close())
raise Exception(
'Invalid ROM detected, please verify that you have loaded the correct rom and reconnect your snes (/snes)')
if 'SlotAlreadyTaken' in args:
raise Exception('Invalid ROM detected, '
'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
elif 'SlotAlreadyTaken' in args:
Utils.persistent_store("servers", ctx.rom, ctx.server_address)
raise Exception('Player slot already in use for that team')
if 'IncompatibleVersion' in args:
elif 'IncompatibleVersion' in args:
raise Exception('Server reported your client version as incompatible')
raise Exception('Connection refused by the multiworld host')
#last to check, recoverable problem
elif 'InvalidPassword' in args:
ctx.ui_node.log_error('Invalid password')
ctx.password = None
await server_auth(ctx, True)
else:
raise Exception("Unknown connection errors: "+str(args))
raise Exception('Connection refused by the multiworld host, no reason provided')
elif cmd == 'Connected':
if ctx.send_unsafe:
ctx.send_unsafe = False
ctx.ui_node.log_info(f'Turning off sending of ALL location checks not declared as missing. If you want it on, please use /send_unsafe true')
Utils.persistent_store("servers", ctx.rom, ctx.server_address)
ctx.team, ctx.slot = args[0]
ctx.player_names = {p: n for p, n in args[1]}
msgs = []
if ctx.locations_checked:
msgs.append(['LocationChecks', [Regions.location_table[loc][0] for loc in ctx.locations_checked]])
msgs.append(['LocationChecks', [Regions.lookup_name_to_id[loc] for loc in ctx.locations_checked]])
if ctx.locations_scouted:
msgs.append(['LocationScouts', list(ctx.locations_scouted)])
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
await send_finished_game(ctx)
ctx.items_missing = args[2] if len(args) >= 3 else [] # Get the server side view of missing as of time of connecting.
# This list is used to only send to the server what is reported as ACTUALLY Missing.
# This also serves to allow an easy visual of what locations were already checked previously
# when /missing is used for the client side view of what is missing.
if not ctx.items_missing:
asyncio.create_task(ctx.send_msgs([['Say', '!missing']]))
elif cmd == 'ReceivedItems':
start_index, items = args
@ -833,7 +882,7 @@ async def process_server_cmd(ctx: Context, cmd, args):
elif start_index != len(ctx.items_received):
sync_msg = [['Sync']]
if ctx.locations_checked:
sync_msg.append(['LocationChecks', [Regions.location_table[loc][0] for loc in ctx.locations_checked]])
sync_msg.append(['LocationChecks', [Regions.lookup_name_to_id[loc] for loc in ctx.locations_checked]])
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received):
for item in items:
@ -999,13 +1048,34 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state"""
count = 0
checked_count = 0
for location in [k for k, v in Regions.location_table.items() if type(v[0]) is int]:
if location not in self.ctx.locations_checked:
self.output('Missing: ' + location)
if location not in self.ctx.items_missing:
self.output('Checked: ' + location)
checked_count += 1
else:
self.output('Missing: ' + location)
count += 1
key_drop_count = 0
for location in [k for k, v in Regions.key_drop_data.items()]:
if location not in self.ctx.items_missing:
key_drop_count += 1
# No point on reporting on missing key drop locations if the server doesn't declare ANY of them missing.
if key_drop_count != len(Regions.key_drop_data.items()):
for location in [k for k, v in Regions.key_drop_data.items()]:
if location not in self.ctx.locations_checked:
if location not in self.ctx.items_missing:
self.output('Checked: ' + location)
key_drop_count += 1
else:
self.output('Missing: ' + location)
count += 1
if count:
self.output(f"Found {count} missing location checks")
self.output(f"Found {count} missing location checks{f'. {checked_count} locations checks previously visited.' if checked_count else ''}")
else:
self.output("No missing location checks found.")
return True
@ -1035,6 +1105,15 @@ class ClientCommandProcessor(CommandProcessor):
else:
self.output("Web UI was never started.")
def _cmd_send_unsafe(self, toggle: str = ""):
"""Force sending of locations the server did not specify was actually missing. WARNING: This may brick online trackers. Turned off on reconnect."""
if toggle:
self.ctx.send_unsafe = toggle.lower() in {"1", "true", "on"}
self.ctx.ui_node.log_info(f'Turning {("on" if self.ctx.send_unsafe else "off")} the option to send ALL location checks to the multiserver.')
else:
self.ctx.ui_node.log_info("You must specify /send_unsafe true explicitly.")
self.ctx.send_unsafe = False
def default(self, raw: str):
asyncio.create_task(self.ctx.send_msgs([['Say', raw]]))
@ -1064,20 +1143,22 @@ async def track_locations(ctx : Context, roomid, roomdata):
new_locations = []
def new_check(location):
ctx.locations_checked.add(location)
ctx.ui_node.log_info("New check: %s (%d/216)" % (location, len(ctx.locations_checked)))
ctx.unsafe_locations_checked.add(location)
ctx.ui_node.log_info("New check: %s (%d/216)" % (location, len(ctx.unsafe_locations_checked)))
ctx.ui_node.send_location_check(ctx, location)
new_locations.append(Regions.location_table[location][0])
for location, (loc_roomid, loc_mask) in location_table_uw.items():
if location not in ctx.locations_checked and loc_roomid == roomid and (roomdata << 4) & loc_mask != 0:
new_check(location)
try:
if location not in ctx.unsafe_locations_checked and loc_roomid == roomid and (roomdata << 4) & loc_mask != 0:
new_check(location)
except Exception as e:
ctx.ui_node.log_info(f"Exception: {e}")
uw_begin = 0x129
uw_end = 0
uw_unchecked = {}
for location, (roomid, mask) in location_table_uw.items():
if location not in ctx.locations_checked:
if location not in ctx.unsafe_locations_checked:
uw_unchecked[location] = (roomid, mask)
uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1)
@ -1094,7 +1175,7 @@ async def track_locations(ctx : Context, roomid, roomdata):
ow_end = 0
ow_unchecked = {}
for location, screenid in location_table_ow.items():
if location not in ctx.locations_checked:
if location not in ctx.unsafe_locations_checked:
ow_unchecked[location] = screenid
ow_begin = min(ow_begin, screenid)
ow_end = max(ow_end, screenid + 1)
@ -1105,22 +1186,27 @@ async def track_locations(ctx : Context, roomid, roomdata):
if ow_data[screenid - ow_begin] & 0x40 != 0:
new_check(location)
if not all([location in ctx.locations_checked for location in location_table_npc.keys()]):
if not all([location in ctx.unsafe_locations_checked for location in location_table_npc.keys()]):
npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2)
if npc_data is not None:
npc_value = npc_data[0] | (npc_data[1] << 8)
for location, mask in location_table_npc.items():
if npc_value & mask != 0 and location not in ctx.locations_checked:
if npc_value & mask != 0 and location not in ctx.unsafe_locations_checked:
new_check(location)
if not all([location in ctx.locations_checked for location in location_table_misc.keys()]):
if not all([location in ctx.unsafe_locations_checked for location in location_table_misc.keys()]):
misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4)
if misc_data is not None:
for location, (offset, mask) in location_table_misc.items():
assert(0x3c6 <= offset <= 0x3c9)
if misc_data[offset - 0x3c6] & mask != 0 and location not in ctx.locations_checked:
if misc_data[offset - 0x3c6] & mask != 0 and location not in ctx.unsafe_locations_checked:
new_check(location)
for location in ctx.unsafe_locations_checked:
if (location in ctx.items_missing and location not in ctx.locations_checked) or ctx.send_unsafe:
ctx.locations_checked.add(location)
new_locations.append(Regions.lookup_name_to_id[location])
await ctx.send_msgs([['LocationChecks', new_locations]])
@ -1151,6 +1237,7 @@ async def game_watcher(ctx : Context):
ctx.rom = rom
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set()
ctx.unsafe_locations_checked = set()
ctx.locations_scouted = set()
ctx.prev_rom = ctx.rom

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,56 +48,64 @@ 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"]
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"]
zip_spoiler = multi_mystery_options["zip_spoiler"]
zip_multidata = multi_mystery_options["zip_multidata"]
zip_format = multi_mystery_options["zip_format"]
#zip_password = multi_mystery_options["zip_password"] not at this time
# 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
basemysterycommand = "BerserkerMystery.exe" # compiled windows
elif os.path.exists("BerserkerMultiServer"):
basemysterycommand = "BerserkerMystery" # compiled linux
basemysterycommand = "BerserkerMystery" # compiled linux
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}"
f"--outputpath \"{output_path}\" --teams {teams} --plando \"{plando_options}\""
if create_spoiler:
command += " --create_spoiler"
@ -107,13 +117,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,9 +148,10 @@ 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]
compression = {1: zipfile.ZIP_DEFLATED,
2: zipfile.ZIP_LZMA,
3: zipfile.ZIP_BZIP2}[zip_format]
typical_zip_ending = {1: "zip",
2: "7z",
@ -150,17 +163,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"AP_{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)
@ -209,10 +222,11 @@ if __name__ == "__main__":
baseservercommand = "BerserkerMultiServer" # compiled linux
else:
baseservercommand = f"py -{py_version} MultiServer.py" # source
#don't have a mac to test that. If you try to run compiled on mac, good luck.
# don't have a mac to test that. If you try to run compiled on mac, good luck.
subprocess.call(f"{baseservercommand} --multidata {os.path.join(output_path, multidataname)}")
except:
import traceback
traceback.print_exc()
input("Press enter to close")

View File

@ -30,7 +30,7 @@ from Utils import get_item_name_from_id, get_location_name_from_address, \
ReceivedItem, _version_tuple, restricted_loads
from NetUtils import Node, Endpoint
console_names = frozenset(set(Items.item_table) | set(Regions.location_table) | set(Items.item_name_groups))
console_names = frozenset(set(Items.item_table) | set(Regions.location_table) | set(Items.item_name_groups) | set(Regions.key_drop_data))
CLIENT_PLAYING = 0
CLIENT_GOAL = 1
@ -59,6 +59,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):
@ -104,6 +113,7 @@ class Context(Node):
self.auto_saver_thread = None
self.save_dirty = False
self.tags = ['AP']
self.minimum_client_versions: typing.Dict[typing.Tuple[int, int], Utils.Version] = {}
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
with open(multidatapath, 'rb') as f:
@ -113,6 +123,16 @@ class Context(Node):
self.data_filename = multidatapath
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
if "minimum_versions" in jsonobj:
mdata_ver = tuple(jsonobj["minimum_versions"]["server"])
if mdata_ver > Utils._version_tuple:
raise RuntimeError(f"Supplied Multidata requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils._version_tuple}")
clients_ver = jsonobj["minimum_versions"].get("clients", [])
self.minimum_client_versions = {}
for team, player, version in clients_ver:
self.minimum_client_versions[team, player] = Utils.Version(*version)
for team, names in enumerate(decoded_obj['names']):
for player, name in enumerate(names, 1):
self.player_names[(team, player)] = name
@ -127,15 +147,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:
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)
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)
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:
@ -435,6 +463,7 @@ def send_new_items(ctx: Context):
def forfeit_player(ctx: Context, team: int, slot: int):
all_locations = {values[0] for values in Regions.location_table.values() if type(values[0]) is int}
all_locations.update({values[1] for values in Regions.key_drop_data.values()})
ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
register_location_checks(ctx, team, slot, all_locations)
@ -450,10 +479,12 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
def register_location_checks(ctx: Context, team: int, slot: int, locations):
found_items = False
new_locations = set(locations) - ctx.location_checks[team, slot]
known_locations = set()
if new_locations:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
for location in new_locations:
if (location, slot) in ctx.locations:
known_locations.add(location)
target_item, target_player = ctx.locations[(location, slot)]
if target_player != slot or slot in ctx.remote_items:
found = False
@ -478,7 +509,7 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations):
if client.team == team and client.wants_item_notification:
asyncio.create_task(
ctx.send_msgs(client, [['ItemFound', (target_item, location, slot)]]))
ctx.location_checks[team, slot] |= new_locations
ctx.location_checks[team, slot] |= known_locations
send_new_items(ctx)
if found_items:
@ -510,7 +541,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[Utils.Hint]:
hints = []
seeked_location = Regions.location_table[location][0]
seeked_location = Regions.lookup_name_to_id[location]
for check, result in ctx.locations.items():
location_id, finding_player = check
if finding_player == slot and location_id == seeked_location:
@ -644,15 +675,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:
@ -665,7 +687,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:
@ -802,10 +824,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective"""
locations = []
for location_id, location_name in Regions.lookup_id_to_name.items(): # cheat console is -1, keep in mind
if location_id != -1 and location_id not in self.ctx.location_checks[self.client.team, self.client.slot]:
locations.append(location_name)
locations = get_missing_checks(self.ctx, self.client)
if len(locations) > 0:
texts = [f'Missing: {location}\n' for location in locations]
@ -930,6 +949,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(response)
return False
def get_missing_checks(ctx: Context, client: Client) -> list:
locations = []
#for location_id in [k[0] for k, v in ctx.locations if k[1] == client.slot]:
# if location_id not in ctx.location_checks[client.team, client.slot]:
# locations.append(Regions.lookup_id_to_name.get(location_id, f'Unknown Location ID: {location_id}'))
for location_id, location_name in Regions.lookup_id_to_name.items(): # cheat console is -1, keep in mind
if location_id != -1 and location_id not in ctx.location_checks[client.team, client.slot] and (location_id, client.slot) in ctx.locations:
locations.append(location_name)
return locations
def get_client_points(ctx: Context, client: Client) -> int:
return (ctx.location_check_points * len(ctx.location_checks[client.team, client.slot]) -
@ -972,9 +1000,14 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
client.name = ctx.player_names[(team, slot)]
client.team = team
client.slot = slot
minver = Utils.Version(*(ctx.minimum_client_versions.get((team, slot), (0,0,0))))
if minver > tuple(args.get('version', Client.version)):
errors.add('IncompatibleVersion')
if ctx.compatibility == 1 and "AP" not in args.get('tags', Client.tags):
errors.add('IncompatibleVersion')
elif ctx.compatibility == 0 and args.get('version', Client.version) != list(_version_tuple):
#only exact version match allowed
elif ctx.compatibility == 0 and tuple(args.get('version', Client.version)) != _version_tuple:
errors.add('IncompatibleVersion')
if errors:
logging.info(f"A client connection was refused due to: {errors}")
@ -986,7 +1019,7 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
client.tags = args.get('tags', Client.tags)
reply = [['Connected', [(client.team, client.slot),
[(p, ctx.get_aliased_name(t, p)) for (t, p), n in ctx.player_names.items() if
t == client.team]]]]
t == client.team], get_missing_checks(ctx, client)]]]
items = get_received_items(ctx, client.team, client.slot)
if items:
reply.append(['ReceivedItems', (0, tuplize_received_items(items))])
@ -1215,7 +1248,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):
@ -1229,7 +1262,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

@ -7,11 +7,13 @@ import typing
import os
import ModuleUpdate
from BaseClasses import PlandoItem, PlandoConnection
ModuleUpdate.update()
import Bosses
from Utils import parse_yaml
from worlds.alttp.Rom import get_sprite_from_name
from worlds.alttp.Rom import Sprite
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Main import main as ERmain
from worlds.alttp.Main import get_seed, seeddigits
@ -43,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
@ -57,12 +62,12 @@ def main(args=None, callback=ERmain):
seed = get_seed(args.seed)
random.seed(seed)
if args.race:
random.seed() # reset to time-based random source
seedname = "M" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
print(f"Generating mystery for {args.multi} player{'s' if args.multi > 1 else ''}, {seedname} Seed {seed}")
if args.race:
random.seed() # reset to time-based random source
weights_cache = {}
if args.weights:
try:
@ -143,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
@ -166,8 +172,9 @@ 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])
if settings.sprite and not os.path.isfile(settings.sprite) and not get_sprite_from_name(
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(
f"Warning: The chosen sprite, \"{settings.sprite}\", for yaml \"{path}\", does not exist.")
@ -238,6 +245,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]:
@ -245,7 +254,7 @@ def get_choice(option, root, value=None) -> typing.Any:
if any(root[option].values()):
return interpret_on_off(
random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0])
raise RuntimeError(f"All options specified in {option} are weighted as zero.")
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
def handle_name(name: str):
@ -258,7 +267,27 @@ def prefer_int(input_data: str) -> typing.Union[str, int]:
except:
return input_data
def roll_settings(weights):
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
boss_shuffle_options = {None: 'none',
'none': 'none',
'simple': 'basic',
'full': 'normal',
'random': 'chaos',
'singularity': 'singularity',
'duality': 'singularity'
}
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
@ -266,8 +295,18 @@ 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)
weights.update(option_set["options"])
if new_options:
for new_option in new_options:
logging.warning(f'Linked Suboption "{new_option}" of "{option_set["name"]}" did not '
f'overwrite a root option. '
f"This is probably in error.")
else:
logging.debug(f"linked option {option_set['name']} skipped.")
except Exception as e:
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
f"Please fix your linked option.") from e
@ -384,14 +423,24 @@ def roll_settings(weights):
ret.item_functionality = get_choice('item_functionality', weights)
ret.shufflebosses = {None: 'none',
'none': 'none',
'simple': 'basic',
'full': 'normal',
'random': 'chaos',
'singularity': 'singularity',
'duality': 'singularity'
}[get_choice('boss_shuffle', weights)]
boss_shuffle = get_choice('boss_shuffle', weights)
if boss_shuffle in boss_shuffle_options:
ret.shufflebosses = boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in boss_shuffle_options:
remainder_shuffle = boss
elif boss not in available_boss_names and not "-" in boss:
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
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',
@ -444,12 +493,22 @@ def roll_settings(weights):
'timed_countdown': 'timed-countdown',
'display': 'display'}[get_choice('timer', weights, False)]
ret.countdown_start_time = int(get_choice('countdown_start_time', weights, 10))
ret.red_clock_time = int(get_choice('red_clock_time', weights, -2))
ret.blue_clock_time = int(get_choice('blue_clock_time', weights, 2))
ret.green_clock_time = int(get_choice('green_clock_time', weights, 4))
ret.dungeon_counters = get_choice('dungeon_counters', weights, 'default')
ret.progressive = convert_to_on_off(get_choice('progressive', weights, 'on'))
ret.shuffle_prizes = get_choice('shuffle_prizes', weights, "g")
ret.required_medallions = (get_choice("misery_mire_medallion", weights, "random"),
get_choice("turtle_rock_medallion", weights, "random"))
for medallion in ret.required_medallions:
if medallion not in {"random", "Ether", "Bombos", "Quake"}:
raise Exception(f"unknown Medallion {medallion}")
inventoryweights = weights.get('startinventory', {})
startitems = []
for item in inventoryweights.keys():
@ -483,6 +542,46 @@ 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)
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))
ret.plando_texts = {}
if "texts" in plando_options:
options = weights.get("plando_texts", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_texts[str(get_choice("at", placement))] = str(get_choice("text", placement))
ret.plando_connections = []
if "connections" in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
if 'rom' in weights:
romweights = weights['rom']
@ -516,6 +615,11 @@ def roll_settings(weights):
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', romweights, "normal"))
ret.ow_palettes = get_choice('ow_palettes', romweights, "default")
ret.uw_palettes = get_choice('uw_palettes', romweights, "default")
ret.hud_palettes = get_choice('hud_palettes', romweights, "default")
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"

128
Utils.py
View File

@ -1,12 +1,18 @@
from __future__ import annotations
import typing
def tuplize_version(version: str) -> typing.Tuple[int, ...]:
return tuple(int(piece, 10) for piece in version.split("."))
return Version(*(int(piece, 10) for piece in version.split(".")))
__version__ = "1.0.0"
class Version(typing.NamedTuple):
major: int
minor: int
micro: int
__version__ = "0.1.0"
_version_tuple = tuplize_version(__version__)
import os
@ -37,11 +43,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):
@ -82,6 +88,7 @@ def local_path(*path):
return os.path.join(local_path.cached_path, *path)
local_path.cached_path = None
@ -93,8 +100,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)
@ -102,9 +111,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()
@ -138,6 +148,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
@ -153,6 +164,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
@ -165,6 +177,91 @@ 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"):
# Refer to host.yaml for comments as to what all these options mean.
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",
}
}
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 +270,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 +321,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
@ -255,7 +358,6 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
return romfile, False
class ReceivedItem(typing.NamedTuple):
item: int
location: int

View File

@ -4,6 +4,7 @@ So unless you're Berserker you need to include license information."""
import os
import uuid
import base64
import socket
from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
@ -30,8 +31,8 @@ app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024 # 4 megabyte limit
# if you want persistent sessions on your server, make sure you make this a constant in your config.yaml
app.config["SECRET_KEY"] = os.urandom(32)
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
app.config["JOB_THRESHOLD"] = 2
app.config['SESSION_PERMANENT'] = True
@ -47,6 +48,8 @@ app.config["PONY"] = {
}
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "simple"
app.config["JSON_AS_ASCII"] = False
app.autoversion = True
app.config["HOSTNAME"] = "berserkermulti.world"
@ -85,16 +88,21 @@ def tutorial(lang='en'):
@app.route('/player-settings')
def player_settings_simple():
return render_template("playerSettings.html")
@app.route('/weighted-settings')
def player_settings():
return render_template("player-settings.html")
return render_template("weightedSettings.html")
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
def viewSeed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("view_seed.html", seed=seed,
return render_template("viewSeed.html", seed=seed,
rooms=[room for room in seed.rooms if room.owner == session["_id"]])
@ -105,7 +113,7 @@ def new_room(seed: UUID):
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
return redirect(url_for("hostRoom", room=room.id))
def _read_log(path: str):
@ -124,7 +132,7 @@ def display_log(room: UUID):
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
def hostRoom(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
@ -137,7 +145,7 @@ def host_room(room: UUID):
with db_session:
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
return render_template("host_room.html", room=room)
return render_template("hostRoom.html", room=room)
@app.route('/favicon.ico')
@ -147,4 +155,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,24 @@
"""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, user # trigger registration
# 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("api.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

33
WebHostLib/api/user.py Normal file
View File

@ -0,0 +1,33 @@
from flask import session, jsonify
from WebHostLib.models import *
from . import api_endpoints
@api_endpoints.route('/get_rooms')
def get_rooms():
response = []
for room in select(room for room in Room if room.owner == session["_id"]):
response.append({
"room_id": room.id,
"seed_id": room.seed.id,
"creation_time": room.creation_time,
"last_activity": room.last_activity,
"last_port": room.last_port,
"timeout": room.timeout,
"tracker": room.tracker,
"players": room.seed.multidata["names"] if room.seed.multidata else [["Singleplayer"]],
})
return jsonify(response)
@api_endpoints.route('/get_seeds')
def get_seeds():
response = []
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
response.append({
"seed_id": seed.id,
"creation_time": seed.creation_time,
"players": seed.multidata["names"] if seed.multidata else [["Singleplayer"]],
})
return jsonify(response)

View File

@ -28,8 +28,8 @@ def mysterycheck():
if type(options) == str:
flash(options)
else:
results, _ = roll_yamls(options)
return render_template("checkresult.html", results=results)
results, _ = roll_options(options)
return render_template("checkResult.html", results=results)
return render_template("check.html")
@ -60,17 +60,20 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
return options
def roll_yamls(options: Dict[str, Union[str, str]]) -> Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
results = {}
rolled_results = {}
for filename, text in options.items():
try:
yaml_data = parse_yaml(text)
if type(text) is dict:
yaml_data = text
else:
yaml_data = parse_yaml(text)
except Exception as e:
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

@ -48,7 +48,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 = ["AP", "WebHost"]

View File

@ -11,7 +11,7 @@ import pickle
from .models import *
from WebHostLib import app
from .check import get_yaml_data, roll_yamls
from .check import get_yaml_data, roll_options
@app.route('/generate', methods=['GET', 'POST'])
@ -27,9 +27,9 @@ def generate(race=False):
if type(options) == str:
flash(options)
else:
results, gen_options = roll_yamls(options)
results, gen_options = roll_options(options)
if any(type(result) == str for result in results.values()):
return render_template("checkresult.html", results=results)
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players for now. "
f"If you have a larger group, please generate it yourself and upload it.")
@ -43,9 +43,15 @@ def generate(race=False):
return redirect(url_for("wait_seed", seed=gen.id))
else:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
race=race, owner=session["_id"].int)
return redirect(url_for("view_seed", seed=seed_id))
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
race=race, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": "+ str(e)))
return redirect(url_for("viewSeed", seed=seed_id))
return render_template("generate.html", race=race)
@ -90,13 +96,14 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
del (erargs.progression_balancing)
ERmain(erargs, seed)
return upload_to_db(target.name, owner, sid)
except BaseException:
return upload_to_db(target.name, owner, sid, race)
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
@ -105,19 +112,20 @@ def wait_seed(seed: UUID):
seed_id = seed
seed = Seed.get(id=seed_id)
if seed:
return redirect(url_for("view_seed", seed=seed_id))
return redirect(url_for("viewSeed", seed=seed_id))
generation = Generation.get(id=seed_id)
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
return "Generation failed, please retry."
return render_template("wait_seed.html", seed_id=seed_id)
return render_template("seedError.html", seed_error=generation.meta.decode())
return render_template("waitSeed.html", seed_id=seed_id)
def upload_to_db(folder, owner, sid):
def upload_to_db(folder, owner, sid, race:bool):
patches = set()
spoiler = ""
multidata = None
for file in os.listdir(folder):
file = os.path.join(folder, file)
@ -129,7 +137,7 @@ def upload_to_db(folder, owner, sid):
player_id=player_id, player_name = player_name))
elif file.endswith(".txt"):
spoiler = open(file, "rt", encoding="utf-8-sig").read()
elif file.endswith(".multidata"):
elif file.endswith(".archipelago"):
multidata = open(file, "rb").read()
if multidata:
with db_session:

View File

@ -52,5 +52,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.5.0
Flask-Compress>=1.8.0
Flask-Limiter>=1.4

View File

@ -0,0 +1,9 @@
window.addEventListener('load', () => {
let tables = $(".autodatatable").DataTable({
"paging": false,
"ordering": true,
"info": false,
"dom": "t",
});
console.log(tables);
});

View File

@ -0,0 +1,9 @@
window.addEventListener('load', () => {
document.getElementById('check-button').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('check-form').submit();
});
});

View File

@ -4,14 +4,11 @@ window.addEventListener('load', () => {
const cookieNotice = document.createElement('div');
cookieNotice.innerText = "This website uses cookies to store information about the games you play.";
cookieNotice.style.position = "fixed";
cookieNotice.style.bottom = "0";
cookieNotice.style.left = "0";
cookieNotice.style.width = "100%";
cookieNotice.style.lineHeight = "40px";
cookieNotice.style.backgroundColor = "#c7cda5";
cookieNotice.style.textAlign = "center";
cookieNotice.style.cursor = "pointer";
cookieNotice.setAttribute('id', 'cookie-notice');
const closeButton = document.createElement('span');
closeButton.setAttribute('id', 'close-button');
closeButton.innerText = 'X';
cookieNotice.appendChild(closeButton);
document.body.appendChild(cookieNotice);
cookieNotice.addEventListener('click', () => {
localStorage.setItem('cookieNotice', "1");

View File

@ -1,9 +1,9 @@
window.addEventListener('load', () => {
document.getElementById('upload-button').addEventListener('click', () => {
document.getElementById('generate-game-button').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('upload-form').submit();
document.getElementById('generate-game-form').submit();
});
});

View File

@ -0,0 +1,11 @@
window.addEventListener('load', () => {
document.getElementById('host-game-button').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('host-game-form').submit();
});
adjustFooterHeight();
});

View File

@ -0,0 +1,188 @@
window.addEventListener('load', () => {
Promise.all([fetchSettingData(), fetchSpriteData()]).then((results) => {
// Page setup
createDefaultSettings(results[0]);
buildUI(results[0]);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true))
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerSettings = JSON.parse(localStorage.getItem('playerSettings'));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateSetting(event));
nameInput.value = playerSettings.name;
// Sprite options
const spriteData = JSON.parse(results[1]);
const spriteSelect = document.getElementById('sprite');
spriteData.sprites.forEach((sprite) => {
if (sprite.name.trim().length === 0) { return; }
const option = document.createElement('option');
option.setAttribute('value', sprite.name.trim());
if (playerSettings.rom.sprite === sprite.name.trim()) { option.selected = true; }
option.innerText = sprite.name;
spriteSelect.appendChild(option);
});
}).catch((error) => {
console.error(error);
})
});
const fetchSettingData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/static/playerSettings.json`, true);
ajax.send();
});
const createDefaultSettings = (settingData) => {
if (!localStorage.getItem('playerSettings')) {
const newSettings = {};
for (let roSetting of Object.keys(settingData.readOnly)){
newSettings[roSetting] = settingData.readOnly[roSetting];
}
for (let generalOption of Object.keys(settingData.generalOptions)){
newSettings[generalOption] = settingData.generalOptions[generalOption];
}
for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameOption] = settingData.gameOptions[gameOption].defaultValue;
}
newSettings.rom = {};
for (let romOption of Object.keys(settingData.romOptions)){
newSettings.rom[romOption] = settingData.romOptions[romOption].defaultValue;
}
localStorage.setItem('playerSettings', JSON.stringify(newSettings));
}
};
const buildUI = (settingData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(settingData.gameOptions).forEach((key, index) => {
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
else { rightGameOpts[key] = settingData.gameOptions[key]; }
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
// ROM Options
const leftRomOpts = {};
const rightRomOpts = {};
Object.keys(settingData.romOptions).forEach((key, index) => {
if (index < Object.keys(settingData.romOptions).length / 2) { leftRomOpts[key] = settingData.romOptions[key]; }
else { rightRomOpts[key] = settingData.romOptions[key]; }
});
document.getElementById('rom-options-left').appendChild(buildOptionsTable(leftRomOpts, true));
document.getElementById('rom-options-right').appendChild(buildOptionsTable(rightRomOpts, true));
};
const buildOptionsTable = (settings, romOpts = false) => {
const currentSettings = JSON.parse(localStorage.getItem('playerSettings'));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(settings).forEach((setting) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.setAttribute('for', setting);
label.setAttribute('data-tooltip', settings[setting].description);
label.innerText = `${settings[setting].friendlyName}:`;
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
const select = document.createElement('select');
select.setAttribute('id', setting);
select.setAttribute('data-key', setting);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
settings[setting].options.forEach((opt) => {
const option = document.createElement('option');
option.setAttribute('value', opt.value);
option.innerText = opt.name;
if ((isNaN(currentSettings[setting]) && (parseInt(opt.value, 10) === parseInt(currentSettings[setting]))) ||
(opt.value === currentSettings[setting])) {
option.selected = true;
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateSetting(event));
tdr.appendChild(select);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const updateSetting = (event) => {
const options = JSON.parse(localStorage.getItem('playerSettings'));
if (event.target.getAttribute('data-romOpt')) {
options.rom[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
} else {
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
}
localStorage.setItem('playerSettings', JSON.stringify(options));
};
const exportSettings = () => {
const settings = JSON.parse(localStorage.getItem('playerSettings'));
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
axios.post('/api/generate', {
weights: { player: localStorage.getItem('playerSettings') },
presetData: { player: localStorage.getItem('playerSettings') },
playerCount: 1,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
});
};
const fetchSpriteData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject('Unable to fetch sprite data.');
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
ajax.send();
});

View File

@ -0,0 +1,47 @@
const adjustFooterHeight = () => {
// If there is no footer on this page, do nothing
const footer = document.getElementById('island-footer');
if (!footer) { return; }
// If the body is taller than the window, also do nothing
if (document.body.offsetHeight > window.innerHeight) {
footer.style.marginTop = '0';
return;
}
// Add a margin-top to the footer to position it at the bottom of the screen
const sibling = footer.previousElementSibling;
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
if (margin < 1) {
footer.style.marginTop = '0';
return;
}
footer.style.marginTop = `${margin}px`;
};
const adjustHeaderWidth = () => {
// If there is no header, do nothing
const header = document.getElementById('base-header');
if (!header) { return; }
const tempDiv = document.createElement('div');
tempDiv.style.width = '100px';
tempDiv.style.height = '100px';
tempDiv.style.overflow = 'scroll';
tempDiv.style.position = 'absolute';
tempDiv.style.top = '-500px';
document.body.appendChild(tempDiv);
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
document.body.removeChild(tempDiv);
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
};
window.addEventListener('load', () => {
window.addEventListener('resize', adjustFooterHeight);
window.addEventListener('resize', adjustHeaderWidth);
adjustFooterHeight();
adjustHeaderWidth();
});

View File

@ -44,6 +44,7 @@ window.addEventListener('load', () => {
// Populate page with HTML generated from markdown
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));

View File

@ -50,30 +50,33 @@ each player to enjoy an experience customized for their taste, and different pla
can all have different options.
### Where do I get a YAML file?
The [Player Settings](/player-settings) page on the website allows you to configure your personal settings
and download a `yaml` file. You may configure up to three presets on this page.
The [Generate Game](/player-settings) page on the website allows you to configure your personal settings and
export a YAML file from them.
### Your YAML file is weighted
The Player Settings page has many options which are primarily represented with sliders. This allows you to
choose how likely certain options are to occur relative to other options within a category.
### Advanced YAML configuration
A more advanced version of the YAML file can be created using the [Weighted Settings](/weighted-settings) page,
which allows you to configure up to three presets. The Weighted Settings page has many options which are
primarily represented with sliders. This allows you to choose how likely certain options are to occur relative
to other options within a category.
For example, imagine the generator creates a bucket labeled "Map Shuffle", and places folded pieces of paper
into the bucket for each sub-option. Also imagine your chosen value for "On" is 20 and your value for "Off" is 40.
into the bucket for each sub-option. Also imagine your chosen value for "On" is 20, and your value for "Off" is 40.
In this example, sixty pieces of paper are put into the bucket. Twenty for "On" and forty for "Off". When the
generator is deciding whether or not to turn on map shuffle for your game, it reaches into this bucket and pulls
out a piece of paper at random. In this example, you are much more likely to have map shuffle turned off.
If you never want an option to be chosen, simply set its value to zero.
If you never want an option to be chosen, simply set its value to zero. Remember that each setting must have at
lease one option set to a number greater than zero.
### Verifying your YAML file
If you would like to validate your YAML file to make sure it works, you may do so on the
[YAML Validator](/mysterycheck) page.
## Generating a Single-Player Game
1. Navigate to [the Generator Page](/generate) and upload your YAML file.
1. Navigate to the [Generate Game](/player-settings), configure your options, and click the "Generate Game" button.
2. You will be presented with a "Seed Info" page, where you can download your patch file.
3. Double-click on your patch file and the emulator should launch with your game automatically. As the
3. Double-click on your patch file, and the emulator should launch with your game automatically. As the
Client is unnecessary for single player games, you may close it and the WebUI.
## Joining a MultiWorld Game
@ -122,10 +125,6 @@ done so already, please do this now. SD2SNES and FXPak Pro users may download th
[here](https://github.com/RedGuyyyy/sd2snes/releases). Other hardware may find helpful information
[on this page](http://usb2snes.com/#supported-platforms).
**To connect with hardware you must use an old version of QUsb2Snes
([v0.7.16](https://github.com/Skarsnik/QUsb2snes/releases/tag/v0.7.16)).**
Versions of QUsb2Snes later than this break compatibility with hardware for multiworld.
1. Close your emulator, which may have auto-launched.
2. Close QUsb2Snes, which launched automatically with the client.
3. Launch the appropriate version of QUsb2Snes (v0.7.16).
@ -154,11 +153,30 @@ The recommended way to host a game is to use the hosting service provided on
3. Upload that zip file to the website linked above.
4. Wait a moment while the seed is generated.
5. When the seed is generated, you will be redirected to a "Seed Info" page.
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players
so they may download their patch files from here.
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
so they may download their patch files from there.
**Note:** The patch files provided on this page will allow players to automatically connect to the server,
while the patch files on the "Seed Info" page will not.
7. Note that a link to a MultiWorld Tracker is at the top of the room page. You should also provide this link
to your players so they can watch the progress of the game. Any observers may also be given the link to
to your players, so they can watch the progress of the game. Any observers may also be given the link to
this page.
8. Once all players have joined, you may begin playing.
## Auto-Tracking
If you would like to use auto-tracking for your game, several pieces of software provide this functionality.
The recommended software for auto-tracking is currently
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
### Installation
1. Download the appropriate installation file for your computer (Windows users want the `.msi` file).
2. During the installation process, you may be asked to install the Microsoft Visual Studio Build Tools. A link
to this software is provided during the installation procedure, and it must be installed manually.
### Enable auto-tracking
1. With OpenTracker launched, click the Tracking menu at the top of the window, then choose **AutoTracker...**
2. Click the **Get Devices** button
3. Select your SNES device from the drop-down list
4. If you would like to track small keys and dungeon items, check the box labeled **Race Illegal Tracking**
5. Click the **Start Autotracking** button
6. Close the AutoTracker window, as it is no longer necessary

View File

@ -43,11 +43,12 @@ Cada jugador en una partida de multiworld proveerá su propio fichero YAML. Esta
que cada jugador disfrute de una experiencia personalizada a su gusto, y cada jugador dentro de la misma partida de multiworld puede tener diferentes opciones.
### Donde puedo obtener un fichero YAML?
La página "[Player Settings](/player-settings)" en el sitio web te permite configurar tu configuración personal y
descargar un fichero "YAML". Puedes tener hasta 3 configuraciones guardadas en esta página.
La página "[Generate Game](/player-settings)" en el sitio web te permite configurar tu configuración personal y
descargar un fichero "YAML".
### Tu fichero YAML esta ponderado
La página "Player settings" tiene muchas opciones representadas con controles deslizantes. Esto permite
### Configuración YAML avanzada
Una version mas avanzada del fichero Yaml puede ser creada usando la pagina ["Weighted settings"](/weighted-settings),
la cual te permite tener almacenadas hasta 3 preajustes. La pagina "Weighted Settings" tiene muchas opciones representadas con controles deslizantes. Esto permite
elegir cuan probable los valores de una categoría pueden ser elegidos sobre otros de la misma.
Por ejemplo, imagina que el generador crea un cubo llamado "map_shuffle", y pone trozos de papel doblado en él por cada sub-opción.
@ -58,16 +59,17 @@ Cuando el generador esta decidiendo si activar o no "map shuffle" para tu partid
meterá la mano en el cubo y sacara un trozo de papel al azar. En este ejemplo,
es mucho mas probable (2 de cada 3 veces (40/60)) que "map shuffle" esté desactivado.
Si quieres que una opción no pueda ser escogida, simplemente asigna el valor 0 a dicha opción.
Si quieres que una opción no pueda ser escogida, simplemente asigna el valor 0 a dicha opción. Recuerda que cada opción debe tener
al menos un valor mayor que cero, si no la generación fallará.
### Verificando tu archivo YAML
Si quieres validar que tu fichero YAML para asegurarte que funciona correctamente, puedes hacerlo en la pagina
[YAML Validator](/mysterycheck).
## Generar una partida para un jugador
1. Navega a [la pagina Generator](/generate) y carga tu fichero YAML.
1. Navega a [la pagina Generate game](/player-settings), configura tus opciones, haz click en el boton "Generate game".
2. Se te redigirá a una pagina "Seed Info", donde puedes descargar tu archivo de parche.
3. Haz doble click en tu fichero de parche y el emulador debería ejecutar tu juego automáticamente. Como el
3. Haz doble click en tu fichero de parche, y el emulador debería ejecutar tu juego automáticamente. Como el
Cliente no es necesario para partidas de un jugador, puedes cerrarlo junto a la pagina web (que tiene como titulo "Multiworld WebUI") que se ha abierto automáticamente.
## Unirse a una partida MultiWorld
@ -113,11 +115,6 @@ Esta guía asume que ya has descargado el firmware correcto para tu dispositivo.
Los usuarios de SD2SNES y FXPak Pro pueden descargar el firmware apropiado
[aqui](https://github.com/RedGuyyyy/sd2snes/releases). Los usuarios de otros dispositivos pueden encontrar información
[en esta página](http://usb2snes.com/#supported-platforms).
**Para conectar con hardware debe usarse una version antigua de QUsb2Snes
([v0.7.16](https://github.com/Skarsnik/QUsb2snes/releases/tag/v0.7.16)).**
Las versiones mas actuales que esta son incompatibles con hardware para multiworld
1. Cierra tu emulador, el cual debe haberse autoejecutado.
2. Cierra QUsb2Snes, el cual fue ejecutado junto al cliente.
3. Ejecuta la version correcta de QUsb2Snes (v0.7.16).
@ -152,4 +149,22 @@ La manera recomendad para hospedar una partida es usar el servicio proveído en
mientras que los de la pagina "Seed info" no.
7. Hay un enlace a un MultiWorld Tracker en la parte superior de la pagina de la sala. Deberías pasar también este enlace
a los jugadores para que puedan ver el progreso de la partida. A los observadores también se les puede pasar este enlace.
8. Una vez todos los jugadores se han unido, podeis empezar a jugar.
8. Una vez todos los jugadores se han unido, podeis empezar a jugar.
## Auto-Tracking
Si deseas usar auto-tracking para tu partida, varios programas ofrecen esta funcionalidad.
El programa recomentdado actualmente es:
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
### Instalación
1. Descarga el fichero de instalacion apropiado para tu ordenador (Usuarios de windows quieren el fichero ".msi").
2. Durante el proceso de insatalación, puede que se te pida instalar Microsoft Visual Studio Build Tools. Un enlace
este programa se muestra durante la proceso, y debe ser ejecutado manualmente.
### Activar auto-tracking
1. Con OpenTracker ejecutado, haz click en el menu Tracking en la parte superior de la ventana, y elige **AutoTracker...**
2. Click the **Get Devices** button
3. Selecciona tu "SNES device" de la lista
4. Si quieres que las llaves y los objetos de mazmorra tambien sean marcados, activa la caja con nombre **Race Illegal Tracking**
5. Haz click en el boton **Start Autotracking**
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria

View File

@ -9,7 +9,7 @@
## Logiciels requis
- [Utilitaires du MultiWorld](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Inclus dans les utilitaires précédents)
- Une solution logicielle ou matérielle capable de charger et de jouer des fichiers ROM de SNES
- Une solution logicielle ou matérielle capable de charger et de lancer des fichiers ROM de SNES
- Un émulateur capable d'éxécuter des scripts Lua
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
[BizHawk](http://tasvideos.org/BizHawk.html))
@ -21,17 +21,17 @@
### Installation sur Windows
1. Téléchargez et installez les utilitaires du MultiWorld à l'aide du lien au-dessus, faites attention à bien installer la version la plus récente.
**Le fichier se situe dans la section "assets" en bas des informations de version**. Si vous voulez jouer des parties classiques de multiworld,
vous voudrez télécharger `Setup.BerserkerMultiWorld.exe`
- Si vous voulez jouer à la version alternative avec le mélangeur de portes dans les donjons, vous voudrez télécharger le fichier
téléchargez `Setup.BerserkerMultiWorld.exe`
- Si vous voulez jouer à la version alternative avec le mélangeur de portes dans les donjons, vous téléchargez le fichier
`Setup.BerserkerMultiWorld.Doors.exe`.
- Durant le processus d'installation, il vous sera demandé de localiser votre ROM v1.0 japonaise. Si vous avez déjà installé le logiciel
auparavant et qu'il s'agit simplement d'une mise à jour, la localisation de la ROM originale ne sera pas requise.
- Il vous sera peut-être également demandé d'installer Microsoft Visual C++. Si vous le possédez déjà (possiblement parce qu'un
jeu Steam l'a déjà installé), l'installateur ne reproposera pas de l'installer.
2. Si vous utilisez un émulateur, vous devriez assigner votre émulateur capable d'éxécuter des scripts Lua comme programme
2. Si vous utilisez un émulateur, il est recommandé d'assigner votre émulateur capable d'éxécuter des scripts Lua comme programme
par défaut pour ouvrir vos ROMs.
1. Extrayez votre dossier d'émulateur sur votre Bureau, ou quelque part dont vous vous souviendrez.
1. Extrayez votre dossier d'émulateur sur votre Bureau, ou à un endroit dont vous vous souviendrez.
2. Faites un clic droit sur un fichier ROM et sélectionnez **Ouvrir avec...**
3. Cochez la case à côté de **Toujours utiliser cette application pour ouvrir les fichiers .sfc**
4. Descendez jusqu'en bas de la liste et sélectionnez **Rechercher une autre application sur ce PC**
@ -49,14 +49,12 @@ sur comment il devrait générer votre seed. Chaque joueur d'un multiwolrd devra
joueur d'apprécier une expérience customisée selon ses goûts, et les différents joueurs d'un même multiworld peuvent avoir différentes options.
### Où est-ce que j'obtiens un fichier YAML ?
Un fichier YAML de base est disponible dans le dossier où les utilitaires du MultiWorld sont installés. Il est situé dans le dossier
`players` et se nomme `easy.yaml`
La page des [paramètres du joueur](/player-settings) vous permet de configurer vos paramètres personnels et de télécharger un fichier `yaml`.
Vous pouvez configurez jusqu'à trois pré-paramétrages sur cette page.
La page [Génération de partie](/player-settings) vous permet de configurer vos paramètres personnels et de les exporter vers un fichier YAML.
### Votre fichier YAML est pondéré
La page de paramétrage a de nombreuses options qui sont essentiellement représentées avec des curseurs glissants. Cela vous permet de choisir quelles
sont les chances qu'une certaine option apparaisse par rapport aux autres disponibles.
### Configuration avancée du fichier YAML
Une version plus avancée du fichier YAML peut être créée en utilisant la page des [paramètres de pondération](/weighted-settings), qui vous permet
de configurer jusqu'à trois préréglages. Cette page a de nombreuses options qui sont essentiellement représentées avec des curseurs glissants.
Cela vous permet de choisir quelles sont les chances qu'une certaine option apparaisse par rapport aux autres disponibles dans une même catégorie.
Par exemple, imaginez que le générateur crée un seau étiqueté "Mélange des cartes", et qu'il place un morceau de papier
pour chaque sous-option. Imaginez également que la valeur pour "On" est 20 et la valeur pour "Off" est 40.
@ -65,14 +63,15 @@ Dans cet exemple, il y a soixante morceaux de papier dans le seau : vingt pour "
décide s'il doit oui ou non activer le mélange des cartes pour votre partie, , il tire aléatoirement un papier dans le seau.
Dans cet exemple, il y a de plus grandes chances d'avoir le mélange de cartes désactivé.
S'il y a une option dont vous ne voulez jamais, mettez simplement sa valeur à zéro.
S'il y a une option dont vous ne voulez jamais, mettez simplement sa valeur à zéro. N'oubliez pas qu'il faut que pour chaque paramètre il faut
au moins une option qui soit paramétrée sur un nombre strictement positif.
### Vérifier son fichier YAML
Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous pouvez le vérifier sur la page du
[Validateur de YAML](/mysterycheck).
## Générer une partie pour un joueur
1. Aller sur la [page du générateur](/generate) et téléversez votre fichier YAML.
1. Aller sur la page [Génération de partie](/player-settings), configurez vos options, et cliquez sur le bouton "Generate Game".
2. Il vous sera alors présenté une page d'informations sur la seed, où vous pourrez télécharger votre patch.
3. Double-cliquez sur le patch et l'émulateur devrait se lancer automatiquement avec la seed. Etant donné que le client
n'est pas requis pour les parties à un joueur, vous pouvez le fermer ainsi que l'interface Web (WebUI).
@ -120,10 +119,6 @@ Les utilisateurs de SD2SNES et de FXPak Pro peuvent télécharger le micro-logic
[ici](https://github.com/RedGuyyyy/sd2snes/releases). Pour les autres solutions, de l'aide peut être trouvée
[sur cette page](http://usb2snes.com/#supported-platforms).
**Pour vous connecter avec une solution matérielle vous devez utiliser une ancienne version de QUsb2Snes
([v0.7.16](https://github.com/Skarsnik/QUsb2snes/releases/tag/v0.7.16)).**
Les versions postérieures brisent la compatibilité avec le multiworld.
1. Fermez votre émulateur, qui s'est potentiellement lancé automatiquement.
2. Fermez QUsb2Snes, qui s'est lancé automatiquement avec le client.
3. Lancez la version appropriée de QUsb2Snes (v0.7.16).
@ -149,13 +144,31 @@ La méthode recommandée pour héberger une partie est d'utiliser le service d'h
1. Récupérez les fichiers YAML des joueurs.
2. Créez une archive zip contenant ces fichiers YAML.
3. Téléversez l'archive zip sur le lien au-dessus.
3. Téléversez l'archive zip sur le lien ci-dessus.
4. Attendez un moment que les seed soient générées.
5. Lorsque les seeds sont générées, vous serez redirigé vers une page d'informations.
5. Lorsque les seeds sont générées, vous serez redirigé vers une page d'informations "Seed Info".
6. Cliquez sur "Create New Room". Cela vous amènera à la page du serveur. Fournissez le lien de cette page aux autres joueurs
afin qu'ils puissent récupérer leurs patchs.
**Note:** Les patchs fournis sur cette page permettront aux joueurs de se connecteur automatiquement au serveur,
tandis que ceux de la page "Seed Info" non.
7. Remarquez qu'un lien vers le traqueur du MultiWorld est en haut de la page de la salle. Vous devriez également fournir ce lien aux joueurs
pour qu'ils puissent la progression de la partie. N'importe quel personne voulant observer devrait avoir accès à ce lien.
8. Une fois que tous les joueurs ont rejoint, vous pouvez commencer à jouer.
pour qu'ils puissent suivre la progression de la partie. N'importe quel personne voulant observer devrait avoir accès à ce lien.
8. Une fois que tous les joueurs ont rejoint, vous pouvez commencer à jouer.
## Auto-tracking
Si vous voulez utiliser l'auto-tracking, plusieurs logiciels offrent cette possibilité.
Le logiciel recommandé pour l'auto-tracking actuellement est
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
### Installation
1. Téléchargez le fichier d'installation approprié pour votre ordinateur (Les utilisateurs Windows voudront le fichier `.msi`).
2. Durant le processus d'installation, il vous sera peut-être demandé d'installer les outils "Microsoft Visual Studio Build Tools". Un
lien est fourni durant l'installation d'OpenTracker, et celle des outils doit se faire manuellement.
### Activer l'auto-tracking
1. Une fois OpenTracker démarré, cliquez sur le menu "Tracking" en haut de la fenêtre, puis choisissez **AutoTracker...**
2. Appuyez sur le bouton **Get Devices**
3. Sélectionnez votre appareil SNES dans la liste déroulante.
4. Si vous voulez tracquer les petites clés ainsi que les objets des donjons, cochez la case **Race Illegal Tracking**
5. Cliquez sur le bouton **Start Autotracking**
6. Fermez la fenêtre "AutoTracker" maintenant, elle n'est plus nécessaire

View File

@ -1,17 +0,0 @@
window.addEventListener('load', () => {
document.getElementById('upload-button').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('upload-form').submit();
});
$("#uploads-table").DataTable({
"paging": false,
"ordering": true,
"order": [[ 3, "desc" ]],
"info": false,
"dom": "t",
});
});

View File

@ -0,0 +1,17 @@
window.addEventListener('load', () => {
console.log("loaded");
$("#rooms-table").DataTable({
"paging": false,
"ordering": true,
"order": [[ 3, "desc" ]],
"info": false,
"dom": "t",
});
$("#seeds-table").DataTable({
"paging": false,
"ordering": true,
"order": [[ 2, "desc" ]],
"info": false,
"dom": "t",
});
});

View File

@ -1,23 +1,20 @@
let spriteData = null;
window.addEventListener('load', () => {
const gameSettings = document.getElementById('game-settings');
const gameSettings = document.getElementById('weighted-settings');
Promise.all([fetchPlayerSettingsYaml(), fetchPlayerSettingsJson(), fetchSpriteData()]).then((results) => {
// Load YAML into object
const sourceData = jsyaml.safeLoad(results[0], { json: true });
// Update localStorage with three settings objects. Preserve original objects if present.
for (let i=1; i<=3; i++) {
const localSettings = JSON.parse(localStorage.getItem(`playerSettings${i}`));
const localSettings = JSON.parse(localStorage.getItem(`weightedSettings${i}`));
const updatedObj = localSettings ? Object.assign(sourceData, localSettings) : sourceData;
localStorage.setItem(`playerSettings${i}`, JSON.stringify(updatedObj));
localStorage.setItem(`weightedSettings${i}`, JSON.stringify(updatedObj));
}
// Parse spriteData into useful sets
spriteData = JSON.parse(results[2]);
// Build the entire UI
buildUI(JSON.parse(results[1]));
buildUI(JSON.parse(results[1]), JSON.parse(results[2]));
// Populate the UI and add event listeners
populateSettings();
@ -27,13 +24,17 @@ window.addEventListener('load', () => {
document.getElementById('export-button').addEventListener('click', exportSettings);
document.getElementById('reset-to-default').addEventListener('click', resetToDefaults);
adjustHeaderWidth();
}).catch((error) => {
console.error(error);
gameSettings.innerHTML = `
<h2>Something went wrong while loading your game settings page.</h2>
<h2>${error}</h2>
<h2><a href="${window.location.origin}">Click here to return to safety!</a></h2>
`
});
document.getElementById('generate-game').addEventListener('click', () => generateGame());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
});
const fetchPlayerSettingsYaml = () => new Promise((resolve, reject) => {
@ -46,7 +47,7 @@ const fetchPlayerSettingsYaml = () => new Promise((resolve, reject) => {
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/static/playerSettings.yaml` ,true);
ajax.open('GET', `${window.location.origin}/static/static/weightedSettings.yaml` ,true);
ajax.send();
});
@ -60,7 +61,7 @@ const fetchPlayerSettingsJson = () => new Promise((resolve, reject) => {
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/static/playerSettings.json`, true);
ajax.open('GET', `${window.location.origin}/static/static/weightedSettings.json`, true);
ajax.send();
});
@ -81,7 +82,7 @@ const fetchSpriteData = () => new Promise((resolve, reject) => {
const handleOptionChange = (event) => {
if(!event.target.matches('.setting')) { return; }
const presetNumber = document.getElementById('preset-number').value;
const settings = JSON.parse(localStorage.getItem(`playerSettings${presetNumber}`))
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`))
const settingString = event.target.getAttribute('data-setting');
document.getElementById(settingString).innerText = event.target.value;
if(getSettingValue(settings, settingString) !== false){
@ -105,7 +106,7 @@ const handleOptionChange = (event) => {
}
// Save the updated settings object bask to localStorage
localStorage.setItem(`playerSettings${presetNumber}`, JSON.stringify(settings));
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(settings));
}else{
console.warn(`Unknown setting string received: ${settingString}`)
}
@ -113,7 +114,7 @@ const handleOptionChange = (event) => {
const populateSettings = () => {
const presetNumber = document.getElementById('preset-number').value;
const settings = JSON.parse(localStorage.getItem(`playerSettings${presetNumber}`))
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`))
const settingsInputs = Array.from(document.querySelectorAll('.setting'));
settingsInputs.forEach((input) => {
const settingString = input.getAttribute('data-setting');
@ -146,13 +147,13 @@ const getSettingValue = (settings, keyString) => {
const exportSettings = () => {
const presetNumber = document.getElementById('preset-number').value;
const settings = JSON.parse(localStorage.getItem(`playerSettings${presetNumber}`));
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${settings.description}.yaml`, yamlText);
};
const resetToDefaults = () => {
[1, 2, 3].forEach((presetNumber) => localStorage.removeItem(`playerSettings${presetNumber}`));
[1, 2, 3].forEach((presetNumber) => localStorage.removeItem(`weightedSettings${presetNumber}`));
location.reload();
};
@ -167,7 +168,7 @@ const download = (filename, text) => {
document.body.removeChild(downloadLink);
};
const buildUI = (settings) => {
const buildUI = (settings, spriteData) => {
const settingsWrapper = document.getElementById('settings-wrapper');
const settingTypes = {
gameOptions: 'Game Options',
@ -175,7 +176,7 @@ const buildUI = (settings) => {
}
Object.keys(settingTypes).forEach((settingTypeKey) => {
const sectionHeader = document.createElement('h1');
const sectionHeader = document.createElement('h2');
sectionHeader.innerText = settingTypes[settingTypeKey];
settingsWrapper.appendChild(sectionHeader);
@ -200,7 +201,7 @@ const buildUI = (settings) => {
});
// Build sprite options
const spriteOptionsHeader = document.createElement('h1');
const spriteOptionsHeader = document.createElement('h2');
spriteOptionsHeader.innerText = 'Sprite Options';
settingsWrapper.appendChild(spriteOptionsHeader);
@ -224,7 +225,7 @@ const buildUI = (settings) => {
tbody.setAttribute('id', 'sprites-tbody');
const currentPreset = document.getElementById('preset-number').value;
const playerSettings = JSON.parse(localStorage.getItem(`playerSettings${currentPreset}`));
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${currentPreset}`));
// Manually add a row for random sprites
addSpriteRow(tbody, playerSettings, 'random');
@ -241,7 +242,7 @@ const buildUI = (settings) => {
settingsWrapper.appendChild(spriteOptionsWrapper);
// Append sprite picker
settingsWrapper.appendChild(buildSpritePicker());
settingsWrapper.appendChild(buildSpritePicker(spriteData));
};
const buildRangeSettings = (parentElement, settings) => {
@ -368,7 +369,7 @@ const addSpriteRow = (tbody, playerSettings, spriteName) => {
const addSpriteOption = (event) => {
const presetNumber = document.getElementById('preset-number').value;
const playerSettings = JSON.parse(localStorage.getItem(`playerSettings${presetNumber}`));
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
const spriteName = event.target.getAttribute('data-sprite');
console.log(event.target);
console.log(spriteName);
@ -380,7 +381,7 @@ const addSpriteOption = (event) => {
// Add option to playerSettings object
playerSettings.rom.sprite[event.target.getAttribute('data-sprite')] = 50;
localStorage.setItem(`playerSettings${presetNumber}`, JSON.stringify(playerSettings));
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(playerSettings));
// Add <tr> to #sprite-options-table
const tbody = document.getElementById('sprites-tbody');
@ -389,19 +390,19 @@ const addSpriteOption = (event) => {
const removeSpriteOption = (event) => {
const presetNumber = document.getElementById('preset-number').value;
const playerSettings = JSON.parse(localStorage.getItem(`playerSettings${presetNumber}`));
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
const spriteName = event.target.getAttribute('data-sprite');
// Remove option from playerSettings object
delete playerSettings.rom.sprite[spriteName];
localStorage.setItem(`playerSettings${presetNumber}`, JSON.stringify(playerSettings));
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(playerSettings));
// Remove <tr> from #sprite-options-table
const tr = document.getElementById(event.target.getAttribute('data-row-id'));
tr.parentNode.removeChild(tr);
};
const buildSpritePicker = () => {
const buildSpritePicker = (spriteData) => {
const spritePicker = document.createElement('div');
spritePicker.setAttribute('id', 'sprite-picker');
@ -412,18 +413,18 @@ const buildSpritePicker = () => {
const sprites = document.createElement('div');
sprites.setAttribute('id', 'sprite-picker-sprites');
Object.keys(spriteData).forEach((spriteName) => {
spriteData.sprites.forEach((sprite) => {
const spriteImg = document.createElement('img');
spriteImg.setAttribute('src', `static/static/sprites/${spriteName}.gif`);
spriteImg.setAttribute('data-sprite', spriteName);
spriteImg.setAttribute('alt', spriteName);
spriteImg.setAttribute('src', `static/static/sprites/${sprite.name}.gif`);
spriteImg.setAttribute('data-sprite', sprite.name);
spriteImg.setAttribute('alt', sprite.name);
// Wrap the image in a span to allow for tooltip presence
const imgWrapper = document.createElement('span');
imgWrapper.className = 'sprite-img-wrapper';
imgWrapper.setAttribute('data-tooltip', spriteName);
imgWrapper.setAttribute('data-tooltip', `${sprite.name}${sprite.author ? `, by ${sprite.author}` : ''}`);
imgWrapper.appendChild(spriteImg);
imgWrapper.setAttribute('data-sprite', spriteName);
imgWrapper.setAttribute('data-sprite', sprite.name);
sprites.appendChild(imgWrapper);
imgWrapper.addEventListener('click', addSpriteOption);
});
@ -431,3 +432,15 @@ const buildSpritePicker = () => {
spritePicker.appendChild(sprites);
return spritePicker;
};
const generateGame = (raceMode = false) => {
const presetNumber = document.getElementById('preset-number').value;
axios.post('/api/generate', {
weights: { player: localStorage.getItem(`weightedSettings${presetNumber}`) },
presetData: { player: localStorage.getItem(`weightedSettings${presetNumber}`) },
playerCount: 1,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
});
};

View File

@ -0,0 +1,4 @@
Copyright 2020 Berserker66 (Fabian Dill)
Copyright 2020 LegendaryLinux (Chris Wilson)
All rights reserved.

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

View File

Before

Width:  |  Height:  |  Size: 541 B

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

View File

Before

Width:  |  Height:  |  Size: 541 B

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

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