Merge branch 'master' into multiworld

This commit is contained in:
compiling 2020-07-10 17:39:34 +10:00 committed by GitHub
commit 1c6e4c11c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
122 changed files with 2511 additions and 1116 deletions

4
.gitignore vendored
View File

@ -20,5 +20,9 @@ weights/
/Players/
/QUsb2Snes/
/options.yaml
/config.yaml
/uploads/
/logs/
_persistent_storage.yaml
mystery_result_*.yaml
/db.db3

View File

@ -13,9 +13,6 @@ def adjust(args):
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)
if os.path.isfile(args.baserom):
baserom = LocalRom(args.baserom, patch=True)
rom.orig_buffer = baserom.orig_buffer
else:
raise RuntimeError(
'Provided Rom is not a valid Link to the Past Randomizer Rom. Please provide one for adjusting.')

View File

@ -102,7 +102,6 @@ class World(object):
set_player_attr('enemy_health', 'default')
set_player_attr('enemy_damage', 'default')
set_player_attr('beemizer', 0)
set_player_attr('progressive', 'on')
set_player_attr('escape_assist', [])
set_player_attr('crystals_needed_for_ganon', 7)
set_player_attr('crystals_needed_for_gt', 7)
@ -114,8 +113,13 @@ class World(object):
set_player_attr('glitch_boots', True)
set_player_attr('progression_balancing', True)
set_player_attr('local_items', set())
set_player_attr('triforce_pieces_available', 30)
set_player_attr('triforce_pieces_required', 20)
@property
def player_ids(self):
yield from range(1, self.players + 1)
def get_name_string_for_object(self, obj) -> str:
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_names(obj.player)})'
@ -250,10 +254,10 @@ class World(object):
elif self.difficulty_requirements[item.player].progressive_shield_limit >= 1:
ret.prog_items['Blue Shield', item.player] += 1
elif 'Bow' in item.name:
if ret.has('Silver Arrows', item.player):
if ret.has('Silver', item.player):
pass
elif ret.has('Bow', item.player) and self.difficulty_requirements[item.player].progressive_bow_limit >= 2:
ret.prog_items['Silver Arrows', item.player] += 1
ret.prog_items['Silver Bow', item.player] += 1
elif self.difficulty_requirements[item.player].progressive_bow_limit >= 1:
ret.prog_items['Bow', item.player] += 1
elif item.name.startswith('Bottle'):
@ -268,10 +272,19 @@ class World(object):
if keys:
for p in range(1, self.players + 1):
from Items import ItemFactory
for item in ItemFactory(['Small Key (Escape)', 'Big Key (Eastern Palace)', 'Big Key (Desert Palace)', 'Small Key (Desert Palace)', 'Big Key (Tower of Hera)', 'Small Key (Tower of Hera)', 'Small Key (Agahnims Tower)', 'Small Key (Agahnims Tower)',
'Big Key (Palace of Darkness)'] + ['Small Key (Palace of Darkness)'] * 6 + ['Big Key (Thieves Town)', 'Small Key (Thieves Town)', 'Big Key (Skull Woods)'] + ['Small Key (Skull Woods)'] * 3 + ['Big Key (Swamp Palace)',
'Small Key (Swamp Palace)', 'Big Key (Ice Palace)'] + ['Small Key (Ice Palace)'] * 2 + ['Big Key (Misery Mire)', 'Big Key (Turtle Rock)', 'Big Key (Ganons Tower)'] + ['Small Key (Misery Mire)'] * 3 + ['Small Key (Turtle Rock)'] * 4 + ['Small Key (Ganons Tower)'] * 4,
p):
for item in ItemFactory(
['Small Key (Hyrule Castle)', 'Big Key (Eastern Palace)', 'Big Key (Desert Palace)',
'Small Key (Desert Palace)', 'Big Key (Tower of Hera)', 'Small Key (Tower of Hera)',
'Small Key (Agahnims Tower)', 'Small Key (Agahnims Tower)',
'Big Key (Palace of Darkness)'] + ['Small Key (Palace of Darkness)'] * 6 + [
'Big Key (Thieves Town)', 'Small Key (Thieves Town)', 'Big Key (Skull Woods)'] + [
'Small Key (Skull Woods)'] * 3 + ['Big Key (Swamp Palace)',
'Small Key (Swamp Palace)', 'Big Key (Ice Palace)'] + [
'Small Key (Ice Palace)'] * 2 + ['Big Key (Misery Mire)', 'Big Key (Turtle Rock)',
'Big Key (Ganons Tower)'] + [
'Small Key (Misery Mire)'] * 3 + ['Small Key (Turtle Rock)'] * 4 + [
'Small Key (Ganons Tower)'] * 4,
p):
soft_collect(item)
ret.sweep_for_events()
return ret
@ -423,26 +436,23 @@ class CollectionState(object):
queue.extend(start.exits)
# run BFS on all connections, and keep track of those blocked by missing items
while True:
try:
connection = queue.popleft()
new_region = connection.connected_region
if new_region in rrp:
bc.remove(connection)
elif connection.can_reach(self):
rrp.add(new_region)
bc.remove(connection)
bc.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
while queue:
connection = queue.popleft()
new_region = connection.connected_region
if new_region in rrp:
bc.remove(connection)
elif connection.can_reach(self):
rrp.add(new_region)
bc.remove(connection)
bc.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
# Retry connections if the new region can unblock them
if new_region.name in indirect_connections:
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
if new_entrance in bc and new_entrance not in queue:
queue.append(new_entrance)
except IndexError:
break
# Retry connections if the new region can unblock them
if new_region.name in indirect_connections:
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
if new_entrance in bc and new_entrance not in queue:
queue.append(new_entrance)
def copy(self) -> CollectionState:
ret = CollectionState(self.world)
@ -455,7 +465,7 @@ class CollectionState(object):
ret.locations_checked = copy.copy(self.locations_checked)
return ret
def can_reach(self, spot, resolution_hint=None, player=None):
def can_reach(self, spot, resolution_hint=None, player=None) -> bool:
if not hasattr(spot, "spot_type"):
# try to resolve a name
if resolution_hint == 'Location':
@ -467,7 +477,7 @@ class CollectionState(object):
spot = self.world.get_region(spot, player)
return spot.can_reach(self)
def sweep_for_events(self, key_only=False, locations=None):
def sweep_for_events(self, key_only: bool = False, locations=None):
# this may need improvement
if locations is None:
locations = self.world.get_filled_locations()
@ -475,7 +485,9 @@ class CollectionState(object):
checked_locations = 0
while new_locations:
reachable_events = [location for location in locations if location.event and
(not key_only or (not self.world.keyshuffle[location.item.player] and location.item.smallkey) or (not self.world.bigkeyshuffle[location.item.player] and location.item.bigkey))
(not key_only or (not self.world.keyshuffle[
location.item.player] and location.item.smallkey) or (not self.world.bigkeyshuffle[
location.item.player] and location.item.bigkey))
and location.can_reach(self)]
for event in reachable_events:
if (event.name, event.player) not in self.events:
@ -484,12 +496,12 @@ class CollectionState(object):
new_locations = len(reachable_events) > checked_locations
checked_locations = len(reachable_events)
def has(self, item, player, count=1):
def has(self, item, player: int, count: int = 1):
if count == 1:
return (item, player) in self.prog_items
return self.prog_items[item, player] >= count
def has_key(self, item, player, count=1):
def has_key(self, item, player, count: int = 1):
if self.world.retro[player]:
return self.can_buy_unlimited('Small Key (Universal)', player)
if count == 1:
@ -505,6 +517,9 @@ class CollectionState(object):
def item_count(self, item, player: int) -> int:
return self.prog_items[item, player]
def has_triforce_pieces(self, count: int, player: int) -> bool:
return self.item_count('Triforce Piece', player) + self.item_count('Power Star', player) >= count
def has_crystals(self, count: int, player: int) -> bool:
crystals = ['Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7']
return len([crystal for crystal in crystals if self.has(crystal, player)]) >= count
@ -561,8 +576,8 @@ class CollectionState(object):
def can_shoot_arrows(self, player: int) -> bool:
if self.world.retro[player]:
# TODO: Progressive and Non-Progressive silvers work differently (progressive is not usable until the shop arrow is bought)
return self.has('Bow', player) and self.can_buy_unlimited('Single Arrow', player)
return self.has('Bow', player)
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy_unlimited('Single Arrow', player)
return self.has('Bow', player) or self.has('Silver Bow', player)
def can_get_good_bee(self, player: int) -> bool:
cave = self.world.get_region('Good Bee Cave', player)
@ -697,10 +712,10 @@ class CollectionState(object):
self.prog_items['Blue Shield', item.player] += 1
changed = True
elif 'Bow' in item.name:
if self.has('Silver Arrows', item.player):
if self.has('Silver Bow', item.player):
pass
elif self.has('Bow', item.player):
self.prog_items['Silver Arrows', item.player] += 1
self.prog_items['Silver Bow', item.player] += 1
changed = True
else:
self.prog_items['Bow', item.player] += 1
@ -751,8 +766,8 @@ class CollectionState(object):
else:
to_remove = 'None'
elif 'Bow' in item.name:
if self.has('Silver Arrows', item.player):
to_remove = 'Silver Arrows'
if self.has('Silver Bow', item.player):
to_remove = 'Silver Bow'
elif self.has('Bow', item.player):
to_remove = 'Bow'
else:
@ -817,7 +832,7 @@ class Region(object):
or (item.bigkey and not self.world.bigkeyshuffle[item.player])
or (item.map and not self.world.mapshuffle[item.player])
or (item.compass and not self.world.compassshuffle[item.player]))
sewer_hack = self.world.mode[item.player] == 'standard' and item.name == 'Small Key (Escape)'
sewer_hack = self.world.mode[item.player] == 'standard' and item.name == 'Small Key (Hyrule Castle)'
if sewer_hack or inside_dungeon_item:
return self.dungeon and self.dungeon.is_dungeon_item(item) and item.player == self.player
@ -937,11 +952,11 @@ class Location(object):
self.item_rule = lambda item: True
self.player = player
def can_fill(self, state, item, check_access=True) -> bool:
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return self.always_allow(state, item) or (self.parent_region.can_fill(item) and self.item_rule(item) and (
not check_access or self.can_reach(state)))
def can_reach(self, state) -> bool:
def can_reach(self, state: CollectionState) -> bool:
if self.parent_region.can_reach(state) and self.access_rule(state):
return True
return False
@ -1195,6 +1210,7 @@ class Spoiler(object):
'players': self.world.players,
'teams': self.world.teams,
'progression_balancing': self.world.progression_balancing,
'triforce_pieces_available': self.world.triforce_pieces_available,
'triforce_pieces_required': self.world.triforce_pieces_required,
}
@ -1241,6 +1257,8 @@ class Spoiler(object):
outfile.write('Swords: %s\n' % self.metadata['weapons'][player])
outfile.write('Goal: %s\n' % self.metadata['goal'][player])
if "triforce" in self.metadata["goal"][player]: # triforce hunt
outfile.write(
"Pieces available for Triforce: %s\n" % self.metadata['triforce_pieces_available'][player])
outfile.write(
"Pieces required for Triforce: %s\n" % self.metadata["triforce_pieces_required"][player])
outfile.write('Difficulty: %s\n' % self.metadata['item_pool'][player])

View File

@ -15,13 +15,17 @@ def create_dungeons(world, player):
dungeon.world = world
return dungeon
ES = make_dungeon('Hyrule Castle', None, ['Hyrule Castle', 'Sewers', 'Sewer Drop', 'Sewers (Dark)', 'Sanctuary'], None, [ItemFactory('Small Key (Escape)', player)], [ItemFactory('Map (Escape)', player)])
EP = make_dungeon('Eastern Palace', 'Armos Knights', ['Eastern Palace'], ItemFactory('Big Key (Eastern Palace)', player), [], ItemFactory(['Map (Eastern Palace)', 'Compass (Eastern Palace)'], player))
ES = make_dungeon('Hyrule Castle', None, ['Hyrule Castle', 'Sewers', 'Sewer Drop', 'Sewers (Dark)', 'Sanctuary'],
None, [ItemFactory('Small Key (Hyrule Castle)', player)],
[ItemFactory('Map (Hyrule Castle)', player)])
EP = make_dungeon('Eastern Palace', 'Armos Knights', ['Eastern Palace'],
ItemFactory('Big Key (Eastern Palace)', player), [],
ItemFactory(['Map (Eastern Palace)', 'Compass (Eastern Palace)'], player))
DP = make_dungeon('Desert Palace', 'Lanmolas', ['Desert Palace North', 'Desert Palace Main (Inner)', 'Desert Palace Main (Outer)', 'Desert Palace East'], ItemFactory('Big Key (Desert Palace)', player), [ItemFactory('Small Key (Desert Palace)', player)], ItemFactory(['Map (Desert Palace)', 'Compass (Desert Palace)'], player))
ToH = make_dungeon('Tower of Hera', 'Moldorm', ['Tower of Hera (Bottom)', 'Tower of Hera (Basement)', 'Tower of Hera (Top)'], ItemFactory('Big Key (Tower of Hera)', player), [ItemFactory('Small Key (Tower of Hera)', player)], ItemFactory(['Map (Tower of Hera)', 'Compass (Tower of Hera)'], player))
PoD = make_dungeon('Palace of Darkness', 'Helmasaur King', ['Palace of Darkness (Entrance)', 'Palace of Darkness (Center)', 'Palace of Darkness (Big Key Chest)', 'Palace of Darkness (Bonk Section)', 'Palace of Darkness (North)', 'Palace of Darkness (Maze)', 'Palace of Darkness (Harmless Hellway)', 'Palace of Darkness (Final Section)'], ItemFactory('Big Key (Palace of Darkness)', player), ItemFactory(['Small Key (Palace of Darkness)'] * 6, player), ItemFactory(['Map (Palace of Darkness)', 'Compass (Palace of Darkness)'], player))
TT = make_dungeon('Thieves Town', 'Blind', ['Thieves Town (Entrance)', 'Thieves Town (Deep)', 'Blind Fight'], ItemFactory('Big Key (Thieves Town)', player), [ItemFactory('Small Key (Thieves Town)', player)], ItemFactory(['Map (Thieves Town)', 'Compass (Thieves Town)'], player))
SW = make_dungeon('Skull Woods', 'Mothula', ['Skull Woods Final Section (Entrance)', 'Skull Woods First Section', 'Skull Woods Second Section', 'Skull Woods Second Section (Drop)', 'Skull Woods Final Section (Mothula)', 'Skull Woods First Section (Right)', 'Skull Woods First Section (Left)', 'Skull Woods First Section (Top)'], ItemFactory('Big Key (Skull Woods)', player), ItemFactory(['Small Key (Skull Woods)'] * 2, player), ItemFactory(['Map (Skull Woods)', 'Compass (Skull Woods)'], player))
SW = make_dungeon('Skull Woods', 'Mothula', ['Skull Woods Final Section (Entrance)', 'Skull Woods First Section', 'Skull Woods Second Section', 'Skull Woods Second Section (Drop)', 'Skull Woods Final Section (Mothula)', 'Skull Woods First Section (Right)', 'Skull Woods First Section (Left)', 'Skull Woods First Section (Top)'], ItemFactory('Big Key (Skull Woods)', player), ItemFactory(['Small Key (Skull Woods)'] * 3, player), ItemFactory(['Map (Skull Woods)', 'Compass (Skull Woods)'], player))
SP = make_dungeon('Swamp Palace', 'Arrghus', ['Swamp Palace (Entrance)', 'Swamp Palace (First Room)', 'Swamp Palace (Starting Area)', 'Swamp Palace (Center)', 'Swamp Palace (North)'], ItemFactory('Big Key (Swamp Palace)', player), [ItemFactory('Small Key (Swamp Palace)', player)], ItemFactory(['Map (Swamp Palace)', 'Compass (Swamp Palace)'], player))
IP = make_dungeon('Ice Palace', 'Kholdstare', ['Ice Palace (Entrance)', 'Ice Palace (Main)', 'Ice Palace (East)', 'Ice Palace (East Top)', 'Ice Palace (Kholdstare)'], ItemFactory('Big Key (Ice Palace)', player), ItemFactory(['Small Key (Ice Palace)'] * 2, player), ItemFactory(['Map (Ice Palace)', 'Compass (Ice Palace)'], player))
MM = make_dungeon('Misery Mire', 'Vitreous', ['Misery Mire (Entrance)', 'Misery Mire (Main)', 'Misery Mire (West)', 'Misery Mire (Final Area)', 'Misery Mire (Vitreous)'], ItemFactory('Big Key (Misery Mire)', player), ItemFactory(['Small Key (Misery Mire)'] * 3, player), ItemFactory(['Map (Misery Mire)', 'Compass (Misery Mire)'], player))
@ -46,15 +50,6 @@ def fill_dungeons(world):
all_state_base = world.get_all_state()
for player in range(1, world.players + 1):
pinball_room = world.get_location('Skull Woods - Pinball Room', player)
if world.retro[player]:
world.push_item(pinball_room, ItemFactory('Small Key (Universal)', player), False)
else:
world.push_item(pinball_room, ItemFactory('Small Key (Skull Woods)', player), False)
pinball_room.event = True
pinball_room.locked = True
dungeons = [(list(dungeon.regions), dungeon.big_key, list(dungeon.small_keys), list(dungeon.dungeon_items)) for dungeon in world.dungeons]
loopcnt = 0
@ -125,16 +120,6 @@ def get_dungeon_item_pool(world):
def fill_dungeons_restrictive(world, shuffled_locations):
all_state_base = world.get_all_state()
for player in range(1, world.players + 1):
pinball_room = world.get_location('Skull Woods - Pinball Room', player)
if world.retro[player]:
world.push_item(pinball_room, ItemFactory('Small Key (Universal)', player), False)
else:
world.push_item(pinball_room, ItemFactory('Small Key (Skull Woods)', player), False)
pinball_room.event = True
pinball_room.locked = True
shuffled_locations.remove(pinball_room)
# with shuffled dungeon items they are distributed as part of the normal item pool
for item in world.get_items():
if (item.smallkey and world.keyshuffle[item.player]) or (item.bigkey and world.bigkeyshuffle[item.player]):

View File

