diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml
index d24c55b49ac2..1a76a7f47160 100644
--- a/.github/workflows/unittests.yml
+++ b/.github/workflows/unittests.yml
@@ -54,9 +54,9 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install pytest pytest-subtests
+ pip install pytest pytest-subtests pytest-xdist
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests
run: |
- pytest
+ pytest -n auto
diff --git a/.gitignore b/.gitignore
index 8e4cc86657a5..f4bcd35c32ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,16 +27,20 @@
*.archipelago
*.apsave
*.BIN
+*.puml
setups
build
bundle/components.wxs
dist
+/prof/
README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
+/sni-*/
+/appimagetool*
/host.yaml
/options.yaml
/config.yaml
@@ -139,6 +143,7 @@ ipython_config.py
.venv*
env/
venv/
+/venv*/
ENV/
env.bak/
venv.bak/
diff --git a/BaseClasses.py b/BaseClasses.py
index 26cdfb528569..1b6677dd1942 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -8,6 +8,7 @@
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
from collections import ChainMap, Counter, deque
+from collections.abc import Collection
from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
Type, ClassVar
@@ -202,14 +203,7 @@ def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tu
self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game]
- for option_key, option in world_type.option_definitions.items():
- getattr(self, option_key)[new_id] = option(option.default)
- for option_key, option in Options.common_options.items():
- getattr(self, option_key)[new_id] = option(option.default)
- for option_key, option in Options.per_game_common_options.items():
- getattr(self, option_key)[new_id] = option(option.default)
-
- self.worlds[new_id] = world_type(self, new_id)
+ self.worlds[new_id] = world_type.create_group(self, new_id, players)
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
self.player_name[new_id] = name
@@ -232,25 +226,24 @@ def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optio
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
- for option_key in Options.common_options:
- setattr(self, option_key, getattr(args, option_key, {}))
- for option_key in Options.per_game_common_options:
- setattr(self, option_key, getattr(args, option_key, {}))
-
for player in self.player_ids:
self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
- for option_key in world_type.option_definitions:
- setattr(self, option_key, getattr(args, option_key, {}))
-
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
+ for option_key in world_type.options_dataclass.type_hints:
+ option_values = getattr(args, option_key, {})
+ setattr(self, option_key, option_values)
+ # TODO - remove this loop once all worlds use options dataclasses
+ options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass
+ self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
+ for option_key in options_dataclass.type_hints})
def set_item_links(self):
item_links = {}
replacement_prio = [False, True, None]
for player in self.player_ids:
- for item_link in self.item_links[player].value:
+ for item_link in self.worlds[player].options.item_links.value:
if item_link["name"] in item_links:
if item_links[item_link["name"]]["game"] != self.game[player]:
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
@@ -305,14 +298,6 @@ def set_item_links(self):
group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
- # intended for unittests
- def set_default_common_options(self):
- for option_key, option in Options.common_options.items():
- setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
- for option_key, option in Options.per_game_common_options.items():
- setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
- self.state = CollectionState(self)
-
def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True
@@ -364,7 +349,7 @@ def _recache(self):
for r_location in region.locations:
self._location_cache[r_location.name, player] = r_location
- def get_regions(self, player=None):
+ def get_regions(self, player: Optional[int] = None) -> Collection[Region]:
return self.regions if player is None else self._region_cache[player].values()
def get_region(self, regionname: str, player: int) -> Region:
@@ -869,19 +854,19 @@ def add_locations(self, locations: Dict[str, Optional[int]],
"""
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address.
-
+
:param locations: dictionary of locations to be created and added to this Region `{name: ID}`
:param location_type: Location class to be used to create the locations with"""
if location_type is None:
location_type = Location
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
-
+
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
"""
Connects this Region to another Region, placing the provided rule on the connection.
-
+
:param connecting_region: Region object to connect to path is `self -> exiting_region`
:param name: name of the connection being created
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
@@ -889,11 +874,11 @@ def connect(self, connecting_region: Region, name: Optional[str] = None,
if rule:
exit_.access_rule = rule
exit_.connect(connecting_region)
-
+
def create_exit(self, name: str) -> Entrance:
"""
Creates and returns an Entrance object as an exit of this region.
-
+
:param name: name of the Entrance being created
"""
exit_ = self.entrance_type(self.player, name, self)
@@ -1263,7 +1248,7 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st
def to_file(self, filename: str) -> None:
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
- res = getattr(self.multiworld, option_key)[player]
+ res = getattr(self.multiworld.worlds[player].options, option_key)
display_name = getattr(option_obj, "display_name", option_key)
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
@@ -1281,8 +1266,7 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
- options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
- for f_option, option in options.items():
+ for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option)
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
diff --git a/BizHawkClient.py b/BizHawkClient.py
new file mode 100644
index 000000000000..86c8e5197e3f
--- /dev/null
+++ b/BizHawkClient.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import ModuleUpdate
+ModuleUpdate.update()
+
+from worlds._bizhawk.context import launch
+
+if __name__ == "__main__":
+ launch()
diff --git a/CommonClient.py b/CommonClient.py
index 61fad6589793..a5e9b4553ab4 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -1,4 +1,6 @@
from __future__ import annotations
+
+import copy
import logging
import asyncio
import urllib.parse
@@ -242,6 +244,7 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option
self.watcher_event = asyncio.Event()
self.jsontotextparser = JSONtoTextParser(self)
+ self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package)
# execution
@@ -377,10 +380,13 @@ def on_print(self, args: dict):
def on_print_json(self, args: dict):
if self.ui:
- self.ui.print_json(args["data"])
- else:
- text = self.jsontotextparser(args["data"])
- logger.info(text)
+ # send copy to UI
+ self.ui.print_json(copy.deepcopy(args["data"]))
+
+ logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])),
+ extra={"NoStream": True})
+ logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])),
+ extra={"NoFile": True})
def on_package(self, cmd: str, args: dict):
"""For custom package handling in subclasses."""
@@ -876,7 +882,7 @@ def get_base_parser(description: typing.Optional[str] = None):
def run_as_textclient():
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
- tags = {"AP", "TextOnly"}
+ tags = CommonContext.tags | {"TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received
want_slot_data = False # Can't use game specific slot_data
diff --git a/Fill.py b/Fill.py
index 3e0342f42cd3..9d5dc0b45776 100644
--- a/Fill.py
+++ b/Fill.py
@@ -5,6 +5,8 @@
from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
+from Options import Accessibility
+
from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
@@ -70,7 +72,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
- if world.accessibility[item_to_place.player] == 'minimal':
+ if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
if single_player_placement else not has_beaten_game
@@ -265,7 +267,7 @@ def fast_fill(world: MultiWorld,
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
- minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
+ minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
@@ -288,7 +290,7 @@ def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locat
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
- return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
+ return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
@@ -531,9 +533,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
- player: world.progression_balancing[player] / 100
+ player: world.worlds[player].options.progression_balancing / 100
for player in world.player_ids
- if world.progression_balancing[player] > 0
+ if world.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
@@ -753,8 +755,6 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
- # TODO: remove. Preferably by implementing key drop
- from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
@@ -840,14 +840,14 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
if "early_locations" in locations:
locations.remove("early_locations")
- for player in worlds:
- locations += early_locations[player]
+ for target_player in worlds:
+ locations += early_locations[target_player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
- for player in worlds:
- locations += non_early_locations[player]
+ for target_player in worlds:
+ locations += non_early_locations[target_player]
- block['locations'] = locations
+ block['locations'] = list(dict.fromkeys(locations))
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if
@@ -897,23 +897,22 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None:
for item_name in items:
item = world.worlds[player].create_item(item_name)
for location in reversed(candidates):
- if location in key_drop_data:
- warn(
- f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
- continue
- if not location.item:
- if location.item_rule(item):
- if location.can_fill(world.state, item, False):
- successful_pairs.append((item, location))
- candidates.remove(location)
- count = count + 1
- break
+ if (location.address is None) == (item.code is None): # either both None or both not None
+ if not location.item:
+ if location.item_rule(item):
+ if location.can_fill(world.state, item, False):
+ successful_pairs.append((item, location))
+ candidates.remove(location)
+ count = count + 1
+ break
+ else:
+ err.append(f"Can't place item at {location} due to fill condition not met.")
else:
- err.append(f"Can't place item at {location} due to fill condition not met.")
+ err.append(f"{item_name} not allowed at {location}.")
else:
- err.append(f"{item_name} not allowed at {location}.")
+ err.append(f"Cannot place {item_name} into already filled location {location}.")
else:
- err.append(f"Cannot place {item_name} into already filled location {location}.")
+ err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:
diff --git a/Generate.py b/Generate.py
index 5d44a1db4550..08fe2b908335 100644
--- a/Generate.py
+++ b/Generate.py
@@ -157,7 +157,8 @@ def main(args=None, callback=ERmain):
for yaml in weights_cache[path]:
if category_name is None:
for category in yaml:
- if category in AutoWorldRegister.world_types and key in Options.common_options:
+ if category in AutoWorldRegister.world_types and \
+ key in Options.CommonOptions.type_hints:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
@@ -340,7 +341,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game]
- options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
+ options = game_world.options_dataclass.type_hints
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
@@ -445,8 +446,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
f"which is not enabled.")
ret = argparse.Namespace()
- for option_key in Options.per_game_common_options:
- if option_key in weights and option_key not in Options.common_options:
+ for option_key in Options.PerGameCommonOptions.type_hints:
+ if option_key in weights and option_key not in Options.CommonOptions.type_hints:
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights)
@@ -466,16 +467,11 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
game_weights = weights[ret.game]
ret.name = get_choice('name', weights)
- for option_key, option in Options.common_options.items():
+ for option_key, option in Options.CommonOptions.type_hints.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
- for option_key, option in world_type.option_definitions.items():
+ for option_key, option in world_type.options_dataclass.type_hints.items():
handle_option(ret, game_weights, option_key, option, plando_options)
- for option_key, option in Options.per_game_common_options.items():
- # skip setting this option if already set from common_options, defaulting to root option
- if option_key not in world_type.option_definitions and \
- (option_key not in Options.common_options or option_key in game_weights):
- handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
diff --git a/Launcher.py b/Launcher.py
index a1548d594ce8..9e184bf1088d 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -50,17 +50,22 @@ def open_host_yaml():
def open_patch():
suffixes = []
for c in components:
- if isfile(get_exe(c)[-1]):
- suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
- isinstance(c.file_identifier, SuffixIdentifier) else []
+ if c.type == Type.CLIENT and \
+ isinstance(c.file_identifier, SuffixIdentifier) and \
+ (c.script_name is None or isfile(get_exe(c)[-1])):
+ suffixes += c.file_identifier.suffixes
try:
- filename = open_filename('Select patch', (('Patches', suffixes),))
+ filename = open_filename("Select patch", (("Patches", suffixes),))
except Exception as e:
- messagebox('Error', str(e), error=True)
+ messagebox("Error", str(e), error=True)
else:
file, component = identify(filename)
if file and component:
- launch([*get_exe(component), file], component.cli)
+ exe = get_exe(component)
+ if exe is None or not isfile(exe[-1]):
+ exe = get_exe("Launcher")
+
+ launch([*exe, file], component.cli)
def generate_yamls():
@@ -107,7 +112,7 @@ def identify(path: Union[None, str]):
return None, None
for component in components:
if component.handles_file(path):
- return path, component
+ return path, component
elif path == component.display_name or path == component.script_name:
return None, component
return None, None
@@ -117,25 +122,25 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str):
name = component
component = None
- if name.startswith('Archipelago'):
+ if name.startswith("Archipelago"):
name = name[11:]
- if name.endswith('.exe'):
+ if name.endswith(".exe"):
name = name[:-4]
- if name.endswith('.py'):
+ if name.endswith(".py"):
name = name[:-3]
if not name:
return None
for c in components:
- if c.script_name == name or c.frozen_name == f'Archipelago{name}':
+ if c.script_name == name or c.frozen_name == f"Archipelago{name}":
component = c
break
if not component:
return None
if is_frozen():
- suffix = '.exe' if is_windows else ''
- return [local_path(f'{component.frozen_name}{suffix}')]
+ suffix = ".exe" if is_windows else ""
+ return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
else:
- return [sys.executable, local_path(f'{component.script_name}.py')]
+ return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
def launch(exe, in_terminal=False):
diff --git a/LttPAdjuster.py b/LttPAdjuster.py
index 802ec47dd1f0..9c5bd102440b 100644
--- a/LttPAdjuster.py
+++ b/LttPAdjuster.py
@@ -1004,6 +1004,7 @@ def update_sprites(event):
self.add_to_sprite_pool(sprite)
def icon_section(self, frame_label, path, no_results_label):
+ os.makedirs(path, exist_ok=True)
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
frame.pack(side=TOP, fill=X)
diff --git a/Main.py b/Main.py
index fe56dc7d9e09..0995d2091f7b 100644
--- a/Main.py
+++ b/Main.py
@@ -108,7 +108,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('')
for player in world.player_ids:
- for item_name, count in world.start_inventory[player].value.items():
+ for item_name, count in world.worlds[player].options.start_inventory.value.items():
for _ in range(count):
world.push_precollected(world.create_item(item_name, player))
@@ -130,23 +130,29 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in world.player_ids:
# items can't be both local and non-local, prefer local
- world.non_local_items[player].value -= world.local_items[player].value
- world.non_local_items[player].value -= set(world.local_early_items[player])
+ world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value
+ world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player])
AutoWorld.call_all(world, "set_rules")
for player in world.player_ids:
- exclusion_rules(world, player, world.exclude_locations[player].value)
- world.priority_locations[player].value -= world.exclude_locations[player].value
- for location_name in world.priority_locations[player].value:
- world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
+ exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value)
+ world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value
+ for location_name in world.worlds[player].options.priority_locations.value:
+ try:
+ location = world.get_location(location_name, player)
+ except KeyError as e: # failed to find the given location. Check if it's a legitimate location
+ if location_name not in world.worlds[player].location_name_to_id:
+ raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
+ else:
+ location.progress_type = LocationProgressType.PRIORITY
# Set local and non-local item rules.
if world.players > 1:
locality_rules(world)
else:
- world.non_local_items[1].value = set()
- world.local_items[1].value = set()
+ world.worlds[1].options.non_local_items.value = set()
+ world.worlds[1].options.local_items.value = set()
AutoWorld.call_all(world, "generate_basic")
@@ -159,7 +165,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, items in depletion_pool.items():
player_world: AutoWorld.World = world.worlds[player]
for count in items.values():
- new_items.append(player_world.create_filler())
+ for _ in range(count):
+ new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(world.itempool):
if depletion_pool[item.player].get(item.name, 0):
@@ -179,6 +186,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if remaining_items:
raise Exception(f"{world.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
+ assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
world.itempool[:] = new_items
# temporary home for item links, should be moved out of Main
@@ -293,15 +301,16 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
output = tempfile.TemporaryDirectory()
with output as temp_dir:
- with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
+ output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__
+ is not world.worlds[player].generate_output.__code__]
+ with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
check_accessibility_task = pool.submit(world.fulfills_accessibility)
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
- for player in world.player_ids:
+ for player in output_players:
# skip starting a thread for methods that say "pass".
- if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
- output_file_futures.append(
- pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
+ output_file_futures.append(
+ pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
@@ -352,11 +361,11 @@ def precollect_hint(location):
f" {location}"
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
- if location.name in world.start_location_hints[location.player]:
+ if location.name in world.worlds[location.player].options.start_location_hints:
precollect_hint(location)
- elif location.item.name in world.start_hints[location.item.player]:
+ elif location.item.name in world.worlds[location.item.player].options.start_hints:
precollect_hint(location)
- elif any([location.item.name in world.start_hints[player]
+ elif any([location.item.name in world.worlds[player].options.start_hints
for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
@@ -392,7 +401,7 @@ def precollect_hint(location):
f.write(bytes([3])) # version of format
f.write(multidata)
- multidata_task = pool.submit(write_multidata)
+ output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():
if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
@@ -400,7 +409,6 @@ def precollect_hint(location):
logger.warning("Location Accessibility requirements not fulfilled.")
# retrieve exceptions via .result() if they occurred.
- multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
if i % 10 == 0 or i == len(output_file_futures):
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
diff --git a/NetUtils.py b/NetUtils.py
index c31aa695104c..a2db6a2ac5c4 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -407,14 +407,22 @@ def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[in
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
LocationStore = _LocationStore
else:
- try:
- import pyximport
- pyximport.install()
- except ImportError:
- pyximport = None
try:
from _speedups import LocationStore
+ import _speedups
+ import os.path
+ if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"):
+ warnings.warn(f"{_speedups.__file__} outdated! "
+ f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!")
except ImportError:
- warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
- "Install a matching C++ compiler for your platform to compile _speedups.")
- LocationStore = _LocationStore
+ try:
+ import pyximport
+ pyximport.install()
+ except ImportError:
+ pyximport = None
+ try:
+ from _speedups import LocationStore
+ except ImportError:
+ warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
+ "Install a matching C++ compiler for your platform to compile _speedups.")
+ LocationStore = _LocationStore
diff --git a/Options.py b/Options.py
index 960e6c19d1ad..d9ddfc2e2fdb 100644
--- a/Options.py
+++ b/Options.py
@@ -2,6 +2,9 @@
import abc
import logging
+from copy import deepcopy
+from dataclasses import dataclass
+import functools
import math
import numbers
import random
@@ -211,6 +214,12 @@ def __gt__(self, other: typing.Union[int, NumericOption]) -> bool:
else:
return self.value > other
+ def __ge__(self, other: typing.Union[int, NumericOption]) -> bool:
+ if isinstance(other, NumericOption):
+ return self.value >= other.value
+ else:
+ return self.value >= other
+
def __bool__(self) -> bool:
return bool(self.value)
@@ -896,10 +905,55 @@ class ProgressionBalancing(SpecialRange):
}
-common_options = {
- "progression_balancing": ProgressionBalancing,
- "accessibility": Accessibility
-}
+class OptionsMetaProperty(type):
+ def __new__(mcs,
+ name: str,
+ bases: typing.Tuple[type, ...],
+ attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty":
+ for attr_type in attrs.values():
+ assert not isinstance(attr_type, AssembleOptions),\
+ f"Options for {name} should be type hinted on the class, not assigned"
+ return super().__new__(mcs, name, bases, attrs)
+
+ @property
+ @functools.lru_cache(maxsize=None)
+ def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]:
+ """Returns type hints of the class as a dictionary."""
+ return typing.get_type_hints(cls)
+
+
+@dataclass
+class CommonOptions(metaclass=OptionsMetaProperty):
+ progression_balancing: ProgressionBalancing
+ accessibility: Accessibility
+
+ def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
+ """
+ Returns a dictionary of [str, Option.value]
+
+ :param option_names: names of the options to return
+ :param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
+ """
+ option_results = {}
+ for option_name in option_names:
+ if option_name in type(self).type_hints:
+ if casing == "snake":
+ display_name = option_name
+ elif casing == "camel":
+ split_name = [name.title() for name in option_name.split("_")]
+ split_name[0] = split_name[0].lower()
+ display_name = "".join(split_name)
+ elif casing == "pascal":
+ display_name = "".join([name.title() for name in option_name.split("_")])
+ elif casing == "kebab":
+ display_name = option_name.replace("_", "-")
+ else:
+ raise ValueError(f"{casing} is invalid casing for as_dict. "
+ "Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
+ option_results[display_name] = getattr(self, option_name).value
+ else:
+ raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
+ return option_results
class LocalItems(ItemSet):
@@ -1020,17 +1074,16 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
link.setdefault("link_replacement", None)
-per_game_common_options = {
- **common_options, # can be overwritten per-game
- "local_items": LocalItems,
- "non_local_items": NonLocalItems,
- "start_inventory": StartInventory,
- "start_hints": StartHints,
- "start_location_hints": StartLocationHints,
- "exclude_locations": ExcludeLocations,
- "priority_locations": PriorityLocations,
- "item_links": ItemLinks
-}
+@dataclass
+class PerGameCommonOptions(CommonOptions):
+ local_items: LocalItems
+ non_local_items: NonLocalItems
+ start_inventory: StartInventory
+ start_hints: StartHints
+ start_location_hints: StartLocationHints
+ exclude_locations: ExcludeLocations
+ priority_locations: PriorityLocations
+ item_links: ItemLinks
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
@@ -1071,10 +1124,7 @@ def dictify_range(option: typing.Union[Range, SpecialRange]):
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
- all_options: typing.Dict[str, AssembleOptions] = {
- **per_game_common_options,
- **world.option_definitions
- }
+ all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
diff --git a/SNIClient.py b/SNIClient.py
index 66d0b2ca9c22..0909c61382b6 100644
--- a/SNIClient.py
+++ b/SNIClient.py
@@ -68,12 +68,11 @@ def connect_to_snes(self, snes_options: str = "") -> bool:
options = snes_options.split()
num_options = len(options)
- if num_options > 0:
- snes_device_number = int(options[0])
-
if num_options > 1:
snes_address = options[0]
snes_device_number = int(options[1])
+ elif num_options > 0:
+ snes_device_number = int(options[0])
self.ctx.snes_reconnect_address = None
if self.ctx.snes_connect_task:
diff --git a/Starcraft2Client.py b/Starcraft2Client.py
index cdcdb39a0b44..87b50d35063e 100644
--- a/Starcraft2Client.py
+++ b/Starcraft2Client.py
@@ -1,1049 +1,11 @@
from __future__ import annotations
-import asyncio
-import copy
-import ctypes
-import logging
-import multiprocessing
-import os.path
-import re
-import sys
-import typing
-import queue
-import zipfile
-import io
-from pathlib import Path
+import ModuleUpdate
+ModuleUpdate.update()
-# CommonClient import first to trigger ModuleUpdater
-from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
-from Utils import init_logging, is_windows
+from worlds.sc2wol.Client import launch
+import Utils
if __name__ == "__main__":
- init_logging("SC2Client", exception_logger="Client")
-
-logger = logging.getLogger("Client")
-sc2_logger = logging.getLogger("Starcraft2")
-
-import nest_asyncio
-from worlds._sc2common import bot
-from worlds._sc2common.bot.data import Race
-from worlds._sc2common.bot.main import run_game
-from worlds._sc2common.bot.player import Bot
-from worlds.sc2wol import SC2WoLWorld
-from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
-from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
-from worlds.sc2wol.MissionTables import lookup_id_to_mission
-from worlds.sc2wol.Regions import MissionInfo
-
-import colorama
-from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser
-from MultiServer import mark_raw
-
-nest_asyncio.apply()
-max_bonus: int = 8
-victory_modulo: int = 100
-
-
-class StarcraftClientProcessor(ClientCommandProcessor):
- ctx: SC2Context
-
- def _cmd_difficulty(self, difficulty: str = "") -> bool:
- """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
- options = difficulty.split()
- num_options = len(options)
-
- if num_options > 0:
- difficulty_choice = options[0].lower()
- if difficulty_choice == "casual":
- self.ctx.difficulty_override = 0
- elif difficulty_choice == "normal":
- self.ctx.difficulty_override = 1
- elif difficulty_choice == "hard":
- self.ctx.difficulty_override = 2
- elif difficulty_choice == "brutal":
- self.ctx.difficulty_override = 3
- else:
- self.output("Unable to parse difficulty '" + options[0] + "'")
- return False
-
- self.output("Difficulty set to " + options[0])
- return True
-
- else:
- if self.ctx.difficulty == -1:
- self.output("Please connect to a seed before checking difficulty.")
- else:
- self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty])
- self.output("To change the difficulty, add the name of the difficulty after the command.")
- return False
-
- def _cmd_disable_mission_check(self) -> bool:
- """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
- the next mission in a chain the other player is doing."""
- self.ctx.missions_unlocked = True
- sc2_logger.info("Mission check has been disabled")
- return True
-
- def _cmd_play(self, mission_id: str = "") -> bool:
- """Start a Starcraft 2 mission"""
-
- options = mission_id.split()
- num_options = len(options)
-
- if num_options > 0:
- mission_number = int(options[0])
-
- self.ctx.play_mission(mission_number)
-
- else:
- sc2_logger.info(
- "Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
- return False
-
- return True
-
- def _cmd_available(self) -> bool:
- """Get what missions are currently available to play"""
-
- request_available_missions(self.ctx)
- return True
-
- def _cmd_unfinished(self) -> bool:
- """Get what missions are currently available to play and have not had all locations checked"""
-
- request_unfinished_missions(self.ctx)
- return True
-
- @mark_raw
- def _cmd_set_path(self, path: str = '') -> bool:
- """Manually set the SC2 install directory (if the automatic detection fails)."""
- if path:
- os.environ["SC2PATH"] = path
- is_mod_installed_correctly()
- return True
- else:
- sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
- return False
-
- def _cmd_download_data(self) -> bool:
- """Download the most recent release of the necessary files for playing SC2 with
- Archipelago. Will overwrite existing files."""
- if "SC2PATH" not in os.environ:
- check_game_install_path()
-
- if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"):
- with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f:
- current_ver = f.read()
- else:
- current_ver = None
-
- tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData',
- current_version=current_ver, force_download=True)
-
- if tempzip != '':
- try:
- zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
- sc2_logger.info(f"Download complete. Version {version} installed.")
- with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
- f.write(version)
- finally:
- os.remove(tempzip)
- else:
- sc2_logger.warning("Download aborted/failed. Read the log for more information.")
- return False
- return True
-
-
-class SC2Context(CommonContext):
- command_processor = StarcraftClientProcessor
- game = "Starcraft 2 Wings of Liberty"
- items_handling = 0b111
- difficulty = -1
- all_in_choice = 0
- mission_order = 0
- mission_req_table: typing.Dict[str, MissionInfo] = {}
- final_mission: int = 29
- announcements = queue.Queue()
- sc2_run_task: typing.Optional[asyncio.Task] = None
- missions_unlocked: bool = False # allow launching missions ignoring requirements
- current_tooltip = None
- last_loc_list = None
- difficulty_override = -1
- mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {}
- last_bot: typing.Optional[ArchipelagoBot] = None
-
- def __init__(self, *args, **kwargs):
- super(SC2Context, self).__init__(*args, **kwargs)
- self.raw_text_parser = RawJSONtoTextParser(self)
-
- async def server_auth(self, password_requested: bool = False):
- if password_requested and not self.password:
- await super(SC2Context, self).server_auth(password_requested)
- await self.get_username()
- await self.send_connect()
-
- def on_package(self, cmd: str, args: dict):
- if cmd in {"Connected"}:
- self.difficulty = args["slot_data"]["game_difficulty"]
- self.all_in_choice = args["slot_data"]["all_in_map"]
- slot_req_table = args["slot_data"]["mission_req"]
- # Maintaining backwards compatibility with older slot data
- self.mission_req_table = {
- mission: MissionInfo(
- **{field: value for field, value in mission_info.items() if field in MissionInfo._fields}
- )
- for mission, mission_info in slot_req_table.items()
- }
- self.mission_order = args["slot_data"].get("mission_order", 0)
- self.final_mission = args["slot_data"].get("final_mission", 29)
-
- self.build_location_to_mission_mapping()
-
- # Looks for the required maps and mods for SC2. Runs check_game_install_path.
- maps_present = is_mod_installed_correctly()
- if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
- with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
- current_ver = f.read()
- if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
- sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
- elif maps_present:
- sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). "
- "Run /download_data to update them.")
-
-
- def on_print_json(self, args: dict):
- # goes to this world
- if "receiving" in args and self.slot_concerns_self(args["receiving"]):
- relevant = True
- # found in this world
- elif "item" in args and self.slot_concerns_self(args["item"].player):
- relevant = True
- # not related
- else:
- relevant = False
-
- if relevant:
- self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"])))
-
- super(SC2Context, self).on_print_json(args)
-
- def run_gui(self):
- from kvui import GameManager, HoverBehavior, ServerToolTip
- from kivy.app import App
- from kivy.clock import Clock
- from kivy.uix.tabbedpanel import TabbedPanelItem
- from kivy.uix.gridlayout import GridLayout
- from kivy.lang import Builder
- from kivy.uix.label import Label
- from kivy.uix.button import Button
- from kivy.uix.floatlayout import FloatLayout
- from kivy.properties import StringProperty
-
- class HoverableButton(HoverBehavior, Button):
- pass
-
- class MissionButton(HoverableButton):
- tooltip_text = StringProperty("Test")
- ctx: SC2Context
-
- def __init__(self, *args, **kwargs):
- super(HoverableButton, self).__init__(*args, **kwargs)
- self.layout = FloatLayout()
- self.popuplabel = ServerToolTip(text=self.text)
- self.layout.add_widget(self.popuplabel)
-
- def on_enter(self):
- self.popuplabel.text = self.tooltip_text
-
- if self.ctx.current_tooltip:
- App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
-
- if self.tooltip_text == "":
- self.ctx.current_tooltip = None
- else:
- App.get_running_app().root.add_widget(self.layout)
- self.ctx.current_tooltip = self.layout
-
- def on_leave(self):
- self.ctx.ui.clear_tooltip()
-
- @property
- def ctx(self) -> CommonContext:
- return App.get_running_app().ctx
-
- class MissionLayout(GridLayout):
- pass
-
- class MissionCategory(GridLayout):
- pass
-
- class SC2Manager(GameManager):
- logging_pairs = [
- ("Client", "Archipelago"),
- ("Starcraft2", "Starcraft2"),
- ]
- base_title = "Archipelago Starcraft 2 Client"
-
- mission_panel = None
- last_checked_locations = {}
- mission_id_to_button = {}
- launching: typing.Union[bool, int] = False # if int -> mission ID
- refresh_from_launching = True
- first_check = True
- ctx: SC2Context
-
- def __init__(self, ctx):
- super().__init__(ctx)
-
- def clear_tooltip(self):
- if self.ctx.current_tooltip:
- App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
-
- self.ctx.current_tooltip = None
-
- def build(self):
- container = super().build()
-
- panel = TabbedPanelItem(text="Starcraft 2 Launcher")
- self.mission_panel = panel.content = MissionLayout()
-
- self.tabs.add_widget(panel)
-
- Clock.schedule_interval(self.build_mission_table, 0.5)
-
- return container
-
- def build_mission_table(self, dt):
- if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
- not self.refresh_from_launching)) or self.first_check:
- self.refresh_from_launching = True
-
- self.mission_panel.clear_widgets()
- if self.ctx.mission_req_table:
- self.last_checked_locations = self.ctx.checked_locations.copy()
- self.first_check = False
-
- self.mission_id_to_button = {}
- categories = {}
- available_missions, unfinished_missions = calc_unfinished_missions(self.ctx)
-
- # separate missions into categories
- for mission in self.ctx.mission_req_table:
- if not self.ctx.mission_req_table[mission].category in categories:
- categories[self.ctx.mission_req_table[mission].category] = []
-
- categories[self.ctx.mission_req_table[mission].category].append(mission)
-
- for category in categories:
- category_panel = MissionCategory()
- if category.startswith('_'):
- category_display_name = ''
- else:
- category_display_name = category
- category_panel.add_widget(
- Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1))
-
- for mission in categories[category]:
- text: str = mission
- tooltip: str = ""
- mission_id: int = self.ctx.mission_req_table[mission].id
- # Map has uncollected locations
- if mission in unfinished_missions:
- text = f"[color=6495ED]{text}[/color]"
- elif mission in available_missions:
- text = f"[color=FFFFFF]{text}[/color]"
- # Map requirements not met
- else:
- text = f"[color=a9a9a9]{text}[/color]"
- tooltip = f"Requires: "
- if self.ctx.mission_req_table[mission].required_world:
- tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
- req_mission in
- self.ctx.mission_req_table[mission].required_world)
-
- if self.ctx.mission_req_table[mission].number:
- tooltip += " and "
- if self.ctx.mission_req_table[mission].number:
- tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
- remaining_location_names: typing.List[str] = [
- self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
- if loc in self.ctx.missing_locations]
-
- if mission_id == self.ctx.final_mission:
- if mission in available_missions:
- text = f"[color=FFBC95]{mission}[/color]"
- else:
- text = f"[color=D0C0BE]{mission}[/color]"
- if tooltip:
- tooltip += "\n"
- tooltip += "Final Mission"
-
- if remaining_location_names:
- if tooltip:
- tooltip += "\n"
- tooltip += f"Uncollected locations:\n"
- tooltip += "\n".join(remaining_location_names)
-
- mission_button = MissionButton(text=text, size_hint_y=None, height=50)
- mission_button.tooltip_text = tooltip
- mission_button.bind(on_press=self.mission_callback)
- self.mission_id_to_button[mission_id] = mission_button
- category_panel.add_widget(mission_button)
-
- category_panel.add_widget(Label(text=""))
- self.mission_panel.add_widget(category_panel)
-
- elif self.launching:
- self.refresh_from_launching = False
-
- self.mission_panel.clear_widgets()
- self.mission_panel.add_widget(Label(text="Launching Mission: " +
- lookup_id_to_mission[self.launching]))
- if self.ctx.ui:
- self.ctx.ui.clear_tooltip()
-
- def mission_callback(self, button):
- if not self.launching:
- mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button)
- self.ctx.play_mission(mission_id)
- self.launching = mission_id
- Clock.schedule_once(self.finish_launching, 10)
-
- def finish_launching(self, dt):
- self.launching = False
-
- self.ui = SC2Manager(self)
- self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
- import pkgutil
- data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode()
- Builder.load_string(data)
-
- async def shutdown(self):
- await super(SC2Context, self).shutdown()
- if self.last_bot:
- self.last_bot.want_close = True
- if self.sc2_run_task:
- self.sc2_run_task.cancel()
-
- def play_mission(self, mission_id: int):
- if self.missions_unlocked or \
- is_mission_available(self, mission_id):
- if self.sc2_run_task:
- if not self.sc2_run_task.done():
- sc2_logger.warning("Starcraft 2 Client is still running!")
- self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
- if self.slot is None:
- sc2_logger.warning("Launching Mission without Archipelago authentication, "
- "checks will not be registered to server.")
- self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
- name="Starcraft 2 Launch")
- else:
- sc2_logger.info(
- f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
- f"Use /unfinished or /available to see what is available.")
-
- def build_location_to_mission_mapping(self):
- mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = {
- mission_info.id: set() for mission_info in self.mission_req_table.values()
- }
-
- for loc in self.server_locations:
- mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo)
- mission_id_to_location_ids[mission_id].add(objective)
- self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in
- mission_id_to_location_ids.items()}
-
- def locations_for_mission(self, mission: str):
- mission_id: int = self.mission_req_table[mission].id
- objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id]
- for objective in objectives:
- yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective
-
-
-async def main():
- multiprocessing.freeze_support()
- parser = get_base_parser()
- parser.add_argument('--name', default=None, help="Slot Name to connect as.")
- args = parser.parse_args()
-
- ctx = SC2Context(args.connect, args.password)
- ctx.auth = args.name
- if ctx.server_task is None:
- ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
-
- if gui_enabled:
- ctx.run_gui()
- ctx.run_cli()
-
- await ctx.exit_event.wait()
-
- await ctx.shutdown()
-
-
-maps_table = [
- "ap_traynor01", "ap_traynor02", "ap_traynor03",
- "ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b",
- "ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05",
- "ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b",
- "ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s",
- "ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04",
- "ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03"
-]
-
-wol_default_categories = [
- "Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist",
- "Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert",
- "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
- "Char", "Char", "Char", "Char"
-]
-wol_default_category_names = [
- "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char"
-]
-
-
-def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]:
- network_item: NetworkItem
- accumulators: typing.List[int] = [0 for _ in type_flaggroups]
-
- for network_item in items:
- name: str = lookup_id_to_name[network_item.item]
- item_data: ItemData = item_table[name]
-
- # exists exactly once
- if item_data.quantity == 1:
- accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number
-
- # exists multiple times
- elif item_data.type == "Upgrade":
- accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number
-
- # sum
- else:
- accumulators[type_flaggroups[item_data.type]] += item_data.number
-
- return accumulators
-
-
-def calc_difficulty(difficulty):
- if difficulty == 0:
- return 'C'
- elif difficulty == 1:
- return 'N'
- elif difficulty == 2:
- return 'H'
- elif difficulty == 3:
- return 'B'
-
- return 'X'
-
-
-async def starcraft_launch(ctx: SC2Context, mission_id: int):
- sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
-
- with DllDirectory(None):
- run_game(bot.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
- name="Archipelago", fullscreen=True)], realtime=True)
-
-
-class ArchipelagoBot(bot.bot_ai.BotAI):
- game_running: bool = False
- mission_completed: bool = False
- boni: typing.List[bool]
- setup_done: bool
- ctx: SC2Context
- mission_id: int
- want_close: bool = False
- can_read_game = False
-
- last_received_update: int = 0
-
- def __init__(self, ctx: SC2Context, mission_id):
- self.setup_done = False
- self.ctx = ctx
- self.ctx.last_bot = self
- self.mission_id = mission_id
- self.boni = [False for _ in range(max_bonus)]
-
- super(ArchipelagoBot, self).__init__()
-
- async def on_step(self, iteration: int):
- if self.want_close:
- self.want_close = False
- await self._client.leave()
- return
- game_state = 0
- if not self.setup_done:
- self.setup_done = True
- start_items = calculate_items(self.ctx.items_received)
- if self.ctx.difficulty_override >= 0:
- difficulty = calc_difficulty(self.ctx.difficulty_override)
- else:
- difficulty = calc_difficulty(self.ctx.difficulty)
- await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
- difficulty,
- start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
- start_items[5], start_items[6], start_items[7], start_items[8], start_items[9],
- self.ctx.all_in_choice, start_items[10]))
- self.last_received_update = len(self.ctx.items_received)
-
- else:
- if not self.ctx.announcements.empty():
- message = self.ctx.announcements.get(timeout=1)
- await self.chat_send("SendMessage " + message)
- self.ctx.announcements.task_done()
-
- # Archipelago reads the health
- for unit in self.all_own_units():
- if unit.health_max == 38281:
- game_state = int(38281 - unit.health)
- self.can_read_game = True
-
- if iteration == 160 and not game_state & 1:
- await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " +
- "Starcraft 2 (This is likely a map issue)")
-
- if self.last_received_update < len(self.ctx.items_received):
- current_items = calculate_items(self.ctx.items_received)
- await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format(
- current_items[0], current_items[1], current_items[2], current_items[3], current_items[4],
- current_items[5], current_items[6], current_items[7]))
- self.last_received_update = len(self.ctx.items_received)
-
- if game_state & 1:
- if not self.game_running:
- print("Archipelago Connected")
- self.game_running = True
-
- if self.can_read_game:
- if game_state & (1 << 1) and not self.mission_completed:
- if self.mission_id != self.ctx.final_mission:
- print("Mission Completed")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}])
- self.mission_completed = True
- else:
- print("Game Complete")
- await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
- self.mission_completed = True
-
- for x, completed in enumerate(self.boni):
- if not completed and game_state & (1 << (x + 2)):
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}])
- self.boni[x] = True
-
- else:
- await self.chat_send("LostConnection - Lost connection to game.")
-
-
-def request_unfinished_missions(ctx: SC2Context):
- if ctx.mission_req_table:
- message = "Unfinished Missions: "
- unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
- unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table)
-
- _, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
-
- # Removing All-In from location pool
- final_mission = lookup_id_to_mission[ctx.final_mission]
- if final_mission in unfinished_missions.keys():
- message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message
- if unfinished_missions[final_mission] == -1:
- unfinished_missions.pop(final_mission)
-
- message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " +
- mark_up_objectives(
- f"[{len(unfinished_missions[mission])}/"
- f"{sum(1 for _ in ctx.locations_for_mission(mission))}]",
- ctx, unfinished_locations, mission)
- for mission in unfinished_missions)
-
- if ctx.ui:
- ctx.ui.log_panels['All'].on_message_markup(message)
- ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
- else:
- sc2_logger.info(message)
- else:
- sc2_logger.warning("No mission table found, you are likely not connected to a server.")
-
-
-def calc_unfinished_missions(ctx: SC2Context, unlocks=None):
- unfinished_missions = []
- locations_completed = []
-
- if not unlocks:
- unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
-
- available_missions = calc_available_missions(ctx, unlocks)
-
- for name in available_missions:
- objectives = set(ctx.locations_for_mission(name))
- if objectives:
- objectives_completed = ctx.checked_locations & objectives
- if len(objectives_completed) < len(objectives):
- unfinished_missions.append(name)
- locations_completed.append(objectives_completed)
-
- else: # infer that this is the final mission as it has no objectives
- unfinished_missions.append(name)
- locations_completed.append(-1)
-
- return available_missions, dict(zip(unfinished_missions, locations_completed))
-
-
-def is_mission_available(ctx: SC2Context, mission_id_to_check):
- unfinished_missions = calc_available_missions(ctx)
-
- return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions)
-
-
-def mark_up_mission_name(ctx: SC2Context, mission, unlock_table):
- """Checks if the mission is required for game completion and adds '*' to the name to mark that."""
-
- if ctx.mission_req_table[mission].completion_critical:
- if ctx.ui:
- message = "[color=AF99EF]" + mission + "[/color]"
- else:
- message = "*" + mission + "*"
- else:
- message = mission
-
- if ctx.ui:
- unlocks = unlock_table[mission]
-
- if len(unlocks) > 0:
- pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: "
- pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks)
- pre_message += f"]"
- message = pre_message + message + "[/ref]"
-
- return message
-
-
-def mark_up_objectives(message, ctx, unfinished_locations, mission):
- formatted_message = message
-
- if ctx.ui:
- locations = unfinished_locations[mission]
-
- pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|"
- pre_message += "
".join(location for location in locations)
- pre_message += f"]"
- formatted_message = pre_message + message + "[/ref]"
-
- return formatted_message
-
-
-def request_available_missions(ctx: SC2Context):
- if ctx.mission_req_table:
- message = "Available Missions: "
-
- # Initialize mission unlock table
- unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
-
- missions = calc_available_missions(ctx, unlocks)
- message += \
- ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}"
- f"[{ctx.mission_req_table[mission].id}]"
- for mission in missions)
-
- if ctx.ui:
- ctx.ui.log_panels['All'].on_message_markup(message)
- ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
- else:
- sc2_logger.info(message)
- else:
- sc2_logger.warning("No mission table found, you are likely not connected to a server.")
-
-
-def calc_available_missions(ctx: SC2Context, unlocks=None):
- available_missions = []
- missions_complete = 0
-
- # Get number of missions completed
- for loc in ctx.checked_locations:
- if loc % victory_modulo == 0:
- missions_complete += 1
-
- for name in ctx.mission_req_table:
- # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
- if unlocks:
- for unlock in ctx.mission_req_table[name].required_world:
- unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name)
-
- if mission_reqs_completed(ctx, name, missions_complete):
- available_missions.append(name)
-
- return available_missions
-
-
-def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int):
- """Returns a bool signifying if the mission has all requirements complete and can be done
-
- Arguments:
- ctx -- instance of SC2Context
- locations_to_check -- the mission string name to check
- missions_complete -- an int of how many missions have been completed
- mission_path -- a list of missions that have already been checked
-"""
- if len(ctx.mission_req_table[mission_name].required_world) >= 1:
- # A check for when the requirements are being or'd
- or_success = False
-
- # Loop through required missions
- for req_mission in ctx.mission_req_table[mission_name].required_world:
- req_success = True
-
- # Check if required mission has been completed
- if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id *
- victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations:
- if not ctx.mission_req_table[mission_name].or_requirements:
- return False
- else:
- req_success = False
-
- # Grid-specific logic (to avoid long path checks and infinite recursion)
- if ctx.mission_order in (3, 4):
- if req_success:
- return True
- else:
- if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
- return False
- else:
- continue
-
- # Recursively check required mission to see if it's requirements are met, in case !collect has been done
- # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
- if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
- if not ctx.mission_req_table[mission_name].or_requirements:
- return False
- else:
- req_success = False
-
- # If requirement check succeeded mark or as satisfied
- if ctx.mission_req_table[mission_name].or_requirements and req_success:
- or_success = True
-
- if ctx.mission_req_table[mission_name].or_requirements:
- # Return false if or requirements not met
- if not or_success:
- return False
-
- # Check number of missions
- if missions_complete >= ctx.mission_req_table[mission_name].number:
- return True
- else:
- return False
- else:
- return True
-
-
-def initialize_blank_mission_dict(location_table):
- unlocks = {}
-
- for mission in list(location_table):
- unlocks[mission] = []
-
- return unlocks
-
-
-def check_game_install_path() -> bool:
- # First thing: go to the default location for ExecuteInfo.
- # An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
- if is_windows:
- # The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
- # https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
- import ctypes.wintypes
- CSIDL_PERSONAL = 5 # My Documents
- SHGFP_TYPE_CURRENT = 0 # Get current, not default value
-
- buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
- ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
- documentspath = buf.value
- einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
- else:
- einfo = str(bot.paths.get_home() / Path(bot.paths.USERPATH[bot.paths.PF]))
-
- # Check if the file exists.
- if os.path.isfile(einfo):
-
- # Open the file and read it, picking out the latest executable's path.
- with open(einfo) as f:
- content = f.read()
- if content:
- try:
- base = re.search(r" = (.*)Versions", content).group(1)
- except AttributeError:
- sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then "
- f"try again.")
- return False
- if os.path.exists(base):
- executable = bot.paths.latest_executeble(Path(base).expanduser() / "Versions")
-
- # Finally, check the path for an actual executable.
- # If we find one, great. Set up the SC2PATH.
- if os.path.isfile(executable):
- sc2_logger.info(f"Found an SC2 install at {base}!")
- sc2_logger.debug(f"Latest executable at {executable}.")
- os.environ["SC2PATH"] = base
- sc2_logger.debug(f"SC2PATH set to {base}.")
- return True
- else:
- sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
- else:
- sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
- else:
- sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. "
- f"If that fails, please run /set_path with your SC2 install directory.")
- return False
-
-
-def is_mod_installed_correctly() -> bool:
- """Searches for all required files."""
- if "SC2PATH" not in os.environ:
- check_game_install_path()
-
- mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
- modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
- wol_required_maps = [
- "ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
- "ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
- "ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
- "ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map",
- "ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
- "ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
- "ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
- ]
- needs_files = False
-
- # Check for maps.
- missing_maps = []
- for mapfile in wol_required_maps:
- if not os.path.isfile(mapdir / mapfile):
- missing_maps.append(mapfile)
- if len(missing_maps) >= 19:
- sc2_logger.warning(f"All map files missing from {mapdir}.")
- needs_files = True
- elif len(missing_maps) > 0:
- for map in missing_maps:
- sc2_logger.debug(f"Missing {map} from {mapdir}.")
- sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
- needs_files = True
- else: # Must be no maps missing
- sc2_logger.info(f"All maps found in {mapdir}.")
-
- # Check for mods.
- if os.path.isfile(modfile):
- sc2_logger.info(f"Archipelago mod found at {modfile}.")
- else:
- sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
- needs_files = True
-
- # Final verdict.
- if needs_files:
- sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
- return False
- else:
- return True
-
-
-class DllDirectory:
- # Credit to Black Sliver for this code.
- # More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
- _old: typing.Optional[str] = None
- _new: typing.Optional[str] = None
-
- def __init__(self, new: typing.Optional[str]):
- self._new = new
-
- def __enter__(self):
- old = self.get()
- if self.set(self._new):
- self._old = old
-
- def __exit__(self, *args):
- if self._old is not None:
- self.set(self._old)
-
- @staticmethod
- def get() -> typing.Optional[str]:
- if sys.platform == "win32":
- n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
- buf = ctypes.create_unicode_buffer(n)
- ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
- return buf.value
- # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
- return None
-
- @staticmethod
- def set(s: typing.Optional[str]) -> bool:
- if sys.platform == "win32":
- return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
- # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
- return False
-
-
-def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
- """Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
- import requests
-
- headers = {"Accept": 'application/vnd.github.v3+json'}
- url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
-
- r1 = requests.get(url, headers=headers)
- if r1.status_code == 200:
- latest_version = r1.json()["tag_name"]
- sc2_logger.info(f"Latest version: {latest_version}.")
- else:
- sc2_logger.warning(f"Status code: {r1.status_code}")
- sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
- sc2_logger.warning(f"text: {r1.text}")
- return "", current_version
-
- if (force_download is False) and (current_version == latest_version):
- sc2_logger.info("Latest version already installed.")
- return "", current_version
-
- sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
- download_url = r1.json()["assets"][0]["browser_download_url"]
-
- r2 = requests.get(download_url, headers=headers)
- if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)):
- with open(f"{repo}.zip", "wb") as fh:
- fh.write(r2.content)
- sc2_logger.info(f"Successfully downloaded {repo}.zip.")
- return f"{repo}.zip", latest_version
- else:
- sc2_logger.warning(f"Status code: {r2.status_code}")
- sc2_logger.warning("Download failed.")
- sc2_logger.warning(f"text: {r2.text}")
- return "", current_version
-
-
-def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
- import requests
-
- headers = {"Accept": 'application/vnd.github.v3+json'}
- url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
-
- r1 = requests.get(url, headers=headers)
- if r1.status_code == 200:
- latest_version = r1.json()["tag_name"]
- if current_version != latest_version:
- return True
- else:
- return False
-
- else:
- sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
- sc2_logger.warning(f"Status code: {r1.status_code}")
- sc2_logger.warning(f"text: {r1.text}")
- return False
-
-
-if __name__ == '__main__':
- colorama.init()
- asyncio.run(main())
- colorama.deinit()
+ Utils.init_logging("Starcraft2Client", exception_logger="Client")
+ launch()
diff --git a/UndertaleClient.py b/UndertaleClient.py
index 6419707211a6..62fbe128bdb9 100644
--- a/UndertaleClient.py
+++ b/UndertaleClient.py
@@ -29,31 +29,31 @@ def _cmd_resync(self):
def _cmd_patch(self):
"""Patch the game."""
if isinstance(self.ctx, UndertaleContext):
- os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
+ os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
self.ctx.patch_game()
self.output("Patched.")
def _cmd_savepath(self, directory: str):
"""Redirect to proper save data folder. (Use before connecting!)"""
if isinstance(self.ctx, UndertaleContext):
- UndertaleContext.save_game_folder = directory
- self.output("Changed to the following directory: " + directory)
+ self.ctx.save_game_folder = directory
+ self.output("Changed to the following directory: " + self.ctx.save_game_folder)
@mark_raw
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext):
- os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
+ os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
if tempInstall is None:
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
- if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
+ if not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
elif not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
- if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
+ if not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
@@ -61,8 +61,8 @@ def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
else:
for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll":
- shutil.copy(tempInstall+"\\"+file_name,
- os.getcwd() + "\\Undertale\\" + file_name)
+ shutil.copy(os.path.join(tempInstall, file_name),
+ os.path.join(os.getcwd(), "Undertale", file_name))
self.ctx.patch_game()
self.output("Patching successful!")
@@ -111,13 +111,13 @@ def __init__(self, server_address, password):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self):
- with open(os.getcwd() + "/Undertale/data.win", "rb") as f:
+ with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
- with open(os.getcwd() + "/Undertale/data.win", "wb") as f:
+ with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
f.write(patchedFile)
- os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True)
- with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" +
- "Which Character.txt"), "w") as f:
+ os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
+ with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
+ "Which Character.txt")), "w") as f:
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
"line other than this one.\n", "frisk"])
f.close()
@@ -385,7 +385,7 @@ async def multi_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path):
for file in files:
if "spots.mine" in file and "Online" in ctx.tags:
- with open(root + "/" + file, "r") as mine:
+ with open(os.path.join(root, file), "r") as mine:
this_x = mine.readline()
this_y = mine.readline()
this_room = mine.readline()
@@ -408,7 +408,7 @@ async def game_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path):
for file in files:
if ".item" in file:
- os.remove(root+"/"+file)
+ os.remove(os.path.join(root, file))
sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
@@ -424,13 +424,13 @@ async def game_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path):
for file in files:
if "DontBeMad.mad" in file:
- os.remove(root+"/"+file)
+ os.remove(os.path.join(root, file))
if "DeathLink" in ctx.tags:
await ctx.send_death()
if "scout" == file:
sending = []
try:
- with open(root+"/"+file, "r") as f:
+ with open(os.path.join(root, file), "r") as f:
lines = f.readlines()
for l in lines:
if ctx.server_locations.__contains__(int(l)+12000):
@@ -438,11 +438,11 @@ async def game_watcher(ctx: UndertaleContext):
finally:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
"create_as_hint": int(2)}])
- os.remove(root+"/"+file)
+ os.remove(os.path.join(root, file))
if "check.spot" in file:
sending = []
try:
- with open(root+"/"+file, "r") as f:
+ with open(os.path.join(root, file), "r") as f:
lines = f.readlines()
for l in lines:
sending = sending+[(int(l.rstrip('\n')))+12000]
@@ -451,7 +451,7 @@ async def game_watcher(ctx: UndertaleContext):
if "victory" in file and str(ctx.route) in file:
victory = True
if ".playerspot" in file and "Online" not in ctx.tags:
- os.remove(root+"/"+file)
+ os.remove(os.path.join(root, file))
if "victory" in file:
if str(ctx.route) == "all_routes":
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
diff --git a/Utils.py b/Utils.py
index 159c6cdcb161..5fb037a17325 100644
--- a/Utils.py
+++ b/Utils.py
@@ -13,6 +13,7 @@
import collections
import importlib
import logging
+import warnings
from argparse import Namespace
from settings import Settings, get_settings
@@ -29,6 +30,7 @@
if typing.TYPE_CHECKING:
import tkinter
import pathlib
+ from BaseClasses import Region
def tuplize_version(version: str) -> Version:
@@ -44,7 +46,7 @@ def as_simple_string(self) -> str:
return ".".join(str(item) for item in self)
-__version__ = "0.4.2"
+__version__ = "0.4.3"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -215,7 +217,13 @@ def get_cert_none_ssl_context():
def get_public_ipv4() -> str:
import socket
import urllib.request
- ip = socket.gethostbyname(socket.gethostname())
+ try:
+ ip = socket.gethostbyname(socket.gethostname())
+ except socket.gaierror:
+ # if hostname or resolvconf is not set up properly, this may fail
+ warnings.warn("Could not resolve own hostname, falling back to 127.0.0.1")
+ ip = "127.0.0.1"
+
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -233,7 +241,13 @@ def get_public_ipv4() -> str:
def get_public_ipv6() -> str:
import socket
import urllib.request
- ip = socket.gethostbyname(socket.gethostname())
+ try:
+ ip = socket.gethostbyname(socket.gethostname())
+ except socket.gaierror:
+ # if hostname or resolvconf is not set up properly, this may fail
+ warnings.warn("Could not resolve own hostname, falling back to ::1")
+ ip = "::1"
+
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -359,11 +373,13 @@ def get_unique_identifier():
class RestrictedUnpickler(pickle.Unpickler):
+ generic_properties_module: Optional[object]
+
def __init__(self, *args, **kwargs):
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils")
- self.generic_properties_module = importlib.import_module("worlds.generic")
+ self.generic_properties_module = None
def find_class(self, module, name):
if module == "builtins" and name in safe_builtins:
@@ -373,6 +389,8 @@ def find_class(self, module, name):
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
+ if not self.generic_properties_module:
+ self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
@@ -441,11 +459,21 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
write_mode,
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter(log_format))
+
+ class Filter(logging.Filter):
+ def __init__(self, filter_name, condition):
+ super().__init__(filter_name)
+ self.condition = condition
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ return self.condition(record)
+
+ file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
root_logger.addHandler(file_handler)
if sys.stdout:
- root_logger.addHandler(
- logging.StreamHandler(sys.stdout)
- )
+ stream_handler = logging.StreamHandler(sys.stdout)
+ stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
+ root_logger.addHandler(stream_handler)
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -572,7 +600,7 @@ def run(*args: str):
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
- selection = (f'--filename="{suggest}',) if suggest else ()
+ selection = (f"--filename={suggest}",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
@@ -584,7 +612,10 @@ def run(*args: str):
f'This attempt was made because open_filename was used for "{title}".')
raise e
else:
- root = tkinter.Tk()
+ try:
+ root = tkinter.Tk()
+ except tkinter.TclError:
+ return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
@@ -597,13 +628,14 @@ def run(*args: str):
if is_linux:
# prefer native dialog
from shutil import which
- kdialog = None#which("kdialog")
+ kdialog = which("kdialog")
if kdialog:
- return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".")
- zenity = None#which("zenity")
+ return run(kdialog, f"--title={title}", "--getexistingdirectory",
+ os.path.abspath(suggest) if suggest else ".")
+ zenity = which("zenity")
if zenity:
z_filters = ("--directory",)
- selection = (f'--filename="{suggest}',) if suggest else ()
+ selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
@@ -615,7 +647,10 @@ def run(*args: str):
f'This attempt was made because open_filename was used for "{title}".')
raise e
else:
- root = tkinter.Tk()
+ try:
+ root = tkinter.Tk()
+ except tkinter.TclError:
+ return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
@@ -645,6 +680,11 @@ def is_kivy_running():
if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
+ elif is_windows:
+ import ctypes
+ style = 0x10 if error else 0x0
+ return ctypes.windll.user32.MessageBoxW(0, text, title, style)
+
# fall back to tk
try:
import tkinter
@@ -755,3 +795,113 @@ def freeze_support() -> None:
import multiprocessing
_extend_freeze_support()
multiprocessing.freeze_support()
+
+
+def visualize_regions(root_region: Region, file_name: str, *,
+ show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
+ linetype_ortho: bool = True) -> None:
+ """Visualize the layout of a world as a PlantUML diagram.
+
+ :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
+ :param file_name: The name of the destination .puml file.
+ :param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection.
+ :param show_locations: (default True) If enabled, the locations will be listed inside each region.
+ Priority locations will be shown in bold.
+ Excluded locations will be stricken out.
+ Locations without ID will be shown in italics.
+ Locked locations will be shown with a padlock icon.
+ For filled locations, the item name will be shown after the location name.
+ Progression items will be shown in bold.
+ Items without ID will be shown in italics.
+ :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
+ :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
+
+ Example usage in World code:
+ from Utils import visualize_regions
+ visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
+
+ Example usage in Main code:
+ from Utils import visualize_regions
+ for player in world.player_ids:
+ visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml")
+ """
+ assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
+ from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
+ from collections import deque
+ import re
+
+ uml: typing.List[str] = list()
+ seen: typing.Set[Region] = set()
+ regions: typing.Deque[Region] = deque((root_region,))
+ multiworld: MultiWorld = root_region.multiworld
+
+ def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
+ name = obj.name
+ if isinstance(obj, Item):
+ name = multiworld.get_name_string_for_object(obj)
+ if obj.advancement:
+ name = f"**{name}**"
+ if obj.code is None:
+ name = f"//{name}//"
+ if isinstance(obj, Location):
+ if obj.progress_type == LocationProgressType.PRIORITY:
+ name = f"**{name}**"
+ elif obj.progress_type == LocationProgressType.EXCLUDED:
+ name = f"--{name}--"
+ if obj.address is None:
+ name = f"//{name}//"
+ return re.sub("[\".:]", "", name)
+
+ def visualize_exits(region: Region) -> None:
+ for exit_ in region.exits:
+ if exit_.connected_region:
+ if show_entrance_names:
+ uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
+ else:
+ try:
+ uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
+ uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
+ except ValueError:
+ uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
+ else:
+ uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
+ uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
+
+ def visualize_locations(region: Region) -> None:
+ any_lock = any(location.locked for location in region.locations)
+ for location in region.locations:
+ lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else ""
+ if location.item:
+ uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}")
+ else:
+ uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
+
+ def visualize_region(region: Region) -> None:
+ uml.append(f"class \"{fmt(region)}\"")
+ if show_locations:
+ visualize_locations(region)
+ visualize_exits(region)
+
+ def visualize_other_regions() -> None:
+ if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
+ uml.append("package \"other regions\" <
Minimum value: ${setting.min}
` +
- `Maximum value: ${setting.max}`;
-
- if (setting.hasOwnProperty('value_names')) {
- hintText.innerHTML += '
Certain values have special meaning:';
- Object.keys(setting.value_names).forEach((specialName) => {
- hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`;
- });
- }
-
- settingWrapper.appendChild(hintText);
-
- const addOptionDiv = document.createElement('div');
- addOptionDiv.classList.add('add-option-div');
- const optionInput = document.createElement('input');
- optionInput.setAttribute('id', `${game}-${settingName}-option`);
- optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`);
- addOptionDiv.appendChild(optionInput);
- const addOptionButton = document.createElement('button');
- addOptionButton.innerText = 'Add';
- addOptionDiv.appendChild(addOptionButton);
- settingWrapper.appendChild(addOptionDiv);
- optionInput.addEventListener('keydown', (evt) => {
- if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); }
+ tbody.appendChild(tr);
});
- addOptionButton.addEventListener('click', () => {
- const optionInput = document.getElementById(`${game}-${settingName}-option`);
- let option = optionInput.value;
- if (!option || !option.trim()) { return; }
- option = parseInt(option, 10);
- if ((option < setting.min) || (option > setting.max)) { return; }
- optionInput.value = '';
- if (document.getElementById(`${game}-${settingName}-${option}-range`)) { return; }
+ optionTable.appendChild(tbody);
+ settingWrapper.appendChild(optionTable);
+ break;
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- tdLeft.innerText = option;
- tr.appendChild(tdLeft);
+ case 'range':
+ case 'special_range':
+ const rangeTable = document.createElement('table');
+ const rangeTbody = document.createElement('tbody');
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('id', `${game}-${settingName}-${option}-range`);
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', option);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][parseInt(option, 10)];
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
+ if (((setting.max - setting.min) + 1) < 11) {
+ for (let i=setting.min; i <= setting.max; ++i) {
+ const tr = document.createElement('tr');
+ const tdLeft = document.createElement('td');
+ tdLeft.classList.add('td-left');
+ tdLeft.innerText = i;
+ tr.appendChild(tdLeft);
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${option}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
+ const tdMiddle = document.createElement('td');
+ tdMiddle.classList.add('td-middle');
+ const range = document.createElement('input');
+ range.setAttribute('type', 'range');
+ range.setAttribute('id', `${this.name}-${settingName}-${i}-range`);
+ range.setAttribute('data-game', this.name);
+ range.setAttribute('data-setting', settingName);
+ range.setAttribute('data-option', i);
+ range.setAttribute('min', 0);
+ range.setAttribute('max', 50);
+ range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
+ range.value = this.current[settingName][i] || 0;
+ tdMiddle.appendChild(range);
+ tr.appendChild(tdMiddle);
- const tdDelete = document.createElement('td');
- tdDelete.classList.add('td-delete');
- const deleteButton = document.createElement('span');
- deleteButton.classList.add('range-option-delete');
- deleteButton.innerText = '❌';
- deleteButton.addEventListener('click', () => {
- range.value = 0;
- range.dispatchEvent(new Event('change'));
- rangeTbody.removeChild(tr);
- });
- tdDelete.appendChild(deleteButton);
- tr.appendChild(tdDelete);
+ const tdRight = document.createElement('td');
+ tdRight.setAttribute('id', `${this.name}-${settingName}-${i}`)
+ tdRight.classList.add('td-right');
+ tdRight.innerText = range.value;
+ tr.appendChild(tdRight);
- rangeTbody.appendChild(tr);
+ rangeTbody.appendChild(tr);
+ }
+ } else {
+ const hintText = document.createElement('p');
+ hintText.classList.add('hint-text');
+ hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' +
+ `below, then press the "Add" button to add a weight for it.
Minimum value: ${setting.min}
` +
+ `Maximum value: ${setting.max}`;
+
+ if (setting.hasOwnProperty('value_names')) {
+ hintText.innerHTML += '
Certain values have special meaning:';
+ Object.keys(setting.value_names).forEach((specialName) => {
+ hintText.innerHTML += `
${specialName}: ${setting.value_names[specialName]}`;
+ });
+ }
- // Save new option to settings
- range.dispatchEvent(new Event('change'));
- });
+ settingWrapper.appendChild(hintText);
+
+ const addOptionDiv = document.createElement('div');
+ addOptionDiv.classList.add('add-option-div');
+ const optionInput = document.createElement('input');
+ optionInput.setAttribute('id', `${this.name}-${settingName}-option`);
+ optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`);
+ addOptionDiv.appendChild(optionInput);
+ const addOptionButton = document.createElement('button');
+ addOptionButton.innerText = 'Add';
+ addOptionDiv.appendChild(addOptionButton);
+ settingWrapper.appendChild(addOptionDiv);
+ optionInput.addEventListener('keydown', (evt) => {
+ if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); }
+ });
- Object.keys(currentSettings[game][settingName]).forEach((option) => {
- // These options are statically generated below, and should always appear even if they are deleted
- // from localStorage
- if (['random-low', 'random', 'random-high'].includes(option)) { return; }
+ addOptionButton.addEventListener('click', () => {
+ const optionInput = document.getElementById(`${this.name}-${settingName}-option`);
+ let option = optionInput.value;
+ if (!option || !option.trim()) { return; }
+ option = parseInt(option, 10);
+ if ((option < setting.min) || (option > setting.max)) { return; }
+ optionInput.value = '';
+ if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; }
- const tr = document.createElement('tr');
+ const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
@@ -430,19 +602,19 @@ const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => {
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
- range.setAttribute('id', `${game}-${settingName}-${option}-range`);
- range.setAttribute('data-game', game);
+ range.setAttribute('id', `${this.name}-${settingName}-${option}-range`);
+ range.setAttribute('data-game', this.name);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][parseInt(option, 10)];
+ range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
+ range.value = this.current[settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${option}`)
+ tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
@@ -454,731 +626,651 @@ const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => {
deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => {
range.value = 0;
- const changeEvent = new Event('change');
- changeEvent.action = 'rangeDelete';
- range.dispatchEvent(changeEvent);
+ range.dispatchEvent(new Event('change'));
rangeTbody.removeChild(tr);
});
tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete);
rangeTbody.appendChild(tr);
- });
- }
-
- ['random', 'random-low', 'random-high'].forEach((option) => {
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- switch(option){
- case 'random':
- tdLeft.innerText = 'Random';
- break;
- case 'random-low':
- tdLeft.innerText = "Random (Low)";
- break;
- case 'random-high':
- tdLeft.innerText = "Random (High)";
- break;
- }
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('id', `${game}-${settingName}-${option}-range`);
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', option);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][option];
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${option}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
- rangeTbody.appendChild(tr);
- });
-
- rangeTable.appendChild(rangeTbody);
- settingWrapper.appendChild(rangeTable);
- break;
+ // Save new option to settings
+ range.dispatchEvent(new Event('change'));
+ });
- case 'items-list':
- const itemsList = document.createElement('div');
- itemsList.classList.add('simple-list');
-
- Object.values(gameItems).forEach((item) => {
- const itemRow = document.createElement('div');
- itemRow.classList.add('list-row');
-
- const itemLabel = document.createElement('label');
- itemLabel.setAttribute('for', `${game}-${settingName}-${item}`)
-
- const itemCheckbox = document.createElement('input');
- itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`);
- itemCheckbox.setAttribute('type', 'checkbox');
- itemCheckbox.setAttribute('data-game', game);
- itemCheckbox.setAttribute('data-setting', settingName);
- itemCheckbox.setAttribute('data-option', item.toString());
- itemCheckbox.addEventListener('change', updateListSetting);
- if (currentSettings[game][settingName].includes(item)) {
- itemCheckbox.setAttribute('checked', '1');
+ Object.keys(this.current[settingName]).forEach((option) => {
+ // These options are statically generated below, and should always appear even if they are deleted
+ // from localStorage
+ if (['random-low', 'random', 'random-high'].includes(option)) { return; }
+
+ const tr = document.createElement('tr');
+ const tdLeft = document.createElement('td');
+ tdLeft.classList.add('td-left');
+ tdLeft.innerText = option;
+ tr.appendChild(tdLeft);
+
+ const tdMiddle = document.createElement('td');
+ tdMiddle.classList.add('td-middle');
+ const range = document.createElement('input');
+ range.setAttribute('type', 'range');
+ range.setAttribute('id', `${this.name}-${settingName}-${option}-range`);
+ range.setAttribute('data-game', this.name);
+ range.setAttribute('data-setting', settingName);
+ range.setAttribute('data-option', option);
+ range.setAttribute('min', 0);
+ range.setAttribute('max', 50);
+ range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
+ range.value = this.current[settingName][parseInt(option, 10)];
+ tdMiddle.appendChild(range);
+ tr.appendChild(tdMiddle);
+
+ const tdRight = document.createElement('td');
+ tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`)
+ tdRight.classList.add('td-right');
+ tdRight.innerText = range.value;
+ tr.appendChild(tdRight);
+
+ const tdDelete = document.createElement('td');
+ tdDelete.classList.add('td-delete');
+ const deleteButton = document.createElement('span');
+ deleteButton.classList.add('range-option-delete');
+ deleteButton.innerText = '❌';
+ deleteButton.addEventListener('click', () => {
+ range.value = 0;
+ const changeEvent = new Event('change');
+ changeEvent.action = 'rangeDelete';
+ range.dispatchEvent(changeEvent);
+ rangeTbody.removeChild(tr);
+ });
+ tdDelete.appendChild(deleteButton);
+ tr.appendChild(tdDelete);
+
+ rangeTbody.appendChild(tr);
+ });
}
- const itemName = document.createElement('span');
- itemName.innerText = item.toString();
+ ['random', 'random-low', 'random-high'].forEach((option) => {
+ const tr = document.createElement('tr');
+ const tdLeft = document.createElement('td');
+ tdLeft.classList.add('td-left');
+ switch(option){
+ case 'random':
+ tdLeft.innerText = 'Random';
+ break;
+ case 'random-low':
+ tdLeft.innerText = "Random (Low)";
+ break;
+ case 'random-high':
+ tdLeft.innerText = "Random (High)";
+ break;
+ }
+ tr.appendChild(tdLeft);
- itemLabel.appendChild(itemCheckbox);
- itemLabel.appendChild(itemName);
+ const tdMiddle = document.createElement('td');
+ tdMiddle.classList.add('td-middle');
+ const range = document.createElement('input');
+ range.setAttribute('type', 'range');
+ range.setAttribute('id', `${this.name}-${settingName}-${option}-range`);
+ range.setAttribute('data-game', this.name);
+ range.setAttribute('data-setting', settingName);
+ range.setAttribute('data-option', option);
+ range.setAttribute('min', 0);
+ range.setAttribute('max', 50);
+ range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
+ range.value = this.current[settingName][option];
+ tdMiddle.appendChild(range);
+ tr.appendChild(tdMiddle);
- itemRow.appendChild(itemLabel);
- itemsList.appendChild((itemRow));
- });
+ const tdRight = document.createElement('td');
+ tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`)
+ tdRight.classList.add('td-right');
+ tdRight.innerText = range.value;
+ tr.appendChild(tdRight);
+ rangeTbody.appendChild(tr);
+ });
- settingWrapper.appendChild(itemsList);
- break;
+ rangeTable.appendChild(rangeTbody);
+ settingWrapper.appendChild(rangeTable);
+ break;
+
+ case 'items-list':
+ const itemsList = document.createElement('div');
+ itemsList.classList.add('simple-list');
+
+ Object.values(this.data.gameItems).forEach((item) => {
+ const itemRow = document.createElement('div');
+ itemRow.classList.add('list-row');
+
+ const itemLabel = document.createElement('label');
+ itemLabel.setAttribute('for', `${this.name}-${settingName}-${item}`)
+
+ const itemCheckbox = document.createElement('input');
+ itemCheckbox.setAttribute('id', `${this.name}-${settingName}-${item}`);
+ itemCheckbox.setAttribute('type', 'checkbox');
+ itemCheckbox.setAttribute('data-game', this.name);
+ itemCheckbox.setAttribute('data-setting', settingName);
+ itemCheckbox.setAttribute('data-option', item.toString());
+ itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
+ if (this.current[settingName].includes(item)) {
+ itemCheckbox.setAttribute('checked', '1');
+ }
- case 'locations-list':
- const locationsList = document.createElement('div');
- locationsList.classList.add('simple-list');
-
- Object.values(gameLocations).forEach((location) => {
- const locationRow = document.createElement('div');
- locationRow.classList.add('list-row');
-
- const locationLabel = document.createElement('label');
- locationLabel.setAttribute('for', `${game}-${settingName}-${location}`)
-
- const locationCheckbox = document.createElement('input');
- locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`);
- locationCheckbox.setAttribute('type', 'checkbox');
- locationCheckbox.setAttribute('data-game', game);
- locationCheckbox.setAttribute('data-setting', settingName);
- locationCheckbox.setAttribute('data-option', location.toString());
- locationCheckbox.addEventListener('change', updateListSetting);
- if (currentSettings[game][settingName].includes(location)) {
- locationCheckbox.setAttribute('checked', '1');
- }
+ const itemName = document.createElement('span');
+ itemName.innerText = item.toString();
- const locationName = document.createElement('span');
- locationName.innerText = location.toString();
+ itemLabel.appendChild(itemCheckbox);
+ itemLabel.appendChild(itemName);
- locationLabel.appendChild(locationCheckbox);
- locationLabel.appendChild(locationName);
+ itemRow.appendChild(itemLabel);
+ itemsList.appendChild((itemRow));
+ });
- locationRow.appendChild(locationLabel);
- locationsList.appendChild((locationRow));
- });
+ settingWrapper.appendChild(itemsList);
+ break;
+
+ case 'locations-list':
+ const locationsList = document.createElement('div');
+ locationsList.classList.add('simple-list');
+
+ Object.values(this.data.gameLocations).forEach((location) => {
+ const locationRow = document.createElement('div');
+ locationRow.classList.add('list-row');
+
+ const locationLabel = document.createElement('label');
+ locationLabel.setAttribute('for', `${this.name}-${settingName}-${location}`)
+
+ const locationCheckbox = document.createElement('input');
+ locationCheckbox.setAttribute('id', `${this.name}-${settingName}-${location}`);
+ locationCheckbox.setAttribute('type', 'checkbox');
+ locationCheckbox.setAttribute('data-game', this.name);
+ locationCheckbox.setAttribute('data-setting', settingName);
+ locationCheckbox.setAttribute('data-option', location.toString());
+ locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
+ if (this.current[settingName].includes(location)) {
+ locationCheckbox.setAttribute('checked', '1');
+ }
- settingWrapper.appendChild(locationsList);
- break;
+ const locationName = document.createElement('span');
+ locationName.innerText = location.toString();
- case 'custom-list':
- const customList = document.createElement('div');
- customList.classList.add('simple-list');
-
- Object.values(settings[settingName].options).forEach((listItem) => {
- const customListRow = document.createElement('div');
- customListRow.classList.add('list-row');
-
- const customItemLabel = document.createElement('label');
- customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`)
-
- const customItemCheckbox = document.createElement('input');
- customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`);
- customItemCheckbox.setAttribute('type', 'checkbox');
- customItemCheckbox.setAttribute('data-game', game);
- customItemCheckbox.setAttribute('data-setting', settingName);
- customItemCheckbox.setAttribute('data-option', listItem.toString());
- customItemCheckbox.addEventListener('change', updateListSetting);
- if (currentSettings[game][settingName].includes(listItem)) {
- customItemCheckbox.setAttribute('checked', '1');
- }
+ locationLabel.appendChild(locationCheckbox);
+ locationLabel.appendChild(locationName);
- const customItemName = document.createElement('span');
- customItemName.innerText = listItem.toString();
+ locationRow.appendChild(locationLabel);
+ locationsList.appendChild((locationRow));
+ });
- customItemLabel.appendChild(customItemCheckbox);
- customItemLabel.appendChild(customItemName);
+ settingWrapper.appendChild(locationsList);
+ break;
+
+ case 'custom-list':
+ const customList = document.createElement('div');
+ customList.classList.add('simple-list');
+
+ Object.values(this.data.gameSettings[settingName].options).forEach((listItem) => {
+ const customListRow = document.createElement('div');
+ customListRow.classList.add('list-row');
+
+ const customItemLabel = document.createElement('label');
+ customItemLabel.setAttribute('for', `${this.name}-${settingName}-${listItem}`)
+
+ const customItemCheckbox = document.createElement('input');
+ customItemCheckbox.setAttribute('id', `${this.name}-${settingName}-${listItem}`);
+ customItemCheckbox.setAttribute('type', 'checkbox');
+ customItemCheckbox.setAttribute('data-game', this.name);
+ customItemCheckbox.setAttribute('data-setting', settingName);
+ customItemCheckbox.setAttribute('data-option', listItem.toString());
+ customItemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
+ if (this.current[settingName].includes(listItem)) {
+ customItemCheckbox.setAttribute('checked', '1');
+ }
- customListRow.appendChild(customItemLabel);
- customList.appendChild((customListRow));
- });
+ const customItemName = document.createElement('span');
+ customItemName.innerText = listItem.toString();
- settingWrapper.appendChild(customList);
- break;
+ customItemLabel.appendChild(customItemCheckbox);
+ customItemLabel.appendChild(customItemName);
- default:
- console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`);
- return;
- }
+ customListRow.appendChild(customItemLabel);
+ customList.appendChild((customListRow));
+ });
- settingsWrapper.appendChild(settingWrapper);
- });
+ settingWrapper.appendChild(customList);
+ break;
- return settingsWrapper;
-};
+ default:
+ console.error(`Unknown setting type for ${this.name} setting ${settingName}: ${setting.type}`);
+ return;
+ }
-const buildItemsDiv = (game, items) => {
- // Sort alphabetical, in pace
- items.sort();
-
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const itemsDiv = document.createElement('div');
- itemsDiv.classList.add('items-div');
-
- const itemsDivHeader = document.createElement('h3');
- itemsDivHeader.innerText = 'Item Pool';
- itemsDiv.appendChild(itemsDivHeader);
-
- const itemsDescription = document.createElement('p');
- itemsDescription.classList.add('setting-description');
- itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' +
- 'your seed or someone else\'s.';
- itemsDiv.appendChild(itemsDescription);
-
- const itemsHint = document.createElement('p');
- itemsHint.classList.add('hint-text');
- itemsHint.innerText = 'Drag and drop items from one box to another.';
- itemsDiv.appendChild(itemsHint);
-
- const itemsWrapper = document.createElement('div');
- itemsWrapper.classList.add('items-wrapper');
-
- // Create container divs for each category
- const availableItemsWrapper = document.createElement('div');
- availableItemsWrapper.classList.add('item-set-wrapper');
- availableItemsWrapper.innerText = 'Available Items';
- const availableItems = document.createElement('div');
- availableItems.classList.add('item-container');
- availableItems.setAttribute('id', `${game}-available_items`);
- availableItems.addEventListener('dragover', itemDragoverHandler);
- availableItems.addEventListener('drop', itemDropHandler);
-
- const startInventoryWrapper = document.createElement('div');
- startInventoryWrapper.classList.add('item-set-wrapper');
- startInventoryWrapper.innerText = 'Start Inventory';
- const startInventory = document.createElement('div');
- startInventory.classList.add('item-container');
- startInventory.setAttribute('id', `${game}-start_inventory`);
- startInventory.setAttribute('data-setting', 'start_inventory');
- startInventory.addEventListener('dragover', itemDragoverHandler);
- startInventory.addEventListener('drop', itemDropHandler);
-
- const localItemsWrapper = document.createElement('div');
- localItemsWrapper.classList.add('item-set-wrapper');
- localItemsWrapper.innerText = 'Local Items';
- const localItems = document.createElement('div');
- localItems.classList.add('item-container');
- localItems.setAttribute('id', `${game}-local_items`);
- localItems.setAttribute('data-setting', 'local_items')
- localItems.addEventListener('dragover', itemDragoverHandler);
- localItems.addEventListener('drop', itemDropHandler);
-
- const nonLocalItemsWrapper = document.createElement('div');
- nonLocalItemsWrapper.classList.add('item-set-wrapper');
- nonLocalItemsWrapper.innerText = 'Non-Local Items';
- const nonLocalItems = document.createElement('div');
- nonLocalItems.classList.add('item-container');
- nonLocalItems.setAttribute('id', `${game}-non_local_items`);
- nonLocalItems.setAttribute('data-setting', 'non_local_items');
- nonLocalItems.addEventListener('dragover', itemDragoverHandler);
- nonLocalItems.addEventListener('drop', itemDropHandler);
-
- // Populate the divs
- items.forEach((item) => {
- if (Object.keys(currentSettings[game].start_inventory).includes(item)){
- const itemDiv = buildItemQtyDiv(game, item);
- itemDiv.setAttribute('data-setting', 'start_inventory');
- startInventory.appendChild(itemDiv);
- } else if (currentSettings[game].local_items.includes(item)) {
- const itemDiv = buildItemDiv(game, item);
- itemDiv.setAttribute('data-setting', 'local_items');
- localItems.appendChild(itemDiv);
- } else if (currentSettings[game].non_local_items.includes(item)) {
- const itemDiv = buildItemDiv(game, item);
- itemDiv.setAttribute('data-setting', 'non_local_items');
- nonLocalItems.appendChild(itemDiv);
- } else {
- const itemDiv = buildItemDiv(game, item);
- availableItems.appendChild(itemDiv);
- }
- });
+ settingsWrapper.appendChild(settingWrapper);
+ });
- availableItemsWrapper.appendChild(availableItems);
- startInventoryWrapper.appendChild(startInventory);
- localItemsWrapper.appendChild(localItems);
- nonLocalItemsWrapper.appendChild(nonLocalItems);
- itemsWrapper.appendChild(availableItemsWrapper);
- itemsWrapper.appendChild(startInventoryWrapper);
- itemsWrapper.appendChild(localItemsWrapper);
- itemsWrapper.appendChild(nonLocalItemsWrapper);
- itemsDiv.appendChild(itemsWrapper);
- return itemsDiv;
-};
+ return settingsWrapper;
+ }
-const buildItemDiv = (game, item) => {
- const itemDiv = document.createElement('div');
- itemDiv.classList.add('item-div');
- itemDiv.setAttribute('id', `${game}-${item}`);
- itemDiv.setAttribute('data-game', game);
- itemDiv.setAttribute('data-item', item);
- itemDiv.setAttribute('draggable', 'true');
- itemDiv.innerText = item;
- itemDiv.addEventListener('dragstart', (evt) => {
- evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id'));
- });
- return itemDiv;
-};
+ #buildItemsDiv() {
+ const itemsDiv = document.createElement('div');
+ itemsDiv.classList.add('items-div');
+
+ const itemsDivHeader = document.createElement('h3');
+ itemsDivHeader.innerText = 'Item Pool';
+ itemsDiv.appendChild(itemsDivHeader);
+
+ const itemsDescription = document.createElement('p');
+ itemsDescription.classList.add('setting-description');
+ itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' +
+ 'your seed or someone else\'s.';
+ itemsDiv.appendChild(itemsDescription);
+
+ const itemsHint = document.createElement('p');
+ itemsHint.classList.add('hint-text');
+ itemsHint.innerText = 'Drag and drop items from one box to another.';
+ itemsDiv.appendChild(itemsHint);
+
+ const itemsWrapper = document.createElement('div');
+ itemsWrapper.classList.add('items-wrapper');
+
+ const itemDragoverHandler = (evt) => evt.preventDefault();
+ const itemDropHandler = (evt) => this.#itemDropHandler(evt);
+
+ // Create container divs for each category
+ const availableItemsWrapper = document.createElement('div');
+ availableItemsWrapper.classList.add('item-set-wrapper');
+ availableItemsWrapper.innerText = 'Available Items';
+ const availableItems = document.createElement('div');
+ availableItems.classList.add('item-container');
+ availableItems.setAttribute('id', `${this.name}-available_items`);
+ availableItems.addEventListener('dragover', itemDragoverHandler);
+ availableItems.addEventListener('drop', itemDropHandler);
+
+ const startInventoryWrapper = document.createElement('div');
+ startInventoryWrapper.classList.add('item-set-wrapper');
+ startInventoryWrapper.innerText = 'Start Inventory';
+ const startInventory = document.createElement('div');
+ startInventory.classList.add('item-container');
+ startInventory.setAttribute('id', `${this.name}-start_inventory`);
+ startInventory.setAttribute('data-setting', 'start_inventory');
+ startInventory.addEventListener('dragover', itemDragoverHandler);
+ startInventory.addEventListener('drop', itemDropHandler);
+
+ const localItemsWrapper = document.createElement('div');
+ localItemsWrapper.classList.add('item-set-wrapper');
+ localItemsWrapper.innerText = 'Local Items';
+ const localItems = document.createElement('div');
+ localItems.classList.add('item-container');
+ localItems.setAttribute('id', `${this.name}-local_items`);
+ localItems.setAttribute('data-setting', 'local_items')
+ localItems.addEventListener('dragover', itemDragoverHandler);
+ localItems.addEventListener('drop', itemDropHandler);
+
+ const nonLocalItemsWrapper = document.createElement('div');
+ nonLocalItemsWrapper.classList.add('item-set-wrapper');
+ nonLocalItemsWrapper.innerText = 'Non-Local Items';
+ const nonLocalItems = document.createElement('div');
+ nonLocalItems.classList.add('item-container');
+ nonLocalItems.setAttribute('id', `${this.name}-non_local_items`);
+ nonLocalItems.setAttribute('data-setting', 'non_local_items');
+ nonLocalItems.addEventListener('dragover', itemDragoverHandler);
+ nonLocalItems.addEventListener('drop', itemDropHandler);
+
+ // Populate the divs
+ this.data.gameItems.forEach((item) => {
+ if (Object.keys(this.current.start_inventory).includes(item)){
+ const itemDiv = this.#buildItemQtyDiv(item);
+ itemDiv.setAttribute('data-setting', 'start_inventory');
+ startInventory.appendChild(itemDiv);
+ } else if (this.current.local_items.includes(item)) {
+ const itemDiv = this.#buildItemDiv(item);
+ itemDiv.setAttribute('data-setting', 'local_items');
+ localItems.appendChild(itemDiv);
+ } else if (this.current.non_local_items.includes(item)) {
+ const itemDiv = this.#buildItemDiv(item);
+ itemDiv.setAttribute('data-setting', 'non_local_items');
+ nonLocalItems.appendChild(itemDiv);
+ } else {
+ const itemDiv = this.#buildItemDiv(item);
+ availableItems.appendChild(itemDiv);
+ }
+ });
-const buildItemQtyDiv = (game, item) => {
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const itemQtyDiv = document.createElement('div');
- itemQtyDiv.classList.add('item-qty-div');
- itemQtyDiv.setAttribute('id', `${game}-${item}`);
- itemQtyDiv.setAttribute('data-game', game);
- itemQtyDiv.setAttribute('data-item', item);
- itemQtyDiv.setAttribute('draggable', 'true');
- itemQtyDiv.innerText = item;
-
- const inputWrapper = document.createElement('div');
- inputWrapper.classList.add('item-qty-input-wrapper')
-
- const itemQty = document.createElement('input');
- itemQty.setAttribute('value', currentSettings[game].start_inventory.hasOwnProperty(item) ?
- currentSettings[game].start_inventory[item] : '1');
- itemQty.setAttribute('data-game', game);
- itemQty.setAttribute('data-setting', 'start_inventory');
- itemQty.setAttribute('data-option', item);
- itemQty.setAttribute('maxlength', '3');
- itemQty.addEventListener('keyup', (evt) => {
- evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value);
- updateItemSetting(evt);
- });
- inputWrapper.appendChild(itemQty);
- itemQtyDiv.appendChild(inputWrapper);
+ availableItemsWrapper.appendChild(availableItems);
+ startInventoryWrapper.appendChild(startInventory);
+ localItemsWrapper.appendChild(localItems);
+ nonLocalItemsWrapper.appendChild(nonLocalItems);
+ itemsWrapper.appendChild(availableItemsWrapper);
+ itemsWrapper.appendChild(startInventoryWrapper);
+ itemsWrapper.appendChild(localItemsWrapper);
+ itemsWrapper.appendChild(nonLocalItemsWrapper);
+ itemsDiv.appendChild(itemsWrapper);
+ return itemsDiv;
+ }
- itemQtyDiv.addEventListener('dragstart', (evt) => {
- evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id'));
- });
- return itemQtyDiv;
-};
+ #buildItemDiv(item) {
+ const itemDiv = document.createElement('div');
+ itemDiv.classList.add('item-div');
+ itemDiv.setAttribute('id', `${this.name}-${item}`);
+ itemDiv.setAttribute('data-game', this.name);
+ itemDiv.setAttribute('data-item', item);
+ itemDiv.setAttribute('draggable', 'true');
+ itemDiv.innerText = item;
+ itemDiv.addEventListener('dragstart', (evt) => {
+ evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id'));
+ });
+ return itemDiv;
+ }
-const itemDragoverHandler = (evt) => {
- evt.preventDefault();
-};
+ #buildItemQtyDiv(item) {
+ const itemQtyDiv = document.createElement('div');
+ itemQtyDiv.classList.add('item-qty-div');
+ itemQtyDiv.setAttribute('id', `${this.name}-${item}`);
+ itemQtyDiv.setAttribute('data-game', this.name);
+ itemQtyDiv.setAttribute('data-item', item);
+ itemQtyDiv.setAttribute('draggable', 'true');
+ itemQtyDiv.innerText = item;
+
+ const inputWrapper = document.createElement('div');
+ inputWrapper.classList.add('item-qty-input-wrapper')
+
+ const itemQty = document.createElement('input');
+ itemQty.setAttribute('value', this.current.start_inventory.hasOwnProperty(item) ?
+ this.current.start_inventory[item] : '1');
+ itemQty.setAttribute('data-game', this.name);
+ itemQty.setAttribute('data-setting', 'start_inventory');
+ itemQty.setAttribute('data-option', item);
+ itemQty.setAttribute('maxlength', '3');
+ itemQty.addEventListener('keyup', (evt) => {
+ evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value);
+ this.#updateItemSetting(evt);
+ });
+ inputWrapper.appendChild(itemQty);
+ itemQtyDiv.appendChild(inputWrapper);
+
+ itemQtyDiv.addEventListener('dragstart', (evt) => {
+ evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id'));
+ });
+ return itemQtyDiv;
+ }
-const itemDropHandler = (evt) => {
- evt.preventDefault();
- const sourceId = evt.dataTransfer.getData('text/plain');
- const sourceDiv = document.getElementById(sourceId);
+ #itemDropHandler(evt) {
+ evt.preventDefault();
+ const sourceId = evt.dataTransfer.getData('text/plain');
+ const sourceDiv = document.getElementById(sourceId);
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const game = sourceDiv.getAttribute('data-game');
- const item = sourceDiv.getAttribute('data-item');
+ const item = sourceDiv.getAttribute('data-item');
- const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null;
- const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null;
+ const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null;
+ const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null;
- const itemDiv = newSetting === 'start_inventory' ? buildItemQtyDiv(game, item) : buildItemDiv(game, item);
+ const itemDiv = newSetting === 'start_inventory' ? this.#buildItemQtyDiv(item) : this.#buildItemDiv(item);
- if (oldSetting) {
- if (oldSetting === 'start_inventory') {
- if (currentSettings[game][oldSetting].hasOwnProperty(item)) {
- delete currentSettings[game][oldSetting][item];
- }
- } else {
- if (currentSettings[game][oldSetting].includes(item)) {
- currentSettings[game][oldSetting].splice(currentSettings[game][oldSetting].indexOf(item), 1);
+ if (oldSetting) {
+ if (oldSetting === 'start_inventory') {
+ if (this.current[oldSetting].hasOwnProperty(item)) {
+ delete this.current[oldSetting][item];
+ }
+ } else {
+ if (this.current[oldSetting].includes(item)) {
+ this.current[oldSetting].splice(this.current[oldSetting].indexOf(item), 1);
+ }
}
}
- }
- if (newSetting) {
- itemDiv.setAttribute('data-setting', newSetting);
- document.getElementById(`${game}-${newSetting}`).appendChild(itemDiv);
- if (newSetting === 'start_inventory') {
- currentSettings[game][newSetting][item] = 1;
- } else {
- if (!currentSettings[game][newSetting].includes(item)){
- currentSettings[game][newSetting].push(item);
+ if (newSetting) {
+ itemDiv.setAttribute('data-setting', newSetting);
+ document.getElementById(`${this.name}-${newSetting}`).appendChild(itemDiv);
+ if (newSetting === 'start_inventory') {
+ this.current[newSetting][item] = 1;
+ } else {
+ if (!this.current[newSetting].includes(item)){
+ this.current[newSetting].push(item);
+ }
}
+ } else {
+ // No setting was assigned, this item has been removed from the settings
+ document.getElementById(`${this.name}-available_items`).appendChild(itemDiv);
}
- } else {
- // No setting was assigned, this item has been removed from the settings
- document.getElementById(`${game}-available_items`).appendChild(itemDiv);
- }
- // Remove the source drag object
- sourceDiv.parentElement.removeChild(sourceDiv);
+ // Remove the source drag object
+ sourceDiv.parentElement.removeChild(sourceDiv);
- // Save the updated settings
- localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
-};
+ // Save the updated settings
+ this.save();
+ }
-const buildHintsDiv = (game, items, locations) => {
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
-
- // Sort alphabetical, in place
- items.sort();
- locations.sort();
-
- const hintsDiv = document.createElement('div');
- hintsDiv.classList.add('hints-div');
- const hintsHeader = document.createElement('h3');
- hintsHeader.innerText = 'Item & Location Hints';
- hintsDiv.appendChild(hintsHeader);
- const hintsDescription = document.createElement('p');
- hintsDescription.classList.add('setting-description');
- hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
- ' items are, or what those locations contain.';
- hintsDiv.appendChild(hintsDescription);
-
- const itemHintsContainer = document.createElement('div');
- itemHintsContainer.classList.add('hints-container');
-
- // Item Hints
- const itemHintsWrapper = document.createElement('div');
- itemHintsWrapper.classList.add('hints-wrapper');
- itemHintsWrapper.innerText = 'Starting Item Hints';
-
- const itemHintsDiv = document.createElement('div');
- itemHintsDiv.classList.add('simple-list');
- items.forEach((item) => {
- const itemRow = document.createElement('div');
- itemRow.classList.add('list-row');
-
- const itemLabel = document.createElement('label');
- itemLabel.setAttribute('for', `${game}-start_hints-${item}`);
-
- const itemCheckbox = document.createElement('input');
- itemCheckbox.setAttribute('type', 'checkbox');
- itemCheckbox.setAttribute('id', `${game}-start_hints-${item}`);
- itemCheckbox.setAttribute('data-game', game);
- itemCheckbox.setAttribute('data-setting', 'start_hints');
- itemCheckbox.setAttribute('data-option', item);
- if (currentSettings[game].start_hints.includes(item)) {
- itemCheckbox.setAttribute('checked', 'true');
- }
- itemCheckbox.addEventListener('change', updateListSetting);
- itemLabel.appendChild(itemCheckbox);
+ #buildHintsDiv() {
+ const hintsDiv = document.createElement('div');
+ hintsDiv.classList.add('hints-div');
+ const hintsHeader = document.createElement('h3');
+ hintsHeader.innerText = 'Item & Location Hints';
+ hintsDiv.appendChild(hintsHeader);
+ const hintsDescription = document.createElement('p');
+ hintsDescription.classList.add('setting-description');
+ hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
+ ' items are, or what those locations contain.';
+ hintsDiv.appendChild(hintsDescription);
+
+ const itemHintsContainer = document.createElement('div');
+ itemHintsContainer.classList.add('hints-container');
+
+ // Item Hints
+ const itemHintsWrapper = document.createElement('div');
+ itemHintsWrapper.classList.add('hints-wrapper');
+ itemHintsWrapper.innerText = 'Starting Item Hints';
+
+ const itemHintsDiv = document.createElement('div');
+ itemHintsDiv.classList.add('simple-list');
+ this.data.gameItems.forEach((item) => {
+ const itemRow = document.createElement('div');
+ itemRow.classList.add('list-row');
+
+ const itemLabel = document.createElement('label');
+ itemLabel.setAttribute('for', `${this.name}-start_hints-${item}`);
+
+ const itemCheckbox = document.createElement('input');
+ itemCheckbox.setAttribute('type', 'checkbox');
+ itemCheckbox.setAttribute('id', `${this.name}-start_hints-${item}`);
+ itemCheckbox.setAttribute('data-game', this.name);
+ itemCheckbox.setAttribute('data-setting', 'start_hints');
+ itemCheckbox.setAttribute('data-option', item);
+ if (this.current.start_hints.includes(item)) {
+ itemCheckbox.setAttribute('checked', 'true');
+ }
+ itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
+ itemLabel.appendChild(itemCheckbox);
- const itemName = document.createElement('span');
- itemName.innerText = item;
- itemLabel.appendChild(itemName);
+ const itemName = document.createElement('span');
+ itemName.innerText = item;
+ itemLabel.appendChild(itemName);
- itemRow.appendChild(itemLabel);
- itemHintsDiv.appendChild(itemRow);
- });
+ itemRow.appendChild(itemLabel);
+ itemHintsDiv.appendChild(itemRow);
+ });
- itemHintsWrapper.appendChild(itemHintsDiv);
- itemHintsContainer.appendChild(itemHintsWrapper);
-
- // Starting Location Hints
- const locationHintsWrapper = document.createElement('div');
- locationHintsWrapper.classList.add('hints-wrapper');
- locationHintsWrapper.innerText = 'Starting Location Hints';
-
- const locationHintsDiv = document.createElement('div');
- locationHintsDiv.classList.add('simple-list');
- locations.forEach((location) => {
- const locationRow = document.createElement('div');
- locationRow.classList.add('list-row');
-
- const locationLabel = document.createElement('label');
- locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`);
-
- const locationCheckbox = document.createElement('input');
- locationCheckbox.setAttribute('type', 'checkbox');
- locationCheckbox.setAttribute('id', `${game}-start_location_hints-${location}`);
- locationCheckbox.setAttribute('data-game', game);
- locationCheckbox.setAttribute('data-setting', 'start_location_hints');
- locationCheckbox.setAttribute('data-option', location);
- if (currentSettings[game].start_location_hints.includes(location)) {
- locationCheckbox.setAttribute('checked', '1');
- }
- locationCheckbox.addEventListener('change', updateListSetting);
- locationLabel.appendChild(locationCheckbox);
+ itemHintsWrapper.appendChild(itemHintsDiv);
+ itemHintsContainer.appendChild(itemHintsWrapper);
+
+ // Starting Location Hints
+ const locationHintsWrapper = document.createElement('div');
+ locationHintsWrapper.classList.add('hints-wrapper');
+ locationHintsWrapper.innerText = 'Starting Location Hints';
+
+ const locationHintsDiv = document.createElement('div');
+ locationHintsDiv.classList.add('simple-list');
+ this.data.gameLocations.forEach((location) => {
+ const locationRow = document.createElement('div');
+ locationRow.classList.add('list-row');
+
+ const locationLabel = document.createElement('label');
+ locationLabel.setAttribute('for', `${this.name}-start_location_hints-${location}`);
+
+ const locationCheckbox = document.createElement('input');
+ locationCheckbox.setAttribute('type', 'checkbox');
+ locationCheckbox.setAttribute('id', `${this.name}-start_location_hints-${location}`);
+ locationCheckbox.setAttribute('data-game', this.name);
+ locationCheckbox.setAttribute('data-setting', 'start_location_hints');
+ locationCheckbox.setAttribute('data-option', location);
+ if (this.current.start_location_hints.includes(location)) {
+ locationCheckbox.setAttribute('checked', '1');
+ }
+ locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
+ locationLabel.appendChild(locationCheckbox);
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
+ const locationName = document.createElement('span');
+ locationName.innerText = location;
+ locationLabel.appendChild(locationName);
- locationRow.appendChild(locationLabel);
- locationHintsDiv.appendChild(locationRow);
- });
+ locationRow.appendChild(locationLabel);
+ locationHintsDiv.appendChild(locationRow);
+ });
- locationHintsWrapper.appendChild(locationHintsDiv);
- itemHintsContainer.appendChild(locationHintsWrapper);
+ locationHintsWrapper.appendChild(locationHintsDiv);
+ itemHintsContainer.appendChild(locationHintsWrapper);
- hintsDiv.appendChild(itemHintsContainer);
- return hintsDiv;
-};
+ hintsDiv.appendChild(itemHintsContainer);
+ return hintsDiv;
+ }
-const buildLocationsDiv = (game, locations) => {
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- locations.sort(); // Sort alphabetical, in-place
-
- const locationsDiv = document.createElement('div');
- locationsDiv.classList.add('locations-div');
- const locationsHeader = document.createElement('h3');
- locationsHeader.innerText = 'Priority & Exclusion Locations';
- locationsDiv.appendChild(locationsHeader);
- const locationsDescription = document.createElement('p');
- locationsDescription.classList.add('setting-description');
- locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' +
- 'excluded locations will not contain progression or useful items.';
- locationsDiv.appendChild(locationsDescription);
-
- const locationsContainer = document.createElement('div');
- locationsContainer.classList.add('locations-container');
-
- // Priority Locations
- const priorityLocationsWrapper = document.createElement('div');
- priorityLocationsWrapper.classList.add('locations-wrapper');
- priorityLocationsWrapper.innerText = 'Priority Locations';
-
- const priorityLocationsDiv = document.createElement('div');
- priorityLocationsDiv.classList.add('simple-list');
- locations.forEach((location) => {
- const locationRow = document.createElement('div');
- locationRow.classList.add('list-row');
-
- const locationLabel = document.createElement('label');
- locationLabel.setAttribute('for', `${game}-priority_locations-${location}`);
-
- const locationCheckbox = document.createElement('input');
- locationCheckbox.setAttribute('type', 'checkbox');
- locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`);
- locationCheckbox.setAttribute('data-game', game);
- locationCheckbox.setAttribute('data-setting', 'priority_locations');
- locationCheckbox.setAttribute('data-option', location);
- if (currentSettings[game].priority_locations.includes(location)) {
- locationCheckbox.setAttribute('checked', '1');
- }
- locationCheckbox.addEventListener('change', updateListSetting);
- locationLabel.appendChild(locationCheckbox);
+ #buildLocationsDiv() {
+ const locationsDiv = document.createElement('div');
+ locationsDiv.classList.add('locations-div');
+ const locationsHeader = document.createElement('h3');
+ locationsHeader.innerText = 'Priority & Exclusion Locations';
+ locationsDiv.appendChild(locationsHeader);
+ const locationsDescription = document.createElement('p');
+ locationsDescription.classList.add('setting-description');
+ locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' +
+ 'excluded locations will not contain progression or useful items.';
+ locationsDiv.appendChild(locationsDescription);
+
+ const locationsContainer = document.createElement('div');
+ locationsContainer.classList.add('locations-container');
+
+ // Priority Locations
+ const priorityLocationsWrapper = document.createElement('div');
+ priorityLocationsWrapper.classList.add('locations-wrapper');
+ priorityLocationsWrapper.innerText = 'Priority Locations';
+
+ const priorityLocationsDiv = document.createElement('div');
+ priorityLocationsDiv.classList.add('simple-list');
+ this.data.gameLocations.forEach((location) => {
+ const locationRow = document.createElement('div');
+ locationRow.classList.add('list-row');
+
+ const locationLabel = document.createElement('label');
+ locationLabel.setAttribute('for', `${this.name}-priority_locations-${location}`);
+
+ const locationCheckbox = document.createElement('input');
+ locationCheckbox.setAttribute('type', 'checkbox');
+ locationCheckbox.setAttribute('id', `${this.name}-priority_locations-${location}`);
+ locationCheckbox.setAttribute('data-game', this.name);
+ locationCheckbox.setAttribute('data-setting', 'priority_locations');
+ locationCheckbox.setAttribute('data-option', location);
+ if (this.current.priority_locations.includes(location)) {
+ locationCheckbox.setAttribute('checked', '1');
+ }
+ locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
+ locationLabel.appendChild(locationCheckbox);
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
+ const locationName = document.createElement('span');
+ locationName.innerText = location;
+ locationLabel.appendChild(locationName);
- locationRow.appendChild(locationLabel);
- priorityLocationsDiv.appendChild(locationRow);
- });
+ locationRow.appendChild(locationLabel);
+ priorityLocationsDiv.appendChild(locationRow);
+ });
- priorityLocationsWrapper.appendChild(priorityLocationsDiv);
- locationsContainer.appendChild(priorityLocationsWrapper);
-
- // Exclude Locations
- const excludeLocationsWrapper = document.createElement('div');
- excludeLocationsWrapper.classList.add('locations-wrapper');
- excludeLocationsWrapper.innerText = 'Exclude Locations';
-
- const excludeLocationsDiv = document.createElement('div');
- excludeLocationsDiv.classList.add('simple-list');
- locations.forEach((location) => {
- const locationRow = document.createElement('div');
- locationRow.classList.add('list-row');
-
- const locationLabel = document.createElement('label');
- locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`);
-
- const locationCheckbox = document.createElement('input');
- locationCheckbox.setAttribute('type', 'checkbox');
- locationCheckbox.setAttribute('id', `${game}-exclude_locations-${location}`);
- locationCheckbox.setAttribute('data-game', game);
- locationCheckbox.setAttribute('data-setting', 'exclude_locations');
- locationCheckbox.setAttribute('data-option', location);
- if (currentSettings[game].exclude_locations.includes(location)) {
- locationCheckbox.setAttribute('checked', '1');
- }
- locationCheckbox.addEventListener('change', updateListSetting);
- locationLabel.appendChild(locationCheckbox);
+ priorityLocationsWrapper.appendChild(priorityLocationsDiv);
+ locationsContainer.appendChild(priorityLocationsWrapper);
+
+ // Exclude Locations
+ const excludeLocationsWrapper = document.createElement('div');
+ excludeLocationsWrapper.classList.add('locations-wrapper');
+ excludeLocationsWrapper.innerText = 'Exclude Locations';
+
+ const excludeLocationsDiv = document.createElement('div');
+ excludeLocationsDiv.classList.add('simple-list');
+ this.data.gameLocations.forEach((location) => {
+ const locationRow = document.createElement('div');
+ locationRow.classList.add('list-row');
+
+ const locationLabel = document.createElement('label');
+ locationLabel.setAttribute('for', `${this.name}-exclude_locations-${location}`);
+
+ const locationCheckbox = document.createElement('input');
+ locationCheckbox.setAttribute('type', 'checkbox');
+ locationCheckbox.setAttribute('id', `${this.name}-exclude_locations-${location}`);
+ locationCheckbox.setAttribute('data-game', this.name);
+ locationCheckbox.setAttribute('data-setting', 'exclude_locations');
+ locationCheckbox.setAttribute('data-option', location);
+ if (this.current.exclude_locations.includes(location)) {
+ locationCheckbox.setAttribute('checked', '1');
+ }
+ locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
+ locationLabel.appendChild(locationCheckbox);
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
+ const locationName = document.createElement('span');
+ locationName.innerText = location;
+ locationLabel.appendChild(locationName);
- locationRow.appendChild(locationLabel);
- excludeLocationsDiv.appendChild(locationRow);
- });
+ locationRow.appendChild(locationLabel);
+ excludeLocationsDiv.appendChild(locationRow);
+ });
- excludeLocationsWrapper.appendChild(excludeLocationsDiv);
- locationsContainer.appendChild(excludeLocationsWrapper);
+ excludeLocationsWrapper.appendChild(excludeLocationsDiv);
+ locationsContainer.appendChild(excludeLocationsWrapper);
- locationsDiv.appendChild(locationsContainer);
- return locationsDiv;
-};
+ locationsDiv.appendChild(locationsContainer);
+ return locationsDiv;
+ }
-const updateVisibleGames = () => {
- const settings = JSON.parse(localStorage.getItem('weighted-settings'));
- Object.keys(settings.game).forEach((game) => {
- const gameDiv = document.getElementById(`${game}-div`);
- const gameOption = document.getElementById(`${game}-game-option`);
- if (parseInt(settings.game[game], 10) > 0) {
- gameDiv.classList.remove('invisible');
- gameOption.classList.add('jump-link');
- gameOption.addEventListener('click', () => {
- const gameDiv = document.getElementById(`${game}-div`);
- if (gameDiv.classList.contains('invisible')) { return; }
- gameDiv.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- });
+ #updateRangeSetting(evt) {
+ const setting = evt.target.getAttribute('data-setting');
+ const option = evt.target.getAttribute('data-option');
+ document.getElementById(`${this.name}-${setting}-${option}`).innerText = evt.target.value;
+ if (evt.action && evt.action === 'rangeDelete') {
+ delete this.current[setting][option];
} else {
- gameDiv.classList.add('invisible');
- gameOption.classList.remove('jump-link');
-
+ this.current[setting][option] = parseInt(evt.target.value, 10);
}
- });
-};
-
-const updateBaseSetting = (event) => {
- const settings = JSON.parse(localStorage.getItem('weighted-settings'));
- const setting = event.target.getAttribute('data-setting');
- const option = event.target.getAttribute('data-option');
- const type = event.target.getAttribute('data-type');
-
- switch(type){
- case 'weight':
- settings[setting][option] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10);
- document.getElementById(`${setting}-${option}`).innerText = event.target.value;
- break;
- case 'data':
- settings[setting] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10);
- break;
- }
-
- localStorage.setItem('weighted-settings', JSON.stringify(settings));
-};
-
-const updateRangeSetting = (evt) => {
- const options = JSON.parse(localStorage.getItem('weighted-settings'));
- const game = evt.target.getAttribute('data-game');
- const setting = evt.target.getAttribute('data-setting');
- const option = evt.target.getAttribute('data-option');
- document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
- if (evt.action && evt.action === 'rangeDelete') {
- delete options[game][setting][option];
- } else {
- options[game][setting][option] = parseInt(evt.target.value, 10);
+ this.save();
}
- localStorage.setItem('weighted-settings', JSON.stringify(options));
-};
-
-const updateListSetting = (evt) => {
- const options = JSON.parse(localStorage.getItem('weighted-settings'));
- const game = evt.target.getAttribute('data-game');
- const setting = evt.target.getAttribute('data-setting');
- const option = evt.target.getAttribute('data-option');
-
- if (evt.target.checked) {
- // If the option is to be enabled and it is already enabled, do nothing
- if (options[game][setting].includes(option)) { return; }
- options[game][setting].push(option);
- } else {
- // If the option is to be disabled and it is already disabled, do nothing
- if (!options[game][setting].includes(option)) { return; }
+ #updateListSetting(evt) {
+ const setting = evt.target.getAttribute('data-setting');
+ const option = evt.target.getAttribute('data-option');
- options[game][setting].splice(options[game][setting].indexOf(option), 1);
- }
- localStorage.setItem('weighted-settings', JSON.stringify(options));
-};
+ if (evt.target.checked) {
+ // If the option is to be enabled and it is already enabled, do nothing
+ if (this.current[setting].includes(option)) { return; }
-const updateItemSetting = (evt) => {
- const options = JSON.parse(localStorage.getItem('weighted-settings'));
- const game = evt.target.getAttribute('data-game');
- const setting = evt.target.getAttribute('data-setting');
- const option = evt.target.getAttribute('data-option');
- if (setting === 'start_inventory') {
- options[game][setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0;
- } else {
- options[game][setting][option] = isNaN(evt.target.value) ?
- evt.target.value : parseInt(evt.target.value, 10);
- }
- localStorage.setItem('weighted-settings', JSON.stringify(options));
-};
+ this.current[setting].push(option);
+ } else {
+ // If the option is to be disabled and it is already disabled, do nothing
+ if (!this.current[setting].includes(option)) { return; }
-const validateSettings = () => {
- const settings = JSON.parse(localStorage.getItem('weighted-settings'));
- const userMessage = document.getElementById('user-message');
- let errorMessage = null;
-
- // User must choose a name for their file
- if (!settings.name || settings.name.trim().length === 0 || settings.name.toLowerCase().trim() === 'player') {
- userMessage.innerText = 'You forgot to set your player name at the top of the page!';
- userMessage.classList.add('visible');
- userMessage.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- return;
+ this.current[setting].splice(this.current[setting].indexOf(option), 1);
+ }
+ this.save();
}
- // Clean up the settings output
- Object.keys(settings.game).forEach((game) => {
- // Remove any disabled games
- if (settings.game[game] === 0) {
- delete settings.game[game];
- delete settings[game];
- return;
+ #updateItemSetting(evt) {
+ const setting = evt.target.getAttribute('data-setting');
+ const option = evt.target.getAttribute('data-option');
+ if (setting === 'start_inventory') {
+ this.current[setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0;
+ } else {
+ this.current[setting][option] = isNaN(evt.target.value) ?
+ evt.target.value : parseInt(evt.target.value, 10);
}
-
- // Remove any disabled options
- Object.keys(settings[game]).forEach((setting) => {
- Object.keys(settings[game][setting]).forEach((option) => {
- if (settings[game][setting][option] === 0) {
- delete settings[game][setting][option];
- }
- });
-
- if (
- Object.keys(settings[game][setting]).length === 0 &&
- !Array.isArray(settings[game][setting]) &&
- setting !== 'start_inventory'
- ) {
- errorMessage = `${game} // ${setting} has no values above zero!`;
- }
- });
- });
-
- if (Object.keys(settings.game).length === 0) {
- errorMessage = 'You have not chosen a game to play!';
+ this.save();
}
- // If an error occurred, alert the user and do not export the file
- if (errorMessage) {
- userMessage.innerText = errorMessage;
- userMessage.classList.add('visible');
- userMessage.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- return;
+ // Saves the current settings to local storage.
+ save() {
+ this.#allSettings.save();
}
-
- // If no error occurred, hide the user message if it is visible
- userMessage.classList.remove('visible');
- return settings;
-};
-
-const exportSettings = () => {
- const settings = validateSettings();
- if (!settings) { return; }
-
- const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
- download(`${document.getElementById('player-name').value}.yaml`, yamlText);
-};
+}
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
@@ -1190,30 +1282,3 @@ const download = (filename, text) => {
downloadLink.click();
document.body.removeChild(downloadLink);
};
-
-const generateGame = (raceMode = false) => {
- const settings = validateSettings();
- if (!settings) { return; }
-
- axios.post('/api/generate', {
- weights: { player: JSON.stringify(settings) },
- presetData: { player: JSON.stringify(settings) },
- playerCount: 1,
- spoiler: 3,
- race: raceMode ? '1' : '0',
- }).then((response) => {
- window.location.href = response.data.url;
- }).catch((error) => {
- const userMessage = document.getElementById('user-message');
- 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.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- console.error(error);
- });
-};
diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png
new file mode 100644
index 000000000000..8fb366b93ff0
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png differ
diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png
new file mode 100644
index 000000000000..336dc5f77af2
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png differ
diff --git a/WebHostLib/static/static/icons/sc2/advanceballistics.png b/WebHostLib/static/static/icons/sc2/advanceballistics.png
new file mode 100644
index 000000000000..1bf7df9fb74c
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/advanceballistics.png differ
diff --git a/WebHostLib/static/static/icons/sc2/autoturretblackops.png b/WebHostLib/static/static/icons/sc2/autoturretblackops.png
new file mode 100644
index 000000000000..552707831a00
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/autoturretblackops.png differ
diff --git a/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png
new file mode 100644
index 000000000000..e7ebf4031619
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png differ
diff --git a/WebHostLib/static/static/icons/sc2/burstcapacitors.png b/WebHostLib/static/static/icons/sc2/burstcapacitors.png
new file mode 100644
index 000000000000..3af9b20a1698
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/burstcapacitors.png differ
diff --git a/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png
new file mode 100644
index 000000000000..d1c0c6c9a010
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png differ
diff --git a/WebHostLib/static/static/icons/sc2/cyclone.png b/WebHostLib/static/static/icons/sc2/cyclone.png
new file mode 100644
index 000000000000..d2016116ea3b
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/cyclone.png differ
diff --git a/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png
new file mode 100644
index 000000000000..351be570d11b
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png differ
diff --git a/WebHostLib/static/static/icons/sc2/drillingclaws.png b/WebHostLib/static/static/icons/sc2/drillingclaws.png
new file mode 100644
index 000000000000..2b067a6e44d4
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/drillingclaws.png differ
diff --git a/WebHostLib/static/static/icons/sc2/emergencythrusters.png b/WebHostLib/static/static/icons/sc2/emergencythrusters.png
new file mode 100644
index 000000000000..159fba37c903
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/emergencythrusters.png differ
diff --git a/WebHostLib/static/static/icons/sc2/hellionbattlemode.png b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png
new file mode 100644
index 000000000000..56bfd98c924c
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png differ
diff --git a/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png
new file mode 100644
index 000000000000..40a5991ebb80
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png differ
diff --git a/WebHostLib/static/static/icons/sc2/hyperflightrotors.png b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png
new file mode 100644
index 000000000000..375325845876
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png differ
diff --git a/WebHostLib/static/static/icons/sc2/hyperfluxor.png b/WebHostLib/static/static/icons/sc2/hyperfluxor.png
new file mode 100644
index 000000000000..cdd95bb515be
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hyperfluxor.png differ
diff --git a/WebHostLib/static/static/icons/sc2/impalerrounds.png b/WebHostLib/static/static/icons/sc2/impalerrounds.png
new file mode 100644
index 000000000000..b00e0c475827
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/impalerrounds.png differ
diff --git a/WebHostLib/static/static/icons/sc2/improvedburstlaser.png b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png
new file mode 100644
index 000000000000..8a48e38e874d
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png differ
diff --git a/WebHostLib/static/static/icons/sc2/improvedsiegemode.png b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png
new file mode 100644
index 000000000000..f19dad952bb5
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png differ
diff --git a/WebHostLib/static/static/icons/sc2/interferencematrix.png b/WebHostLib/static/static/icons/sc2/interferencematrix.png
new file mode 100644
index 000000000000..ced928aa57a9
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/interferencematrix.png differ
diff --git a/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png
new file mode 100644
index 000000000000..e97f3db0d29a
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png differ
diff --git a/WebHostLib/static/static/icons/sc2/jotunboosters.png b/WebHostLib/static/static/icons/sc2/jotunboosters.png
new file mode 100644
index 000000000000..25720306e5c2
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/jotunboosters.png differ
diff --git a/WebHostLib/static/static/icons/sc2/jumpjets.png b/WebHostLib/static/static/icons/sc2/jumpjets.png
new file mode 100644
index 000000000000..dfdfef4052ca
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/jumpjets.png differ
diff --git a/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png
new file mode 100644
index 000000000000..c57899b270ff
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png differ
diff --git a/WebHostLib/static/static/icons/sc2/liberator.png b/WebHostLib/static/static/icons/sc2/liberator.png
new file mode 100644
index 000000000000..31507be5fe68
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/liberator.png differ
diff --git a/WebHostLib/static/static/icons/sc2/lockdown.png b/WebHostLib/static/static/icons/sc2/lockdown.png
new file mode 100644
index 000000000000..a2e7f5dc3e3f
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/lockdown.png differ
diff --git a/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png
new file mode 100644
index 000000000000..0272b4b73892
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png differ
diff --git a/WebHostLib/static/static/icons/sc2/magrailmunitions.png b/WebHostLib/static/static/icons/sc2/magrailmunitions.png
new file mode 100644
index 000000000000..ec303498ccdb
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/magrailmunitions.png differ
diff --git a/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png
new file mode 100644
index 000000000000..1c7ce9d6ab1a
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png differ
diff --git a/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png
new file mode 100644
index 000000000000..04d68d35dc46
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png differ
diff --git a/WebHostLib/static/static/icons/sc2/opticalflare.png b/WebHostLib/static/static/icons/sc2/opticalflare.png
new file mode 100644
index 000000000000..f888fd518b99
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/opticalflare.png differ
diff --git a/WebHostLib/static/static/icons/sc2/optimizedlogistics.png b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png
new file mode 100644
index 000000000000..dcf5fd72da86
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png differ
diff --git a/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png
new file mode 100644
index 000000000000..b9f2f055c265
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png differ
diff --git a/WebHostLib/static/static/icons/sc2/restoration.png b/WebHostLib/static/static/icons/sc2/restoration.png
new file mode 100644
index 000000000000..f5c94e1aeefd
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/restoration.png differ
diff --git a/WebHostLib/static/static/icons/sc2/ripwavemissiles.png b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png
new file mode 100644
index 000000000000..f68e82039765
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png differ
diff --git a/WebHostLib/static/static/icons/sc2/shreddermissile.png b/WebHostLib/static/static/icons/sc2/shreddermissile.png
new file mode 100644
index 000000000000..40899095fe3a
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/shreddermissile.png differ
diff --git a/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png
new file mode 100644
index 000000000000..1b9f8cf06097
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png differ
diff --git a/WebHostLib/static/static/icons/sc2/siegetankrange.png b/WebHostLib/static/static/icons/sc2/siegetankrange.png
new file mode 100644
index 000000000000..5aef00a656c9
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/siegetankrange.png differ
diff --git a/WebHostLib/static/static/icons/sc2/specialordance.png b/WebHostLib/static/static/icons/sc2/specialordance.png
new file mode 100644
index 000000000000..4f7410d7ca9e
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/specialordance.png differ
diff --git a/WebHostLib/static/static/icons/sc2/spidermine.png b/WebHostLib/static/static/icons/sc2/spidermine.png
new file mode 100644
index 000000000000..bb39cf0bf8ce
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/spidermine.png differ
diff --git a/WebHostLib/static/static/icons/sc2/staticempblast.png b/WebHostLib/static/static/icons/sc2/staticempblast.png
new file mode 100644
index 000000000000..38f361510775
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/staticempblast.png differ
diff --git a/WebHostLib/static/static/icons/sc2/superstimpack.png b/WebHostLib/static/static/icons/sc2/superstimpack.png
new file mode 100644
index 000000000000..0fba8ce5749a
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/superstimpack.png differ
diff --git a/WebHostLib/static/static/icons/sc2/targetingoptics.png b/WebHostLib/static/static/icons/sc2/targetingoptics.png
new file mode 100644
index 000000000000..057a40f08e30
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/targetingoptics.png differ
diff --git a/WebHostLib/static/static/icons/sc2/terran-cloak-color.png b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png
new file mode 100644
index 000000000000..44d1bb9541fb
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png differ
diff --git a/WebHostLib/static/static/icons/sc2/terran-emp-color.png b/WebHostLib/static/static/icons/sc2/terran-emp-color.png
new file mode 100644
index 000000000000..972b828c75e2
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terran-emp-color.png differ
diff --git a/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png
new file mode 100644
index 000000000000..9d5982655183
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png differ
diff --git a/WebHostLib/static/static/icons/sc2/thorsiegemode.png b/WebHostLib/static/static/icons/sc2/thorsiegemode.png
new file mode 100644
index 000000000000..a298fb57de5a
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/thorsiegemode.png differ
diff --git a/WebHostLib/static/static/icons/sc2/transformationservos.png b/WebHostLib/static/static/icons/sc2/transformationservos.png
new file mode 100644
index 000000000000..f7f0524ac15c
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/transformationservos.png differ
diff --git a/WebHostLib/static/static/icons/sc2/valkyrie.png b/WebHostLib/static/static/icons/sc2/valkyrie.png
new file mode 100644
index 000000000000..9cbf339b10db
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/valkyrie.png differ
diff --git a/WebHostLib/static/static/icons/sc2/warpjump.png b/WebHostLib/static/static/icons/sc2/warpjump.png
new file mode 100644
index 000000000000..ff0a7b1af4aa
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/warpjump.png differ
diff --git a/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png
new file mode 100644
index 000000000000..8f5e09c6a593
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png differ
diff --git a/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png
new file mode 100644
index 000000000000..7097db05e6c0
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png differ
diff --git a/WebHostLib/static/static/icons/sc2/widowmine.png b/WebHostLib/static/static/icons/sc2/widowmine.png
new file mode 100644
index 000000000000..802c49a83d88
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine.png differ
diff --git a/WebHostLib/static/static/icons/sc2/widowminehidden.png b/WebHostLib/static/static/icons/sc2/widowminehidden.png
new file mode 100644
index 000000000000..e568742e8a50
Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowminehidden.png differ
diff --git a/WebHostLib/static/styles/landing.css b/WebHostLib/static/styles/landing.css
index 202c43badd5f..96975553c142 100644
--- a/WebHostLib/static/styles/landing.css
+++ b/WebHostLib/static/styles/landing.css
@@ -235,9 +235,6 @@ html{
line-height: 30px;
}
-#landing .variable{
- color: #ffff00;
-}
.landing-deco{
position: absolute;
diff --git a/WebHostLib/static/styles/sc2wolTracker.css b/WebHostLib/static/styles/sc2wolTracker.css
index b68668ecf60e..a7d8bd28c4f8 100644
--- a/WebHostLib/static/styles/sc2wolTracker.css
+++ b/WebHostLib/static/styles/sc2wolTracker.css
@@ -9,7 +9,7 @@
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
- width: 500px;
+ width: 710px;
background-color: #525494;
}
@@ -34,10 +34,12 @@
max-height: 40px;
border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%);
+ background-color: black;
}
#inventory-table img.acquired{
filter: none;
+ background-color: black;
}
#inventory-table div.counted-item {
@@ -52,7 +54,7 @@
}
#location-table{
- width: 500px;
+ width: 710px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css
index f86ab581ca47..7396daa95404 100644
--- a/WebHostLib/static/styles/supportedGames.css
+++ b/WebHostLib/static/styles/supportedGames.css
@@ -18,6 +18,22 @@
margin-bottom: 2px;
}
+#games .collapse-toggle{
+ cursor: pointer;
+}
+
+#games h2 .collapse-arrow{
+ font-size: 20px;
+ display: inline-block; /* make vertical-align work */
+ padding-bottom: 9px;
+ vertical-align: middle;
+ padding-right: 8px;
+}
+
+#games p.collapsed{
+ display: none;
+}
+
#games a{
font-size: 16px;
}
@@ -31,3 +47,13 @@
line-height: 25px;
margin-bottom: 7px;
}
+
+#games .page-controls{
+ display: flex;
+ flex-direction: row;
+ margin-top: 0.25rem;
+}
+
+#games .page-controls button{
+ margin-left: 0.5rem;
+}
diff --git a/WebHostLib/templates/check.html b/WebHostLib/templates/check.html
index 04b51340b513..8a3da7db472a 100644
--- a/WebHostLib/templates/check.html
+++ b/WebHostLib/templates/check.html
@@ -17,9 +17,9 @@ Upload Yaml
- {{ seeds }} + {{ seeds }} games were generated and - {{ rooms }} + {{ rooms }} were hosted in the last 7 days.
diff --git a/WebHostLib/templates/multiFactorioTracker.html b/WebHostLib/templates/multiFactorioTracker.html index e8fa7b152cf2..389a79d411b5 100644 --- a/WebHostLib/templates/multiFactorioTracker.html +++ b/WebHostLib/templates/multiFactorioTracker.html @@ -1,46 +1,42 @@ {% extends "multiTracker.html" %} -{% block custom_table_headers %} +{# establish the to be tracked data. Display Name, factorio/AP internal name, display image #} +{%- set science_packs = [ + ("Logistic Science Pack", "logistic-science-pack", + "https://wiki.factorio.com/images/thumb/Logistic_science_pack.png/32px-Logistic_science_pack.png"), + ("Military Science Pack", "military-science-pack", + "https://wiki.factorio.com/images/thumb/Military_science_pack.png/32px-Military_science_pack.png"), + ("Chemical Science Pack", "chemical-science-pack", + "https://wiki.factorio.com/images/thumb/Chemical_science_pack.png/32px-Chemical_science_pack.png"), + ("Production Science Pack", "production-science-pack", + "https://wiki.factorio.com/images/thumb/Production_science_pack.png/32px-Production_science_pack.png"), + ("Utility Science Pack", "utility-science-pack", + "https://wiki.factorio.com/images/thumb/Utility_science_pack.png/32px-Utility_science_pack.png"), + ("Space Science Pack", "space-science-pack", + "https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"), +] -%} +{%- block custom_table_headers %} +{#- macro that creates a table header with display name and image -#} +{%- macro make_header(name, img_src) %}+ | Starting Resources | |||||||||||||||||||||||
+ | Weapon & Armor Upgrades | + | + | + | ||||||||||||||||||||
+ | Base | |||||||||||||||||||||||
- | - | - | ||||||||||||||||||||||
+ | + | + | + | - | + | |||||||||||||||||||
+ | + | + | - | - | + | + | + | + | + | + | + | + | ||||||||||||
+ | + | + | + | + | + | |||||||||||||||||||
+ | Infantry | ++ | + Vehicles + | |||||||||||||||||||||
- | - | - | - | + | + | + | + | + | + | + | + | + | + | + | + | + | + | |||||||
- | + | + | + | + | + | + | + | + | ||||||||||||||||
+ | + | + | + | + | + | + | + | + | + | + | ||||||||||||||
+ | + | + | + | + | + | + | + | + | + | + | ||||||||||||||
+ | + | + | + | + | + | + | + | + | + | + | ||||||||||||||
- Vehicles - | ++ | + | + | + | + | + | + | + | + | |||||||||||||||
- | - | - | - | + | + | + | + | + | + | + | + | + | + | |||||||||||
- | - | - | - | - | - | - | - | - | + | + | + | + | + | + | + | + | + | |||||||
- Starships - | ++ | + | ||||||||||||||||||||||
- | - | - | - | + | + | + | + | + | + | + | ||||||||||||||
+ | + | + | + | + | + | |||||||||||||||||||
+ Starships + | ||||||||||||||||||||||||
+ | + | + | + | + | + | + | + | + | + | + | ||||||||||||||
+ | + | + | + | + | ||||||||||||||||||||
- | - | - | - | + | + | + | + | + | + | |||||||||||||||
- Dominion - | ++ | + | + | + | + | + | + | |||||||||||||||||
- | - | + | + | + | + | + | + | + | + | + | + | + | + | + | + | |||||||||
- | - | - | - | - | + | + | + | + | + | + | + | + | ||||||||||||
+ | Mercenaries | |||||||||||||||||||||||
- Lab Upgrades + | + General Upgrades | |||||||||||||||||||||||
- | - | - | - | - | - | - | - | + | - | |||||||||||||||
- | - | - | - | - | - | - | - | + | ||||||||||||||||
+ | Protoss Units | |||||||||||||||||||||||
Rooms: | -+ |
{% call macros.list_rooms(seed.rooms | selectattr("owner", "eq", session["_id"])) %}
-The following list is a list of client commands which may be available to you through your Archipelago client. You -execute these commands in your client window. - -The following commands are available in these clients: SNIClient, FactorioClient, FF1Client. - -- `/connect (Optionally mention a Tag name and get information on who has that Tag. For example: !status DeathLink) + + +### Utilities +- `!countdown |