Merge branch 'ArchipelagoMW:main' into instruction_patch_clean

This commit is contained in:
Silvris
2023-10-30 20:20:13 -05:00
committed by GitHub
479 changed files with 26487 additions and 14439 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

5
.gitignore vendored
View File

@@ -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/

View File

@@ -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)

9
BizHawkClient.py Normal file
View File

@@ -0,0 +1,9 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds._bizhawk.context import launch
if __name__ == "__main__":
launch()

View File

@@ -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

103
Fill.py
View File

@@ -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']:

View File

@@ -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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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):

64
Main.py
View File

@@ -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)}).')

View File

@@ -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]

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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:

252
Utils.py
View File

@@ -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\" <<Cloud>> {")
for region in other_regions:
uml.append(f"class \"{fmt(region)}\"")
uml.append("}")
uml.append("@startuml")
uml.append("hide circle")
uml.append("hide empty members")
if linetype_ortho:
uml.append("skinparam linetype ortho")
while regions:
if (current_region := regions.popleft()) not in seen:
seen.add(current_region)
visualize_region(current_region)
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
if show_other_regions:
visualize_other_regions()
uml.append("@enduml")
with open(file_name, "wt", encoding="utf-8") as f:
f.write("\n".join(uml))
class RepeatableChain:
def __init__(self, iterable: typing.Iterable):
self.iterable = iterable
def __iter__(self):
return itertools.chain.from_iterable(self.iterable)
def __bool__(self):
return any(sub_iterable for sub_iterable in self.iterable)
def __len__(self):
return sum(len(iterable) for iterable in self.iterable)

View File

@@ -13,15 +13,6 @@ import Utils
import settings
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
from WebHostLib import register, app as raw_app
from waitress import serve
from WebHostLib.models import db
from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
@@ -29,6 +20,9 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
def get_app():
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db
register()
app = raw_app
if os.path.exists(configpath) and not app.config["TESTING"]:
@@ -40,6 +34,7 @@ def get_app():
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
cache.init_app(app)
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
return app
@@ -120,6 +115,11 @@ if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.options import create as create_options_files
try:
update_sprites_lttp()
except Exception as e:
@@ -136,4 +136,5 @@ if __name__ == "__main__":
if app.config["DEBUG"]:
app.run(debug=True, port=app.config["PORT"])
else:
from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])

View File

@@ -49,11 +49,10 @@ app.config["PONY"] = {
'create_db': True
}
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
app.config["JSON_AS_ASCII"] = False
app.config["CACHE_TYPE"] = "SimpleCache"
app.config["HOST_ADDRESS"] = ""
cache = Cache(app)
cache = Cache()
Compress(app)

View File