@ -8,7 +8,7 @@ import textwrap
import shlex
import sys
from Main import main
from Main import main, get_seed
from Rom import get_sprite_from_name
from Utils import is_bundled, close_console
@ -67,7 +67,7 @@ def parse_arguments(argv, no_defaults=False):
Vanilla: Swords are in vanilla locations.
''')
parser.add_argument('--goal', default=defval('ganon'), const='ganon', nargs='?',
choices=['ganon', 'pedestal', 'dungeons', 'triforcehunt', 'localtriforcehunt', 'crystals'],
choices=['ganon', 'pedestal', 'dungeons', 'triforcehunt', 'localtriforcehunt', 'ganontriforcehunt', 'localganontriforcehunt', 'crystals'],
help='''\
Select completion goal. (default: %(default)s)
Ganon: Collect all crystals, beat Agahnim 2 then
@ -79,10 +79,17 @@ def parse_arguments(argv, no_defaults=False):
Triforce Hunt: Places 30 Triforce Pieces in the world, collect
20 of them to beat the game.
Local Triforce Hunt: Places 30 Triforce Pieces in your world, collect
20 of them to beat the game.
20 of them to beat the game.
Ganon Triforce Hunt: Places 30 Triforce Pieces in the world, collect
20 of them, then defeat Ganon.
Local Ganon Triforce Hunt: Places 30 Triforce Pieces in your world,
collect 20 of them, then defeat Ganon.
''')
parser.add_argument('--triforce_pieces_available', default=defval(30),
type=lambda value: min(max(int(value), 1), 90),
help='''Set Triforce Pieces available in item pool.''')
parser.add_argument('--triforce_pieces_required', default=defval(20),
type=lambda value: min(max(int(value), 1), 30),
type=lambda value: min(max(int(value), 1), 90),
help='''Set Triforce Pieces required to win a Triforce Hunt''')
parser.add_argument('--difficulty', default=defval('normal'), const='normal', nargs='?',
choices=['normal', 'hard', 'expert'],
@ -280,17 +287,15 @@ def parse_arguments(argv, no_defaults=False):
''')
parser.add_argument('--suppress_rom', help='Do not create an output rom file.', action='store_true')
parser.add_argument('--gui', help='Launch the GUI', action='store_true')
parser.add_argument('--jsonout', action='store_true', help='''\
Output .json patch to stdout instead of a patched rom. Used
for VT site integration, do not use otherwise.
''')
parser.add_argument('--skip_progression_balancing', action='store_true', default=defval(False),
help="Skip Multiworld Progression balancing.")
parser.add_argument('--skip_playthrough', action='store_true', default=defval(False))
parser.add_argument('--enemizercli', default=defval('EnemizerCLI/EnemizerCLI.Core'))
parser.add_argument('--shufflebosses', default=defval('none'), choices=['none', 'basic', 'normal', 'chaos'])
parser.add_argument('--shuffleenemies', default=defval('none'), choices=['none', 'shuffled', 'chaos'])
parser.add_argument('--enemy_health', default=defval('default'), choices=['default', 'easy', 'normal', 'hard', 'expert'])
parser.add_argument('--shuffleenemies', default=defval('none'),
choices=['none', 'shuffled', 'chaos', 'chaosthieves'])
parser.add_argument('--enemy_health', default=defval('default'),
choices=['default', 'easy', 'normal', 'hard', 'expert'])
parser.add_argument('--enemy_damage', default=defval('default'), choices=['default', 'shuffled', 'chaos'])
parser.add_argument('--shufflepots', default=defval(False), action='store_true')
parser.add_argument('--beemizer', default=defval(0), type=lambda value: min(max(int(value), 0), 4))
@ -302,8 +307,7 @@ def parse_arguments(argv, no_defaults=False):
parser.add_argument('--race', default=defval(False), action='store_true')
parser.add_argument('--outputname')
parser.add_argument('--create_diff', default=defval(False), action='store_true', help='''\
create a binary patch file from which the randomized rom can be recreated using MultiClient.
Does not work with jsonout.''')
create a binary patch file from which the randomized rom can be recreated using MultiClient.''')
parser.add_argument('--disable_glitch_boots', default=defval(False), action='store_true', help='''\
turns off starting with Pegasus Boots in glitched modes.''')
@ -333,7 +337,7 @@ def parse_arguments(argv, no_defaults=False):
'local_items', 'retro', 'accessibility', 'hints', 'beemizer',
'shufflebosses', 'shuffleenemies', 'enemy_health', 'enemy_damage', 'shufflepots',
'ow_palettes', 'uw_palettes', 'sprite', 'disablemusic', 'quickswap', 'fastmenu', 'heartcolor',
'heartbeep', "skip_progression_balancing", "triforce_pieces_required",
'heartbeep', "skip_progression_balancing", "triforce_pieces_available", "triforce_pieces_required",
'remote_items', 'progressive', 'dungeon_counters', 'glitch_boots']:
value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name)
if player == 1:
@ -357,15 +361,14 @@ def start():
sys.exit(0)
# ToDo: Validate files further than mere existance
if not args.jsonout and not os.path.isfile(args.rom):
input('Could not find valid base rom for patching at expected path %s. Please run with -h to see help for further information. \nPress Enter to exit.' % args.rom)
if not os.path.isfile(args.rom):
input(
'Could not find valid base 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 any([sprite is not None and not os.path.isfile(sprite) and not get_sprite_from_name(sprite) for sprite in
args.sprite.values()]):
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
sys.exit(1)
if any([sprite is not None and not os.path.isfile(sprite) and not get_sprite_from_name(sprite) for sprite in args.sprite.values()]):
if not args.jsonout:
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
sys.exit(1)
else:
raise IOError('Cannot find sprite file at %s' % args.sprite)
# set up logger
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[args.loglevel]
@ -378,7 +381,7 @@ def start():
seed = args.seed
for _ in range(args.count):
main(seed=seed, args=args)
seed = random.randint(0, 999999999)
seed = get_seed()
else:
main(seed=args.seed, args=args)

10
Fill.py
View File

@ -244,8 +244,8 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
continue
gftower_trash_count = (
random.randint(15, 50) if world.goal[player] in {'triforcehunt', 'localtriforcehunt'} else random.randint(0,
15))
random.randint(15, 50) if 'triforcehunt' in world.goal[player]
else random.randint(0, 15))
gtower_locations = [location for location in fill_locations if
'Ganons Tower' in location.name and location.player == player]
@ -274,21 +274,21 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
# 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 (Escape)' and world.mode[item.player] == 'standard' and
key=lambda item: 1 if item.name == 'Small Key (Hyrule Castle)' and world.mode[item.player] == 'standard' and
world.keyshuffle[item.player] else 0)
fill_restrictive(world, world.state, fill_locations, progitempool)
if any(
localprioitempool.values() or localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
for player, items in localprioitempool.items(): # already shuffled
for player, items in localprioitempool.items(): # items already shuffled
local_locations = [location for location in fill_locations if location.player == player]
random.shuffle(local_locations)
for item_to_place in items:
spot_to_fill = local_locations.pop()
world.push_item(spot_to_fill, item_to_place, False)
fill_locations.remove(spot_to_fill)
for player, items in localrestitempool.items(): # already shuffled
for player, items in localrestitempool.items(): # items already shuffled
local_locations = [location for location in fill_locations if location.player == player]
random.shuffle(local_locations)
for item_to_place in items:

46
Gui.py
View File

@ -16,7 +16,7 @@ ModuleUpdate.update()
from AdjusterMain import adjust
from EntranceRandomizer import parse_arguments
from GuiUtils import ToolTips, set_icon, BackgroundTaskProgress
from Main import main, __version__ as ESVersion
from Main import main, get_seed, __version__ as ESVersion
from Rom import Sprite
from Utils import is_bundled, local_path, output_path, open_file
@ -240,7 +240,7 @@ def guiMain(args=None):
goalVar = StringVar()
goalVar.set('ganon')
goalOptionMenu = OptionMenu(goalFrame, goalVar, 'ganon', 'pedestal', 'dungeons', 'triforcehunt',
'localtriforcehunt', 'crystals')
'localtriforcehunt', 'ganontriforcehunt', 'localganontriforcehunt', 'crystals')
goalOptionMenu.pack(side=RIGHT)
goalLabel = Label(goalFrame, text='Game goal')
goalLabel.pack(side=LEFT)
@ -327,7 +327,7 @@ def guiMain(args=None):
shuffleFrame = Frame(drowDownFrame)
shuffleVar = StringVar()
shuffleVar.set('full')
shuffleVar.set('vanilla')
shuffleOptionMenu = OptionMenu(shuffleFrame, shuffleVar, 'vanilla', 'simple', 'restricted', 'full', 'crossed', 'insanity', 'restricted_legacy', 'full_legacy', 'madness_legacy', 'insanity_legacy', 'dungeonsfull', 'dungeonssimple')
shuffleOptionMenu.pack(side=RIGHT)
shuffleLabel = Label(shuffleFrame, text='Entrance shuffle algorithm')
@ -349,10 +349,7 @@ def guiMain(args=None):
shuffleFrame.pack(expand=True, anchor=E)
enemizerFrame = LabelFrame(randomizerWindow, text="Enemizer", padx=5, pady=2)
enemizerFrame.columnconfigure(0, weight=1)
enemizerFrame.columnconfigure(1, weight=1)
enemizerFrame.columnconfigure(2, weight=1)
enemizerFrame.columnconfigure(3, weight=1)
enemizerPathFrame = Frame(enemizerFrame)
enemizerPathFrame.grid(row=0, column=0, columnspan=3, sticky=W+E, padx=3)
@ -378,7 +375,7 @@ def guiMain(args=None):
enemizerEnemyLabel.pack(side=LEFT)
enemyShuffleVar = StringVar()
enemyShuffleVar.set('none')
enemizerEnemyOption = OptionMenu(enemizerEnemyFrame, enemyShuffleVar, 'none', 'shuffled', 'chaos')
enemizerEnemyOption = OptionMenu(enemizerEnemyFrame, enemyShuffleVar, 'none', 'shuffled', 'chaos', 'chaosthieves')
enemizerEnemyOption.pack(side=LEFT)
enemizerBossFrame = Frame(enemizerFrame)
@ -408,20 +405,21 @@ def guiMain(args=None):
enemizerHealthOption = OptionMenu(enemizerHealthFrame, enemizerHealthVar, 'default', 'easy', 'normal', 'hard', 'expert')
enemizerHealthOption.pack(side=LEFT)
bottomFrame = Frame(randomizerWindow, pady=5)
multiworldframe = LabelFrame(randomizerWindow, text="Multiworld", padx=5, pady=2)
worldLabel = Label(bottomFrame, text='Worlds')
worldLabel = Label(multiworldframe, text='Worlds')
worldVar = StringVar()
worldSpinbox = Spinbox(bottomFrame, from_=1, to=100, width=5, textvariable=worldVar)
namesLabel = Label(bottomFrame, text='Player names')
worldSpinbox = Spinbox(multiworldframe, from_=1, to=255, width=5, textvariable=worldVar)
namesLabel = Label(multiworldframe, text='Player names')
namesVar = StringVar()
namesEntry = Entry(bottomFrame, textvariable=namesVar)
seedLabel = Label(bottomFrame, text='Seed #')
namesEntry = Entry(multiworldframe, textvariable=namesVar)
seedLabel = Label(multiworldframe, text='Seed #')
seedVar = StringVar()
seedEntry = Entry(bottomFrame, width=15, textvariable=seedVar)
countLabel = Label(bottomFrame, text='Count')
seedEntry = Entry(multiworldframe, width=20, textvariable=seedVar)
countLabel = Label(multiworldframe, text='Count')
countVar = StringVar()
countSpinbox = Spinbox(bottomFrame, from_=1, to=100, width=5, textvariable=countVar)
countSpinbox = Spinbox(multiworldframe, from_=1, to=100, width=5, textvariable=countVar)
def generateRom():
guiargs = Namespace()
@ -500,7 +498,7 @@ def guiMain(args=None):
seed = guiargs.seed
for _ in range(guiargs.count):
main(seed=seed, args=guiargs)
seed = random.randint(0, 999999999)
seed = get_seed()
else:
main(seed=guiargs.seed, args=guiargs)
except Exception as e:
@ -509,24 +507,24 @@ def guiMain(args=None):
else:
messagebox.showinfo(title="Success", message="Rom patched successfully")
generateButton = Button(bottomFrame, text='Generate Patched Rom', command=generateRom)
generateButton = Button(farBottomFrame, text='Generate Patched Rom', command=generateRom)
worldLabel.pack(side=LEFT)
worldSpinbox.pack(side=LEFT)
namesLabel.pack(side=LEFT)
namesEntry.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=LEFT, padx=(5, 0))
generateButton.pack(side=RIGHT, padx=(5, 0))
openOutputButton.pack(side=RIGHT)
openOutputButton.pack(side=LEFT)
drowDownFrame.pack(side=LEFT)
rightHalfFrame.pack(side=RIGHT)
topFrame.pack(side=TOP)
bottomFrame.pack(side=BOTTOM)
multiworldframe.pack(side=BOTTOM, expand=True, fill=X)
enemizerFrame.pack(side=BOTTOM, fill=BOTH)
# Adjuster Controls
@ -1350,7 +1348,7 @@ class SpriteSelector(object):
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 ""))
button.image = image
button.grid(row=i // 16, column=i % 16)
button.grid(row=i // 32, column=i % 32)
i += 1
if i == 0:

View File

@ -32,7 +32,7 @@ normalfinal25extra = ['Rupees (20)'] * 23 + ['Rupees (5)'] * 2
Difficulty = namedtuple('Difficulty',
['baseitems', 'bottles', 'bottle_count', 'same_bottle', 'progressiveshield',
'basicshield', 'progressivearmor', 'basicarmor', 'swordless',
'progressivesword', 'basicsword', 'basicbow', 'timedohko', 'timedother',
'progressivesword', 'basicsword', 'progressivebow', 'basicbow', 'timedohko', 'timedother',
'triforcehunt', 'retro',
'extras', 'progressive_sword_limit', 'progressive_shield_limit',
'progressive_armor_limit', 'progressive_bottle_limit',
@ -43,78 +43,82 @@ total_items_to_place = 153
difficulties = {
'normal': Difficulty(
baseitems = normalbaseitems,
bottles = normalbottles,
bottle_count = 4,
same_bottle = False,
progressiveshield = ['Progressive Shield'] * 3,
basicshield = ['Blue Shield', 'Red Shield', 'Mirror Shield'],
progressivearmor = ['Progressive Armor'] * 2,
basicarmor = ['Blue Mail', 'Red Mail'],
swordless = ['Rupees (20)'] * 4,
progressivesword = ['Progressive Sword'] * 4,
basicsword = ['Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword'],
basicbow = ['Bow', 'Silver Arrows'],
timedohko = ['Green Clock'] * 25,
timedother = ['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10,
triforcehunt = ['Triforce Piece'] * 30,
retro = ['Small Key (Universal)'] * 17 + ['Rupees (20)'] * 10,
extras = [normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra],
progressive_sword_limit = 4,
progressive_shield_limit = 3,
progressive_armor_limit = 2,
progressive_bow_limit = 2,
bottles=normalbottles,
bottle_count=4,
same_bottle=False,
progressiveshield=['Progressive Shield'] * 3,
basicshield=['Blue Shield', 'Red Shield', 'Mirror Shield'],
progressivearmor=['Progressive Armor'] * 2,
basicarmor=['Blue Mail', 'Red Mail'],
swordless=['Rupees (20)'] * 4,
progressivesword=['Progressive Sword'] * 4,
basicsword=['Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword'],
progressivebow=["Progressive Bow"] * 2,
basicbow=['Bow', 'Silver Bow'],
timedohko=['Green Clock'] * 25,
timedother=['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10,
triforcehunt=['Triforce Piece'] * 30,
retro=['Small Key (Universal)'] * 18 + ['Rupees (20)'] * 10,
extras=[normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra],
progressive_sword_limit=4,
progressive_shield_limit=3,
progressive_armor_limit=2,
progressive_bow_limit=2,
progressive_bottle_limit = 4,
boss_heart_container_limit = 255,
heart_piece_limit = 255,
boss_heart_container_limit = 10,
heart_piece_limit = 24,
),
'hard': Difficulty(
baseitems = normalbaseitems,
bottles = hardbottles,
bottle_count = 4,
same_bottle = False,
progressiveshield = ['Progressive Shield'] * 3,
basicshield = ['Blue Shield', 'Red Shield', 'Red Shield'],
progressivearmor = ['Progressive Armor'] * 2,
basicarmor = ['Progressive Armor'] * 2, # neither will count
swordless = ['Rupees (20)'] * 4,
progressivesword = ['Progressive Sword'] * 4,
basicsword = ['Fighter Sword', 'Master Sword', 'Master Sword', 'Tempered Sword'],
basicbow = ['Bow'] * 2,
timedohko = ['Green Clock'] * 25,
timedother = ['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10,
triforcehunt = ['Triforce Piece'] * 30,
retro = ['Small Key (Universal)'] * 12 + ['Rupees (5)'] * 15,
extras = [normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra],
progressive_sword_limit = 3,
progressive_shield_limit = 2,
progressive_armor_limit = 0,
progressive_bow_limit = 1,
bottles=hardbottles,
bottle_count=4,
same_bottle=False,
progressiveshield=['Progressive Shield'] * 3,
basicshield=['Blue Shield', 'Red Shield', 'Red Shield'],
progressivearmor=['Progressive Armor'] * 2,
basicarmor=['Progressive Armor'] * 2, # neither will count
swordless=['Rupees (20)'] * 4,
progressivesword=['Progressive Sword'] * 4,
basicsword=['Fighter Sword', 'Master Sword', 'Master Sword', 'Tempered Sword'],
progressivebow=["Progressive Bow"] * 2,
basicbow=['Bow'] * 2,
timedohko=['Green Clock'] * 25,
timedother=['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10,
triforcehunt=['Triforce Piece'] * 30,
retro=['Small Key (Universal)'] * 12 + ['Rupees (5)'] * 16,
extras=[normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra],
progressive_sword_limit=3,
progressive_shield_limit=2,
progressive_armor_limit=0,
progressive_bow_limit=1,
progressive_bottle_limit = 4,
boss_heart_container_limit = 6,
heart_piece_limit = 16,
),
'expert': Difficulty(
baseitems = normalbaseitems,
bottles = hardbottles,
bottle_count = 4,
same_bottle = False,
progressiveshield = ['Progressive Shield'] * 3,
basicshield = ['Progressive Shield'] * 3, #only the first one will upgrade, making this equivalent to two blue shields
progressivearmor = ['Progressive Armor'] * 2, # neither will count
basicarmor = ['Progressive Armor'] * 2, # neither will count
swordless = ['Rupees (20)'] * 4,
progressivesword = ['Progressive Sword'] * 4,
basicsword = ['Fighter Sword', 'Fighter Sword', 'Master Sword', 'Master Sword'],
basicbow = ['Bow'] * 2,
timedohko = ['Green Clock'] * 20 + ['Red Clock'] * 5,
timedother = ['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10,
triforcehunt = ['Triforce Piece'] * 30,
retro = ['Small Key (Universal)'] * 12 + ['Rupees (5)'] * 15,
extras = [normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra],
progressive_sword_limit = 2,
progressive_shield_limit = 1,
progressive_armor_limit = 0,
progressive_bow_limit = 1,
bottles=hardbottles,
bottle_count=4,
same_bottle=False,
progressiveshield=['Progressive Shield'] * 3,
basicshield=['Progressive Shield'] * 3,
# only the first one will upgrade, making this equivalent to two blue shields
progressivearmor=['Progressive Armor'] * 2, # neither will count
basicarmor=['Progressive Armor'] * 2, # neither will count
swordless=['Rupees (20)'] * 4,
progressivesword=['Progressive Sword'] * 4,
basicsword=['Fighter Sword', 'Fighter Sword', 'Master Sword', 'Master Sword'],
progressivebow=["Progressive Bow"] * 2,
basicbow=['Bow'] * 2,
timedohko=['Green Clock'] * 20 + ['Red Clock'] * 5,
timedother=['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10,
triforcehunt=['Triforce Piece'] * 30,
retro=['Small Key (Universal)'] * 12 + ['Rupees (5)'] * 16,
extras=[normalfirst15extra, normalsecond15extra, normalthird10extra, normalfourth5extra, normalfinal25extra],
progressive_sword_limit=2,
progressive_shield_limit=1,
progressive_armor_limit=0,
progressive_bow_limit=1,
progressive_bottle_limit = 4,
boss_heart_container_limit = 2,
heart_piece_limit = 8,
@ -124,7 +128,7 @@ difficulties = {
def generate_itempool(world, player):
if world.difficulty[player] not in ['normal', 'hard', 'expert']:
raise NotImplementedError(f"Diffulty {world.difficulty[player]}")
if world.goal[player] not in {'ganon', 'pedestal', 'dungeons', 'triforcehunt', 'localtriforcehunt', 'crystals'}:
if world.goal[player] not in {'ganon', 'pedestal', 'dungeons', 'triforcehunt', 'localtriforcehunt', 'ganontriforcehunt', 'localganontriforcehunt', 'crystals'}:
raise NotImplementedError(f"Goal {world.goal[player]}")
if world.mode[player] not in {'open', 'standard', 'inverted'}:
raise NotImplementedError(f"Mode {world.mode[player]}")
@ -142,9 +146,8 @@ def generate_itempool(world, player):
region = world.get_region('Light World', player)
loc = Location(player, "Murahdahla", parent=region)
loc.access_rule = lambda state: state.item_count('Triforce Piece', player) + state.item_count('Power Star',
player) >= \
state.world.treasure_hunt_count[player]
loc.access_rule = lambda state: state.has_triforce_pieces(state.world.treasure_hunt_count[player], player)
region.locations.append(loc)
world.dynamic_locations.append(loc)
@ -256,7 +259,16 @@ def generate_itempool(world, player):
return item if not choice else ItemFactory("Bee Trap", player) if choice == 'trap' else ItemFactory("Bee", player)
return item
world.itempool += [beemizer(item) for item in items]
progressionitems = [item for item in items if item.advancement or item.priority or item.type]
nonprogressionitems = [beemizer(item) for item in items if not item.advancement and not item.priority and not item.type]
random.shuffle(nonprogressionitems)
triforce_pieces = world.triforce_pieces_available[player]
if 'triforcehunt' in world.goal[player] and triforce_pieces > 30:
progressionitems += [ItemFactory("Triforce Piece", player)] * (triforce_pieces - 30)
nonprogressionitems = nonprogressionitems[(triforce_pieces-30):]
world.itempool += progressionitems + nonprogressionitems
# shuffle medallions
mm_medallion = ['Ether', 'Quake', 'Bombos'][random.randint(0, 2)]
@ -460,11 +472,11 @@ def get_pool_core(world, player: int):
pool.extend(diff.basicarmor)
if want_progressives():
pool.extend(['Progressive Bow'] * 2)
pool.extend(diff.progressivebow)
elif swords != 'swordless':
pool.extend(diff.basicbow)
else:
pool.extend(['Bow', 'Silver Arrows'])
pool.extend(['Bow', 'Silver Bow'])
if swords == 'swordless':
pool.extend(diff.swordless)
@ -501,7 +513,9 @@ def get_pool_core(world, player: int):
pool.extend(diff.timedohko)
extraitems -= len(diff.timedohko)
clock_mode = 'countdown-ohko'
if goal in {'triforcehunt', 'localtriforcehunt'}:
if 'triforcehunt' in goal:
while len(diff.triforcehunt) > world.triforce_pieces_available[player]:
diff.triforcehunt.pop()
pool.extend(diff.triforcehunt)
extraitems -= len(diff.triforcehunt)
treasure_hunt_count = world.triforce_pieces_required[player]
@ -552,7 +566,7 @@ def make_custom_item_pool(progressive, shuffle, difficulty, timer, goal, mode, s
itemtotal = itemtotal + customitemarray[70]
pool.extend(['Bow'] * customitemarray[0])
pool.extend(['Silver Arrows']* customitemarray[1])
pool.extend(['Silver Bow']* customitemarray[1])
pool.extend(['Blue Boomerang'] * customitemarray[2])
pool.extend(['Red Boomerang'] * customitemarray[3])
pool.extend(['Hookshot'] * customitemarray[4])
@ -632,8 +646,7 @@ def make_custom_item_pool(progressive, shuffle, difficulty, timer, goal, mode, s
treasure_hunt_count = max(min(customitemarray[67], 99), 1) #To display, count must be between 1 and 99.
treasure_hunt_icon = 'Triforce Piece'
# Ensure game is always possible to complete here, force sufficient pieces if the player is unwilling.
if (customitemarray[66] < treasure_hunt_count) and (goal in {'triforcehunt', 'localtriforcehunt'}) and (
customitemarray[68] == 0):
if (customitemarray[66] < treasure_hunt_count) and ('triforcehunt' in goal) and (customitemarray[68] == 0):
extrapieces = treasure_hunt_count - customitemarray[66]
pool.extend(['Triforce Piece'] * extrapieces)
itemtotal = itemtotal + extrapieces

View File

@ -26,41 +26,42 @@ def ItemFactory(items, player):
item_table = {'Bow': (True, False, None, 0x0B, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'the Bow'),
'Progressive Bow': (True, False, None, 0x64, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'),
'Progressive Bow (Alt)': (True, False, None, 0x65, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'),
'Silver Arrows': (True, False, None, 0x58, 'Do you fancy\nsilver tipped\narrows?', 'and the ganonsbane','ganon-killing kid', 'ganon doom for sale', 'fungus for pork','archer boy shines again', 'the Silver Arrows'),
'Silver Bow': (True, False, None, 0x3B, 'Buy 1 Silver\nget Archery\nfor free.', 'the baconmaker', 'ganon-killing kid', 'ganon doom for sale', 'fungus for pork', 'archer boy shines again', 'the Silver Bow'),
'Book of Mudora': (True, False, None, 0x1D, 'This is a\nparadox?!', 'and the story book', 'the scholarly kid', 'moon runes for sale', 'drugs for literacy', 'book-worm boy can read again', 'the Book'),
'Hammer': (True, False, None, 0x09, 'stop\nhammer time!', 'and m c hammer', 'hammer-smashing kid', 'm c hammer for sale', 'stop... hammer time', 'stop, hammer time', 'the hammer'),
'Hammer': (True, False, None, 0x09, 'stop\nhammer time!', 'and m c hammer', 'hammer-smashing kid', 'm c hammer for sale', 'stop... hammer time', 'stop, hammer time', 'the Hammer'),
'Hookshot': (True, False, None, 0x0A, 'BOING!!!\nBOING!!!\nBOING!!!', 'and the tickle beam', 'tickle-monster kid', 'tickle beam for sale', 'witch and tickle boy', 'beam boy tickles again', 'the Hookshot'),
'Magic Mirror': (True, False, None, 0x1A, 'Isn\'t your\nreflection so\npretty?', 'the face reflector', 'the narcissistic kid', 'your face for sale', 'trades looking-glass', 'narcissistic boy is happy again', 'the Mirror'),
'Flute': (True, False, None, 0x14, 'Save the duck\nand fly to\nfreedom!', 'and the duck call', 'the duck-call kid', 'duck call for sale', 'duck-calls for trade', 'flute boy plays again', 'the Flute'),
'Pegasus Boots': (True, False, None, 0x4B, 'Gotta go fast!', 'and the sprint shoes', 'the running-man kid', 'sprint shoe for sale', 'shrooms for speed', 'gotta-go-fast boy runs again', 'the Boots'),
'Power Glove': (True, False, None, 0x1B, 'Now you can\nlift weak\nstuff!', 'and the grey mittens', 'body-building kid', 'lift glove for sale', 'fungus for gloves', 'body-building boy lifts again', 'the glove'),
'Cape': (True, False, None, 0x19, 'Wear this to\nbecome\ninvisible!', 'the camouflage cape', 'red riding-hood kid', 'red hood for sale', 'hood from a hood', 'dapper boy hides again', 'the cape'),
'Mushroom': (True, False, None, 0x29, 'I\'m a fun guy!\n\nI\'m a funghi!', 'and the legal drugs', 'the drug-dealing kid', 'legal drugs for sale', 'shroom swap', 'shroom boy sells drugs again', 'the mushroom'),
'Shovel': (True, False, None, 0x13, 'Can\n You\n Dig it?', 'and the spade', 'archaeologist kid', 'dirt spade for sale', 'can you dig it', 'shovel boy digs again', 'the shovel'),
'Lamp': (True, False, None, 0x12, 'Baby, baby,\nbaby.\nLight my way!', 'and the flashlight', 'light-shining kid', 'flashlight for sale', 'fungus for illumination', 'illuminated boy can see again', 'the lamp'),
'Magic Powder': (True, False, None, 0x0D, 'you can turn\nanti-faeries\ninto faeries', 'and the magic sack', 'the sack-holding kid', 'magic sack for sale', 'the witch and assistant', 'magic boy plays marbles again', 'the powder'),
'Moon Pearl': (True, False, None, 0x1F, ' Bunny Link\n be\n gone!', 'and the jaw breaker', 'fortune-telling kid', 'lunar orb for sale', 'shrooms for moon rock', 'moon boy plays ball again', 'the moon pearl'),
'Cane of Somaria': (True, False, None, 0x15, 'I make blocks\nto hold down\nswitches!', 'and the red blocks', 'the block-making kid', 'block stick for sale', 'block stick for trade', 'cane boy makes blocks again', 'the red cane'),
'Fire Rod': (True, False, None, 0x07, 'I\'m the hot\nrod. I make\nthings burn!', 'and the flamethrower', 'fire-starting kid', 'rage rod for sale', 'fungus for rage-rod', 'firestarter boy burns again', 'the fire rod'),
'Flippers': (True, False, None, 0x1E, 'fancy a swim?', 'and the toewebs', 'the swimming kid', 'finger webs for sale', 'shrooms let you swim', 'swimming boy swims again', 'the flippers'),
'Ice Rod': (True, False, None, 0x08, 'I\'m the cold\nrod. I make\nthings freeze!', 'and the freeze ray', 'the ice-bending kid', 'freeze ray for sale', 'fungus for ice-rod', 'ice-cube boy freezes again', 'the ice rod'),
'Titans Mitts': (True, False, None, 0x1C, 'Now you can\nlift heavy\nstuff!', 'and the golden glove', 'body-building kid', 'carry glove for sale', 'fungus for bling-gloves', 'body-building boy has gold again', 'the mitts'),
'Power Glove': (True, False, None, 0x1B, 'Now you can\nlift weak\nstuff!', 'and the grey mittens', 'body-building kid', 'lift glove for sale', 'fungus for gloves', 'body-building boy lifts again', 'the Glove'),
'Cape': (True, False, None, 0x19, 'Wear this to\nbecome\ninvisible!', 'the camouflage cape', 'red riding-hood kid', 'red hood for sale', 'hood from a hood', 'dapper boy hides again', 'the Cape'),
'Mushroom': (True, False, None, 0x29, 'I\'m a fun guy!\n\nI\'m a funghi!', 'and the legal drugs', 'the drug-dealing kid', 'legal drugs for sale', 'shroom swap', 'shroom boy sells drugs again', 'the Mushroom'),
'Shovel': (True, False, None, 0x13, 'Can\n You\n Dig it?', 'and the spade', 'archaeologist kid', 'dirt spade for sale', 'can you dig it', 'shovel boy digs again', 'the Shovel'),
'Lamp': (True, False, None, 0x12, 'Baby, baby,\nbaby.\nLight my way!', 'and the flashlight', 'light-shining kid', 'flashlight for sale', 'fungus for illumination', 'illuminated boy can see again', 'the Lamp'),
'Magic Powder': (True, False, None, 0x0D, 'you can turn\nanti-faeries\ninto faeries', 'and the magic sack', 'the sack-holding kid', 'magic sack for sale', 'the witch and assistant', 'magic boy plays marbles again', 'the Powder'),
'Moon Pearl': (True, False, None, 0x1F, ' Bunny Link\n be\n gone!', 'and the jaw breaker', 'fortune-telling kid', 'lunar orb for sale', 'shrooms for moon rock', 'moon boy plays ball again', 'the Moon Pearl'),
'Cane of Somaria': (True, False, None, 0x15, 'I make blocks\nto hold down\nswitches!', 'and the red blocks', 'the block-making kid', 'block stick for sale', 'block stick for trade', 'cane boy makes blocks again', 'the Red Cane'),
'Fire Rod': (True, False, None, 0x07, 'I\'m the hot\nrod. I make\nthings burn!', 'and the flamethrower', 'fire-starting kid', 'rage rod for sale', 'fungus for rage-rod', 'firestarter boy burns again', 'the Fire Rod'),
'Flippers': (True, False, None, 0x1E, 'fancy a swim?', 'and the toewebs', 'the swimming kid', 'finger webs for sale', 'shrooms let you swim', 'swimming boy swims again', 'the Flippers'),
'Ice Rod': (True, False, None, 0x08, 'I\'m the cold\nrod. I make\nthings freeze!', 'and the freeze ray', 'the ice-bending kid', 'freeze ray for sale', 'fungus for ice-rod', 'ice-cube boy freezes again', 'the Ice Rod'),
'Titans Mitts': (True, False, None, 0x1C, 'Now you can\nlift heavy\nstuff!', 'and the golden glove', 'body-building kid', 'carry glove for sale', 'fungus for bling-gloves', 'body-building boy has gold again', 'the Mitts'),
'Bombos': (True, False, None, 0x0F, 'Burn, baby,\nburn! Fear my\nring of fire!', 'and the swirly coin', 'coin-collecting kid', 'swirly coin for sale', 'shrooms for swirly-coin', 'medallion boy melts room again', 'Bombos'),
'Ether': (True, False, None, 0x10, 'This magic\ncoin freezes\neverything!', 'and the bolt coin', 'coin-collecting kid', 'bolt coin for sale', 'shrooms for bolt-coin', 'medallion boy sees floor again', 'Ether'),
'Quake': (True, False, None, 0x11, 'Maxing out the\nRichter scale\nis what I do!', 'and the wavy coin', 'coin-collecting kid', 'wavy coin for sale', 'shrooms for wavy-coin', 'medallion boy shakes dirt again', 'Quake'),
'Bottle': (True, False, None, 0x16, 'Now you can\nstore potions\nand stuff!', 'and the terrarium', 'the terrarium kid', 'terrarium for sale', 'special promotion', 'bottle boy has terrarium again', 'a Bottle'),
'Bottle (Red Potion)': (True, False, None, 0x2B, 'Hearty red goop!', 'and the red goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has red goo again', 'a Bottle'),
'Bottle (Green Potion)': (True, False, None, 0x2C, 'Refreshing green goop!', 'and the green goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has green goo again', 'a Bottle'),
'Bottle (Blue Potion)': (True, False, None, 0x2D, 'Delicious blue goop!', 'and the blue goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has blue goo again', 'a Bottle'),
'Bottle (Fairy)': (True, False, None, 0x3D, 'Save me and I will revive you', 'and the captive', 'the tingle kid','hostage for sale', 'fairy dust and shrooms', 'bottle boy has friend again', 'a Bottle'),
'Bottle (Bee)': (True, False, None, 0x3C, 'I will sting your foes a few times', 'and the sting buddy', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has mad bee again', 'a Bottle'),
'Bottle (Good Bee)': (True, False, None, 0x48, 'I will sting your foes a whole lot!', 'and the sparkle sting', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has beetor again', 'a Bottle'),
'Bottle': (True, False, None, 0x16, 'Now you can\nstore potions\nand stuff!', 'and the terrarium', 'the terrarium kid', 'terrarium for sale', 'special promotion', 'bottle boy has terrarium again', 'a bottle'),
'Bottle (Red Potion)': (True, False, None, 0x2B, 'Hearty red goop!', 'and the red goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has red goo again', 'a bottle'),
'Bottle (Green Potion)': (True, False, None, 0x2C, 'Refreshing green goop!', 'and the green goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has green goo again', 'a bottle'),
'Bottle (Blue Potion)': (True, False, None, 0x2D, 'Delicious blue goop!', 'and the blue goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has blue goo again', 'a bottle'),
'Bottle (Fairy)': (True, False, None, 0x3D, 'Save me and I will revive you', 'and the captive', 'the tingle kid','hostage for sale', 'fairy dust and shrooms', 'bottle boy has friend again', 'a bottle'),
'Bottle (Bee)': (True, False, None, 0x3C, 'I will sting your foes a few times', 'and the sting buddy', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has mad bee again', 'a bottle'),
'Bottle (Good Bee)': (True, False, None, 0x48, 'I will sting your foes a whole lot!', 'and the sparkle sting', 'the beekeeper kid', 'insect for sale', 'shroom pollenation', 'bottle boy has beetor again', 'a bottle'),
'Master Sword': (True, False, 'Sword', 0x50, 'I beat barries and pigs alike', 'and the master sword', 'sword-wielding kid', 'glow sword for sale', 'fungus for blue slasher', 'sword boy fights again', 'the Master Sword'),
'Tempered Sword': (True, False, 'Sword', 0x02, 'I stole the\nblacksmith\'s\njob!', 'the tempered sword', 'sword-wielding kid', 'flame sword for sale', 'fungus for red slasher', 'sword boy fights again', 'the Tempered Sword'),
'Fighter Sword': (True, False, 'Sword', 0x49, 'A pathetic\nsword rests\nhere!', 'the tiny sword', 'sword-wielding kid', 'tiny sword for sale', 'fungus for tiny slasher', 'sword boy fights again', 'the small sword'),
'Fighter Sword': (True, False, 'Sword', 0x49, 'A pathetic\nsword rests\nhere!', 'the tiny sword', 'sword-wielding kid', 'tiny sword for sale', 'fungus for tiny slasher', 'sword boy fights again', 'the Small Sword'),
'Golden Sword': (True, False, 'Sword', 0x03, 'The butter\nsword rests\nhere!', 'and the butter sword', 'sword-wielding kid', 'butter for sale', 'cap churned to butter', 'sword boy fights again', 'the Golden Sword'),
'Progressive Sword': (True, False, 'Sword', 0x5E, 'a better copy\nof your sword\nfor your time', 'the unknown sword', 'sword-wielding kid', 'sword for sale', 'fungus for some slasher', 'sword boy fights again', 'a sword'),
'Progressive Glove': (True, False, None, 0x61, 'a way to lift\nheavier things', 'and the lift upgrade', 'body-building kid', 'some glove for sale', 'fungus for gloves', 'body-building boy lifts again', 'a glove'),
'Silver Arrows': (True, False, None, 0x58, 'Do you fancy\nsilver tipped\narrows?', 'and the ganonsbane', 'ganon-killing kid', 'ganon doom for sale', 'fungus for pork', 'archer boy shines again', 'the silver arrows'),
'Progressive Sword': (True, False, 'Sword', 0x5E, 'a better copy\nof your sword\nfor your time', 'the unknown sword', 'sword-wielding kid', 'sword for sale', 'fungus for some slasher', 'sword boy fights again', 'a Sword'),
'Progressive Glove': (True, False, None, 0x61, 'a way to lift\nheavier things', 'and the lift upgrade', 'body-building kid', 'some glove for sale', 'fungus for gloves', 'body-building boy lifts again', 'a Glove'),
'Green Pendant': (True, False, 'Crystal', (0x04, 0x38, 0x62, 0x00, 0x69, 0x01), None, None, None, None, None, None, None),
'Blue Pendant': (True, False, 'Crystal', (0x02, 0x34, 0x60, 0x00, 0x69, 0x02), None, None, None, None, None, None, None),
'Red Pendant': (True, False, 'Crystal', (0x01, 0x32, 0x60, 0x00, 0x69, 0x03), None, None, None, None, None, None, None),
@ -83,17 +84,17 @@ item_table = {'Bow': (True, False, None, 0x0B, 'You have\nchosen the\narcher cla
'Bombs (10)': (False, False, None, 0x31, 'I make things\ngo BOOM! Ten\ntimes!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'ten bombs'),
'Bomb Upgrade (+10)': (False, False, None, 0x52, 'increase bomb\nstorage, low\nlow price', 'and the bomb bag', 'boom-enlarging kid', 'bomb boost for sale', 'the shroom goes boom', 'upgrade boy explodes more again', 'bomb capacity'),
'Bomb Upgrade (+5)': (False, False, None, 0x51, 'increase bomb\nstorage, low\nlow price', 'and the bomb bag', 'boom-enlarging kid', 'bomb boost for sale', 'the shroom goes boom', 'upgrade boy explodes more again', 'bomb capacity'),
'Blue Mail': (False, True, None, 0x22, 'Now you\'re a\nblue elf!', 'and the banana hat', 'the protected kid', 'banana hat for sale', 'the clothing store', 'tailor boy banana hatted again', 'the blue mail'),
'Red Mail': (False, True, None, 0x23, 'Now you\'re a\nred elf!', 'and the eggplant hat', 'well-protected kid', 'purple hat for sale', 'the nice clothing store', 'tailor boy fears nothing again', 'the red mail'),
'Blue Mail': (False, True, None, 0x22, 'Now you\'re a\nblue elf!', 'and the banana hat', 'the protected kid', 'banana hat for sale', 'the clothing store', 'tailor boy banana hatted again', 'the Blue Mail'),
'Red Mail': (False, True, None, 0x23, 'Now you\'re a\nred elf!', 'and the eggplant hat', 'well-protected kid', 'purple hat for sale', 'the nice clothing store', 'tailor boy fears nothing again', 'the Red Mail'),
'Progressive Armor': (False, True, None, 0x60, 'time for a\nchange of\nclothes?', 'and the unknown hat', 'the protected kid', 'new hat for sale', 'the clothing store', 'tailor boy has threads again', 'some armor'),
'Blue Boomerang': (True, False, None, 0x0C, 'No matter what\nyou do, blue\nreturns to you', 'and the bluemarang', 'the bat-throwing kid', 'bent stick for sale', 'fungus for puma-stick', 'throwing boy plays fetch again', 'the blue boomerang'),
'Red Boomerang': (True, False, None, 0x2A, 'No matter what\nyou do, red\nreturns to you', 'and the badmarang', 'the bat-throwing kid', 'air foil for sale', 'fungus for return-stick', 'magical boy plays fetch again', 'the red boomerang'),
'Blue Shield': (False, True, None, 0x04, 'Now you can\ndefend against\npebbles!', 'and the stone blocker', 'shield-wielding kid', 'shield for sale', 'fungus for shield', 'shield boy defends again', 'the blue shield'),
'Red Shield': (False, True, None, 0x05, 'Now you can\ndefend against\nfireballs!', 'and the shot blocker', 'shield-wielding kid', 'fire shield for sale', 'fungus for fire shield', 'shield boy defends again', 'the red shield'),
'Mirror Shield': (True, False, None, 0x06, 'Now you can\ndefend against\nlasers!', 'and the laser blocker', 'shield-wielding kid', 'face shield for sale', 'fungus for face shield', 'shield boy defends again', 'the mirror shield'),
'Blue Boomerang': (True, False, None, 0x0C, 'No matter what\nyou do, blue\nreturns to you', 'and the bluemarang', 'the bat-throwing kid', 'bent stick for sale', 'fungus for puma-stick', 'throwing boy plays fetch again', 'the Blue Boomerang'),
'Red Boomerang': (True, False, None, 0x2A, 'No matter what\nyou do, red\nreturns to you', 'and the badmarang', 'the bat-throwing kid', 'air foil for sale', 'fungus for return-stick', 'magical boy plays fetch again', 'the Bed Boomerang'),
'Blue Shield': (False, True, None, 0x04, 'Now you can\ndefend against\npebbles!', 'and the stone blocker', 'shield-wielding kid', 'shield for sale', 'fungus for shield', 'shield boy defends again', 'the Blue Shield'),
'Red Shield': (False, True, None, 0x05, 'Now you can\ndefend against\nfireballs!', 'and the shot blocker', 'shield-wielding kid', 'fire shield for sale', 'fungus for fire shield', 'shield boy defends again', 'the Red Shield'),
'Mirror Shield': (True, False, None, 0x06, 'Now you can\ndefend against\nlasers!', 'and the laser blocker', 'shield-wielding kid', 'face shield for sale', 'fungus for face shield', 'shield boy defends again', 'the Mirror Shield'),
'Progressive Shield': (True, False, None, 0x5F, 'have a better\nblocker in\nfront of you', 'and the new shield', 'shield-wielding kid', 'shield for sale', 'fungus for shield', 'shield boy defends again', 'a shield'),
'Bug Catching Net': (True, False, None, 0x21, 'Let\'s catch\nsome bees and\nfaeries!', 'and the bee catcher', 'the bug-catching kid', 'stick web for sale', 'fungus for butterflies', 'wrong boy catches bees again', 'the bug net'),
'Cane of Byrna': (True, False, None, 0x18, 'Use this to\nbecome\ninvincible!', 'and the bad cane', 'the spark-making kid', 'spark stick for sale', 'spark-stick for trade', 'cane boy encircles again', 'the blue cane'),
'Bug Catching Net': (True, False, None, 0x21, 'Let\'s catch\nsome bees and\nfaeries!', 'and the bee catcher', 'the bug-catching kid', 'stick web for sale', 'fungus for butterflies', 'wrong boy catches bees again', 'the Bug Net'),
'Cane of Byrna': (True, False, None, 0x18, 'Use this to\nbecome\ninvincible!', 'and the bad cane', 'the spark-making kid', 'spark stick for sale', 'spark-stick for trade', 'cane boy encircles again', 'the Blue Cane'),
'Boss Heart Container': (False, False, None, 0x3E, 'Maximum health\nincreased!\nYeah!', 'and the full heart', 'the life-giving kid', 'love for sale', 'fungus for life', 'life boy feels love again', 'a heart'),
'Sanctuary Heart Container': (False, False, None, 0x3F, 'Maximum health\nincreased!\nYeah!', 'and the full heart', 'the life-giving kid', 'love for sale', 'fungus for life', 'life boy feels love again', 'a heart'),
'Piece of Heart': (False, False, None, 0x17, 'Just a little\npiece of love!', 'and the broken heart', 'the life-giving kid', 'little love for sale', 'fungus for life', 'life boy feels some love again', 'a heart piece'),
@ -109,8 +110,8 @@ item_table = {'Bow': (True, False, None, 0x0B, 'You have\nchosen the\narcher cla
'Green Clock': (False, True, None, 0x5D, 'a lot of time', 'the emerald clock', 'the emerald-time kid', 'green time for sale', 'for emerald time', 'moment boy adjusts time again', 'a red clock'),
'Single RNG': (False, True, None, 0x62, 'something you don\'t yet have', None, None, None, None, 'unknown boy somethings again', 'a new mystery'),
'Multi RNG': (False, True, None, 0x63, 'something you may already have', None, None, None, None, 'unknown boy somethings again', 'a total mystery'),
'Magic Upgrade (1/2)': (True, False, None, 0x4E, 'Your magic\npower has been\ndoubled!', 'and the spell power', 'the magic-saving kid', 'wizardry for sale', 'mekalekahi mekahiney ho', 'magic boy saves magic again', 'half magic'), # can be required to beat mothula in an open seed in very very rare circumstance
'Magic Upgrade (1/4)': (True, False, None, 0x4F, 'Your magic\npower has been\nquadrupled!', 'and the spell power', 'the magic-saving kid', 'wizardry for sale', 'mekalekahi mekahiney ho', 'magic boy saves magic again', 'quarter magic'), # can be required to beat mothula in an open seed in very very rare circumstance
'Magic Upgrade (1/2)': (True, False, None, 0x4E, 'Your magic\npower has been\ndoubled!', 'and the spell power', 'the magic-saving kid', 'wizardry for sale', 'mekalekahi mekahiney ho', 'magic boy saves magic again', 'Half Magic'), # can be required to beat mothula in an open seed in very very rare circumstance
'Magic Upgrade (1/4)': (True, False, None, 0x4F, 'Your magic\npower has been\nquadrupled!', 'and the spell power', 'the magic-saving kid', 'wizardry for sale', 'mekalekahi mekahiney ho', 'magic boy saves magic again', 'Quarter Magic'), # can be required to beat mothula in an open seed in very very rare circumstance
'Small Key (Eastern Palace)': (False, False, 'SmallKey', 0xA2, 'A small key to Armos Knights', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Eastern Palace'),
'Big Key (Eastern Palace)': (False, False, 'BigKey', 0x9D, 'A big key to Armos Knights', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Eastern Palace'),
'Compass (Eastern Palace)': (False, True, 'Compass', 0x8D, 'Now you can find the Armos Knights!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Eastern Palace'),
@ -123,10 +124,10 @@ item_table = {'Bow': (True, False, None, 0x0B, 'You have\nchosen the\narcher cla
'Big Key (Tower of Hera)': (False, False, 'BigKey', 0x95, 'A big key to Hera', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Tower of Hera'),
'Compass (Tower of Hera)': (False, True, 'Compass', 0x85, 'Now you can find Moldorm!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Tower of Hera'),
'Map (Tower of Hera)': (False, True, 'Map', 0x75, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Tower of Hera'),
'Small Key (Escape)': (False, False, 'SmallKey', 0xA0, 'A small key to the castle', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Hyrule Castle'),
'Big Key (Escape)': (False, False, 'BigKey', 0x9F, 'A big key to the castle', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Hyrule Castle'),
'Compass (Escape)': (False, True, 'Compass', 0x8F, 'Now you can find no boss!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Hyrule Castle'),
'Map (Escape)': (False, True, 'Map', 0x7F, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Hyrule Castle'),
'Small Key (Hyrule Castle)': (False, False, 'SmallKey', 0xA0, 'A small key to the castle', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Hyrule Castle'),
'Big Key (Hyrule Castle)': (False, False, 'BigKey', 0x9F, 'A big key to the castle', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Hyrule Castle'),
'Compass (Hyrule Castle)': (False, True, 'Compass', 0x8F, 'Now you can find no boss!', 'and the compass', 'the magnetic kid', 'compass for sale', 'magnetic fungus', 'compass boy finds boss again', 'a compass to Hyrule Castle'),
'Map (Hyrule Castle)': (False, True, 'Map', 0x7F, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Hyrule Castle'),
'Small Key (Agahnims Tower)': (False, False, 'SmallKey', 0xA4, 'A small key to Agahnim', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key to Castle Tower'),
# doors-specific items, baserom will not be able to understand these
'Big Key (Agahnims Tower)': (False, False, 'BigKey', 0x9B, 'A big key to Agahnim', 'and the big key', 'the big-unlock kid', 'big key for sale', 'face key fungus', 'key boy opens chest again', 'a big key to Castle Tower'),
@ -167,7 +168,7 @@ item_table = {'Bow': (True, False, None, 0x0B, 'You have\nchosen the\narcher cla
'Map (Ganons Tower)': (False, True, 'Map', 0x72, 'A tightly folded map rests here', 'and the map', 'cartography kid', 'map for sale', 'a map to shrooms', 'map boy navigates again', 'a map to Ganon\'s Tower'),
'Small Key (Universal)': (False, True, None, 0xAF, 'A small key for any door', 'and the key', 'the unlocking kid', 'keys for sale', 'unlock the fungus', 'key boy opens door again', 'a small key'),
'Nothing': (False, False, None, 0x5A, 'Some Hot Air', 'and the Nothing', 'the zen kid', 'outright theft', 'shroom theft', 'empty boy is bored again', 'nothing'),
'Bee Trap': (False, False, None, 0xB0, 'We will sting your face a whole lot!', 'and the sting buddies', 'the beekeeper kid', 'insects for sale', 'shroom pollenation', 'bottle boy has mad bees again', 'friendship'),
'Bee Trap': (False, False, None, 0xB0, 'We will sting your face a whole lot!', 'and the sting buddies', 'the beekeeper kid', 'insects for sale', 'shroom pollenation', 'bottle boy has mad bees again', 'Friendship'),
'Red Potion': (False, False, None, 0x2E, 'Hearty red goop!', 'and the red goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has red goo again', 'a red potion'),
'Green Potion': (False, False, None, 0x2F, 'Refreshing green goop!', 'and the green goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has green goo again', 'a green potion'),
'Blue Potion': (False, False, None, 0x30, 'Delicious blue goop!', 'and the blue goo', 'the liquid kid', 'potion for sale', 'free samples', 'bottle boy has blue goo again', 'a blue potion'),
@ -185,7 +186,7 @@ lookup_id_to_name = {data[3]: name for name, data in item_table.items()}
hint_blacklist = {"Triforce"}
item_name_groups = {"Bows": {"Bow", "Silver Arrows", "Progressive Bow (Alt)", "Progressive Bow"},
item_name_groups = {"Bows": {"Bow", "Silver Arrows", "Silver Bow", "Progressive Bow (Alt)", "Progressive Bow"},
"Gloves": {"Power Glove", "Progressive Glove", "Titans Mitts"},
"Medallions": {"Ether", "Bombos", "Quake"}}
# generic groups, (Name, substring)
@ -208,3 +209,5 @@ for basename, substring in _simple_groups:
tempset.add(itemname)
del (_simple_groups)
progression_items = {name for name, data in item_table.items() if type(data[3]) == int and data[0]}

View File

@ -2,6 +2,7 @@ MIT License
Copyright (c) 2017 LLCoolDave
Copyright (c) 2020 Berserker66
Copyright (c) 2020 CaitSith2
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

137
Main.py
View File

@ -10,16 +10,26 @@ import zlib
from BaseClasses import World, CollectionState, Item, Region, Location, Shop
from Items import ItemFactory
from Regions import create_regions, create_shops, mark_light_world_regions
from Regions import create_regions, create_shops, mark_light_world_regions, lookup_vanilla_location_to_entrance
from InvertedRegions import create_inverted_regions, mark_dark_world_regions
from EntranceShuffle import link_entrances, link_inverted_entrances
from Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, JsonRom, get_hash_string
from Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string
from Rules import set_rules
from Dungeons import create_dungeons, fill_dungeons, fill_dungeons_restrictive
from Fill import distribute_items_cutoff, distribute_items_staleness, distribute_items_restrictive, flood_items, balance_multiworld_progression
from Fill import distribute_items_cutoff, distribute_items_staleness, distribute_items_restrictive, flood_items, \
balance_multiworld_progression
from ItemList import generate_itempool, difficulties, fill_prizes
from Utils import output_path, parse_player_names, get_options, __version__
seeddigits = 20
def get_seed(seed=None):
if seed is None:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)
return seed
def main(args, seed=None):
if args.outputpath:
@ -33,11 +43,7 @@ def main(args, seed=None):
args.item_functionality, args.timer, args.progressive.copy(), args.goal, args.algorithm,
args.accessibility, args.shuffleganon, args.retro, args.custom, args.customitemarray, args.hints)
logger = logging.getLogger('')
if seed is None:
random.seed(None)
world.seed = random.randint(0, 999999999)
else:
world.seed = int(seed)
world.seed = get_seed(seed)
random.seed(world.seed)
world.remote_items = args.remote_items.copy()
@ -58,6 +64,7 @@ def main(args, seed=None):
world.progressive = args.progressive.copy()
world.dungeon_counters = args.dungeon_counters.copy()
world.glitch_boots = args.glitch_boots.copy()
world.triforce_pieces_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.progression_balancing = {player: not balance for player, balance in args.skip_progression_balancing.items()}
@ -87,6 +94,8 @@ def main(args, seed=None):
world.push_precollected(item)
world.local_items[player] = {item.strip() for item in args.local_items[player].split(',')}
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player], world.triforce_pieces_required[player])
if world.mode[player] != 'inverted':
create_regions(world, player)
else:
@ -152,10 +161,9 @@ def main(args, seed=None):
logger.info('Patching ROM.')
outfilebase = 'ER_%s' % (args.outputname if args.outputname else world.seed)
outfilebase = 'BM_%s' % (args.outputname if args.outputname else world.seed)
rom_names = []
jsonout = {}
def _gen_rom(team: int, player: int):
sprite_random_on_hit = type(args.sprite[player]) is str and args.sprite[player].lower() == 'randomonhit'
@ -163,15 +171,13 @@ def main(args, seed=None):
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or args.shufflepots[player] or sprite_random_on_hit)
rom = JsonRom() if args.jsonout or use_enemizer else LocalRom(args.rom)
rom = LocalRom(args.rom)
patch_rom(world, rom, player, team, use_enemizer)
if use_enemizer and (args.enemizercli or not args.jsonout):
patch_enemizer(world, player, rom, args.rom, args.enemizercli, args.shufflepots[player],
if use_enemizer:
patch_enemizer(world, player, rom, args.enemizercli,
sprite_random_on_hit)
if not args.jsonout:
rom = LocalRom.fromJsonRom(rom, args.rom, 0x400000)
if args.race:
patch_race_rom(rom)
@ -182,49 +188,46 @@ def main(args, seed=None):
args.fastmenu[player], args.disablemusic[player], args.sprite[player],
args.ow_palettes[player], args.uw_palettes[player])
if args.jsonout:
jsonout[f'patch_t{team}_p{player}'] = rom.patches
else:
mcsb_name = ''
if all([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]):
mcsb_name = '-keysanity'
elif [world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]].count(True) == 1:
mcsb_name = '-mapshuffle' if world.mapshuffle[player] else '-compassshuffle' if world.compassshuffle[
player] else '-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]):
mcsb_name = '-%s%s%s%sshuffle' % (
'M' if world.mapshuffle[player] else '', 'C' if world.compassshuffle[player] else '',
'S' if world.keyshuffle[player] else '', 'B' if world.bigkeyshuffle[player] else '')
mcsb_name = ''
if all([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]):
mcsb_name = '-keysanity'
elif [world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]].count(True) == 1:
mcsb_name = '-mapshuffle' if world.mapshuffle[player] else '-compassshuffle' if world.compassshuffle[
player] else '-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]):
mcsb_name = '-%s%s%s%sshuffle' % (
'M' if world.mapshuffle[player] else '', 'C' if world.compassshuffle[player] else '',
'S' if world.keyshuffle[player] else '', 'B' if world.bigkeyshuffle[player] else '')
outfilepname = f'_T{team + 1}' if world.teams > 1 else ''
if world.players > 1:
outfilepname += f'_P{player}'
if world.players > 1 or world.teams > 1:
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" if world.player_names[player][
team] != 'Player%d' % player else ''
outfilesuffix = ('_%s_%s-%s-%s-%s%s_%s-%s%s%s%s%s' % (world.logic[player], world.difficulty[player],
world.difficulty_adjustments[player],
world.mode[player], world.goal[player],
"" if world.timer[player] in [False,
'display'] else "-" +
world.timer[
player],
world.shuffle[player], world.algorithm,
mcsb_name,
"-retro" if world.retro[player] else "",
"-prog_" + world.progressive[player] if
world.progressive[player] in ['off',
'random'] else "",
"-nohints" if not world.hints[
player] else "")) if not args.outputname else ''
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
rom.write_to_file(rompath)
if args.create_diff:
import Patch
Patch.create_patch_file(rompath)
outfilepname = f'_T{team + 1}' if world.teams > 1 else ''
if world.players > 1:
outfilepname += f'_P{player}'
if world.players > 1 or world.teams > 1:
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" if world.player_names[player][
team] != 'Player%d' % player else ''
outfilesuffix = ('_%s_%s-%s-%s-%s%s_%s-%s%s%s%s%s' % (world.logic[player], world.difficulty[player],
world.difficulty_adjustments[player],
world.mode[player], world.goal[player],
"" if world.timer[player] in [False,
'display'] else "-" +
world.timer[
player],
world.shuffle[player], world.algorithm,
mcsb_name,
"-retro" if world.retro[player] else "",
"-prog_" + world.progressive[player] if
world.progressive[player] in ['off',
'random'] else "",
"-nohints" if not world.hints[
player] else "")) if not args.outputname else ''
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
rom.write_to_file(rompath)
if args.create_diff:
import Patch
Patch.create_patch_file(rompath)
return (player, team, list(rom.name))
if not args.suppress_rom:
@ -253,7 +256,12 @@ def main(args, seed=None):
main_entrance = get_entrance_to_region(region)
for location in region.locations:
if type(location.address) == int: # skips events and crystals
er_hint_data[region.player][location.address] = main_entrance.name
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
precollected_items = [[] for player in range(world.players)]
for item in world.precollected_items:
precollected_items[item.player - 1].append(item.code)
multidata = zlib.compress(json.dumps({"names": parsed_names,
"roms": rom_names,
@ -265,20 +273,17 @@ def main(args, seed=None):
type(location.address) is int],
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items
}).encode("utf-8"), 9)
if args.jsonout:
jsonout["multidata"] = list(multidata)
else:
with open(output_path('%s_multidata' % outfilebase), 'wb') as f:
f.write(multidata)
with open(output_path('%s.multidata' % outfilebase), 'wb') as f:
f.write(multidata)
if not args.skip_playthrough:
logger.info('Calculating playthrough.')
create_playthrough(world)
if args.jsonout:
print(json.dumps({**jsonout, 'spoiler': world.spoiler.to_json()}))
elif args.create_spoiler:
if args.create_spoiler:
world.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
logger.info('Done. Enjoy.')

View File

@ -48,9 +48,16 @@ class Context():
self.snes_address = snes_address
self.server_address = server_address
# WebUI Stuff
self.ui_node = WebUI.WebUiClient()
self.custom_address = None
self.webui_socket_port: typing.Optional[int] = port
self.hint_cost = 0
self.check_points = 0
self.forfeit_mode = ''
self.remaining_mode = ''
self.hint_points = 0
# End WebUI Stuff
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
@ -436,7 +443,7 @@ async def get_snes_devices(ctx: Context):
async def snes_connect(ctx: Context, address):
if ctx.snes_socket is not None:
if ctx.snes_socket is not None and ctx.snes_state == SNES_CONNECTED:
ctx.ui_node.log_error('Already connected to snes')
return
@ -475,7 +482,7 @@ async def snes_connect(ctx: Context, address):
ctx.snes_attached_device = (devices.index(device), device)
ctx.ui_node.send_connection_status(ctx)
if 'SD2SNES'.lower() in device.lower() or (len(device) == 4 and device[:3] == 'COM'):
if 'sd2snes' in device.lower() or (len(device) == 4 and device[:3] == 'COM'):
ctx.ui_node.log_info("SD2SNES Detected")
ctx.is_sd2snes = True
await ctx.snes_socket.send(json.dumps({"Opcode" : "Info", "Space" : "SNES"}))
@ -761,7 +768,13 @@ async def process_server_cmd(ctx: Context, cmd, args):
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"A !hint costs {args['hint_cost']} points and you get {args['location_check_points']} for each location checked.")
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'])
ctx.check_points = int(args['location_check_points'])
ctx.forfeit_mode = args['forfeit_mode']
ctx.remaining_mode = args['remaining_mode']
ctx.ui_node.send_game_info(ctx)
if len(args['players']) < 1:
ctx.ui_node.log_info('No player connected')
else:
@ -805,6 +818,8 @@ async def process_server_cmd(ctx: Context, cmd, args):
msgs.append(['LocationScouts', list(ctx.locations_scouted)])
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
await send_finished_game(ctx)
elif cmd == 'ReceivedItems':
start_index, items = args
@ -880,8 +895,13 @@ async def process_server_cmd(ctx: Context, cmd, args):
elif cmd == "AliasUpdate":
ctx.player_names = {p: n for p, n in args}
elif cmd == 'Print':
ctx.ui_node.log_info(args)
elif cmd == 'HintPointUpdate':
ctx.hint_points = args[0]
else:
logging.debug(f"unknown command {args}")
@ -1045,6 +1065,7 @@ async def track_locations(ctx : Context, roomid, roomdata):
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.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():
@ -1102,6 +1123,14 @@ async def track_locations(ctx : Context, roomid, roomdata):
await ctx.send_msgs([['LocationChecks', new_locations]])
async def send_finished_game(ctx: Context):
try:
await ctx.send_msgs([['GameFinished', '']])
ctx.finished_game = True
except Exception as ex:
logging.exception(ex)
async def game_watcher(ctx : Context):
prev_game_timer = 0
perf_counter = time.perf_counter()
@ -1141,11 +1170,7 @@ async def game_watcher(ctx : Context):
delay = 7 if ctx.slow_mode else 2
if gameend[0]:
if not ctx.finished_game:
try:
await ctx.send_msgs([['GameFinished', '']])
ctx.finished_game = True
except Exception as ex:
logging.exception(ex)
await(send_finished_game(ctx))
if time.perf_counter() - perf_counter < delay:
continue
@ -1223,6 +1248,10 @@ async def websocket_server(websocket: websockets.WebSocketServerProtocol, path,
ctx.ui_node.send_connection_status(ctx)
elif data['content'] == 'devices':
await get_snes_devices(ctx)
elif data['content'] == 'gameInfo':
ctx.ui_node.send_game_info(ctx)
elif data['content'] == 'checkData':
ctx.ui_node.send_location_check(ctx, 'Waiting for check...')
elif data['type'] == 'webConfig':
if 'serverAddress' in data['content']:

View File

@ -118,8 +118,8 @@ if __name__ == "__main__":
seedname = segment
break
multidataname = f"ER_{seedname}_multidata"
spoilername = f"ER_{seedname}_Spoiler.txt"
multidataname = f"BM_{seedname}.multidata"
spoilername = f"BM_{seedname}_Spoiler.txt"
romfilename = ""
if player_name:
@ -156,7 +156,7 @@ if __name__ == "__main__":
print(f"Removed {file} which is now present in the zipfile")
zipname = os.path.join(output_path, f"ER_{seedname}.{typical_zip_ending}")
zipname = os.path.join(output_path, f"BM_{seedname}.{typical_zip_ending}")
print(f"Creating zipfile {zipname}")
ipv4 = (host if host else get_public_ipv4()) + ":" + str(port)

View File

@ -11,6 +11,7 @@ import typing
import inspect
import weakref
import datetime
import threading
import ModuleUpdate
@ -33,6 +34,7 @@ CLIENT_PLAYING = 0
CLIENT_GOAL = 1
class Client(Endpoint):
version: typing.List[int] = [0, 0, 0]
tags: typing.List[str] = []
@ -46,9 +48,8 @@ class Client(Endpoint):
self.send_index = 0
self.tags = []
self.version = [0, 0, 0]
self.messageprocessor = ClientMessageProcessor(ctx, self)
self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx)
ctx.client_connection_timers[self.team, self.slot] = datetime.datetime.now(datetime.timezone.utc)
@property
def wants_item_notification(self):
@ -57,8 +58,10 @@ class Client(Endpoint):
class Context(Node):
def __init__(self, host: str, port: int, password: str, location_check_points: int, hint_cost: int,
item_cheat: bool, forfeit_mode: str = "disabled", remaining_mode: str = "disabled"):
item_cheat: bool, forfeit_mode: str = "disabled", remaining_mode: str = "disabled",
auto_shutdown: typing.SupportsFloat = 0):
super(Context, self).__init__()
self.shutdown_task = None
self.data_filename = None
self.save_filename = None
self.saving = False
@ -83,20 +86,27 @@ class Context(Node):
self.item_cheat = item_cheat
self.running = True
self.client_activity_timers: typing.Dict[
typing.Tuple[int, int], datetime.datetime] = {} # datatime of last new item check
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last new item check
self.client_connection_timers: typing.Dict[
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last connection
self.client_game_state: typing.Dict[typing.Tuple[int, int], int] = collections.defaultdict(int)
self.er_hint_data: typing.Dict[int, typing.Dict[int, str]] = {}
self.auto_shutdown = auto_shutdown
self.commandprocessor = ServerCommandProcessor(self)
self.embedded_blacklist = {"host", "port"}
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
self.auto_save_interval = 60 # in seconds
self.auto_saver_thread = None
self.save_dirty = False
self.tags = ['Berserker']
def load(self, multidatapath: str):
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
with open(multidatapath, 'rb') as f:
self._load(f)
self._load(json.loads(zlib.decompress(f.read()).decode("utf-8-sig")),
use_embedded_server_options)
self.data_filename = multidatapath
def _load(self, fileobj):
jsonobj = json.loads(zlib.decompress(fileobj.read()).decode("utf-8-sig"))
def _load(self, jsonobj: dict, use_embedded_server_options: bool):
for team, names in enumerate(jsonobj['names']):
for player, name in enumerate(names, 1):
self.player_names[(team, player)] = name
@ -106,8 +116,44 @@ class Context(Node):
if "er_hint_data" in jsonobj:
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
for player, loc_data in jsonobj["er_hint_data"].items()}
if use_embedded_server_options:
server_options = jsonobj.get("server_options", {})
self._set_options(server_options)
def init_save(self, enabled: bool):
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)
def save(self, now=False) -> bool:
if self.saving:
if now:
self.save_dirty = False
return self._save()
self.save_dirty = True
return True
return False
def _save(self) -> bool:
try:
jsonstr = json.dumps(self.get_save())
with open(self.save_filename, "wb") as f:
f.write(zlib.compress(jsonstr.encode("utf-8")))
except Exception as e:
logging.exception(e)
return False
else:
return True
def init_save(self, enabled: bool = True):
self.saving = enabled
if self.saving:
if not self.save_filename:
@ -121,6 +167,24 @@ class Context(Node):
logging.error('No save data found, starting a new game')
except Exception as e:
logging.exception(e)
self._start_async_saving()
def _start_async_saving(self):
if not self.auto_saver_thread:
def save_regularly():
import time
while self.running:
time.sleep(self.auto_save_interval)
if self.save_dirty:
logging.debug("Saving multisave via thread.")
self.save_dirty = False
self._save()
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
import atexit
atexit.register(self._save) # make sure we save on exit too
def get_save(self) -> dict:
d = {
@ -130,7 +194,11 @@ class Context(Node):
"hints": tuple((key, list(value)) for key, value in self.hints.items()),
"location_checks": tuple((key, tuple(value)) for key, value in self.location_checks.items()),
"name_aliases": tuple((key, value) for key, value in self.name_aliases.items()),
"client_game_state": tuple((key, value) for key, value in self.client_game_state.items())
"client_game_state": tuple((key, value) for key, value in self.client_game_state.items()),
"client_activity_timers": tuple(
(key, value.timestamp()) for key, value in self.client_activity_timers.items()),
"client_connection_timers": tuple(
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
}
return d
@ -161,7 +229,16 @@ class Context(Node):
self.name_aliases.update({tuple(key): value for key, value in savedata["name_aliases"]})
if "client_game_state" in savedata:
self.client_game_state.update({tuple(key): value for key, value in savedata["client_game_state"]})
if "client_activity_timers" in savedata:
self.client_connection_timers.update(
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_connection_timers"]})
self.client_activity_timers.update(
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_activity_timers"]})
self.location_checks.update({tuple(key): set(value) for key, value in savedata["location_checks"]})
logging.info(f'Loaded save file with {sum([len(p) for p in received_items.values()])} received items '
f'for {len(received_items)} players')
@ -254,11 +331,11 @@ async def on_client_connected(ctx: Context, client: Client):
in ctx.endpoints if client.auth],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ['Berserker'],
'tags': ctx.tags,
'version': Utils._version_tuple,
'forfeit_mode': ctx.forfeit_mode,
'remaining_mode': ctx.remaining_mode,
'hint_cost' : ctx.hint_cost,
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points
}]])
@ -274,7 +351,7 @@ async def on_client_joined(ctx: Context, client: Client):
client.team + 1,
".".join(str(x) for x in client.version),
client.tags))
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def on_client_left(ctx: Context, client: Client):
ctx.notify_all("%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
@ -292,7 +369,7 @@ async def countdown(ctx: Context, timer):
ctx.countdown_timer -= 1
await asyncio.sleep(1)
ctx.notify_all(f'[Server]: GO')
ctx.countdown_timer = 0
async def missing(ctx: Context, client: Client, locations: list):
await ctx.send_msgs(client, [['Missing', {
@ -386,7 +463,10 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations):
send_new_items(ctx)
if found_items:
save(ctx)
for client in ctx.endpoints:
if client.team == team and client.slot == slot:
asyncio.create_task(ctx.send_msgs(client, [["HintPointUpdate", (get_client_points(ctx, client),)]]))
ctx.save()
def notify_team(ctx: Context, team: int, text: str):
@ -394,15 +474,6 @@ def notify_team(ctx: Context, team: int, text: str):
ctx.broadcast_team(team, [['Print', text]])
def save(ctx: Context):
if ctx.saving:
try:
jsonstr = json.dumps(ctx.get_save())
with open(ctx.save_filename, "wb") as f:
f.write(zlib.compress(jsonstr.encode("utf-8")))
except Exception as e:
logging.exception(e)
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[Utils.Hint]:
hints = []
@ -550,9 +621,33 @@ class CommandProcessor(metaclass=CommandMeta):
self.output(str(exception))
class CommonCommandProcessor(CommandProcessor):
ctx: Context
simple_options = {"hint_cost": int,
"location_check_points": int,
"password": str,
"forfeit_mode": str,
"item_cheat": bool,
"auto_save_interval": int}
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
asyncio.create_task(countdown(self.ctx, timer))
return True
def _cmd_options(self):
"""List all current options. Warning: lists password."""
self.output("Current options:")
for option in self.simple_options:
self.output(f"Option {option} is set to {getattr(self.ctx, option)}")
class ClientMessageProcessor(CommandProcessor):
marker = "!"
ctx: Context
def __init__(self, ctx: Context, client: Client):
self.ctx = ctx
@ -625,48 +720,48 @@ class ClientMessageProcessor(CommandProcessor):
"Your client is too old to send game beaten information. Please update, load you savegame and reconnect.")
return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
asyncio.create_task(countdown(self.ctx, timer))
return True
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)
if len(locations) > 0:
asyncio.create_task(missing(self.ctx, self.client, locations))
if self.client.version < [2, 3, 0]:
buffer = ""
for location in locations:
buffer += f'Missing: {location}\n'
self.output(buffer + f"Found {len(locations)} missing location checks")
else:
asyncio.create_task(missing(self.ctx, self.client, locations))
else:
self.output("No missing location checks found.")
return True
@mark_raw
def _cmd_alias(self, alias_name: str = ""):
"""Set your alias to the passed name."""
if alias_name:
alias_name = alias_name[:15].strip()
alias_name = alias_name[:16].strip()
self.ctx.name_aliases[self.client.team, self.client.slot] = alias_name
self.output(f"Hello, {alias_name}")
update_aliases(self.ctx, self.client.team)
save(self.ctx)
self.ctx.save()
return True
elif (self.client.team, self.client.slot) in self.ctx.name_aliases:
del (self.ctx.name_aliases[self.client.team, self.client.slot])
self.output("Removed Alias")
update_aliases(self.ctx, self.client.team)
save(self.ctx)
self.ctx.save()
return True
return False
@mark_raw
def _cmd_getitem(self, item_name: str) -> bool:
"""Cheat in an item"""
"""Cheat in an item, if it is enabled on this server"""
if self.ctx.item_cheat:
item_name, usable, response = get_intended_text(item_name, Items.item_table.keys())
if usable:
@ -685,9 +780,7 @@ class ClientMessageProcessor(CommandProcessor):
@mark_raw
def _cmd_hint(self, item_or_location: str = "") -> bool:
"""Use !hint {item_name/location_name}, for example !hint Lamp or !hint Link's House. """
points_available = self.ctx.location_check_points * len(
self.ctx.location_checks[self.client.team, self.client.slot]) - \
self.ctx.hint_cost * self.ctx.hints_used[self.client.team, self.client.slot]
points_available = get_client_points(self.ctx, self.client)
if not item_or_location:
self.output(f"A hint costs {self.ctx.hint_cost} points. "
f"You have {points_available} points.")
@ -746,10 +839,11 @@ class ClientMessageProcessor(CommandProcessor):
self.ctx.hints[self.client.team, hint.receiving_player].add(hint)
else:
self.output(
"Could not pay for everything. Rerun the hint later with more points to get the remaining hints.")
if not_found_hints:
self.output(
"Could not pay for everything. Rerun the hint later with more points to get the remaining hints.")
notify_hints(self.ctx, self.client.team, found_hints + hints)
save(self.ctx)
self.ctx.save()
return True
else:
@ -767,6 +861,10 @@ class ClientMessageProcessor(CommandProcessor):
return False
def get_client_points(ctx: Context, client: Client) -> int:
return (ctx.location_check_points * len(ctx.location_checks[client.team, client.slot]) -
ctx.hint_cost * ctx.hints_used[client.team, client.slot])
async def process_client_cmd(ctx: Context, client: Client, cmd, args):
if type(cmd) is not str:
await ctx.send_msgs(client, [['InvalidCmd']])
@ -787,8 +885,17 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
errors.add('InvalidRom')
else:
team, slot = ctx.rom_names[tuple(args['rom'])]
if any([c.slot == slot and c.team == team for c in ctx.endpoints if c.auth]):
errors.add('SlotAlreadyTaken')
# this can only ever be 0 or 1 elements
clients = [c for c in ctx.endpoints if c.auth and c.slot == slot and c.team == team]
if clients:
# likely same player with a "ghosted" slot. We bust the ghost.
if "uuid" in args and ctx.client_ids[team, slot] == args["uuid"]:
await clients[0].socket.close() # we have to await the DC of the ghost, so not to create data pasta
client.name = ctx.player_names[(team, slot)]
client.team = team
client.slot = slot
else:
errors.add('SlotAlreadyTaken')
else:
client.name = ctx.player_names[(team, slot)]
client.team = team
@ -797,6 +904,7 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
if errors:
await ctx.send_msgs(client, [['ConnectionRefused', list(errors)]])
else:
ctx.client_ids[client.team, client.slot] = args.get("uuid", None)
client.auth = True
client.version = args.get('version', Client.version)
client.tags = args.get('tags', Client.tags)
@ -868,14 +976,7 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
client.messageprocessor(args)
def set_password(ctx: Context, password):
ctx.password = password
logging.warning('Password set to ' + password if password else 'Password disabled')
class ServerCommandProcessor(CommandProcessor):
ctx: Context
class ServerCommandProcessor(CommonCommandProcessor):
def __init__(self, ctx: Context):
self.ctx = ctx
super(ServerCommandProcessor, self).__init__()
@ -897,9 +998,13 @@ class ServerCommandProcessor(CommandProcessor):
def _cmd_save(self) -> bool:
"""Save current state to multidata"""
save(self.ctx)
self.output("Game saved")
return True
if self.ctx.saving:
self.ctx.save(True)
self.output("Game saved")
return True
else:
self.output("Saving is disabled.")
return False
def _cmd_players(self) -> bool:
"""Get information about connected players"""
@ -909,11 +1014,14 @@ class ServerCommandProcessor(CommandProcessor):
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
asyncio.create_task(self.ctx.server.ws_server._close())
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.running = False
return True
@mark_raw
def _cmd_alias(self, player_name_then_alias_name):
"""Set a player's alias, by listing their base name and then their intended alias."""
player_name, alias_name = player_name_then_alias_name.split(" ", 1)
player_name, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
@ -924,24 +1032,18 @@ class ServerCommandProcessor(CommandProcessor):
self.ctx.name_aliases[team, slot] = alias_name
self.output(f"Named {player_name} as {alias_name}")
update_aliases(self.ctx, team)
save(self.ctx)
self.ctx.save()
return True
else:
del (self.ctx.name_aliases[team, slot])
self.output(f"Removed Alias for {player_name}")
update_aliases(self.ctx, team)
save(self.ctx)
self.ctx.save()
return True
else:
self.output(response)
return False
@mark_raw
def _cmd_password(self, new_password: str = "") -> bool:
"""Set the server password. Leave the password text empty to remove the password"""
set_password(self.ctx, new_password if new_password else None)
return True
@mark_raw
def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player's game to their intended recipients"""
@ -1002,6 +1104,25 @@ class ServerCommandProcessor(CommandProcessor):
self.output(response)
return False
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)
if attrtype:
if attrtype == bool:
def attrtype(input_text: str):
if input_text.lower() in {"off", "0", "false", "none", "null", "no"}:
return False
else:
return True
setattr(self.ctx, option_name, attrtype(option))
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())
self.output(f"Unrecognized Option {option_name}, known: "
f"{', '.join(known)}")
return False
async def console(ctx: Context):
session = prompt_toolkit.PromptSession()
@ -1045,15 +1166,46 @@ def parse_args() -> argparse.Namespace:
disabled: !remaining is never available
goal: !remaining can be used after goal completion
''')
parser.add_argument('--auto_shutdown', default=defaults["auto_shutdown"], type=int,
help="automatically shut down the server after this many minutes without new location checks. "
"0 to keep running. Not yet implemented.")
parser.add_argument('--use_embedded_options', action="store_true",
help='retrieve forfeit, remaining and hint options from the multidata file,'
' instead of host.yaml')
args = parser.parse_args()
return args
async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown * 60)
while ctx.running:
if not ctx.client_activity_timers.values():
asyncio.create_task(ctx.server.ws_server._close())
ctx.running = False
if to_cancel:
for task in to_cancel:
task.cancel()
logging.info("Shutting down due to inactivity.")
else:
newest_activity = max(ctx.client_activity_timers.values())
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown * 60 - delta.total_seconds()
if seconds < 0:
asyncio.create_task(ctx.server.ws_server._close())
ctx.running = False
if to_cancel:
for task in to_cancel:
task.cancel()
logging.info("Shutting down due to inactivity.")
else:
await asyncio.sleep(seconds)
async def main(args: argparse.Namespace):
logging.basicConfig(format='[%(asctime)s] %(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
ctx = Context(args.host, args.port, args.password, args.location_check_points, args.hint_cost,
not args.disable_item_cheat, args.forfeit_mode, args.remaining_mode)
not args.disable_item_cheat, args.forfeit_mode, args.remaining_mode, args.auto_shutdown)
data_filename = args.multidata
@ -1063,9 +1215,9 @@ async def main(args: argparse.Namespace):
import tkinter.filedialog
root = tkinter.Tk()
root.withdraw()
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*multidata"),))
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.multidata"),))
ctx.load(data_filename)
ctx.load(data_filename, args.use_embedded_options)
except Exception as e:
logging.exception('Failed to read multiworld data (%s)' % e)
@ -1078,9 +1230,20 @@ async def main(args: argparse.Namespace):
ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
'No password' if not ctx.password else 'Password: %s' % ctx.password))
await ctx.server
await console(ctx)
console_task = asyncio.create_task(console(ctx))
if ctx.auto_shutdown:
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
await console_task
if ctx.shutdown_task:
await ctx.shutdown_task
client_message_processor = ClientMessageProcessor
if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.run_until_complete(main(parse_args()))
try:
asyncio.run(main(parse_args()))
except asyncio.exceptions.CancelledError:
pass

