Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4762715c | ||
|
|
5c21538553 | ||
|
|
85481d7321 | ||
|
|
153fa16bcf | ||
|
|
71642f494f | ||
|
|
8ba408385b | ||
|
|
d2c420a1fd | ||
|
|
855ff480a5 | ||
|
|
eb586aab55 | ||
|
|
b097f30f4d | ||
|
|
78f565c706 | ||
|
|
af30d8b7cd | ||
|
|
e79a918c03 | ||
|
|
83dc92c6a5 | ||
|
|
64c80c32f0 | ||
|
|
12eba33dbf | ||
|
|
0eee1f2d01 | ||
|
|
39a5921522 | ||
|
|
c99a689504 | ||
|
|
997a3e18a3 | ||
|
|
15747f48e9 | ||
|
|
d62b46f6cd | ||
|
|
d406e4c3d9 | ||
|
|
fc7d37def4 | ||
|
|
f6b3dfe5ba | ||
|
|
fe9094dedc | ||
|
|
34ff5d9662 | ||
|
|
df9bad75ea | ||
|
|
21af3bf563 | ||
|
|
b2f5f095fc | ||
|
|
8a1ac566c8 | ||
|
|
75bf595f86 | ||
|
|
312f13e254 | ||
|
|
2fc4006dfa | ||
|
|
47f7ec16c0 | ||
|
|
e105616b96 | ||
|
|
a503134533 | ||
|
|
bceb8540a1 | ||
|
|
10c6a70696 | ||
|
|
b809d76b79 | ||
|
|
bfad85223b | ||
|
|
b53c5593a8 | ||
|
|
3bfb98a1c6 | ||
|
|
573fde4bbc | ||
|
|
5c8a076790 | ||
|
|
20b173453d | ||
|
|
3460c9f714 | ||
|
|
69a5bf0159 | ||
|
|
01f0f309d1 | ||
|
|
3d67e1dbdb | ||
|
|
719e21ac8c | ||
|
|
14ed3b82a0 | ||
|
|
9e5e43fcd5 | ||
|
|
7493b7f35e | ||
|
|
54b3a57f46 | ||
|
|
4f998a6880 | ||
|
|
62a6cdc9f7 | ||
|
|
bc83dfa9e2 | ||
|
|
5adbab1d2b | ||
|
|
b0c1a7acce | ||
|
|
14cadbf80d | ||
|
|
741ab3e45c | ||
|
|
f456dba993 | ||
|
|
50a21fbd74 | ||
|
|
768ae584d3 | ||
|
|
ae32315bf7 | ||
|
|
9821e05386 | ||
|
|
38bc3d47ad | ||
|
|
4feb3bf411 | ||
|
|
b53d6c370b | ||
|
|
31c550d410 |
1
.gitignore
vendored
@@ -38,6 +38,7 @@ success.txt
|
|||||||
output/
|
output/
|
||||||
Output Logs/
|
Output Logs/
|
||||||
/factorio/
|
/factorio/
|
||||||
|
/WebHostLib/static/generated
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
242
BaseClasses.py
@@ -6,7 +6,7 @@ import logging
|
|||||||
import json
|
import json
|
||||||
import functools
|
import functools
|
||||||
from collections import OrderedDict, Counter, deque
|
from collections import OrderedDict, Counter, deque
|
||||||
from typing import *
|
from typing import List, Dict, Optional, Set, Iterable, Union, Any
|
||||||
import secrets
|
import secrets
|
||||||
import random
|
import random
|
||||||
|
|
||||||
@@ -14,16 +14,17 @@ import random
|
|||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_names: Dict[int, List[str]]
|
player_names: Dict[int, List[str]]
|
||||||
_region_cache: dict
|
_region_cache: Dict[int, Dict[str, Region]]
|
||||||
difficulty_requirements: dict
|
difficulty_requirements: dict
|
||||||
required_medallions: dict
|
required_medallions: dict
|
||||||
dark_room_logic: Dict[int, str]
|
dark_room_logic: Dict[int, str]
|
||||||
restrict_dungeon_item_on_boss: Dict[int, bool]
|
restrict_dungeon_item_on_boss: Dict[int, bool]
|
||||||
plando_texts: List[Dict[str, str]]
|
plando_texts: List[Dict[str, str]]
|
||||||
plando_items: List[PlandoItem]
|
plando_items: List
|
||||||
plando_connections: List[PlandoConnection]
|
plando_connections: List
|
||||||
er_seeds: Dict[int, str]
|
er_seeds: Dict[int, str]
|
||||||
worlds: Dict[int, "AutoWorld.World"]
|
worlds: Dict[int, Any]
|
||||||
|
is_race: bool = False
|
||||||
|
|
||||||
class AttributeProxy():
|
class AttributeProxy():
|
||||||
def __init__(self, rule):
|
def __init__(self, rule):
|
||||||
@@ -68,7 +69,6 @@ class MultiWorld():
|
|||||||
self.fix_palaceofdarkness_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
self.fix_palaceofdarkness_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||||
self.fix_trock_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
self.fix_trock_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
|
||||||
self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
|
self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
|
||||||
self.remote_items = self.AttributeProxy(lambda player: self.game[player] != "A Link to the Past")
|
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr, val):
|
def set_player_attr(attr, val):
|
||||||
@@ -140,7 +140,6 @@ class MultiWorld():
|
|||||||
self.custom_data = {}
|
self.custom_data = {}
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
|
|
||||||
|
|
||||||
def set_options(self, args):
|
def set_options(self, args):
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
@@ -152,27 +151,15 @@ class MultiWorld():
|
|||||||
|
|
||||||
def secure(self):
|
def secure(self):
|
||||||
self.random = secrets.SystemRandom()
|
self.random = secrets.SystemRandom()
|
||||||
|
self.is_race = True
|
||||||
|
|
||||||
@functools.cached_property
|
@functools.cached_property
|
||||||
def player_ids(self):
|
def player_ids(self):
|
||||||
return tuple(range(1, self.players + 1))
|
return tuple(range(1, self.players + 1))
|
||||||
|
|
||||||
# Todo: make these automatic, or something like get_players_for_game(game_name)
|
@functools.lru_cache()
|
||||||
@functools.cached_property
|
def get_game_players(self, game_name: str):
|
||||||
def alttp_player_ids(self):
|
return tuple(player for player in self.player_ids if self.game[player] == game_name)
|
||||||
return tuple(player for player in range(1, self.players + 1) if self.game[player] == "A Link to the Past")
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def hk_player_ids(self):
|
|
||||||
return tuple(player for player in range(1, self.players + 1) if self.game[player] == "Hollow Knight")
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def factorio_player_ids(self):
|
|
||||||
return tuple(player for player in range(1, self.players + 1) if self.game[player] == "Factorio")
|
|
||||||
|
|
||||||
@functools.cached_property
|
|
||||||
def minecraft_player_ids(self):
|
|
||||||
return tuple(player for player in range(1, self.players + 1) if self.game[player] == "Minecraft")
|
|
||||||
|
|
||||||
def get_name_string_for_object(self, obj) -> str:
|
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)})'
|
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_names(obj.player)})'
|
||||||
@@ -241,7 +228,7 @@ class MultiWorld():
|
|||||||
self.worlds[item.player].collect(ret, item)
|
self.worlds[item.player].collect(ret, item)
|
||||||
|
|
||||||
if keys:
|
if keys:
|
||||||
for p in self.alttp_player_ids:
|
for p in self.get_game_players("A Link to the Past"):
|
||||||
world = self.worlds[p]
|
world = self.worlds[p]
|
||||||
from worlds.alttp.Items import ItemFactory
|
from worlds.alttp.Items import ItemFactory
|
||||||
for item in ItemFactory(
|
for item in ItemFactory(
|
||||||
@@ -272,6 +259,8 @@ class MultiWorld():
|
|||||||
return next(location for location in self.get_locations() if
|
return next(location for location in self.get_locations() if
|
||||||
location.item and location.item.name == item and location.item.player == player)
|
location.item and location.item.name == item and location.item.player == player)
|
||||||
|
|
||||||
|
def create_item(self, item_name: str, player: int) -> Item:
|
||||||
|
return self.worlds[player].create_item(item_name)
|
||||||
|
|
||||||
def push_precollected(self, item: Item):
|
def push_precollected(self, item: Item):
|
||||||
item.world = self
|
item.world = self
|
||||||
@@ -563,31 +552,25 @@ class CollectionState(object):
|
|||||||
def has(self, item, player: int, count: int = 1):
|
def has(self, item, player: int, count: int = 1):
|
||||||
return self.prog_items[item, player] >= count
|
return self.prog_items[item, player] >= count
|
||||||
|
|
||||||
def has_essence(self, player: int, count: int):
|
def has_all(self, items: Set[str], player:int):
|
||||||
return self.prog_items["Dream_Nail", player]
|
return all(self.prog_items[item, player] for item in items)
|
||||||
# return self.prog_items["Essence", player] >= count
|
|
||||||
|
|
||||||
def has_grubs(self, player: int, count: int):
|
def has_any(self, items: Set[str], player:int):
|
||||||
from worlds.hk import Items as HKItems
|
return any(self.prog_items[item, player] for item in items)
|
||||||
found = 0
|
|
||||||
|
|
||||||
for item_name in HKItems.lookup_type_to_names["Grub"]:
|
def has_group(self, item_name_group: str, player: int, count: int = 1):
|
||||||
|
found: int = 0
|
||||||
|
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||||
found += self.prog_items[item_name, player]
|
found += self.prog_items[item_name, player]
|
||||||
if found >= count:
|
if found >= count:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def has_flames(self, player: int, count: int):
|
def count_group(self, item_name_group: str, player: int):
|
||||||
from worlds.hk import Items as HKItems
|
found: int = 0
|
||||||
found = 0
|
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||||
|
|
||||||
for item_name in HKItems.lookup_type_to_names["Flame"]:
|
|
||||||
found += self.prog_items[item_name, player]
|
found += self.prog_items[item_name, player]
|
||||||
if found >= count:
|
return found
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_key(self, item, player, count: int = 1):
|
def has_key(self, item, player, count: int = 1):
|
||||||
if self.world.logic[player] == 'nologic':
|
if self.world.logic[player] == 'nologic':
|
||||||
@@ -621,25 +604,9 @@ class CollectionState(object):
|
|||||||
def can_lift_rocks(self, player: int):
|
def can_lift_rocks(self, player: int):
|
||||||
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
|
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
|
||||||
|
|
||||||
def has_bottle(self, player: int) -> bool:
|
|
||||||
return self.has_bottles(1, player)
|
|
||||||
|
|
||||||
def bottle_count(self, player: int) -> int:
|
def bottle_count(self, player: int) -> int:
|
||||||
found: int = 0
|
return min(self.world.difficulty_requirements[player].progressive_bottle_limit,
|
||||||
for bottlename in item_name_groups["Bottles"]:
|
self.count_group("Bottles", player))
|
||||||
found += self.prog_items[bottlename, player]
|
|
||||||
return min(self.world.difficulty_requirements[player].progressive_bottle_limit, found)
|
|
||||||
|
|
||||||
def has_bottles(self, bottles: int, player: int) -> bool:
|
|
||||||
"""Version of bottle_count that allows fast abort"""
|
|
||||||
if bottles > self.world.difficulty_requirements[player].progressive_bottle_limit:
|
|
||||||
return False
|
|
||||||
found: int = 0
|
|
||||||
for bottlename in item_name_groups["Bottles"]:
|
|
||||||
found += self.prog_items[bottlename, player]
|
|
||||||
if found >= bottles:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def has_hearts(self, player: int, count: int) -> int:
|
def has_hearts(self, player: int, count: int) -> int:
|
||||||
# Warning: This only considers items that are marked as advancement items
|
# Warning: This only considers items that are marked as advancement items
|
||||||
@@ -688,7 +655,7 @@ class CollectionState(object):
|
|||||||
def can_get_good_bee(self, player: int) -> bool:
|
def can_get_good_bee(self, player: int) -> bool:
|
||||||
cave = self.world.get_region('Good Bee Cave', player)
|
cave = self.world.get_region('Good Bee Cave', player)
|
||||||
return (
|
return (
|
||||||
self.has_bottle(player) and
|
self.has_group("Bottles", player) and
|
||||||
self.has('Bug Catching Net', player) and
|
self.has('Bug Catching Net', player) and
|
||||||
(self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and
|
(self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and
|
||||||
cave.can_reach(self) and
|
cave.can_reach(self) and
|
||||||
@@ -774,92 +741,6 @@ class CollectionState(object):
|
|||||||
def can_bomb_clip(self, region: Region, player: int) -> bool:
|
def can_bomb_clip(self, region: Region, player: int) -> bool:
|
||||||
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
|
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
|
||||||
|
|
||||||
# Minecraft logic functions
|
|
||||||
def has_iron_ingots(self, player: int):
|
|
||||||
return self.has('Progressive Tools', player) and self.has('Ingot Crafting', player)
|
|
||||||
|
|
||||||
def has_gold_ingots(self, player: int):
|
|
||||||
return self.has('Ingot Crafting', player) and (self.has('Progressive Tools', player, 2) or self.can_reach('The Nether', 'Region', player))
|
|
||||||
|
|
||||||
def has_diamond_pickaxe(self, player: int):
|
|
||||||
return self.has('Progressive Tools', player, 3) and self.has_iron_ingots(player)
|
|
||||||
|
|
||||||
def craft_crossbow(self, player: int):
|
|
||||||
return self.has('Archery', player) and self.has_iron_ingots(player)
|
|
||||||
|
|
||||||
def has_bottle_mc(self, player: int):
|
|
||||||
return self.has('Bottles', player) and self.has('Ingot Crafting', player)
|
|
||||||
|
|
||||||
def can_enchant(self, player: int):
|
|
||||||
return self.has('Enchanting', player) and self.has_diamond_pickaxe(player) # mine obsidian and lapis
|
|
||||||
|
|
||||||
def can_use_anvil(self, player: int):
|
|
||||||
return self.has('Enchanting', player) and self.has('Resource Blocks', player) and self.has_iron_ingots(player)
|
|
||||||
|
|
||||||
def fortress_loot(self, player: int): # saddles, blaze rods, wither skulls
|
|
||||||
return self.can_reach('Nether Fortress', 'Region', player) and self.basic_combat(player)
|
|
||||||
|
|
||||||
def can_brew_potions(self, player: int):
|
|
||||||
return self.fortress_loot(player) and self.has('Brewing', player) and self.has_bottle_mc(player)
|
|
||||||
|
|
||||||
def can_piglin_trade(self, player: int):
|
|
||||||
return self.has_gold_ingots(player) and (self.can_reach('The Nether', 'Region', player) or self.can_reach('Bastion Remnant', 'Region', player))
|
|
||||||
|
|
||||||
def enter_stronghold(self, player: int):
|
|
||||||
return self.fortress_loot(player) and self.has('Brewing', player) and self.has('3 Ender Pearls', player)
|
|
||||||
|
|
||||||
# Difficulty-dependent functions
|
|
||||||
def combat_difficulty(self, player: int):
|
|
||||||
return self.world.combat_difficulty[player].get_option_name()
|
|
||||||
|
|
||||||
def can_adventure(self, player: int):
|
|
||||||
if self.combat_difficulty(player) == 'easy':
|
|
||||||
return self.has('Progressive Weapons', player, 2) and self.has_iron_ingots(player)
|
|
||||||
elif self.combat_difficulty(player) == 'hard':
|
|
||||||
return True
|
|
||||||
return self.has('Progressive Weapons', player) and (self.has('Ingot Crafting', player) or self.has('Campfire', player))
|
|
||||||
|
|
||||||
def basic_combat(self, player: int):
|
|
||||||
if self.combat_difficulty(player) == 'easy':
|
|
||||||
return self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and \
|
|
||||||
self.has('Shield', player) and self.has_iron_ingots(player)
|
|
||||||
elif self.combat_difficulty(player) == 'hard':
|
|
||||||
return True
|
|
||||||
return self.has('Progressive Weapons', player) and (self.has('Progressive Armor', player) or self.has('Shield', player)) and self.has_iron_ingots(player)
|
|
||||||
|
|
||||||
def complete_raid(self, player: int):
|
|
||||||
reach_regions = self.can_reach('Village', 'Region', player) and self.can_reach('Pillager Outpost', 'Region', player)
|
|
||||||
if self.combat_difficulty(player) == 'easy':
|
|
||||||
return reach_regions and \
|
|
||||||
self.has('Progressive Weapons', player, 3) and self.has('Progressive Armor', player, 2) and \
|
|
||||||
self.has('Shield', player) and self.has('Archery', player) and \
|
|
||||||
self.has('Progressive Tools', player, 2) and self.has_iron_ingots(player)
|
|
||||||
elif self.combat_difficulty(player) == 'hard': # might be too hard?
|
|
||||||
return reach_regions and self.has('Progressive Weapons', player, 2) and self.has_iron_ingots(player) and \
|
|
||||||
(self.has('Progressive Armor', player) or self.has('Shield', player))
|
|
||||||
return reach_regions and self.has('Progressive Weapons', player, 2) and self.has_iron_ingots(player) and \
|
|
||||||
self.has('Progressive Armor', player) and self.has('Shield', player)
|
|
||||||
|
|
||||||
def can_kill_wither(self, player: int):
|
|
||||||
normal_kill = self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and self.can_brew_potions(player) and self.can_enchant(player)
|
|
||||||
if self.combat_difficulty(player) == 'easy':
|
|
||||||
return self.fortress_loot(player) and normal_kill and self.has('Archery', player)
|
|
||||||
elif self.combat_difficulty(player) == 'hard': # cheese kill using bedrock ceilings
|
|
||||||
return self.fortress_loot(player) and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player))
|
|
||||||
return self.fortress_loot(player) and normal_kill
|
|
||||||
|
|
||||||
def can_kill_ender_dragon(self, player: int):
|
|
||||||
# Since it is possible to kill the dragon without getting any of the advancements related to it, we need to require that it can be respawned.
|
|
||||||
respawn_dragon = self.can_reach('The Nether', 'Region', player) and self.has('Ingot Crafting', player)
|
|
||||||
if self.combat_difficulty(player) == 'easy':
|
|
||||||
return respawn_dragon and self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and \
|
|
||||||
self.has('Archery', player) and self.can_brew_potions(player) and self.can_enchant(player)
|
|
||||||
if self.combat_difficulty(player) == 'hard':
|
|
||||||
return respawn_dragon and ((self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player)) or \
|
|
||||||
(self.has('Progressive Weapons', player, 1) and self.has('Bed', player)))
|
|
||||||
return respawn_dragon and self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and self.has('Archery', player)
|
|
||||||
|
|
||||||
|
|
||||||
def collect(self, item: Item, event: bool = False, location: Location = None) -> bool:
|
def collect(self, item: Item, event: bool = False, location: Location = None) -> bool:
|
||||||
if location:
|
if location:
|
||||||
self.locations_checked.add(location)
|
self.locations_checked.add(location)
|
||||||
@@ -927,7 +808,8 @@ class CollectionState(object):
|
|||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
@unique
|
@unique
|
||||||
class RegionType(Enum):
|
class RegionType(int, Enum):
|
||||||
|
Generic = 0
|
||||||
LightWorld = 1
|
LightWorld = 1
|
||||||
DarkWorld = 2
|
DarkWorld = 2
|
||||||
Cave = 3 # Also includes Houses
|
Cave = 3 # Also includes Houses
|
||||||
@@ -941,7 +823,7 @@ class RegionType(Enum):
|
|||||||
|
|
||||||
class Region(object):
|
class Region(object):
|
||||||
|
|
||||||
def __init__(self, name: str, type, hint, player: int):
|
def __init__(self, name: str, type, hint, player: int, world: Optional[MultiWorld] = None):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.type = type
|
self.type = type
|
||||||
self.entrances = []
|
self.entrances = []
|
||||||
@@ -949,15 +831,14 @@ class Region(object):
|
|||||||
self.locations = []
|
self.locations = []
|
||||||
self.dungeon = None
|
self.dungeon = None
|
||||||
self.shop = None
|
self.shop = None
|
||||||
self.world = None
|
self.world = world
|
||||||
self.is_light_world = False # will be set after making connections.
|
self.is_light_world = False # will be set after making connections.
|
||||||
self.is_dark_world = False
|
self.is_dark_world = False
|
||||||
self.spot_type = 'Region'
|
self.spot_type = 'Region'
|
||||||
self.hint_text = hint
|
self.hint_text = hint
|
||||||
self.recursion_count = 0
|
|
||||||
self.player = player
|
self.player = player
|
||||||
|
|
||||||
def can_reach(self, state):
|
def can_reach(self, state: CollectionState):
|
||||||
if state.stale[self.player]:
|
if state.stale[self.player]:
|
||||||
state.update_reachable_regions(self.player)
|
state.update_reachable_regions(self.player)
|
||||||
return self in state.reachable_regions[self.player]
|
return self in state.reachable_regions[self.player]
|
||||||
@@ -986,6 +867,7 @@ class Region(object):
|
|||||||
|
|
||||||
|
|
||||||
class Entrance(object):
|
class Entrance(object):
|
||||||
|
spot_type = 'Entrance'
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = '', parent=None):
|
def __init__(self, player: int, name: str = '', parent=None):
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -993,9 +875,6 @@ class Entrance(object):
|
|||||||
self.connected_region = None
|
self.connected_region = None
|
||||||
self.target = None
|
self.target = None
|
||||||
self.addresses = None
|
self.addresses = None
|
||||||
self.spot_type = 'Entrance'
|
|
||||||
self.recursion_count = 0
|
|
||||||
self.vanilla = None
|
|
||||||
self.access_rule = lambda state: True
|
self.access_rule = lambda state: True
|
||||||
self.player = player
|
self.player = player
|
||||||
self.hide_path = False
|
self.hide_path = False
|
||||||
@@ -1008,11 +887,10 @@ class Entrance(object):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def connect(self, region, addresses=None, target=None, vanilla=None):
|
def connect(self, region, addresses=None, target=None):
|
||||||
self.connected_region = region
|
self.connected_region = region
|
||||||
self.target = target
|
self.target = target
|
||||||
self.addresses = addresses
|
self.addresses = addresses
|
||||||
self.vanilla = vanilla
|
|
||||||
region.entrances.append(self)
|
region.entrances.append(self)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -1085,17 +963,16 @@ class Location():
|
|||||||
spot_type = 'Location'
|
spot_type = 'Location'
|
||||||
game: str = "Generic"
|
game: str = "Generic"
|
||||||
crystal: bool = False
|
crystal: bool = False
|
||||||
|
always_allow = staticmethod(lambda item, state: False)
|
||||||
|
access_rule = staticmethod(lambda state: True)
|
||||||
|
item_rule = staticmethod(lambda item: True)
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = '', address:int = None, parent=None):
|
def __init__(self, player: int, name: str = '', address:int = None, parent=None):
|
||||||
self.name = name
|
self.name: str = name
|
||||||
self.address = address
|
self.address: Optional[int] = address
|
||||||
self.parent_region: Region = parent
|
self.parent_region: Region = parent
|
||||||
self.recursion_count = 0
|
self.player: int = player
|
||||||
self.player = player
|
self.item: Optional[Item] = None
|
||||||
self.item = None
|
|
||||||
self.always_allow = lambda item, state: False
|
|
||||||
self.access_rule = lambda state: True
|
|
||||||
self.item_rule = lambda item: True
|
|
||||||
|
|
||||||
def can_fill(self, state: CollectionState, item: 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)))
|
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)))
|
||||||
@@ -1127,20 +1004,28 @@ class Location():
|
|||||||
def __lt__(self, other):
|
def __lt__(self, other):
|
||||||
return (self.player, self.name) < (other.player, other.name)
|
return (self.player, self.name) < (other.player, other.name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_item(self) -> bool:
|
||||||
|
"""Returns True if the item in this location matches game."""
|
||||||
|
return self.item and self.item.game == self.game
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hint_text(self):
|
def hint_text(self):
|
||||||
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
|
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
|
||||||
|
|
||||||
|
|
||||||
class Item():
|
class Item():
|
||||||
location: Optional[Location] = None
|
location: Optional[Location] = None
|
||||||
world: Optional[MultiWorld] = None
|
world: Optional[MultiWorld] = None
|
||||||
game: str = "Generic"
|
game: str = "Generic"
|
||||||
type: str = None
|
type: str = None
|
||||||
pedestal_credit_text = "and the Unknown Item"
|
never_exclude = False # change manually to ensure that a specific nonprogression item never goes on an excluded location
|
||||||
sickkid_credit_text = None
|
pedestal_credit_text: str = "and the Unknown Item"
|
||||||
magicshop_credit_text = None
|
sickkid_credit_text: Optional[str] = None
|
||||||
zora_credit_text = None
|
magicshop_credit_text: Optional[str] = None
|
||||||
fluteboy_credit_text = None
|
zora_credit_text: Optional[str] = None
|
||||||
|
fluteboy_credit_text: Optional[str] = None
|
||||||
|
code: Optional[str] = None # an item with ID None is called an Event, and does not get written to multidata
|
||||||
|
|
||||||
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
|
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -1244,7 +1129,7 @@ class Spoiler(object):
|
|||||||
|
|
||||||
def parse_data(self):
|
def parse_data(self):
|
||||||
self.medallions = OrderedDict()
|
self.medallions = OrderedDict()
|
||||||
for player in self.world.alttp_player_ids:
|
for player in self.world.get_game_players("A Link to the Past"):
|
||||||
self.medallions[f'Misery Mire ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][0]
|
self.medallions[f'Misery Mire ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][0]
|
||||||
self.medallions[f'Turtle Rock ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][1]
|
self.medallions[f'Turtle Rock ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][1]
|
||||||
|
|
||||||
@@ -1300,7 +1185,7 @@ class Spoiler(object):
|
|||||||
shopdata['item_{}'.format(index)] += ", {} - {}".format(item['replacement'], item['replacement_price']) if item['replacement_price'] else item['replacement']
|
shopdata['item_{}'.format(index)] += ", {} - {}".format(item['replacement'], item['replacement_price']) if item['replacement_price'] else item['replacement']
|
||||||
self.shops.append(shopdata)
|
self.shops.append(shopdata)
|
||||||
|
|
||||||
for player in self.world.alttp_player_ids:
|
for player in self.world.get_game_players("A Link to the Past"):
|
||||||
self.bosses[str(player)] = OrderedDict()
|
self.bosses[str(player)] = OrderedDict()
|
||||||
self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name
|
self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name
|
||||||
self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name
|
self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name
|
||||||
@@ -1414,11 +1299,11 @@ class Spoiler(object):
|
|||||||
res = getattr(self.world, f_option)[player]
|
res = getattr(self.world, f_option)[player]
|
||||||
outfile.write(f'{f_option+":":33}{bool_to_text(res) if type(res) == Options.Toggle else res.get_option_name()}\n')
|
outfile.write(f'{f_option+":":33}{bool_to_text(res) if type(res) == Options.Toggle else res.get_option_name()}\n')
|
||||||
|
|
||||||
if player in self.world.alttp_player_ids:
|
if player in self.world.get_game_players("A Link to the Past"):
|
||||||
for team in range(self.world.teams):
|
for team in range(self.world.teams):
|
||||||
outfile.write('%s%s\n' % (
|
outfile.write('%s%s\n' % (
|
||||||
f"Hash - {self.world.player_names[player][team]} (Team {team + 1}): " if
|
f"Hash - {self.world.player_names[player][team]} (Team {team + 1}): " if
|
||||||
(player in self.world.alttp_player_ids and self.world.teams > 1) else 'Hash: ',
|
(player in self.world.get_game_players("A Link to the Past") and self.world.teams > 1) else 'Hash: ',
|
||||||
self.hashes[player, team]))
|
self.hashes[player, team]))
|
||||||
|
|
||||||
outfile.write('Logic: %s\n' % self.metadata['logic'][player])
|
outfile.write('Logic: %s\n' % self.metadata['logic'][player])
|
||||||
@@ -1491,10 +1376,10 @@ class Spoiler(object):
|
|||||||
outfile.write('\n\nMedallions:\n')
|
outfile.write('\n\nMedallions:\n')
|
||||||
for dungeon, medallion in self.medallions.items():
|
for dungeon, medallion in self.medallions.items():
|
||||||
outfile.write(f'\n{dungeon}: {medallion}')
|
outfile.write(f'\n{dungeon}: {medallion}')
|
||||||
|
factorio_players = self.world.get_game_players("Factorio")
|
||||||
if self.world.factorio_player_ids:
|
if factorio_players:
|
||||||
outfile.write('\n\nRecipes:\n')
|
outfile.write('\n\nRecipes:\n')
|
||||||
for player in self.world.factorio_player_ids:
|
for player in factorio_players:
|
||||||
name = self.world.get_player_names(player)
|
name = self.world.get_player_names(player)
|
||||||
for recipe in self.world.worlds[player].custom_recipes.values():
|
for recipe in self.world.worlds[player].custom_recipes.values():
|
||||||
outfile.write(f"\n{recipe.name} ({name}): {recipe.ingredients} -> {recipe.products}")
|
outfile.write(f"\n{recipe.name} ({name}): {recipe.ingredients} -> {recipe.products}")
|
||||||
@@ -1510,7 +1395,7 @@ class Spoiler(object):
|
|||||||
outfile.write('\n\nShops:\n\n')
|
outfile.write('\n\nShops:\n\n')
|
||||||
outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item)) for shop in self.shops))
|
outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item)) for shop in self.shops))
|
||||||
|
|
||||||
for player in self.world.alttp_player_ids:
|
for player in self.world.get_game_players("A Link to the Past"):
|
||||||
if self.world.boss_shuffle[player] != 'none':
|
if self.world.boss_shuffle[player] != 'none':
|
||||||
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
|
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
|
||||||
outfile.write(f'\n\nBosses{(f" ({self.world.get_player_names(player)})" if self.world.players > 1 else "")}:\n')
|
outfile.write(f'\n\nBosses{(f" ({self.world.get_player_names(player)})" if self.world.players > 1 else "")}:\n')
|
||||||
@@ -1534,6 +1419,3 @@ class Spoiler(object):
|
|||||||
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
||||||
|
|
||||||
outfile.write('\n'.join(path_listings))
|
outfile.write('\n'.join(path_listings))
|
||||||
|
|
||||||
from worlds.alttp.Items import item_name_groups
|
|
||||||
from worlds.generic import PlandoItem, PlandoConnection
|
|
||||||
@@ -8,14 +8,9 @@ import websockets
|
|||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor
|
||||||
|
|
||||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
|
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
|
||||||
from Utils import Version
|
from Utils import Version
|
||||||
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
# logging note:
|
|
||||||
# logging.* gets send to only the text console, logger.* gets send to the WebUI as well, if it's initialized.
|
|
||||||
from worlds import network_data_package
|
|
||||||
from worlds.alttp import Items, Regions
|
|
||||||
|
|
||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
|
|
||||||
@@ -54,7 +49,7 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
"""List all missing location checks, from your local game state"""
|
"""List all missing location checks, from your local game state"""
|
||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
for location, location_id in Regions.lookup_name_to_id.items():
|
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id.items():
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
continue
|
continue
|
||||||
if location_id not in self.ctx.locations_checked:
|
if location_id not in self.ctx.locations_checked:
|
||||||
@@ -92,7 +87,9 @@ class CommonContext():
|
|||||||
starting_reconnect_delay = 5
|
starting_reconnect_delay = 5
|
||||||
current_reconnect_delay = starting_reconnect_delay
|
current_reconnect_delay = starting_reconnect_delay
|
||||||
command_processor = ClientCommandProcessor
|
command_processor = ClientCommandProcessor
|
||||||
def __init__(self, server_address, password, found_items: bool):
|
game: None
|
||||||
|
|
||||||
|
def __init__(self, server_address, password):
|
||||||
# server state
|
# server state
|
||||||
self.server_address = server_address
|
self.server_address = server_address
|
||||||
self.password = password
|
self.password = password
|
||||||
@@ -103,7 +100,6 @@ class CommonContext():
|
|||||||
# own state
|
# own state
|
||||||
self.finished_game = False
|
self.finished_game = False
|
||||||
self.ready = False
|
self.ready = False
|
||||||
self.found_items = found_items
|
|
||||||
self.team = None
|
self.team = None
|
||||||
self.slot = None
|
self.slot = None
|
||||||
self.auth = None
|
self.auth = None
|
||||||
@@ -389,8 +385,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
elif cmd == 'PrintJSON':
|
elif cmd == 'PrintJSON':
|
||||||
ctx.on_print_json(args)
|
ctx.on_print_json(args)
|
||||||
|
|
||||||
elif cmd == 'InvalidArguments':
|
elif cmd == 'InvalidPacket':
|
||||||
logger.warning(f"Invalid Arguments: {args['text']}")
|
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"unknown command {cmd}")
|
logger.debug(f"unknown command {cmd}")
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
Hint description:
|
|
||||||
|
|
||||||
Hints will appear in the following ratios across the 15 telepathic tiles that have hints and the five storyteller locations:
|
|
||||||
|
|
||||||
4 hints for inconvenient entrances.
|
|
||||||
4 hints for random entrances (this can by coincidence pick inconvenient entrances that aren't used for the first set of hints).
|
|
||||||
3 hints for inconvenient item locations.
|
|
||||||
5 hints for valuable items.
|
|
||||||
4 junk hints.
|
|
||||||
|
|
||||||
In the vanilla, dungeonssimple, and dungeonsfull shuffles, the following ratios will be used instead:
|
|
||||||
|
|
||||||
5 hints for inconvenient item locations.
|
|
||||||
8 hints for valuable items.
|
|
||||||
7 junk hints.
|
|
||||||
|
|
||||||
In the simple, restricted, and restricted legacy shuffles, these are the ratios:
|
|
||||||
|
|
||||||
2 hints for inconvenient entrances.
|
|
||||||
1 hint for an inconvenient dungeon entrance.
|
|
||||||
4 hints for random entrances (this can by coincidence pick inconvenient entrances that aren't used for the first set of hints).
|
|
||||||
3 hints for inconvenient item locations.
|
|
||||||
5 hints for valuable items.
|
|
||||||
5 junk hints.
|
|
||||||
|
|
||||||
These hints will use the following format:
|
|
||||||
|
|
||||||
Entrance hints go "[Entrance on overworld] leads to [interior]".
|
|
||||||
|
|
||||||
Inconvenient item locations are a little more custom but amount to "[Location] has [item name]". The item name is literal and will specify which dungeon the dungeon specific items hail from (small key/big key/map/compass).
|
|
||||||
|
|
||||||
The valuable items are of the format "[item name] can be found [location]". The item name is again literal, and the location text is taken from Ganon's silver arrow hints. Note that the way it works is that every unique valuable item that exists is considered independently, and you won't get multiple hints for the EXACT same item (so you can only get one hint for Progressive Sword no matter how many swords exist in the seed, but if swords are not progressive, you could get hints for both Master Sword and Tempered Sword). More copies of an item existing does not increase the probability of getting a hint for that particular item (you are equally likely to get a hint for a Progressive Sword as for the Hammer). Unlike the IR, item names are never obfuscated by "something unique", and there is no special bias for hints for GT Big Key or Pegasus Boots.
|
|
||||||
|
|
||||||
Hint Locations:
|
|
||||||
|
|
||||||
Eastern Palace room before Big Chest
|
|
||||||
Desert Palace bonk torch room
|
|
||||||
Tower of Hera entrance room
|
|
||||||
Tower of Hera Big Chest room
|
|
||||||
Castle Tower after dark rooms
|
|
||||||
Palace of Darkness before Bow section
|
|
||||||
Swamp Palace entryway
|
|
||||||
Thieves' Town upstairs
|
|
||||||
Ice Palace entrance
|
|
||||||
Ice Palace after first drop
|
|
||||||
Ice Palace tall ice floor room
|
|
||||||
Misery Mire cutscene room
|
|
||||||
Turtle Rock entrance
|
|
||||||
Spectacle Rock cave
|
|
||||||
Spiky Hint cave
|
|
||||||
PoD Bdlg NPC
|
|
||||||
Near PoD Storyteller (bug near bomb wall)
|
|
||||||
Dark Sanctuary Storyteller (long room with tables)
|
|
||||||
Near Mire Storyteller (feather duster in winding cave)
|
|
||||||
SE DW Storyteller (owl in winding cave)
|
|
||||||
|
|
||||||
Inconvenient entrance list:
|
|
||||||
|
|
||||||
Skull Woods Final
|
|
||||||
Ice Palace
|
|
||||||
Misery Mire
|
|
||||||
Turtle Rock
|
|
||||||
Ganon's Tower
|
|
||||||
Mimic Ledge
|
|
||||||
SW DM Foothills Cave (mirror from upper Bumper ledge)
|
|
||||||
Hammer Pegs (near purple chest)
|
|
||||||
Super Bomb cracked wall
|
|
||||||
|
|
||||||
Inconvenient location list:
|
|
||||||
|
|
||||||
Swamp left (two chests)
|
|
||||||
Mire left (two chests)
|
|
||||||
Hera basement
|
|
||||||
Eastern Palace Big Key chest (protected by anti-fairies)
|
|
||||||
Thieves' Town Big Chest
|
|
||||||
Ice Palace Big Chest
|
|
||||||
Ganon's Tower Big Chest
|
|
||||||
Purple Chest
|
|
||||||
Spike Cave
|
|
||||||
Magic Bat
|
|
||||||
Sahasrahla (Green Pendant)
|
|
||||||
|
|
||||||
In the vanilla, dungeonssimple, and dungeonsfull shuffles, the following two locations are added to the inconvenient locations list:
|
|
||||||
|
|
||||||
Graveyard Cave
|
|
||||||
Mimic Cave
|
|
||||||
|
|
||||||
Valuable Items are simply all items that are shown on the pause subscreen (Y, B, or A sections) minus Silver Arrows and plus Triforce Pieces, Magic Upgrades (1/2 or 1/4), and the Single Arrow. If key shuffle is being used, you can additionally get hints for Small Keys or Big Keys but not hints for Maps or Compasses.
|
|
||||||
|
|
||||||
While the exact verbage of location names and item names can be found in the source code, here's a copy for reference:
|
|
||||||
|
|
||||||
Overworld Entrance naming:
|
|
||||||
|
|
||||||
Turtle Rock: Turtle Rock Main
|
|
||||||
Misery Mire: Misery Mire
|
|
||||||
Ice Palace: Ice Palace
|
|
||||||
Skull Woods Final Section: The back of Skull Woods
|
|
||||||
Death Mountain Return Cave (West): The SW DM Foothills Cave
|
|
||||||
Mimic Cave: Mimic Ledge
|
|
||||||
Dark World Hammer Peg Cave: The rows of pegs
|
|
||||||
Pyramid Fairy: The crack on the pyramid
|
|
||||||
Eastern Palace: Eastern Palace
|
|
||||||
Elder House (East): Elder House
|
|
||||||
Elder House (West): Elder House
|
|
||||||
Two Brothers House (East): Eastern Quarreling Brothers' house
|
|
||||||
Old Man Cave (West): The lower DM entrance
|
|
||||||
Hyrule Castle Entrance (South): The ground level castle door
|
|
||||||
Thieves Town: Thieves' Town
|
|
||||||
Bumper Cave (Bottom): The lower Bumper Cave
|
|
||||||
Swamp Palace: Swamp Palace
|
|
||||||
Dark Death Mountain Ledge (West): The East dark DM connector ledge
|
|
||||||
Dark Death Mountain Ledge (East): The East dark DM connector ledge
|
|
||||||
Superbunny Cave (Top): The summit of dark DM cave
|
|
||||||
Superbunny Cave (Bottom): The base of east dark DM
|
|
||||||
Hookshot Cave: The rock on dark DM
|
|
||||||
Desert Palace Entrance (South): The book sealed passage
|
|
||||||
Tower of Hera: The Tower of Hera
|
|
||||||
Two Brothers House (West): The door near the race game
|
|
||||||
Old Man Cave (East): The SW-most cave on west DM
|
|
||||||
Old Man House (Bottom): A cave with a door on west DM
|
|
||||||
Old Man House (Top): The eastmost cave on west DM
|
|
||||||
Death Mountain Return Cave (East): The westmost cave on west DM
|
|
||||||
Spectacle Rock Cave Peak: The highest cave on west DM
|
|
||||||
Spectacle Rock Cave: The right ledge on west DM
|
|
||||||
Spectacle Rock Cave (Bottom): The left ledge on west DM
|
|
||||||
Paradox Cave (Bottom): The right paired cave on east DM
|
|
||||||
Paradox Cave (Middle): The southmost cave on east DM
|
|
||||||
Paradox Cave (Top): The east DM summit cave
|
|
||||||
Fairy Ascension Cave (Bottom): The east DM cave behind rocks
|
|
||||||
Fairy Ascension Cave (Top): The central ledge on east DM
|
|
||||||
Spiral Cave: The left ledge on east DM
|
|
||||||
Spiral Cave (Bottom): The SWmost cave on east DM
|
|
||||||
Palace of Darkness: Palace of Darkness
|
|
||||||
Hyrule Castle Entrance (West): The left castle door
|
|
||||||
Hyrule Castle Entrance (East): The right castle door
|
|
||||||
Agahnims Tower: The sealed castle door
|
|
||||||
Desert Palace Entrance (West): The westmost building in the desert
|
|
||||||
Desert Palace Entrance (North): The northmost cave in the desert
|
|
||||||
Blinds Hideout: Blind's old house
|
|
||||||
Lake Hylia Fairy: A cave NE of Lake Hylia
|
|
||||||
Light Hype Fairy: The cave south of your house
|
|
||||||
Desert Fairy: The cave near the desert
|
|
||||||
Chicken House: The chicken lady's house
|
|
||||||
Aginahs Cave: The open desert cave
|
|
||||||
Sahasrahlas Hut: The house near armos
|
|
||||||
Cave Shop (Lake Hylia): The cave NW Lake Hylia
|
|
||||||
Blacksmiths Hut: The old smithery
|
|
||||||
Sick Kids House: The central house in Kakariko
|
|
||||||
Lost Woods Gamble: A tree trunk door
|
|
||||||
Fortune Teller (Light): A building NE of Kakariko
|
|
||||||
Snitch Lady (East): A house guarded by a snitch
|
|
||||||
Snitch Lady (West): A house guarded by a snitch
|
|
||||||
Bush Covered House: A house with an uncut lawn
|
|
||||||
Tavern (Front): A building with a backdoor
|
|
||||||
Light World Bomb Hut: A Kakariko building with no door
|
|
||||||
Kakariko Shop: The old Kakariko shop
|
|
||||||
Mini Moldorm Cave: The cave south of Lake Hylia
|
|
||||||
Long Fairy Cave: The eastmost portal cave
|
|
||||||
Good Bee Cave: The open cave SE Lake Hylia
|
|
||||||
20 Rupee Cave: The rock SE Lake Hylia
|
|
||||||
50 Rupee Cave: The rock near the desert
|
|
||||||
Ice Rod Cave: The sealed cave SE Lake Hylia
|
|
||||||
Library: The old library
|
|
||||||
Potion Shop: The witch's building
|
|
||||||
Dam: The old dam
|
|
||||||
Lumberjack House: The lumberjack house
|
|
||||||
Lake Hylia Fortune Teller: The building NW Lake Hylia
|
|
||||||
Kakariko Gamble Game: The old Kakariko gambling den
|
|
||||||
Waterfall of Wishing: Going behind the waterfall
|
|
||||||
Capacity Upgrade: The cave on the island
|
|
||||||
Bonk Rock Cave: The rock pile near Sanctuary
|
|
||||||
Graveyard Cave: The graveyard ledge
|
|
||||||
Checkerboard Cave: The NE desert ledge
|
|
||||||
Cave 45: The ledge south of haunted grove
|
|
||||||
Kings Grave: The northeastmost grave
|
|
||||||
Bonk Fairy (Light): The rock pile near your home
|
|
||||||
Hookshot Fairy: A cave on east DM
|
|
||||||
Bonk Fairy (Dark): The rock pile near the old bomb shop
|
|
||||||
Dark Sanctuary Hint: The dark sanctuary cave
|
|
||||||
Dark Lake Hylia Fairy: The cave NE dark Lake Hylia
|
|
||||||
C-Shaped House: The NE house in Village of Outcasts
|
|
||||||
Big Bomb Shop: The old bomb shop
|
|
||||||
Dark Death Mountain Fairy: The SW cave on dark DM
|
|
||||||
Dark Lake Hylia Shop: The building NW dark Lake Hylia
|
|
||||||
Dark World Shop: The hammer sealed building
|
|
||||||
Red Shield Shop: The fenced in building
|
|
||||||
Mire Shed: The western hut in the mire
|
|
||||||
East Dark World Hint: The dark cave near the eastmost portal
|
|
||||||
Dark Desert Hint: The cave east of the mire
|
|
||||||
Spike Cave: The ledge cave on west dark DM
|
|
||||||
Palace of Darkness Hint: The building south of Kiki
|
|
||||||
Dark Lake Hylia Ledge Spike Cave: The rock SE dark Lake Hylia
|
|
||||||
Cave Shop (Dark Death Mountain): The base of east dark DM
|
|
||||||
Dark World Potion Shop: The building near the catfish
|
|
||||||
Archery Game: The old archery game
|
|
||||||
Dark World Lumberjack Shop: The northmost Dark World building
|
|
||||||
Hype Cave: The cave south of the old bomb shop
|
|
||||||
Brewery: The Village of Outcasts building with no door
|
|
||||||
Dark Lake Hylia Ledge Hint: The open cave SE dark Lake Hylia
|
|
||||||
Chest Game: The westmost building in the Village of Outcasts
|
|
||||||
Dark Desert Fairy: The eastern hut in the mire
|
|
||||||
Dark Lake Hylia Ledge Fairy: The sealed cave SE dark Lake Hylia
|
|
||||||
Fortune Teller (Dark): The building NE the Village of Outcasts
|
|
||||||
Sanctuary: Sanctuary
|
|
||||||
Lumberjack Tree Cave: The cave Behind Lumberjacks
|
|
||||||
Lost Woods Hideout Stump: The stump in Lost Woods
|
|
||||||
North Fairy Cave: The cave East of Graveyard
|
|
||||||
Bat Cave Cave: The cave in eastern Kakariko
|
|
||||||
Kakariko Well Cave: The cave in northern Kakariko
|
|
||||||
Hyrule Castle Secret Entrance Stairs: The tunnel near the castle
|
|
||||||
Skull Woods First Section Door: The southeastmost skull
|
|
||||||
Skull Woods Second Section Door (East): The central open skull
|
|
||||||
Skull Woods Second Section Door (West): The westmost open skull
|
|
||||||
Desert Palace Entrance (East): The eastern building in the desert
|
|
||||||
Turtle Rock Isolated Ledge Entrance: The isolated ledge on east dark DM
|
|
||||||
Bumper Cave (Top): The upper Bumper Cave
|
|
||||||
Hookshot Cave Back Entrance: The stairs on the floating island
|
|
||||||
|
|
||||||
Destination Entrance Naming:
|
|
||||||
|
|
||||||
Hyrule Castle: Hyrule Castle (all three entrances)
|
|
||||||
Eastern Palace: Eastern Palace
|
|
||||||
Desert Palace: Desert Palace (all four entrances, including final)
|
|
||||||
Tower of Hera: Tower of Hera
|
|
||||||
Palace of Darkness: Palace of Darkness
|
|
||||||
Swamp Palace: Swamp Palace
|
|
||||||
Skull Woods: Skull Woods (any entrance including final)
|
|
||||||
Thieves' Town: Thieves' Town
|
|
||||||
Ice Palace: Ice Palace
|
|
||||||
Misery Mire: Misery Mire
|
|
||||||
Turtle Rock: Turtle Rock (all four entrances)
|
|
||||||
Ganon's Tower: Ganon's Tower
|
|
||||||
Castle Tower: Agahnim's Tower
|
|
||||||
A connector: Paradox Cave, Spectacle Rock Cave, Hookshot Cave, Superbunny Cave, Spiral Cave, Old Man Fetch Cave, Old Man House, Elder House, Quarreling Brothers' House, Bumper Cave, DM Fairy Ascent Cave, DM Exit Cave
|
|
||||||
A bounty of five items: Mini-moldorm cave, Hype Cave, Blind's Hideout
|
|
||||||
Sahasrahla: Sahasrahla
|
|
||||||
A cave with two items: Mire hut, Waterfall Fairy, Pyramid Fairy
|
|
||||||
A fairy fountain: Any healer fairy cave, either bonk cave with four fairies, the "long fairy" cave
|
|
||||||
A common shop: Any shop that sells bombs by default
|
|
||||||
The rare shop: The shop that sells the Red Shield by default
|
|
||||||
The potion shop: Potion Shop
|
|
||||||
The bomb shop: Bomb Shop
|
|
||||||
A fortune teller: Any of the three fortune tellers
|
|
||||||
A house with a chest: Chicken Lady's house, C-House, Brewery
|
|
||||||
A cave with an item: Checkerboard cave, Hammer Pegs cave, Cave 45, Graveyard Ledge cave
|
|
||||||
A cave with a chest: Sanc Bonk Rock Cave, Cape Grave Cave, Ice Rod Cave, Aginah's Cave
|
|
||||||
The dam: Watergate
|
|
||||||
The sick kid: Sick Kid
|
|
||||||
The library: Library
|
|
||||||
Mimic Cave: Mimic Cave
|
|
||||||
Spike Cave: Spike Cave
|
|
||||||
A game of 16 chests: VoO chest game (for the item)
|
|
||||||
A storyteller: The four DW NPCs who charge 20 rupees for a hint as well as the PoD Bdlg guy who gives a free hint
|
|
||||||
A cave with some cash: 20 rupee cave, 50 rupee cave (both have thieves and some pots)
|
|
||||||
A game of chance: Gambling game (just for cash, no items)
|
|
||||||
A game of skill: Archery minigame
|
|
||||||
The queen of fairies: Capacity Upgrade Fairy
|
|
||||||
A drop's exit: Sanctuary, LW Thieves' Hideout, Kakariko Well, Magic Bat, Useless Fairy, Uncle Tunnel, Ganon drop exit
|
|
||||||
A restock room: The Kakariko bomb/arrow restock room
|
|
||||||
The tavern: The Kakariko tavern
|
|
||||||
The grass man: The Kakariko man with many beds
|
|
||||||
A cold bee: The "wrong side" of Ice Rod cave where you can get a Good Bee
|
|
||||||
Fairies deep in a cave: Hookshot Fairy
|
|
||||||
|
|
||||||
Location naming reference:
|
|
||||||
|
|
||||||
Mushroom: in the woods
|
|
||||||
Master Sword Pedestal: at the pedestal
|
|
||||||
Bottle Merchant: with a merchant
|
|
||||||
Stumpy: with tree boy
|
|
||||||
Flute Spot: underground
|
|
||||||
Digging Game: underground
|
|
||||||
Lake Hylia Island: on an island
|
|
||||||
Floating Island: on an island
|
|
||||||
Bumper Cave Ledge: on a ledge
|
|
||||||
Spectacle Rock: atop a rock
|
|
||||||
Maze Race: at the race
|
|
||||||
Desert Ledge: in the desert
|
|
||||||
Pyramid: on the pyramid
|
|
||||||
Catfish: with a catfish
|
|
||||||
Ether Tablet: at a monument
|
|
||||||
Bombos Tablet: at a monument
|
|
||||||
Hobo: with the hobo
|
|
||||||
Zora's Ledge: near Zora
|
|
||||||
King Zora: at a high price
|
|
||||||
Sunken Treasure: underwater
|
|
||||||
Floodgate Chest: in the dam
|
|
||||||
Blacksmith: with the smith
|
|
||||||
Purple Chest: from a box
|
|
||||||
Old Man: with the old man
|
|
||||||
Link's Uncle: with your uncle
|
|
||||||
Secret Passage: near your uncle
|
|
||||||
Kakariko Well (5 items): in a well
|
|
||||||
Lost Woods Hideout: near a thief
|
|
||||||
Lumberjack Tree: in a hole
|
|
||||||
Magic Bat: with the bat
|
|
||||||
Paradox Cave (7 items): in a cave with seven chests
|
|
||||||
Blind's Hideout (5 items): in a basement
|
|
||||||
Mini Moldorm Cave (5 items): near Moldorms
|
|
||||||
Hype Cave (4 back chests): near a bat-like man
|
|
||||||
Hype Cave - Generous Guy: with a bat-like man
|
|
||||||
Hookshot Cave (4 items): across pits
|
|
||||||
Sahasrahla's Hut (chests in back): near the elder
|
|
||||||
Sahasrahla: with the elder
|
|
||||||
Waterfall Fairy (2 items): near a fairy
|
|
||||||
Pyramid Fairy (2 items): near a fairy
|
|
||||||
Mire Shed (2 items): near sparks
|
|
||||||
Superbunny Cave (2 items): in a connection
|
|
||||||
Spiral Cave: in spiral cave
|
|
||||||
Kakariko Tavern: in the bar
|
|
||||||
Link's House: in your home
|
|
||||||
Sick Kid: with the sick
|
|
||||||
Library: near books
|
|
||||||
Potion Shop: near potions
|
|
||||||
Spike Cave: beyond spikes
|
|
||||||
Mimic Cave: in a cave of mimicry
|
|
||||||
Chest Game: as a prize
|
|
||||||
Chicken House: near poultry
|
|
||||||
Aginah's Cave: with Aginah
|
|
||||||
Ice Rod Cave: in a frozen cave
|
|
||||||
Brewery: alone in a home
|
|
||||||
C-Shaped House: alone in a home
|
|
||||||
Spectacle Rock Cave: alone in a cave
|
|
||||||
King's Tomb: alone in a cave
|
|
||||||
Cave 45: alone in a cave
|
|
||||||
Graveyard Cave: alone in a cave
|
|
||||||
Checkerboard Cave: alone in a cave
|
|
||||||
Bonk Rock Cave: alone in a cave
|
|
||||||
Peg Cave: alone in a cave
|
|
||||||
Sanctuary: in Sanctuary
|
|
||||||
Hyrule Castle - Boomerang Chest: in Hyrule Castle
|
|
||||||
Hyrule Castle - Map Chest: in Hyrule Castle
|
|
||||||
Hyrule Castle - Zelda's Chest: in Hyrule Castle
|
|
||||||
Sewers - Dark Cross: in the sewers
|
|
||||||
Sewers - Secret Room (3 items): in the sewers
|
|
||||||
Eastern Palace - Boss: with the Armos
|
|
||||||
Eastern Palace (otherwise, 5 items): in Eastern Palace
|
|
||||||
Desert Palace - Boss: with Lanmolas
|
|
||||||
Desert Palace (otherwise, 5 items): in Desert Palace
|
|
||||||
Tower of Hera - Boss: with Moldorm
|
|
||||||
Tower of Hera (otherwise, 5 items): in Tower of Hera
|
|
||||||
Castle Tower (2 items): in Castle Tower
|
|
||||||
Palace of Darkness - Boss: with Helmasaur King
|
|
||||||
Palace of Darkness (otherwise, 13 items): in Palace of Darkness
|
|
||||||
Swamp Palace - Boss: with Arrghus
|
|
||||||
Swamp Palace (otherwise, 9 items): in Swamp Palace
|
|
||||||
Skull Woods - Bridge Room: near Mothula
|
|
||||||
Skull Woods - Boss: with Mothula
|
|
||||||
Skull Woods (otherwise, 6 items): in Skull Woods
|
|
||||||
Thieves' Town - Boss: with Blind
|
|
||||||
Thieves' Town (otherwise, 7 items): in Thieves' Town
|
|
||||||
Ice Palace - Boss: with Kholdstare
|
|
||||||
Ice Palace (otherwise, 7 items): in Ice Palace
|
|
||||||
Misery Mire - Boss: with Vitreous
|
|
||||||
Misery Mire (otherwise, 7 items): in Misery Mire
|
|
||||||
Turtle Rock - Boss: with Trinexx
|
|
||||||
Turtle Rock (otherwise, 11 items): in Turtle Rock
|
|
||||||
Ganons Tower (after climb, 4 items): atop Ganon's Tower
|
|
||||||
Ganon's Tower (otherwise, 23 items): in Ganon's Tower
|
|
||||||
@@ -20,24 +20,117 @@ from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus, JSONtoTextP
|
|||||||
|
|
||||||
from worlds.factorio.Technologies import lookup_id_to_name
|
from worlds.factorio.Technologies import lookup_id_to_name
|
||||||
|
|
||||||
rcon_port = 24242
|
os.makedirs("logs", exist_ok=True)
|
||||||
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
|
|
||||||
|
|
||||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
|
# Log to file in gui case
|
||||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
if getattr(sys, "frozen", False) and not "--nogui" in sys.argv:
|
||||||
options = Utils.get_options()
|
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
|
||||||
executable = options["factorio_options"]["executable"]
|
filename=os.path.join("logs", "FactorioClient.txt"), filemode="w")
|
||||||
bin_dir = os.path.dirname(executable)
|
else:
|
||||||
if not os.path.isdir(bin_dir):
|
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
|
||||||
raise FileNotFoundError(bin_dir)
|
logging.getLogger().addHandler(logging.FileHandler(os.path.join("logs", "FactorioClient.txt"), "w"))
|
||||||
if not os.path.exists(executable):
|
|
||||||
if os.path.exists(executable + ".exe"):
|
|
||||||
executable = executable + ".exe"
|
|
||||||
else:
|
|
||||||
raise FileNotFoundError(executable)
|
|
||||||
|
|
||||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *sys.argv[1:])
|
gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv
|
||||||
|
|
||||||
|
def get_kivy_app():
|
||||||
|
os.environ["KIVY_NO_CONSOLELOG"] = "1"
|
||||||
|
os.environ["KIVY_NO_FILELOG"] = "1"
|
||||||
|
os.environ["KIVY_NO_ARGS"] = "1"
|
||||||
|
from kivy.app import App
|
||||||
|
from kivy.base import ExceptionHandler, ExceptionManager, Config
|
||||||
|
from kivy.uix.gridlayout import GridLayout
|
||||||
|
from kivy.uix.textinput import TextInput
|
||||||
|
from kivy.uix.recycleview import RecycleView
|
||||||
|
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
||||||
|
from kivy.lang import Builder
|
||||||
|
|
||||||
|
|
||||||
|
class FactorioManager(App):
|
||||||
|
def __init__(self, ctx):
|
||||||
|
super(FactorioManager, self).__init__()
|
||||||
|
self.ctx = ctx
|
||||||
|
self.commandprocessor = ctx.command_processor(ctx)
|
||||||
|
self.icon = r"data/icon.png"
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
self.grid = GridLayout()
|
||||||
|
self.grid.cols = 1
|
||||||
|
|
||||||
|
self.tabs = TabbedPanel()
|
||||||
|
self.tabs.default_tab_text = "All"
|
||||||
|
self.title = "Archipelago Factorio Client"
|
||||||
|
pairs = [
|
||||||
|
("Client", "Archipelago"),
|
||||||
|
("FactorioServer", "Factorio Server Log"),
|
||||||
|
("FactorioWatcher", "Bridge Data Log"),
|
||||||
|
]
|
||||||
|
self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in pairs))
|
||||||
|
for logger_name, display_name in pairs:
|
||||||
|
bridge_logger = logging.getLogger(logger_name)
|
||||||
|
panel = TabbedPanelItem(text=display_name)
|
||||||
|
panel.content = UILog(bridge_logger)
|
||||||
|
self.tabs.add_widget(panel)
|
||||||
|
|
||||||
|
self.grid.add_widget(self.tabs)
|
||||||
|
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
|
||||||
|
textinput.bind(on_text_validate=self.on_message)
|
||||||
|
self.grid.add_widget(textinput)
|
||||||
|
self.commandprocessor("/help")
|
||||||
|
return self.grid
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
self.ctx.exit_event.set()
|
||||||
|
|
||||||
|
def on_message(self, textinput: TextInput):
|
||||||
|
try:
|
||||||
|
input_text = textinput.text.strip()
|
||||||
|
textinput.text = ""
|
||||||
|
|
||||||
|
if self.ctx.input_requests > 0:
|
||||||
|
self.ctx.input_requests -= 1
|
||||||
|
self.ctx.input_queue.put_nowait(input_text)
|
||||||
|
elif input_text:
|
||||||
|
self.commandprocessor(input_text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
def on_address(self, text: str):
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
|
||||||
|
class LogtoUI(logging.Handler):
|
||||||
|
def __init__(self, on_log):
|
||||||
|
super(LogtoUI, self).__init__(logging.DEBUG)
|
||||||
|
self.on_log = on_log
|
||||||
|
|
||||||
|
def handle(self, record: logging.LogRecord) -> None:
|
||||||
|
self.on_log(record)
|
||||||
|
|
||||||
|
|
||||||
|
class UILog(RecycleView):
|
||||||
|
cols = 1
|
||||||
|
|
||||||
|
def __init__(self, *loggers_to_handle, **kwargs):
|
||||||
|
super(UILog, self).__init__(**kwargs)
|
||||||
|
self.data = []
|
||||||
|
for logger in loggers_to_handle:
|
||||||
|
logger.addHandler(LogtoUI(self.on_log))
|
||||||
|
|
||||||
|
def on_log(self, record: logging.LogRecord) -> None:
|
||||||
|
self.data.append({"text": record.getMessage()})
|
||||||
|
|
||||||
|
|
||||||
|
class E(ExceptionHandler):
|
||||||
|
def handle_exception(self, inst):
|
||||||
|
logger.exception(inst)
|
||||||
|
return ExceptionManager.RAISE
|
||||||
|
|
||||||
|
|
||||||
|
ExceptionManager.add_handler(E())
|
||||||
|
|
||||||
|
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||||
|
Builder.load_file(Utils.local_path("data", "client.kv"))
|
||||||
|
return FactorioManager
|
||||||
|
|
||||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||||
ctx: FactorioContext
|
ctx: FactorioContext
|
||||||
@@ -56,7 +149,7 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
|||||||
"""Connect to a MultiWorld Server"""
|
"""Connect to a MultiWorld Server"""
|
||||||
if not self.ctx.auth:
|
if not self.ctx.auth:
|
||||||
if self.ctx.rcon_client:
|
if self.ctx.rcon_client:
|
||||||
get_info(self.ctx, self.ctx.rcon_client) # retrieve current auth code
|
get_info(self.ctx, self.ctx.rcon_client) # retrieve current auth code
|
||||||
else:
|
else:
|
||||||
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.")
|
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.")
|
||||||
return super(FactorioCommandProcessor, self)._cmd_connect(address)
|
return super(FactorioCommandProcessor, self)._cmd_connect(address)
|
||||||
@@ -64,9 +157,10 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
|||||||
|
|
||||||
class FactorioContext(CommonContext):
|
class FactorioContext(CommonContext):
|
||||||
command_processor = FactorioCommandProcessor
|
command_processor = FactorioCommandProcessor
|
||||||
|
game = "Factorio"
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, server_address, password):
|
||||||
super(FactorioContext, self).__init__(*args, **kwargs)
|
super(FactorioContext, self).__init__(server_address, password)
|
||||||
self.send_index = 0
|
self.send_index = 0
|
||||||
self.rcon_client = None
|
self.rcon_client = None
|
||||||
self.awaiting_bridge = False
|
self.awaiting_bridge = False
|
||||||
@@ -91,8 +185,6 @@ class FactorioContext(CommonContext):
|
|||||||
f"{cleaned_text}\")")
|
f"{cleaned_text}\")")
|
||||||
|
|
||||||
def on_print_json(self, args: dict):
|
def on_print_json(self, args: dict):
|
||||||
if not self.found_items and args.get("type", None) == "ItemSend" and args["receiving"] == args["sending"]:
|
|
||||||
pass # don't want info on other player's local pickups.
|
|
||||||
text = self.raw_json_text_parser(copy.deepcopy(args["data"]))
|
text = self.raw_json_text_parser(copy.deepcopy(args["data"]))
|
||||||
logger.info(text)
|
logger.info(text)
|
||||||
if self.rcon_client:
|
if self.rcon_client:
|
||||||
@@ -117,7 +209,8 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
if data["slot_name"] != ctx.auth:
|
if data["slot_name"] != ctx.auth:
|
||||||
logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
||||||
elif data["seed_name"] != ctx.seed_name:
|
elif data["seed_name"] != ctx.seed_name:
|
||||||
logger.warning(f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
logger.warning(
|
||||||
|
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||||
else:
|
else:
|
||||||
data = data["info"]
|
data = data["info"]
|
||||||
research_data = data["research_done"]
|
research_data = data["research_done"]
|
||||||
@@ -160,7 +253,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
|||||||
if not os.path.exists(savegame_name):
|
if not os.path.exists(savegame_name):
|
||||||
logger.info(f"Creating savegame {savegame_name}")
|
logger.info(f"Creating savegame {savegame_name}")
|
||||||
subprocess.run((
|
subprocess.run((
|
||||||
executable, "--create", savegame_name
|
executable, "--create", savegame_name, "--preset", "archipelago"
|
||||||
))
|
))
|
||||||
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
|
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
|
||||||
*(str(elem) for elem in server_args)),
|
*(str(elem) for elem in server_args)),
|
||||||
@@ -210,6 +303,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
factorio_process.terminate()
|
factorio_process.terminate()
|
||||||
|
factorio_process.wait(5)
|
||||||
|
|
||||||
|
|
||||||
def get_info(ctx, rcon_client):
|
def get_info(ctx, rcon_client):
|
||||||
@@ -223,7 +317,7 @@ async def factorio_spinup_server(ctx: FactorioContext):
|
|||||||
if not os.path.exists(savegame_name):
|
if not os.path.exists(savegame_name):
|
||||||
logger.info(f"Creating savegame {savegame_name}")
|
logger.info(f"Creating savegame {savegame_name}")
|
||||||
subprocess.run((
|
subprocess.run((
|
||||||
executable, "--create", savegame_name, "--preset", "archipelago"
|
executable, "--create", savegame_name
|
||||||
))
|
))
|
||||||
factorio_process = subprocess.Popen(
|
factorio_process = subprocess.Popen(
|
||||||
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
||||||
@@ -245,7 +339,6 @@ async def factorio_spinup_server(ctx: FactorioContext):
|
|||||||
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||||
get_info(ctx, rcon_client)
|
get_info(ctx, rcon_client)
|
||||||
|
|
||||||
|
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -258,14 +351,15 @@ async def factorio_spinup_server(ctx: FactorioContext):
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
factorio_process.terminate()
|
factorio_process.terminate()
|
||||||
|
factorio_process.wait(5)
|
||||||
|
|
||||||
|
|
||||||
async def main(ui=None):
|
async def main(args):
|
||||||
ctx = FactorioContext(None, None, True)
|
ctx = FactorioContext(args.connect, args.password)
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||||
if ui:
|
if gui_enabled:
|
||||||
input_task = None
|
input_task = None
|
||||||
ui_app = ui(ctx)
|
ui_app = get_kivy_app()(ctx)
|
||||||
ui_task = asyncio.create_task(ui_app.async_run(), name="UI")
|
ui_task = asyncio.create_task(ui_app.async_run(), name="UI")
|
||||||
else:
|
else:
|
||||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||||
@@ -313,8 +407,40 @@ class FactorioJSONtoTextParser(JSONtoTextParser):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Optional arguments to FactorioClient follow. "
|
||||||
|
"Remaining arguments get passed into bound Factorio instance."
|
||||||
|
"Refer to factorio --help for those.")
|
||||||
|
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||||
|
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||||
|
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||||
|
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
|
||||||
|
if not Utils.is_frozen(): # Frozen state has no cmd window in the first place
|
||||||
|
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
|
||||||
|
|
||||||
|
args, rest = parser.parse_known_args()
|
||||||
colorama.init()
|
colorama.init()
|
||||||
|
rcon_port = args.rcon_port
|
||||||
|
rcon_password = args.rcon_password if args.rcon_password else ''.join(random.choice(string.ascii_letters) for x in range(32))
|
||||||
|
|
||||||
|
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||||
|
options = Utils.get_options()
|
||||||
|
executable = options["factorio_options"]["executable"]
|
||||||
|
bin_dir = os.path.dirname(executable)
|
||||||
|
if not os.path.exists(bin_dir):
|
||||||
|
raise FileNotFoundError(f"Path {bin_dir} does not exist or could not be accessed.")
|
||||||
|
if not os.path.isdir(bin_dir):
|
||||||
|
raise FileNotFoundError(f"Path {bin_dir} is not a directory.")
|
||||||
|
if not os.path.exists(executable):
|
||||||
|
if os.path.exists(executable + ".exe"):
|
||||||
|
executable = executable + ".exe"
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||||
|
|
||||||
|
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
loop.run_until_complete(main())
|
loop.run_until_complete(main(args))
|
||||||
loop.close()
|
loop.close()
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import os
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
os.makedirs("logs", exist_ok=True)
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
|
|
||||||
filename=os.path.join("logs", "FactorioClient.txt"), filemode="w")
|
|
||||||
else:
|
|
||||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
|
|
||||||
logging.getLogger().addHandler(logging.FileHandler(os.path.join("logs", "FactorioClient.txt"), "w"))
|
|
||||||
os.environ["KIVY_NO_CONSOLELOG"] = "1"
|
|
||||||
os.environ["KIVY_NO_FILELOG"] = "1"
|
|
||||||
os.environ["KIVY_NO_ARGS"] = "1"
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from CommonClient import logger
|
|
||||||
from FactorioClient import main
|
|
||||||
|
|
||||||
|
|
||||||
from kivy.app import App
|
|
||||||
from kivy.uix.label import Label
|
|
||||||
from kivy.base import ExceptionHandler, ExceptionManager, Config
|
|
||||||
from kivy.uix.gridlayout import GridLayout
|
|
||||||
from kivy.uix.textinput import TextInput
|
|
||||||
from kivy.uix.recycleview import RecycleView
|
|
||||||
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
|
||||||
from kivy.lang import Builder
|
|
||||||
|
|
||||||
|
|
||||||
class FactorioManager(App):
|
|
||||||
def __init__(self, ctx):
|
|
||||||
super(FactorioManager, self).__init__()
|
|
||||||
self.ctx = ctx
|
|
||||||
self.commandprocessor = ctx.command_processor(ctx)
|
|
||||||
self.icon = r"data/icon.png"
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
self.grid = GridLayout()
|
|
||||||
self.grid.cols = 1
|
|
||||||
self.tabs = TabbedPanel()
|
|
||||||
self.tabs.default_tab_text = "All"
|
|
||||||
self.title = "Archipelago Factorio Client"
|
|
||||||
pairs = [
|
|
||||||
("Client", "Archipelago"),
|
|
||||||
("FactorioServer", "Factorio Server Log"),
|
|
||||||
("FactorioWatcher", "Bridge Data Log"),
|
|
||||||
]
|
|
||||||
self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in pairs))
|
|
||||||
for logger_name, display_name in pairs:
|
|
||||||
bridge_logger = logging.getLogger(logger_name)
|
|
||||||
panel = TabbedPanelItem(text=display_name)
|
|
||||||
panel.content = UILog(bridge_logger)
|
|
||||||
self.tabs.add_widget(panel)
|
|
||||||
|
|
||||||
self.grid.add_widget(self.tabs)
|
|
||||||
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
|
|
||||||
textinput.bind(on_text_validate=self.on_message)
|
|
||||||
self.grid.add_widget(textinput)
|
|
||||||
self.commandprocessor("/help")
|
|
||||||
return self.grid
|
|
||||||
|
|
||||||
def on_stop(self):
|
|
||||||
self.ctx.exit_event.set()
|
|
||||||
|
|
||||||
def on_message(self, textinput: TextInput):
|
|
||||||
try:
|
|
||||||
input_text = textinput.text.strip()
|
|
||||||
textinput.text = ""
|
|
||||||
|
|
||||||
if self.ctx.input_requests > 0:
|
|
||||||
self.ctx.input_requests -= 1
|
|
||||||
self.ctx.input_queue.put_nowait(input_text)
|
|
||||||
elif input_text:
|
|
||||||
self.commandprocessor(input_text)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(e)
|
|
||||||
|
|
||||||
|
|
||||||
class LogtoUI(logging.Handler):
|
|
||||||
def __init__(self, on_log):
|
|
||||||
super(LogtoUI, self).__init__(logging.DEBUG)
|
|
||||||
self.on_log = on_log
|
|
||||||
|
|
||||||
def handle(self, record: logging.LogRecord) -> None:
|
|
||||||
self.on_log(record)
|
|
||||||
|
|
||||||
|
|
||||||
class UILog(RecycleView):
|
|
||||||
cols = 1
|
|
||||||
|
|
||||||
def __init__(self, *loggers_to_handle, **kwargs):
|
|
||||||
super(UILog, self).__init__(**kwargs)
|
|
||||||
self.data = []
|
|
||||||
for logger in loggers_to_handle:
|
|
||||||
logger.addHandler(LogtoUI(self.on_log))
|
|
||||||
|
|
||||||
def on_log(self, record: logging.LogRecord) -> None:
|
|
||||||
self.data.append({"text": record.getMessage()})
|
|
||||||
|
|
||||||
|
|
||||||
class E(ExceptionHandler):
|
|
||||||
def handle_exception(self, inst):
|
|
||||||
logger.exception(inst)
|
|
||||||
return ExceptionManager.RAISE
|
|
||||||
|
|
||||||
ExceptionManager.add_handler(E())
|
|
||||||
|
|
||||||
|
|
||||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
|
||||||
Builder.load_string('''
|
|
||||||
<TabbedPanel>
|
|
||||||
tab_width: 200
|
|
||||||
<Row@Label>:
|
|
||||||
canvas.before:
|
|
||||||
Color:
|
|
||||||
rgba: 0.2, 0.2, 0.2, 1
|
|
||||||
Rectangle:
|
|
||||||
size: self.size
|
|
||||||
pos: self.pos
|
|
||||||
text_size: self.width, None
|
|
||||||
size_hint_y: None
|
|
||||||
height: self.texture_size[1]
|
|
||||||
font_size: dp(20)
|
|
||||||
<UILog>:
|
|
||||||
viewclass: 'Row'
|
|
||||||
scroll_y: 0
|
|
||||||
effect_cls: "ScrollEffect"
|
|
||||||
RecycleBoxLayout:
|
|
||||||
default_size: None, dp(20)
|
|
||||||
default_size_hint: 1, None
|
|
||||||
size_hint_y: None
|
|
||||||
height: self.minimum_height
|
|
||||||
orientation: 'vertical'
|
|
||||||
spacing: dp(3)
|
|
||||||
''')
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
ui_app = FactorioManager
|
|
||||||
loop.run_until_complete(main(ui_app))
|
|
||||||
loop.close()
|
|
||||||
15
Fill.py
@@ -3,9 +3,10 @@ import typing
|
|||||||
import collections
|
import collections
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
from BaseClasses import CollectionState, PlandoItem, Location, MultiWorld
|
from BaseClasses import CollectionState, Location, MultiWorld
|
||||||
from worlds.alttp.Items import ItemFactory
|
from worlds.alttp.Items import ItemFactory
|
||||||
from worlds.alttp.Regions import key_drop_data
|
from worlds.alttp.Regions import key_drop_data
|
||||||
|
from worlds.generic import PlandoItem
|
||||||
|
|
||||||
|
|
||||||
class FillError(RuntimeError):
|
class FillError(RuntimeError):
|
||||||
@@ -77,12 +78,15 @@ def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_lo
|
|||||||
# get items to distribute
|
# get items to distribute
|
||||||
world.random.shuffle(world.itempool)
|
world.random.shuffle(world.itempool)
|
||||||
progitempool = []
|
progitempool = []
|
||||||
|
nonexcludeditempool = []
|
||||||
localrestitempool = {player: [] for player in range(1, world.players + 1)}
|
localrestitempool = {player: [] for player in range(1, world.players + 1)}
|
||||||
restitempool = []
|
restitempool = []
|
||||||
|
|
||||||
for item in world.itempool:
|
for item in world.itempool:
|
||||||
if item.advancement:
|
if item.advancement:
|
||||||
progitempool.append(item)
|
progitempool.append(item)
|
||||||
|
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
|
||||||
|
nonexcludeditempool.append(item)
|
||||||
elif item.name in world.local_items[item.player]:
|
elif item.name in world.local_items[item.player]:
|
||||||
localrestitempool[item.player].append(item)
|
localrestitempool[item.player].append(item)
|
||||||
else:
|
else:
|
||||||
@@ -91,7 +95,7 @@ def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_lo
|
|||||||
standard_keyshuffle_players = set()
|
standard_keyshuffle_players = set()
|
||||||
|
|
||||||
# fill in gtower locations with trash first
|
# fill in gtower locations with trash first
|
||||||
for player in world.alttp_player_ids:
|
for player in world.get_game_players("A Link to the Past"):
|
||||||
if not gftower_trash or not world.ganonstower_vanilla[player] or \
|
if not gftower_trash or not world.ganonstower_vanilla[player] or \
|
||||||
world.logic[player] in {'owglitches', 'hybridglitches', "nologic"}:
|
world.logic[player] in {'owglitches', 'hybridglitches', "nologic"}:
|
||||||
gtower_trash_count = 0
|
gtower_trash_count = 0
|
||||||
@@ -136,6 +140,10 @@ def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_lo
|
|||||||
world.random.shuffle(fill_locations)
|
world.random.shuffle(fill_locations)
|
||||||
fill_restrictive(world, world.state, fill_locations, progitempool)
|
fill_restrictive(world, world.state, fill_locations, progitempool)
|
||||||
|
|
||||||
|
if nonexcludeditempool:
|
||||||
|
world.random.shuffle(fill_locations)
|
||||||
|
fill_restrictive(world, world.state, fill_locations, nonexcludeditempool) # needs logical fill to not conflict with local items
|
||||||
|
|
||||||
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
||||||
local_locations = {player: [] for player in world.player_ids}
|
local_locations = {player: [] for player in world.player_ids}
|
||||||
for location in fill_locations:
|
for location in fill_locations:
|
||||||
@@ -347,7 +355,8 @@ def balance_multiworld_progression(world: MultiWorld):
|
|||||||
if world.has_beaten_game(state):
|
if world.has_beaten_game(state):
|
||||||
break
|
break
|
||||||
elif not sphere_locations:
|
elif not sphere_locations:
|
||||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
logging.warning("Progression Balancing ran out of paths.")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
|
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
|
||||||
|
|||||||
@@ -9,17 +9,16 @@ from collections import Counter
|
|||||||
import string
|
import string
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
from worlds.alttp import Options as LttPOptions
|
|
||||||
from worlds.generic import PlandoItem, PlandoConnection
|
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
from Utils import parse_yaml, version_tuple, __version__, tuplize_version
|
from worlds.alttp import Options as LttPOptions
|
||||||
|
from worlds.generic import PlandoItem, PlandoConnection
|
||||||
|
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Main import get_seed, seeddigits
|
from Main import get_seed, seeddigits
|
||||||
import Options
|
import Options
|
||||||
from worlds import lookup_any_item_name_to_id
|
|
||||||
from worlds.alttp.Items import item_name_groups, item_table
|
from worlds.alttp.Items import item_name_groups, item_table
|
||||||
from worlds.alttp import Bosses
|
from worlds.alttp import Bosses
|
||||||
from worlds.alttp.Text import TextTable
|
from worlds.alttp.Text import TextTable
|
||||||
@@ -29,40 +28,38 @@ from worlds.AutoWorld import AutoWorldRegister
|
|||||||
categories = set(AutoWorldRegister.world_types)
|
categories = set(AutoWorldRegister.world_types)
|
||||||
|
|
||||||
def mystery_argparse():
|
def mystery_argparse():
|
||||||
parser = argparse.ArgumentParser(add_help=False)
|
options = get_options()
|
||||||
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
|
defaults = options["generator"]
|
||||||
multiargs, _ = parser.parse_known_args()
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||||
parser.add_argument('--weights',
|
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
|
||||||
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||||
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||||
action='store_true')
|
action='store_true')
|
||||||
|
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
|
||||||
|
help="Input directory for player files.")
|
||||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||||
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
|
parser.add_argument('--multi', default=defaults["players"], type=lambda value: min(max(int(value), 1), 255))
|
||||||
parser.add_argument('--teams', default=1, type=lambda value: max(int(value), 1))
|
parser.add_argument('--teams', default=1, type=lambda value: max(int(value), 1))
|
||||||
parser.add_argument('--create_spoiler', action='store_true')
|
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||||
parser.add_argument('--skip_playthrough', action='store_true')
|
parser.add_argument('--rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
|
||||||
parser.add_argument('--pre_roll', action='store_true')
|
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
|
||||||
parser.add_argument('--rom')
|
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
|
||||||
parser.add_argument('--enemizercli')
|
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||||
parser.add_argument('--outputpath')
|
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
|
||||||
parser.add_argument('--glitch_triforce', action='store_true')
|
|
||||||
parser.add_argument('--race', action='store_true')
|
|
||||||
parser.add_argument('--meta', default=None)
|
|
||||||
parser.add_argument('--log_output_path', help='Path to store output log')
|
parser.add_argument('--log_output_path', help='Path to store output log')
|
||||||
parser.add_argument('--loglevel', default='info', help='Sets log level')
|
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||||
parser.add_argument('--create_diff', action="store_true")
|
|
||||||
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
|
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
|
||||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||||
parser.add_argument('--plando', default="bosses",
|
parser.add_argument('--plando', default=defaults["plando_options"],
|
||||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||||
parser.add_argument('--seed_name')
|
|
||||||
for player in range(1, multiargs.multi + 1):
|
|
||||||
parser.add_argument(f'--p{player}', help=argparse.SUPPRESS)
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if not os.path.isabs(args.weights_file_path):
|
||||||
|
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||||
|
if not os.path.isabs(args.meta_file_path):
|
||||||
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||||
return args
|
return args, options
|
||||||
|
|
||||||
|
|
||||||
def get_seed_name(random):
|
def get_seed_name(random):
|
||||||
@@ -71,106 +68,93 @@ def get_seed_name(random):
|
|||||||
|
|
||||||
def main(args=None, callback=ERmain):
|
def main(args=None, callback=ERmain):
|
||||||
if not args:
|
if not args:
|
||||||
args = mystery_argparse()
|
args, options = mystery_argparse()
|
||||||
|
|
||||||
seed = get_seed(args.seed)
|
seed = get_seed(args.seed)
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
seed_name = args.seed_name if args.seed_name else get_seed_name(random)
|
seed_name = get_seed_name(random)
|
||||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed}")
|
|
||||||
|
|
||||||
if args.race:
|
if args.race:
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # reset to time-based random source
|
||||||
|
|
||||||
weights_cache = {}
|
weights_cache = {}
|
||||||
if args.weights:
|
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||||
try:
|
try:
|
||||||
weights_cache[args.weights] = read_weights_yaml(args.weights)
|
weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {args.weights} is destroyed. Please fix your yaml.") from e
|
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||||
print(f"Weights: {args.weights} >> "
|
print(f"Weights: {args.weights_file_path} >> "
|
||||||
f"{get_choice('description', weights_cache[args.weights], 'No description specified')}")
|
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
|
||||||
if args.meta:
|
|
||||||
|
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||||
try:
|
try:
|
||||||
weights_cache[args.meta] = read_weights_yaml(args.meta)
|
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {args.meta} is destroyed. Please fix your yaml.") from e
|
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||||
meta_weights = weights_cache[args.meta]
|
meta_weights = weights_cache[args.meta_file_path]
|
||||||
print(f"Meta: {args.meta} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
|
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
|
||||||
if args.samesettings:
|
if args.samesettings:
|
||||||
raise Exception("Cannot mix --samesettings with --meta")
|
raise Exception("Cannot mix --samesettings with --meta")
|
||||||
|
else:
|
||||||
for player in range(1, args.multi + 1):
|
meta_weights = None
|
||||||
path = getattr(args, f'p{player}')
|
player_id = 1
|
||||||
if path:
|
player_files = {}
|
||||||
|
for file in os.scandir(args.player_files_path):
|
||||||
|
fname = file.name
|
||||||
|
if file.is_file() and os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||||
|
path = os.path.join(args.player_files_path, fname)
|
||||||
try:
|
try:
|
||||||
if path not in weights_cache:
|
weights_cache[fname] = read_weights_yaml(path)
|
||||||
weights_cache[path] = read_weights_yaml(path)
|
|
||||||
print(f"P{player} Weights: {path} >> "
|
|
||||||
f"{get_choice('description', weights_cache[path], 'No description specified')}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
|
||||||
|
else:
|
||||||
|
print(f"P{player_id} Weights: {fname} >> "
|
||||||
|
f"{get_choice('description', weights_cache[fname], 'No description specified')}")
|
||||||
|
player_files[player_id] = fname
|
||||||
|
player_id += 1
|
||||||
|
|
||||||
|
args.multi = max(player_id-1, args.multi)
|
||||||
|
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed}")
|
||||||
|
|
||||||
|
if not weights_cache:
|
||||||
|
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||||
|
f"A mix is also permitted.")
|
||||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||||
erargs.seed = seed
|
erargs.seed = seed
|
||||||
erargs.name = {x: "" for x in range(1, args.multi + 1)} # only so it can be overwrittin in mystery
|
erargs.name = {x: "" for x in range(1, args.multi + 1)} # only so it can be overwrittin in mystery
|
||||||
erargs.create_spoiler = args.create_spoiler
|
erargs.create_spoiler = args.spoiler > 0
|
||||||
erargs.create_diff = args.create_diff
|
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||||
erargs.glitch_triforce = args.glitch_triforce
|
|
||||||
erargs.race = args.race
|
erargs.race = args.race
|
||||||
erargs.skip_playthrough = args.skip_playthrough
|
erargs.skip_playthrough = args.spoiler < 2
|
||||||
erargs.outputname = seed_name
|
erargs.outputname = seed_name
|
||||||
erargs.outputpath = args.outputpath
|
erargs.outputpath = args.outputpath
|
||||||
erargs.teams = args.teams
|
erargs.teams = args.teams
|
||||||
|
|
||||||
# set up logger
|
# set up logger
|
||||||
if args.loglevel:
|
if args.log_level:
|
||||||
erargs.loglevel = args.loglevel
|
erargs.loglevel = args.log_level
|
||||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||||
erargs.loglevel]
|
erargs.loglevel]
|
||||||
|
|
||||||
if args.log_output_path:
|
if args.log_output_path:
|
||||||
import sys
|
|
||||||
class LoggerWriter(object):
|
|
||||||
def __init__(self, writer):
|
|
||||||
self._writer = writer
|
|
||||||
self._msg = ''
|
|
||||||
|
|
||||||
def write(self, message):
|
|
||||||
self._msg = self._msg + message
|
|
||||||
while '\n' in self._msg:
|
|
||||||
pos = self._msg.find('\n')
|
|
||||||
self._writer(self._msg[:pos])
|
|
||||||
self._msg = self._msg[pos + 1:]
|
|
||||||
|
|
||||||
def flush(self):
|
|
||||||
if self._msg != '':
|
|
||||||
self._writer(self._msg)
|
|
||||||
self._msg = ''
|
|
||||||
|
|
||||||
log = logging.getLogger("stderr")
|
|
||||||
log.addHandler(logging.StreamHandler())
|
|
||||||
sys.stderr = LoggerWriter(log.error)
|
|
||||||
os.makedirs(args.log_output_path, exist_ok=True)
|
os.makedirs(args.log_output_path, exist_ok=True)
|
||||||
logging.basicConfig(format='%(message)s', level=loglevel,
|
logging.basicConfig(format='%(message)s', level=loglevel, force=True,
|
||||||
filename=os.path.join(args.log_output_path, f"{seed}.log"))
|
filename=os.path.join(args.log_output_path, f"{seed}.log"))
|
||||||
else:
|
else:
|
||||||
logging.basicConfig(format='%(message)s', level=loglevel)
|
logging.basicConfig(format='%(message)s', level=loglevel, force=True)
|
||||||
if args.rom:
|
|
||||||
erargs.rom = args.rom
|
|
||||||
|
|
||||||
if args.enemizercli:
|
erargs.rom = args.rom
|
||||||
erargs.enemizercli = args.enemizercli
|
erargs.enemizercli = args.enemizercli
|
||||||
|
|
||||||
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
||||||
for k, v in weights_cache.items()}
|
for k, v in weights_cache.items()}
|
||||||
player_path_cache = {}
|
player_path_cache = {}
|
||||||
for player in range(1, args.multi + 1):
|
for player in range(1, args.multi + 1):
|
||||||
player_path_cache[player] = getattr(args, f'p{player}') if getattr(args, f'p{player}') else args.weights
|
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||||
|
|
||||||
if args.meta:
|
if meta_weights:
|
||||||
for player, path in player_path_cache.items():
|
for player, path in player_path_cache.items():
|
||||||
weights_cache[path].setdefault("meta_ignore", [])
|
weights_cache[path].setdefault("meta_ignore", [])
|
||||||
meta_weights = weights_cache[args.meta]
|
|
||||||
for key in meta_weights:
|
for key in meta_weights:
|
||||||
option = get_choice(key, meta_weights)
|
option = get_choice(key, meta_weights)
|
||||||
if option is not None:
|
if option is not None:
|
||||||
@@ -189,31 +173,6 @@ def main(args=None, callback=ERmain):
|
|||||||
try:
|
try:
|
||||||
settings = settings_cache[path] if settings_cache[path] else \
|
settings = settings_cache[path] if settings_cache[path] else \
|
||||||
roll_settings(weights_cache[path], args.plando)
|
roll_settings(weights_cache[path], args.plando)
|
||||||
if args.pre_roll:
|
|
||||||
import yaml
|
|
||||||
if path == args.weights:
|
|
||||||
settings.name = f"Player{player}"
|
|
||||||
elif not settings.name:
|
|
||||||
settings.name = os.path.splitext(os.path.split(path)[-1])[0]
|
|
||||||
|
|
||||||
if "-" not in settings.shuffle and settings.shuffle != "vanilla":
|
|
||||||
settings.shuffle += f"-{random.randint(0, 2 ** 64)}"
|
|
||||||
|
|
||||||
pre_rolled = dict()
|
|
||||||
pre_rolled["original_seed_number"] = seed
|
|
||||||
pre_rolled["original_seed_name"] = seed_name
|
|
||||||
pre_rolled["pre_rolled"] = vars(settings).copy()
|
|
||||||
if "plando_items" in pre_rolled["pre_rolled"]:
|
|
||||||
pre_rolled["pre_rolled"]["plando_items"] = [item.to_dict() for item in
|
|
||||||
pre_rolled["pre_rolled"]["plando_items"]]
|
|
||||||
if "plando_connections" in pre_rolled["pre_rolled"]:
|
|
||||||
pre_rolled["pre_rolled"]["plando_connections"] = [connection.to_dict() for connection in
|
|
||||||
pre_rolled["pre_rolled"][
|
|
||||||
"plando_connections"]]
|
|
||||||
|
|
||||||
with open(os.path.join(args.outputpath if args.outputpath else ".",
|
|
||||||
f"{os.path.split(path)[-1].split('.')[0]}_pre_rolled.yaml"), "wt") as f:
|
|
||||||
yaml.dump(pre_rolled, f)
|
|
||||||
for k, v in vars(settings).items():
|
for k, v in vars(settings).items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
try:
|
try:
|
||||||
@@ -224,7 +183,7 @@ def main(args=None, callback=ERmain):
|
|||||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
if path == args.weights: # if name came from the weights file, just use base player name
|
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||||
erargs.name[player] = f"Player{player}"
|
erargs.name[player] = f"Player{player}"
|
||||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
@@ -253,7 +212,7 @@ def main(args=None, callback=ERmain):
|
|||||||
logging.debug(f"No player settings defined for option '{option}'")
|
logging.debug(f"No player settings defined for option '{option}'")
|
||||||
if args.outputpath:
|
if args.outputpath:
|
||||||
os.makedirs(args.outputpath, exist_ok=True)
|
os.makedirs(args.outputpath, exist_ok=True)
|
||||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"mystery_result_{seed}.yaml"), "wt") as f:
|
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||||
yaml.dump(important, f)
|
yaml.dump(important, f)
|
||||||
|
|
||||||
callback(erargs, seed)
|
callback(erargs, seed)
|
||||||
@@ -454,37 +413,6 @@ def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str
|
|||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
|
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
|
||||||
if "pre_rolled" in weights:
|
|
||||||
pre_rolled = weights["pre_rolled"]
|
|
||||||
|
|
||||||
if "plando_items" in pre_rolled:
|
|
||||||
pre_rolled["plando_items"] = [PlandoItem(item["item"],
|
|
||||||
item["location"],
|
|
||||||
item["world"],
|
|
||||||
item["from_pool"],
|
|
||||||
item["force"]) for item in pre_rolled["plando_items"]]
|
|
||||||
if "items" not in plando_options and pre_rolled["plando_items"]:
|
|
||||||
raise Exception("Item Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
|
||||||
|
|
||||||
if "plando_connections" in pre_rolled:
|
|
||||||
pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"],
|
|
||||||
connection["exit"],
|
|
||||||
connection["direction"]) for connection in
|
|
||||||
pre_rolled["plando_connections"]]
|
|
||||||
if "connections" not in plando_options and pre_rolled["plando_connections"]:
|
|
||||||
raise Exception("Connection Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
|
||||||
|
|
||||||
if "bosses" not in plando_options:
|
|
||||||
try:
|
|
||||||
pre_rolled["shufflebosses"] = get_plando_bosses(pre_rolled["shufflebosses"], plando_options)
|
|
||||||
except Exception as ex:
|
|
||||||
raise Exception("Boss Plando is turned off. Reusing this pre-rolled setting not permitted.") from ex
|
|
||||||
|
|
||||||
if pre_rolled.get("plando_texts") and "texts" not in plando_options:
|
|
||||||
raise Exception("Text Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
|
||||||
|
|
||||||
return argparse.Namespace(**pre_rolled)
|
|
||||||
|
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
weights = roll_linked_options(weights)
|
weights = roll_linked_options(weights)
|
||||||
|
|
||||||
@@ -516,21 +444,22 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
|||||||
ret.game = get_choice("game", weights)
|
ret.game = get_choice("game", weights)
|
||||||
if ret.game not in weights:
|
if ret.game not in weights:
|
||||||
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
||||||
|
world_type = AutoWorldRegister.world_types[ret.game]
|
||||||
game_weights = weights[ret.game]
|
game_weights = weights[ret.game]
|
||||||
ret.local_items = set()
|
ret.local_items = set()
|
||||||
for item_name in game_weights.get('local_items', []):
|
for item_name in game_weights.get('local_items', []):
|
||||||
items = item_name_groups.get(item_name, {item_name})
|
items = world_type.item_name_groups.get(item_name, {item_name})
|
||||||
for item in items:
|
for item in items:
|
||||||
if item in lookup_any_item_name_to_id:
|
if item in world_type.item_names:
|
||||||
ret.local_items.add(item)
|
ret.local_items.add(item)
|
||||||
else:
|
else:
|
||||||
raise Exception(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.non_local_items = set()
|
ret.non_local_items = set()
|
||||||
for item_name in game_weights.get('non_local_items', []):
|
for item_name in game_weights.get('non_local_items', []):
|
||||||
items = item_name_groups.get(item_name, {item_name})
|
items = world_type.item_name_groups.get(item_name, {item_name})
|
||||||
for item in items:
|
for item in items:
|
||||||
if item in lookup_any_item_name_to_id:
|
if item in world_type.item_names:
|
||||||
ret.non_local_items.add(item)
|
ret.non_local_items.add(item)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
|
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
|
||||||
@@ -547,6 +476,13 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
|||||||
ret.startinventory = startitems
|
ret.startinventory = startitems
|
||||||
ret.start_hints = set(game_weights.get('start_hints', []))
|
ret.start_hints = set(game_weights.get('start_hints', []))
|
||||||
|
|
||||||
|
ret.excluded_locations = set()
|
||||||
|
for location in game_weights.get('exclude_locations', []):
|
||||||
|
if location in world_type.location_names:
|
||||||
|
ret.excluded_locations.add(location)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Could not exclude location {location}, as it was not recognized.")
|
||||||
|
|
||||||
if ret.game in AutoWorldRegister.world_types:
|
if ret.game in AutoWorldRegister.world_types:
|
||||||
for option_name, option in AutoWorldRegister.world_types[ret.game].options.items():
|
for option_name, option in AutoWorldRegister.world_types[ret.game].options.items():
|
||||||
if option_name in game_weights:
|
if option_name in game_weights:
|
||||||
@@ -556,7 +492,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
|||||||
else:
|
else:
|
||||||
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
|
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error generating option {option_name} in {ret.game}")
|
raise Exception(f"Error generating option {option_name} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
setattr(ret, option_name, option(option.default))
|
setattr(ret, option_name, option(option.default))
|
||||||
if ret.game == "Minecraft":
|
if ret.game == "Minecraft":
|
||||||
@@ -778,7 +714,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
get_choice("direction", placement, "both")
|
get_choice("direction", placement, "both")
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||||
ret.sprite = get_choice('sprite', weights, "Link")
|
ret.sprite = get_choice('sprite', weights, "Link")
|
||||||
if 'random_sprite_on_event' in weights:
|
if 'random_sprite_on_event' in weights:
|
||||||
192
GuiUtils.py
@@ -1,192 +0,0 @@
|
|||||||
import queue
|
|
||||||
import threading
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
from Utils import local_path
|
|
||||||
|
|
||||||
def set_icon(window):
|
|
||||||
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
|
|
||||||
window.tk.call('wm', 'iconphoto', window._w, logo)
|
|
||||||
|
|
||||||
# Although tkinter is intended to be thread safe, there are many reports of issues
|
|
||||||
# some which may be platform specific, or depend on if the TCL library was compiled without
|
|
||||||
# multithreading support. Therefore I will assume it is not thread safe to avoid any possible problems
|
|
||||||
class BackgroundTask(object):
|
|
||||||
def __init__(self, window, code_to_run, *args):
|
|
||||||
self.window = window
|
|
||||||
self.queue = queue.Queue()
|
|
||||||
self.running = True
|
|
||||||
self.process_queue()
|
|
||||||
self.task = threading.Thread(target=code_to_run, args=(self, *args))
|
|
||||||
self.task.start()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
self.running = False
|
|
||||||
|
|
||||||
# safe to call from worker
|
|
||||||
def queue_event(self, event):
|
|
||||||
self.queue.put(event)
|
|
||||||
|
|
||||||
def process_queue(self):
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
if not self.running:
|
|
||||||
return
|
|
||||||
event = self.queue.get_nowait()
|
|
||||||
event()
|
|
||||||
if self.running:
|
|
||||||
#if self is no longer running self.window may no longer be valid
|
|
||||||
self.window.update_idletasks()
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
if self.running:
|
|
||||||
self.window.after(100, self.process_queue)
|
|
||||||
|
|
||||||
class BackgroundTaskProgress(BackgroundTask):
|
|
||||||
def __init__(self, parent, code_to_run, title, *args):
|
|
||||||
self.parent = parent
|
|
||||||
self.window = tk.Toplevel(parent)
|
|
||||||
self.window['padx'] = 5
|
|
||||||
self.window['pady'] = 5
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.window.attributes("-toolwindow", 1)
|
|
||||||
except tk.TclError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.window.wm_title(title)
|
|
||||||
self.label_var = tk.StringVar()
|
|
||||||
self.label_var.set("")
|
|
||||||
self.label = tk.Label(self.window, textvariable=self.label_var, width=50)
|
|
||||||
self.label.pack()
|
|
||||||
self.window.resizable(width=False, height=False)
|
|
||||||
|
|
||||||
set_icon(self.window)
|
|
||||||
self.window.focus()
|
|
||||||
super().__init__(self.window, code_to_run, *args)
|
|
||||||
|
|
||||||
#safe to call from worker thread
|
|
||||||
def update_status(self, text):
|
|
||||||
self.queue_event(lambda: self.label_var.set(text))
|
|
||||||
|
|
||||||
# only call this in an event callback
|
|
||||||
def close_window(self):
|
|
||||||
self.stop()
|
|
||||||
self.window.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTips(object):
|
|
||||||
# This class derived from wckToolTips which is available under the following license:
|
|
||||||
|
|
||||||
# Copyright (c) 1998-2007 by Secret Labs AB
|
|
||||||
# Copyright (c) 1998-2007 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# By obtaining, using, and/or copying this software and/or its
|
|
||||||
# associated documentation, you agree that you have read, understood,
|
|
||||||
# and will comply with the following terms and conditions:
|
|
||||||
#
|
|
||||||
# Permission to use, copy, modify, and distribute this software and its
|
|
||||||
# associated documentation for any purpose and without fee is hereby
|
|
||||||
# granted, provided that the above copyright notice appears in all
|
|
||||||
# copies, and that both that copyright notice and this permission notice
|
|
||||||
# appear in supporting documentation, and that the name of Secret Labs
|
|
||||||
# AB or the author not be used in advertising or publicity pertaining to
|
|
||||||
# distribution of the software without specific, written prior
|
|
||||||
# permission.
|
|
||||||
#
|
|
||||||
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
|
||||||
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
||||||
# FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR
|
|
||||||
# ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
||||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
||||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
|
||||||
# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
||||||
|
|
||||||
label = None
|
|
||||||
window = None
|
|
||||||
active = 0
|
|
||||||
tag = None
|
|
||||||
after_id = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def getcontroller(cls, widget):
|
|
||||||
if cls.tag is None:
|
|
||||||
|
|
||||||
cls.tag = "ui_tooltip_%d" % id(cls)
|
|
||||||
widget.bind_class(cls.tag, "<Enter>", cls.enter)
|
|
||||||
widget.bind_class(cls.tag, "<Leave>", cls.leave)
|
|
||||||
widget.bind_class(cls.tag, "<Motion>", cls.motion)
|
|
||||||
widget.bind_class(cls.tag, "<Destroy>", cls.leave)
|
|
||||||
|
|
||||||
# pick suitable colors for tooltips
|
|
||||||
try:
|
|
||||||
cls.bg = "systeminfobackground"
|
|
||||||
cls.fg = "systeminfotext"
|
|
||||||
widget.winfo_rgb(cls.fg) # make sure system colors exist
|
|
||||||
widget.winfo_rgb(cls.bg)
|
|
||||||
except Exception:
|
|
||||||
cls.bg = "#ffffe0"
|
|
||||||
cls.fg = "black"
|
|
||||||
|
|
||||||
return cls.tag
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def register(cls, widget, text):
|
|
||||||
widget.ui_tooltip_text = text
|
|
||||||
tags = list(widget.bindtags())
|
|
||||||
tags.append(cls.getcontroller(widget))
|
|
||||||
widget.bindtags(tuple(tags))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def unregister(cls, widget):
|
|
||||||
tags = list(widget.bindtags())
|
|
||||||
tags.remove(cls.getcontroller(widget))
|
|
||||||
widget.bindtags(tuple(tags))
|
|
||||||
|
|
||||||
# event handlers
|
|
||||||
@classmethod
|
|
||||||
def enter(cls, event):
|
|
||||||
widget = event.widget
|
|
||||||
if not cls.label:
|
|
||||||
# create and hide balloon help window
|
|
||||||
cls.popup = tk.Toplevel(bg=cls.fg, bd=1)
|
|
||||||
cls.popup.overrideredirect(1)
|
|
||||||
cls.popup.withdraw()
|
|
||||||
cls.label = tk.Label(
|
|
||||||
cls.popup, fg=cls.fg, bg=cls.bg, bd=0, padx=2, justify=tk.LEFT
|
|
||||||
)
|
|
||||||
cls.label.pack()
|
|
||||||
cls.active = 0
|
|
||||||
cls.xy = event.x_root + 16, event.y_root + 10
|
|
||||||
cls.event_xy = event.x, event.y
|
|
||||||
cls.after_id = widget.after(200, cls.display, widget)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def motion(cls, event):
|
|
||||||
cls.xy = event.x_root + 16, event.y_root + 10
|
|
||||||
cls.event_xy = event.x, event.y
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def display(cls, widget):
|
|
||||||
if not cls.active:
|
|
||||||
# display balloon help window
|
|
||||||
text = widget.ui_tooltip_text
|
|
||||||
if callable(text):
|
|
||||||
text = text(widget, cls.event_xy)
|
|
||||||
cls.label.config(text=text)
|
|
||||||
cls.popup.deiconify()
|
|
||||||
cls.popup.lift()
|
|
||||||
cls.popup.geometry("+%d+%d" % cls.xy)
|
|
||||||
cls.active = 1
|
|
||||||
cls.after_id = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def leave(cls, event):
|
|
||||||
widget = event.widget
|
|
||||||
if cls.active:
|
|
||||||
cls.popup.withdraw()
|
|
||||||
cls.active = 0
|
|
||||||
if cls.after_id:
|
|
||||||
widget.after_cancel(cls.after_id)
|
|
||||||
cls.after_id = None
|
|
||||||
875
LttPAdjuster.py
@@ -1,17 +1,26 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import queue
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
import textwrap
|
import textwrap
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from tkinter import Tk
|
import tkinter as tk
|
||||||
|
from argparse import Namespace
|
||||||
from Gui import update_sprites
|
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||||
from GuiUtils import BackgroundTaskProgress
|
from glob import glob
|
||||||
|
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, LEFT, X, TOP, LabelFrame, \
|
||||||
|
IntVar, Checkbutton, E, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings
|
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings
|
||||||
from Utils import output_path
|
from Utils import output_path, local_path, open_file
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
@@ -155,8 +164,6 @@ def adjust(args):
|
|||||||
def adjustGUI():
|
def adjustGUI():
|
||||||
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
||||||
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
||||||
from Gui import get_rom_options_frame, get_rom_frame
|
|
||||||
from GuiUtils import set_icon
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from Main import __version__ as MWVersion
|
from Main import __version__ as MWVersion
|
||||||
adjustWindow = Tk()
|
adjustWindow = Tk()
|
||||||
@@ -239,5 +246,859 @@ def run_sprite_update():
|
|||||||
print("Done updating sprites")
|
print("Done updating sprites")
|
||||||
|
|
||||||
|
|
||||||
|
def update_sprites(task, on_finish=None):
|
||||||
|
resultmessage = ""
|
||||||
|
successful = True
|
||||||
|
sprite_dir = local_path("data", "sprites", "alttpr")
|
||||||
|
os.makedirs(sprite_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def finished():
|
||||||
|
task.close_window()
|
||||||
|
if on_finish:
|
||||||
|
on_finish(successful, resultmessage)
|
||||||
|
|
||||||
|
try:
|
||||||
|
task.update_status("Downloading alttpr sprites list")
|
||||||
|
with urlopen('https://alttpr.com/sprites') as response:
|
||||||
|
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||||
|
successful = False
|
||||||
|
task.queue_event(finished)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
task.update_status("Determining needed sprites")
|
||||||
|
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
||||||
|
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||||
|
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
||||||
|
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if filename not in current_sprites]
|
||||||
|
|
||||||
|
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
||||||
|
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
||||||
|
except Exception as e:
|
||||||
|
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||||
|
successful = False
|
||||||
|
task.queue_event(finished)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def dl(sprite_url, filename):
|
||||||
|
target = os.path.join(sprite_dir, filename)
|
||||||
|
with urlopen(sprite_url) as response, open(target, 'wb') as out:
|
||||||
|
shutil.copyfileobj(response, out)
|
||||||
|
|
||||||
|
def rem(sprite):
|
||||||
|
os.remove(os.path.join(sprite_dir, sprite))
|
||||||
|
|
||||||
|
|
||||||
|
with ThreadPoolExecutor() as pool:
|
||||||
|
dl_tasks = []
|
||||||
|
rem_tasks = []
|
||||||
|
|
||||||
|
for (sprite_url, filename) in needed_sprites:
|
||||||
|
dl_tasks.append(pool.submit(dl, sprite_url, filename))
|
||||||
|
|
||||||
|
for sprite in obsolete_sprites:
|
||||||
|
rem_tasks.append(pool.submit(rem, sprite))
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
updated = 0
|
||||||
|
|
||||||
|
for dl_task in as_completed(dl_tasks):
|
||||||
|
updated += 1
|
||||||
|
task.update_status("Downloading needed sprite %g/%g" % (updated, len(needed_sprites)))
|
||||||
|
try:
|
||||||
|
dl_task.result()
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
resultmessage = "Error downloading sprite. Not all sprites updated.\n\n%s: %s" % (
|
||||||
|
type(e).__name__, e)
|
||||||
|
successful = False
|
||||||
|
|
||||||
|
for rem_task in as_completed(rem_tasks):
|
||||||
|
deleted += 1
|
||||||
|
task.update_status("Removing obsolete sprite %g/%g" % (deleted, len(obsolete_sprites)))
|
||||||
|
try:
|
||||||
|
rem_task.result()
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
resultmessage = "Error removing obsolete sprite. Not all sprites updated.\n\n%s: %s" % (
|
||||||
|
type(e).__name__, e)
|
||||||
|
successful = False
|
||||||
|
|
||||||
|
if successful:
|
||||||
|
resultmessage = "alttpr sprites updated successfully"
|
||||||
|
|
||||||
|
task.queue_event(finished)
|
||||||
|
|
||||||
|
|
||||||
|
def set_icon(window):
|
||||||
|
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
|
||||||
|
window.tk.call('wm', 'iconphoto', window._w, logo)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundTask(object):
|
||||||
|
def __init__(self, window, code_to_run, *args):
|
||||||
|
self.window = window
|
||||||
|
self.queue = queue.Queue()
|
||||||
|
self.running = True
|
||||||
|
self.process_queue()
|
||||||
|
self.task = threading.Thread(target=code_to_run, args=(self, *args))
|
||||||
|
self.task.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
# safe to call from worker
|
||||||
|
def queue_event(self, event):
|
||||||
|
self.queue.put(event)
|
||||||
|
|
||||||
|
def process_queue(self):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if not self.running:
|
||||||
|
return
|
||||||
|
event = self.queue.get_nowait()
|
||||||
|
event()
|
||||||
|
if self.running:
|
||||||
|
#if self is no longer running self.window may no longer be valid
|
||||||
|
self.window.update_idletasks()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
if self.running:
|
||||||
|
self.window.after(100, self.process_queue)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundTaskProgress(BackgroundTask):
|
||||||
|
def __init__(self, parent, code_to_run, title, *args):
|
||||||
|
self.parent = parent
|
||||||
|
self.window = tk.Toplevel(parent)
|
||||||
|
self.window['padx'] = 5
|
||||||
|
self.window['pady'] = 5
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.window.attributes("-toolwindow", 1)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.window.wm_title(title)
|
||||||
|
self.label_var = tk.StringVar()
|
||||||
|
self.label_var.set("")
|
||||||
|
self.label = tk.Label(self.window, textvariable=self.label_var, width=50)
|
||||||
|
self.label.pack()
|
||||||
|
self.window.resizable(width=False, height=False)
|
||||||
|
|
||||||
|
set_icon(self.window)
|
||||||
|
self.window.focus()
|
||||||
|
super().__init__(self.window, code_to_run, *args)
|
||||||
|
|
||||||
|
# safe to call from worker thread
|
||||||
|
def update_status(self, text):
|
||||||
|
self.queue_event(lambda: self.label_var.set(text))
|
||||||
|
|
||||||
|
# only call this in an event callback
|
||||||
|
def close_window(self):
|
||||||
|
self.stop()
|
||||||
|
self.window.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def get_rom_frame(parent=None):
|
||||||
|
romFrame = Frame(parent)
|
||||||
|
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||||
|
romVar = StringVar(value="Zelda no Densetsu - Kamigami no Triforce (Japan).sfc")
|
||||||
|
romEntry = Entry(romFrame, textvariable=romVar)
|
||||||
|
|
||||||
|
def RomSelect():
|
||||||
|
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc")), ("All Files", "*")])
|
||||||
|
import Patch
|
||||||
|
try:
|
||||||
|
Patch.get_base_rom_bytes(rom) # throws error on checksum fail
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
messagebox.showerror(title="Error while reading ROM", message=str(e))
|
||||||
|
else:
|
||||||
|
romVar.set(rom)
|
||||||
|
romSelectButton['state'] = "disabled"
|
||||||
|
romSelectButton["text"] = "ROM verified"
|
||||||
|
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||||
|
|
||||||
|
baseRomLabel.pack(side=LEFT)
|
||||||
|
romEntry.pack(side=LEFT, expand=True, fill=X)
|
||||||
|
romSelectButton.pack(side=LEFT)
|
||||||
|
romFrame.pack(side=TOP, expand=True, fill=X)
|
||||||
|
|
||||||
|
return romFrame, romVar
|
||||||
|
|
||||||
|
|
||||||
|
def get_rom_options_frame(parent=None):
|
||||||
|
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||||
|
romOptionsFrame.columnconfigure(0, weight=1)
|
||||||
|
romOptionsFrame.columnconfigure(1, weight=1)
|
||||||
|
for i in range(5):
|
||||||
|
romOptionsFrame.rowconfigure(i, weight=1)
|
||||||
|
vars = Namespace()
|
||||||
|
|
||||||
|
vars.disableMusicVar = IntVar()
|
||||||
|
disableMusicCheckbutton = Checkbutton(romOptionsFrame, text="Disable music", variable=vars.disableMusicVar)
|
||||||
|
disableMusicCheckbutton.grid(row=0, column=0, sticky=E)
|
||||||
|
|
||||||
|
vars.disableFlashingVar = IntVar(value=1)
|
||||||
|
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)", variable=vars.disableFlashingVar)
|
||||||
|
disableFlashingCheckbutton.grid(row=6, column=0, sticky=E)
|
||||||
|
|
||||||
|
spriteDialogFrame = Frame(romOptionsFrame)
|
||||||
|
spriteDialogFrame.grid(row=0, column=1)
|
||||||
|
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
vars.spriteNameVar = StringVar()
|
||||||
|
vars.sprite = None
|
||||||
|
def set_sprite(sprite_param):
|
||||||
|
nonlocal vars
|
||||||
|
if isinstance(sprite_param, str):
|
||||||
|
vars.sprite = sprite_param
|
||||||
|
vars.spriteNameVar.set(sprite_param)
|
||||||
|
elif sprite_param is None or not sprite_param.valid:
|
||||||
|
vars.sprite = None
|
||||||
|
vars.spriteNameVar.set('(unchanged)')
|
||||||
|
else:
|
||||||
|
vars.sprite = sprite_param
|
||||||
|
vars.spriteNameVar.set(vars.sprite.name)
|
||||||
|
|
||||||
|
set_sprite(None)
|
||||||
|
vars.spriteNameVar.set('(unchanged)')
|
||||||
|
spriteEntry = Label(spriteDialogFrame, textvariable=vars.spriteNameVar)
|
||||||
|
|
||||||
|
def SpriteSelect():
|
||||||
|
nonlocal vars
|
||||||
|
SpriteSelector(parent, set_sprite, spritePool=vars.sprite_pool)
|
||||||
|
|
||||||
|
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
||||||
|
|
||||||
|
baseSpriteLabel.pack(side=LEFT)
|
||||||
|
spriteEntry.pack(side=LEFT)
|
||||||
|
spriteSelectButton.pack(side=LEFT)
|
||||||
|
|
||||||
|
vars.quickSwapVar = IntVar(value=1)
|
||||||
|
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
||||||
|
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
||||||
|
|
||||||
|
fastMenuFrame = Frame(romOptionsFrame)
|
||||||
|
fastMenuFrame.grid(row=1, column=1, sticky=E)
|
||||||
|
fastMenuLabel = Label(fastMenuFrame, text='Menu speed')
|
||||||
|
fastMenuLabel.pack(side=LEFT)
|
||||||
|
vars.fastMenuVar = StringVar()
|
||||||
|
vars.fastMenuVar.set('normal')
|
||||||
|
fastMenuOptionMenu = OptionMenu(fastMenuFrame, vars.fastMenuVar, 'normal', 'instant', 'double', 'triple', 'quadruple', 'half')
|
||||||
|
fastMenuOptionMenu.pack(side=LEFT)
|
||||||
|
|
||||||
|
heartcolorFrame = Frame(romOptionsFrame)
|
||||||
|
heartcolorFrame.grid(row=2, column=0, sticky=E)
|
||||||
|
heartcolorLabel = Label(heartcolorFrame, text='Heart color')
|
||||||
|
heartcolorLabel.pack(side=LEFT)
|
||||||
|
vars.heartcolorVar = StringVar()
|
||||||
|
vars.heartcolorVar.set('red')
|
||||||
|
heartcolorOptionMenu = OptionMenu(heartcolorFrame, vars.heartcolorVar, 'red', 'blue', 'green', 'yellow', 'random')
|
||||||
|
heartcolorOptionMenu.pack(side=LEFT)
|
||||||
|
|
||||||
|
heartbeepFrame = Frame(romOptionsFrame)
|
||||||
|
heartbeepFrame.grid(row=2, column=1, sticky=E)
|
||||||
|
heartbeepLabel = Label(heartbeepFrame, text='Heartbeep')
|
||||||
|
heartbeepLabel.pack(side=LEFT)
|
||||||
|
vars.heartbeepVar = StringVar()
|
||||||
|
vars.heartbeepVar.set('normal')
|
||||||
|
heartbeepOptionMenu = OptionMenu(heartbeepFrame, vars.heartbeepVar, 'double', 'normal', 'half', 'quarter', 'off')
|
||||||
|
heartbeepOptionMenu.pack(side=LEFT)
|
||||||
|
|
||||||
|
owPalettesFrame = Frame(romOptionsFrame)
|
||||||
|
owPalettesFrame.grid(row=3, column=0, sticky=E)
|
||||||
|
owPalettesLabel = Label(owPalettesFrame, text='Overworld palettes')
|
||||||
|
owPalettesLabel.pack(side=LEFT)
|
||||||
|
vars.owPalettesVar = StringVar()
|
||||||
|
vars.owPalettesVar.set('default')
|
||||||
|
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||||
|
owPalettesOptionMenu.pack(side=LEFT)
|
||||||
|
|
||||||
|
uwPalettesFrame = Frame(romOptionsFrame)
|
||||||
|
uwPalettesFrame.grid(row=3, column=1, sticky=E)
|
||||||
|
uwPalettesLabel = Label(uwPalettesFrame, text='Dungeon palettes')
|
||||||
|
uwPalettesLabel.pack(side=LEFT)
|
||||||
|
vars.uwPalettesVar = StringVar()
|
||||||
|
vars.uwPalettesVar.set('default')
|
||||||
|
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||||
|
uwPalettesOptionMenu.pack(side=LEFT)
|
||||||
|
|
||||||
|
hudPalettesFrame = Frame(romOptionsFrame)
|
||||||
|
hudPalettesFrame.grid(row=4, column=0, sticky=E)
|
||||||
|
hudPalettesLabel = Label(hudPalettesFrame, text='HUD palettes')
|
||||||
|
hudPalettesLabel.pack(side=LEFT)
|
||||||
|
vars.hudPalettesVar = StringVar()
|
||||||
|
vars.hudPalettesVar.set('default')
|
||||||
|
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||||
|
hudPalettesOptionMenu.pack(side=LEFT)
|
||||||
|
|
||||||
|
swordPalettesFrame = Frame(romOptionsFrame)
|
||||||
|
swordPalettesFrame.grid(row=4, column=1, sticky=E)
|
||||||
|
swordPalettesLabel = Label(swordPalettesFrame, text='Sword palettes')
|
||||||
|
swordPalettesLabel.pack(side=LEFT)
|
||||||
|
vars.swordPalettesVar = StringVar()
|
||||||
|
vars.swordPalettesVar.set('default')
|
||||||
|
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||||
|
swordPalettesOptionMenu.pack(side=LEFT)
|
||||||
|
|
||||||
|
shieldPalettesFrame = Frame(romOptionsFrame)
|
||||||
|
shieldPalettesFrame.grid(row=5, column=0, sticky=E)
|
||||||
|
shieldPalettesLabel = Label(shieldPalettesFrame, text='Shield palettes')
|
||||||
|
shieldPalettesLabel.pack(side=LEFT)
|
||||||
|
vars.shieldPalettesVar = StringVar()
|
||||||
|
vars.shieldPalettesVar.set('default')
|
||||||
|
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||||
|
shieldPalettesOptionMenu.pack(side=LEFT)
|
||||||
|
|
||||||
|
spritePoolFrame = Frame(romOptionsFrame)
|
||||||
|
spritePoolFrame.grid(row=5, column=1)
|
||||||
|
baseSpritePoolLabel = Label(spritePoolFrame, text='Sprite Pool:')
|
||||||
|
|
||||||
|
vars.spritePoolCountVar = StringVar()
|
||||||
|
vars.sprite_pool = []
|
||||||
|
def set_sprite_pool(sprite_param):
|
||||||
|
nonlocal vars
|
||||||
|
operation = "add"
|
||||||
|
if isinstance(sprite_param, tuple):
|
||||||
|
operation, sprite_param = sprite_param
|
||||||
|
if isinstance(sprite_param, Sprite) and sprite_param.valid:
|
||||||
|
sprite_param = sprite_param.name
|
||||||
|
if isinstance(sprite_param, str):
|
||||||
|
if operation == "add":
|
||||||
|
vars.sprite_pool.append(sprite_param)
|
||||||
|
elif operation == "remove":
|
||||||
|
vars.sprite_pool.remove(sprite_param)
|
||||||
|
elif operation == "clear":
|
||||||
|
vars.sprite_pool.clear()
|
||||||
|
vars.spritePoolCountVar.set(str(len(vars.sprite_pool)))
|
||||||
|
|
||||||
|
set_sprite_pool(None)
|
||||||
|
vars.spritePoolCountVar.set('0')
|
||||||
|
spritePoolEntry = Label(spritePoolFrame, textvariable=vars.spritePoolCountVar)
|
||||||
|
|
||||||
|
def SpritePoolSelect():
|
||||||
|
nonlocal vars
|
||||||
|
SpriteSelector(parent, set_sprite_pool, randomOnEvent=False, spritePool=vars.sprite_pool)
|
||||||
|
|
||||||
|
def SpritePoolClear():
|
||||||
|
nonlocal vars
|
||||||
|
vars.sprite_pool.clear()
|
||||||
|
vars.spritePoolCountVar.set('0')
|
||||||
|
|
||||||
|
spritePoolSelectButton = Button(spritePoolFrame, text='...', command=SpritePoolSelect)
|
||||||
|
spritePoolClearButton = Button(spritePoolFrame, text='Clear', command=SpritePoolClear)
|
||||||
|
|
||||||
|
baseSpritePoolLabel.pack(side=LEFT)
|
||||||
|
spritePoolEntry.pack(side=LEFT)
|
||||||
|
spritePoolSelectButton.pack(side=LEFT)
|
||||||
|
spritePoolClearButton.pack(side=LEFT)
|
||||||
|
|
||||||
|
return romOptionsFrame, vars, set_sprite
|
||||||
|
|
||||||
|
|
||||||
|
class SpriteSelector():
|
||||||
|
def __init__(self, parent, callback, adjuster=False, randomOnEvent=True, spritePool=None):
|
||||||
|
self.deploy_icons()
|
||||||
|
self.parent = parent
|
||||||
|
self.window = Toplevel(parent)
|
||||||
|
self.callback = callback
|
||||||
|
self.adjuster = adjuster
|
||||||
|
self.randomOnEvent = randomOnEvent
|
||||||
|
self.spritePoolButtons = None
|
||||||
|
|
||||||
|
self.window.wm_title("TAKE ANY ONE YOU WANT")
|
||||||
|
self.window['padx'] = 5
|
||||||
|
self.window['pady'] = 5
|
||||||
|
self.spritesPerRow = 32
|
||||||
|
self.all_sprites = []
|
||||||
|
self.sprite_pool = spritePool
|
||||||
|
|
||||||
|
def open_custom_sprite_dir(_evt):
|
||||||
|
open_file(self.custom_sprite_dir)
|
||||||
|
|
||||||
|
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
|
||||||
|
|
||||||
|
custom_frametitle = Frame(self.window)
|
||||||
|
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||||
|
title_link = Label(custom_frametitle, text="(open)", fg="blue", cursor="hand2")
|
||||||
|
title_text.pack(side=LEFT)
|
||||||
|
title_link.pack(side=LEFT)
|
||||||
|
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||||
|
|
||||||
|
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir, 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
||||||
|
self.icon_section(custom_frametitle, self.custom_sprite_dir, 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
||||||
|
if not randomOnEvent:
|
||||||
|
self.sprite_pool_section(spritePool)
|
||||||
|
|
||||||
|
frame = Frame(self.window)
|
||||||
|
frame.pack(side=BOTTOM, fill=X, pady=5)
|
||||||
|
|
||||||
|
if self.randomOnEvent:
|
||||||
|
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||||
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
|
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
||||||
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
|
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
self.randomButtonText = StringVar()
|
||||||
|
button = Button(frame, textvariable=self.randomButtonText, command=self.use_random_sprite)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
self.randomButtonText.set("Random")
|
||||||
|
|
||||||
|
self.randomOnEventText = StringVar()
|
||||||
|
self.randomOnHitVar = IntVar()
|
||||||
|
self.randomOnEnterVar = IntVar()
|
||||||
|
self.randomOnExitVar = IntVar()
|
||||||
|
self.randomOnSlashVar = IntVar()
|
||||||
|
self.randomOnItemVar = IntVar()
|
||||||
|
self.randomOnBonkVar = IntVar()
|
||||||
|
self.randomOnRandomVar = IntVar()
|
||||||
|
|
||||||
|
if self.randomOnEvent:
|
||||||
|
button = Checkbutton(frame, text="Hit", command=self.update_random_button, variable=self.randomOnHitVar)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
button = Checkbutton(frame, text="Enter", command=self.update_random_button, variable=self.randomOnEnterVar)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
button = Checkbutton(frame, text="Exit", command=self.update_random_button, variable=self.randomOnExitVar)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
button = Checkbutton(frame, text="Slash", command=self.update_random_button, variable=self.randomOnSlashVar)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
button = Checkbutton(frame, text="Item", command=self.update_random_button, variable=self.randomOnItemVar)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
button = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
button = Checkbutton(frame, text="Random", command=self.update_random_button, variable=self.randomOnRandomVar)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
if adjuster:
|
||||||
|
button = Button(frame, text="Current sprite from rom", command=self.use_default_sprite)
|
||||||
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
|
set_icon(self.window)
|
||||||
|
self.window.focus()
|
||||||
|
|
||||||
|
def remove_from_sprite_pool(self, button, spritename):
|
||||||
|
self.callback(("remove", spritename))
|
||||||
|
self.spritePoolButtons.buttons.remove(button)
|
||||||
|
button.destroy()
|
||||||
|
|
||||||
|
def add_to_sprite_pool(self, spritename):
|
||||||
|
if isinstance(spritename, str):
|
||||||
|
if spritename == "random":
|
||||||
|
button = Button(self.spritePoolButtons, text="?")
|
||||||
|
button['font'] = font.Font(size=19)
|
||||||
|
button.configure(command=lambda spr="random": self.remove_from_sprite_pool(button, spr))
|
||||||
|
ToolTips.register(button, "Random")
|
||||||
|
self.spritePoolButtons.buttons.append(button)
|
||||||
|
else:
|
||||||
|
spritename = Sprite.get_sprite_from_name(spritename)
|
||||||
|
if isinstance(spritename, Sprite) and spritename.valid:
|
||||||
|
image = get_image_for_sprite(spritename)
|
||||||
|
if image is None:
|
||||||
|
return
|
||||||
|
button = Button(self.spritePoolButtons, image=image)
|
||||||
|
button.configure(command=lambda spr=spritename: self.remove_from_sprite_pool(button, spr.name))
|
||||||
|
ToolTips.register(button, spritename.name +
|
||||||
|
f"\nBy: {spritename.author_name if spritename.author_name else ''}")
|
||||||
|
button.image = image
|
||||||
|
|
||||||
|
self.spritePoolButtons.buttons.append(button)
|
||||||
|
self.grid_fill_sprites(self.spritePoolButtons)
|
||||||
|
|
||||||
|
def sprite_pool_section(self, spritePool):
|
||||||
|
def clear_sprite_pool(_evt):
|
||||||
|
self.callback(("clear", "Clear"))
|
||||||
|
for button in self.spritePoolButtons.buttons:
|
||||||
|
button.destroy()
|
||||||
|
self.spritePoolButtons.buttons.clear()
|
||||||
|
|
||||||
|
frametitle = Frame(self.window)
|
||||||
|
title_text = Label(frametitle, text="Sprite Pool")
|
||||||
|
title_link = Label(frametitle, text="(clear)", fg="blue", cursor="hand2")
|
||||||
|
title_text.pack(side=LEFT)
|
||||||
|
title_link.pack(side=LEFT)
|
||||||
|
title_link.bind("<Button-1>", clear_sprite_pool)
|
||||||
|
|
||||||
|
self.spritePoolButtons = LabelFrame(self.window, labelwidget=frametitle, padx=5, pady=5)
|
||||||
|
self.spritePoolButtons.pack(side=TOP, fill=X)
|
||||||
|
self.spritePoolButtons.buttons = []
|
||||||
|
|
||||||
|
def update_sprites(event):
|
||||||
|
self.spritesPerRow = (event.width - 10) // 38
|
||||||
|
self.grid_fill_sprites(self.spritePoolButtons)
|
||||||
|
|
||||||
|
self.grid_fill_sprites(self.spritePoolButtons)
|
||||||
|
self.spritePoolButtons.bind("<Configure>", update_sprites)
|
||||||
|
|
||||||
|
if spritePool:
|
||||||
|
for sprite in spritePool:
|
||||||
|
self.add_to_sprite_pool(sprite)
|
||||||
|
|
||||||
|
def icon_section(self, frame_label, path, no_results_label):
|
||||||
|
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
|
||||||
|
frame.pack(side=TOP, fill=X)
|
||||||
|
|
||||||
|
sprites = []
|
||||||
|
|
||||||
|
for file in os.listdir(path):
|
||||||
|
sprites.append((file, Sprite(os.path.join(path, file))))
|
||||||
|
|
||||||
|
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
||||||
|
|
||||||
|
frame.buttons = []
|
||||||
|
for file, sprite in sprites:
|
||||||
|
image = get_image_for_sprite(sprite)
|
||||||
|
if image is None:
|
||||||
|
continue
|
||||||
|
self.all_sprites.append(sprite)
|
||||||
|
button = Button(frame, image=image, command=lambda spr=sprite: self.select_sprite(spr))
|
||||||
|
ToolTips.register(button, sprite.name +
|
||||||
|
("\nBy: %s" % sprite.author_name if sprite.author_name else "") +
|
||||||
|
f"\nFrom: {file}")
|
||||||
|
button.image = image
|
||||||
|
frame.buttons.append(button)
|
||||||
|
|
||||||
|
if not frame.buttons:
|
||||||
|
label = Label(frame, text=no_results_label)
|
||||||
|
label.pack()
|
||||||
|
|
||||||
|
def update_sprites(event):
|
||||||
|
self.spritesPerRow = (event.width - 10) // 38
|
||||||
|
self.grid_fill_sprites(frame)
|
||||||
|
|
||||||
|
self.grid_fill_sprites(frame)
|
||||||
|
|
||||||
|
frame.bind("<Configure>", update_sprites)
|
||||||
|
|
||||||
|
def grid_fill_sprites(self, frame):
|
||||||
|
for i, button in enumerate(frame.buttons):
|
||||||
|
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
||||||
|
|
||||||
|
def update_alttpr_sprites(self):
|
||||||
|
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
||||||
|
self.window.destroy()
|
||||||
|
self.parent.update()
|
||||||
|
|
||||||
|
def on_finish(successful, resultmessage):
|
||||||
|
if successful:
|
||||||
|
messagebox.showinfo("Sprite Updater", resultmessage)
|
||||||
|
else:
|
||||||
|
logging.error(resultmessage)
|
||||||
|
messagebox.showerror("Sprite Updater", resultmessage)
|
||||||
|
SpriteSelector(self.parent, self.callback, self.adjuster)
|
||||||
|
|
||||||
|
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
|
||||||
|
|
||||||
|
|
||||||
|
def browse_for_sprite(self):
|
||||||
|
sprite = filedialog.askopenfilename(
|
||||||
|
filetypes=[("All Sprite Sources", (".zspr", ".spr", ".sfc", ".smc")),
|
||||||
|
("ZSprite files", ".zspr"),
|
||||||
|
("Sprite files", ".spr"),
|
||||||
|
("Rom Files", (".sfc", ".smc")),
|
||||||
|
("All Files", "*")])
|
||||||
|
try:
|
||||||
|
self.callback(Sprite(sprite))
|
||||||
|
except Exception:
|
||||||
|
self.callback(None)
|
||||||
|
self.window.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def use_default_sprite(self):
|
||||||
|
self.callback(None)
|
||||||
|
self.window.destroy()
|
||||||
|
|
||||||
|
def use_default_link_sprite(self):
|
||||||
|
if self.randomOnEvent:
|
||||||
|
self.callback(Sprite.default_link_sprite())
|
||||||
|
self.window.destroy()
|
||||||
|
else:
|
||||||
|
self.callback("link")
|
||||||
|
self.add_to_sprite_pool("link")
|
||||||
|
|
||||||
|
def update_random_button(self):
|
||||||
|
if self.randomOnRandomVar.get():
|
||||||
|
randomon = "random"
|
||||||
|
else:
|
||||||
|
randomon = "-hit" if self.randomOnHitVar.get() else ""
|
||||||
|
randomon += "-enter" if self.randomOnEnterVar.get() else ""
|
||||||
|
randomon += "-exit" if self.randomOnExitVar.get() else ""
|
||||||
|
randomon += "-slash" if self.randomOnSlashVar.get() else ""
|
||||||
|
randomon += "-item" if self.randomOnItemVar.get() else ""
|
||||||
|
randomon += "-bonk" if self.randomOnBonkVar.get() else ""
|
||||||
|
|
||||||
|
self.randomOnEventText.set(f"randomon{randomon}" if randomon else None)
|
||||||
|
self.randomButtonText.set("Random On Event" if randomon else "Random")
|
||||||
|
|
||||||
|
def use_random_sprite(self):
|
||||||
|
if not self.randomOnEvent:
|
||||||
|
self.callback("random")
|
||||||
|
self.add_to_sprite_pool("random")
|
||||||
|
return
|
||||||
|
elif self.randomOnEventText.get():
|
||||||
|
self.callback(self.randomOnEventText.get())
|
||||||
|
elif self.sprite_pool:
|
||||||
|
self.callback(random.choice(self.sprite_pool))
|
||||||
|
elif self.all_sprites:
|
||||||
|
self.callback(random.choice(self.all_sprites))
|
||||||
|
else:
|
||||||
|
self.callback(None)
|
||||||
|
self.window.destroy()
|
||||||
|
|
||||||
|
def select_sprite(self, spritename):
|
||||||
|
self.callback(spritename)
|
||||||
|
if self.randomOnEvent:
|
||||||
|
self.window.destroy()
|
||||||
|
else:
|
||||||
|
self.add_to_sprite_pool(spritename)
|
||||||
|
|
||||||
|
def deploy_icons(self):
|
||||||
|
if not os.path.exists(self.custom_sprite_dir):
|
||||||
|
os.makedirs(self.custom_sprite_dir)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alttpr_sprite_dir(self):
|
||||||
|
return local_path("data", "sprites", "alttpr")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def custom_sprite_dir(self):
|
||||||
|
return local_path("data", "sprites", "custom")
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||||
|
if not sprite.valid:
|
||||||
|
return None
|
||||||
|
height = 24
|
||||||
|
width = 16
|
||||||
|
|
||||||
|
def draw_sprite_into_gif(add_palette_color, set_pixel_color_index):
|
||||||
|
|
||||||
|
def drawsprite(spr, pal_as_colors, offset):
|
||||||
|
for y, row in enumerate(spr):
|
||||||
|
for x, pal_index in enumerate(row):
|
||||||
|
if pal_index:
|
||||||
|
color = pal_as_colors[pal_index - 1]
|
||||||
|
set_pixel_color_index(x + offset[0], y + offset[1], color)
|
||||||
|
|
||||||
|
add_palette_color(16, (40, 40, 40))
|
||||||
|
shadow = [
|
||||||
|
[0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0],
|
||||||
|
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
|
||||||
|
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
|
||||||
|
[0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0],
|
||||||
|
]
|
||||||
|
|
||||||
|
drawsprite(shadow, [16], (2, 17))
|
||||||
|
|
||||||
|
palettes = sprite.decode_palette()
|
||||||
|
for i in range(15):
|
||||||
|
add_palette_color(i + 1, palettes[0][i])
|
||||||
|
|
||||||
|
body = sprite.decode16(0x4C0)
|
||||||
|
drawsprite(body, list(range(1, 16)), (0, 8))
|
||||||
|
head = sprite.decode16(0x40)
|
||||||
|
drawsprite(head, list(range(1, 16)), (0, 0))
|
||||||
|
|
||||||
|
def make_gif(callback):
|
||||||
|
gif_header = b'GIF89a'
|
||||||
|
|
||||||
|
gif_lsd = bytearray(7)
|
||||||
|
gif_lsd[0] = width
|
||||||
|
gif_lsd[2] = height
|
||||||
|
gif_lsd[4] = 0xF4 # 32 color palette follows. transparant + 15 for sprite + 1 for shadow=17 which rounds up to 32 as nearest power of 2
|
||||||
|
gif_lsd[5] = 0 # background color is zero
|
||||||
|
gif_lsd[6] = 0 # aspect raio not specified
|
||||||
|
gif_gct = bytearray(3 * 32)
|
||||||
|
|
||||||
|
gif_gce = bytearray(8)
|
||||||
|
gif_gce[0] = 0x21 # start of extention blocked
|
||||||
|
gif_gce[1] = 0xF9 # identifies this as the Graphics Control extension
|
||||||
|
gif_gce[2] = 4 # we are suppling only the 4 four bytes
|
||||||
|
gif_gce[3] = 0x01 # this gif includes transparency
|
||||||
|
gif_gce[4] = gif_gce[5] = 0 # animation frrame delay (unused)
|
||||||
|
gif_gce[6] = 0 # transparent color is index 0
|
||||||
|
gif_gce[7] = 0 # end of gif_gce
|
||||||
|
gif_id = bytearray(10)
|
||||||
|
gif_id[0] = 0x2c
|
||||||
|
# byte 1,2 are image left. 3,4 are image top both are left as zerosuitsamus
|
||||||
|
gif_id[5] = width
|
||||||
|
gif_id[7] = height
|
||||||
|
gif_id[9] = 0 # no local color table
|
||||||
|
|
||||||
|
gif_img_minimum_code_size = bytes([7]) # we choose 7 bits, so that each pixel is represented by a byte, for conviennce.
|
||||||
|
|
||||||
|
clear = 0x80
|
||||||
|
stop = 0x81
|
||||||
|
|
||||||
|
unchunked_image_data = bytearray(height * (width + 1) + 1)
|
||||||
|
# we technically need a Clear code once every 125 bytes, but we do it at the start of every row for simplicity
|
||||||
|
for row in range(height):
|
||||||
|
unchunked_image_data[row * (width + 1)] = clear
|
||||||
|
unchunked_image_data[-1] = stop
|
||||||
|
|
||||||
|
def add_palette_color(index, color):
|
||||||
|
gif_gct[3 * index] = color[0]
|
||||||
|
gif_gct[3 * index + 1] = color[1]
|
||||||
|
gif_gct[3 * index + 2] = color[2]
|
||||||
|
|
||||||
|
def set_pixel_color_index(x, y, color):
|
||||||
|
unchunked_image_data[y * (width + 1) + x + 1] = color
|
||||||
|
|
||||||
|
callback(add_palette_color, set_pixel_color_index)
|
||||||
|
|
||||||
|
def chunk_image(img):
|
||||||
|
for i in range(0, len(img), 255):
|
||||||
|
chunk = img[i:i + 255]
|
||||||
|
yield bytes([len(chunk)])
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
gif_img = b''.join([gif_img_minimum_code_size] + list(chunk_image(unchunked_image_data)) + [b'\x00'])
|
||||||
|
|
||||||
|
gif = b''.join([gif_header, gif_lsd, gif_gct, gif_gce, gif_id, gif_img, b'\x3b'])
|
||||||
|
|
||||||
|
return gif
|
||||||
|
|
||||||
|
gif_data = make_gif(draw_sprite_into_gif)
|
||||||
|
if gif_only:
|
||||||
|
return gif_data
|
||||||
|
|
||||||
|
image = PhotoImage(data=gif_data)
|
||||||
|
|
||||||
|
return image.zoom(2)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTips(object):
|
||||||
|
# This class derived from wckToolTips which is available under the following license:
|
||||||
|
|
||||||
|
# Copyright (c) 1998-2007 by Secret Labs AB
|
||||||
|
# Copyright (c) 1998-2007 by Fredrik Lundh
|
||||||
|
#
|
||||||
|
# By obtaining, using, and/or copying this software and/or its
|
||||||
|
# associated documentation, you agree that you have read, understood,
|
||||||
|
# and will comply with the following terms and conditions:
|
||||||
|
#
|
||||||
|
# Permission to use, copy, modify, and distribute this software and its
|
||||||
|
# associated documentation for any purpose and without fee is hereby
|
||||||
|
# granted, provided that the above copyright notice appears in all
|
||||||
|
# copies, and that both that copyright notice and this permission notice
|
||||||
|
# appear in supporting documentation, and that the name of Secret Labs
|
||||||
|
# AB or the author not be used in advertising or publicity pertaining to
|
||||||
|
# distribution of the software without specific, written prior
|
||||||
|
# permission.
|
||||||
|
#
|
||||||
|
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||||
|
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||||
|
# FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR
|
||||||
|
# ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||||
|
# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
label = None
|
||||||
|
window = None
|
||||||
|
active = 0
|
||||||
|
tag = None
|
||||||
|
after_id = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getcontroller(cls, widget):
|
||||||
|
if cls.tag is None:
|
||||||
|
|
||||||
|
cls.tag = "ui_tooltip_%d" % id(cls)
|
||||||
|
widget.bind_class(cls.tag, "<Enter>", cls.enter)
|
||||||
|
widget.bind_class(cls.tag, "<Leave>", cls.leave)
|
||||||
|
widget.bind_class(cls.tag, "<Motion>", cls.motion)
|
||||||
|
widget.bind_class(cls.tag, "<Destroy>", cls.leave)
|
||||||
|
|
||||||
|
# pick suitable colors for tooltips
|
||||||
|
try:
|
||||||
|
cls.bg = "systeminfobackground"
|
||||||
|
cls.fg = "systeminfotext"
|
||||||
|
widget.winfo_rgb(cls.fg) # make sure system colors exist
|
||||||
|
widget.winfo_rgb(cls.bg)
|
||||||
|
except Exception:
|
||||||
|
cls.bg = "#ffffe0"
|
||||||
|
cls.fg = "black"
|
||||||
|
|
||||||
|
return cls.tag
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, widget, text):
|
||||||
|
widget.ui_tooltip_text = text
|
||||||
|
tags = list(widget.bindtags())
|
||||||
|
tags.append(cls.getcontroller(widget))
|
||||||
|
widget.bindtags(tuple(tags))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unregister(cls, widget):
|
||||||
|
tags = list(widget.bindtags())
|
||||||
|
tags.remove(cls.getcontroller(widget))
|
||||||
|
widget.bindtags(tuple(tags))
|
||||||
|
|
||||||
|
# event handlers
|
||||||
|
@classmethod
|
||||||
|
def enter(cls, event):
|
||||||
|
widget = event.widget
|
||||||
|
if not cls.label:
|
||||||
|
# create and hide balloon help window
|
||||||
|
cls.popup = tk.Toplevel(bg=cls.fg, bd=1)
|
||||||
|
cls.popup.overrideredirect(1)
|
||||||
|
cls.popup.withdraw()
|
||||||
|
cls.label = tk.Label(
|
||||||
|
cls.popup, fg=cls.fg, bg=cls.bg, bd=0, padx=2, justify=tk.LEFT
|
||||||
|
)
|
||||||
|
cls.label.pack()
|
||||||
|
cls.active = 0
|
||||||
|
cls.xy = event.x_root + 16, event.y_root + 10
|
||||||
|
cls.event_xy = event.x, event.y
|
||||||
|
cls.after_id = widget.after(200, cls.display, widget)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def motion(cls, event):
|
||||||
|
cls.xy = event.x_root + 16, event.y_root + 10
|
||||||
|
cls.event_xy = event.x, event.y
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def display(cls, widget):
|
||||||
|
if not cls.active:
|
||||||
|
# display balloon help window
|
||||||
|
text = widget.ui_tooltip_text
|
||||||
|
if callable(text):
|
||||||
|
text = text(widget, cls.event_xy)
|
||||||
|
cls.label.config(text=text)
|
||||||
|
cls.popup.deiconify()
|
||||||
|
cls.popup.lift()
|
||||||
|
cls.popup.geometry("+%d+%d" % cls.xy)
|
||||||
|
cls.active = 1
|
||||||
|
cls.after_id = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def leave(cls, event):
|
||||||
|
widget = event.widget
|
||||||
|
if cls.active:
|
||||||
|
cls.popup.withdraw()
|
||||||
|
cls.active = 0
|
||||||
|
if cls.after_id:
|
||||||
|
widget.after_cancel(cls.after_id)
|
||||||
|
cls.after_id = None
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
@@ -57,8 +57,10 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
|||||||
|
|
||||||
class Context(CommonContext):
|
class Context(CommonContext):
|
||||||
command_processor = LttPCommandProcessor
|
command_processor = LttPCommandProcessor
|
||||||
def __init__(self, snes_address, server_address, password, found_items):
|
game = "A Link to the Past"
|
||||||
super(Context, self).__init__(server_address, password, found_items)
|
|
||||||
|
def __init__(self, snes_address, server_address, password):
|
||||||
|
super(Context, self).__init__(server_address, password)
|
||||||
|
|
||||||
# snes stuff
|
# snes stuff
|
||||||
self.snes_address = snes_address
|
self.snes_address = snes_address
|
||||||
@@ -872,7 +874,7 @@ async def main():
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
||||||
|
|
||||||
ctx = Context(args.snes, args.connect, args.password, args.founditems)
|
ctx = Context(args.snes, args.connect, args.password)
|
||||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||||
|
|
||||||
if ctx.server_task is None:
|
if ctx.server_task is None:
|
||||||
|
|||||||
466
Main.py
@@ -6,23 +6,21 @@ import time
|
|||||||
import zlib
|
import zlib
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import pickle
|
import pickle
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
from typing import Dict, Tuple
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
from BaseClasses import MultiWorld, CollectionState, Region, Item
|
from BaseClasses import MultiWorld, CollectionState, Region, Item
|
||||||
from worlds.alttp.Items import ItemFactory, item_name_groups
|
from worlds.alttp.Items import item_name_groups
|
||||||
from worlds.alttp.Regions import create_regions, mark_light_world_regions, \
|
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
||||||
lookup_vanilla_location_to_entrance
|
|
||||||
from worlds.alttp.InvertedRegions import create_inverted_regions, mark_dark_world_regions
|
|
||||||
from worlds.alttp.EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
|
|
||||||
from worlds.alttp.Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string
|
from worlds.alttp.Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string
|
||||||
from worlds.alttp.Rules import set_rules
|
from worlds.alttp.Dungeons import fill_dungeons, fill_dungeons_restrictive
|
||||||
from worlds.alttp.Dungeons import create_dungeons, fill_dungeons, fill_dungeons_restrictive
|
|
||||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||||
from worlds.alttp.Shops import create_shops, ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
from worlds.alttp.Shops import ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||||
from worlds.alttp.ItemPool import generate_itempool, difficulties, fill_prizes
|
from worlds.alttp.ItemPool import difficulties, fill_prizes
|
||||||
from Utils import output_path, parse_player_names, get_options, __version__, version_tuple
|
from Utils import output_path, parse_player_names, get_options, __version__, version_tuple
|
||||||
from worlds.generic.Rules import locality_rules
|
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||||
from worlds import Games, lookup_any_item_name_to_id, AutoWorld
|
from worlds import AutoWorld
|
||||||
import Patch
|
import Patch
|
||||||
|
|
||||||
seeddigits = 20
|
seeddigits = 20
|
||||||
@@ -122,10 +120,13 @@ def main(args, seed=None):
|
|||||||
world.set_options(args)
|
world.set_options(args)
|
||||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||||
|
|
||||||
world.slot_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in
|
world.slot_seeds = {player: random.Random(world.random.getrandbits(64)) for player in
|
||||||
range(1, world.players + 1)}
|
range(1, world.players + 1)}
|
||||||
|
|
||||||
for player in range(1, world.players + 1):
|
AutoWorld.call_all(world, "generate_early")
|
||||||
|
|
||||||
|
# system for sharing ER layouts
|
||||||
|
for player in world.get_game_players("A Link to the Past"):
|
||||||
world.er_seeds[player] = str(world.random.randint(0, 2 ** 64))
|
world.er_seeds[player] = str(world.random.randint(0, 2 ** 64))
|
||||||
|
|
||||||
if "-" in world.shuffle[player]:
|
if "-" in world.shuffle[player]:
|
||||||
@@ -134,7 +135,6 @@ def main(args, seed=None):
|
|||||||
if shuffle == "vanilla":
|
if shuffle == "vanilla":
|
||||||
world.er_seeds[player] = "vanilla"
|
world.er_seeds[player] = "vanilla"
|
||||||
elif seed.startswith("group-") or args.race:
|
elif seed.startswith("group-") or args.race:
|
||||||
# renamed from team to group to not confuse with existing team name use
|
|
||||||
world.er_seeds[player] = get_same_seed(world, (
|
world.er_seeds[player] = get_same_seed(world, (
|
||||||
shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
|
shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
|
||||||
else: # not a race or group seed, use set seed as is.
|
else: # not a race or group seed, use set seed as is.
|
||||||
@@ -145,8 +145,9 @@ def main(args, seed=None):
|
|||||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||||
|
|
||||||
logger.info("Found World Types:")
|
logger.info("Found World Types:")
|
||||||
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
logger.info(f" {name:30} {cls}")
|
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | {len(cls.location_names):3} Locations")
|
||||||
|
|
||||||
parsed_names = parse_player_names(args.names, world.players, args.teams)
|
parsed_names = parse_player_names(args.names, world.players, args.teams)
|
||||||
world.teams = len(parsed_names)
|
world.teams = len(parsed_names)
|
||||||
@@ -157,91 +158,46 @@ def main(args, seed=None):
|
|||||||
world.player_names[player].append(name)
|
world.player_names[player].append(name)
|
||||||
|
|
||||||
logger.info('')
|
logger.info('')
|
||||||
for player in world.alttp_player_ids:
|
for player in world.get_game_players("A Link to the Past"):
|
||||||
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
|
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
|
||||||
|
|
||||||
for player in world.player_ids:
|
for player in world.player_ids:
|
||||||
for item_name in args.startinventory[player]:
|
for item_name in args.startinventory[player]:
|
||||||
item = Item(item_name, True, lookup_any_item_name_to_id[item_name], player)
|
world.push_precollected(world.create_item(item_name, player))
|
||||||
item.game = world.game[player]
|
|
||||||
world.push_precollected(item)
|
|
||||||
|
|
||||||
for player in world.player_ids:
|
for player in world.player_ids:
|
||||||
|
if player in world.get_game_players("A Link to the Past"):
|
||||||
|
# enforce pre-defined local items.
|
||||||
|
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||||
|
world.local_items[player].add('Triforce Piece')
|
||||||
|
|
||||||
# enforce pre-defined local items.
|
# dungeon items can't be in non-local if the appropriate dungeon item shuffle setting is not set.
|
||||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
if not world.mapshuffle[player]:
|
||||||
world.local_items[player].add('Triforce Piece')
|
world.non_local_items[player] -= item_name_groups['Maps']
|
||||||
|
|
||||||
|
if not world.compassshuffle[player]:
|
||||||
|
world.non_local_items[player] -= item_name_groups['Compasses']
|
||||||
|
|
||||||
|
if not world.keyshuffle[player]:
|
||||||
|
world.non_local_items[player] -= item_name_groups['Small Keys']
|
||||||
|
# This could probably use a more elegant solution.
|
||||||
|
elif world.keyshuffle[player] == True and world.mode[player] == "Standard":
|
||||||
|
world.local_items[player].add("Small Key (Hyrule Castle)")
|
||||||
|
if not world.bigkeyshuffle[player]:
|
||||||
|
world.non_local_items[player] -= item_name_groups['Big Keys']
|
||||||
|
|
||||||
|
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||||
|
world.non_local_items[player] -= item_name_groups['Pendants']
|
||||||
|
world.non_local_items[player] -= item_name_groups['Crystals']
|
||||||
|
|
||||||
# items can't be both local and non-local, prefer local
|
# items can't be both local and non-local, prefer local
|
||||||
world.non_local_items[player] -= world.local_items[player]
|
world.non_local_items[player] -= world.local_items[player]
|
||||||
|
|
||||||
# dungeon items can't be in non-local if the appropriate dungeon item shuffle setting is not set.
|
logger.info('Creating World.')
|
||||||
if not world.mapshuffle[player]:
|
|
||||||
world.non_local_items[player] -= item_name_groups['Maps']
|
|
||||||
|
|
||||||
if not world.compassshuffle[player]:
|
|
||||||
world.non_local_items[player] -= item_name_groups['Compasses']
|
|
||||||
|
|
||||||
if not world.keyshuffle[player]:
|
|
||||||
world.non_local_items[player] -= item_name_groups['Small Keys']
|
|
||||||
|
|
||||||
if not world.bigkeyshuffle[player]:
|
|
||||||
world.non_local_items[player] -= item_name_groups['Big Keys']
|
|
||||||
|
|
||||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
|
||||||
world.non_local_items[player] -= item_name_groups['Pendants']
|
|
||||||
world.non_local_items[player] -= item_name_groups['Crystals']
|
|
||||||
|
|
||||||
AutoWorld.call_all(world, "create_regions")
|
AutoWorld.call_all(world, "create_regions")
|
||||||
|
|
||||||
for player in world.alttp_player_ids:
|
logger.info('Creating Items.')
|
||||||
if world.open_pyramid[player] == 'goal':
|
AutoWorld.call_all(world, "create_items")
|
||||||
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt',
|
|
||||||
'localganontriforcehunt', 'ganonpedestal'}
|
|
||||||
elif world.open_pyramid[player] == 'auto':
|
|
||||||
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt',
|
|
||||||
'localganontriforcehunt', 'ganonpedestal'} and \
|
|
||||||
(world.shuffle[player] in {'vanilla', 'dungeonssimple', 'dungeonsfull',
|
|
||||||
'dungeonscrossed'} or not world.shuffle_ganon)
|
|
||||||
else:
|
|
||||||
world.open_pyramid[player] = {'on': True, 'off': False, 'yes': True, 'no': False}.get(
|
|
||||||
world.open_pyramid[player], 'auto')
|
|
||||||
|
|
||||||
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:
|
|
||||||
create_inverted_regions(world, player)
|
|
||||||
create_shops(world, player)
|
|
||||||
create_dungeons(world, player)
|
|
||||||
|
|
||||||
logger.info('Shuffling the World about.')
|
|
||||||
|
|
||||||
for player in world.alttp_player_ids:
|
|
||||||
if world.logic[player] not in ["noglitches", "minorglitches"] and world.shuffle[player] in \
|
|
||||||
{"vanilla", "dungeonssimple", "dungeonsfull", "simple", "restricted", "full"}:
|
|
||||||
world.fix_fake_world[player] = False
|
|
||||||
|
|
||||||
# seeded entrance shuffle
|
|
||||||
old_random = world.random
|
|
||||||
world.random = random.Random(world.er_seeds[player])
|
|
||||||
|
|
||||||
if world.mode[player] != 'inverted':
|
|
||||||
link_entrances(world, player)
|
|
||||||
mark_light_world_regions(world, player)
|
|
||||||
else:
|
|
||||||
link_inverted_entrances(world, player)
|
|
||||||
mark_dark_world_regions(world, player)
|
|
||||||
|
|
||||||
world.random = old_random
|
|
||||||
plando_connect(world, player)
|
|
||||||
|
|
||||||
logger.info('Generating Item Pool.')
|
|
||||||
|
|
||||||
for player in world.alttp_player_ids:
|
|
||||||
generate_itempool(world, player)
|
|
||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
if world.players > 1:
|
if world.players > 1:
|
||||||
@@ -250,8 +206,8 @@ def main(args, seed=None):
|
|||||||
|
|
||||||
AutoWorld.call_all(world, "set_rules")
|
AutoWorld.call_all(world, "set_rules")
|
||||||
|
|
||||||
for player in world.alttp_player_ids:
|
for player in world.player_ids:
|
||||||
set_rules(world, player)
|
exclusion_rules(world, player, args.excluded_locations[player])
|
||||||
|
|
||||||
AutoWorld.call_all(world, "generate_basic")
|
AutoWorld.call_all(world, "generate_basic")
|
||||||
|
|
||||||
@@ -297,7 +253,7 @@ def main(args, seed=None):
|
|||||||
outfilebase = 'AP_' + world.seed_name
|
outfilebase = 'AP_' + world.seed_name
|
||||||
rom_names = []
|
rom_names = []
|
||||||
|
|
||||||
def _gen_rom(team: int, player: int):
|
def _gen_rom(team: int, player: int, output_directory:str):
|
||||||
use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
|
use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
|
||||||
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
|
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
|
||||||
or world.shufflepots[player] or world.bush_shuffle[player]
|
or world.shufflepots[player] or world.bush_shuffle[player]
|
||||||
@@ -308,7 +264,7 @@ def main(args, seed=None):
|
|||||||
patch_rom(world, rom, player, team, use_enemizer)
|
patch_rom(world, rom, player, team, use_enemizer)
|
||||||
|
|
||||||
if use_enemizer:
|
if use_enemizer:
|
||||||
patch_enemizer(world, team, player, rom, args.enemizercli)
|
patch_enemizer(world, team, player, rom, args.enemizercli, output_directory)
|
||||||
|
|
||||||
if args.race:
|
if args.race:
|
||||||
patch_race_rom(rom, world, player)
|
patch_race_rom(rom, world, player)
|
||||||
@@ -386,180 +342,185 @@ def main(args, seed=None):
|
|||||||
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
|
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
|
||||||
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
|
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
|
||||||
) if not args.outputname else ''
|
) if not args.outputname else ''
|
||||||
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
|
rompath = os.path.join(output_directory, f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
|
||||||
rom.write_to_file(rompath, hide_enemizer=True)
|
rom.write_to_file(rompath, hide_enemizer=True)
|
||||||
if args.create_diff:
|
Patch.create_patch_file(rompath, player=player, player_name=world.player_names[player][team])
|
||||||
Patch.create_patch_file(rompath, player=player, player_name=world.player_names[player][team])
|
os.unlink(rompath)
|
||||||
return player, team, bytes(rom.name)
|
return player, team, bytes(rom.name)
|
||||||
|
|
||||||
pool = concurrent.futures.ThreadPoolExecutor()
|
pool = concurrent.futures.ThreadPoolExecutor()
|
||||||
|
|
||||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
output = tempfile.TemporaryDirectory()
|
||||||
|
with output as temp_dir:
|
||||||
|
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||||
|
rom_futures = []
|
||||||
|
output_file_futures = []
|
||||||
|
for team in range(world.teams):
|
||||||
|
for player in world.get_game_players("A Link to the Past"):
|
||||||
|
rom_futures.append(pool.submit(_gen_rom, team, player, temp_dir))
|
||||||
|
for player in world.player_ids:
|
||||||
|
output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
rom_futures = []
|
def get_entrance_to_region(region: Region):
|
||||||
output_file_futures = []
|
for entrance in region.entrances:
|
||||||
for team in range(world.teams):
|
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
||||||
for player in world.alttp_player_ids:
|
return entrance
|
||||||
rom_futures.append(pool.submit(_gen_rom, team, player))
|
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
||||||
for player in world.player_ids:
|
return get_entrance_to_region(entrance.parent_region)
|
||||||
output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player))
|
|
||||||
|
|
||||||
def get_entrance_to_region(region: Region):
|
# collect ER hint info
|
||||||
for entrance in region.entrances:
|
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
||||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
|
world.shuffle[player] != "vanilla" or world.retro[player]}
|
||||||
return entrance
|
from worlds.alttp.Regions import RegionType
|
||||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
for region in world.regions:
|
||||||
return get_entrance_to_region(entrance.parent_region)
|
if region.player in er_hint_data and region.locations:
|
||||||
|
main_entrance = get_entrance_to_region(region)
|
||||||
|
for location in region.locations:
|
||||||
|
if type(location.address) == int: # skips events and crystals
|
||||||
|
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
||||||
|
er_hint_data[region.player][location.address] = main_entrance.name
|
||||||
|
|
||||||
# collect ER hint info
|
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||||
er_hint_data = {player: {} for player in range(1, world.players + 1) if
|
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||||
world.shuffle[player] != "vanilla" or world.retro[player]}
|
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
|
||||||
from worlds.alttp.Regions import RegionType
|
|
||||||
for region in world.regions:
|
|
||||||
if region.player in er_hint_data and region.locations:
|
|
||||||
main_entrance = get_entrance_to_region(region)
|
|
||||||
for location in region.locations:
|
|
||||||
if type(location.address) == int: # skips events and crystals
|
|
||||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
|
||||||
er_hint_data[region.player][location.address] = main_entrance.name
|
|
||||||
|
|
||||||
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||||
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
for player in range(1, world.players + 1)}
|
||||||
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
|
|
||||||
|
|
||||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
for player in range(1, world.players + 1):
|
||||||
for player in range(1, world.players + 1)}
|
checks_in_area[player]["Total"] = 0
|
||||||
|
|
||||||
for player in range(1, world.players + 1):
|
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
|
||||||
checks_in_area[player]["Total"] = 0
|
main_entrance = get_entrance_to_region(location.parent_region)
|
||||||
|
if location.game != "A Link to the Past":
|
||||||
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif location.parent_region.dungeon:
|
||||||
|
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||||
|
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||||
|
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||||
|
checks_in_area[location.player][dungeonname].append(location.address)
|
||||||
|
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||||
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
|
checks_in_area[location.player]["Total"] += 1
|
||||||
|
|
||||||
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
|
oldmancaves = []
|
||||||
main_entrance = get_entrance_to_region(location.parent_region)
|
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||||
if location.game != Games.LTTP:
|
for index, take_any in enumerate(takeanyregions):
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if
|
||||||
elif location.parent_region.dungeon:
|
world.retro[player]]:
|
||||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
item = world.create_item(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
region.player)
|
||||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
player = region.player
|
||||||
checks_in_area[location.player][dungeonname].append(location.address)
|
location_id = SHOP_ID_START + total_shop_slots + index
|
||||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
|
||||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
|
||||||
checks_in_area[location.player]["Total"] += 1
|
|
||||||
|
|
||||||
oldmancaves = []
|
main_entrance = get_entrance_to_region(region)
|
||||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
for index, take_any in enumerate(takeanyregions):
|
checks_in_area[player]["Light World"].append(location_id)
|
||||||
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if
|
else:
|
||||||
world.retro[player]]:
|
checks_in_area[player]["Dark World"].append(location_id)
|
||||||
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
checks_in_area[player]["Total"] += 1
|
||||||
region.player)
|
|
||||||
player = region.player
|
|
||||||
location_id = SHOP_ID_START + total_shop_slots + index
|
|
||||||
|
|
||||||
main_entrance = get_entrance_to_region(region)
|
er_hint_data[player][location_id] = main_entrance.name
|
||||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||||
checks_in_area[player]["Light World"].append(location_id)
|
|
||||||
|
FillDisabledShopSlots(world)
|
||||||
|
|
||||||
|
def write_multidata(roms, outputs):
|
||||||
|
import base64
|
||||||
|
import NetUtils
|
||||||
|
for future in roms:
|
||||||
|
rom_name = future.result()
|
||||||
|
rom_names.append(rom_name)
|
||||||
|
slot_data = {}
|
||||||
|
client_versions = {}
|
||||||
|
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
|
||||||
|
games = {}
|
||||||
|
for slot in world.player_ids:
|
||||||
|
client_versions[slot] = world.worlds[slot].get_required_client_version()
|
||||||
|
games[slot] = world.game[slot]
|
||||||
|
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
|
||||||
|
slot, team, rom_name in rom_names}
|
||||||
|
precollected_items = {player: [] for player in range(1, world.players + 1)}
|
||||||
|
for item in world.precollected_items:
|
||||||
|
precollected_items[item.player].append(item.code)
|
||||||
|
precollected_hints = {player: set() for player in range(1, world.players + 1)}
|
||||||
|
# for now special case Factorio tech_tree_information
|
||||||
|
sending_visible_players = set()
|
||||||
|
for player in world.get_game_players("Factorio"):
|
||||||
|
if world.tech_tree_information[player].value == 2:
|
||||||
|
sending_visible_players.add(player)
|
||||||
|
|
||||||
|
for i, team in enumerate(parsed_names):
|
||||||
|
for player, name in enumerate(team, 1):
|
||||||
|
if player not in world.get_game_players("A Link to the Past"):
|
||||||
|
connect_names[name] = (i, player)
|
||||||
|
|
||||||
|
for slot in world.player_ids:
|
||||||
|
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
|
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
|
||||||
|
for location in world.get_filled_locations():
|
||||||
|
if type(location.address) == int:
|
||||||
|
locations_data[location.player][location.address] = location.item.code, location.item.player
|
||||||
|
if location.player in sending_visible_players and location.item.player != location.player:
|
||||||
|
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||||
|
location.item.code, False)
|
||||||
|
precollected_hints[location.player].add(hint)
|
||||||
|
precollected_hints[location.item.player].add(hint)
|
||||||
|
elif location.item.name in args.start_hints[location.item.player]:
|
||||||
|
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||||
|
location.item.code, False,
|
||||||
|
er_hint_data.get(location.player, {}).get(location.address, ""))
|
||||||
|
precollected_hints[location.player].add(hint)
|
||||||
|
precollected_hints[location.item.player].add(hint)
|
||||||
|
|
||||||
|
multidata = zlib.compress(pickle.dumps({
|
||||||
|
"slot_data": slot_data,
|
||||||
|
"games": games,
|
||||||
|
"names": parsed_names,
|
||||||
|
"connect_names": connect_names,
|
||||||
|
"remote_items": {player for player in world.player_ids if
|
||||||
|
world.worlds[player].remote_items},
|
||||||
|
"locations": locations_data,
|
||||||
|
"checks_in_area": checks_in_area,
|
||||||
|
"server_options": get_options()["server_options"],
|
||||||
|
"er_hint_data": er_hint_data,
|
||||||
|
"precollected_items": precollected_items,
|
||||||
|
"precollected_hints": precollected_hints,
|
||||||
|
"version": tuple(version_tuple),
|
||||||
|
"tags": ["AP"],
|
||||||
|
"minimum_versions": minimum_versions,
|
||||||
|
"seed_name": world.seed_name
|
||||||
|
}), 9)
|
||||||
|
|
||||||
|
with open(os.path.join(temp_dir, '%s.archipelago' % outfilebase), 'wb') as f:
|
||||||
|
f.write(bytes([1])) # version of format
|
||||||
|
f.write(multidata)
|
||||||
|
for future in outputs:
|
||||||
|
future.result() # collect errors if they occured
|
||||||
|
|
||||||
|
multidata_task = pool.submit(write_multidata, rom_futures, output_file_futures)
|
||||||
|
if not check_accessibility_task.result():
|
||||||
|
if not world.can_beat_game():
|
||||||
|
raise Exception("Game appears as unbeatable. Aborting.")
|
||||||
else:
|
else:
|
||||||
checks_in_area[player]["Dark World"].append(location_id)
|
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||||
checks_in_area[player]["Total"] += 1
|
if multidata_task:
|
||||||
|
multidata_task.result() # retrieve exception if one exists
|
||||||
er_hint_data[player][location_id] = main_entrance.name
|
pool.shutdown() # wait for all queued tasks to complete
|
||||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
if not args.skip_playthrough:
|
||||||
|
logger.info('Calculating playthrough.')
|
||||||
FillDisabledShopSlots(world)
|
create_playthrough(world)
|
||||||
|
if args.create_spoiler: # needs spoiler.hashes to be filled, that depend on rom_futures being done
|
||||||
def write_multidata(roms, outputs):
|
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||||
import base64
|
zipfilename = output_path(f"AP_{world.seed_name}.zip")
|
||||||
import NetUtils
|
logger.info(f'Creating final archive at {zipfilename}.')
|
||||||
for future in roms:
|
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
||||||
rom_name = future.result()
|
compresslevel=9) as zf:
|
||||||
rom_names.append(rom_name)
|
for file in os.scandir(temp_dir):
|
||||||
slot_data = {}
|
zf.write(os.path.join(temp_dir, file), arcname=file.name)
|
||||||
client_versions = {}
|
|
||||||
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
|
|
||||||
games = {}
|
|
||||||
for slot in world.player_ids:
|
|
||||||
client_versions[slot] = world.worlds[slot].get_required_client_version()
|
|
||||||
games[slot] = world.game[slot]
|
|
||||||
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
|
|
||||||
slot, team, rom_name in rom_names}
|
|
||||||
precollected_items = {player: [] for player in range(1, world.players + 1)}
|
|
||||||
for item in world.precollected_items:
|
|
||||||
precollected_items[item.player].append(item.code)
|
|
||||||
precollected_hints = {player: set() for player in range(1, world.players + 1)}
|
|
||||||
# for now special case Factorio tech_tree_information
|
|
||||||
sending_visible_players = set()
|
|
||||||
for player in world.factorio_player_ids:
|
|
||||||
if world.tech_tree_information[player].value == 2:
|
|
||||||
sending_visible_players.add(player)
|
|
||||||
|
|
||||||
for i, team in enumerate(parsed_names):
|
|
||||||
for player, name in enumerate(team, 1):
|
|
||||||
if player not in world.alttp_player_ids:
|
|
||||||
connect_names[name] = (i, player)
|
|
||||||
if world.hk_player_ids:
|
|
||||||
for slot in world.hk_player_ids:
|
|
||||||
slot_data[slot] = AutoWorld.call_single(world, "fill_slot_data", slot)
|
|
||||||
for slot in world.minecraft_player_ids:
|
|
||||||
slot_data[slot] = AutoWorld.call_single(world, "fill_slot_data", slot)
|
|
||||||
|
|
||||||
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
|
|
||||||
for location in world.get_filled_locations():
|
|
||||||
if type(location.address) == int:
|
|
||||||
locations_data[location.player][location.address] = (location.item.code, location.item.player)
|
|
||||||
if location.player in sending_visible_players and location.item.player != location.player:
|
|
||||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
|
||||||
location.item.code, False)
|
|
||||||
precollected_hints[location.player].add(hint)
|
|
||||||
precollected_hints[location.item.player].add(hint)
|
|
||||||
elif location.item.name in args.start_hints[location.item.player]:
|
|
||||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
|
||||||
location.item.code, False,
|
|
||||||
er_hint_data.get(location.player, {}).get(location.address, ""))
|
|
||||||
precollected_hints[location.player].add(hint)
|
|
||||||
precollected_hints[location.item.player].add(hint)
|
|
||||||
|
|
||||||
multidata = zlib.compress(pickle.dumps({
|
|
||||||
"slot_data": slot_data,
|
|
||||||
"games": games,
|
|
||||||
"names": parsed_names,
|
|
||||||
"connect_names": connect_names,
|
|
||||||
"remote_items": {player for player in range(1, world.players + 1) if
|
|
||||||
world.remote_items[player]},
|
|
||||||
"locations": locations_data,
|
|
||||||
"checks_in_area": checks_in_area,
|
|
||||||
"server_options": get_options()["server_options"],
|
|
||||||
"er_hint_data": er_hint_data,
|
|
||||||
"precollected_items": precollected_items,
|
|
||||||
"precollected_hints": precollected_hints,
|
|
||||||
"version": tuple(version_tuple),
|
|
||||||
"tags": ["AP"],
|
|
||||||
"minimum_versions": minimum_versions,
|
|
||||||
"seed_name": world.seed_name
|
|
||||||
}), 9)
|
|
||||||
|
|
||||||
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
|
|
||||||
f.write(bytes([1])) # version of format
|
|
||||||
f.write(multidata)
|
|
||||||
for future in outputs:
|
|
||||||
future.result() # collect errors if they occured
|
|
||||||
|
|
||||||
multidata_task = pool.submit(write_multidata, rom_futures, output_file_futures)
|
|
||||||
if not check_accessibility_task.result():
|
|
||||||
if not world.can_beat_game():
|
|
||||||
raise Exception("Game appears as unbeatable. Aborting.")
|
|
||||||
else:
|
|
||||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
|
||||||
if multidata_task:
|
|
||||||
multidata_task.result() # retrieve exception if one exists
|
|
||||||
pool.shutdown() # wait for all queued tasks to complete
|
|
||||||
if not args.skip_playthrough:
|
|
||||||
logger.info('Calculating playthrough.')
|
|
||||||
create_playthrough(world)
|
|
||||||
if args.create_spoiler: # needs spoiler.hashes to be filled, that depend on rom_futures being done
|
|
||||||
world.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
|
||||||
|
|
||||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||||
return world
|
return world
|
||||||
@@ -577,7 +538,8 @@ def create_playthrough(world):
|
|||||||
while sphere_candidates:
|
while sphere_candidates:
|
||||||
state.sweep_for_events(key_only=True)
|
state.sweep_for_events(key_only=True)
|
||||||
|
|
||||||
# build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
# build up spheres of collection radius.
|
||||||
|
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||||
|
|
||||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||||
|
|
||||||
@@ -678,7 +640,7 @@ def create_playthrough(world):
|
|||||||
world.spoiler.paths.update(
|
world.spoiler.paths.update(
|
||||||
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
||||||
sphere if location.player == player})
|
sphere if location.player == player})
|
||||||
if player in world.alttp_player_ids:
|
if player in world.get_game_players("A Link to the Past"):
|
||||||
for path in dict(world.spoiler.paths).values():
|
for path in dict(world.spoiler.paths).values():
|
||||||
if any(exit == 'Pyramid Fairy' for (_, exit) in path):
|
if any(exit == 'Pyramid Fairy' for (_, exit) in path):
|
||||||
if world.mode[player] != 'inverted':
|
if world.mode[player] != 'inverted':
|
||||||
|
|||||||
226
MultiMystery.py
@@ -1,226 +0,0 @@
|
|||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import concurrent.futures
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
from shutil import which
|
|
||||||
|
|
||||||
|
|
||||||
def feedback(text: str):
|
|
||||||
logging.info(text)
|
|
||||||
input("Press Enter to ignore and probably crash.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
logging.basicConfig(format='%(message)s', level=logging.INFO)
|
|
||||||
try:
|
|
||||||
import ModuleUpdate
|
|
||||||
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(add_help=False)
|
|
||||||
parser.add_argument('--disable_autohost', action='store_true')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
from Utils import get_public_ipv4, get_options
|
|
||||||
from Mystery import get_seed_name
|
|
||||||
|
|
||||||
options = get_options()
|
|
||||||
|
|
||||||
multi_mystery_options = options["multi_mystery_options"]
|
|
||||||
output_path = options["general_options"]["output_path"]
|
|
||||||
enemizer_path = multi_mystery_options["enemizer_path"]
|
|
||||||
player_files_path = multi_mystery_options["player_files_path"]
|
|
||||||
target_player_count = multi_mystery_options["players"]
|
|
||||||
glitch_triforce = multi_mystery_options["glitch_triforce_room"]
|
|
||||||
race = multi_mystery_options["race"]
|
|
||||||
plando_options = multi_mystery_options["plando_options"]
|
|
||||||
create_spoiler = multi_mystery_options["create_spoiler"]
|
|
||||||
zip_roms = multi_mystery_options["zip_roms"]
|
|
||||||
zip_diffs = multi_mystery_options["zip_diffs"]
|
|
||||||
zip_apmcs = multi_mystery_options["zip_apmcs"]
|
|
||||||
zip_spoiler = multi_mystery_options["zip_spoiler"]
|
|
||||||
zip_multidata = multi_mystery_options["zip_multidata"]
|
|
||||||
zip_format = multi_mystery_options["zip_format"]
|
|
||||||
# zip_password = multi_mystery_options["zip_password"] not at this time
|
|
||||||
meta_file_path = multi_mystery_options["meta_file_path"]
|
|
||||||
weights_file_path = multi_mystery_options["weights_file_path"]
|
|
||||||
pre_roll = multi_mystery_options["pre_roll"]
|
|
||||||
teams = multi_mystery_options["teams"]
|
|
||||||
rom_file = options["lttp_options"]["rom_file"]
|
|
||||||
host = options["server_options"]["host"]
|
|
||||||
port = options["server_options"]["port"]
|
|
||||||
|
|
||||||
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
||||||
|
|
||||||
if not os.path.exists(enemizer_path):
|
|
||||||
feedback(
|
|
||||||
f"Enemizer not found at {enemizer_path}, please adjust the path in MultiMystery.py's config or put Enemizer in the default location.")
|
|
||||||
if not os.path.exists(rom_file):
|
|
||||||
feedback(f"Base rom is expected as {rom_file} in the Multiworld root folder please place/rename it there.")
|
|
||||||
player_files = []
|
|
||||||
os.makedirs(player_files_path, exist_ok=True)
|
|
||||||
for file in os.listdir(player_files_path):
|
|
||||||
lfile = file.lower()
|
|
||||||
if lfile.endswith(".yaml") and lfile != meta_file_path.lower() and lfile != weights_file_path.lower():
|
|
||||||
player_files.append(file)
|
|
||||||
logging.info(f"Found player's file {file}.")
|
|
||||||
|
|
||||||
player_string = ""
|
|
||||||
for i, file in enumerate(player_files, 1):
|
|
||||||
player_string += f"--p{i} \"{os.path.join(player_files_path, file)}\" "
|
|
||||||
|
|
||||||
if os.path.exists("ArchipelagoMystery.exe"):
|
|
||||||
basemysterycommand = "ArchipelagoMystery.exe" # compiled windows
|
|
||||||
elif os.path.exists("ArchipelagoMystery"):
|
|
||||||
basemysterycommand = "./ArchipelagoMystery" # compiled linux
|
|
||||||
elif which('py'):
|
|
||||||
basemysterycommand = f"py -{py_version} Mystery.py" # source windows
|
|
||||||
else:
|
|
||||||
basemysterycommand = f"python3 Mystery.py" # source others
|
|
||||||
|
|
||||||
weights_file_path = os.path.join(player_files_path, weights_file_path)
|
|
||||||
if os.path.exists(weights_file_path):
|
|
||||||
target_player_count = max(len(player_files), target_player_count)
|
|
||||||
else:
|
|
||||||
target_player_count = len(player_files)
|
|
||||||
|
|
||||||
if target_player_count == 0:
|
|
||||||
feedback(f"No player files found. Please put them in a {player_files_path} folder.")
|
|
||||||
else:
|
|
||||||
logging.info(f"{target_player_count} Players found.")
|
|
||||||
seed_name = get_seed_name(random)
|
|
||||||
command = f"{basemysterycommand} --multi {target_player_count} {player_string} " \
|
|
||||||
f"--rom \"{rom_file}\" --enemizercli \"{enemizer_path}\" " \
|
|
||||||
f"--outputpath \"{output_path}\" --teams {teams} --plando \"{plando_options}\" " \
|
|
||||||
f"--seed_name {seed_name}"
|
|
||||||
|
|
||||||
if create_spoiler:
|
|
||||||
command += " --create_spoiler"
|
|
||||||
if create_spoiler == 2:
|
|
||||||
command += " --skip_playthrough"
|
|
||||||
if zip_diffs:
|
|
||||||
command += " --create_diff"
|
|
||||||
if glitch_triforce:
|
|
||||||
command += " --glitch_triforce"
|
|
||||||
if race:
|
|
||||||
command += " --race"
|
|
||||||
if os.path.exists(os.path.join(player_files_path, meta_file_path)):
|
|
||||||
command += f" --meta {os.path.join(player_files_path, meta_file_path)}"
|
|
||||||
if os.path.exists(weights_file_path):
|
|
||||||
command += f" --weights {weights_file_path}"
|
|
||||||
if pre_roll:
|
|
||||||
command += " --pre_roll"
|
|
||||||
|
|
||||||
logging.info(command)
|
|
||||||
import time
|
|
||||||
|
|
||||||
start = time.perf_counter()
|
|
||||||
text = subprocess.check_output(command, shell=True).decode()
|
|
||||||
logging.info(f"Took {time.perf_counter() - start:.3f} seconds to generate multiworld.")
|
|
||||||
|
|
||||||
multidataname = f"AP_{seed_name}.archipelago"
|
|
||||||
spoilername = f"AP_{seed_name}_Spoiler.txt"
|
|
||||||
romfilename = ""
|
|
||||||
|
|
||||||
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs, zip_apmcs)):
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
compression = {1: zipfile.ZIP_DEFLATED,
|
|
||||||
2: zipfile.ZIP_LZMA,
|
|
||||||
3: zipfile.ZIP_BZIP2}[zip_format]
|
|
||||||
|
|
||||||
typical_zip_ending = {1: "zip",
|
|
||||||
2: "7z",
|
|
||||||
3: "bz2"}[zip_format]
|
|
||||||
|
|
||||||
ziplock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def pack_file(file: str):
|
|
||||||
with ziplock:
|
|
||||||
zf.write(os.path.join(output_path, file), file)
|
|
||||||
logging.info(f"Packed {file} into zipfile {zipname}")
|
|
||||||
|
|
||||||
|
|
||||||
def remove_zipped_file(file: str):
|
|
||||||
os.remove(os.path.join(output_path, file))
|
|
||||||
logging.info(f"Removed {file} which is now present in the zipfile")
|
|
||||||
|
|
||||||
|
|
||||||
zipname = os.path.join(output_path, f"AP_{seed_name}.{typical_zip_ending}")
|
|
||||||
|
|
||||||
logging.info(f"Creating zipfile {zipname}")
|
|
||||||
ipv4 = (host if host else get_public_ipv4()) + ":" + str(port)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_sfc_file(file: str):
|
|
||||||
if zip_roms:
|
|
||||||
pack_file(file)
|
|
||||||
if zip_roms == 2:
|
|
||||||
remove_zipped_file(file)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_diff_file(file: str):
|
|
||||||
if zip_diffs > 0:
|
|
||||||
pack_file(file)
|
|
||||||
if zip_diffs == 2:
|
|
||||||
remove_zipped_file(file)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_apmc_file(file: str):
|
|
||||||
if zip_apmcs:
|
|
||||||
pack_file(file)
|
|
||||||
if zip_apmcs == 2:
|
|
||||||
remove_zipped_file(file)
|
|
||||||
|
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
||||||
futures = []
|
|
||||||
files = os.listdir(output_path)
|
|
||||||
with zipfile.ZipFile(zipname, "w", compression=compression, compresslevel=9) as zf:
|
|
||||||
for file in files:
|
|
||||||
if seed_name in file:
|
|
||||||
if file.endswith(".sfc"):
|
|
||||||
futures.append(pool.submit(_handle_sfc_file, file))
|
|
||||||
elif file.endswith(".apbp"):
|
|
||||||
futures.append(pool.submit(_handle_diff_file, file))
|
|
||||||
elif file.endswith(".apmc"):
|
|
||||||
futures.append(pool.submit(_handle_apmc_file, file))
|
|
||||||
# just handle like a diff file for now
|
|
||||||
elif file.endswith(".zip"):
|
|
||||||
futures.append(pool.submit(_handle_diff_file, file))
|
|
||||||
|
|
||||||
if zip_multidata and os.path.exists(os.path.join(output_path, multidataname)):
|
|
||||||
pack_file(multidataname)
|
|
||||||
if zip_multidata == 2:
|
|
||||||
remove_zipped_file(multidataname)
|
|
||||||
|
|
||||||
if zip_spoiler and create_spoiler:
|
|
||||||
pack_file(spoilername)
|
|
||||||
if zip_spoiler == 2:
|
|
||||||
remove_zipped_file(spoilername)
|
|
||||||
|
|
||||||
for future in futures:
|
|
||||||
future.result() # make sure we close the zip AFTER any packing is done
|
|
||||||
|
|
||||||
if not args.disable_autohost:
|
|
||||||
if os.path.exists(os.path.join(output_path, multidataname)):
|
|
||||||
if os.path.exists("ArchipelagoServer.exe"):
|
|
||||||
baseservercommand = ["ArchipelagoServer.exe"] # compiled windows
|
|
||||||
elif os.path.exists("ArchipelagoServer"):
|
|
||||||
baseservercommand = ["./ArchipelagoServer"] # compiled linux
|
|
||||||
elif which('py'):
|
|
||||||
baseservercommand = ["py", f"-{py_version}", "MultiServer.py"] # source windows
|
|
||||||
else:
|
|
||||||
baseservercommand = ["python3", "MultiServer.py"] # source others
|
|
||||||
# don't have a mac to test that. If you try to run compiled on mac, good luck.
|
|
||||||
subprocess.call(baseservercommand + ["--multidata", os.path.join(output_path, multidataname)])
|
|
||||||
except:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
input("Press enter to close")
|
|
||||||
165
MultiServer.py
@@ -25,20 +25,15 @@ import prompt_toolkit
|
|||||||
from prompt_toolkit.patch_stdout import patch_stdout
|
from prompt_toolkit.patch_stdout import patch_stdout
|
||||||
from fuzzywuzzy import process as fuzzy_process
|
from fuzzywuzzy import process as fuzzy_process
|
||||||
|
|
||||||
from worlds.alttp import Items, Regions
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_item_name_to_id, \
|
proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
|
||||||
lookup_any_location_id_to_name, lookup_any_location_name_to_id
|
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import get_item_name_from_id, get_location_name_from_id, \
|
from Utils import get_item_name_from_id, get_location_name_from_id, \
|
||||||
version_tuple, restricted_loads, Version
|
version_tuple, restricted_loads, Version
|
||||||
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer
|
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer
|
||||||
|
|
||||||
colorama.init()
|
colorama.init()
|
||||||
lttp_console_names = frozenset(set(Items.item_table) | set(Items.item_name_groups) | set(Regions.lookup_name_to_id))
|
|
||||||
all_items = frozenset(lookup_any_item_name_to_id)
|
|
||||||
all_locations = frozenset(lookup_any_location_name_to_id)
|
|
||||||
all_console_names = frozenset(all_items | all_locations)
|
|
||||||
|
|
||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
version = Version(0, 0, 0)
|
version = Version(0, 0, 0)
|
||||||
@@ -55,6 +50,7 @@ class Client(Endpoint):
|
|||||||
self.messageprocessor = client_message_processor(ctx, self)
|
self.messageprocessor = client_message_processor(ctx, self)
|
||||||
self.ctx = weakref.ref(ctx)
|
self.ctx = weakref.ref(ctx)
|
||||||
|
|
||||||
|
team_slot = typing.Tuple[int, int]
|
||||||
|
|
||||||
class Context(Node):
|
class Context(Node):
|
||||||
simple_options = {"hint_cost": int,
|
simple_options = {"hint_cost": int,
|
||||||
@@ -75,7 +71,8 @@ class Context(Node):
|
|||||||
self.data_filename = None
|
self.data_filename = None
|
||||||
self.save_filename = None
|
self.save_filename = None
|
||||||
self.saving = False
|
self.saving = False
|
||||||
self.player_names = {}
|
self.player_names: typing.Dict[team_slot, str] = {}
|
||||||
|
self.player_name_lookup: typing.Dict[str, team_slot] = {}
|
||||||
self.connect_names = {} # names of slots clients can connect to
|
self.connect_names = {} # names of slots clients can connect to
|
||||||
self.allow_forfeits = {}
|
self.allow_forfeits = {}
|
||||||
self.remote_items = set()
|
self.remote_items = set()
|
||||||
@@ -87,21 +84,21 @@ class Context(Node):
|
|||||||
self.server = None
|
self.server = None
|
||||||
self.countdown_timer = 0
|
self.countdown_timer = 0
|
||||||
self.received_items = {}
|
self.received_items = {}
|
||||||
self.name_aliases: typing.Dict[typing.Tuple[int, int], str] = {}
|
self.name_aliases: typing.Dict[team_slot, str] = {}
|
||||||
self.location_checks = collections.defaultdict(set)
|
self.location_checks = collections.defaultdict(set)
|
||||||
self.hint_cost = hint_cost
|
self.hint_cost = hint_cost
|
||||||
self.location_check_points = location_check_points
|
self.location_check_points = location_check_points
|
||||||
self.hints_used = collections.defaultdict(int)
|
self.hints_used = collections.defaultdict(int)
|
||||||
self.hints: typing.Dict[typing.Tuple[int, int], typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
||||||
self.forfeit_mode: str = forfeit_mode
|
self.forfeit_mode: str = forfeit_mode
|
||||||
self.remaining_mode: str = remaining_mode
|
self.remaining_mode: str = remaining_mode
|
||||||
self.item_cheat = item_cheat
|
self.item_cheat = item_cheat
|
||||||
self.running = True
|
self.running = True
|
||||||
self.client_activity_timers: typing.Dict[
|
self.client_activity_timers: typing.Dict[
|
||||||
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last new item check
|
team_slot, datetime.datetime] = {} # datetime of last new item check
|
||||||
self.client_connection_timers: typing.Dict[
|
self.client_connection_timers: typing.Dict[
|
||||||
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last connection
|
team_slot, datetime.datetime] = {} # datetime of last connection
|
||||||
self.client_game_state: typing.Dict[typing.Tuple[int, int], int] = collections.defaultdict(int)
|
self.client_game_state: typing.Dict[team_slot, int] = collections.defaultdict(int)
|
||||||
self.er_hint_data: typing.Dict[int, typing.Dict[int, str]] = {}
|
self.er_hint_data: typing.Dict[int, typing.Dict[int, str]] = {}
|
||||||
self.auto_shutdown = auto_shutdown
|
self.auto_shutdown = auto_shutdown
|
||||||
self.commandprocessor = ServerCommandProcessor(self)
|
self.commandprocessor = ServerCommandProcessor(self)
|
||||||
@@ -111,7 +108,7 @@ class Context(Node):
|
|||||||
self.auto_saver_thread = None
|
self.auto_saver_thread = None
|
||||||
self.save_dirty = False
|
self.save_dirty = False
|
||||||
self.tags = ['AP']
|
self.tags = ['AP']
|
||||||
self.games = {}
|
self.games: typing.Dict[int, str] = {}
|
||||||
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
|
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
|
||||||
self.seed_name = ""
|
self.seed_name = ""
|
||||||
|
|
||||||
@@ -147,7 +144,8 @@ class Context(Node):
|
|||||||
|
|
||||||
for team, names in enumerate(decoded_obj['names']):
|
for team, names in enumerate(decoded_obj['names']):
|
||||||
for player, name in enumerate(names, 1):
|
for player, name in enumerate(names, 1):
|
||||||
self.player_names[(team, player)] = name
|
self.player_names[team, player] = name
|
||||||
|
self.player_name_lookup[name] = team, player
|
||||||
self.seed_name = decoded_obj["seed_name"]
|
self.seed_name = decoded_obj["seed_name"]
|
||||||
self.connect_names = decoded_obj['connect_names']
|
self.connect_names = decoded_obj['connect_names']
|
||||||
self.remote_items = decoded_obj['remote_items']
|
self.remote_items = decoded_obj['remote_items']
|
||||||
@@ -396,6 +394,8 @@ async def on_client_connected(ctx: Context, client: Client):
|
|||||||
'hint_cost': ctx.hint_cost,
|
'hint_cost': ctx.hint_cost,
|
||||||
'location_check_points': ctx.location_check_points,
|
'location_check_points': ctx.location_check_points,
|
||||||
'datapackage_version': network_data_package["version"],
|
'datapackage_version': network_data_package["version"],
|
||||||
|
'datapackage_versions': {game: game_data["version"] for game, game_data
|
||||||
|
in network_data_package["games"].items()},
|
||||||
'seed_name': ctx.seed_name
|
'seed_name': ctx.seed_name
|
||||||
}])
|
}])
|
||||||
|
|
||||||
@@ -519,7 +519,7 @@ def notify_team(ctx: Context, team: int, text: str):
|
|||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
|
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
|
||||||
hints = []
|
hints = []
|
||||||
seeked_item_id = lookup_any_item_name_to_id[item]
|
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
|
||||||
for finding_player, check_data in ctx.locations.items():
|
for finding_player, check_data in ctx.locations.items():
|
||||||
for location_id, result in check_data.items():
|
for location_id, result in check_data.items():
|
||||||
item_id, receiving_player = result
|
item_id, receiving_player = result
|
||||||
@@ -532,7 +532,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
|
|||||||
|
|
||||||
|
|
||||||
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
||||||
seeked_location: int = Regions.lookup_name_to_id[location]
|
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
|
||||||
item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None))
|
item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None))
|
||||||
if item_id:
|
if item_id:
|
||||||
found = seeked_location in ctx.location_checks[team, slot]
|
found = seeked_location in ctx.location_checks[team, slot]
|
||||||
@@ -573,8 +573,7 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
|||||||
"item": net_item}
|
"item": net_item}
|
||||||
|
|
||||||
|
|
||||||
def get_intended_text(input_text: str, possible_answers: typing.Iterable[str] = all_console_names) -> typing.Tuple[
|
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
|
||||||
str, bool, str]:
|
|
||||||
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
|
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
|
||||||
if len(picks) > 1:
|
if len(picks) > 1:
|
||||||
dif = picks[0][1] - picks[1][1]
|
dif = picks[0][1] - picks[1][1]
|
||||||
@@ -865,9 +864,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def _cmd_getitem(self, item_name: str) -> bool:
|
def _cmd_getitem(self, item_name: str) -> bool:
|
||||||
"""Cheat in an item, if it is enabled on this server"""
|
"""Cheat in an item, if it is enabled on this server"""
|
||||||
if self.ctx.item_cheat:
|
if self.ctx.item_cheat:
|
||||||
item_name, usable, response = get_intended_text(item_name, Items.item_table.keys())
|
world = proxy_worlds[self.ctx.games[self.client.slot]]
|
||||||
|
item_name, usable, response = get_intended_text(item_name,
|
||||||
|
world.item_names)
|
||||||
if usable:
|
if usable:
|
||||||
new_item = NetworkItem(Items.item_table[item_name][2], -1, self.client.slot)
|
new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot)
|
||||||
get_received_items(self.ctx, self.client.team, self.client.slot).append(new_item)
|
get_received_items(self.ctx, self.client.team, self.client.slot).append(new_item)
|
||||||
self.ctx.notify_all(
|
self.ctx.notify_all(
|
||||||
'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team,
|
'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team,
|
||||||
@@ -894,16 +895,17 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
notify_hints(self.ctx, self.client.team, list(hints))
|
notify_hints(self.ctx, self.client.team, list(hints))
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
item_name, usable, response = get_intended_text(item_or_location)
|
world = proxy_worlds[self.ctx.games[self.client.slot]]
|
||||||
|
item_name, usable, response = get_intended_text(item_or_location, world.all_names)
|
||||||
if usable:
|
if usable:
|
||||||
if item_name in Items.hint_blacklist:
|
if item_name in world.hint_blacklist:
|
||||||
self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.")
|
||||||
hints = []
|
hints = []
|
||||||
elif item_name in Items.item_name_groups:
|
elif item_name in world.item_name_groups:
|
||||||
hints = []
|
hints = []
|
||||||
for item in Items.item_name_groups[item_name]:
|
for item in world.item_name_groups[item_name]:
|
||||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
|
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
|
||||||
elif item_name in lookup_any_item_name_to_id: # item name
|
elif item_name in world.item_names: # item name
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name)
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name)
|
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name)
|
||||||
@@ -983,21 +985,19 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
cmd: str = args["cmd"]
|
cmd: str = args["cmd"]
|
||||||
except:
|
except:
|
||||||
logging.exception(f"Could not get command from {args}")
|
logging.exception(f"Could not get command from {args}")
|
||||||
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
||||||
|
"text": f"Could not get command from {args} at `cmd`"}])
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if type(cmd) is not str:
|
if type(cmd) is not str:
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidCmd", "text": f"Command should be str, got {type(cmd)}"}])
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
||||||
return
|
"text": f"Command should be str, got {type(cmd)}"}])
|
||||||
|
|
||||||
if args is not None and type(args) != dict:
|
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidArguments",
|
|
||||||
'text': f'Expected Optional[dict], got {type(args)} for {cmd}'}])
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if cmd == 'Connect':
|
if cmd == 'Connect':
|
||||||
if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \
|
if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \
|
||||||
'game' not in args:
|
'game' not in args:
|
||||||
await ctx.send_msgs(client, [{'cmd': 'InvalidArguments', 'text': 'Connect'}])
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", 'text': 'Connect'}])
|
||||||
return
|
return
|
||||||
|
|
||||||
errors = set()
|
errors = set()
|
||||||
@@ -1017,6 +1017,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
if clients:
|
if clients:
|
||||||
# likely same player with a "ghosted" slot. We bust the ghost.
|
# likely same player with a "ghosted" slot. We bust the ghost.
|
||||||
if "uuid" in args and ctx.client_ids[team, slot] == args["uuid"]:
|
if "uuid" in args and ctx.client_ids[team, slot] == args["uuid"]:
|
||||||
|
await ctx.send_msgs(clients[0], [{"cmd": "Print", "text": "You are getting kicked "
|
||||||
|
"by yourself reconnecting."}])
|
||||||
await clients[0].socket.close() # we have to await the DC of the ghost, so not to create data pasta
|
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.name = ctx.player_names[(team, slot)]
|
||||||
client.team = team
|
client.team = team
|
||||||
@@ -1048,6 +1050,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
"players": ctx.get_players_package(),
|
"players": ctx.get_players_package(),
|
||||||
"missing_locations": get_missing_checks(ctx, client),
|
"missing_locations": get_missing_checks(ctx, client),
|
||||||
"checked_locations": get_checked_checks(ctx, client),
|
"checked_locations": get_checked_checks(ctx, client),
|
||||||
|
# get is needed for old multidata that was sparsely populated
|
||||||
"slot_data": ctx.slot_data.get(client.slot, {})
|
"slot_data": ctx.slot_data.get(client.slot, {})
|
||||||
}]
|
}]
|
||||||
items = get_received_items(ctx, client.team, client.slot)
|
items = get_received_items(ctx, client.team, client.slot)
|
||||||
@@ -1059,8 +1062,17 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
await on_client_joined(ctx, client)
|
await on_client_joined(ctx, client)
|
||||||
|
|
||||||
elif cmd == "GetDataPackage":
|
elif cmd == "GetDataPackage":
|
||||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
exclusions = set(args.get("exclusions", []))
|
||||||
"data": network_data_package}])
|
if exclusions:
|
||||||
|
games = {name: game_data for name, game_data in network_data_package["games"].items()
|
||||||
|
if name not in exclusions}
|
||||||
|
package = network_data_package.copy()
|
||||||
|
package["games"] = games
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||||
|
"data": package}])
|
||||||
|
else:
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||||
|
"data": network_data_package}])
|
||||||
elif client.auth:
|
elif client.auth:
|
||||||
if cmd == 'Sync':
|
if cmd == 'Sync':
|
||||||
items = get_received_items(ctx, client.team, client.slot)
|
items = get_received_items(ctx, client.team, client.slot)
|
||||||
@@ -1076,7 +1088,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
locs = []
|
locs = []
|
||||||
for location in args["locations"]:
|
for location in args["locations"]:
|
||||||
if type(location) is not int or location not in lookup_any_location_id_to_name:
|
if type(location) is not int or location not in lookup_any_location_id_to_name:
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text": 'LocationScouts'}])
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts'}])
|
||||||
return
|
return
|
||||||
target_item, target_player = ctx.locations[client.slot][location]
|
target_item, target_player = ctx.locations[client.slot][location]
|
||||||
locs.append(NetworkItem(target_item, location, target_player))
|
locs.append(NetworkItem(target_item, location, target_player))
|
||||||
@@ -1088,7 +1100,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
|
|
||||||
if cmd == 'Say':
|
if cmd == 'Say':
|
||||||
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text": 'Say'}])
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'Say'}])
|
||||||
return
|
return
|
||||||
|
|
||||||
client.messageprocessor(args["text"])
|
client.messageprocessor(args["text"])
|
||||||
@@ -1224,17 +1236,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
"""Sends an item to the specified player"""
|
"""Sends an item to the specified player"""
|
||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
item = " ".join(item_name)
|
item = " ".join(item_name)
|
||||||
item, usable, response = get_intended_text(item, all_items)
|
world = proxy_worlds[self.ctx.games[slot]]
|
||||||
|
item, usable, response = get_intended_text(item, world.item_names)
|
||||||
if usable:
|
if usable:
|
||||||
for client in self.ctx.endpoints:
|
new_item = NetworkItem(world.item_name_to_id[item], -1, 0)
|
||||||
if client.name == seeked_player:
|
get_received_items(self.ctx, team, slot).append(new_item)
|
||||||
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, 0)
|
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
|
||||||
get_received_items(self.ctx, client.team, client.slot).append(new_item)
|
self.ctx.get_aliased_name(team, slot))
|
||||||
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
|
send_new_items(self.ctx)
|
||||||
self.ctx.get_aliased_name(client.team, client.slot))
|
return True
|
||||||
send_new_items(self.ctx)
|
|
||||||
return True
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
@@ -1246,27 +1258,27 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
"""Send out a hint for a player's item or location to their team"""
|
"""Send out a hint for a player's item or location to their team"""
|
||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
if name == seeked_player:
|
item = " ".join(item_or_location)
|
||||||
item = " ".join(item_or_location)
|
world = proxy_worlds[self.ctx.games[slot]]
|
||||||
item, usable, response = get_intended_text(item)
|
item, usable, response = get_intended_text(item, world.all_names)
|
||||||
if usable:
|
if usable:
|
||||||
if item in Items.item_name_groups:
|
if item in world.item_name_groups:
|
||||||
hints = []
|
hints = []
|
||||||
for item in Items.item_name_groups[item]:
|
for item in world.item_name_groups[item]:
|
||||||
hints.extend(collect_hints(self.ctx, team, slot, item))
|
hints.extend(collect_hints(self.ctx, team, slot, item))
|
||||||
elif item in all_items: # item name
|
elif item in world.item_names: # item name
|
||||||
hints = collect_hints(self.ctx, team, slot, item)
|
hints = collect_hints(self.ctx, team, slot, item)
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hints_location(self.ctx, team, slot, item)
|
hints = collect_hints_location(self.ctx, team, slot, item)
|
||||||
if hints:
|
if hints:
|
||||||
notify_hints(self.ctx, team, hints)
|
notify_hints(self.ctx, team, hints)
|
||||||
else:
|
else:
|
||||||
self.output("No hints found.")
|
self.output("No hints found.")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
@@ -1310,11 +1322,11 @@ async def console(ctx: Context):
|
|||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
defaults = Utils.get_options()["server_options"]
|
defaults = Utils.get_options()["server_options"]
|
||||||
|
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
||||||
parser.add_argument('--host', default=defaults["host"])
|
parser.add_argument('--host', default=defaults["host"])
|
||||||
parser.add_argument('--port', default=defaults["port"], type=int)
|
parser.add_argument('--port', default=defaults["port"], type=int)
|
||||||
parser.add_argument('--server_password', default=defaults["server_password"])
|
parser.add_argument('--server_password', default=defaults["server_password"])
|
||||||
parser.add_argument('--password', default=defaults["password"])
|
parser.add_argument('--password', default=defaults["password"])
|
||||||
parser.add_argument('--multidata', default=defaults["multidata"])
|
|
||||||
parser.add_argument('--savefile', default=defaults["savefile"])
|
parser.add_argument('--savefile', default=defaults["savefile"])
|
||||||
parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true')
|
parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true')
|
||||||
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
||||||
@@ -1396,7 +1408,20 @@ async def main(args: argparse.Namespace):
|
|||||||
import tkinter.filedialog
|
import tkinter.filedialog
|
||||||
root = tkinter.Tk()
|
root = tkinter.Tk()
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago"),))
|
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
|
||||||
|
|
||||||
|
if data_filename.endswith(".zip"):
|
||||||
|
import zipfile
|
||||||
|
with zipfile.ZipFile(data_filename) as zf:
|
||||||
|
for file in zf.namelist():
|
||||||
|
if file.endswith(".archipelago"):
|
||||||
|
import os
|
||||||
|
data_filename = os.path.join(os.path.dirname(data_filename), file)
|
||||||
|
with open(data_filename, "wb") as f:
|
||||||
|
f.write(zf.read(file))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise Exception("No .archipelago found in archive.")
|
||||||
|
|
||||||
ctx.load(data_filename, args.use_embedded_options)
|
ctx.load(data_filename, args.use_embedded_options)
|
||||||
|
|
||||||
|
|||||||
16
NetUtils.py
@@ -109,9 +109,9 @@ class Node:
|
|||||||
for endpoint in self.endpoints:
|
for endpoint in self.endpoints:
|
||||||
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
|
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
|
||||||
|
|
||||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]):
|
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
||||||
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
|
if not endpoint.socket or not endpoint.socket.open:
|
||||||
return
|
return False
|
||||||
msg = self.dumper(msgs)
|
msg = self.dumper(msgs)
|
||||||
try:
|
try:
|
||||||
await endpoint.socket.send(msg)
|
await endpoint.socket.send(msg)
|
||||||
@@ -121,18 +121,20 @@ class Node:
|
|||||||
else:
|
else:
|
||||||
if self.log_network:
|
if self.log_network:
|
||||||
logging.info(f"Outgoing message: {msg}")
|
logging.info(f"Outgoing message: {msg}")
|
||||||
|
return True
|
||||||
|
|
||||||
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str):
|
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
|
||||||
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
|
if not endpoint.socket or not endpoint.socket.open:
|
||||||
return
|
return False
|
||||||
try:
|
try:
|
||||||
await endpoint.socket.send(msg)
|
await endpoint.socket.send(msg)
|
||||||
except websockets.ConnectionClosed:
|
except websockets.ConnectionClosed:
|
||||||
logging.exception("Exception during send_msgs")
|
logging.exception("Exception during send_encoded_msgs")
|
||||||
await self.disconnect(endpoint)
|
await self.disconnect(endpoint)
|
||||||
else:
|
else:
|
||||||
if self.log_network:
|
if self.log_network:
|
||||||
logging.info(f"Outgoing message: {msg}")
|
logging.info(f"Outgoing message: {msg}")
|
||||||
|
return True
|
||||||
|
|
||||||
async def disconnect(self, endpoint):
|
async def disconnect(self, endpoint):
|
||||||
if endpoint in self.endpoints:
|
if endpoint in self.endpoints:
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ class AssembleOptions(type):
|
|||||||
# apply aliases, without name_lookup
|
# apply aliases, without name_lookup
|
||||||
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
|
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||||
name.startswith("alias_")})
|
name.startswith("alias_")})
|
||||||
|
|
||||||
|
# auto-validate schema on __init__
|
||||||
|
if "schema" in attrs.keys():
|
||||||
|
def validate_decorator(func):
|
||||||
|
def validate(self, *args, **kwargs):
|
||||||
|
func(self, *args, **kwargs)
|
||||||
|
self.value = self.schema.validate(self.value)
|
||||||
|
return validate
|
||||||
|
attrs["__init__"] = validate_decorator(attrs["__init__"])
|
||||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
class Option(metaclass=AssembleOptions):
|
class Option(metaclass=AssembleOptions):
|
||||||
|
|||||||
23
Utils.py
@@ -13,7 +13,7 @@ class Version(typing.NamedTuple):
|
|||||||
build: int
|
build: int
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.1.5"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
import builtins
|
import builtins
|
||||||
@@ -84,7 +84,7 @@ def cache_argsless(function):
|
|||||||
return _wrap
|
return _wrap
|
||||||
|
|
||||||
|
|
||||||
def is_bundled() -> bool:
|
def is_frozen() -> bool:
|
||||||
return getattr(sys, 'frozen', False)
|
return getattr(sys, 'frozen', False)
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ def local_path(*path):
|
|||||||
if local_path.cached_path:
|
if local_path.cached_path:
|
||||||
return os.path.join(local_path.cached_path, *path)
|
return os.path.join(local_path.cached_path, *path)
|
||||||
|
|
||||||
elif is_bundled():
|
elif is_frozen():
|
||||||
if hasattr(sys, "_MEIPASS"):
|
if hasattr(sys, "_MEIPASS"):
|
||||||
# we are running in a PyInstaller bundle
|
# we are running in a PyInstaller bundle
|
||||||
local_path.cached_path = sys._MEIPASS # pylint: disable=protected-access,no-member
|
local_path.cached_path = sys._MEIPASS # pylint: disable=protected-access,no-member
|
||||||
@@ -200,29 +200,16 @@ def get_default_options() -> dict:
|
|||||||
"compatibility": 2,
|
"compatibility": 2,
|
||||||
"log_network": 0
|
"log_network": 0
|
||||||
},
|
},
|
||||||
"multi_mystery_options": {
|
"generator": {
|
||||||
"teams": 1,
|
"teams": 1,
|
||||||
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
|
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
|
||||||
"player_files_path": "Players",
|
"player_files_path": "Players",
|
||||||
"players": 0,
|
"players": 0,
|
||||||
"weights_file_path": "weights.yaml",
|
"weights_file_path": "weights.yaml",
|
||||||
"meta_file_path": "meta.yaml",
|
"meta_file_path": "meta.yaml",
|
||||||
"pre_roll": False,
|
"spoiler": 2,
|
||||||
"create_spoiler": 1,
|
|
||||||
"zip_roms": 0,
|
|
||||||
"zip_diffs": 2,
|
|
||||||
"zip_apmcs": 1,
|
|
||||||
"zip_spoiler": 0,
|
|
||||||
"zip_multidata": 1,
|
|
||||||
"zip_format": 1,
|
|
||||||
"glitch_triforce_room": 1,
|
"glitch_triforce_room": 1,
|
||||||
"race": 0,
|
"race": 0,
|
||||||
"cpu_threads": 0,
|
|
||||||
"max_attempts": 0,
|
|
||||||
"take_first_working": False,
|
|
||||||
"keep_all_seeds": False,
|
|
||||||
"log_output_path": "Output Logs",
|
|
||||||
"log_level": None,
|
|
||||||
"plando_options": "bosses",
|
"plando_options": "bosses",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ import ModuleUpdate
|
|||||||
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
|
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
# in case app gets imported by something like gunicorn
|
||||||
|
import Utils
|
||||||
|
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||||
|
|
||||||
from WebHostLib import app as raw_app
|
from WebHostLib import app as raw_app
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
from WebHostLib.models import db
|
from WebHostLib.models import db
|
||||||
from WebHostLib.autolauncher import autohost
|
from WebHostLib.autolauncher import autohost
|
||||||
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
configpath = os.path.abspath("config.yaml")
|
configpath = os.path.abspath("config.yaml")
|
||||||
|
|
||||||
@@ -30,7 +36,9 @@ if __name__ == "__main__":
|
|||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
multiprocessing.set_start_method('spawn')
|
multiprocessing.set_start_method('spawn')
|
||||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||||
|
update_sprites_lttp()
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
create_options_files()
|
||||||
if app.config["SELFLAUNCH"]:
|
if app.config["SELFLAUNCH"]:
|
||||||
autohost(app.config)
|
autohost(app.config)
|
||||||
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
|
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
|
||||||
|
|||||||
@@ -82,18 +82,19 @@ def page_not_found(err):
|
|||||||
|
|
||||||
|
|
||||||
games_list = {
|
games_list = {
|
||||||
"zelda3": ("The Legend of Zelda: A Link to the Past",
|
"A Link to the Past": ("The Legend of Zelda: A Link to the Past",
|
||||||
"""
|
"""
|
||||||
The Legend of Zelda: A Link to the Past is an action/adventure game. Take on the role of Link,
|
The Legend of Zelda: A Link to the Past is an action/adventure game. Take on the role of
|
||||||
a boy who is destined to save the land of Hyrule. Delve through three palaces and nine dungeons on
|
Link, a boy who is destined to save the land of Hyrule. Delve through three palaces and nine
|
||||||
your quest to rescue the descendents of the seven wise men and defeat the evil Ganon!"""),
|
dungeons on your quest to rescue the descendents of the seven wise men and defeat the evil
|
||||||
"factorio": ("Factorio",
|
Ganon!"""),
|
||||||
|
"Factorio": ("Factorio",
|
||||||
"""
|
"""
|
||||||
Factorio is a game about automation. You play as an engineer who has crash landed on the planet
|
Factorio is a game about automation. You play as an engineer who has crash landed on the planet
|
||||||
Nauvis, an inhospitable world filled with dangerous creatures called biters. Build a factory,
|
Nauvis, an inhospitable world filled with dangerous creatures called biters. Build a factory,
|
||||||
research new technologies, and become more efficient in your quest to build a rocket and return home.
|
research new technologies, and become more efficient in your quest to build a rocket and return home.
|
||||||
"""),
|
"""),
|
||||||
"minecraft": ("Minecraft",
|
"Minecraft": ("Minecraft",
|
||||||
"""
|
"""
|
||||||
Minecraft is a game about creativity. In a world made entirely of cubes, you explore, discover, mine,
|
Minecraft is a game about creativity. In a world made entirely of cubes, you explore, discover, mine,
|
||||||
craft, and try not to explode. Delve deep into the earth and discover abandoned mines, ancient
|
craft, and try not to explode. Delve deep into the earth and discover abandoned mines, ancient
|
||||||
@@ -102,6 +103,12 @@ games_list = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Player settings pages
|
||||||
|
@app.route('/games/<string:game>/player-settings')
|
||||||
|
def player_settings(game):
|
||||||
|
return render_template(f"player-settings.html")
|
||||||
|
|
||||||
|
|
||||||
# Game sub-pages
|
# Game sub-pages
|
||||||
@app.route('/games/<string:game>/<string:page>')
|
@app.route('/games/<string:game>/<string:page>')
|
||||||
def game_pages(game, page):
|
def game_pages(game, page):
|
||||||
@@ -169,7 +176,7 @@ def display_log(room: UUID):
|
|||||||
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
|
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||||
def hostRoom(room: UUID):
|
def hostRoom(room: UUID):
|
||||||
room = Room.get(id=room)
|
room = Room.get(id=room)
|
||||||
if room is None:
|
if room is None:
|
||||||
@@ -185,6 +192,9 @@ def hostRoom(room: UUID):
|
|||||||
|
|
||||||
return render_template("hostRoom.html", room=room)
|
return render_template("hostRoom.html", room=room)
|
||||||
|
|
||||||
|
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
|
||||||
|
def hostRoomRedirect(room: UUID):
|
||||||
|
return redirect(url_for("hostRoom", room=room))
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon():
|
def favicon():
|
||||||
@@ -194,4 +204,5 @@ def favicon():
|
|||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
|
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
|
||||||
|
|
||||||
app.register_blueprint(api.api_endpoints)
|
app.register_blueprint(api.api_endpoints)
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ from uuid import UUID
|
|||||||
from flask import Blueprint, abort
|
from flask import Blueprint, abort
|
||||||
|
|
||||||
from ..models import Room
|
from ..models import Room
|
||||||
|
from .. import cache
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
from . import generate, user # trigger registration
|
from . import generate, user # trigger registration
|
||||||
|
|
||||||
|
|
||||||
# unsorted/misc endpoints
|
# unsorted/misc endpoints
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/room_status/<suuid:room>')
|
@api_endpoints.route('/room_status/<suuid:room>')
|
||||||
def room_info(room: UUID):
|
def room_info(room: UUID):
|
||||||
room = Room.get(id=room)
|
room = Room.get(id=room)
|
||||||
@@ -22,3 +23,18 @@ def room_info(room: UUID):
|
|||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
"timeout": room.timeout}
|
"timeout": room.timeout}
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route('/datapackage')
|
||||||
|
@cache.cached()
|
||||||
|
def get_datapackge():
|
||||||
|
from worlds import network_data_package
|
||||||
|
return network_data_package
|
||||||
|
|
||||||
|
@api_endpoints.route('/datapackage_version')
|
||||||
|
@cache.cached()
|
||||||
|
def get_datapackge_versions():
|
||||||
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
|
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||||
|
version_package["version"] = network_data_package["version"]
|
||||||
|
return version_package
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def allowed_file(filename):
|
|||||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||||
|
|
||||||
|
|
||||||
from Mystery import roll_settings
|
from Generate import roll_settings
|
||||||
from Utils import parse_yaml
|
from Utils import parse_yaml
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from flask import request, flash, redirect, url_for, session, render_template
|
|||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Main import get_seed, seeddigits
|
from Main import get_seed, seeddigits
|
||||||
from Mystery import handle_name
|
from Generate import handle_name
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
from .models import *
|
from .models import *
|
||||||
@@ -80,7 +80,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
|
|||||||
erargs.outputname = seedname
|
erargs.outputname = seedname
|
||||||
erargs.outputpath = target.name
|
erargs.outputpath = target.name
|
||||||
erargs.teams = 1
|
erargs.teams = 1
|
||||||
erargs.create_diff = True
|
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
|
|||||||
45
WebHostLib/lttpsprites.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import json
|
||||||
|
|
||||||
|
from Utils import local_path
|
||||||
|
from worlds.alttp.Rom import Sprite
|
||||||
|
|
||||||
|
|
||||||
|
def update_sprites_lttp():
|
||||||
|
from tkinter import Tk
|
||||||
|
from LttPAdjuster import get_image_for_sprite
|
||||||
|
from LttPAdjuster import BackgroundTaskProgress
|
||||||
|
from LttPAdjuster import update_sprites
|
||||||
|
|
||||||
|
# Target directories
|
||||||
|
input_dir = local_path("data", "sprites", "alttpr")
|
||||||
|
output_dir = local_path("WebHostLib", "static", "generated")
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||||
|
# update sprites through gui.py's functions
|
||||||
|
done = threading.Event()
|
||||||
|
top = Tk()
|
||||||
|
top.withdraw()
|
||||||
|
BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||||
|
while not done.isSet():
|
||||||
|
top.update()
|
||||||
|
|
||||||
|
spriteData = []
|
||||||
|
|
||||||
|
for file in os.listdir(input_dir):
|
||||||
|
sprite = Sprite(os.path.join(input_dir, file))
|
||||||
|
|
||||||
|
if not sprite.name:
|
||||||
|
print("Warning:", file, "has no name.")
|
||||||
|
sprite.name = file.split(".", 1)[0]
|
||||||
|
if sprite.valid:
|
||||||
|
with open(os.path.join(output_dir, "sprites", f"{os.path.splitext(file)[0]}.gif"), 'wb') as image:
|
||||||
|
image.write(get_image_for_sprite(sprite, True))
|
||||||
|
spriteData.append({"file": file, "author": sprite.author_name, "name": sprite.name})
|
||||||
|
else:
|
||||||
|
print(file, "dropped, as it has no valid sprite data.")
|
||||||
|
spriteData.sort(key=lambda entry: entry["name"])
|
||||||
|
with open(f'{output_dir}/spriteData.json', 'w') as file:
|
||||||
|
json.dump({"sprites": spriteData}, file, indent=1)
|
||||||
|
return spriteData
|
||||||
54
WebHostLib/options.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
from Utils import __version__
|
||||||
|
from jinja2 import Template
|
||||||
|
import yaml
|
||||||
|
import json
|
||||||
|
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
|
target_folder = os.path.join("WebHostLib", "static", "generated")
|
||||||
|
|
||||||
|
|
||||||
|
def create():
|
||||||
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
|
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
||||||
|
options=world.options, __version__=__version__, game=game_name, yaml_dump=yaml.dump
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(os.path.join(target_folder, game_name + ".yaml"), "w") as f:
|
||||||
|
f.write(res)
|
||||||
|
|
||||||
|
# Generate JSON files for player-settings pages
|
||||||
|
player_settings = {
|
||||||
|
"baseOptions": {
|
||||||
|
"description": "Generated by https://archipelago.gg/",
|
||||||
|
"name": "Player",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
game_options = {}
|
||||||
|
for option_name, option in world.options.items():
|
||||||
|
if option.options:
|
||||||
|
this_option = {
|
||||||
|
"type": "select",
|
||||||
|
"friendlyName": option.friendly_name if hasattr(option, "friendly_name") else option_name,
|
||||||
|
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||||
|
"defaultValue": None,
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for sub_option_name, sub_option_id in option.options.items():
|
||||||
|
this_option["options"].append({
|
||||||
|
"name": sub_option_name,
|
||||||
|
"value": sub_option_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
if sub_option_id == option.default:
|
||||||
|
this_option["defaultValue"] = sub_option_name
|
||||||
|
|
||||||
|
game_options[option_name] = this_option
|
||||||
|
|
||||||
|
player_settings["gameOptions"] = game_options
|
||||||
|
|
||||||
|
with open(os.path.join(target_folder, game_name + ".json"), "w") as f:
|
||||||
|
f.write(json.dumps(player_settings, indent=2, separators=(',', ': ')))
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
|
let gameName = null;
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
Promise.all([fetchSettingData(), fetchSpriteData()]).then((results) => {
|
const urlMatches = window.location.href.match(/^.*\/(.*)\/player-settings/);
|
||||||
|
gameName = decodeURIComponent(urlMatches[1]);
|
||||||
|
|
||||||
|
// Update game name on page
|
||||||
|
document.getElementById('game-name').innerHTML = gameName;
|
||||||
|
|
||||||
|
Promise.all([fetchSettingData()]).then((results) => {
|
||||||
// Page setup
|
// Page setup
|
||||||
createDefaultSettings(results[0]);
|
createDefaultSettings(results[0]);
|
||||||
buildUI(results[0]);
|
buildUI(results[0]);
|
||||||
@@ -11,24 +19,13 @@ window.addEventListener('load', () => {
|
|||||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||||
|
|
||||||
// Name input field
|
// Name input field
|
||||||
const playerSettings = JSON.parse(localStorage.getItem('playerSettings'));
|
const playerSettings = JSON.parse(localStorage.getItem(gameName));
|
||||||
const nameInput = document.getElementById('player-name');
|
const nameInput = document.getElementById('player-name');
|
||||||
nameInput.addEventListener('keyup', (event) => updateSetting(event));
|
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
|
||||||
nameInput.value = playerSettings.name;
|
nameInput.value = playerSettings.name;
|
||||||
|
|
||||||
// Sprite options
|
|
||||||
const spriteData = JSON.parse(results[1]);
|
|
||||||
const spriteSelect = document.getElementById('sprite');
|
|
||||||
spriteData.sprites.forEach((sprite) => {
|
|
||||||
if (sprite.name.trim().length === 0) { return; }
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.setAttribute('value', sprite.name.trim());
|
|
||||||
if (playerSettings.rom.sprite === sprite.name.trim()) { option.selected = true; }
|
|
||||||
option.innerText = sprite.name;
|
|
||||||
spriteSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
const url = new URL(window.location.href);
|
||||||
|
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,27 +40,22 @@ const fetchSettingData = () => new Promise((resolve, reject) => {
|
|||||||
try{ resolve(JSON.parse(ajax.responseText)); }
|
try{ resolve(JSON.parse(ajax.responseText)); }
|
||||||
catch(error){ reject(error); }
|
catch(error){ reject(error); }
|
||||||
};
|
};
|
||||||
ajax.open('GET', `${window.location.origin}/static/static/playerSettings.json`, true);
|
ajax.open('GET', `${window.location.origin}/static/generated/${gameName}.json`, true);
|
||||||
ajax.send();
|
ajax.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
const createDefaultSettings = (settingData) => {
|
const createDefaultSettings = (settingData) => {
|
||||||
if (!localStorage.getItem('playerSettings')) {
|
if (!localStorage.getItem(gameName)) {
|
||||||
const newSettings = {};
|
const newSettings = {
|
||||||
for (let roSetting of Object.keys(settingData.readOnly)){
|
[gameName]: {},
|
||||||
newSettings[roSetting] = settingData.readOnly[roSetting];
|
};
|
||||||
}
|
for (let baseOption of Object.keys(settingData.baseOptions)){
|
||||||
for (let generalOption of Object.keys(settingData.generalOptions)){
|
newSettings[baseOption] = settingData.baseOptions[baseOption];
|
||||||
newSettings[generalOption] = settingData.generalOptions[generalOption];
|
|
||||||
}
|
}
|
||||||
for (let gameOption of Object.keys(settingData.gameOptions)){
|
for (let gameOption of Object.keys(settingData.gameOptions)){
|
||||||
newSettings[gameOption] = settingData.gameOptions[gameOption].defaultValue;
|
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
|
||||||
}
|
}
|
||||||
newSettings.rom = {};
|
localStorage.setItem(gameName, JSON.stringify(newSettings));
|
||||||
for (let romOption of Object.keys(settingData.romOptions)){
|
|
||||||
newSettings.rom[romOption] = settingData.romOptions[romOption].defaultValue;
|
|
||||||
}
|
|
||||||
localStorage.setItem('playerSettings', JSON.stringify(newSettings));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,20 +69,10 @@ const buildUI = (settingData) => {
|
|||||||
});
|
});
|
||||||
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
||||||
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
||||||
|
|
||||||
// ROM Options
|
|
||||||
const leftRomOpts = {};
|
|
||||||
const rightRomOpts = {};
|
|
||||||
Object.keys(settingData.romOptions).forEach((key, index) => {
|
|
||||||
if (index < Object.keys(settingData.romOptions).length / 2) { leftRomOpts[key] = settingData.romOptions[key]; }
|
|
||||||
else { rightRomOpts[key] = settingData.romOptions[key]; }
|
|
||||||
});
|
|
||||||
document.getElementById('rom-options-left').appendChild(buildOptionsTable(leftRomOpts, true));
|
|
||||||
document.getElementById('rom-options-right').appendChild(buildOptionsTable(rightRomOpts, true));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildOptionsTable = (settings, romOpts = false) => {
|
const buildOptionsTable = (settings, romOpts = false) => {
|
||||||
const currentSettings = JSON.parse(localStorage.getItem('playerSettings'));
|
const currentSettings = JSON.parse(localStorage.getItem(gameName));
|
||||||
const table = document.createElement('table');
|
const table = document.createElement('table');
|
||||||
const tbody = document.createElement('tbody');
|
const tbody = document.createElement('tbody');
|
||||||
|
|
||||||
@@ -122,7 +104,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
}
|
}
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
select.addEventListener('change', (event) => updateSetting(event));
|
select.addEventListener('change', (event) => updateGameSetting(event));
|
||||||
tdr.appendChild(select);
|
tdr.appendChild(select);
|
||||||
tr.appendChild(tdr);
|
tr.appendChild(tdr);
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
@@ -132,20 +114,22 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
return table;
|
return table;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSetting = (event) => {
|
const updateBaseSetting = (event) => {
|
||||||
const options = JSON.parse(localStorage.getItem('playerSettings'));
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
if (event.target.getAttribute('data-romOpt')) {
|
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
options.rom[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
event.target.value : parseInt(event.target.value);
|
||||||
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGameSetting = (event) => {
|
||||||
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
event.target.value : parseInt(event.target.value, 10);
|
event.target.value : parseInt(event.target.value, 10);
|
||||||
} else {
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
|
||||||
event.target.value : parseInt(event.target.value, 10);
|
|
||||||
}
|
|
||||||
localStorage.setItem('playerSettings', JSON.stringify(options));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportSettings = () => {
|
const exportSettings = () => {
|
||||||
const settings = JSON.parse(localStorage.getItem('playerSettings'));
|
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||||
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
|
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
|
||||||
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||||
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||||
@@ -164,8 +148,8 @@ const download = (filename, text) => {
|
|||||||
|
|
||||||
const generateGame = (raceMode = false) => {
|
const generateGame = (raceMode = false) => {
|
||||||
axios.post('/api/generate', {
|
axios.post('/api/generate', {
|
||||||
weights: { player: localStorage.getItem('playerSettings') },
|
weights: { player: localStorage.getItem(gameName) },
|
||||||
presetData: { player: localStorage.getItem('playerSettings') },
|
presetData: { player: localStorage.getItem(gameName) },
|
||||||
playerCount: 1,
|
playerCount: 1,
|
||||||
race: raceMode ? '1' : '0',
|
race: raceMode ? '1' : '0',
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
@@ -173,22 +157,11 @@ const generateGame = (raceMode = false) => {
|
|||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
const userMessage = document.getElementById('user-message');
|
const userMessage = document.getElementById('user-message');
|
||||||
userMessage.innerText = 'Something went wrong and your game could not be generated.';
|
userMessage.innerText = 'Something went wrong and your game could not be generated.';
|
||||||
|
if (error.response.data.text) {
|
||||||
|
userMessage.innerText += ' ' + error.response.data.text;
|
||||||
|
}
|
||||||
userMessage.classList.add('visible');
|
userMessage.classList.add('visible');
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSpriteData = () => new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject('Unable to fetch sprite data.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
|
|
||||||
ajax.send();
|
|
||||||
});
|
|
||||||
@@ -93,7 +93,7 @@ const fetchSpriteData = () => new Promise((resolve, reject) => {
|
|||||||
}
|
}
|
||||||
resolve(ajax.responseText);
|
resolve(ajax.responseText);
|
||||||
};
|
};
|
||||||
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
|
ajax.open('GET', `${window.location.origin}/static/generated/spriteData.json`, true);
|
||||||
ajax.send();
|
ajax.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,7 +446,7 @@ const buildSpritePicker = (spriteData) => {
|
|||||||
let spriteGifFile = sprite.file.split('.');
|
let spriteGifFile = sprite.file.split('.');
|
||||||
spriteGifFile.pop();
|
spriteGifFile.pop();
|
||||||
spriteGifFile = spriteGifFile.join('.') + '.gif';
|
spriteGifFile = spriteGifFile.join('.') + '.gif';
|
||||||
spriteImg.setAttribute('src', `static/static/sprites/${spriteGifFile}`);
|
spriteImg.setAttribute('src', `static/generated/sprites/${spriteGifFile}`);
|
||||||
spriteImg.setAttribute('data-sprite', sprite.file.split('.')[0]);
|
spriteImg.setAttribute('data-sprite', sprite.file.split('.')[0]);
|
||||||
spriteImg.setAttribute('alt', sprite.name);
|
spriteImg.setAttribute('alt', sprite.name);
|
||||||
|
|
||||||
@@ -476,6 +476,9 @@ const generateGame = (raceMode = false) => {
|
|||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
const userMessage = document.getElementById('user-message');
|
const userMessage = document.getElementById('user-message');
|
||||||
userMessage.innerText = 'Something went wrong and your game could not be generated.';
|
userMessage.innerText = 'Something went wrong and your game could not be generated.';
|
||||||
|
if (error.response.data.text) {
|
||||||
|
userMessage.innerText += ' ' + error.response.data.text;
|
||||||
|
}
|
||||||
userMessage.classList.add('visible');
|
userMessage.classList.add('visible');
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -1,705 +0,0 @@
|
|||||||
{
|
|
||||||
"readOnly": {
|
|
||||||
"description": "Generated by MultiWorld website",
|
|
||||||
"triforce_pieces_mode": "available",
|
|
||||||
"triforce_pieces_available": 30,
|
|
||||||
"triforce_pieces_required": 20,
|
|
||||||
"shuffle_prizes": "none",
|
|
||||||
"timer": "none",
|
|
||||||
"glitch_boots": "on",
|
|
||||||
"key_drop_shuffle": "off",
|
|
||||||
"experimental": "off",
|
|
||||||
"debug": "off"
|
|
||||||
},
|
|
||||||
"generalOptions": {
|
|
||||||
"name": "PlayerName"
|
|
||||||
},
|
|
||||||
"gameOptions": {
|
|
||||||
"goals": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Goal",
|
|
||||||
"description": "Choose the condition for winning the game",
|
|
||||||
"defaultValue": "ganon",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Kill Ganon",
|
|
||||||
"value": "ganon"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fast Ganon (Pyramid Always Open)",
|
|
||||||
"value": "crystals"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "All Bosses",
|
|
||||||
"value": "bosses"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Master Sword Pedestal",
|
|
||||||
"value": "pedestal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Master Sword Pedestal + Ganon",
|
|
||||||
"value": "ganon_pedestal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Triforce Hunt",
|
|
||||||
"value": "triforce_hunt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Triforce Hunt + Ganon",
|
|
||||||
"value": "ganon_triforce_hunt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Ice Rod Hunt",
|
|
||||||
"value": "ice_rod_hunt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"mode": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "World State",
|
|
||||||
"description": "Choose the state of the game world",
|
|
||||||
"defaultValue": "standard",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Standard",
|
|
||||||
"value": "standard"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Open",
|
|
||||||
"value": "open"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Inverted",
|
|
||||||
"value": "inverted"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"accessibility": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Accessibility",
|
|
||||||
"description": "Choose how much of the world will be available",
|
|
||||||
"defaultValue": "locations",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Locations Guaranteed",
|
|
||||||
"value": "locations"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Items Guaranteed",
|
|
||||||
"value": "items"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Beatable Only",
|
|
||||||
"value": "none"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"progressive": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Progressive Items",
|
|
||||||
"description": "Turn progressive items on or off, or randomize them",
|
|
||||||
"defaultValue": "on",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "All Progressive",
|
|
||||||
"value": "on"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "None Progressive",
|
|
||||||
"value": "off"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Randomize Each",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tower_open": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Ganon's Tower Access",
|
|
||||||
"description": "Choose how many crystals are required to open Ganon's Tower",
|
|
||||||
"defaultValue": 7,
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "7 Crystals",
|
|
||||||
"value": 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "6 Crystals",
|
|
||||||
"value": 6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "5 Crystals",
|
|
||||||
"value": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "4 Crystals",
|
|
||||||
"value": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "3 Crystals",
|
|
||||||
"value": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "2 Crystals",
|
|
||||||
"value": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "1 Crystals",
|
|
||||||
"value": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "0 Crystals",
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Random",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ganon_open": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Ganon Vulnerable",
|
|
||||||
"description": "Choose how many crystals are required to kill Ganon",
|
|
||||||
"defaultValue": 7,
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "7 Crystals",
|
|
||||||
"value": 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "6 Crystals",
|
|
||||||
"value": 6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "5 Crystals",
|
|
||||||
"value": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "4 Crystals",
|
|
||||||
"value": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "3 Crystals",
|
|
||||||
"value": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "2 Crystals",
|
|
||||||
"value": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "1 Crystals",
|
|
||||||
"value": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "0 Crystals",
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Random",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"retro": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Retro Mode",
|
|
||||||
"description": "Choose if you want to play in retro mode",
|
|
||||||
"defaultValue": "off",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Disabled",
|
|
||||||
"value": "off"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Enabled",
|
|
||||||
"value": "on"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hints": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Hints",
|
|
||||||
"description": "Choose to enable or disable tile hints",
|
|
||||||
"defaultValue": "on",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Enabled",
|
|
||||||
"value": "on"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Disabled",
|
|
||||||
"value": "off"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"weapons": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Sword Locations",
|
|
||||||
"description": "Choose where you will find your swords",
|
|
||||||
"defaultValue": "assured",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Assured",
|
|
||||||
"value": "assured"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Vanilla",
|
|
||||||
"value": "vanilla"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Swordless",
|
|
||||||
"value": "swordless"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Randomized",
|
|
||||||
"value": "randomized"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"glitches_required":{
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Glitches Required",
|
|
||||||
"description": "Choose which glitches will be considered in-logic",
|
|
||||||
"defaultValue": "none",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "None",
|
|
||||||
"value": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Minor Glitches",
|
|
||||||
"value": "minor_glitches"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Overworld Glitches",
|
|
||||||
"value": "overworld_glitches"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "No Logic",
|
|
||||||
"value": "no_logic"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dark_room_logic": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Dark Room Logic",
|
|
||||||
"description": "Choose your logical access to dark rooms",
|
|
||||||
"defaultValue": "lamp",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Lamp Required",
|
|
||||||
"value": "lamp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Torches Lightable",
|
|
||||||
"value": "torches"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Always In-Logic",
|
|
||||||
"value": "none"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dungeon_items": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Dungeon Item Shuffle",
|
|
||||||
"description": "Choose which dungeon items you want shuffled",
|
|
||||||
"defaultValue": "none",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "None",
|
|
||||||
"value": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Map & Compass",
|
|
||||||
"value": "mc"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Small Keys Only",
|
|
||||||
"value": "s"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Big Keys Only",
|
|
||||||
"value": "b"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Small and Big Keys",
|
|
||||||
"value": "sb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Full Keysanity",
|
|
||||||
"value": "mscb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Universal Small Keys",
|
|
||||||
"value": "u"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"entrance_shuffle": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Entrance Shuffle",
|
|
||||||
"description": "Shuffles the game map. Not recommended for beginners",
|
|
||||||
"defaultValue": "none",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "None",
|
|
||||||
"value": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Only Dungeons, Simple",
|
|
||||||
"value": "dungeonssimple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Only Dungeons, Full",
|
|
||||||
"value": "dungeonsfull"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Simple",
|
|
||||||
"value": "simple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Restricted",
|
|
||||||
"value": "restricted"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Full",
|
|
||||||
"value": "full"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Crossed",
|
|
||||||
"value": "crossed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Insanity",
|
|
||||||
"value": "insanity"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"item_pool": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Item Pool",
|
|
||||||
"description": "Changes the available upgrade items (1/2 Magic, hearts, sword upgrades, etc)",
|
|
||||||
"defaultValue": "normal",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Easy",
|
|
||||||
"value": "easy"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Normal",
|
|
||||||
"value": "normal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Hard",
|
|
||||||
"value": "hard"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Expert",
|
|
||||||
"value": "expert"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"item_functionality": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Item Functionality",
|
|
||||||
"description": "Changes the abilities of your items",
|
|
||||||
"defaultValue": "normal",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Easy",
|
|
||||||
"value": "easy"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Normal",
|
|
||||||
"value": "normal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Hard",
|
|
||||||
"value": "hard"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Expert",
|
|
||||||
"value": "expert"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"enemy_shuffle": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Enemy Shuffle",
|
|
||||||
"description": "Randomize the enemies which appear throughout the game",
|
|
||||||
"defaultValue": "off",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Disabled",
|
|
||||||
"value": "off"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Enabled",
|
|
||||||
"value": "on"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"boss_shuffle": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Boss Shuffle",
|
|
||||||
"description": "Shuffle the bosses within dungeons",
|
|
||||||
"defaultValue": "none",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Disabled",
|
|
||||||
"value": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Simple",
|
|
||||||
"value": "simple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Full",
|
|
||||||
"value": "full"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Singularity",
|
|
||||||
"value": "singularity"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Random",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"shop_shuffle": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Shop Shuffle",
|
|
||||||
"description": "Shuffles the content and prices of shops throughout Hyrule",
|
|
||||||
"defaultValue": "none",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "None",
|
|
||||||
"value": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Inventory",
|
|
||||||
"value": "f"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Prices",
|
|
||||||
"value": "p"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Capacity Upgrades",
|
|
||||||
"value": "u"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Inventory and Prices",
|
|
||||||
"value": "fp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Inventory, Prices, and Upgrades",
|
|
||||||
"value": "fpu"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"romOptions": {
|
|
||||||
"disablemusic": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Game Music",
|
|
||||||
"description": "Choose to enable or disable in-game music",
|
|
||||||
"defaultValue": "off",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Enabled",
|
|
||||||
"value": "off"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Disabled",
|
|
||||||
"value": "on"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"quickswap": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Item Quick-Swap",
|
|
||||||
"description": "Enable or disable quick-swap using the L+R buttons",
|
|
||||||
"defaultValue": "on",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Enabled",
|
|
||||||
"value": "on"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Disabled",
|
|
||||||
"value": "off"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"menuspeed": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Menu Speed",
|
|
||||||
"description": "Changes the animation speed of the in-game menu",
|
|
||||||
"defaultValue": "normal",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Normal",
|
|
||||||
"value": "normal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Instant",
|
|
||||||
"value": "instant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Double",
|
|
||||||
"value": "double"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Triple",
|
|
||||||
"value": "triple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Quadruple",
|
|
||||||
"value": "quadruple"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Half-Speed",
|
|
||||||
"value": "half"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"heartbeep": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Heart-Beep Speed",
|
|
||||||
"description": "Change the frequency of the heart beep alert when you are at low health",
|
|
||||||
"defaultValue": "normal",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Double Speed",
|
|
||||||
"value": "double"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Normal",
|
|
||||||
"value": "normal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Half-Speed",
|
|
||||||
"value": "half"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Quarter-Speed",
|
|
||||||
"value": "quarter"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Disabled",
|
|
||||||
"value": "off"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"heartcolor": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Heart Color",
|
|
||||||
"description": "Change the color of your hearts in-game",
|
|
||||||
"defaultValue": "red",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Red",
|
|
||||||
"value": "red"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Blue",
|
|
||||||
"value": "blue"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Green",
|
|
||||||
"value": "green"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Yellow",
|
|
||||||
"value": "yellow"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Random",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ow_palettes": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Overworld Palette",
|
|
||||||
"description": "Change the colors of the overworld",
|
|
||||||
"defaultValue": "default",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Vanilla",
|
|
||||||
"value": "default"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Randomized",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"uw_palettes": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Underworld Palette",
|
|
||||||
"description": "Change the colors of the underworld",
|
|
||||||
"defaultValue": "default",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Vanilla",
|
|
||||||
"value": "default"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Randomized",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hud_palettes": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "HUD Palette",
|
|
||||||
"description": "Change the colors of the user-interface",
|
|
||||||
"defaultValue": "default",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Vanilla",
|
|
||||||
"value": "default"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Randomized",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sword_palettes": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Sword Palette",
|
|
||||||
"description": "Change the colors of the swords, within reason",
|
|
||||||
"defaultValue": "default",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Vanilla",
|
|
||||||
"value": "default"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Randomized",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sprite": {
|
|
||||||
"type": "select",
|
|
||||||
"friendlyName": "Sprite",
|
|
||||||
"description": "Choose a sprite to play as!",
|
|
||||||
"defaultValue": "link",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"name": "Random",
|
|
||||||
"value": "random"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |