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
|
@ -3,11 +3,7 @@
|
|||
|
||||
name: unittests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
@ -30,3 +30,7 @@ weights/
|
|||
_persistent_storage.yaml
|
||||
mystery_result_*.yaml
|
||||
/db.db3
|
||||
*-errors.txt
|
||||
success.txt
|
||||
output/
|
||||
Output Logs/
|
||||
|
|
17
Adjuster.py
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
@ -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
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
5
LICENSE
|
@ -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.
|
||||
|
|
161
MultiClient.py
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
140
Mystery.py
|
@ -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
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
|
@ -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
|
|
@ -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)
|
|
@ -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:
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
window.addEventListener('load', () => {
|
||||
let tables = $(".autodatatable").DataTable({
|
||||
"paging": false,
|
||||
"ordering": true,
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
});
|
||||
console.log(tables);
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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");
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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();
|
||||
});
|
|
@ -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();
|
||||
});
|
|
@ -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'));
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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",
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
Copyright 2020 Berserker66 (Fabian Dill)
|
||||
Copyright 2020 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
After Width: | Height: | Size: 78 KiB |
After Width: | Height: | Size: 71 KiB |
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 57 KiB |
After Width: | Height: | Size: 64 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 9.5 KiB |
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 9.8 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 39 KiB |
After Width: | Height: | Size: 39 KiB |
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 31 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 250 KiB |
After Width: | Height: | Size: 210 KiB |
After Width: | Height: | Size: 292 KiB |
After Width: | Height: | Size: 162 KiB |
After Width: | Height: | Size: 161 KiB |
After Width: | Height: | Size: 163 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 541 B |
Before Width: | Height: | Size: 541 B After Width: | Height: | Size: 541 B |
After Width: | Height: | Size: 541 B |
After Width: | Height: | Size: 541 B |
After Width: | Height: | Size: 541 B |
After Width: | Height: | Size: 541 B |
Before Width: | Height: | Size: 541 B After Width: | Height: | Size: 541 B |
After Width: | Height: | Size: 541 B |
After Width: | Height: | Size: 541 B |
After Width: | Height: | Size: 541 B |
After Width: | Height: | Size: 541 B |