View File

@ -14,11 +14,11 @@ from Utils import parse_yaml
from Rom import get_sprite_from_name
from EntranceRandomizer import parse_arguments
from Main import main as ERmain
from Main import get_seed, seeddigits
from Items import item_name_groups, item_table
def main():
def mystery_argparse():
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
multiargs, _ = parser.parse_known_args()
@ -46,15 +46,17 @@ def main():
for player in range(1, multiargs.multi + 1):
parser.add_argument(f'--p{player}', help=argparse.SUPPRESS)
args = parser.parse_args()
return args
if args.seed is None:
random.seed(None)
seed = random.randint(0, 999999999)
else:
seed = args.seed
def main(args=None):
if not args:
args = mystery_argparse()
seed = get_seed(args.seed)
random.seed(seed)
seedname = "M"+(f"{random.randint(0, 999999999)}".zfill(9))
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}")
weights_cache = {}
@ -63,14 +65,14 @@ def main():
weights_cache[args.weights] = get_weights(args.weights)
except Exception as e:
raise ValueError(f"File {args.weights} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights} >> {weights_cache[args.weights]['description']}")
print(f"Weights: {args.weights} >> {get_choice('description', weights_cache[args.weights], 'No description specified')}")
if args.meta:
try:
weights_cache[args.meta] = get_weights(args.meta)
except Exception as e:
raise ValueError(f"File {args.meta} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta]
print(f"Meta: {args.meta} >> {meta_weights['meta_description']}")
print(f"Meta: {args.meta} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta")
@ -80,7 +82,7 @@ def main():
try:
if path not in weights_cache:
weights_cache[path] = get_weights(path)
print(f"P{player} Weights: {path} >> {weights_cache[path]['description']}")
print(f"P{player} Weights: {path} >> {get_choice('description', weights_cache[path], 'No description specified')}")
except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
@ -223,15 +225,17 @@ def convert_to_on_off(value):
return {True: "on", False: "off"}.get(value, value)
def get_choice(option, root) -> typing.Any:
def get_choice(option, root, value=None) -> typing.Any:
if option not in root:
return None
return value
if type(root[option]) is not dict:
return interpret_on_off(root[option])
if not root[option]:
return None
return interpret_on_off(
random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0])
return value
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.")
def handle_name(name: str):
@ -251,13 +255,13 @@ def roll_settings(weights):
ret.name = handle_name(ret.name)
glitches_required = get_choice('glitches_required', weights)
if glitches_required not in ['none', 'no_logic', 'overworld_glitches']:
if glitches_required not in ['none', 'no_logic', 'overworld_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG and No Logic supported")
glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches'}[
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches' : 'minorglitches'}[
glitches_required]
ret.progression_balancing = get_choice('progression_balancing',
weights) if 'progression_balancing' in weights else True
ret.progression_balancing = get_choice('progression_balancing', weights, True)
# item_placement = get_choice('item_placement')
# not supported in ER
@ -269,10 +273,10 @@ def roll_settings(weights):
elif not dungeon_items:
dungeon_items = ""
ret.mapshuffle = get_choice('map_shuffle', weights) if 'map_shuffle' in weights else 'm' in dungeon_items
ret.compassshuffle = get_choice('compass_shuffle', weights) if 'compass_shuffle' in weights else 'c' in dungeon_items
ret.keyshuffle = get_choice('smallkey_shuffle', weights) if 'smallkey_shuffle' in weights else 's' in dungeon_items
ret.bigkeyshuffle = get_choice('bigkey_shuffle', weights) if 'bigkey_shuffle' in weights else 'b' in dungeon_items
ret.mapshuffle = get_choice('map_shuffle', weights, 'm' in dungeon_items)
ret.compassshuffle = get_choice('compass_shuffle', weights, 'c' in dungeon_items)
ret.keyshuffle = get_choice('smallkey_shuffle', weights, 's' in dungeon_items)
ret.bigkeyshuffle = get_choice('bigkey_shuffle', weights, 'b' in dungeon_items)
ret.accessibility = get_choice('accessibility', weights)
@ -286,17 +290,21 @@ def roll_settings(weights):
'pedestal': 'pedestal',
'triforce_hunt': 'triforcehunt',
'triforce-hunt': 'triforcehunt', # deprecated, moving all goals to `_`
'local_triforce_hunt': 'localtriforcehunt'
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt'
}[goal]
ret.openpyramid = goal == 'fast_ganon'
ret.openpyramid = goal in ['fast_ganon', 'ganon_triforce_hunt', 'local_ganon_triforce_hunt']
ret.crystals_gt = get_choice('tower_open', weights)
ret.crystals_ganon = get_choice('ganon_open', weights)
ret.triforce_pieces_required = get_choice('triforce_pieces_required',
weights) if "triforce_pieces_required" in weights else 20
ret.triforce_pieces_required = min(max(1, int(ret.triforce_pieces_required)), 30)
ret.triforce_pieces_available = get_choice('triforce_pieces_available', weights, 30)
ret.triforce_pieces_available = min(max(1, int(ret.triforce_pieces_available)), 90)
ret.triforce_pieces_required = get_choice('triforce_pieces_required', weights, 20)
ret.triforce_pieces_required = min(max(1, int(ret.triforce_pieces_required)), 90)
ret.mode = get_choice('world_state', weights)
if ret.mode == 'retro':
@ -323,7 +331,9 @@ def roll_settings(weights):
ret.shuffleenemies = {'none': 'none',
'shuffled': 'shuffled',
'random': 'chaos'
'random': 'chaos',
'chaosthieves': 'chaosthieves',
'chaos': 'chaos'
}[get_choice('enemy_shuffle', weights)]
ret.enemy_damage = {'default': 'default',
@ -335,7 +345,7 @@ def roll_settings(weights):
ret.shufflepots = get_choice('pot_shuffle', weights)
ret.beemizer = int(get_choice('beemizer', weights)) if 'beemizer' in weights else 0
ret.beemizer = int(get_choice('beemizer', weights, 0))
ret.timer = {'none': False,
None: False,
@ -344,11 +354,11 @@ def roll_settings(weights):
'timed_ohko': 'timed-ohko',
'ohko': 'ohko',
'timed_countdown': 'timed-countdown',
'display': 'display'}[get_choice('timer', weights)] if 'timer' in weights.keys() else False
'display': 'display'}[get_choice('timer', weights, False)]
ret.dungeon_counters = get_choice('dungeon_counters', weights) if 'dungeon_counters' in weights else 'default'
ret.dungeon_counters = get_choice('dungeon_counters', weights, 'default')
ret.progressive = convert_to_on_off(get_choice('progressive', weights)) if "progressive" in weights else 'on'
ret.progressive = convert_to_on_off(get_choice('progressive', weights, 'on'))
inventoryweights = weights.get('startinventory', {})
startitems = []
for item in inventoryweights.keys():
@ -362,18 +372,22 @@ def roll_settings(weights):
startitems.append(item)
ret.startinventory = ','.join(startitems)
ret.glitch_boots = get_choice('glitch_boots', weights) if 'glitch_boots' in weights else True
ret.glitch_boots = get_choice('glitch_boots', weights, True)
ret.remote_items = get_choice('remote_items', weights) if 'remote_items' in weights else False
ret.remote_items = get_choice('remote_items', weights, False)
ret.local_items = set()
if get_choice("local_keys", weights, "l" in dungeon_items):
ret.local_items = item_name_groups["Small Keys"] | item_name_groups["Big Keys"]
else:
ret.local_items = set()
for item_name in weights.get('local_items', []):
items = item_name_groups.get(item_name, {item_name})
for item in items:
if item in item_table:
ret.local_items.add(item)
else:
logging.warning(f"Could not force item {item} to be world-local, as it was not recognized.")
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
ret.local_items = ",".join(ret.local_items)
if 'rom' in weights:

