diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c6ed9612ad..a40084b9ab 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -66,10 +66,10 @@ jobs:
- name: Get a recent python
uses: actions/setup-python@v4
with:
- python-version: '3.9'
+ python-version: '3.11'
- name: Install build-time dependencies
run: |
- echo "PYTHON=python3.9" >> $GITHUB_ENV
+ echo "PYTHON=python3.11" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 42594721d0..cc68a88b76 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -44,10 +44,10 @@ jobs:
- name: Get a recent python
uses: actions/setup-python@v4
with:
- python-version: '3.9'
+ python-version: '3.11'
- name: Install build-time dependencies
run: |
- echo "PYTHON=python3.9" >> $GITHUB_ENV
+ echo "PYTHON=python3.11" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml
index 8ff0f8bb44..1a76a7f471 100644
--- a/.github/workflows/unittests.yml
+++ b/.github/workflows/unittests.yml
@@ -26,28 +26,24 @@ on:
jobs:
build:
runs-on: ${{ matrix.os }}
- name: Test Python ${{ matrix.python.version }} ${{ matrix.os }} ${{ matrix.cython }}
+ name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
- cython:
- - '' # default
python:
- {version: '3.8'}
- {version: '3.9'}
- {version: '3.10'}
+ - {version: '3.11'}
include:
- python: {version: '3.8'} # win7 compat
os: windows-latest
- - python: {version: '3.10'} # current
+ - python: {version: '3.11'} # current
os: windows-latest
- - python: {version: '3.10'} # current
+ - python: {version: '3.11'} # current
os: macos-latest
- - python: {version: '3.10'} # current
- os: ubuntu-latest
- cython: beta
steps:
- uses: actions/checkout@v3
@@ -55,17 +51,12 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python.version }}
- - name: Install cython beta
- if: ${{ matrix.cython == 'beta' }}
- run: |
- python -m pip install --upgrade pip
- python -m pip install --pre --upgrade cython
- 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 8e4cc86657..f4bcd35c32 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 7c12a94dea..5dcc9daacd 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -1,13 +1,15 @@
from __future__ import annotations
import copy
+import itertools
import functools
import logging
import random
import secrets
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 import Counter, deque
+from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
Type, ClassVar
@@ -46,7 +48,6 @@ class ThreadBarrierProxy:
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
- _region_cache: Dict[int, Dict[str, Region]]
difficulty_requirements: dict
required_medallions: dict
dark_room_logic: Dict[int, str]
@@ -56,7 +57,7 @@ class MultiWorld():
plando_connections: List
worlds: Dict[int, auto_world]
groups: Dict[int, Group]
- regions: List[Region]
+ regions: RegionManager
itempool: List[Item]
is_race: bool = False
precollected_items: Dict[int, List[Item]]
@@ -91,6 +92,34 @@ class MultiWorld():
def __getitem__(self, player) -> bool:
return self.rule(player)
+ class RegionManager:
+ region_cache: Dict[int, Dict[str, Region]]
+ entrance_cache: Dict[int, Dict[str, Entrance]]
+ location_cache: Dict[int, Dict[str, Location]]
+
+ def __init__(self, players: int):
+ self.region_cache = {player: {} for player in range(1, players+1)}
+ self.entrance_cache = {player: {} for player in range(1, players+1)}
+ self.location_cache = {player: {} for player in range(1, players+1)}
+
+ def __iadd__(self, other: Iterable[Region]):
+ self.extend(other)
+ return self
+
+ def append(self, region: Region):
+ self.region_cache[region.player][region.name] = region
+
+ def extend(self, regions: Iterable[Region]):
+ for region in regions:
+ self.region_cache[region.player][region.name] = region
+
+ def __iter__(self) -> Iterator[Region]:
+ for regions in self.region_cache.values():
+ yield from regions.values()
+
+ def __len__(self):
+ return sum(len(regions) for regions in self.region_cache.values())
+
def __init__(self, players: int):
# world-local random state is saved for multiple generations running concurrently
self.random = ThreadBarrierProxy(random.Random())
@@ -99,16 +128,12 @@ class MultiWorld():
self.glitch_triforce = False
self.algorithm = 'balanced'
self.groups = {}
- self.regions = []
+ self.regions = self.RegionManager(players)
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
- self._cached_entrances = None
- self._cached_locations = None
- self._entrance_cache = {}
- self._location_cache: Dict[Tuple[str, int], Location] = {}
self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
@@ -136,7 +161,6 @@ class MultiWorld():
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
- set_player_attr('_region_cache', {})
set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches")
set_player_attr('mode', 'open')
@@ -180,7 +204,6 @@ class MultiWorld():
set_player_attr('plando_connections', [])
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
- self.custom_data = {}
self.worlds = {}
self.per_slot_randoms = {}
self.plando_options = PlandoOptions.none
@@ -198,18 +221,9 @@ class MultiWorld():
new_id: int = self.players + len(self.groups) + 1
self.game[new_id] = game
- self.custom_data[new_id] = {}
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 +246,23 @@ class MultiWorld():
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 +317,6 @@ class MultiWorld():
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
@@ -321,11 +325,15 @@ class MultiWorld():
def player_ids(self) -> Tuple[int, ...]:
return tuple(range(1, self.players + 1))
- @functools.lru_cache()
+ @Utils.cache_self1
def get_game_players(self, game_name: str) -> Tuple[int, ...]:
return tuple(player for player in self.player_ids if self.game[player] == game_name)
- @functools.lru_cache()
+ @Utils.cache_self1
+ def get_game_groups(self, game_name: str) -> Tuple[int, ...]:
+ return tuple(group_id for group_id in self.groups if self.game[group_id] == game_name)
+
+ @Utils.cache_self1
def get_game_worlds(self, game_name: str):
return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name)
@@ -343,50 +351,21 @@ class MultiWorld():
""" the base name (without file extension) for each player's output file for a seed """
return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}"
- def initialize_regions(self, regions=None):
- for region in regions if regions else self.regions:
- region.multiworld = self
- self._region_cache[region.player][region.name] = region
-
@functools.cached_property
def world_name_lookup(self):
return {self.player_name[player_id]: player_id for player_id in self.player_ids}
- def _recache(self):
- """Rebuild world cache"""
- self._cached_locations = None
- for region in self.regions:
- player = region.player
- self._region_cache[player][region.name] = region
- for exit in region.exits:
- self._entrance_cache[exit.name, player] = exit
+ def get_regions(self, player: Optional[int] = None) -> Collection[Region]:
+ return self.regions if player is None else self.regions.region_cache[player].values()
- for r_location in region.locations:
- self._location_cache[r_location.name, player] = r_location
+ def get_region(self, region_name: str, player: int) -> Region:
+ return self.regions.region_cache[player][region_name]
- def get_regions(self, player=None):
- return self.regions if player is None else self._region_cache[player].values()
+ def get_entrance(self, entrance_name: str, player: int) -> Entrance:
+ return self.regions.entrance_cache[player][entrance_name]
- def get_region(self, regionname: str, player: int) -> Region:
- try:
- return self._region_cache[player][regionname]
- except KeyError:
- self._recache()
- return self._region_cache[player][regionname]
-
- def get_entrance(self, entrance: str, player: int) -> Entrance:
- try:
- return self._entrance_cache[entrance, player]
- except KeyError:
- self._recache()
- return self._entrance_cache[entrance, player]
-
- def get_location(self, location: str, player: int) -> Location:
- try:
- return self._location_cache[location, player]
- except KeyError:
- self._recache()
- return self._location_cache[location, player]
+ def get_location(self, location_name: str, player: int) -> Location:
+ return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool) -> CollectionState:
cached = getattr(self, "_all_state", None)
@@ -447,28 +426,22 @@ class MultiWorld():
logging.debug('Placed %s at %s', item, location)
- def get_entrances(self) -> List[Entrance]:
- if self._cached_entrances is None:
- self._cached_entrances = [entrance for region in self.regions for entrance in region.entrances]
- return self._cached_entrances
-
- def clear_entrance_cache(self):
- self._cached_entrances = None
+ def get_entrances(self, player: Optional[int] = None) -> Iterable[Entrance]:
+ if player is not None:
+ return self.regions.entrance_cache[player].values()
+ return Utils.RepeatableChain(tuple(self.regions.entrance_cache[player].values()
+ for player in self.regions.entrance_cache))
def register_indirect_condition(self, region: Region, entrance: Entrance):
"""Report that access to this Region can result in unlocking this Entrance,
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance)
- def get_locations(self, player: Optional[int] = None) -> List[Location]:
- if self._cached_locations is None:
- self._cached_locations = [location for region in self.regions for location in region.locations]
+ def get_locations(self, player: Optional[int] = None) -> Iterable[Location]:
if player is not None:
- return [location for location in self._cached_locations if location.player == player]
- return self._cached_locations
-
- def clear_location_cache(self):
- self._cached_locations = None
+ return self.regions.location_cache[player].values()
+ return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values()
+ for player in self.regions.location_cache))
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
return [location for location in self.get_locations(player) if location.item is None]
@@ -487,17 +460,20 @@ class MultiWorld():
def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]):
for player in players:
if not location_names:
- location_names = [location.name for location in self.get_unfilled_locations(player)]
- for location_name in location_names:
- location = self._location_cache.get((location_name, player), None)
- if location is not None and location.item is None:
+ valid_locations = [location.name for location in self.get_unfilled_locations(player)]
+ else:
+ valid_locations = location_names
+ relevant_cache = self.regions.location_cache[player]
+ for location_name in valid_locations:
+ location = relevant_cache.get(location_name, None)
+ if location and location.item is None:
yield location
def unlocks_new_location(self, item: Item) -> bool:
temp_state = self.state.copy()
temp_state.collect(item, True)
- for location in self.get_unfilled_locations():
+ for location in self.get_unfilled_locations(item.player):
if temp_state.can_reach(location) and not self.state.can_reach(location):
return True
@@ -837,28 +813,88 @@ class Region:
locations: List[Location]
entrance_type: ClassVar[Type[Entrance]] = Entrance
+ class Register(MutableSequence):
+ region_manager: MultiWorld.RegionManager
+
+ def __init__(self, region_manager: MultiWorld.RegionManager):
+ self._list = []
+ self.region_manager = region_manager
+
+ def __getitem__(self, index: int) -> Location:
+ return self._list.__getitem__(index)
+
+ def __setitem__(self, index: int, value: Location) -> None:
+ raise NotImplementedError()
+
+ def __len__(self) -> int:
+ return self._list.__len__()
+
+ # This seems to not be needed, but that's a bit suspicious.
+ # def __del__(self):
+ # self.clear()
+
+ def copy(self):
+ return self._list.copy()
+
+ class LocationRegister(Register):
+ def __delitem__(self, index: int) -> None:
+ location: Location = self._list.__getitem__(index)
+ self._list.__delitem__(index)
+ del(self.region_manager.location_cache[location.player][location.name])
+
+ def insert(self, index: int, value: Location) -> None:
+ self._list.insert(index, value)
+ self.region_manager.location_cache[value.player][value.name] = value
+
+ class EntranceRegister(Register):
+ def __delitem__(self, index: int) -> None:
+ entrance: Entrance = self._list.__getitem__(index)
+ self._list.__delitem__(index)
+ del(self.region_manager.entrance_cache[entrance.player][entrance.name])
+
+ def insert(self, index: int, value: Entrance) -> None:
+ self._list.insert(index, value)
+ self.region_manager.entrance_cache[value.player][value.name] = value
+
+ _locations: LocationRegister[Location]
+ _exits: EntranceRegister[Entrance]
+
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
self.entrances = []
- self.exits = []
- self.locations = []
+ self._exits = self.EntranceRegister(multiworld.regions)
+ self._locations = self.LocationRegister(multiworld.regions)
self.multiworld = multiworld
self._hint_text = hint
self.player = player
+ def get_locations(self):
+ return self._locations
+
+ def set_locations(self, new):
+ if new is self._locations:
+ return
+ self._locations.clear()
+ self._locations.extend(new)
+
+ locations = property(get_locations, set_locations)
+
+ def get_exits(self):
+ return self._exits
+
+ def set_exits(self, new):
+ if new is self._exits:
+ return
+ self._exits.clear()
+ self._exits.extend(new)
+
+ exits = property(get_exits, set_exits)
+
def can_reach(self, state: CollectionState) -> bool:
if state.stale[self.player]:
state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player]
- def can_reach_private(self, state: CollectionState) -> bool:
- for entrance in self.entrances:
- if entrance.can_reach(state):
- if not self in state.path:
- state.path[self] = (self.name, state.path.get(entrance, None))
- return True
- return False
-
@property
def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name
@@ -875,19 +911,19 @@ class Region:
"""
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:
+ rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
"""
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"""
@@ -895,11 +931,12 @@ class Region:
if rule:
exit_.access_rule = rule
exit_.connect(connecting_region)
-
+ return exit_
+
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)
@@ -1269,7 +1306,7 @@ class Spoiler:
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")
@@ -1287,8 +1324,7 @@ class Spoiler:
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 0000000000..86c8e5197e
--- /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 0c48068d39..a5e9b4553a 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 @@ class CommonContext:
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 @@ class CommonContext:
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."""
@@ -833,7 +839,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
- if args["key"] == "EnergyLink":
+ if args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
@@ -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 c6fb5c546f..c9660ab708 100644
--- a/Fill.py
+++ b/Fill.py
@@ -5,6 +5,8 @@ import typing
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
@@ -13,6 +15,10 @@ class FillError(RuntimeError):
pass
+def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
+ logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
+
+
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
@@ -24,7 +30,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
- allow_partial: bool = False, allow_excluded: bool = False) -> None:
+ allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
"""
:param world: Multiworld to be filled.
:param base_state: State assumed before fill.
@@ -36,22 +42,29 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
:param on_place: callback that is called when a placement happens
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
+ :param name: name of this fill step for progress logging purposes
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
cleanup_required = False
-
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
+ # for progress logging
+ total = min(len(item_pool), len(locations))
+ placed = 0
+
while any(reachable_items.values()) and locations:
# grab one item per player
items_to_place = [items.pop()
for items in reachable_items.values() if items]
for item in items_to_place:
- item_pool.remove(item)
+ for p, pool_item in enumerate(item_pool):
+ if pool_item is item:
+ item_pool.pop(p)
+ break
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items)
@@ -67,7 +80,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
@@ -147,13 +160,19 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement
+ placed += 1
+ if not placed % 1000:
+ _log_fill_progress(name, placed, total)
if on_place:
on_place(spot_to_fill)
+ if total > 1000:
+ _log_fill_progress(name, placed, total)
+
if cleanup_required:
# validate all placements and remove invalid ones
+ state = sweep_from_pool(base_state, [])
for placement in placements:
- state = sweep_from_pool(base_state, [])
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
placement.item.location = None
unplaced_items.append(placement.item)
@@ -193,6 +212,8 @@ def remaining_fill(world: MultiWorld,
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
+ total = min(len(itempool), len(locations))
+ placed = 0
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
@@ -242,6 +263,12 @@ def remaining_fill(world: MultiWorld,
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
+ placed += 1
+ if not placed % 1000:
+ _log_fill_progress("Remaining", placed, total)
+
+ if total > 1000:
+ _log_fill_progress("Remaining", placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
@@ -262,7 +289,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:
@@ -277,7 +304,7 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
- fill_restrictive(world, state, locations, pool)
+ fill_restrictive(world, state, locations, pool, name="Accessibility Corrections")
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
@@ -285,7 +312,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)
@@ -347,23 +374,25 @@ def distribute_early_items(world: MultiWorld,
player_local = early_local_rest_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
- player_local, lock=True, allow_partial=True)
+ player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
- fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
+ fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
+ name="Early Items")
early_locations += early_priority_locations
for player in world.player_ids:
player_local = early_local_prog_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
- player_local, lock=True, allow_partial=True)
+ player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
- fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
+ fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
+ name="Early Progression")
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
@@ -417,13 +446,14 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
if prioritylocations:
# "priority fill"
- fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
+ fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
+ name="Priority")
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
- # "progression fill"
- fill_restrictive(world, world.state, defaultlocations, progitempool)
+ # "advancement/progression fill"
+ fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression")
if progitempool:
raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
@@ -528,9 +558,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.')
@@ -750,8 +780,6 @@ def distribute_planned(world: MultiWorld) -> 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]
@@ -837,14 +865,14 @@ def distribute_planned(world: MultiWorld) -> 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
@@ -894,23 +922,22 @@ def distribute_planned(world: MultiWorld) -> 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 bd1c4aa6fd..8113d8a0d7 100644
--- a/Generate.py
+++ b/Generate.py
@@ -7,8 +7,8 @@ import random
import string
import urllib.parse
import urllib.request
-from collections import ChainMap, Counter
-from typing import Any, Callable, Dict, Tuple, Union
+from collections import Counter
+from typing import Any, Dict, Tuple, Union
import ModuleUpdate
@@ -86,7 +86,7 @@ def main(args=None, callback=ERmain):
try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
except Exception as e:
- raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
+ raise ValueError(f"File {args.weights_file_path} is invalid. Please fix your yaml.") from e
logging.info(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
@@ -94,7 +94,7 @@ def main(args=None, callback=ERmain):
try:
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
except Exception as e:
- raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
+ raise ValueError(f"File {args.meta_file_path} is invalid. Please fix your yaml.") from e
logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
del(meta_weights["meta_description"])
@@ -114,7 +114,7 @@ def main(args=None, callback=ERmain):
try:
weights_cache[fname] = read_weights_yamls(path)
except Exception as e:
- raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
+ raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
@@ -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}.")
@@ -168,7 +169,7 @@ def main(args=None, callback=ERmain):
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
- erargs.player_settings = {}
+ erargs.player_options = {}
player = 1
while player <= args.multi:
@@ -195,7 +196,7 @@ def main(args=None, callback=ERmain):
player += 1
except Exception as e:
- raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
+ raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
@@ -224,7 +225,7 @@ def main(args=None, callback=ERmain):
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
- callback(erargs, seed)
+ return callback(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -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)
@@ -374,7 +375,7 @@ def roll_linked_options(weights: dict) -> dict:
else:
logging.debug(f"linked option {option_set['name']} skipped.")
except Exception as e:
- raise ValueError(f"Linked option {option_set['name']} is destroyed. "
+ raise ValueError(f"Linked option {option_set['name']} is invalid. "
f"Please fix your linked option.") from e
return weights
@@ -404,7 +405,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
except Exception as e:
- raise ValueError(f"Your trigger number {i + 1} is destroyed. "
+ raise ValueError(f"Your trigger number {i + 1} is invalid. "
f"Please fix your triggers.") from e
return weights
@@ -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":
@@ -643,6 +639,15 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if __name__ == '__main__':
import atexit
confirmation = atexit.register(input, "Press enter to close.")
- main()
+ multiworld = main()
+ if __debug__:
+ import gc
+ import sys
+ import weakref
+ weak = weakref.ref(multiworld)
+ del multiworld
+ gc.collect() # need to collect to deref all hard references
+ assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \
+ " This would be a memory leak."
# in case of error-free exit should not need confirmation
atexit.unregister(confirmation)
diff --git a/Launcher.py b/Launcher.py
index a1548d594c..9e184bf108 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/LinksAwakeningClient.py b/LinksAwakeningClient.py
index 8f1cbc1e9e..f3fc9d2cdb 100644
--- a/LinksAwakeningClient.py
+++ b/LinksAwakeningClient.py
@@ -347,12 +347,13 @@ class LinksAwakeningClient():
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
await asyncio.sleep(1.0)
continue
+ self.stop_bizhawk_spam = False
logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
return
except (BlockingIOError, TimeoutError, ConnectionResetError):
await asyncio.sleep(1.0)
pass
- self.stop_bizhawk_spam = False
+
async def reset_auth(self):
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
self.auth = auth
diff --git a/LttPAdjuster.py b/LttPAdjuster.py
index d1c03bd49e..9c5bd10244 100644
--- a/LttPAdjuster.py
+++ b/LttPAdjuster.py
@@ -25,7 +25,7 @@ ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
- get_adjuster_settings, tkinter_center_window, init_logging
+ get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
GAME_ALTTP = "A Link to the Past"
@@ -43,6 +43,47 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action):
return textwrap.dedent(action.help)
+# See argparse.BooleanOptionalAction
+class BooleanOptionalActionWithDisable(argparse.Action):
+ def __init__(self,
+ option_strings,
+ dest,
+ default=None,
+ type=None,
+ choices=None,
+ required=False,
+ help=None,
+ metavar=None):
+
+ _option_strings = []
+ for option_string in option_strings:
+ _option_strings.append(option_string)
+
+ if option_string.startswith('--'):
+ option_string = '--disable' + option_string[2:]
+ _option_strings.append(option_string)
+
+ if help is not None and default is not None:
+ help += " (default: %(default)s)"
+
+ super().__init__(
+ option_strings=_option_strings,
+ dest=dest,
+ nargs=0,
+ default=default,
+ type=type,
+ choices=choices,
+ required=required,
+ help=help,
+ metavar=metavar)
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ if option_string in self.option_strings:
+ setattr(namespace, self.dest, not option_string.startswith('--disable'))
+
+ def format_usage(self):
+ return ' | '.join(self.option_strings)
+
def get_argparser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
@@ -52,6 +93,8 @@ def get_argparser() -> argparse.ArgumentParser:
help='Path to an ALttP Japan(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
+ parser.add_argument('--auto_apply', default='ask',
+ choices=['ask', 'always', 'never'], help='Whether or not to apply settings automatically in the future.')
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
help='''\
@@ -61,7 +104,7 @@ def get_argparser() -> argparse.ArgumentParser:
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
- parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
+ parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable)
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
help='''\
@@ -104,21 +147,23 @@ def get_argparser() -> argparse.ArgumentParser:
Alternatively, can be a ALttP Rom patched with a Link
sprite that will be extracted.
''')
+ parser.add_argument('--sprite_pool', nargs='+', default=[], help='''
+ A list of sprites to pull from.
+ ''')
parser.add_argument('--oof', help='''\
Path to a sound effect to replace Link's "oof" sound.
Needs to be in a .brr format and have a length of no
more than 2673 bytes, created from a 16-bit signed PCM
.wav at 12khz. https://github.com/boldowa/snesbrr
''')
- parser.add_argument('--names', default='', type=str)
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
return parser
def main():
parser = get_argparser()
- args = parser.parse_args()
- args.music = not args.disablemusic
+ args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP))
+
# set up logger
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
args.loglevel]
@@ -530,9 +575,6 @@ class AttachTooltip(object):
def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
- if not adjuster_settings:
- adjuster_settings = Namespace()
- adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
@@ -560,33 +602,8 @@ def get_rom_frame(parent=None):
return romFrame, romVar
-
def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
- defaults = {
- "auto_apply": 'ask',
- "music": True,
- "reduceflashing": True,
- "deathlink": False,
- "sprite": None,
- "oof": None,
- "quickswap": True,
- "menuspeed": 'normal',
- "heartcolor": 'red',
- "heartbeep": 'normal',
- "ow_palettes": 'default',
- "uw_palettes": 'default',
- "hud_palettes": 'default',
- "sword_palettes": 'default',
- "shield_palettes": 'default',
- "sprite_pool": [],
- "allowcollect": False,
- }
- if not adjuster_settings:
- adjuster_settings = Namespace()
- for key, defaultvalue in defaults.items():
- if not hasattr(adjuster_settings, key):
- setattr(adjuster_settings, key, defaultvalue)
romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
@@ -987,6 +1004,7 @@ class SpriteSelector():
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/MMBN3Client.py b/MMBN3Client.py
index d8ee581bd4..3f7474a6fd 100644
--- a/MMBN3Client.py
+++ b/MMBN3Client.py
@@ -71,6 +71,7 @@ class MMBN3Context(CommonContext):
self.auth_name = None
self.slot_data = dict()
self.patching_error = False
+ self.sent_hints = []
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -175,13 +176,16 @@ async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
# If trade hinting is enabled, send scout checks
if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
- scouted_locs = [loc.id for loc in scoutable_locations
+ trade_bits = [loc.id for loc in scoutable_locations
if check_location_scouted(loc, payload["locations"])]
- await ctx.send_msgs([{
- "cmd": "LocationScouts",
- "locations": scouted_locs,
- "create_as_hint": 2
- }])
+ scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints]
+ if len(scouted_locs) > 0:
+ ctx.sent_hints.extend(scouted_locs)
+ await ctx.send_msgs([{
+ "cmd": "LocationScouts",
+ "locations": scouted_locs,
+ "create_as_hint": 2
+ }])
def check_location_packet(location, memory):
diff --git a/Main.py b/Main.py
index c81466bf78..691b88b137 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))
@@ -122,31 +122,34 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Creating Items.')
AutoWorld.call_all(world, "create_items")
- # All worlds should have finished creating all regions, locations, and entrances.
- # Recache to ensure that they are all visible for locality rules.
- world._recache()
-
logger.info('Calculating Access Rules.')
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])
-
- if world.players > 1:
- locality_rules(world)
- else:
- world.non_local_items[1].value = set()
- world.local_items[1].value = set()
+ 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.worlds[1].options.non_local_items.value = set()
+ world.worlds[1].options.local_items.value = set()
+
AutoWorld.call_all(world, "generate_basic")
# remove starting inventory from pool items.
@@ -158,7 +161,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):
@@ -178,6 +182,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
@@ -224,7 +229,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
region = Region("Menu", group_id, world, "ItemLink")
world.regions.append(region)
- locations = region.locations = []
+ locations = region.locations
for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
@@ -258,7 +263,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
if any(world.item_links.values()):
- world._recache()
world._all_state = None
logger.info("Running Item Plando")
@@ -292,15 +296,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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]] = {}
@@ -351,11 +356,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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)
@@ -391,7 +396,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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.")
@@ -399,7 +404,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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/ModuleUpdate.py b/ModuleUpdate.py
index 209f2da672..c33e894e8b 100644
--- a/ModuleUpdate.py
+++ b/ModuleUpdate.py
@@ -67,14 +67,23 @@ def update(yes=False, force=False):
install_pkg_resources(yes=yes)
import pkg_resources
+ prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
for line in requirementsfile:
- if not line or line[0] == "#":
- continue # ignore comments
+ if not line or line.lstrip(" \t")[0] == "#":
+ if not prev:
+ continue # ignore comments
+ line = ""
+ elif line.rstrip("\r\n").endswith("\\"):
+ prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line
+ continue
+ line = prev + line
+ line = line.split("--hash=")[0] # remove hashes from requirement for version checking
+ prev = ""
if line.startswith(("https://", "git+https://")):
# extract name and version for url
rest = line.split('/')[-1]
diff --git a/MultiServer.py b/MultiServer.py
index 3e73910b9f..8be8d64132 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -795,7 +795,7 @@ async def on_client_joined(ctx: Context, client: Client):
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
f"{verb} {ctx.games[client.slot]} has joined. "
- f"Client({version_str}), {client.tags}).",
+ f"Client({version_str}), {client.tags}.",
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
ctx.notify_client(client, "Now that you are connected, "
"you can use !help to list commands to run via the server. "
@@ -2118,13 +2118,15 @@ class ServerCommandProcessor(CommonCommandProcessor):
async def console(ctx: Context):
import sys
queue = asyncio.Queue()
- Utils.stream_input(sys.stdin, queue)
+ worker = Utils.stream_input(sys.stdin, queue)
while not ctx.exit_event.is_set():
try:
# I don't get why this while loop is needed. Works fine without it on clients,
# but the queue.get() for server never fulfills if the queue is empty when entering the await.
while queue.qsize() == 0:
await asyncio.sleep(0.05)
+ if not worker.is_alive():
+ return
input_text = await queue.get()
queue.task_done()
ctx.commandprocessor(input_text)
diff --git a/NetUtils.py b/NetUtils.py
index 99c37238c3..a2db6a2ac5 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -390,7 +390,7 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
checked = state[team, slot]
if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time.
- return list(self)
+ return list(self[slot])
return [location_id for
location_id in self[slot] if
location_id not in checked]
@@ -407,14 +407,22 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
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 5e638a463a..9b4f9d9908 100644
--- a/Options.py
+++ b/Options.py
@@ -1,13 +1,18 @@
from __future__ import annotations
+
import abc
import logging
from copy import deepcopy
+from dataclasses import dataclass
+import functools
import math
import numbers
-import typing
import random
+import typing
+from copy import deepcopy
+
+from schema import And, Optional, Or, Schema
-from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results
if typing.TYPE_CHECKING:
@@ -209,6 +214,12 @@ class NumericOption(Option[int], numbers.Integral, abc.ABC):
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)
@@ -769,7 +780,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
-class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
+class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default: typing.Dict[str, typing.Any] = {}
supports_weighting = False
@@ -787,8 +798,14 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
def get_option_name(self, value):
return ", ".join(f"{key}: {v}" for key, v in value.items())
- def __contains__(self, item):
- return item in self.value
+ def __getitem__(self, item: str) -> typing.Any:
+ return self.value.__getitem__(item)
+
+ def __iter__(self) -> typing.Iterator[str]:
+ return self.value.__iter__()
+
+ def __len__(self) -> int:
+ return self.value.__len__()
class ItemDict(OptionDict):
@@ -888,10 +905,58 @@ 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'.")
+ value = getattr(self, option_name).value
+ if isinstance(value, set):
+ value = sorted(value)
+ option_results[display_name] = value
+ else:
+ raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
+ return option_results
class LocalItems(ItemSet):
@@ -949,6 +1014,7 @@ class DeathLink(Toggle):
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
+ display_name = "Item Links"
default = []
schema = Schema([
{
@@ -1011,17 +1077,16 @@ class ItemLinks(OptionList):
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):
@@ -1062,10 +1127,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
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/PokemonClient.py b/PokemonClient.py
index e7306d2b8e..6b43a53b8f 100644
--- a/PokemonClient.py
+++ b/PokemonClient.py
@@ -29,6 +29,9 @@ for location in location_data:
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
+location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"
+ and location.address is not None}
+
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
@@ -72,6 +75,7 @@ class GBContext(CommonContext):
self.items_handling = 0b001
self.sent_release = False
self.sent_collect = False
+ self.auto_hints = set()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -153,6 +157,33 @@ async def parse_locations(data: List, ctx: GBContext):
locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
locations.append(loc_id)
+
+ hints = []
+ if flags["EventFlag"][280] & 16:
+ hints.append("Cerulean Bicycle Shop")
+ if flags["EventFlag"][280] & 32:
+ hints.append("Route 2 Gate - Oak's Aide")
+ if flags["EventFlag"][280] & 64:
+ hints.append("Route 11 Gate 2F - Oak's Aide")
+ if flags["EventFlag"][280] & 128:
+ hints.append("Route 15 Gate 2F - Oak's Aide")
+ if flags["EventFlag"][281] & 1:
+ hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2",
+ "Celadon Prize Corner - Item Prize 3"]
+ if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"]
+ not in ctx.checked_locations):
+ hints.append("Fossil - Choice B")
+ elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"]
+ not in ctx.checked_locations):
+ hints.append("Fossil - Choice A")
+ hints = [
+ location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and
+ location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked
+ ]
+ if hints:
+ await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}])
+ ctx.auto_hints.update(hints)
+
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
diff --git a/SNIClient.py b/SNIClient.py
index 50b557e6d7..0909c61382 100644
--- a/SNIClient.py
+++ b/SNIClient.py
@@ -68,12 +68,11 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
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:
@@ -565,14 +564,16 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try:
for address, data in write_list:
- PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
- # REVIEW: above: `if snes_socket is None: return False`
- # Does it need to be checked again?
- if ctx.snes_socket is not None:
- await ctx.snes_socket.send(dumps(PutAddress_Request))
- await ctx.snes_socket.send(data)
- else:
- snes_logger.warning(f"Could not send data to SNES: {data}")
+ while data:
+ # Divide the write into packets of 256 bytes.
+ PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]]
+ if ctx.snes_socket is not None:
+ await ctx.snes_socket.send(dumps(PutAddress_Request))
+ await ctx.snes_socket.send(data[:256])
+ address += 256
+ data = data[256:]
+ else:
+ snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed:
return False
diff --git a/Starcraft2Client.py b/Starcraft2Client.py
index cdcdb39a0b..87b50d3506 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 94ed15d695..62fbe128bd 100644
--- a/UndertaleClient.py
+++ b/UndertaleClient.py
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
+import sys
import asyncio
import typing
import bsdiff4
@@ -11,7 +12,7 @@ from NetUtils import NetworkItem, ClientStatus
from worlds import undertale
from MultiServer import mark_raw
from CommonClient import CommonContext, server_loop, \
- gui_enabled, ClientCommandProcessor, get_base_parser
+ gui_enabled, ClientCommandProcessor, logger, get_base_parser
from Utils import async_start
@@ -28,31 +29,31 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
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."
@@ -60,8 +61,8 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
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!")
@@ -98,6 +99,7 @@ class UndertaleContext(CommonContext):
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.pieces_needed = 0
+ self.finished_game = False
self.game = "Undertale"
self.got_deathlink = False
self.syncing = False
@@ -105,15 +107,17 @@ class UndertaleContext(CommonContext):
self.tem_armor = False
self.completed_count = 0
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
+ # self.save_game_folder: files go in this path to pass data between us and the actual game
+ 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()
@@ -233,7 +237,7 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
f.close()
filename = f"check.spot"
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
- for ss in ctx.checked_locations:
+ for ss in set(args["checked_locations"]):
f.write(str(ss-12000)+"\n")
f.close()
elif cmd == "LocationInfo":
@@ -359,7 +363,7 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
if "checked_locations" in args:
filename = f"check.spot"
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
- for ss in ctx.checked_locations:
+ for ss in set(args["checked_locations"]):
f.write(str(ss-12000)+"\n")
f.close()
@@ -381,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()
@@ -404,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)})
@@ -420,36 +424,34 @@ 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):
- sending = sending + [int(l)+12000]
+ sending = sending + [int(l.rstrip('\n'))+12000]
+ finally:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
"create_as_hint": int(2)}])
- finally:
- 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))+12000]
- message = [{"cmd": "LocationChecks", "locations": sending}]
- await ctx.send_msgs(message)
+ sending = sending+[(int(l.rstrip('\n')))+12000]
finally:
- pass
+ await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}])
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 f3e748d1cc..114c2e8103 100644
--- a/Utils.py
+++ b/Utils.py
@@ -5,6 +5,7 @@ import json
import typing
import builtins
import os
+import itertools
import subprocess
import sys
import pickle
@@ -13,7 +14,9 @@ import io
import collections
import importlib
import logging
+import warnings
+from argparse import Namespace
from settings import Settings, get_settings
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from yaml import load, load_all, dump, SafeLoader
@@ -28,6 +31,7 @@ except ImportError:
if typing.TYPE_CHECKING:
import tkinter
import pathlib
+ from BaseClasses import Region
def tuplize_version(version: str) -> Version:
@@ -43,7 +47,7 @@ class Version(typing.NamedTuple):
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")
@@ -70,6 +74,8 @@ def snes_to_pc(value: int) -> int:
RetType = typing.TypeVar("RetType")
+S = typing.TypeVar("S")
+T = typing.TypeVar("T")
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
@@ -87,6 +93,31 @@ def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[]
return _wrap
+def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]:
+ """Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple."""
+
+ assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache."
+
+ cache_name = f"__cache_{function.__name__}__"
+
+ @functools.wraps(function)
+ def wrap(self: S, arg: T) -> RetType:
+ cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]],
+ getattr(self, cache_name, None))
+ if cache is None:
+ res = function(self, arg)
+ setattr(self, cache_name, {arg: res})
+ return res
+ try:
+ return cache[arg]
+ except KeyError:
+ res = function(self, arg)
+ cache[arg] = res
+ return res
+
+ return wrap
+
+
def is_frozen() -> bool:
return typing.cast(bool, getattr(sys, 'frozen', False))
@@ -214,7 +245,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()
@@ -232,7 +269,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()
@@ -242,15 +285,13 @@ def get_public_ipv6() -> str:
return ip
-OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
+OptionsType = Settings # TODO: remove when removing get_options
-@cache_argsless
-def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
- return Settings(None)
-
-
-get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
+def get_options() -> Settings:
+ # TODO: switch to Utils.deprecate after 0.4.4
+ warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
+ return get_settings()
def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -318,12 +359,27 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
except Exception as e:
logging.debug(f"Could not store data package: {e}")
+def get_default_adjuster_settings(game_name: str) -> Namespace:
+ import LttPAdjuster
+ adjuster_settings = Namespace()
+ if game_name == LttPAdjuster.GAME_ALTTP:
+ return LttPAdjuster.get_argparser().parse_known_args(args=[])[0]
-def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
- adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings
+def get_adjuster_settings_no_defaults(game_name: str) -> Namespace:
+ return persistent_load().get("adjuster", {}).get(game_name, Namespace())
+
+
+def get_adjuster_settings(game_name: str) -> Namespace:
+ adjuster_settings = get_adjuster_settings_no_defaults(game_name)
+ default_settings = get_default_adjuster_settings(game_name)
+
+ # Fill in any arguments from the argparser that we haven't seen before
+ return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)})
+
+
@cache_argsless
def get_unique_identifier():
uuid = persistent_load().get("client", {}).get("uuid", None)
@@ -343,11 +399,13 @@ safe_builtins = frozenset((
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:
@@ -357,6 +415,8 @@ class RestrictedUnpickler(pickle.Unpickler):
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"):
@@ -425,11 +485,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
@@ -556,7 +626,7 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
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
@@ -568,7 +638,10 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
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)
@@ -581,13 +654,14 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[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
@@ -599,7 +673,10 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[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)
@@ -629,6 +706,11 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
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
@@ -739,3 +821,127 @@ 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', `${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')); }
+ });
+
+ 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 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;
+ range.dispatchEvent(new Event('change'));
+ rangeTbody.removeChild(tr);
+ });
+ tdDelete.appendChild(deleteButton);
+ tr.appendChild(tdDelete);
+
+ rangeTbody.appendChild(tr);
+
+ // Save new option to settings
+ range.dispatchEvent(new Event('change'));
+ });
+
+ 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);
+ });
+ }
+
+ ['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', `${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);
+
+ 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);
+ });
+
+ 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');
+ }
+
+ const itemName = document.createElement('span');
+ itemName.innerText = item.toString();
+
+ itemLabel.appendChild(itemCheckbox);
+ itemLabel.appendChild(itemName);
+
+ itemRow.appendChild(itemLabel);
+ itemsList.appendChild((itemRow));
+ });
+
+ 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');
+ }
+
+ const locationName = document.createElement('span');
+ locationName.innerText = location.toString();
+
+ locationLabel.appendChild(locationCheckbox);
+ locationLabel.appendChild(locationName);
+
+ locationRow.appendChild(locationLabel);
+ locationsList.appendChild((locationRow));
+ });
+
+ 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');
+ }
+
+ const customItemName = document.createElement('span');
+ customItemName.innerText = listItem.toString();
+
+ customItemLabel.appendChild(customItemCheckbox);
+ customItemLabel.appendChild(customItemName);
+
+ customListRow.appendChild(customItemLabel);
+ customList.appendChild((customListRow));
+ });
+
+ settingWrapper.appendChild(customList);
+ break;
+
+ default:
+ console.error(`Unknown setting type for ${this.name} setting ${settingName}: ${setting.type}`);
+ return;
+ }
+
+ settingsWrapper.appendChild(settingWrapper);
+ });
+
+ return settingsWrapper;
+ }
+
+ #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);
+ }
+ });
+
+ 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;
+ }
+
+ #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;
+ }
+
+ #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;
+ }
+
+ #itemDropHandler(evt) {
+ evt.preventDefault();
+ const sourceId = evt.dataTransfer.getData('text/plain');
+ const sourceDiv = document.getElementById(sourceId);
+
+ 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 itemDiv = newSetting === 'start_inventory' ? this.#buildItemQtyDiv(item) : this.#buildItemDiv(item);
+
+ 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(`${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);
+ }
+
+ // Remove the source drag object
+ sourceDiv.parentElement.removeChild(sourceDiv);
+
+ // Save the updated settings
+ this.save();
+ }
+
+ #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);
+
+ 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');
+ 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);
+
+ locationRow.appendChild(locationLabel);
+ locationHintsDiv.appendChild(locationRow);
+ });
+
+ locationHintsWrapper.appendChild(locationHintsDiv);
+ itemHintsContainer.appendChild(locationHintsWrapper);
+
+ hintsDiv.appendChild(itemHintsContainer);
+ return hintsDiv;
+ }
+
+ #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);
+
+ 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');
+ 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);
+
+ locationRow.appendChild(locationLabel);
+ excludeLocationsDiv.appendChild(locationRow);
+ });
+
+ excludeLocationsWrapper.appendChild(excludeLocationsDiv);
+ locationsContainer.appendChild(excludeLocationsWrapper);
+
+ locationsDiv.appendChild(locationsContainer);
+ return locationsDiv;
+ }
+
+ #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 {
+ this.current[setting][option] = parseInt(evt.target.value, 10);
+ }
+ this.save();
+ }
+
+ #updateListSetting(evt) {
+ 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 (this.current[setting].includes(option)) { return; }
+
+ 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; }
+
+ this.current[setting].splice(this.current[setting].indexOf(option), 1);
+ }
+ this.save();
+ }
+
+ #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);
+ }
+ this.save();
+ }
+
+ // Saves the current settings to local storage.
+ save() {
+ this.#allSettings.save();
+ }
+}
+
+/** Create an anchor and trigger a download of a text file. */
+const download = (filename, text) => {
+ const downloadLink = document.createElement('a');
+ downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
+ downloadLink.setAttribute('download', filename);
+ downloadLink.style.display = 'none';
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+ document.body.removeChild(downloadLink);
+};
diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js
deleted file mode 100644
index 6e86d470f0..0000000000
--- a/WebHostLib/static/assets/weighted-settings.js
+++ /dev/null
@@ -1,1219 +0,0 @@
-window.addEventListener('load', () => {
- fetchSettingData().then((results) => {
- let settingHash = localStorage.getItem('weighted-settings-hash');
- if (!settingHash) {
- // If no hash data has been set before, set it now
- settingHash = md5(JSON.stringify(results));
- localStorage.setItem('weighted-settings-hash', settingHash);
- localStorage.removeItem('weighted-settings');
- }
-
- if (settingHash !== md5(JSON.stringify(results))) {
- const userMessage = document.getElementById('user-message');
- userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " +
- "them all to default.";
- userMessage.classList.add('visible');
- userMessage.addEventListener('click', resetSettings);
- }
-
- // Page setup
- createDefaultSettings(results);
- buildUI(results);
- updateVisibleGames();
- adjustHeaderWidth();
-
- // Event listeners
- document.getElementById('export-settings').addEventListener('click', () => exportSettings());
- document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
- document.getElementById('generate-game').addEventListener('click', () => generateGame());
-
- // Name input field
- const weightedSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const nameInput = document.getElementById('player-name');
- nameInput.setAttribute('data-type', 'data');
- nameInput.setAttribute('data-setting', 'name');
- nameInput.addEventListener('keyup', updateBaseSetting);
- nameInput.value = weightedSettings.name;
- });
-});
-
-const resetSettings = () => {
- localStorage.removeItem('weighted-settings');
- localStorage.removeItem('weighted-settings-hash')
- window.location.reload();
-};
-
-const fetchSettingData = () => new Promise((resolve, reject) => {
- fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => {
- try{ response.json().then((jsonObj) => resolve(jsonObj)); }
- catch(error){ reject(error); }
- });
-});
-
-const createDefaultSettings = (settingData) => {
- if (!localStorage.getItem('weighted-settings')) {
- const newSettings = {};
-
- // Transfer base options directly
- for (let baseOption of Object.keys(settingData.baseOptions)){
- newSettings[baseOption] = settingData.baseOptions[baseOption];
- }
-
- // Set options per game
- for (let game of Object.keys(settingData.games)) {
- // Initialize game object
- newSettings[game] = {};
-
- // Transfer game settings
- for (let gameSetting of Object.keys(settingData.games[game].gameSettings)){
- newSettings[game][gameSetting] = {};
-
- const setting = settingData.games[game].gameSettings[gameSetting];
- switch(setting.type){
- case 'select':
- setting.options.forEach((option) => {
- newSettings[game][gameSetting][option.value] =
- (setting.hasOwnProperty('defaultValue') && setting.defaultValue === option.value) ? 25 : 0;
- });
- break;
- case 'range':
- case 'special_range':
- newSettings[game][gameSetting]['random'] = 0;
- newSettings[game][gameSetting]['random-low'] = 0;
- newSettings[game][gameSetting]['random-high'] = 0;
- if (setting.hasOwnProperty('defaultValue')) {
- newSettings[game][gameSetting][setting.defaultValue] = 25;
- } else {
- newSettings[game][gameSetting][setting.min] = 25;
- }
- break;
-
- case 'items-list':
- case 'locations-list':
- case 'custom-list':
- newSettings[game][gameSetting] = setting.defaultValue;
- break;
-
- default:
- console.error(`Unknown setting type for ${game} setting ${gameSetting}: ${setting.type}`);
- }
- }
-
- newSettings[game].start_inventory = {};
- newSettings[game].exclude_locations = [];
- newSettings[game].priority_locations = [];
- newSettings[game].local_items = [];
- newSettings[game].non_local_items = [];
- newSettings[game].start_hints = [];
- newSettings[game].start_location_hints = [];
- }
-
- localStorage.setItem('weighted-settings', JSON.stringify(newSettings));
- }
-};
-
-const buildUI = (settingData) => {
- // Build the game-choice div
- buildGameChoice(settingData.games);
-
- const gamesWrapper = document.getElementById('games-wrapper');
- Object.keys(settingData.games).forEach((game) => {
- // Create game div, invisible by default
- const gameDiv = document.createElement('div');
- gameDiv.setAttribute('id', `${game}-div`);
- gameDiv.classList.add('game-div');
- gameDiv.classList.add('invisible');
-
- const gameHeader = document.createElement('h2');
- gameHeader.innerText = game;
- gameDiv.appendChild(gameHeader);
-
- const collapseButton = document.createElement('a');
- collapseButton.innerText = '(Collapse)';
- gameDiv.appendChild(collapseButton);
-
- const expandButton = document.createElement('a');
- expandButton.innerText = '(Expand)';
- expandButton.classList.add('invisible');
- gameDiv.appendChild(expandButton);
-
- settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
- settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
-
- const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings,
- settingData.games[game].gameItems, settingData.games[game].gameLocations);
- gameDiv.appendChild(weightedSettingsDiv);
-
- const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems);
- gameDiv.appendChild(itemPoolDiv);
-
- const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations);
- gameDiv.appendChild(hintsDiv);
-
- const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations);
- gameDiv.appendChild(locationsDiv);
-
- gamesWrapper.appendChild(gameDiv);
-
- collapseButton.addEventListener('click', () => {
- collapseButton.classList.add('invisible');
- weightedSettingsDiv.classList.add('invisible');
- itemPoolDiv.classList.add('invisible');
- hintsDiv.classList.add('invisible');
- expandButton.classList.remove('invisible');
- });
-
- expandButton.addEventListener('click', () => {
- collapseButton.classList.remove('invisible');
- weightedSettingsDiv.classList.remove('invisible');
- itemPoolDiv.classList.remove('invisible');
- hintsDiv.classList.remove('invisible');
- expandButton.classList.add('invisible');
- });
- });
-};
-
-const buildGameChoice = (games) => {
- const settings = JSON.parse(localStorage.getItem('weighted-settings'));
- const gameChoiceDiv = document.getElementById('game-choice');
- const h2 = document.createElement('h2');
- h2.innerText = 'Game Select';
- gameChoiceDiv.appendChild(h2);
-
- const gameSelectDescription = document.createElement('p');
- gameSelectDescription.classList.add('setting-description');
- gameSelectDescription.innerText = 'Choose which games you might be required to play.';
- gameChoiceDiv.appendChild(gameSelectDescription);
-
- const hintText = document.createElement('p');
- hintText.classList.add('hint-text');
- hintText.innerText = 'If a game\'s value is greater than zero, you can click it\'s name to jump ' +
- 'to that section.'
- gameChoiceDiv.appendChild(hintText);
-
- // Build the game choice table
- const table = document.createElement('table');
- const tbody = document.createElement('tbody');
-
- Object.keys(games).forEach((game) => {
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- const span = document.createElement('span');
- span.innerText = game;
- span.setAttribute('id', `${game}-game-option`)
- tdLeft.appendChild(span);
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.setAttribute('data-type', 'weight');
- range.setAttribute('data-setting', 'game');
- range.setAttribute('data-option', game);
- range.value = settings.game[game];
- range.addEventListener('change', (evt) => {
- updateBaseSetting(evt);
- updateVisibleGames(); // Show or hide games based on the new settings
- });
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `game-${game}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
- tbody.appendChild(tr);
- });
-
- table.appendChild(tbody);
- gameChoiceDiv.appendChild(table);
-};
-
-const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => {
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const settingsWrapper = document.createElement('div');
- settingsWrapper.classList.add('settings-wrapper');
-
- Object.keys(settings).forEach((settingName) => {
- const setting = settings[settingName];
- const settingWrapper = document.createElement('div');
- settingWrapper.classList.add('setting-wrapper');
-
- const settingNameHeader = document.createElement('h4');
- settingNameHeader.innerText = setting.displayName;
- settingWrapper.appendChild(settingNameHeader);
-
- const settingDescription = document.createElement('p');
- settingDescription.classList.add('setting-description');
- settingDescription.innerText = setting.description.replace(/(\n)/g, ' ');
- settingWrapper.appendChild(settingDescription);
-
- switch(setting.type){
- case 'select':
- const optionTable = document.createElement('table');
- const tbody = document.createElement('tbody');
-
- // Add a weight range for each option
- setting.options.forEach((option) => {
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- tdLeft.innerText = option.name;
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', option.value);
- range.setAttribute('data-type', setting.type);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][option.value];
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${option.value}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
-
- tbody.appendChild(tr);
- });
-
- optionTable.appendChild(tbody);
- settingWrapper.appendChild(optionTable);
- break;
-
- case 'range':
- case 'special_range':
- const rangeTable = document.createElement('table');
- const rangeTbody = document.createElement('tbody');
-
- 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 tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('id', `${game}-${settingName}-${i}-range`);
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', i);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][i] || 0;
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
-
- 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]}`;
- });
- }
-
- 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')); }
- });
-
- 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; }
-
- 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', `${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);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${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;
- range.dispatchEvent(new Event('change'));
- rangeTbody.removeChild(tr);
- });
- tdDelete.appendChild(deleteButton);
- tr.appendChild(tdDelete);
-
- rangeTbody.appendChild(tr);
-
- // Save new option to settings
- range.dispatchEvent(new Event('change'));
- });
-
- 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; }
-
- 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', `${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);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${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);
- });
- }
-
- ['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;
-
- 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');
- }
-
- const itemName = document.createElement('span');
- itemName.innerText = item.toString();
-
- itemLabel.appendChild(itemCheckbox);
- itemLabel.appendChild(itemName);
-
- itemRow.appendChild(itemLabel);
- itemsList.appendChild((itemRow));
- });
-
- settingWrapper.appendChild(itemsList);
- break;
-
- 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 locationName = document.createElement('span');
- locationName.innerText = location.toString();
-
- locationLabel.appendChild(locationCheckbox);
- locationLabel.appendChild(locationName);
-
- locationRow.appendChild(locationLabel);
- locationsList.appendChild((locationRow));
- });
-
- settingWrapper.appendChild(locationsList);
- break;
-
- 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');
- }
-
- const customItemName = document.createElement('span');
- customItemName.innerText = listItem.toString();
-
- customItemLabel.appendChild(customItemCheckbox);
- customItemLabel.appendChild(customItemName);
-
- customListRow.appendChild(customItemLabel);
- customList.appendChild((customListRow));
- });
-
- settingWrapper.appendChild(customList);
- break;
-
- default:
- console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`);
- return;
- }
-
- settingsWrapper.appendChild(settingWrapper);
- });
-
- return settingsWrapper;
-};
-
-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);
- }
- });
-
- 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;
-};
-
-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;
-};
-
-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);
-
- itemQtyDiv.addEventListener('dragstart', (evt) => {
- evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id'));
- });
- return itemQtyDiv;
-};
-
-const itemDragoverHandler = (evt) => {
- evt.preventDefault();
-};
-
-const 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 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);
-
- 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 (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);
- }
- }
- } 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);
-
- // Save the updated settings
- localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
-};
-
-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);
-
- const itemName = document.createElement('span');
- itemName.innerText = item;
- itemLabel.appendChild(itemName);
-
- 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);
-
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
-
- locationRow.appendChild(locationLabel);
- locationHintsDiv.appendChild(locationRow);
- });
-
- locationHintsWrapper.appendChild(locationHintsDiv);
- itemHintsContainer.appendChild(locationHintsWrapper);
-
- 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);
-
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
-
- 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);
-
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
-
- locationRow.appendChild(locationLabel);
- excludeLocationsDiv.appendChild(locationRow);
- });
-
- excludeLocationsWrapper.appendChild(excludeLocationsDiv);
- locationsContainer.appendChild(excludeLocationsWrapper);
-
- 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',
- });
- });
- } else {
- gameDiv.classList.add('invisible');
- gameOption.classList.remove('jump-link');
-
- }
- });
-};
-
-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);
- }
- 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; }
-
- options[game][setting].splice(options[game][setting].indexOf(option), 1);
- }
- localStorage.setItem('weighted-settings', JSON.stringify(options));
-};
-
-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));
-};
-
-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;
- }
-
- // 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;
- }
-
- // 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!';
- }
-
- // 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;
- }
-
- // 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) => {
- const downloadLink = document.createElement('a');
- downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
- downloadLink.setAttribute('download', filename);
- downloadLink.style.display = 'none';
- document.body.appendChild(downloadLink);
- downloadLink.click();
- document.body.removeChild(downloadLink);
-};
-
-const generateGame = (raceMode = false) => {
- 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 0000000000..8fb366b93f
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 0000000000..336dc5f77a
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 0000000000..1bf7df9fb7
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 0000000000..552707831a
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 0000000000..e7ebf40316
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 0000000000..3af9b20a16
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 0000000000..d1c0c6c9a0
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 0000000000..d2016116ea
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 0000000000..351be570d1
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 0000000000..2b067a6e44
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 0000000000..159fba37c9
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 0000000000..56bfd98c92
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 0000000000..40a5991ebb
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 0000000000..3753258458
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 0000000000..cdd95bb515
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 0000000000..b00e0c4758
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 0000000000..8a48e38e87
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 0000000000..f19dad952b
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 0000000000..ced928aa57
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 0000000000..e97f3db0d2
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 0000000000..25720306e5
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 0000000000..dfdfef4052
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 0000000000..c57899b270
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 0000000000..31507be5fe
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 0000000000..a2e7f5dc3e
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 0000000000..0272b4b738
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 0000000000..ec303498cc
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 0000000000..1c7ce9d6ab
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 0000000000..04d68d35dc
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 0000000000..f888fd518b
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 0000000000..dcf5fd72da
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 0000000000..b9f2f055c2
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 0000000000..f5c94e1aee
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 0000000000..f68e820397
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 0000000000..40899095fe
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 0000000000..1b9f8cf060
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 0000000000..5aef00a656
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 0000000000..4f7410d7ca
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 0000000000..bb39cf0bf8
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 0000000000..38f3615107
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 0000000000..0fba8ce574
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 0000000000..057a40f08e
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 0000000000..44d1bb9541
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 0000000000..972b828c75
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 0000000000..9d59826551
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 0000000000..a298fb57de
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 0000000000..f7f0524ac1
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 0000000000..9cbf339b10
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 0000000000..ff0a7b1af4
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 0000000000..8f5e09c6a5
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 0000000000..7097db05e6
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 0000000000..802c49a83d
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 0000000000..e568742e8a
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 202c43badd..96975553c1 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/player-settings.css b/WebHostLib/static/styles/player-options.css
similarity index 60%
rename from WebHostLib/static/styles/player-settings.css
rename to WebHostLib/static/styles/player-options.css
index 9ba47d5fd0..2f5481d285 100644
--- a/WebHostLib/static/styles/player-settings.css
+++ b/WebHostLib/static/styles/player-options.css
@@ -4,8 +4,9 @@ html{
background-size: 650px 650px;
}
-#player-settings{
- max-width: 1000px;
+#player-options{
+ box-sizing: border-box;
+ max-width: 1024px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
@@ -14,14 +15,14 @@ html{
color: #eeffeb;
}
-#player-settings #player-settings-button-row{
+#player-options #player-options-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
-#player-settings code{
+#player-options code{
background-color: #d9cd8e;
border-radius: 4px;
padding-left: 0.25rem;
@@ -29,7 +30,7 @@ html{
color: #000000;
}
-#player-settings #user-message{
+#player-options #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
@@ -39,12 +40,12 @@ html{
text-align: center;
}
-#player-settings #user-message.visible{
+#player-options #user-message.visible{
display: block;
cursor: pointer;
}
-#player-settings h1{
+#player-options h1{
font-size: 2.5rem;
font-weight: normal;
width: 100%;
@@ -52,7 +53,7 @@ html{
text-shadow: 1px 1px 4px #000000;
}
-#player-settings h2{
+#player-options h2{
font-size: 40px;
font-weight: normal;
width: 100%;
@@ -61,22 +62,22 @@ html{
text-shadow: 1px 1px 2px #000000;
}
-#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
+#player-options h3, #player-options h4, #player-options h5, #player-options h6{
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
-#player-settings input:not([type]){
+#player-options input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
-#player-settings input:not([type]):focus{
+#player-options input:not([type]):focus{
border: 1px solid #ffffff;
}
-#player-settings select{
+#player-options select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
@@ -84,72 +85,72 @@ html{
background-color: #ffffff;
}
-#player-settings #game-options, #player-settings #rom-options{
+#player-options #game-options, #player-options #rom-options{
display: flex;
flex-direction: row;
}
-#player-settings .left, #player-settings .right{
+#player-options .left, #player-options .right{
flex-grow: 1;
}
-#player-settings .left{
+#player-options .left{
margin-right: 10px;
}
-#player-settings .right{
+#player-options .right{
margin-left: 10px;
}
-#player-settings table{
+#player-options table{
margin-bottom: 30px;
width: 100%;
}
-#player-settings table .select-container{
+#player-options table .select-container{
display: flex;
flex-direction: row;
}
-#player-settings table .select-container select{
+#player-options table .select-container select{
min-width: 200px;
flex-grow: 1;
}
-#player-settings table select:disabled{
+#player-options table select:disabled{
background-color: lightgray;
}
-#player-settings table .range-container{
+#player-options table .range-container{
display: flex;
flex-direction: row;
}
-#player-settings table .range-container input[type=range]{
+#player-options table .range-container input[type=range]{
flex-grow: 1;
}
-#player-settings table .range-value{
+#player-options table .range-value{
min-width: 20px;
margin-left: 0.25rem;
}
-#player-settings table .special-range-container{
+#player-options table .special-range-container{
display: flex;
flex-direction: column;
}
-#player-settings table .special-range-wrapper{
+#player-options table .special-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
-#player-settings table .special-range-wrapper input[type=range]{
+#player-options table .special-range-wrapper input[type=range]{
flex-grow: 1;
}
-#player-settings table .randomize-button {
+#player-options table .randomize-button {
max-height: 24px;
line-height: 16px;
padding: 2px 8px;
@@ -159,36 +160,54 @@ html{
border-radius: 3px;
}
-#player-settings table .randomize-button.active {
+#player-options table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
-#player-settings table label{
+#player-options table .randomize-button[data-tooltip]::after {
+ left: unset;
+ right: 0;
+}
+
+#player-options table label{
display: block;
min-width: 200px;
margin-right: 4px;
cursor: default;
}
-#player-settings th, #player-settings td{
+#player-options th, #player-options td{
border: none;
padding: 3px;
font-size: 17px;
vertical-align: top;
}
-@media all and (max-width: 1000px), all and (orientation: portrait){
- #player-settings #game-options{
+@media all and (max-width: 1024px) {
+ #player-options {
+ border-radius: 0;
+ }
+
+ #player-options #game-options{
justify-content: flex-start;
flex-wrap: wrap;
}
- #player-settings .left, #player-settings .right{
- flex-grow: unset;
+ #player-options .left,
+ #player-options .right {
+ margin: 0;
+ }
+
+ #game-options table {
+ margin-bottom: 0;
}
#game-options table label{
display: block;
min-width: 200px;
}
+
+ #game-options table tr td {
+ width: 50%;
+ }
}
diff --git a/WebHostLib/static/styles/sc2wolTracker.css b/WebHostLib/static/styles/sc2wolTracker.css
index b68668ecf6..a7d8bd28c4 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 f86ab581ca..7396daa954 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/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css
index 0e00553c72..0cc2ede59f 100644
--- a/WebHostLib/static/styles/tracker.css
+++ b/WebHostLib/static/styles/tracker.css
@@ -55,16 +55,16 @@ table.dataTable thead{
font-family: LexendDeca-Regular, sans-serif;
}
-table.dataTable tbody{
+table.dataTable tbody, table.dataTable tfoot{
background-color: #dce2bd;
font-family: LexendDeca-Light, sans-serif;
}
-table.dataTable tbody tr:hover{
+table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{
background-color: #e2eabb;
}
-table.dataTable tbody td{
+table.dataTable tbody td, table.dataTable tfoot td{
padding: 4px 6px;
}
@@ -97,10 +97,14 @@ table.dataTable thead th.lower-row{
top: 46px;
}
-table.dataTable tbody td{
+table.dataTable tbody td, table.dataTable tfoot td{
border: 1px solid #bba967;
}
+table.dataTable tfoot td{
+ font-weight: bold;
+}
+
div.dataTables_scrollBody{
background-color: inherit !important;
}
diff --git a/WebHostLib/static/styles/weighted-settings.css b/WebHostLib/static/styles/weighted-options.css
similarity index 100%
rename from WebHostLib/static/styles/weighted-settings.css
rename to WebHostLib/static/styles/weighted-options.css
diff --git a/WebHostLib/templates/check.html b/WebHostLib/templates/check.html
index 04b51340b5..8a3da7db47 100644
--- a/WebHostLib/templates/check.html
+++ b/WebHostLib/templates/check.html
@@ -17,9 +17,9 @@
- {{ 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 e8fa7b152c..389a79d411 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) %}Choose the options you would like to play with! You may generate a single-player game from this page, - or download a settings file you can use to participate in a MultiWorld.
+ or download an options file you can use to participate in a MultiWorld.
- A more advanced settings configuration for all games can be found on the
- Weighted Settings page.
+ A more advanced options configuration for all games can be found on the
+ Weighted options page.
A list of all games you have generated can be found on the User Content Page.
@@ -39,8 +39,8 @@