@@ -3,8 +3,6 @@ from __future__ import annotations
import json
import logging
import multiprocessing
import os
import sys
import threading
import time
import typing
@@ -13,55 +11,7 @@ from datetime import timedelta, datetime
from pony.orm import db_session, select, commit
from Utils import restricted_loads
class CommonLocker():
"""Uses a file lock to signal that something is already running"""
lock_folder = "file_locks"
def __init__(self, lockname: str, folder=None):
if folder:
self.lock_folder = folder
os.makedirs(self.lock_folder, exist_ok=True)
self.lockname = lockname
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
class AlreadyRunningException(Exception):
pass
if sys.platform == 'win32':
class Locker(CommonLocker):
def __enter__(self):
try:
if os.path.exists(self.lockfile):
os.unlink(self.lockfile)
self.fp = os.open(
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
except OSError as e:
raise AlreadyRunningException() from e
def __exit__(self, _type, value, tb):
fp = getattr(self, "fp", None)
if fp:
os.close(self.fp)
os.unlink(self.lockfile)
else: # unix
import fcntl
class Locker(CommonLocker):
def __enter__(self):
try:
self.fp = open(self.lockfile, "wb")
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
raise AlreadyRunningException() from e
def __exit__(self, _type, value, tb):
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
self.fp.close()
from .locker import Locker, AlreadyRunningException
def launch_room(room: Room, config: dict):

View File

@@ -1,5 +1,6 @@
import zipfile
from typing import *
import base64
from typing import Union, Dict, Set, Tuple
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
@@ -24,13 +25,21 @@ def check():
if 'file' not in request.files:
flash('No file part')
else:
file = request.files['file']
options = get_yaml_data(file)
files = request.files.getlist('file')
options = get_yaml_data(files)
if isinstance(options, str):
flash(options)
else:
results, _ = roll_options(options)
return render_template("checkResult.html", results=results)
if len(options) > 1:
# offer combined file back
combined_yaml = "---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
for file_name, file_content in options.items())
combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode()
else:
combined_yaml = ""
return render_template("checkResult.html",
results=results, combined_yaml=combined_yaml)
return render_template("check.html")
@@ -39,30 +48,34 @@ def mysterycheck():
return redirect(url_for("check"), 301)
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {}
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
return 'No selected file'
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
for uploaded_file in files:
# if user does not select file, browser also
# submit an empty part without filename
if uploaded_file.filename == '':
return 'No selected file'
elif uploaded_file.filename in options:
return f'Conflicting files named {uploaded_file.filename} submitted'
elif uploaded_file and allowed_file(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"):
with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist()
with zipfile.ZipFile(uploaded_file, 'r') as zfile:
infolist = zfile.infolist()
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read()
else:
options = {file.filename: file.read()}
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return ("Uploaded data contained a rom file, "
"which is likely to contain copyrighted material. "
"Your file was deleted.")
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read()
else:
options[uploaded_file.filename] = uploaded_file.read()
if not options:
return "Did not find a .yaml file to process."
return options

View File

@@ -11,6 +11,7 @@ import socket
import threading
import time
import typing
import sys
import websockets
from pony.orm import commit, db_session, select
@@ -19,6 +20,7 @@ import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import restricted_loads, cache_argsless
from .locker import Locker
from .models import Command, GameDataPackage, Room, db
@@ -163,16 +165,21 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
db.generate_mapping(check_tables=False)
async def main():
if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext(static_server_data)
ctx.load(room_id)
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
gc.collect() # free intermediate objects used during setup
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
except OSError: # likely port in use
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server
@@ -198,16 +205,15 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
await ctx.shutdown_task
logging.info("Shutting down")
from .autolauncher import Locker
with Locker(room_id):
try:
asyncio.run(main())
except KeyboardInterrupt:
except (KeyboardInterrupt, SystemExit):
with db_session:
room = Room.get(id=room_id)
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
except:
except Exception:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1

View File

@@ -64,8 +64,8 @@ def generate(race=False):
if 'file' not in request.files:
flash('No file part')
else:
file = request.files['file']
options = get_yaml_data(file)
files = request.files.getlist('file')
options = get_yaml_data(files)
if isinstance(options, str):
flash(options)
else:

51
WebHostLib/locker.py Normal file
View File

@@ -0,0 +1,51 @@
import os
import sys
class CommonLocker:
"""Uses a file lock to signal that something is already running"""
lock_folder = "file_locks"
def __init__(self, lockname: str, folder=None):
if folder:
self.lock_folder = folder
os.makedirs(self.lock_folder, exist_ok=True)
self.lockname = lockname
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
class AlreadyRunningException(Exception):
pass
if sys.platform == 'win32':
class Locker(CommonLocker):
def __enter__(self):
try:
if os.path.exists(self.lockfile):
os.unlink(self.lockfile)
self.fp = os.open(
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
except OSError as e:
raise AlreadyRunningException() from e
def __exit__(self, _type, value, tb):
fp = getattr(self, "fp", None)
if fp:
os.close(self.fp)
os.unlink(self.lockfile)
else: # unix
import fcntl
class Locker(CommonLocker):
def __enter__(self):
try:
self.fp = open(self.lockfile, "wb")
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
raise AlreadyRunningException() from e
def __exit__(self, _type, value, tb):
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
self.fp.close()

View File

@@ -32,29 +32,46 @@ def page_not_found(err):
# Start Playing Page
@app.route('/start-playing')
@cache.cached()
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
# TODO for back compat. remove around 0.4.5
@app.route("/weighted-settings")
def weighted_settings():
return render_template(f"weighted-settings.html")
return redirect("weighted-options", 301)
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
@app.route("/weighted-options")
@cache.cached()
def weighted_options():
return render_template("weighted-options.html")
# TODO for back compat. remove around 0.4.5
@app.route("/games/<string:game>/player-settings")
def player_settings(game: str):
return redirect(url_for("player_options", game=game), 301)
# Player options pages
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
return render_template("player-options.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
@cache.cached()
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
@@ -64,21 +81,25 @@ def games():
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@cache.cached()
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
@cache.cached()
def tutorial_landing():
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
@cache.cached()
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
@cache.cached()
def terms(lang):
return render_template("glossary.html", lang=lang)
@@ -147,7 +168,7 @@ def host_room(room: UUID):
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
return send_from_directory(os.path.join(app.root_path, "static", "static"),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@@ -167,10 +188,11 @@ def get_datapackage():
@app.route('/index')
@app.route('/sitemap')
@cache.cached()
def get_sitemap():
available_games: List[Dict[str, Union[str, bool]]] = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page
available_games.append({ 'title': game, 'has_settings': has_settings })
return render_template("siteMap.html", games=available_games)

View File

@@ -25,7 +25,7 @@ def create():
return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
weighted_settings = {
weighted_options = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
"name": "Player",
@@ -36,13 +36,10 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items():
all_options: typing.Dict[str, Options.AssembleOptions] = {
**Options.per_game_common_options,
**world.option_definitions
}
all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints
# Generate JSON files for player-settings pages
player_settings = {
# Generate JSON files for player-options pages
player_options = {
"baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
"game": game_name,
@@ -120,17 +117,17 @@ def create():
}
else:
logging.debug(f"{option} not exported to Web Settings.")
logging.debug(f"{option} not exported to Web options.")
player_settings["gameOptions"] = game_options
player_options["gameOptions"] = game_options
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
json.dump(player_settings, f, indent=2, separators=(',', ': '))
with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
json.dump(player_options, f, indent=2, separators=(',', ': '))
if not world.hidden and world.web.settings_page is True:
# Add the random option to Choice, TextChoice, and Toggle settings
if not world.hidden and world.web.options_page is True:
# Add the random option to Choice, TextChoice, and Toggle options
for option in game_options.values():
if option["type"] == "select":
option["options"].append({"name": "Random", "value": "random"})
@@ -138,11 +135,11 @@ def create():
if not option["defaultValue"]:
option["defaultValue"] = "random"
weighted_settings["baseOptions"]["game"][game_name] = 0
weighted_settings["games"][game_name] = {}
weighted_settings["games"][game_name]["gameSettings"] = game_options
weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
weighted_options["baseOptions"]["game"][game_name] = 0
weighted_options["games"][game_name] = {}
weighted_options["games"][game_name]["gameSettings"] = game_options
weighted_options["games"][game_name]["gameItems"] = tuple(world.item_names)
weighted_options["games"][game_name]["gameLocations"] = tuple(world.location_names)
with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
json.dump(weighted_settings, f, indent=2, separators=(',', ': '))
with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
json.dump(weighted_options, f, indent=2, separators=(',', ': '))

View File

@@ -1,9 +1,9 @@
flask>=2.2.3
pony>=0.7.16; python_version <= '3.10'
pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11'
flask>=3.0.0
pony>=0.7.17
waitress>=2.1.2
Flask-Caching>=2.0.2
Flask-Compress>=1.13
Flask-Limiter>=3.3.0
bokeh>=3.1.1
Flask-Caching>=2.1.0
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.2.2; python_version >= '3.9'
markupsafe>=2.1.3

View File

@@ -2,13 +2,62 @@
## What is a randomizer?
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
A randomizer is a modification of a game which reorganizes the items required to progress through that game. A
normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized
game, you might first find item C, then A, then B.
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
the items they encounter in new and interesting ways.
This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they
play. Putting items in non-standard locations can require the player to think about the game world and the items they
encounter in new and interesting ways.
## What is a multiworld?
While a randomizer shuffles a game, a multiworld randomizer shuffles that game for multiple players. For example, in a
two player multiworld, players A and B each get their own randomized version of a game, called a world. In each
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
player B, the item will be sent to player B's world over the internet. This creates a cooperative experience, requiring
players to rely upon each other to complete their game.
## What does multi-game mean?
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago?
Yes. All of our supported games can be generated as single-player experiences both on the website and by installing
the Archipelago generator software. The fastest way to do this is on the website. Find the Supported Game you wish to
play, open the Settings Page, pick your settings, and click Generate Game.
## How do I get started?
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
including multiple games, and hosting multiworlds on the website for ease and convenience.
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now, there are quite a few common terms used
by the communities surrounding them. A list of Archipelago jargon and terms commonly used by the community can be
found in the [Glossary](/glossary/en).
## Does everyone need to be connected at the same time?
There are two different play-styles that are common for Archipelago multiworld sessions. These sessions can either
be considered synchronous (or "sync"), where everyone connects and plays at the same time, or asynchronous (or "async"),
where players connect and play at their own pace. The setup for both is identical. The difference in play-style is how
you and your friends choose to organize and play your multiworld. Most groups decide on the format before creating
their multiworld.
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
in that game belonging to other players are sent out automatically. This allows other players to continue to play
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
## What happens if an item is placed somewhere it is impossible to get?
@@ -17,53 +66,15 @@ is to ensure items necessary to complete the game will be accessible to the play
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
comfortable exploiting certain glitches in the game.
## What is a multi-world?
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
item will be sent to player B's world over the internet.
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
their game.
## What happens if a person has to leave early?
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
items in that game which belong to other players are sent out automatically, so other players can continue to play.
## What does multi-game mean?
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
players to randomize any of a number of supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment.
## Can I generate a single-player game with Archipelago?
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
the website is not required to generate them.
## How do I get started?
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our code on GitHub
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
The best way to get started is to take a look at our code on GitHub:
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
There you will find examples of games in the worlds folder
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
There, you will find examples of games in the `worlds` folder:
[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
You may also find developer documentation in the docs folder
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.

View File

@@ -1,41 +1,41 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-settings').getAttribute('data-game');
gameName = document.getElementById('player-options').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerText = gameName;
fetchSettingData().then((results) => {
let settingHash = localStorage.getItem(`${gameName}-hash`);
if (!settingHash) {
fetchOptionData().then((results) => {
let optionHash = localStorage.getItem(`${gameName}-hash`);
if (!optionHash) {
// If no hash data has been set before, set it now
settingHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, settingHash);
optionHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, optionHash);
localStorage.removeItem(gameName);
}
if (settingHash !== md5(JSON.stringify(results))) {
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
if (optionHash !== md5(JSON.stringify(results))) {
showUserMessage("Your options are out of date! Click here to update them! Be aware this will reset " +
"them all to default.");
document.getElementById('user-message').addEventListener('click', resetSettings);
document.getElementById('user-message').addEventListener('click', resetOptions);
}
// Page setup
createDefaultSettings(results);
createDefaultOptions(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
document.getElementById('export-options').addEventListener('click', () => exportOptions());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerSettings = JSON.parse(localStorage.getItem(gameName));
const playerOptions = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
nameInput.value = playerOptions.name;
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
@@ -43,13 +43,13 @@ window.addEventListener('load', () => {
})
});
const resetSettings = () => {
const resetOptions = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`)
window.location.reload();
};
const fetchSettingData = () => new Promise((resolve, reject) => {
const fetchOptionData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
@@ -60,54 +60,54 @@ const fetchSettingData = () => new Promise((resolve, reject) => {
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true);
ajax.send();
});
const createDefaultSettings = (settingData) => {
const createDefaultOptions = (optionData) => {
if (!localStorage.getItem(gameName)) {
const newSettings = {
const newOptions = {
[gameName]: {},
};
for (let baseOption of Object.keys(settingData.baseOptions)){
newSettings[baseOption] = settingData.baseOptions[baseOption];
for (let baseOption of Object.keys(optionData.baseOptions)){
newOptions[baseOption] = optionData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
for (let gameOption of Object.keys(optionData.gameOptions)){
newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newSettings));
localStorage.setItem(gameName, JSON.stringify(newOptions));
}
};
const buildUI = (settingData) => {
const buildUI = (optionData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(settingData.gameOptions).forEach((key, index) => {
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
else { rightGameOpts[key] = settingData.gameOptions[key]; }
Object.keys(optionData.gameOptions).forEach((key, index) => {
if (index < Object.keys(optionData.gameOptions).length / 2) { leftGameOpts[key] = optionData.gameOptions[key]; }
else { rightGameOpts[key] = optionData.gameOptions[key]; }
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
};
const buildOptionsTable = (settings, romOpts = false) => {
const currentSettings = JSON.parse(localStorage.getItem(gameName));
const buildOptionsTable = (options, romOpts = false) => {
const currentOptions = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(settings).forEach((setting) => {
Object.keys(options).forEach((option) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${settings[setting].displayName}: `;
label.setAttribute('for', setting);
label.textContent = `${options[option].displayName}: `;
label.setAttribute('for', option);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', settings[setting].description);
questionSpan.setAttribute('data-tooltip', options[option].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
@@ -120,36 +120,36 @@ const buildOptionsTable = (settings, romOpts = false) => {
const randomButton = document.createElement('button');
switch(settings[setting].type){
switch(options[option].type){
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', setting);
select.setAttribute('data-key', setting);
select.setAttribute('id', option);
select.setAttribute('data-key', option);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
settings[setting].options.forEach((opt) => {
options[option].options.forEach((opt) => {
const option = document.createElement('option');
option.setAttribute('value', opt.value);
option.innerText = opt.name;
if ((isNaN(currentSettings[gameName][setting]) &&
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
(opt.value === currentSettings[gameName][setting]))
if ((isNaN(currentOptions[gameName][option]) &&
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
(opt.value === currentOptions[gameName][option]))
{
option.selected = true;
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateGameSetting(event.target));
select.addEventListener('change', (event) => updateGameOption(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentSettings[gameName][setting] === 'random') {
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
@@ -163,30 +163,30 @@ const buildOptionsTable = (settings, romOpts = false) => {
let range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('data-key', setting);
range.setAttribute('min', settings[setting].min);
range.setAttribute('max', settings[setting].max);
range.value = currentSettings[gameName][setting];
range.setAttribute('data-key', option);
range.setAttribute('min', options[option].min);
range.setAttribute('max', options[option].max);
range.value = currentOptions[gameName][option];
range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
rangeVal.setAttribute('id', `${option}-value`);
rangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentSettings[gameName][setting] === 'random') {
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
@@ -200,11 +200,11 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Build the select element
let specialRangeSelect = document.createElement('select');
specialRangeSelect.setAttribute('data-key', setting);
Object.keys(settings[setting].value_names).forEach((presetName) => {
specialRangeSelect.setAttribute('data-key', option);
Object.keys(options[option].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].value_names[presetName];
presetOption.value = options[option].value_names[presetName];
const words = presetOption.innerText.split("_");
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
@@ -217,8 +217,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
customOption.value = 'custom';
customOption.selected = true;
specialRangeSelect.appendChild(customOption);
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) {
specialRangeSelect.value = Number(currentOptions[gameName][option]);
}
// Build range element
@@ -226,17 +226,17 @@ const buildOptionsTable = (settings, romOpts = false) => {
specialRangeWrapper.classList.add('special-range-wrapper');
let specialRange = document.createElement('input');
specialRange.setAttribute('type', 'range');
specialRange.setAttribute('data-key', setting);
specialRange.setAttribute('min', settings[setting].min);
specialRange.setAttribute('max', settings[setting].max);
specialRange.value = currentSettings[gameName][setting];
specialRange.setAttribute('data-key', option);
specialRange.setAttribute('min', options[option].min);
specialRange.setAttribute('max', options[option].max);
specialRange.value = currentOptions[gameName][option];
// Build rage value element
let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
specialRangeVal.setAttribute('id', `${option}-value`);
specialRangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
@@ -244,18 +244,18 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
// Configure range event handler
specialRange.addEventListener('change', (event) => {
// Update select element
specialRangeSelect.value =
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
(Object.values(options[option].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(specialRangeSelect);
@@ -266,12 +266,12 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, specialRange, specialRangeSelect)
);
if (currentSettings[gameName][setting] === 'random') {
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
specialRange.disabled = true;
specialRangeSelect.disabled = true;
@@ -281,7 +281,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
break;
default:
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`);
return;
}
@@ -311,37 +311,35 @@ const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
optionalSelectElement.disabled = true;
}
}
updateGameSetting(randomButton);
updateGameOption(active ? inputElement : randomButton);
};
const updateBaseSetting = (event) => {
const updateBaseOption = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameSetting = (settingElement) => {
const updateGameOption = (optionElement) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (settingElement.classList.contains('randomize-button')) {
if (optionElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][settingElement.getAttribute('data-key')] = 'random';
options[gameName][optionElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
settingElement.value : parseInt(settingElement.value, 10);
options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ?
optionElement.value : parseInt(optionElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportSettings = () => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
const exportOptions = () => {
const options = JSON.parse(localStorage.getItem(gameName));
if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
@@ -357,14 +355,14 @@ const download = (filename, text) => {
};
const generateGame = (raceMode = false) => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
const options = JSON.parse(localStorage.getItem(gameName));
if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: settings },
presetData: { player: settings },
weights: { player: options },
presetData: { player: options },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',

View File

@@ -0,0 +1,64 @@
window.addEventListener('load', () => {
// Add toggle listener to all elements with .collapse-toggle
const toggleButtons = document.querySelectorAll('.collapse-toggle');
toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse));
// Handle game filter input
const gameSearch = document.getElementById('game-search');
gameSearch.value = '';
gameSearch.addEventListener('input', (evt) => {
if (!evt.target.value.trim()) {
// If input is empty, display all collapsed games
return toggleButtons.forEach((header) => {
header.style.display = null;
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
});
}
// Loop over all the games
toggleButtons.forEach((header) => {
// If the game name includes the search string, display the game. If not, hide it
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
header.style.display = null;
header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
} else {
header.style.display = 'none';
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
}
});
});
document.getElementById('expand-all').addEventListener('click', expandAll);
document.getElementById('collapse-all').addEventListener('click', collapseAll);
});
const toggleCollapse = (evt) => {
const gameArrow = evt.target.firstElementChild;
const gameInfo = evt.target.nextElementSibling;
if (gameInfo.classList.contains('collapsed')) {
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
};
const expandAll = () => {
document.querySelectorAll('.collapse-toggle').forEach((header) => {
if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
});
};
const collapseAll = () => {
document.querySelectorAll('.collapse-toggle').forEach((header) => {
if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
});
};

View File

@@ -14,6 +14,17 @@ const adjustTableHeight = () => {
}
};
/**
* Convert an integer number of seconds into a human readable HH:MM format
* @param {Number} seconds
* @returns {string}
*/
const secondsToHours = (seconds) => {
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
return `${hours}:${minutes}`;
};
window.addEventListener('load', () => {
const tables = $(".table").DataTable({
paging: false,
@@ -27,7 +38,18 @@ window.addEventListener('load', () => {
stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
},
footerCallback: function(tfoot, data, start, end, display) {
if (tfoot) {
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
}
},
columnDefs: [
{
targets: 'last-activity',
name: 'lastActivity'
},
{
targets: 'hours',
render: function (data, type, row) {
@@ -40,11 +62,7 @@ window.addEventListener('load', () => {
if (data === "None")
return data;
let hours = Math.floor(data / 3600);
let minutes = Math.floor((data - (hours * 3600)) / 60);
if (minutes < 10) {minutes = "0"+minutes;}
return hours+':'+minutes;
return secondsToHours(data);
}
},
{
@@ -114,11 +132,16 @@ window.addEventListener('load', () => {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear();
old_table.rows.add(new_trs).draw();
if (footer_tr.length) {
$(old_table.table).find("tfoot").html(footer_tr);
}
old_table.rows.add(new_trs);
old_table.draw();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -235,9 +235,6 @@ html{
line-height: 30px;
}
#landing .variable{
color: #ffff00;
}
.landing-deco{
position: absolute;

View File

@@ -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%;
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -17,9 +17,9 @@
</p>
<div id="check-form-wrapper">
<form id="check-form" method="post" enctype="multipart/form-data">
<input id="file-input" type="file" name="file">
<input id="file-input" type="file" name="file" multiple>
</form>
<button id="check-button">Upload</button>
<button id="check-button">Upload File(s)</button>
</div>
</div>
</div>

View File

@@ -28,6 +28,10 @@
{% endfor %}
</tbody>
</table>
{% if combined_yaml %}
<h1>Combined File Download</h1>
<p><a href="data:text/yaml;base64,{{ combined_yaml }}" download="combined.yaml">Download</a></p>
{% endif %}
</div>
</div>
{% endblock %}

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