View File

@ -21,6 +21,7 @@ def get_base_rom_path(file_name: str = "") -> str:
file_name = Utils.local_path(file_name)
return file_name
def get_base_rom_bytes(file_name: str = "") -> bytes:
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
if not base_rom_bytes:
@ -38,7 +39,9 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
patch = yaml.dump({"meta": metadata,
"patch": patch})
"patch": patch,
"game": "alttp",
"base_checksum": JAP10HASH})
return patch.encode(encoding="utf-8-sig")
@ -49,24 +52,27 @@ def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
return generate_yaml(patch, metadata)
def create_patch_file(rom_file_to_patch: str, server: str = "") -> str:
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None) -> str:
bytes = generate_patch(load_bytes(rom_file_to_patch),
{
"server": server}) # allow immediate connection to server in multiworld. Empty string otherwise
target = os.path.splitext(rom_file_to_patch)[0] + ".bmbp"
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ".bmbp"
write_lzma(bytes, target)
return target
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
def create_rom_bytes(patch_file: str) -> Tuple[dict, str, bytearray]:
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
patched_data = bsdiff4.patch(get_base_rom_bytes(), data["patch"])
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
target = os.path.splitext(patch_file)[0] + ".sfc"
return data["meta"], target, patched_data
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:
f.write(patched_data)
return data["meta"], target
return data, target
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:

236
Plando.py
View File

@ -1,236 +0,0 @@
#!/usr/bin/env python3
import argparse
import hashlib
import logging
import os
import random
import time
import sys
from BaseClasses import World
from Regions import create_regions
from EntranceShuffle import link_entrances, connect_entrance, connect_two_way, connect_exit
from Rom import patch_rom, LocalRom, write_string_to_rom, apply_rom_settings, get_sprite_from_name
from Rules import set_rules
from Dungeons import create_dungeons
from Items import ItemFactory
from ItemList import difficulties
from Main import create_playthrough
__version__ = '0.2-dev'
def main(args):
start_time = time.perf_counter()
# initialize the world
world = World(1, 'vanilla', 'noglitches', 'standard', 'normal', 'none', 'on', 'ganon', 'freshness', False, False, False, False, False, False, None, False)
world.player_names[1].append("Player1")
logger = logging.getLogger('')
hasher = hashlib.md5()
with open(args.plando, 'rb') as plandofile:
buf = plandofile.read()
hasher.update(buf)
world.seed = int(hasher.hexdigest(), 16) % 1000000000
random.seed(world.seed)
logger.info('ALttP Plandomizer Version %s - Seed: %s\n\n', __version__, args.plando)
world.difficulty_requirements[1] = difficulties[world.difficulty[1]]
create_regions(world, 1)
create_dungeons(world, 1)
link_entrances(world, 1)
logger.info('Calculating Access Rules.')
set_rules(world, 1)
logger.info('Fill the world.')
text_patches = []
fill_world(world, args.plando, text_patches)
if world.get_entrance('Dam', 1).connected_region.name != 'Dam' or world.get_entrance('Swamp Palace', 1).connected_region.name != 'Swamp Palace (Entrance)':
world.swamp_patch_required[1] = True
logger.info('Calculating playthrough.')
try:
create_playthrough(world)
except RuntimeError:
if args.ignore_unsolvable:
pass
else:
raise
logger.info('Patching ROM.')
rom = LocalRom(args.rom)
patch_rom(world, rom, 1, 1, False)
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.fastmenu, args.disablemusic, args.sprite, args.ow_palettes, args.uw_palettes)
for textname, texttype, text in text_patches:
if texttype == 'text':
write_string_to_rom(rom, textname, text)
#elif texttype == 'credit':
# write_credits_string_to_rom(rom, textname, text)
outfilebase = 'Plando_%s_%s' % (os.path.splitext(os.path.basename(args.plando))[0], world.seed)
rom.write_to_file('%s.sfc' % outfilebase)
if args.create_spoiler:
world.spoiler.to_file('%s_Spoiler.txt' % outfilebase)
logger.info('Done. Enjoy.')
logger.debug('Total Time: %s', time.perf_counter() - start_time)
return world
def fill_world(world, plando, text_patches):
mm_medallion = 'Ether'
tr_medallion = 'Quake'
logger = logging.getLogger('')
with open(plando, 'r') as plandofile:
for line in plandofile.readlines():
if line.startswith('#'):
continue
if ':' in line:
line = line.lstrip()
if line.startswith('!'):
if line.startswith('!mm_medallion'):
_, medallionstr = line.split(':', 1)
mm_medallion = medallionstr.strip()
elif line.startswith('!tr_medallion'):
_, medallionstr = line.split(':', 1)
tr_medallion = medallionstr.strip()
elif line.startswith('!mode'):
_, modestr = line.split(':', 1)
world.mode = {1: modestr.strip()}
elif line.startswith('!logic'):
_, logicstr = line.split(':', 1)
world.logic = {1: logicstr.strip()}
elif line.startswith('!goal'):
_, goalstr = line.split(':', 1)
world.goal = {1: goalstr.strip()}
elif line.startswith('!light_cone_sewers'):
_, sewerstr = line.split(':', 1)
world.sewer_light_cone = {1: sewerstr.strip().lower() == 'true'}
elif line.startswith('!light_cone_lw'):
_, lwconestr = line.split(':', 1)
world.light_world_light_cone = lwconestr.strip().lower() == 'true'
elif line.startswith('!light_cone_dw'):
_, dwconestr = line.split(':', 1)
world.dark_world_light_cone = dwconestr.strip().lower() == 'true'
elif line.startswith('!fix_trock_doors'):
_, trdstr = line.split(':', 1)
world.fix_trock_doors = {1: trdstr.strip().lower() == 'true'}
elif line.startswith('!fix_trock_exit'):
_, trfstr = line.split(':', 1)
world.fix_trock_exit = {1: trfstr.strip().lower() == 'true'}
elif line.startswith('!fix_gtower_exit'):
_, gtfstr = line.split(':', 1)
world.fix_gtower_exit = gtfstr.strip().lower() == 'true'
elif line.startswith('!fix_pod_exit'):
_, podestr = line.split(':', 1)
world.fix_palaceofdarkness_exit = {1: podestr.strip().lower() == 'true'}
elif line.startswith('!fix_skullwoods_exit'):
_, swestr = line.split(':', 1)
world.fix_skullwoods_exit = {1: swestr.strip().lower() == 'true'}
elif line.startswith('!check_beatable_only'):
_, chkbtstr = line.split(':', 1)
world.check_beatable_only = chkbtstr.strip().lower() == 'true'
elif line.startswith('!ganon_death_pyramid_respawn'):
_, gnpstr = line.split(':', 1)
world.ganon_at_pyramid = gnpstr.strip().lower() == 'true'
elif line.startswith('!save_quit_boss'):
_, sqbstr = line.split(':', 1)
world.save_and_quite_from_boss = sqbstr.strip().lower() == 'true'
elif line.startswith('!text_'):
textname, text = line.split(':', 1)
text_patches.append([textname.lstrip('!text_').strip(), 'text', text.strip()])
#temporarilly removed. New credits system not ready to handle this.
#elif line.startswith('!credits_'):
# textname, text = line.split(':', 1)
# text_patches.append([textname.lstrip('!credits_').strip(), 'credits', text.strip()])
continue
locationstr, itemstr = line.split(':', 1)
location = world.get_location(locationstr.strip(), 1)
if location is None:
logger.warning('Unknown location: %s', locationstr)
continue
else:
item = ItemFactory(itemstr.strip(), 1)
if item is not None:
world.push_item(location, item)
if item.smallkey or item.bigkey:
location.event = True
elif '<=>' in line:
entrance, exit = line.split('<=>', 1)
connect_two_way(world, entrance.strip(), exit.strip(), 1)
elif '=>' in line:
entrance, exit = line.split('=>', 1)
connect_entrance(world, entrance.strip(), exit.strip(), 1)
elif '<=' in line:
entrance, exit = line.split('<=', 1)
connect_exit(world, exit.strip(), entrance.strip(), 1)
world.required_medallions[1] = (mm_medallion, tr_medallion)
# set up Agahnim Events
world.get_location('Agahnim 1', 1).event = True
world.get_location('Agahnim 1', 1).item = ItemFactory('Beat Agahnim 1', 1)
world.get_location('Agahnim 2', 1).event = True
world.get_location('Agahnim 2', 1).item = ItemFactory('Beat Agahnim 2', 1)
def start():
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--create_spoiler', help='Output a Spoiler File', action='store_true')
parser.add_argument('--ignore_unsolvable', help='Do not abort if seed is deemed unsolvable.', action='store_true')
parser.add_argument('--rom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc', help='Path to an ALttP JAP(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?', choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--fastmenu', default='normal', const='normal', nargs='?', choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
help='''\
Select the rate at which the menu opens and closes.
(default: %(default)s)
''')
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
parser.add_argument('--heartbeep', default='normal', const='normal', nargs='?', choices=['normal', 'half', 'quarter', 'off'],
help='Select the rate at which the heart beep sound is played at low health.')
parser.add_argument('--heartcolor', default='red', const='red', nargs='?', choices=['red', 'blue', 'green', 'yellow'],
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('--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.')
parser.add_argument('--plando', help='Filled out template to use for setting up the rom.')
args = parser.parse_args()
# ToDo: Validate files further than mere existance
if not os.path.isfile(args.rom):
input('Could not find valid base 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 not os.path.isfile(args.plando):
input('Could not find Plandomizer distribution at expected path %s. Please run with -h to see help for further information. \nPress Enter to exit.' % args.plando)
sys.exit(1)
if args.sprite is not None and not os.path.isfile(args.sprite) and not get_sprite_from_name(args.sprite):
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
sys.exit(1)
# set up logger
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[args.loglevel]
logging.basicConfig(format='%(message)s', level=loglevel)
main(args=args)
if __name__ == '__main__':
start()

View File

@ -1,244 +0,0 @@
# Lines starting with a # are comments and ignored by the parsers
# Lines without a : are also ignored
# These are special instructions for setting the medallion requirements to enter the dungeons
!mm_medallion: Bombos
!tr_medallion: Quake
# This sets the game mode
!mode: open
# This sets the logic (for verification purposes)
!logic: noglitches
# This sets the goal (only used for generating the spoiler log)
!goal: ganon
# Now we fill in all locations
Mushroom: Mushroom
Bottle Merchant: Bottle
Flute Spot: Flute
Sunken Treasure: Nothing
Purple Chest: Nothing
Blind's Hideout - Top: Nothing
Blind's Hideout - Left: Nothing
Blind's Hideout - Right: Nothing
Blind's Hideout - Far Left: Nothing
Blind's Hideout - Far Right: Nothing
Link's Uncle: Fighter Sword
Secret Passage: Nothing
King Zora: Flippers
Zora's Ledge: Nothing
King's Tomb: Cape
Floodgate Chest: Nothing
Link's House: Lamp
Kakariko Tavern: Nothing
Chicken House: Nothing
Aginah's Cave: Nothing
Sahasrahla's Hut - Left: Nothing
Sahasrahla's Hut - Middle: Nothing
Sahasrahla's Hut - Right: Nothing
Sahasrahla: Pegasus Boots
Kakariko Well - Top: Nothing
Kakariko Well - Left: Nothing
Kakariko Well - Middle: Nothing
Kakariko Well - Right: Nothing
Kakariko Well - Bottom: Nothing
Blacksmith: Tempered Sword
Magic Bat: Magic Upgrade (1/2)
Sick Kid: Bug Catching Net
Hobo: Bottle
Lost Woods Hideout: Nothing
Lumberjack Tree: Nothing
Cave 45: Nothing
Graveyard Cave: Nothing
Checkerboard Cave: Nothing
Mini Moldorm Cave - Far Left: Nothing
Mini Moldorm Cave - Left: Nothing
Mini Moldorm Cave - Right: Nothing
Mini Moldorm Cave - Far Right: Nothing
Mini Moldorm Cave - Generous Guy: Nothing
Ice Rod Cave: Ice Rod
Bonk Rock Cave: Nothing
Library: Book of Mudora
Potion Shop: Magic Powder
Lake Hylia Island: Nothing
Maze Race: Nothing
Desert Ledge: Nothing
Desert Palace - Big Chest: Power Glove
Desert Palace - Torch: Small Key (Desert Palace)
Desert Palace - Map Chest: Nothing
Desert Palace - Compass Chest: Nothing
Desert Palace - Big Key Chest: Big Key (Desert Palace)
Desert Palace - Boss: Nothing
Desert Palace - Prize: Blue Pendant
Eastern Palace - Compass Chest: Nothing
Eastern Palace - Big Chest: Bow
Eastern Palace - Cannonball Chest: Nothing
Eastern Palace - Big Key Chest: Big Key (Eastern Palace)
Eastern Palace - Map Chest: Nothing
Eastern Palace - Boss: Nothing
Eastern Palace - Prize: Green Pendant
Master Sword Pedestal: Master Sword
Hyrule Castle - Boomerang Chest: Nothing
Hyrule Castle - Map Chest: Nothing
Hyrule Castle - Zelda's Chest: Nothing
Sewers - Dark Cross: Small Key (Escape)
Sewers - Secret Room - Left: Nothing
Sewers - Secret Room - Middle: Nothing
Sewers - Secret Room - Right: Nothing
Sanctuary: Sanctuary Heart Container
Castle Tower - Room 03: Small Key (Agahnims Tower)
Castle Tower - Dark Maze: Small Key (Agahnims Tower)
Old Man: Magic Mirror
Spectacle Rock Cave: Nothing
Paradox Cave Lower - Far Left: Nothing
Paradox Cave Lower - Left: Nothing
Paradox Cave Lower - Right: Nothing
Paradox Cave Lower - Far Right: Nothing
Paradox Cave Lower - Middle: Nothing
Paradox Cave Upper - Left: Nothing
Paradox Cave Upper - Right: Nothing
Spiral Cave: Nothing
Ether Tablet: Ether
Spectacle Rock: Nothing
Tower of Hera - Basement Cage: Small Key (Tower of Hera)
Tower of Hera - Map Chest: Nothing
Tower of Hera - Big Key Chest: Big Key (Tower of Hera)
Tower of Hera - Compass Chest: Nothing
Tower of Hera - Big Chest: Moon Pearl
Tower of Hera - Boss: Nothing
Tower of Hera - Prize: Red Pendant
Pyramid: Nothing
Catfish: Quake
Stumpy: Shovel
Digging Game: Nothing
Bombos Tablet: Bombos
Hype Cave - Top: Nothing
Hype Cave - Middle Right: Nothing
Hype Cave - Middle Left: Nothing
Hype Cave - Bottom: Nothing
Hype Cave - Generous Guy: Nothing
Peg Cave: Nothing
Pyramid Fairy - Left: Golden Sword
Pyramid Fairy - Right: Silver Arrows
Brewery: Nothing
C-Shaped House: Nothing
Chest Game: Nothing
Bumper Cave Ledge: Nothing
Mire Shed - Left: Nothing
Mire Shed - Right: Nothing
Superbunny Cave - Top: Nothing
Superbunny Cave - Bottom: Nothing
Spike Cave: Cane of Byrna
Hookshot Cave - Top Right: Nothing
Hookshot Cave - Top Left: Nothing
Hookshot Cave - Bottom Right: Nothing
Hookshot Cave - Bottom Left: Nothing
Floating Island: Nothing
Mimic Cave: Nothing
Swamp Palace - Entrance: Small Key (Swamp Palace)
Swamp Palace - Map Chest: Nothing
Swamp Palace - Big Chest: Hookshot
Swamp Palace - Compass Chest: Nothing
Swamp Palace - Big Key Chest: Big Key (Swamp Palace)
Swamp Palace - West Chest: Nothing
Swamp Palace - Flooded Room - Left: Nothing
Swamp Palace - Flooded Room - Right: Nothing
Swamp Palace - Waterfall Room: Nothing
Swamp Palace - Boss: Nothing
Swamp Palace - Prize: Crystal 2
Thieves' Town - Big Key Chest: Big Key (Thieves Town)
Thieves' Town - Map Chest: Nothing
Thieves' Town - Compass Chest: Nothing
Thieves' Town - Ambush Chest: Nothing
Thieves' Town - Attic: Nothing
Thieves' Town - Big Chest: Titans Mitts
Thieves' Town - Blind's Cell: Small Key (Thieves Town)
Thieves' Town - Boss: Nothing
Thieves' Town - Prize: Crystal 4
Skull Woods - Compass Chest: Nothing
Skull Woods - Map Chest: Nothing
Skull Woods - Big Chest: Fire Rod
Skull Woods - Pot Prison: Small Key (Skull Woods)
Skull Woods - Pinball Room: Small Key (Skull Woods)
Skull Woods - Big Key Chest: Big Key (Skull Woods)
Skull Woods - Bridge Room: Small Key (Skull Woods)
Skull Woods - Boss: Nothing
Skull Woods - Prize: Crystal 3
Ice Palace - Compass Chest: Nothing
Ice Palace - Freezor Chest: Nothing
Ice Palace - Big Chest: Blue Mail
Ice Palace - Iced T Room: Small Key (Ice Palace)
Ice Palace - Spike Room: Small Key (Ice Palace)
Ice Palace - Big Key Chest: Big Key (Ice Palace)
Ice Palace - Map Chest: Nothing
Ice Palace - Boss: Nothing
Ice Palace - Prize: Crystal 5
Misery Mire - Big Chest: Cane of Somaria
Misery Mire - Map Chest: Nothing
Misery Mire - Main Lobby: Small Key (Misery Mire)
Misery Mire - Bridge Chest: Small Key (Misery Mire)
Misery Mire - Spike Chest: Small Key (Misery Mire)
Misery Mire - Compass Chest: Nothing
Misery Mire - Big Key Chest: Big Key (Misery Mire)
Misery Mire - Boss: Nothing
Misery Mire - Prize: Crystal 6
Turtle Rock - Compass Chest: Nothing
Turtle Rock - Roller Room - Left: Nothing
Turtle Rock - Roller Room - Right: Small Key (Turtle Rock)
Turtle Rock - Chain Chomps: Small Key (Turtle Rock)
Turtle Rock - Big Key Chest: Big Key (Turtle Rock)
Turtle Rock - Big Chest: Mirror Shield
Turtle Rock - Crystaroller Room: Small Key (Turtle Rock)
Turtle Rock - Eye Bridge - Bottom Left: Small Key (Turtle Rock)
Turtle Rock - Eye Bridge - Bottom Right: Nothing
Turtle Rock - Eye Bridge - Top Left: Nothing
Turtle Rock - Eye Bridge - Top Right: Nothing
Turtle Rock - Boss: Nothing
Turtle Rock - Prize: Crystal 7
Palace of Darkness - Shooter Room: Small Key (Palace of Darkness)
Palace of Darkness - The Arena - Bridge: Small Key (Palace of Darkness)
Palace of Darkness - Stalfos Basement: Small Key (Palace of Darkness)
Palace of Darkness - Big Key Chest: Big Key (Palace of Darkness)
Palace of Darkness - The Arena - Ledge: Small Key (Palace of Darkness)
Palace of Darkness - Map Chest: Nothing
Palace of Darkness - Compass Chest: Nothing
# logic cannot account for hammer and small key in maze
Palace of Darkness - Dark Basement - Left: Small Key (Palace of Darkness)
Palace of Darkness - Dark Basement - Right: Small Key (Palace of Darkness)
Palace of Darkness - Dark Maze - Top: Nothing
Palace of Darkness - Dark Maze - Bottom: Nothing
Palace of Darkness - Big Chest: Hammer
Palace of Darkness - Harmless Hellway: Nothing
Palace of Darkness - Boss: Nothing
Palace of Darkness - Prize: Crystal 1
Ganons Tower - Bob's Torch: Small Key (Ganons Tower)
Ganons Tower - Hope Room - Left: Nothing
Ganons Tower - Hope Room - Right: Nothing
Ganons Tower - Tile Room: Small Key (Ganons Tower)
Ganons Tower - Compass Room - Top Left: Nothing
Ganons Tower - Compass Room - Top Right: Nothing
Ganons Tower - Compass Room - Bottom Left: Nothing
Ganons Tower - Compass Room - Bottom Right: Nothing
Ganons Tower - DMs Room - Top Left: Nothing
Ganons Tower - DMs Room - Top Right: Nothing
Ganons Tower - DMs Room - Bottom Left: Nothing
Ganons Tower - DMs Room - Bottom Right: Nothing
Ganons Tower - Map Chest: Nothing
Ganons Tower - Firesnake Room: Small Key (Ganons Tower)
Ganons Tower - Randomizer Room - Top Left: Nothing
Ganons Tower - Randomizer Room - Top Right: Nothing
Ganons Tower - Randomizer Room - Bottom Left: Nothing
Ganons Tower - Randomizer Room - Bottom Right: Nothing
Ganons Tower - Bob's Chest: Nothing
Ganons Tower - Big Chest: Red Mail
Ganons Tower - Big Key Room - Left: Nothing
Ganons Tower - Big Key Room - Right: Nothing
Ganons Tower - Big Key Chest: Big Key (Ganons Tower)
Ganons Tower - Mini Helmasaur Room - Left: Nothing
Ganons Tower - Mini Helmasaur Room - Right: Nothing
Ganons Tower - Pre-Moldorm Chest: Small Key (Ganons Tower)
Ganons Tower - Validation Chest: Nothing
Ganon: Triforce

View File

@ -1,33 +1,35 @@
Berserker's Multiworld
======================
This is a complete fork of Bonta's Multiworld V31.
It is a drop-in replacement with everything from Bonta's Multiworld included.
You can find a guide here: https://docs.google.com/document/d/1r7qs1-MK7YbFf2d-mEUeTy2wHykIf1ALG9pLtVvUbSw/edit#
Or use the Wiki button at the top
A Multiworld implementation for the Legend of Zelda: A Link to the Past Randomizer
For setup and instructions there's a [Wiki](https://github.com/Berserker66/MultiWorld-Utilities/wiki)
Downloads can be found at [Releases](https://github.com/Berserker66/MultiWorld-Utilities/releases), including compiled windows binaries.
Additions/Changes
Additions/Changes compared to Bonta's V31
-----------------
Project
* Available in precompiled form and guided setup for Windows 64Bit on [Releases](https://github.com/Berserker66/MultiWorld-Utilities/releases) page.
* Compatible with Python 3.7 and 3.8. Potentially future versions as well.
* Compatible with Python 3.7 and 3.8. Forward Checks for Python 4.0 are done.
* Update modules if they are too old, preventing a crash when trying to connect among potential other issues
* Autoinstall missing modules
* Allow newer versions of modules than specified, as they will *usually* not break compatibility
* Support for V31 extendedmsu
* Uses "V32" MSU
* Has support for binary patching to allow legal distribution of multiworld rom files
* Various performance improvements (over 100% faster in most cases)
* Various fixes
* Overworld Glitches Logic
* Newer Entrance Randomizer Logic, allowing more potential item and boss locations
* completely redesigned command interface, with `!help` and `/help`
* New Goal: local triforce hunt - limits triforce pieces to your own world so it is your own goal to accomplish
MultiMystery.py
* Allows you to generate a Multiworld with individual player mystery weights. Since weights can also be set to 100%, this also allows for individual settings for each player in a regular multiworld.
Basis is a .yaml file that sets these weights. You can find an [easy.yaml](https://github.com/Berserker66/MultiWorld-Utilities/blob/master/easy.yaml) in this project folder to get started.
* Additional instructions are at the start of the file. Open with a text editor.
* Configuration options in the host.yaml file.
* Allows a new Mode called "Meta-Mystery", allowing certain mystery settings to apply to all players.
* For example, everyone gets the same but random goal.
MultiServer.py
* Supports automatic port-forwarding, can be enabled in host.yaml
@ -35,6 +37,7 @@ Basis is a .yaml file that sets these weights. You can find an [easy.yaml](https
* /forfeit Playername now works when the player is not currently connected
* Added `/hint` and `!hint`, configuration in host.yaml and description in help
* various commands, like /send and /hint use "fuzzy text matching", no longer requiring you to enter a location, player name or item name perfectly
* Some item groups also exist, so `/hint Bottles` lists all bottle varieties
Mystery.py
* Defaults to generating a non-race ROM (Bonta's only makes race ROMs at this time)
@ -47,8 +50,12 @@ If a race ROM is desired, pass --create-race as argument to it
* Option for "glitch_boots", allowing to run glitched modes without automatic boots
* Supports new Meta-Mystery mode. Read [meta.yaml](https://github.com/Berserker66/MultiWorld-Utilities/blob/master/meta.yaml) for details.
* Added `dungeonssimple` and `dungeonsfull` ER modes
* Option for local items
* Option for linked options
* Added 'l' to dungeon_items to have a local-world keysanity
MultiClient.py
* Has a Webbrowser based UI now
* Awaits a QUsb2Snes connection when started, latching on when available
* completely redesigned command interface, with `!help` and `/help`
* Running it with a patch file will patch out the multiworld rom and then automatically connect to the host that created the multiworld

View File

@ -1,4 +1,5 @@
import collections
from BaseClasses import Region, Location, Entrance, RegionType, Shop, ShopType
@ -629,11 +630,116 @@ location_table = {'Mushroom': (0x180013, 0x186338, False, 'in the woods'),
'Skull Woods - Prize': (
[0x120A3, 0x53F12, 0x53F13, 0x180058, 0x18007B, 0xC704], None, True, 'Skull Woods'),
'Ice Palace - Prize': (
[0x120A4, 0x53F5A, 0x53F5B, 0x180059, 0x180073, 0xC705], None, True, 'Ice Palace'),
[0x120A4, 0x53F5A, 0x53F5B, 0x180059, 0x180073, 0xC705], None, True, 'Ice Palace'),
'Misery Mire - Prize': (
[0x120A2, 0x53F48, 0x53F49, 0x180057, 0x180075, 0xC703], None, True, 'Misery Mire'),
[0x120A2, 0x53F48, 0x53F49, 0x180057, 0x180075, 0xC703], None, True, 'Misery Mire'),
'Turtle Rock - Prize': (
[0x120A7, 0x53F24, 0x53F25, 0x18005C, 0x180079, 0xC708], None, True, 'Turtle Rock')}
[0x120A7, 0x53F24, 0x53F25, 0x18005C, 0x180079, 0xC708], None, True, 'Turtle Rock')}
lookup_id_to_name = {data[0]: name for name, data in location_table.items() if type(data[0]) == int}
lookup_id_to_name[-1] = "cheat console"
lookup_vanilla_location_to_entrance = {1572883: 'Kings Grave Inner Rocks', 191256: 'Kings Grave Inner Rocks',
1573194: 'Kings Grave Inner Rocks', 1573189: 'Kings Grave Inner Rocks',
212328: 'Kings Grave Inner Rocks', 60175: 'Blinds Hideout',
60178: 'Blinds Hideout', 60181: 'Blinds Hideout', 60184: 'Blinds Hideout',
60187: 'Blinds Hideout', 188229: 'Hyrule Castle Secret Entrance Drop',
59761: 'Hyrule Castle Secret Entrance Drop', 975299: 'Zoras River',
1573193: 'Zoras River', 59824: 'Waterfall of Wishing',
59857: 'Waterfall of Wishing', 59770: 'Kings Grave', 59788: 'Dam',
59836: 'Links House', 59854: 'Tavern North', 59881: 'Chicken House',
59890: 'Aginahs Cave', 60034: 'Sahasrahlas Hut', 60037: 'Sahasrahlas Hut',
60040: 'Sahasrahlas Hut', 193020: 'Sahasrahlas Hut',
60046: 'Kakariko Well Drop', 60049: 'Kakariko Well Drop',
60052: 'Kakariko Well Drop', 60055: 'Kakariko Well Drop',
60058: 'Kakariko Well Drop', 1572906: 'Blacksmiths Hut',
1572885: 'Bat Cave Drop', 211407: 'Sick Kids House',
212605: 'Hobo Bridge', 1572864: 'Lost Woods Hideout Drop',
1572865: 'Lumberjack Tree Tree', 1572867: 'Cave 45',
1572868: 'Graveyard Cave', 1572869: 'Checkerboard Cave',
60226: 'Mini Moldorm Cave', 60229: 'Mini Moldorm Cave',
60232: 'Mini Moldorm Cave', 60235: 'Mini Moldorm Cave',
1572880: 'Mini Moldorm Cave', 60238: 'Ice Rod Cave',
60223: 'Bonk Rock Cave', 1572882: 'Library', 1572884: 'Potion Shop',
1573188: 'Lake Hylia Island Mirror Spot',
1573186: 'Maze Race Mirror Spot', 1573187: 'Desert Ledge Return Rocks',
59791: 'Desert Palace Entrance (West)',
1573216: 'Desert Palace Entrance (West)',
59830: 'Desert Palace Entrance (West)',
59851: 'Desert Palace Entrance (West)',
59842: 'Desert Palace Entrance (West)',
1573201: 'Desert Palace Entrance (North)', 59767: 'Eastern Palace',
59773: 'Eastern Palace', 59827: 'Eastern Palace', 59833: 'Eastern Palace',
59893: 'Eastern Palace', 1573200: 'Eastern Palace',
166320: 'Master Sword Meadow', 59764: 'Hyrule Castle Entrance (South)',
60172: 'Hyrule Castle Entrance (South)',
60169: 'Hyrule Castle Entrance (South)',
59758: 'Hyrule Castle Entrance (South)',
60253: 'Hyrule Castle Entrance (South)',
60256: 'Hyrule Castle Entrance (South)',
60259: 'Hyrule Castle Entrance (South)', 60025: 'Sanctuary S&Q',
60085: 'Agahnims Tower', 60082: 'Agahnims Tower',
1010170: 'Old Man Cave (West)', 1572866: 'Spectacle Rock Cave',
60202: 'Paradox Cave (Bottom)', 60205: 'Paradox Cave (Bottom)',
60208: 'Paradox Cave (Bottom)', 60211: 'Paradox Cave (Bottom)',
60214: 'Paradox Cave (Bottom)', 60217: 'Paradox Cave (Bottom)',
60220: 'Paradox Cave (Bottom)', 59839: 'Spiral Cave',
1572886: 'Death Mountain (Top)', 1573184: 'Spectacle Rock Mirror Spot',
1573218: 'Tower of Hera', 59821: 'Tower of Hera', 59878: 'Tower of Hera',
59899: 'Tower of Hera', 59896: 'Tower of Hera', 1573202: 'Tower of Hera',
1573191: 'Top of Pyramid', 975237: 'Catfish Entrance Rock',
209095: 'South Dark World Bridge', 1573192: 'South Dark World Bridge',
1572887: 'Bombos Tablet Mirror Spot', 60190: 'Hype Cave',
60193: 'Hype Cave', 60196: 'Hype Cave', 60199: 'Hype Cave',
1572881: 'Hype Cave', 1572870: 'Dark World Hammer Peg Cave',
59776: 'Pyramid Fairy', 59779: 'Pyramid Fairy', 59884: 'Brewery',
59887: 'C-Shaped House', 60840: 'Chest Game',
1573190: 'Bumper Cave (Bottom)', 60019: 'Mire Shed', 60022: 'Mire Shed',
60028: 'Superbunny Cave (Top)', 60031: 'Superbunny Cave (Top)',
60043: 'Spike Cave', 60241: 'Hookshot Cave', 60244: 'Hookshot Cave',
60250: 'Hookshot Cave', 60247: 'Hookshot Cave',
1573185: 'Floating Island Mirror Spot', 59845: 'Mimic Cave',
60061: 'Swamp Palace', 59782: 'Swamp Palace', 59785: 'Swamp Palace',
60064: 'Swamp Palace', 60070: 'Swamp Palace', 60067: 'Swamp Palace',
60073: 'Swamp Palace', 60076: 'Swamp Palace', 60079: 'Swamp Palace',
1573204: 'Swamp Palace', 59908: 'Thieves Town', 59905: 'Thieves Town',
59911: 'Thieves Town', 59914: 'Thieves Town', 59917: 'Thieves Town',
59920: 'Thieves Town', 59923: 'Thieves Town', 1573206: 'Thieves Town',
59803: 'Skull Woods First Section Door',
59848: 'Skull Woods First Section Hole (East)',
59794: 'Skull Woods First Section Hole (West)',
59809: 'Skull Woods First Section Hole (West)',
59800: 'Skull Woods First Section Hole (North)',
59806: 'Skull Woods Second Section Door (East)',
59902: 'Skull Woods Final Section', 1573205: 'Skull Woods Final Section',
59860: 'Ice Palace', 59797: 'Ice Palace', 59818: 'Ice Palace',
59875: 'Ice Palace', 59872: 'Ice Palace', 59812: 'Ice Palace',
59869: 'Ice Palace', 1573207: 'Ice Palace', 60007: 'Misery Mire',
60010: 'Misery Mire', 59998: 'Misery Mire', 60001: 'Misery Mire',
59866: 'Misery Mire', 60004: 'Misery Mire', 60013: 'Misery Mire',
1573208: 'Misery Mire', 59938: 'Turtle Rock', 59932: 'Turtle Rock',
59935: 'Turtle Rock', 59926: 'Turtle Rock',
59941: 'Dark Death Mountain Ledge (West)',
59929: 'Dark Death Mountain Ledge (East)',
59956: 'Dark Death Mountain Ledge (West)',
59953: 'Turtle Rock Isolated Ledge Entrance',
59950: 'Turtle Rock Isolated Ledge Entrance',
59947: 'Turtle Rock Isolated Ledge Entrance',
59944: 'Turtle Rock Isolated Ledge Entrance',
1573209: 'Turtle Rock Isolated Ledge Entrance',
59995: 'Palace of Darkness', 59965: 'Palace of Darkness',
59977: 'Palace of Darkness', 59959: 'Palace of Darkness',
59962: 'Palace of Darkness', 59986: 'Palace of Darkness',
59971: 'Palace of Darkness', 59980: 'Palace of Darkness',
59983: 'Palace of Darkness', 59989: 'Palace of Darkness',
59992: 'Palace of Darkness', 59968: 'Palace of Darkness',
59974: 'Palace of Darkness', 1573203: 'Palace of Darkness',
1573217: 'Ganons Tower', 60121: 'Ganons Tower', 60124: 'Ganons Tower',
60130: 'Ganons Tower', 60133: 'Ganons Tower', 60136: 'Ganons Tower',
60139: 'Ganons Tower', 60142: 'Ganons Tower', 60088: 'Ganons Tower',
60091: 'Ganons Tower', 60094: 'Ganons Tower', 60097: 'Ganons Tower',
60115: 'Ganons Tower', 60112: 'Ganons Tower', 60100: 'Ganons Tower',
60103: 'Ganons Tower', 60106: 'Ganons Tower', 60109: 'Ganons Tower',
60127: 'Ganons Tower', 60118: 'Ganons Tower', 60148: 'Ganons Tower',
60151: 'Ganons Tower', 60145: 'Ganons Tower', 60157: 'Ganons Tower',
60160: 'Ganons Tower', 60163: 'Ganons Tower', 60166: 'Ganons Tower'}

203
Rom.py
View File

@ -22,54 +22,7 @@ from EntranceShuffle import door_addresses
JAP10HASH = '03a63945398191337e896e5771f77173'
RANDOMIZERBASEHASH = 'aec17dd8b3c76c16d0b0311c36eb1c00'
class JsonRom(object):
def __init__(self, name=None, hash=None):
self.name = name
self.hash = hash
self.orig_buffer = None
self.patches = {}
self.addresses = []
def write_byte(self, address, value):
self.write_bytes(address, [value])
def write_bytes(self, startaddress, values):
if not values:
return
values = list(values)
pos = bisect.bisect_right(self.addresses, startaddress)
intervalstart = self.addresses[pos-1] if pos else None
intervalpatch = self.patches[str(intervalstart)] if pos else None
if pos and startaddress <= intervalstart + len(intervalpatch): # merge with previous segment
offset = startaddress - intervalstart
intervalpatch[offset:offset+len(values)] = values
startaddress = intervalstart
values = intervalpatch
else: # new segment
self.addresses.insert(pos, startaddress)
self.patches[str(startaddress)] = values
pos = pos + 1
while pos < len(self.addresses) and self.addresses[pos] <= startaddress + len(values): # merge the next segment into this one
intervalstart = self.addresses[pos]
values.extend(self.patches[str(intervalstart)][startaddress+len(values)-intervalstart:])
del self.patches[str(intervalstart)]
del self.addresses[pos]
def write_to_file(self, file):
with open(file, 'w') as stream:
json.dump([self.patches], stream)
def get_hash(self):
h = hashlib.md5()
h.update(json.dumps([self.patches]).encode('utf-8'))
return h.hexdigest()
RANDOMIZERBASEHASH = 'a567da86e8bd499256da4bba2209a3fd'
class LocalRom(object):
@ -93,6 +46,10 @@ class LocalRom(object):
with open(file, 'wb') as outfile:
outfile.write(self.buffer)
def read_from_file(self, file):
with open(file, 'rb') as stream:
self.buffer = bytearray(stream.read())
@staticmethod
def fromJsonRom(rom, file, rom_size=0x200000):
ret = LocalRom(file, True, rom.name, rom.hash)
@ -101,12 +58,37 @@ class LocalRom(object):
ret.write_bytes(int(address), values)
return ret
@staticmethod
def verify(buffer, expected=RANDOMIZERBASEHASH):
buffermd5 = hashlib.md5()
buffermd5.update(buffer)
return expected == buffermd5.hexdigest()
def patch_base_rom(self):
from Patch import create_patch_file, create_rom_bytes
if os.path.isfile(local_path('basepatch.sfc')):
with open(local_path('basepatch.sfc'), 'rb') as stream:
buffer = bytearray(stream.read())
if self.verify(buffer):
self.buffer = buffer
if not os.path.exists(local_path(os.path.join('data', 'basepatch.bmbp'))):
create_patch_file(local_path('basepatch.sfc'))
return
if os.path.isfile(local_path(os.path.join('data', 'basepatch.bmbp'))):
_, target, buffer = create_rom_bytes(local_path(os.path.join('data', 'basepatch.bmbp')))
if self.verify(buffer):
self.buffer = bytearray(buffer)
with open(local_path('basepatch.sfc'), 'wb') as stream:
stream.write(buffer)
return
# verify correct checksum of baserom
basemd5 = hashlib.md5()
basemd5.update(self.buffer)
if JAP10HASH != basemd5.hexdigest():
logging.getLogger('').warning('Supplied Base Rom does not match known MD5 for JAP(1.0) release. Will try to patch anyway.')
if not self.verify(self.buffer, JAP10HASH):
logging.getLogger('').warning(
'Supplied Base Rom does not match known MD5 for JAP(1.0) release. Will try to patch anyway.')
# extend to 2MB
self.buffer.extend(bytearray([0x00]) * (0x200000 - len(self.buffer)))
@ -120,10 +102,14 @@ class LocalRom(object):
self.write_bytes(int(baseaddress), values)
# verify md5
patchedmd5 = hashlib.md5()
patchedmd5.update(self.buffer)
if patchedmd5.hexdigest() not in [RANDOMIZERBASEHASH]:
raise RuntimeError('Provided Base Rom unsuitable for patching. Please provide a JAP(1.0) "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" rom to use as a base.')
if self.verify(self.buffer):
with open(local_path('basepatch.sfc'), 'wb') as stream:
stream.write(self.buffer)
create_patch_file(local_path('basepatch.sfc'), destination=local_path(os.path.join('data', 'basepatch.bmbp')))
os.remove(local_path('data/base2current.json'))
else:
raise RuntimeError(
'Provided Base Rom unsuitable for patching. Please provide a JAP(1.0) "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" rom to use as a base.')
def write_crc(self):
crc = (sum(self.buffer[:0x7FDC] + self.buffer[0x7FE0:]) + 0x01FE) & 0xFFFF
@ -157,19 +143,16 @@ def read_rom(stream) -> bytearray:
buffer = buffer[0x200:]
return buffer
def patch_enemizer(world, player, rom, baserom_path, enemizercli, shufflepots, random_sprite_on_hit):
baserom_path = os.path.abspath(baserom_path)
basepatch_path = os.path.abspath(local_path('data/base2current.json'))
enemizer_basepatch_path = os.path.join(os.path.dirname(enemizercli), "enemizerBasePatch.json")
randopatch_path = os.path.abspath(output_path(f'enemizer_randopatch_{player}.json'))
def patch_enemizer(world, player: int, rom: LocalRom, enemizercli, random_sprite_on_hit):
randopatch_path = os.path.abspath(output_path(f'enemizer_randopatch_{player}.sfc'))
options_path = os.path.abspath(output_path(f'enemizer_options_{player}.json'))
enemizer_output_path = os.path.abspath(output_path(f'enemizer_output_{player}.json'))
enemizer_output_path = os.path.abspath(output_path(f'enemizer_output_{player}.sfc'))
# write options file for enemizer
options = {
'RandomizeEnemies': world.enemy_shuffle[player] != 'none',
'RandomizeEnemiesType': 3,
'RandomizeBushEnemyChance': world.enemy_shuffle[player] == 'chaos',
'RandomizeBushEnemyChance': 'chaos' in world.enemy_shuffle[player],
'RandomizeEnemyHealthRange': world.enemy_health[player] != 'default',
'RandomizeEnemyHealthType': {'default': 0, 'easy': 0, 'normal': 1, 'hard': 2, 'expert': 3}[
world.enemy_health[player]],
@ -178,12 +161,14 @@ def patch_enemizer(world, player, rom, baserom_path, enemizercli, shufflepots, r
'AllowEnemyZeroDamage': True,
'ShuffleEnemyDamageGroups': world.enemy_damage[player] != 'default',
'EnemyDamageChaosMode': world.enemy_damage[player] == 'chaos',
'EasyModeEscape': False,
'EasyModeEscape': world.mode[player] == "standard",
'EnemiesAbsorbable': False,
'AbsorbableSpawnRate': 10,
'AbsorbableTypes': {
'FullMagic': True, 'SmallMagic': True, 'Bomb_1': True, 'BlueRupee': True, 'Heart': True, 'BigKey': True, 'Key': True,
'Fairy': True, 'Arrow_10': True, 'Arrow_5': True, 'Bomb_8': True, 'Bomb_4': True, 'GreenRupee': True, 'RedRupee': True
'FullMagic': True, 'SmallMagic': True, 'Bomb_1': True, 'BlueRupee': True, 'Heart': True, 'BigKey': True,
'Key': True,
'Fairy': True, 'Arrow_10': True, 'Arrow_5': True, 'Bomb_8': True, 'Bomb_4': True, 'GreenRupee': True,
'RedRupee': True
},
'BossMadness': False,
'RandomizeBosses': True,
@ -205,7 +190,7 @@ def patch_enemizer(world, player, rom, baserom_path, enemizercli, shufflepots, r
'GrayscaleMode': False,
'GenerateSpoilers': False,
'RandomizeLinkSpritePalette': False,
'RandomizePots': shufflepots,
'RandomizePots': world.shufflepots[player],
'ShuffleMusic': False,
'BootlegMagic': True,
'CustomBosses': False,
@ -218,7 +203,8 @@ def patch_enemizer(world, player, rom, baserom_path, enemizercli, shufflepots, r
'BeesLevel': 0,
'RandomizeTileTrapPattern': world.enemy_shuffle[player] == 'chaos',
'RandomizeTileTrapFloorTile': False,
'AllowKillableThief': bool(random.randint(0,1)) if world.enemy_shuffle[player] == 'chaos' else world.enemy_shuffle[player] != 'none',
'AllowKillableThief': bool(random.randint(0, 1)) if 'thieves' in world.enemy_shuffle[player] else
world.enemy_shuffle[player] != 'none',
'RandomizeSpriteOnHit': random_sprite_on_hit,
'DebugMode': False,
'DebugForceEnemy': False,
@ -255,21 +241,14 @@ def patch_enemizer(world, player, rom, baserom_path, enemizercli, shufflepots, r
json.dump(options, f)
subprocess.check_call([os.path.abspath(enemizercli),
'--rom', baserom_path,
'--rom', randopatch_path,
'--seed', str(world.rom_seeds[player]),
'--base', basepatch_path,
'--randomizer', randopatch_path,
'--binary',
'--enemizer', options_path,
'--output', enemizer_output_path],
cwd=os.path.dirname(enemizercli), stdout=subprocess.DEVNULL)
with open(enemizer_basepatch_path, 'r') as f:
for patch in json.load(f):
rom.write_bytes(patch["address"], patch["patchData"])
with open(enemizer_output_path, 'r') as f:
for patch in json.load(f):
rom.write_bytes(patch["address"], patch["patchData"])
cwd=os.path.dirname(enemizercli))
rom.read_from_file(enemizer_output_path)
os.remove(enemizer_output_path)
if random_sprite_on_hit:
_populate_sprite_table()
@ -285,7 +264,7 @@ def patch_enemizer(world, player, rom, baserom_path, enemizercli, shufflepots, r
rom.write_bytes(0x307000 + (i * 0x8000), sprite.palette)
rom.write_bytes(0x307078 + (i * 0x8000), sprite.glove_palette)
for used in (randopatch_path, options_path, enemizer_output_path):
for used in (randopatch_path, options_path):
try:
os.remove(used)
except OSError:
@ -942,6 +921,7 @@ def patch_rom(world, rom, player, team, enemized):
equip[0x38E] |= 0x80
if startingstate.has('Silver Arrows', player):
equip[0x38E] |= 0x40
#TODO add Silver Bow
if startingstate.has('Titans Mitts', player):
equip[0x354] = 2
@ -980,7 +960,7 @@ def patch_rom(world, rom, player, team, enemized):
if item.player != player:
continue
if item.name in ['Bow', 'Silver Arrows', 'Progressive Bow', 'Progressive Bow (Alt)',
if item.name in ['Bow', 'Silver Bow', 'Silver Arrows', 'Progressive Bow', 'Progressive Bow (Alt)',
'Titans Mitts', 'Power Glove', 'Progressive Glove',
'Golden Sword', 'Tempered Sword', 'Master Sword', 'Fighter Sword', 'Progressive Sword',
'Mirror Shield', 'Red Shield', 'Blue Shield', 'Progressive Shield',
@ -992,22 +972,35 @@ def patch_rom(world, rom, player, team, enemized):
'Cape': (0x352, 1), 'Lamp': (0x34A, 1), 'Moon Pearl': (0x357, 1), 'Cane of Somaria': (0x350, 1), 'Cane of Byrna': (0x351, 1),
'Fire Rod': (0x345, 1), 'Ice Rod': (0x346, 1), 'Bombos': (0x347, 1), 'Ether': (0x348, 1), 'Quake': (0x349, 1)}
or_table = {'Green Pendant': (0x374, 0x04), 'Red Pendant': (0x374, 0x01), 'Blue Pendant': (0x374, 0x02),
'Crystal 1': (0x37A, 0x02), 'Crystal 2': (0x37A, 0x10), 'Crystal 3': (0x37A, 0x40), 'Crystal 4': (0x37A, 0x20),
'Crystal 1': (0x37A, 0x02), 'Crystal 2': (0x37A, 0x10), 'Crystal 3': (0x37A, 0x40),
'Crystal 4': (0x37A, 0x20),
'Crystal 5': (0x37A, 0x04), 'Crystal 6': (0x37A, 0x01), 'Crystal 7': (0x37A, 0x08),
'Big Key (Eastern Palace)': (0x367, 0x20), 'Compass (Eastern Palace)': (0x365, 0x20), 'Map (Eastern Palace)': (0x369, 0x20),
'Big Key (Desert Palace)': (0x367, 0x10), 'Compass (Desert Palace)': (0x365, 0x10), 'Map (Desert Palace)': (0x369, 0x10),
'Big Key (Tower of Hera)': (0x366, 0x20), 'Compass (Tower of Hera)': (0x364, 0x20), 'Map (Tower of Hera)': (0x368, 0x20),
'Big Key (Escape)': (0x367, 0xC0), 'Compass (Escape)': (0x365, 0xC0), 'Map (Escape)': (0x369, 0xC0),
'Big Key (Eastern Palace)': (0x367, 0x20), 'Compass (Eastern Palace)': (0x365, 0x20),
'Map (Eastern Palace)': (0x369, 0x20),
'Big Key (Desert Palace)': (0x367, 0x10), 'Compass (Desert Palace)': (0x365, 0x10),
'Map (Desert Palace)': (0x369, 0x10),
'Big Key (Tower of Hera)': (0x366, 0x20), 'Compass (Tower of Hera)': (0x364, 0x20),
'Map (Tower of Hera)': (0x368, 0x20),
'Big Key (Hyrule Castle)': (0x367, 0xC0), 'Compass (Hyrule Castle)': (0x365, 0xC0),
'Map (Hyrule Castle)': (0x369, 0xC0),
# doors-specific items
'Big Key (Agahnims Tower)': (0x367, 0x08), 'Compass (Agahnims Tower)': (0x365, 0x08), 'Map (Agahnims Tower)': (0x369, 0x08),
'Big Key (Agahnims Tower)': (0x367, 0x08), 'Compass (Agahnims Tower)': (0x365, 0x08),
'Map (Agahnims Tower)': (0x369, 0x08),
# end of doors-specific items
'Big Key (Palace of Darkness)': (0x367, 0x02), 'Compass (Palace of Darkness)': (0x365, 0x02), 'Map (Palace of Darkness)': (0x369, 0x02),
'Big Key (Thieves Town)': (0x366, 0x10), 'Compass (Thieves Town)': (0x364, 0x10), 'Map (Thieves Town)': (0x368, 0x10),
'Big Key (Skull Woods)': (0x366, 0x80), 'Compass (Skull Woods)': (0x364, 0x80), 'Map (Skull Woods)': (0x368, 0x80),
'Big Key (Swamp Palace)': (0x367, 0x04), 'Compass (Swamp Palace)': (0x365, 0x04), 'Map (Swamp Palace)': (0x369, 0x04),
'Big Key (Ice Palace)': (0x366, 0x40), 'Compass (Ice Palace)': (0x364, 0x40), 'Map (Ice Palace)': (0x368, 0x40),
'Big Key (Misery Mire)': (0x367, 0x01), 'Compass (Misery Mire)': (0x365, 0x01), 'Map (Misery Mire)': (0x369, 0x01),
'Big Key (Turtle Rock)': (0x366, 0x08), 'Compass (Turtle Rock)': (0x364, 0x08), 'Map (Turtle Rock)': (0x368, 0x08),
'Big Key (Palace of Darkness)': (0x367, 0x02), 'Compass (Palace of Darkness)': (0x365, 0x02),
'Map (Palace of Darkness)': (0x369, 0x02),
'Big Key (Thieves Town)': (0x366, 0x10), 'Compass (Thieves Town)': (0x364, 0x10),
'Map (Thieves Town)': (0x368, 0x10),
'Big Key (Skull Woods)': (0x366, 0x80), 'Compass (Skull Woods)': (0x364, 0x80),
'Map (Skull Woods)': (0x368, 0x80),
'Big Key (Swamp Palace)': (0x367, 0x04), 'Compass (Swamp Palace)': (0x365, 0x04),
'Map (Swamp Palace)': (0x369, 0x04),
'Big Key (Ice Palace)': (0x366, 0x40), 'Compass (Ice Palace)': (0x364, 0x40),
'Map (Ice Palace)': (0x368, 0x40),
'Big Key (Misery Mire)': (0x367, 0x01), 'Compass (Misery Mire)': (0x365, 0x01),
'Map (Misery Mire)': (0x369, 0x01),
'Big Key (Turtle Rock)': (0x366, 0x08), 'Compass (Turtle Rock)': (0x364, 0x08),
'Map (Turtle Rock)': (0x368, 0x08),
'Big Key (Ganons Tower)': (0x366, 0x04), 'Compass (Ganons Tower)': (0x364, 0x04), 'Map (Ganons Tower)': (0x368, 0x04)}
set_or_table = {'Flippers': (0x356, 1, 0x379, 0x02),'Pegasus Boots': (0x355, 1, 0x379, 0x04),
'Shovel': (0x34C, 1, 0x38C, 0x04), 'Flute': (0x34C, 3, 0x38C, 0x01),
@ -1021,7 +1014,7 @@ def patch_rom(world, rom, player, team, enemized):
'Small Key (Ice Palace)': [0x385],
'Small Key (Misery Mire)': [0x383], 'Small Key (Turtle Rock)': [0x388],
'Small Key (Ganons Tower)': [0x389],
'Small Key (Universal)': [0x38B], 'Small Key (Escape)': [0x37C, 0x37D]}
'Small Key (Universal)': [0x38B], 'Small Key (Hyrule Castle)': [0x37C, 0x37D]}
bottles = {'Bottle': 2, 'Bottle (Red Potion)': 3, 'Bottle (Green Potion)': 4, 'Bottle (Blue Potion)': 5,
'Bottle (Fairy)': 6, 'Bottle (Bee)': 7, 'Bottle (Good Bee)': 8}
rupees = {'Rupee (1)': 1, 'Rupees (5)': 5, 'Rupees (20)': 20, 'Rupees (50)': 50, 'Rupees (100)': 100, 'Rupees (300)': 300}
@ -1095,6 +1088,8 @@ def patch_rom(world, rom, player, team, enemized):
if world.goal[player] in ['pedestal', 'triforcehunt', 'localtriforcehunt']:
rom.write_byte(0x18003E, 0x01) # make ganon invincible
elif world.goal[player] in ['ganontriforcehunt', 'localganontriforcehunt']:
rom.write_byte(0x18003E, 0x05) # make ganon invincible until 20 triforce pieces are collected
elif world.goal[player] in ['dungeons']:
rom.write_byte(0x18003E, 0x02) # make ganon invincible until all dungeons are beat
elif world.goal[player] in ['crystals']:
@ -1732,7 +1727,7 @@ def write_strings(rom, world, player, team):
# We still need the older hints of course. Those are done here.
silverarrows = world.find_items('Silver Arrows', player)
silverarrows = world.find_items('Silver Bow', player)
random.shuffle(silverarrows)
silverarrow_hint = (' %s?' % hint_text(silverarrows[0]).replace('Ganon\'s', 'my')) if silverarrows else '?\nI think not!'
tt['ganon_phase_3_no_silvers'] = 'Did you find the silver arrows%s' % silverarrow_hint
@ -1775,9 +1770,11 @@ def write_strings(rom, world, player, team):
if world.goal[player] in ['triforcehunt', 'localtriforcehunt']:
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Get the Triforce Pieces.'
tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.'
tt['sign_ganon'] = 'Go find the Triforce pieces... Ganon is invincible!'
tt[
'murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\ninvisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\nhidden in a hollow tree. If you bring\n%d triforce pieces, I can reassemble it." % \
if world.goal[player] == 'triforcehunt' and world.players > 1:
tt['sign_ganon'] = 'Go find the Triforce pieces with your friends... Ganon is invincible!'
else:
tt['sign_ganon'] = 'Go find the Triforce pieces... Ganon is invincible!'
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\ninvisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\nhidden in a hollow tree. If you bring\n%d triforce pieces, I can reassemble it." % \
world.treasure_hunt_count[player]
elif world.goal[player] in ['pedestal']:
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Your goal is at the pedestal.'
@ -1787,6 +1784,10 @@ def write_strings(rom, world, player, team):
tt['ganon_fall_in'] = Ganon1_texts[random.randint(0, len(Ganon1_texts) - 1)]
tt['ganon_fall_in_alt'] = 'You cannot defeat me until you finish your goal!'
tt['ganon_phase_3_alt'] = 'Got wax in\nyour ears?\nI can not die!'
if world.goal[player] == 'ganontriforcehunt' and world.players > 1:
tt['sign_ganon'] = 'You need to find %d Triforce pieces with your friends to defeat Ganon.' % world.treasure_hunt_count[player]
elif world.goal[player] in ['ganontriforcehunt', 'localganontriforcehunt']:
tt['sign_ganon'] = 'You need to find %d Triforce pieces to defeat Ganon.' % world.treasure_hunt_count[player]
tt['kakariko_tavern_fisherman'] = TavernMan_texts[random.randint(0, len(TavernMan_texts) - 1)]
@ -2290,7 +2291,7 @@ RelevantItems = ['Bow',
]
SmallKeys = ['Small Key (Eastern Palace)',
'Small Key (Escape)',
'Small Key (Hyrule Castle)',
'Small Key (Desert Palace)',
'Small Key (Tower of Hera)',
'Small Key (Agahnims Tower)',

112
Rules.py
View File

@ -2,21 +2,25 @@ import collections
import logging
import OverworldGlitchRules
from BaseClasses import RegionType, World, Entrance
from Items import ItemFactory
from Items import ItemFactory, progression_items, item_name_groups
from OverworldGlitchRules import overworld_glitches_rules, no_logic_rules
def set_rules(world, player):
locality_rules(world, player)
if world.logic[player] == 'nologic':
logging.getLogger('').info('WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
logging.getLogger('').info(
'WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
world.get_region('Menu', player).can_reach_private = lambda state: True
no_logic_rules(world, player)
for exit in world.get_region('Menu', player).exits:
exit.hide_path = True
return
crossover_logic(world, player)
global_rules(world, player)
if world.mode[player] != 'inverted':
default_rules(world, player)
@ -39,7 +43,8 @@ def set_rules(world, player):
fake_flipper_rules(world, player)
overworld_glitches_rules(world, player)
elif world.logic[player] == 'minorglitches':
logging.getLogger('').info('Minor Glitches may be buggy still. No guarantee for proper logic checks.')
no_glitches_rules(world, player)
fake_flipper_rules(world, player)
else:
raise NotImplementedError('Not implemented yet')
@ -115,36 +120,63 @@ def add_lamp_requirement(spot, player):
add_rule(spot, lambda state: state.has('Lamp', player, state.world.lamps_needed_for_dark_rooms))
def forbid_item(location, item, player):
def forbid_item(location, item, player: int):
old_rule = location.item_rule
location.item_rule = lambda i: (i.name != item or i.player != player) and old_rule(i)
def forbid_items(location, items: set, player: int):
old_rule = location.item_rule
location.item_rule = lambda i: (i.player != player or i.name not in items) and old_rule(i)
def add_item_rule(location, rule):
old_rule = location.item_rule
location.item_rule = lambda item: rule(item) and old_rule(item)
def item_in_locations(state, item, player, locations):
for location in locations:
if item_name(state, location[0], location[1]) == (item, player):
return True
return False
def item_name(state, location, player):
location = state.world.get_location(location, player)
if location.item is None:
return None
return (location.item.name, location.item.player)
def global_rules(world, player):
# ganon can only carry triforce
add_item_rule(world.get_location('Ganon', player), lambda item: item.name == 'Triforce' and item.player == player)
if world.goal[player] == "localtriforcehunt":
def locality_rules(world, player):
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].add('Triforce Piece')
if world.local_items[player]:
for location in world.get_locations():
if location.player != player:
for item in world.local_items[player]:
forbid_item(location, item, player)
forbid_items(location, world.local_items[player], player)
non_crossover_items = (item_name_groups["Small Keys"] | item_name_groups["Big Keys"] | progression_items) - {
"Small Key (Universal)"}
def crossover_logic(world, player):
""" Simple and not graceful solution to logic loops if you mix no logic and logic.
Making it so that logical progression cannot be placed in no logic worlds."""
no_logic_players = set()
for other_player in world.player_ids:
if world.logic[other_player] == 'nologic':
no_logic_players.add(other_player)
if no_logic_players:
for location in world.get_locations():
if location.player in no_logic_players:
forbid_items(location, non_crossover_items, player)
def global_rules(world, player):
# ganon can only carry triforce
add_item_rule(world.get_location('Ganon', player), lambda item: item.name == 'Triforce' and item.player == player)
# determines which S&Q locations are available - hide from paths since it isn't an in-game location
world.get_region('Menu', player).can_reach_private = lambda state: True
for exit in world.get_region('Menu', player).exits:
@ -171,26 +203,40 @@ def global_rules(world, player):
set_rule(world.get_location('Spike Cave', player), lambda state:
state.has('Hammer', player) and state.can_lift_rocks(player) and
((state.has('Cape', player) and state.can_extend_magic(player, 16, True)) or
(state.has('Cane of Byrna', player) and
(state.can_extend_magic(player, 12, True) or
(state.world.can_take_damage[player] and (state.has_Boots(player) or state.has_hearts(player, 4))))))
)
(state.has('Cane of Byrna', player) and
(state.can_extend_magic(player, 12, True) or
(state.world.can_take_damage[player] and (state.has_Boots(player) or state.has_hearts(player, 4))))))
)
set_rule(world.get_location('Hookshot Cave - Top Right', player), lambda state: state.has('Hookshot', player))
set_rule(world.get_location('Hookshot Cave - Top Left', player), lambda state: state.has('Hookshot', player))
set_rule(world.get_location('Hookshot Cave - Bottom Right', player), lambda state: state.has('Hookshot', player) or state.has('Pegasus Boots', player))
set_rule(world.get_location('Hookshot Cave - Bottom Right', player),
lambda state: state.has('Hookshot', player) or state.has('Pegasus Boots', player))
set_rule(world.get_location('Hookshot Cave - Bottom Left', player), lambda state: state.has('Hookshot', player))
set_rule(world.get_entrance('Sewers Door', player), lambda state: state.has_key('Small Key (Escape)', player) or (world.retro[player] and world.mode[player] == 'standard')) # standard retro cannot access the shop
set_rule(world.get_entrance('Sewers Back Door', player), lambda state: state.has_key('Small Key (Escape)', player))
set_rule(world.get_entrance('Agahnim 1', player), lambda state: state.has_sword(player) and state.has_key('Small Key (Agahnims Tower)', player, 2))
set_rule(world.get_entrance('Sewers Door', player),
lambda state: state.has_key('Small Key (Hyrule Castle)', player) or (world.retro[player] and world.mode[
player] == 'standard')) # standard retro cannot access the shop
set_rule(world.get_entrance('Sewers Back Door', player),
lambda state: state.has_key('Small Key (Hyrule Castle)', player))
set_rule(world.get_entrance('Agahnim 1', player),
lambda state: state.has_sword(player) and state.has_key('Small Key (Agahnims Tower)', player, 2))
set_defeat_dungeon_boss_rule(world.get_location('Agahnim 1', player))
set_rule(world.get_location('Castle Tower - Room 03', player), lambda state: state.can_kill_most_things(player, 8))
set_rule(world.get_location('Castle Tower - Dark Maze', player), lambda state: state.can_kill_most_things(player, 8) and state.has_key('Small Key (Agahnims Tower)', player))
set_rule(world.get_location('Castle Tower - Dark Maze', player),
lambda state: state.can_kill_most_things(player, 8) and state.has_key('Small Key (Agahnims Tower)',
player))
set_rule(world.get_location('Eastern Palace - Big Chest', player), lambda state: state.has('Big Key (Eastern Palace)', player))
set_rule(world.get_location('Eastern Palace - Boss', player), lambda state: state.can_shoot_arrows(player) and state.has('Big Key (Eastern Palace)', player) and state.world.get_location('Eastern Palace - Boss', player).parent_region.dungeon.boss.can_defeat(state))
set_rule(world.get_location('Eastern Palace - Prize', player), lambda state: state.can_shoot_arrows(player) and state.has('Big Key (Eastern Palace)', player) and state.world.get_location('Eastern Palace - Prize', player).parent_region.dungeon.boss.can_defeat(state))
set_rule(world.get_location('Eastern Palace - Big Chest', player),
lambda state: state.has('Big Key (Eastern Palace)', player))
set_rule(world.get_location('Eastern Palace - Boss', player),
lambda state: state.can_shoot_arrows(player) and state.has('Big Key (Eastern Palace)',
player) and state.world.get_location(
'Eastern Palace - Boss', player).parent_region.dungeon.boss.can_defeat(state))
set_rule(world.get_location('Eastern Palace - Prize', player),
lambda state: state.can_shoot_arrows(player) and state.has('Big Key (Eastern Palace)',
player) and state.world.get_location(
'Eastern Palace - Prize', player).parent_region.dungeon.boss.can_defeat(state))
for location in ['Eastern Palace - Boss', 'Eastern Palace - Big Chest']:
forbid_item(world.get_location(location, player), 'Big Key (Eastern Palace)', player)
@ -377,8 +423,13 @@ def global_rules(world, player):
'Ganons Tower - Pre-Moldorm Chest', 'Ganons Tower - Validation Chest']:
forbid_item(world.get_location(location, player), 'Big Key (Ganons Tower)', player)
set_rule(world.get_location('Ganon', player), lambda state: state.has_beam_sword(player) and state.has_fire_source(player) and state.has_crystals(world.crystals_needed_for_ganon[player], player)
and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or (state.has('Silver Arrows', player) and state.can_shoot_arrows(player)) or state.has('Lamp', player) or state.can_extend_magic(player, 12))) # need to light torch a sufficient amount of times
if world.goal[player] in ['ganontriforcehunt', 'localganontriforcehunt']:
set_rule(world.get_location('Ganon', player), lambda state: state.has_beam_sword(player) and state.has_fire_source(player) and state.has_triforce_pieces(world.treasure_hunt_count[player], player)
and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or (state.has('Silver Bow', player) and state.can_shoot_arrows(player)) or state.has('Lamp', player) or state.can_extend_magic(player, 12))) # need to light torch a sufficient amount of times
else:
set_rule(world.get_location('Ganon', player), lambda state: state.has_beam_sword(player) and state.has_fire_source(player) and state.has_crystals(world.crystals_needed_for_ganon[player], player)
and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or (state.has('Silver Bow', player) and state.can_shoot_arrows(player)) or state.has('Lamp', player) or state.can_extend_magic(player, 12))) # need to light torch a sufficient amount of times
set_rule(world.get_entrance('Ganon Drop', player), lambda state: state.has_beam_sword(player)) # need to damage ganon to get tiles to drop
@ -764,8 +815,10 @@ def add_conditional_lamps(world, player):
def open_rules(world, player):
# softlock protection as you can reach the sewers small key door with a guard drop key
set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player), lambda state: state.has_key('Small Key (Escape)', player))
set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player), lambda state: state.has_key('Small Key (Escape)', player))
set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player),
lambda state: state.has_key('Small Key (Hyrule Castle)', player))
set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player),
lambda state: state.has_key('Small Key (Hyrule Castle)', player))
def swordless_rules(world, player):
@ -774,7 +827,10 @@ def swordless_rules(world, player):
set_rule(world.get_location('Ether Tablet', player), lambda state: state.has('Book of Mudora', player) and state.has('Hammer', player))
set_rule(world.get_entrance('Skull Woods Torch Room', player), lambda state: state.has_key('Small Key (Skull Woods)', player, 3) and state.has('Fire Rod', player)) # no curtain
set_rule(world.get_entrance('Ice Palace Entrance Room', player), lambda state: state.has('Fire Rod', player) or state.has('Bombos', player)) #in swordless mode bombos pads are present in the relevant parts of ice palace
set_rule(world.get_location('Ganon', player), lambda state: state.has('Hammer', player) and state.has_fire_source(player) and state.has('Silver Arrows', player) and state.can_shoot_arrows(player) and state.has_crystals(world.crystals_needed_for_ganon[player], player))
if world.goal[player] in ['ganontriforcehunt', 'localganontriforcehunt']:
set_rule(world.get_location('Ganon', player), lambda state: state.has('Hammer', player) and state.has_fire_source(player) and state.has('Silver Bow', player) and state.can_shoot_arrows(player) and state.has_triforce_pieces(world.treasure_hunt_count[player], player))
else:
set_rule(world.get_location('Ganon', player), lambda state: state.has('Hammer', player) and state.has_fire_source(player) and state.has('Silver Bow', player) and state.can_shoot_arrows(player) and state.has_crystals(world.crystals_needed_for_ganon[player], player))
set_rule(world.get_entrance('Ganon Drop', player), lambda state: state.has('Hammer', player)) # need to damage ganon to get tiles to drop
if world.mode[player] != 'inverted':

93
Text.py
View File

@ -45,18 +45,18 @@ Uncle_texts = [
"Don't worry.\nI got this\ncovered.",
"Race you to\nthe castle!",
"\n hi",
"I'M JUST GOING\nOUT FOR A\nPACK OF SMOKES",
"I'm just going\nout for a\npack of smokes",
"It's dangerous\nto go alone.\nSee ya!",
"ARE YOU A BAD\nENOUGH DUDE TO\nRESCUE ZELDA?",
"Are you a bad\nenough dude to\nrescue Zelda?",
"\n\n I AM ERROR",
"This seed is\nsub 2 hours,\nguaranteed.",
"The chest is\na secret to\neverybody.",
"I'm off to\nfind the\nwind fish.",
"The shortcut\nto Ganon\nis this way!",
"THE MOON IS\nCRASHING! RUN\nFOR YOUR LIFE!",
"The moon is\ncrashing! Run\nfor your life!",
"Time to fight\nhe who must\nnot be named.",
"RED MAIL\nIS FOR\nCOWARDS.",
"HEY!\n\nLISTEN!",
"Red Mail\nis for\ncowards.",
"Hey!\n\nListen!",
"Well\nexcuuuuuse me,\nprincess!",
"5,000 Rupee\nreward for >\nYou're boned.",
"Welcome to\nStoops Lonk's\nHoose",
@ -66,6 +66,22 @@ Uncle_texts = [
"Get to the\nchop...\ncastle!",
"Come with me\nif you want\nto live",
"I must go\nmy planet\nneeds me",
"Are we in\ngo mode yet?",
"Darn, I\nthought this\nwas combo.",
"Don't check\nanything I\nwouldn't!",
"I know where\nthe bow is!",
"This message\nwill self\ndestruct.",
"Time to cast\nMeteo on\nGanon!",
"I have a\nlong, full\nlife ahead!",
"Why did that\nsoda have a\nskull on it?",
"Something\nrandom just\ncame up.",
"I'm bad at\nthis. Can you\ndo it for me?",
"Link!\n Wake up!\n ... Bye!",
"Text me when\nyou hit\ngo mode.",
"Turn off the\nstove before\nyou leave.",
"It's raining.\nI'm taking\nthe umbrella.",
"Count to 30.\nThen come\nfind me.",
"Gonna shuffle\nall the items\nreal quick.",
]
Triforce_texts = [
'Product has Hole in center. Bad seller, 0 out of 5.',
@ -78,28 +94,28 @@ Triforce_texts = [
"\n G G",
"All your base\nare belong\nto us.",
"You have ended\nthe domination\nof Dr. Wily",
" thanks for\n playing!!!",
"\n You Win!",
" Thank you!\n your quest\n is over.",
" A winner\n is\n you!",
" Thanks for\n playing!!!",
"\n You Win!",
" Thank you!\n Your quest\n is over.",
" A winner\n is\n you!",
"\n WINNER!!",
"\n I'm sorry\n\n but your\nprincess is in\nanother castle",
"\n success!",
" Whelp…\n that just\n happened",
" Oh hey…\n it's you",
"\n I'm sorry\n\n but your\nprincess is in\nanother castle",
"\n Success!",
" Whelp…\n that just\n happened",
" Oh hey…\n it's you",
"\n Wheeeeee!!",
" Time for\n another one?",
" Time for\n another one?",
"and\n\n scene",
"\n GOT EM!!",
"\nTHE VALUUUE!!!",
"\n Got 'em!!",
"\nThe valuuue!!!",
"Cool seed,\n\nright?",
"\n We did it!",
" Spam those\n emotes in\n wilds chat",
" Spam those\n emotes in\n Wild's chat",
"\n O M G",
" Hello. Will\n you be my\n friend?",
" Beetorp\n was\n here!",
" Hello. Will\n you be my\n friend?",
" Beetorp\n was\n here!",
"The Wind Fish\nwill wake\nsoon. Hoot!",
"meow meow meow\nmeow meow meow\n oh my god!",
"Meow meow meow\nMeow meow meow\n Oh my god!",
"Ahhhhhhhhh\nYa ya yaaaah\nYa ya yaaah",
".done\n\n.comment lol",
"You get to\ndrink from\nthe firehose",
@ -107,7 +123,12 @@ Triforce_texts = [
"You get one\nwish. Choose\nwisely, hero!",
"Can you please\nbreak us three\nup? Thanks.",
" Pick us up\n before we\n get dizzy!",
"\n Honk."
"Thank you,\nMikey. Youre\n2 minutes late",
"This was a\n7000 series\ntrain.",
" I'd buy\n that for\n a rupee!",
" Did you like\n that bow\n placement?",
"I promise the\nnext seed will\nbe better.",
"\n Honk.",
]
BombShop2_texts = ['Bombs!\nBombs!\nBiggest!\nBestest!\nGreatest!\nBoomest!']
Sahasrahla2_texts = ['You already got my item, idiot.', 'Why are you still talking to me?', 'This text won\'t change.', 'Have you met my brother, Hasarahshla?']
@ -146,6 +167,18 @@ Blind_texts = [
"I tried to\ncatch fog,\nbut I mist.",
"Winter is a\ngreat time\nto chill.",
"Do you think\nthe Ice Rod\nis cool?",
"Pyramids?\nI never saw\nthe point.",
"Stone golems\nare created as\nblank slates.",
"Desert humor\nis often dry.",
"Ganon is a\nbacon of\ndespair!",
"Butchering\ncows means\nhigh steaks.",
"I can't search\nthe web...\nToo many links",
"I can whistle\nMost pitches\nbut I can't C",
"The Blinds\nStore is\ncurtain death",
"Dark Aga Rooms\nare not a\nbright idea.",
"Best advice\nfor a Goron?\nBe Boulder.",
"Equestrian\nservices are\na stable job.",
"Do I like\ndrills? Just\na bit.",
]
Ganon1_texts = [
"Start your day\nsmiling with a\ndelicious\nwhole grain\nbreakfast\ncreated for\nyour\nincredible\ninsides.",
@ -153,7 +186,7 @@ Ganon1_texts = [
"Impa says that\nthe mark on\nyour hand\nmeans that you\nare the hero\nchosen to\nawaken Zelda.\nYour blood can\nresurrect me.",
"Don't stand,\n\ndon't stand so\nDon't stand so\n\nclose to me\nDon't stand so\nclose to me\nBack off buddy",
"So ya\nThought ya\nMight like to\ngo to the show\nTo feel the\nwarm thrill of\nconfusion\nThat space\ncadet glow.",
"Like other\npulmonate land\ngastropods,\nthe majority\nof land slugs\nhave two pairs\nof 'feelers'\n,or tentacles,\non their head.",
"Like other\npulmonate land\ngastropods,\nthe majority\nof land slugs\nhave two pairs\nof 'feelers'\nor tentacles,\non their head.",
"If you were a\nburrito, what\nkind of a\nburrito would\nyou be?\nMe, I fancy I\nwould be a\nspicy barbacoa\nburrito.",
"I am your\nfather's\nbrother's\nnephew's\ncousin's\nformer\nroommate. What\ndoes that make\nus, you ask?",
"I'll be more\neager about\nencouraging\nthinking\noutside the\nbox when there\nis evidence of\nany thinking\ninside it.",
@ -171,6 +204,8 @@ Ganon1_texts = [
"When I conquer\nthe Light\nWorld, I'll\nhold a parade\nof all my\nmonsters to\ndemonstrate my\nmight to the\npeople!",
"Life, dreams,\nhope...\nWhere'd they\ncome from? And\nwhere are they\nheaded? These\nthings... I am\ngoing to\ndestroy!",
"My minions all\nfailed to\nguard those\nitems?!\n\nWhy am I\nsurrounded by\nincompetent\nfools?!",
"Bacon dates to\n1500 BCE and\nrefers to the\nback of a pig.\nThe average\nAmerican eats\n18 pounds of\nRoman \"petaso\"\nevery year.",
"The enrichment\nCenter would\nLike to remind\nYou that the\nCompanion\nDuck will not\nbetray you\nand in fact\ncannot speak.",
"Goose is\nactually the\nterm for\nfemale geese,\nmale geese are\ncalled\nganders.",
]
TavernMan_texts = [
@ -250,6 +285,20 @@ junk_texts = [
"{C:GREEN}\nI bet a nice\ncup of tea\nwould help! >",
"{C:GREEN}\nI bet you\nexpected help,\ndidn't you? >",
"{C:GREEN}\nLearn to make\nplogues, easy\nand yummy! >",
"{C:GREEN}\nI don't know\nwhere it is\neither. >",
"{C:GREEN}\nA dog exists\nsomewhere. >",
"{C:GREEN}\nIf all else\nfails use\nfire. >",
"{C:GREEN}\nItems are\nrequired to\nwin. >",
"{C:GREEN}\nDid you try\nchecking\nvanilla? >",
"{C:GREEN}\n> If you find\nmy lunch,\ndon't eat it.",
"{C:GREEN}\nDeadrocks are\nannoying. >",
"{C:GREEN}\nMist Form\nis in the\nCatacombs. >",
"{C:GREEN}\nMaybe you\ncould hire a\ndetective? >",
"{C:GREEN}\n> READ\nor the owl\nwill eat you.",
"{C:GREEN}\n> Bunnies\nare cute.",
"{C:GREEN}\nPugs are the\nsuperior dog\nbreed. >",
"{C:GREEN}\nYou are\nplaying\nALTTPR. >",
"{C:GREEN}\nOther randos\nexist too!\nTry some! >",
]
KingsReturn_texts = [

View File

@ -1,15 +1,21 @@
from __future__ import annotations
import typing
__version__ = "2.3.1"
_version_tuple = tuple(int(piece, 10) for piece in __version__.split("."))
def tuplize_version(version: str) -> typing.Tuple[int, ...]:
return tuple(int(piece, 10) for piece in version.split("."))
__version__ = "2.3.4"
_version_tuple = tuplize_version(__version__)
import os
import subprocess
import sys
import typing
import functools
from yaml import load, dump
from yaml import load, dump, safe_load
try:
from yaml import CLoader as Loader
@ -141,7 +147,7 @@ def make_new_base2current(old_rom='Zelda no Densetsu - Kamigami no Triforce (Jap
out_data[idx] = [int(new)]
for offset in reversed(list(out_data.keys())):
if offset - 1 in out_data:
out_data[offset-1].extend(out_data.pop(offset))
out_data[offset - 1].extend(out_data.pop(offset))
with open('data/base2current.json', 'wt') as outfile:
json.dump([{key: value} for key, value in out_data.items()], outfile, separators=(",", ":"))
@ -150,7 +156,8 @@ def make_new_base2current(old_rom='Zelda no Densetsu - Kamigami no Triforce (Jap
return "New Rom Hash: " + basemd5.hexdigest()
parse_yaml = functools.partial(load, Loader=Loader)
parse_yaml = safe_load
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
class Hint(typing.NamedTuple):
@ -190,6 +197,17 @@ 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
import logging
ip = socket.gethostbyname(socket.gethostname())
try:
ip = urllib.request.urlopen('https://v6.ident.me').read().decode('utf8').strip()
except Exception as e:
logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available
return ip
def get_options() -> dict:
if not hasattr(get_options, "options"):
@ -234,7 +252,7 @@ def persistent_load() -> typing.Dict[dict]:
if os.path.exists(path):
try:
with open(path, "r") as f:
storage = parse_yaml(f.read())
storage = unsafe_parse_yaml(f.read())
except Exception as e:
import logging
logging.debug(f"Could not read store: {e}")
@ -244,7 +262,7 @@ def persistent_load() -> typing.Dict[dict]:
return storage
def get_adjuster_settings(romfile: str):
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:
@ -278,6 +296,7 @@ def get_adjuster_settings(romfile: str):
get_adjuster_settings.adjuster_settings = adjuster_settings
get_adjuster_settings.adjust_wanted = adjust_wanted
return romfile, adjusted
return romfile, False

54
WebHost.py Normal file
View File

@ -0,0 +1,54 @@
import os
import multiprocessing
import logging
from WebHost import app
from waitress import serve
from WebHost.models import db, Room, db_session, select
DEBUG = False
port = 80
def autohost(config: dict):
return
# not implemented yet. https://github.com/ponyorm/pony/issues/527
import time
from datetime import timedelta, datetime
def keep_running():
# db.bind(**config["PONY"])
# db.generate_mapping(check_tables=False)
while 1:
time.sleep(3)
with db_session:
rooms = select(
room for room in Room if
room.last_activity >= datetime.utcnow() - timedelta(hours=room.timeout))
logging.info(rooms)
import threading
threading.Thread(target=keep_running).start()
if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
configpath = "config.yaml"
if os.path.exists(configpath):
import yaml
with open(configpath) as c:
app.config.update(yaml.safe_load(c))
logging.info(f"Updated config from {configpath}")
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
if DEBUG:
autohost(app.config)
app.run(debug=True, port=port)
else:
serve(app, port=port, threads=app.config["WAITRESS_THREADS"])

140
WebHost/__init__.py Normal file
View File

@ -0,0 +1,140 @@
"""Friendly reminder that if you want to host this somewhere on the internet, that it's licensed under MIT Berserker66
So unless you're Berserker you need to include license information."""
import os
import logging
import typing
import multiprocessing
import threading
from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort
from flask_caching import Cache
from flaskext.autoversion import Autoversion
from flask_compress import Compress
from .models import *
UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs')
os.makedirs(LOGS_FOLDER, exist_ok=True)
def allowed_file(filename):
return filename.endswith(('multidata', ".zip"))
app = Flask(__name__)
Pony(app)
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)
app.config['SESSION_PERMANENT'] = True
app.config[
"WAITRESS_THREADS"] = 10 # waitress uses one thread for I/O, these are for processing of views that then get sent
app.config["PONY"] = {
'provider': 'sqlite',
'filename': os.path.abspath('db.db3'),
'create_db': True
}
app.config["CACHE_TYPE"] = "simple"
app.autoversion = True
av = Autoversion(app)
cache = Cache(app)
Compress(app)
# this local cache is risky business if app hosting is done with subprocesses as it will not sync. Waitress is fine though
multiworlds = {}
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
class MultiworldInstance():
def __init__(self, room: Room):
self.room_id = room.id
self.process: typing.Optional[multiprocessing.Process] = None
multiworlds[self.room_id] = self
def start(self):
if self.process and self.process.is_alive():
return False
logging.info(f"Spinning up {self.room_id}")
with db_session:
self.process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, app.config["PONY"]),
name="MultiHost")
self.process.start()
def stop(self):
if self.process:
self.process.terminate()
self.process = None
@app.route('/seed/<uuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("view_seed.html", seed=seed,
rooms=[room for room in seed.rooms if room.owner == session["_id"]])
@app.route('/new_room/<uuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<uuid:room>')
def display_log(room: UUID):
# noinspection PyTypeChecker
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
processstartlock = threading.Lock()
@app.route('/hosted/<uuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
Command(room=room, commandtext=cmd)
commit()
with db_session:
multiworld = multiworlds.get(room.id, None)
if not multiworld:
multiworld = MultiworldInstance(room)
with processstartlock:
multiworld.start()
return render_template("host_room.html", room=room)
from WebHost.customserver import run_server_process
from . import tracker, upload, landing # to trigger app routing picking up on it

134
WebHost/customserver.py Normal file
View File

@ -0,0 +1,134 @@
import functools
import logging
import os
import websockets
import asyncio
import socket
import threading
import time
import random
from WebHost import LOGS_FOLDER
from .models import *
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6
class CustomClientMessageProcessor(ClientMessageProcessor):
def _cmd_video(self, platform, user):
"""Set a link for your name in the WebHost tracker pointing to a video stream"""
if platform.lower().startswith("t"): # twitch
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
self.ctx.save()
self.output(f"Registered Twitch Stream https://www.twitch.tv/{user}")
return True
return False
# inject
import MultiServer
MultiServer.client_message_processor = CustomClientMessageProcessor
del (MultiServer)
class DBCommandProcessor(ServerCommandProcessor):
def output(self, text: str):
logging.info(text)
class WebHostContext(Context):
def __init__(self):
super(WebHostContext, self).__init__("", 0, "", 1, 40, True, "enabled", "enabled", 0)
self.main_loop = asyncio.get_running_loop()
self.video = {}
self.tags = ["Berserker", "WebHost"]
def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self)
while self.running:
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id)
if commands:
for command in commands:
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
time.sleep(5)
@db_session
def load(self, room_id: int):
self.room_id = room_id
room = Room.get(id=room_id)
if room.last_port:
self.port = room.last_port
else:
self.port = get_random_port()
return self._load(room.seed.multidata, True)
@db_session
def init_save(self, enabled: bool = True):
self.saving = enabled
if self.saving:
existings_savegame = Room.get(id=self.room_id).multisave
if existings_savegame:
self.set_save(existings_savegame)
self._start_async_saving()
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
def _save(self) -> bool:
Room.get(id=self.room_id).multisave = self.get_save()
return True
def get_save(self) -> dict:
d = super(WebHostContext, self).get_save()
d["video"] = [(tuple(playerslot), videodata) for playerslot, videodata in self.video.items()]
return d
def get_random_port():
return random.randint(49152, 65535)
def run_server_process(room_id, ponyconfig: dict):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
async def main():
logging.basicConfig(format='[%(asctime)s] %(message)s',
level=logging.INFO,
handlers=[
logging.FileHandler(os.path.join(LOGS_FOLDER, f"{room_id}.txt"), 'a', 'utf-8-sig')])
ctx = WebHostContext()
ctx.load(room_id)
ctx.auto_shutdown = 24 * 60 * 60 # 24 hours
ctx.init_save()
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
ping_interval=None)
await ctx.server
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
ping_interval=None)
await ctx.server
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = socketname[1]
elif wssocket.family == socket.AF_INET:
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
ctx.auto_shutdown = 6 * 60
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
logging.info("Shutting down")
asyncio.run(main())

8
WebHost/landing.py Normal file
View File

@ -0,0 +1,8 @@
from flask import render_template
from WebHost import app, cache
@cache.memoize(timeout=300)
@app.route('/', methods=['GET', 'POST'])
def landing():
return render_template("landing.html")

42
WebHost/models.py Normal file
View File

@ -0,0 +1,42 @@
from datetime import datetime
from uuid import UUID, uuid4
from pony.orm import *
db = Database()
class Patch(db.Entity):
id = PrimaryKey(int, auto=True)
player = Required(int)
data = Required(buffer, lazy=True)
seed = Optional('Seed')
class Room(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow())
owner = Required(UUID, index=True)
commands = Set('Command')
seed = Required('Seed', index=True)
multisave = Optional(Json, lazy=True)
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
timeout = Required(int, default=lambda: 6)
tracker = Optional(UUID, index=True)
last_port = Optional(int, default=lambda: 0)
class Seed(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
rooms = Set(Room)
multidata = Optional(Json, lazy=True)
owner = Required(UUID, index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow())
patches = Set(Patch)
spoiler = Optional(str, lazy=True)
class Command(db.Entity):
id = PrimaryKey(int, auto=True)
room = Required(Room)
commandtext = Required(str)

6
WebHost/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
flask>=1.1.2
pony>=0.7.13
waitress>=1.4.4
flask-caching>=1.9.0
Flask-Autoversion>=0.2.0
Flask-Compress>=1.5.0

View File

@ -0,0 +1,114 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('jquery')) :
typeof define === 'function' && define.amd ? define(['exports', 'jquery'], factory) :
(factory((global.$ = global.$ || {}, global.$.fn = global.$.fn || {}), global.$));
}(this, (function (exports, $) {
'use strict';
$ = $ && $.hasOwnProperty('default') ? $['default'] : $;
// 参考了reference
// debouncing function from John Hann
// http://unscriptable.com/index.php/2009/03/20/debouncing-javascript-methods/
function debounce(func, threshold) {
var timeout;
return function debounced() {
var obj = this, args = arguments;
function delayed() {
// 让调用smartresize的对象执行
func.apply(obj, args);
/*
timeout = null;这个语句只是单纯将timeout指向null
而timeout指向的定时器还存在
要想清除定时器让setTimeout调用的函数不执行要用clearTimeout(timeout)
eg
var timeout = setTimeout(function(){
alert('timeout = null');// 执行
},1000);
timeout = null;
var timeout = setTimeout(function(){
alert('clearTimeout(timeout)');// 不执行
},1000);
clearTimeout(timeout);
var timeout = setTimeout(function(){
clearTimeout(timeout);
alert('clearTimeout(timeout)');// 执行(已经开始执行匿名函数了)
},1000);
*/
timeout = null;
}
// 如果有timeout正在倒计时则清除当前timeout
timeout && clearTimeout(timeout);
timeout = setTimeout(delayed, threshold || 100);
};
}
function smartscroll(fn, threshold) {
return fn ? this.bind('scroll', debounce(fn, threshold)) : this.trigger('smartscroll');
}
//jquery-smartscroll
$.fn.smartscroll = smartscroll;
function scrollsync(options) {
var defaluts = {
x_sync: true,
y_sync: true,
use_smartscroll: false,
smartscroll_delay: 10,
};
// 使用jQuery.extend 覆盖插件默认参数
var options = $.extend({}, defaluts, options);
console.log(options);
var scroll_type = options.use_smartscroll ? 'smartscroll' : 'scroll';
var $containers = this;
// 滚动后设置scrolling的值调用set同步滚动条
var scrolling = {};
Object.defineProperty(scrolling, 'top', {
set: function (val) {
$containers.each(function () {
$(this).scrollTop(val);
});
}
});
Object.defineProperty(scrolling, 'left', {
set: function (val) {
$containers.each(function () {
$(this).scrollLeft(val);
});
}
});
$containers.on({
mouseover: function () {
if (scroll_type == 'smartscroll') {
$(this).smartscroll(function () {
options.x_sync && (scrolling.top = $(this).scrollTop());
options.y_sync && (scrolling.left = $(this).scrollLeft());
}, options.smartscroll_delay);
return;
}
$(this).bind('scroll', function () {
options.x_sync && (scrolling.top = $(this).scrollTop());
options.y_sync && (scrolling.left = $(this).scrollLeft());
});
},
mouseout: function () {
$(this).unbind('scroll');
}
});
return this;
}
exports.scrollsync = scrollsync;
Object.defineProperty(exports, '__esModule', {value: true});
})));

31
WebHost/static/static.css Normal file
View File

@ -0,0 +1,31 @@
table.dataTable.table-sm > thead > tr > th :not(.sorting_disabled) {
padding: 1px;
}
.dataTable > thead > tr > th[class*="sort"]:before,
.dataTable > thead > tr > th[class*="sort"]:after {
content: "" !important;
}
th {
padding: 1px !important;
}
table {
width: 100% !important;
}
img.alttp-sprite {
height: 32px;
width: 32px;
object-fit: contain;
}
/* this is specific to the tracker right now */
@media all and (max-width: 1750px) {
img.alttp-sprite {
height: 16px;
width: 16px;
object-fit: contain;
}
}

View File

@ -0,0 +1,43 @@
{% extends 'layout.html' %}
{% block head %}
<title>Multiworld {{ room.id }}</title>
{% endblock %}
{% block body %}
{% if room.owner == session["_id"] %}
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id }}</a><br>
{% endif %}
{% if room.tracker %}
This room has a <a href="{{ url_for("get_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.<br>
{% endif %}
This room will be closed after {{ room.timeout }} hours of inactivity. Should you wish to continue later,
you can simply refresh this page and the server will be started again.<br>
{% if room.owner == session["_id"] %}
<form method=post>
<div class="form-group">
<label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd"
placeholder="Server Command. /help to list them, list gets appended to log.">
</div>
</form>
{% endif %}
Log:
<div id="logger"></div>
<script>
var xmlhttp = new XMLHttpRequest();
var url = '{{ url_for('display_log', room = room.id) }}';
xmlhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
document.getElementById("logger").innerText = this.responseText;
}
};
function request_new() {
xmlhttp.open("GET", url, true);
xmlhttp.send();
}
window.setTimeout(request_new, 1000);
window.setInterval(request_new, 3000);
</script>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends 'layout.html' %}
{% block head %}
<title>Berserker's Multiworld</title>
{% endblock %}
{% block body %}
<nav class="navbar navbar-dark bg-dark navbar-expand-sm">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for("uploads") }}">Start a Group</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for("uploads") }}">Upload a Multiworld</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for("uploads") }}">Your Content</a>
</li>
</ul>
</nav>
<br>
<div class="container container-fluid">
<div class="jumbotron jumbotron-fluid">
<div class="container">
<div class="col-md-5 p-lg-2 mx-auto my-2">
<h1 class="text-center display-4 font-weight-normal">Berserker's Multiworld</h1>
<p class="text-center lead font-weight-normal"><a
href="https://github.com/Berserker66/MultiWorld-Utilities">Source Code</a>
- <a href="https://github.com/Berserker66/MultiWorld-Utilities/wiki">Wiki</a>
-
<a href="https://github.com/Berserker66/MultiWorld-Utilities/graphs/contributors">Contributors</a>
</p>
</div>
<div>
<p class="lead">This is a randomizer for The Legend of Zelda: A Link to the Past.</p>
<p class="lead">It is a multiworld, meaning items get shuffled across multiple players' worlds
which get exchanged on pickup through the internet.</p>
<p class="lead">This website allows hosting such a Multiworld and comes with an item and location
tracker.</p>
<p class="lead">Currently you still require a locally installed client to play, that handles
connecting to the server and patching a vanilla game to the randomized one. Get started on the
<a href="https://github.com/Berserker66/MultiWorld-Utilities/wiki">Wiki</a>.</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
{% block head %}<title>Berserker's Multiworld</title>
{% endblock %}
</head>
<body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class=".container-fluid">
{% for message in messages %}
<div class="alert alert-danger" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block body %}{% endblock %}
<br> {# spacing for notice #}
<footer class="page-footer" style="position: fixed; left: 0; bottom: 0; width: 100%; text-align: center">
<div class="container">
<span class="text-muted">This site uses a cookie to track your session in order to give you ownership over uploaded files and created instances.</span>
{# <button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('cookiefooter').remove()">X</button> #}
</div>
</footer>
</body>

View File

@ -0,0 +1,10 @@
{% macro list_rooms(rooms) -%}
Rooms:
<ul class="list-group">
{% for room in rooms %}
<li class="list-group-item"><a href="{{ url_for("host_room", room=room.id) }}">Room #{{ room.id }}</a></li>
{% endfor %}
{{ caller() }}
</ul>
{%- endmacro %}

View File

@ -0,0 +1,172 @@
{% extends 'layout.html' %}
{% block head %}
<title>Multiworld Tracker for Room {{ room.id }}</title>
<link rel="stylesheet" type="text/css"
href="https://cdn.datatables.net/v/bs4/jq-3.3.1/dt-1.10.21/fh-3.1.7/datatables.min.css"/>
<link rel="stylesheet" type="text/css" href="{{ static_autoversion("static.css") }}"/>
<script type="text/javascript"
src="https://cdn.datatables.net/v/bs4/jq-3.3.1/dt-1.10.21/fh-3.1.7/datatables.min.js"></script>
<script src="{{ static_autoversion("jquery.scrollsync.js") }}"></script>
<script>
$(document).ready(function () {
var tables = $(".table").DataTable({
"paging": false,
"ordering": true,
"info": false,
"dom": "t",
"scrollY": "39vh",
"scrollCollapse": true,
});
$('#searchbox').keyup(function () {
tables.search($(this).val()).draw();
});
function update() {
var target = $("<div></div>");
target.load("/tracker/{{ room.tracker }}", function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
var new_trs = $(new_table).find("tbody>tr");
var old_table = tables.eq(i);
var topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
var leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear();
old_table.rows.add(new_trs).draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
});
} else {
console.log("Failed to connect to Server, in order to update Table Data.");
console.log(response);
}
})
}
setInterval(update, 30000);
$(".dataTables_scrollBody").scrollsync({
y_sync: true,
x_sync: true
})
$(window).resize(function () {
tables.draw();
});
setTimeout(
tables.draw, {# this fixes the top header misalignment, for some reason #}
500
);
})
</script>
{% endblock %}
{% block body %}
<input id="searchbox" class="form-control" type="text" placeholder="Search">
<div>
{% for team, players in inventory.items() %}
<table class="table table-striped table-bordered table-hover table-sm">
<thead class="thead-dark">
<tr>
<th>#</th>
<th>Name</th>
{% for name in tracking_names %}
{% if name in icons %}
<th style="text-align: center"><img class="alttp-sprite"
src="{{ icons[name] }}"
alt="{{ name|e }}"></th>
{% else %}
<th>{{ name|e }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
{% for player, items in players.items() %}
<tr>
<td class="table-info">{{ loop.index }}</td>
{% if (team, loop.index) in video %}
<td class="table-info">
<a target="_blank" href="https://www.twitch.tv/{{ video[(team, loop.index)][1] }}">
{{ player_names[(team, loop.index)] }}
▶️</a></td>
{% else %}
<td class="table-info">{{ player_names[(team, loop.index)] }}</td>{% endif %}
{% for id in tracking_ids %}
{% if items[id] %}
<td style="text-align: center" class="table-success">
{% if id in multi_items %}{{ items[id] }}{% else %}✔️{% endif %}</td>
{% else %}
<td></td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% for team, players in checks_done.items() %}
<table class="table table-striped table-bordered table-hover table-sm">
<thead class="thead-dark">
<tr>
<th rowspan="2">#</th>
<th rowspan="2">Name</th>
{% for area in ordered_areas %}
{% set colspan = (3 if area in key_locations else 1) %}
{% if area in icons %}
<th colspan="{{ colspan }}" style="text-align: center"><img class="alttp-sprite"
src="{{ icons[area] }}"
alt="{{ area }}"></th>
{% else %}
<th colspan="{{ colspan }}">{{ area }}</th>
{% endif %}
{% endfor %}
<th rowspan="2">Last Activity</th>
</tr>
<tr>
{% for area in ordered_areas %}
<th style="text-align: center"><img class="alttp-sprite" src="{{ icons["Chest"] }}" alt="Checks">
</th>
{% if area in key_locations %}
<th style="text-align: center"><img class="alttp-sprite"
src="{{ icons["Small Key"] }}" alt="Small Key"></th>
<th style="text-align: center"><img class="alttp-sprite"
src="{{ icons["Big Key"] }}" alt="Big Key"></th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
{% for player, checks in players.items() %}
<tr>
<td class="table-info">{{ loop.index }}</td>
<td class="table-info">{{ player_names[(team, loop.index)]|e }}</td>
{% for area in ordered_areas %}
{% set checks_done = checks[area] %}
{% set checks_total = checks_in_area[area] %}
{% if checks_done == checks_total %}
<td style="text-align: center" class="table-success">
{{ checks_done }}/{{ checks_total }}</td>
{% else %}
<td style="text-align: center">{{ checks_done }}/{{ checks_total }}</td>
{% endif %}
{% if area in key_locations %}
<td>{{ inventory[team][player][small_key_ids[area]] }}</td>
<td>{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}</td>
{% endif %}
{% endfor %}
{% if activity_timers[(team, player)] %}
<td class="table-info">{{ activity_timers[(team, player)] | render_timedelta }}</td>
{% else %}
<td class="table-warning">None</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends 'layout.html' %}
{% block head %}
<title>Upload Multidata</title>
{% endblock %}
{% block body %}
<h1>Upload Multidata or Multiworld Zip</h1>
<form method=post enctype=multipart/form-data>
<input type=file name=file>
<input type=submit value=Upload>
</form>
<br>
{% if rooms %}
<h1>Your Rooms:</h1>
<ul class="list-group">
{% for room in rooms %}
<li class="list-group-item"><a href="{{ url_for("host_room", room=room.id) }}">Room #{{ room.id }}</a>
based on <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id }}</a></li>
{% endfor %}
</ul>
{% else %}
<h3>No rooms owned by you were found. Upload a Multiworld to get started.</h3>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends 'layout.html' %}
{% import "macros.html" as macros %}
{% block head %}
<title>Multiworld Seed {{ seed.id }}</title>
{% endblock %}
{% block body %}
Seed #{{ seed.id }}<br>
Created: {{ seed.creation_time }} UTC <br>
Players:
<ul class="list-group">
{% for team in seed.multidata["names"] %}
<li class="list-group-item">Team #{{ loop.index }} - {{ team | length }}
<ul class="list-group">
{% for player in team %}
<li class="list-group-item">{{ player }}</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{% call macros.list_rooms(rooms) %}
<li class="list-group-item list-group-item-action"><a href="{{ url_for("new_room", seed=seed.id) }}">new
room</a></li>
{% endcall %}
{% endblock %}

266
WebHost/tracker.py Normal file
View File

@ -0,0 +1,266 @@
import collections
from flask import render_template
from werkzeug.exceptions import abort
import datetime
import logging
from uuid import UUID
import Items
from WebHost import app, cache, Room
def get_id(item_name):
return Items.item_table[item_name][3]
icons = {
"Progressive Sword":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725",
"Pegasus Boots":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9",
"Progressive Glove":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/53/ALttP_Titan's_Mitt_Sprite.png?version=6ac54c3016a23b94413784881fcd3c75",
"Flippers":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/88/ALttP_Zora's_Flippers_Sprite.png?version=b9d7521bb3a5a4d986879f70a70bc3da",
"Moon Pearl":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e",
"Progressive Bow":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed",
"Blue Boomerang":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e",
"Red Boomerang":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400",
"Hookshot":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b",
"Mushroom":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59",
"Magic Powder":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Powder_Sprite.png?version=deaf51f8636823558bd6e6307435fb01",
"Fire Rod":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0",
"Ice Rod":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc",
"Bombos":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26",
"Ether":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5",
"Quake":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879",
"Lamp":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce",
"Hammer":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500",
"Shovel":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05",
"Flute":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390",
"Bug Catching Net":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6",
"Book of Mudora":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744",
"Bottle":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b",
"Cane of Somaria":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943",
"Cane of Byrna":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54",
"Cape":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832",
"Magic Mirror":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc",
"Triforce":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48",
"Small Key":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e",
"Big Key":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d",
"Chest":
r"https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda"
}
links = {"Bow": "Progressive Bow",
"Silver Arrows": "Progressive Bow",
"Silver Bow": "Progressive Bow",
"Progressive Bow (Alt)": "Progressive Bow",
"Bottle (Red Potion)": "Bottle",
"Bottle (Green Potion)": "Bottle",
"Bottle (Blue Potion)": "Bottle",
"Bottle (Fairy)": "Bottle",
"Bottle (Bee)": "Bottle",
"Bottle (Good Bee)": "Bottle",
"Fighter Sword": "Progressive Sword",
"Master Sword": "Progressive Sword",
"Tempered Sword": "Progressive Sword",
"Golden Sword": "Progressive Sword",
"Power Glove": "Progressive Glove",
"Titans Mitts": "Progressive Glove"
}
levels = {"Fighter Sword": 1,
"Master Sword": 2,
"Tempered Sword": 3,
"Golden Sword": 4,
"Power Glove": 1,
"Titans Mitts": 2,
"Silver Bow": 2}
multi_items = {get_id(name) for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove")}
links = {get_id(key): get_id(value) for key, value in links.items()}
levels = {get_id(key): value for key, value in levels.items()}
tracking_names = ["Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer",
"Hookshot", "Magic Mirror", "Flute",
"Pegasus Boots", "Progressive Glove", "Flippers", "Moon Pearl", "Blue Boomerang",
"Red Boomerang", "Bug Catching Net", "Cape", "Shovel", "Lamp",
"Mushroom", "Magic Powder",
"Cane of Somaria", "Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake",
"Bottle", "Triforce"] # TODO make sure this list has what we need and sort it better
default_locations = {
'Light World': {1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175,
1572880, 60049, 60178, 1572883, 60052, 60181, 1572885, 60055, 60184, 191256, 60058, 60187, 1572884,
1572886, 1572887, 1572906, 60202, 60205, 59824, 166320, 1010170, 60208, 60211, 60214, 60217, 59836,
60220, 60223, 59839, 1573184, 60226, 975299, 1573188, 1573189, 188229, 60229, 60232, 1573193,
1573194, 60235, 1573187, 59845, 59854, 211407, 60238, 59857, 1573185, 1573186, 1572882, 212328,
59881, 59761, 59890, 59770, 193020, 212605},
'Dark World': {59776, 59779, 975237, 1572870, 60043, 1572881, 60190, 60193, 60196, 60199, 60840, 1573190, 209095,
1573192, 1573191, 60241, 60244, 60247, 60250, 59884, 59887, 60019, 60022, 60028, 60031},
'Desert Palace': {1573216, 59842, 59851, 59791, 1573201, 59830},
'Eastern Palace': {1573200, 59827, 59893, 59767, 59833, 59773},
'Hyrule Castle': {60256, 60259, 60169, 60172, 59758, 59764, 60025, 60253},
'Agahnims Tower': {60082, 60085},
'Tower of Hera': {1573218, 59878, 59821, 1573202, 59896, 59899},
'Swamp Palace': {60064, 60067, 60070, 59782, 59785, 60073, 60076, 60079, 1573204, 60061},
'Thieves Town': {59905, 59908, 59911, 59914, 59917, 59920, 59923, 1573206},
'Skull Woods': {59809, 59902, 59848, 59794, 1573205, 59800, 59803, 59806},
'Ice Palace': {59872, 59875, 59812, 59818, 59860, 59797, 1573207, 59869},
'Misery Mire': {60001, 60004, 60007, 60010, 60013, 1573208, 59866, 59998},
'Turtle Rock': {59938, 59941, 59944, 1573209, 59947, 59950, 59953, 59956, 59926, 59929, 59932, 59935},
'Palace of Darkness': {59968, 59971, 59974, 59977, 59980, 59983, 59986, 1573203, 59989, 59959, 59992, 59962, 59995,
59965},
'Ganons Tower': {60160, 60163, 60166, 60088, 60091, 60094, 60097, 60100, 60103, 60106, 60109, 60112, 60115, 60118,
60121, 60124, 60127, 1573217, 60130, 60133, 60136, 60139, 60142, 60145, 60148, 60151, 60157},
'Total': set()}
key_locations = {"Desert Palace", "Eastern Palace", "Hyrule Castle", "Agahnims Tower", "Tower of Hera", "Swamp Palace",
"Thieves Town", "Skull Woods", "Ice Palace", "Misery Mire", "Turtle Rock", "Palace of Darkness",
"Ganons Tower"}
location_to_area = {}
for area, locations in default_locations.items():
for location in locations:
location_to_area[location] = area
checks_in_area = {area: len(checks) for area, checks in default_locations.items()}
checks_in_area["Total"] = 216
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
tracking_ids = []
for item in tracking_names:
tracking_ids.append(get_id(item))
small_key_ids = {}
big_key_ids = {}
for item_name, data in Items.item_table.items():
if "Key" in item_name:
area = item_name.split("(")[1][:-1]
if "Small" in item_name:
small_key_ids[area] = data[3]
else:
big_key_ids[area] = data[3]
from MultiServer import get_item_name_from_id
def attribute_item(inventory, team, recipient, item):
target_item = links.get(item, item)
if item in levels: # non-progressive
inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item])
else:
inventory[team][recipient][target_item] += 1
@app.template_filter()
def render_timedelta(delta: datetime.timedelta):
hours, minutes = divmod(delta.total_seconds() / 60, 60)
hours = str(int(hours))
minutes = str(int(minutes)).zfill(2)
return f"{hours}:{minutes}"
_multidata_cache = {}
def get_static_room_data(room: Room):
result = _multidata_cache.get(room.seed.id, None)
if result:
return result
multidata = room.seed.multidata
# in > 100 players this can take a bit of time and is the main reason for the cache
locations = {tuple(k): tuple(v) for k, v in multidata['locations']}
names = multidata["names"]
_multidata_cache[room.seed.id] = locations, names
return locations, names
@app.route('/tracker/<uuid:tracker>')
@cache.memoize(timeout=30) # update every 30 seconds
def get_tracker(tracker: UUID):
room = Room.get(tracker=tracker)
if not room:
abort(404)
locations, names = get_static_room_data(room)
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1)}
for teamnumber, team in enumerate(names)}
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
for playernumber in range(1, len(team) + 1)}
for teamnumber, team in enumerate(names)}
precollected_items = room.seed.multidata.get("precollected_items", None)
for (team, player), locations_checked in room.multisave.get("location_checks", {}):
if precollected_items:
precollected = precollected_items[player - 1]
for item_id in precollected:
attribute_item(inventory, team, player, item_id)
for location in locations_checked:
item, recipient = locations[location, player]
attribute_item(inventory, team, recipient, item)
checks_done[team][player][location_to_area[location]] += 1
checks_done[team][player]["Total"] += 1
for (team, player), game_state in room.multisave.get("client_game_state", []):
if game_state:
inventory[team][player][106] = 1 # Triforce
activity_timers = {}
now = datetime.datetime.utcnow()
for (team, player), timestamp in room.multisave.get("client_activity_timers", []):
activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
player_names = {}
for team, names in enumerate(names):
for player, name in enumerate(names, 1):
player_names[(team, player)] = name
for (team, player), alias in room.multisave.get("name_aliases", []):
player_names[(team, player)] = alias
video = {}
for (team, player), data in room.multisave.get("video", []):
video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=icons,
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
checks_in_area=checks_in_area, activity_timers=activity_timers,
key_locations=key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids,
video=video)

73
WebHost/upload.py Normal file
View File

@ -0,0 +1,73 @@
import json
import zlib
import zipfile
import logging
from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, select
from WebHost import app, allowed_file, Seed, Room, Patch
accepted_zip_contents = {"patches": ".bmbp",
"spoiler": ".txt",
"multidata": "multidata"}
banned_zip_contents = (".sfc",)
@app.route('/uploads', methods=['GET', 'POST'])
def uploads():
if request.method == 'POST':
# check if the post request has the file part
if 'file' not in request.files:
flash('No file part')
else:
file = request.files['file']
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
flash('No selected file')
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
patches = set()
spoiler = ""
multidata = None
with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist()
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
elif file.filename.endswith(".bmbp"):
player = int(file.filename.split("P")[-1].split(".")[0].split("_")[0])
patches.add(Patch(data=zfile.open(file, "r").read(), player=player))
elif file.filename.endswith(".txt"):
spoiler = zfile.open(file, "rt").read().decode("utf-8-sig")
elif file.filename.endswith("multidata"):
try:
multidata = json.loads(zlib.decompress(zfile.open(file).read()).decode("utf-8-sig"))
except:
flash("Could not load multidata. File may be corrupted or incompatible.")
if multidata:
commit() # commit patches
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=session["_id"])
commit() # create seed
for patch in patches:
patch.seed = seed
return redirect(url_for("view_seed", seed=seed.id))
else:
flash("No multidata was found in the zip file, which is required.")
else:
try:
multidata = json.loads(zlib.decompress(file.read()).decode("utf-8-sig"))
except:
flash("Could not load multidata. File may be corrupted or incompatible.")
else:
seed = Seed(multidata=multidata, owner=session["_id"])
commit() # place into DB and generate ids
return redirect(url_for("view_seed", seed=seed.id))
else:
flash("Not recognized file format. Awaiting a .multidata file.")
rooms = select(room for room in Room if room.owner == session["_id"])
return render_template("uploads.html", rooms=rooms)

View File

@ -57,6 +57,7 @@ class WebUiClient(Node):
'serverAddress': server_address,
'server': 1 if ctx.server is not None and not ctx.server.socket.closed else 0,
}))
def send_device_list(self, devices):
self.broadcast_all(self.build_message('availableDevices', {
'devices': devices,
@ -105,11 +106,20 @@ class WebUiClient(Node):
'entranceLocation': entrance_location,
}))
def send_game_state(self, ctx: Context):
self.broadcast_all(self.build_message('gameState', {
'hintCost': 0,
'checkPoints': 0,
'playerPoints': 0,
def send_game_info(self, ctx: Context):
self.broadcast_all(self.build_message('gameInfo', {
'serverVersion': Utils.__version__,
'hintCost': ctx.hint_cost,
'checkPoints': ctx.check_points,
'forfeitMode': ctx.forfeit_mode,
'remainingMode': ctx.remaining_mode,
}))
def send_location_check(self, ctx: Context, last_check: str):
self.broadcast_all(self.build_message('locationCheck', {
'totalChecks': len(ctx.locations_checked),
'hintPoints': ctx.hint_points,
'lastCheck': last_check,
}))
@ -117,14 +127,27 @@ class WaitingForUiException(Exception):
pass
webthread = None
web_thread = None
PORT = 5050
Handler = partial(http.server.SimpleHTTPRequestHandler,
class RequestHandler(http.server.SimpleHTTPRequestHandler):
def log_request(self, code='-', size='-'):
pass
def log_message(self, format, *args):
pass
def log_date_time_string(self):
pass
Handler = partial(RequestHandler,
directory=Utils.local_path(os.path.join("data", "web", "public")))
def start_server(socket_port: int, on_start=lambda: None):
global webthread
global web_thread
try:
server = socketserver.TCPServer(("", PORT), Handler)
except OSError:
@ -144,4 +167,4 @@ def start_server(socket_port: int, on_start=lambda: None):
else:
print("serving at port", PORT)
on_start()
webthread = threading.Thread(target=server.serve_forever).start()
web_thread = threading.Thread(target=server.serve_forever).start()

1
_config.yml Normal file
View File

@ -0,0 +1 @@
theme: jekyll-theme-slate

File diff suppressed because one or more lines are too long

BIN
data/basepatch.bmbp Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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