Compare commits

..

5 Commits

Author SHA1 Message Date
Fabian Dill
62b3fd4d37 WebHost: invert multitracker control back to webhost 2023-08-28 17:18:13 +02:00
Fabian Dill
e2f7153312 Factorio: convert multitracker to Template once, instead of per render 2023-08-28 13:52:56 +02:00
Fabian Dill
96d4143030 WebHost: move new API hooks to WebWorld 2023-08-28 13:49:14 +02:00
Fabian Dill
a1dcaf52e3 WebHost: offer API to modify WebHost 2023-08-28 01:37:50 +02:00
Fabian Dill
aab8f31345 Factorio: fix website multitracker 2023-08-28 01:08:19 +02:00
745 changed files with 23724 additions and 97810 deletions

View File

@@ -54,9 +54,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-subtests pytest-xdist pip install pytest pytest-subtests
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests - name: Unittests
run: | run: |
pytest -n auto pytest

7
.gitignore vendored
View File

@@ -9,14 +9,12 @@
*.apmc *.apmc
*.apz5 *.apz5
*.aptloz *.aptloz
*.apemerald
*.pyc *.pyc
*.pyd *.pyd
*.sfc *.sfc
*.z64 *.z64
*.n64 *.n64
*.nes *.nes
*.smc
*.sms *.sms
*.gb *.gb
*.gbc *.gbc
@@ -29,20 +27,16 @@
*.archipelago *.archipelago
*.apsave *.apsave
*.BIN *.BIN
*.puml
setups setups
build build
bundle/components.wxs bundle/components.wxs
dist dist
/prof/
README.html README.html
.vs/ .vs/
EnemizerCLI/ EnemizerCLI/
/Players/ /Players/
/SNI/ /SNI/
/sni-*/
/appimagetool*
/host.yaml /host.yaml
/options.yaml /options.yaml
/config.yaml /config.yaml
@@ -145,7 +139,6 @@ ipython_config.py
.venv* .venv*
env/ env/
venv/ venv/
/venv*/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/

View File

@@ -115,12 +115,11 @@ class AdventureContext(CommonContext):
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved": elif cmd == "Retrieved":
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]: self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] if self.freeincarnates_used is None:
if self.freeincarnates_used is None: self.freeincarnates_used = 0
self.freeincarnates_used = 0 self.freeincarnates_used += self.freeincarnate_pending
self.freeincarnates_used += self.freeincarnate_pending self.send_pending_freeincarnates()
self.send_pending_freeincarnates()
elif cmd == "SetReply": elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used": if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"] self.freeincarnates_used = args["value"]

View File

@@ -1,15 +1,13 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
import itertools
import functools import functools
import logging import logging
import random import random
import secrets import secrets
import typing # this can go away when Python 3.8 support is dropped import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace from argparse import Namespace
from collections import Counter, deque from collections import ChainMap, Counter, deque
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
Type, ClassVar Type, ClassVar
@@ -48,6 +46,7 @@ class ThreadBarrierProxy:
class MultiWorld(): class MultiWorld():
debug_types = False debug_types = False
player_name: Dict[int, str] player_name: Dict[int, str]
_region_cache: Dict[int, Dict[str, Region]]
difficulty_requirements: dict difficulty_requirements: dict
required_medallions: dict required_medallions: dict
dark_room_logic: Dict[int, str] dark_room_logic: Dict[int, str]
@@ -57,7 +56,7 @@ class MultiWorld():
plando_connections: List plando_connections: List
worlds: Dict[int, auto_world] worlds: Dict[int, auto_world]
groups: Dict[int, Group] groups: Dict[int, Group]
regions: RegionManager regions: List[Region]
itempool: List[Item] itempool: List[Item]
is_race: bool = False is_race: bool = False
precollected_items: Dict[int, List[Item]] precollected_items: Dict[int, List[Item]]
@@ -92,39 +91,6 @@ class MultiWorld():
def __getitem__(self, player) -> bool: def __getitem__(self, player) -> bool:
return self.rule(player) 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 add_group(self, new_id: int):
self.region_cache[new_id] = {}
self.entrance_cache[new_id] = {}
self.location_cache[new_id] = {}
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): def __init__(self, players: int):
# world-local random state is saved for multiple generations running concurrently # world-local random state is saved for multiple generations running concurrently
self.random = ThreadBarrierProxy(random.Random()) self.random = ThreadBarrierProxy(random.Random())
@@ -133,12 +99,16 @@ class MultiWorld():
self.glitch_triforce = False self.glitch_triforce = False
self.algorithm = 'balanced' self.algorithm = 'balanced'
self.groups = {} self.groups = {}
self.regions = self.RegionManager(players) self.regions = []
self.shops = [] self.shops = []
self.itempool = [] self.itempool = []
self.seed = None self.seed = None
self.seed_name: str = "Unavailable" self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids} 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.required_locations = []
self.light_world_light_cone = False self.light_world_light_cone = False
self.dark_world_light_cone = False self.dark_world_light_cone = False
@@ -166,6 +136,7 @@ class MultiWorld():
def set_player_attr(attr, val): def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('_region_cache', {})
set_player_attr('shuffle', "vanilla") set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches") set_player_attr('logic', "noglitches")
set_player_attr('mode', 'open') set_player_attr('mode', 'open')
@@ -209,6 +180,7 @@ class MultiWorld():
set_player_attr('plando_connections', []) set_player_attr('plando_connections', [])
set_player_attr('game', "A Link to the Past") set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True) set_player_attr('completion_condition', lambda state: True)
self.custom_data = {}
self.worlds = {} self.worlds = {}
self.per_slot_randoms = {} self.per_slot_randoms = {}
self.plando_options = PlandoOptions.none self.plando_options = PlandoOptions.none
@@ -225,11 +197,19 @@ class MultiWorld():
return group_id, group return group_id, group
new_id: int = self.players + len(self.groups) + 1 new_id: int = self.players + len(self.groups) + 1
self.regions.add_group(new_id)
self.game[new_id] = game self.game[new_id] = game
self.custom_data[new_id] = {}
self.player_types[new_id] = NetUtils.SlotType.group self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game] world_type = AutoWorld.AutoWorldRegister.world_types[game]
self.worlds[new_id] = world_type.create_group(self, new_id, players) 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].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id]) self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
self.player_name[new_id] = name self.player_name[new_id] = name
@@ -252,28 +232,25 @@ class MultiWorld():
range(1, self.players + 1)} range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None: def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses for option_key in Options.common_options:
all_keys: Set[str] = {key for player in self.player_ids for key in setattr(self, option_key, getattr(args, option_key, {}))
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints} for option_key in Options.per_game_common_options:
for option_key in all_keys: setattr(self, option_key, getattr(args, option_key, {}))
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.")
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
for player in self.player_ids: for player in self.player_ids:
self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[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] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player] self.worlds[player].random = self.per_slot_randoms[player]
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.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): def set_item_links(self):
item_links = {} item_links = {}
replacement_prio = [False, True, None] replacement_prio = [False, True, None]
for player in self.player_ids: for player in self.player_ids:
for item_link in self.worlds[player].options.item_links.value: for item_link in self.item_links[player].value:
if item_link["name"] in item_links: if item_link["name"] in item_links:
if item_links[item_link["name"]]["game"] != self.game[player]: if item_links[item_link["name"]]["game"] != self.game[player]:
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}") raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
@@ -328,6 +305,14 @@ class MultiWorld():
group["non_local_items"] = item_link["non_local_items"] group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]] 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): def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom()) self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True self.is_race = True
@@ -336,15 +321,11 @@ class MultiWorld():
def player_ids(self) -> Tuple[int, ...]: def player_ids(self) -> Tuple[int, ...]:
return tuple(range(1, self.players + 1)) return tuple(range(1, self.players + 1))
@Utils.cache_self1 @functools.lru_cache()
def get_game_players(self, game_name: str) -> Tuple[int, ...]: 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) return tuple(player for player in self.player_ids if self.game[player] == game_name)
@Utils.cache_self1 @functools.lru_cache()
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): def get_game_worlds(self, game_name: str):
return tuple(world for player, world in self.worlds.items() if return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name) player not in self.groups and self.game[player] == game_name)
@@ -362,21 +343,50 @@ class MultiWorld():
""" the base name (without file extension) for each player's output file for a seed """ """ 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(' ', '_')}" 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 @functools.cached_property
def world_name_lookup(self): def world_name_lookup(self):
return {self.player_name[player_id]: player_id for player_id in self.player_ids} return {self.player_name[player_id]: player_id for player_id in self.player_ids}
def get_regions(self, player: Optional[int] = None) -> Collection[Region]: def _recache(self):
return self.regions if player is None else self.regions.region_cache[player].values() """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_region(self, region_name: str, player: int) -> Region: for r_location in region.locations:
return self.regions.region_cache[player][region_name] self._location_cache[r_location.name, player] = r_location
def get_entrance(self, entrance_name: str, player: int) -> Entrance: def get_regions(self, player=None):
return self.regions.entrance_cache[player][entrance_name] return self.regions if player is None else self._region_cache[player].values()
def get_location(self, location_name: str, player: int) -> Location: def get_region(self, regionname: str, player: int) -> Region:
return self.regions.location_cache[player][location_name] 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_all_state(self, use_cache: bool) -> CollectionState: def get_all_state(self, use_cache: bool) -> CollectionState:
cached = getattr(self, "_all_state", None) cached = getattr(self, "_all_state", None)
@@ -437,22 +447,28 @@ class MultiWorld():
logging.debug('Placed %s at %s', item, location) logging.debug('Placed %s at %s', item, location)
def get_entrances(self, player: Optional[int] = None) -> Iterable[Entrance]: def get_entrances(self) -> List[Entrance]:
if player is not None: if self._cached_entrances is None:
return self.regions.entrance_cache[player].values() self._cached_entrances = [entrance for region in self.regions for entrance in region.entrances]
return Utils.RepeatableChain(tuple(self.regions.entrance_cache[player].values() return self._cached_entrances
for player in self.regions.entrance_cache))
def clear_entrance_cache(self):
self._cached_entrances = None
def register_indirect_condition(self, region: Region, entrance: Entrance): def register_indirect_condition(self, region: Region, entrance: Entrance):
"""Report that access to this Region can result in unlocking this 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.""" state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance) self.indirect_connections.setdefault(region, set()).add(entrance)
def get_locations(self, player: Optional[int] = None) -> Iterable[Location]: 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]
if player is not None: if player is not None:
return self.regions.location_cache[player].values() return [location for location in self._cached_locations if location.player == player]
return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values() return self._cached_locations
for player in self.regions.location_cache))
def clear_location_cache(self):
self._cached_locations = None
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]: 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] return [location for location in self.get_locations(player) if location.item is None]
@@ -474,17 +490,16 @@ class MultiWorld():
valid_locations = [location.name for location in self.get_unfilled_locations(player)] valid_locations = [location.name for location in self.get_unfilled_locations(player)]
else: else:
valid_locations = location_names valid_locations = location_names
relevant_cache = self.regions.location_cache[player]
for location_name in valid_locations: for location_name in valid_locations:
location = relevant_cache.get(location_name, None) location = self._location_cache.get((location_name, player), None)
if location and location.item is None: if location is not None and location.item is None:
yield location yield location
def unlocks_new_location(self, item: Item) -> bool: def unlocks_new_location(self, item: Item) -> bool:
temp_state = self.state.copy() temp_state = self.state.copy()
temp_state.collect(item, True) temp_state.collect(item, True)
for location in self.get_unfilled_locations(item.player): for location in self.get_unfilled_locations():
if temp_state.can_reach(location) and not self.state.can_reach(location): if temp_state.can_reach(location) and not self.state.can_reach(location):
return True return True
@@ -496,7 +511,7 @@ class MultiWorld():
else: else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool: def can_beat_game(self, starting_state: Optional[CollectionState] = None):
if starting_state: if starting_state:
if self.has_beaten_game(starting_state): if self.has_beaten_game(starting_state):
return True return True
@@ -509,7 +524,7 @@ class MultiWorld():
and location.item.advancement and location not in state.locations_checked} and location.item.advancement and location not in state.locations_checked}
while prog_locations: while prog_locations:
sphere: Set[Location] = set() sphere = set()
# build up spheres of collection radius. # build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations: for location in prog_locations:
@@ -529,19 +544,12 @@ class MultiWorld():
return False return False
def get_spheres(self) -> Iterator[Set[Location]]: def get_spheres(self):
"""
yields a set of locations for each logical sphere
If there are unreachable locations, the last sphere of reachable
locations is followed by an empty set, and then a set of all of the
unreachable locations.
"""
state = CollectionState(self) state = CollectionState(self)
locations = set(self.get_filled_locations()) locations = set(self.get_filled_locations())
while locations: while locations:
sphere: Set[Location] = set() sphere = set()
for location in locations: for location in locations:
if location.can_reach(state): if location.can_reach(state):
@@ -623,7 +631,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState(): class CollectionState():
prog_items: Dict[int, Counter[str]] prog_items: typing.Counter[Tuple[str, int]]
multiworld: MultiWorld multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]] reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]] blocked_connections: Dict[int, Set[Entrance]]
@@ -635,7 +643,7 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld): def __init__(self, parent: MultiWorld):
self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.prog_items = Counter()
self.multiworld = parent self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()} self.blocked_connections = {player: set() for player in parent.get_all_ids()}
@@ -651,39 +659,39 @@ class CollectionState():
def update_reachable_regions(self, player: int): def update_reachable_regions(self, player: int):
self.stale[player] = False self.stale[player] = False
reachable_regions = self.reachable_regions[player] rrp = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player] bc = self.blocked_connections[player]
queue = deque(self.blocked_connections[player]) queue = deque(self.blocked_connections[player])
start = self.multiworld.get_region("Menu", player) start = self.multiworld.get_region('Menu', player)
# init on first call - this can't be done on construction since the regions don't exist yet # init on first call - this can't be done on construction since the regions don't exist yet
if start not in reachable_regions: if start not in rrp:
reachable_regions.add(start) rrp.add(start)
blocked_connections.update(start.exits) bc.update(start.exits)
queue.extend(start.exits) queue.extend(start.exits)
# run BFS on all connections, and keep track of those blocked by missing items # run BFS on all connections, and keep track of those blocked by missing items
while queue: while queue:
connection = queue.popleft() connection = queue.popleft()
new_region = connection.connected_region new_region = connection.connected_region
if new_region in reachable_regions: if new_region in rrp:
blocked_connections.remove(connection) bc.remove(connection)
elif connection.can_reach(self): elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
reachable_regions.add(new_region) rrp.add(new_region)
blocked_connections.remove(connection) bc.remove(connection)
blocked_connections.update(new_region.exits) bc.update(new_region.exits)
queue.extend(new_region.exits) queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None)) self.path[new_region] = (new_region.name, self.path.get(connection, None))
# Retry connections if the new region can unblock them # Retry connections if the new region can unblock them
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
if new_entrance in blocked_connections and new_entrance not in queue: if new_entrance in bc and new_entrance not in queue:
queue.append(new_entrance) queue.append(new_entrance)
def copy(self) -> CollectionState: def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld) ret = CollectionState(self.multiworld)
ret.prog_items = copy.deepcopy(self.prog_items) ret.prog_items = self.prog_items.copy()
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions} self.reachable_regions}
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
@@ -726,43 +734,37 @@ class CollectionState():
assert isinstance(event.item, Item), "tried to collect Event with no Item" assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(event.item, True, event) self.collect(event.item, True, event)
# item name related
def has(self, item: str, player: int, count: int = 1) -> bool: def has(self, item: str, player: int, count: int = 1) -> bool:
return self.prog_items[player][item] >= count return self.prog_items[item, player] >= count
def has_all(self, items: Iterable[str], player: int) -> bool: def has_all(self, items: Set[str], player: int) -> bool:
"""Returns True if each item name of items is in state at least once.""" """Returns True if each item name of items is in state at least once."""
return all(self.prog_items[player][item] for item in items) return all(self.prog_items[item, player] for item in items)
def has_any(self, items: Iterable[str], player: int) -> bool: def has_any(self, items: Set[str], player: int) -> bool:
"""Returns True if at least one item name of items is in state at least once.""" """Returns True if at least one item name of items is in state at least once."""
return any(self.prog_items[player][item] for item in items) return any(self.prog_items[item, player] for item in items)
def count(self, item: str, player: int) -> int: def count(self, item: str, player: int) -> int:
return self.prog_items[player][item] return self.prog_items[item, player]
def item_count(self, item: str, player: int) -> int:
Utils.deprecate("Use count instead.")
return self.count(item, player)
# item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
found: int = 0 found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += player_prog_items[item_name] found += self.prog_items[item_name, player]
if found >= count: if found >= count:
return True return True
return False return False
def count_group(self, item_name_group: str, player: int) -> int: def count_group(self, item_name_group: str, player: int) -> int:
found: int = 0 found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += player_prog_items[item_name] found += self.prog_items[item_name, player]
return found return found
# Item related def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool: def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location: if location:
self.locations_checked.add(location) self.locations_checked.add(location)
@@ -770,7 +772,7 @@ class CollectionState():
changed = self.multiworld.worlds[item.player].collect(self, item) changed = self.multiworld.worlds[item.player].collect(self, item)
if not changed and event: if not changed and event:
self.prog_items[item.player][item.name] += 1 self.prog_items[item.name, item.player] += 1
changed = True changed = True
self.stale[item.player] = True self.stale[item.player] = True
@@ -837,88 +839,28 @@ class Region:
locations: List[Location] locations: List[Location]
entrance_type: ClassVar[Type[Entrance]] = Entrance 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): def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name self.name = name
self.entrances = [] self.entrances = []
self._exits = self.EntranceRegister(multiworld.regions) self.exits = []
self._locations = self.LocationRegister(multiworld.regions) self.locations = []
self.multiworld = multiworld self.multiworld = multiworld
self._hint_text = hint self._hint_text = hint
self.player = player 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: def can_reach(self, state: CollectionState) -> bool:
if state.stale[self.player]: if state.stale[self.player]:
state.update_reachable_regions(self.player) state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player] return self in state.reachable_regions[self.player]
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 @property
def hint_text(self) -> str: def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name return self._hint_text if self._hint_text else self.name
@@ -935,19 +877,19 @@ class Region:
""" """
Adds locations to the Region object, where location_type is your Location class and locations is a dict of Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address. location names to address.
:param locations: dictionary of locations to be created and added to this Region `{name: ID}` :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""" :param location_type: Location class to be used to create the locations with"""
if location_type is None: if location_type is None:
location_type = Location location_type = Location
for location, address in locations.items(): for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self)) self.locations.append(location_type(self.player, location, address, self))
def connect(self, connecting_region: Region, name: Optional[str] = None, def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type: rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
""" """
Connects this Region to another Region, placing the provided rule on the connection. 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 connecting_region: Region object to connect to path is `self -> exiting_region`
:param name: name of the connection being created :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""" :param rule: callable to determine access of this connection to go from self to the exiting_region"""
@@ -955,12 +897,11 @@ class Region:
if rule: if rule:
exit_.access_rule = rule exit_.access_rule = rule
exit_.connect(connecting_region) exit_.connect(connecting_region)
return exit_
def create_exit(self, name: str) -> Entrance: def create_exit(self, name: str) -> Entrance:
""" """
Creates and returns an Entrance object as an exit of this region. Creates and returns an Entrance object as an exit of this region.
:param name: name of the Entrance being created :param name: name of the Entrance being created
""" """
exit_ = self.entrance_type(self.player, name, self) exit_ = self.entrance_type(self.player, name, self)
@@ -1330,7 +1271,7 @@ class Spoiler:
def to_file(self, filename: str) -> None: def to_file(self, filename: str) -> None:
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
res = getattr(self.multiworld.worlds[player].options, option_key) res = getattr(self.multiworld, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key) display_name = getattr(option_obj, "display_name", option_key)
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n") outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
@@ -1348,7 +1289,8 @@ class Spoiler:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player]) outfile.write('Game: %s\n' % self.multiworld.game[player])
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items(): options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
for f_option, option in options.items():
write_option(f_option, option) write_option(f_option, option)
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)

View File

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

View File

@@ -1,6 +1,4 @@
from __future__ import annotations from __future__ import annotations
import copy
import logging import logging
import asyncio import asyncio
import urllib.parse import urllib.parse
@@ -244,7 +242,6 @@ class CommonContext:
self.watcher_event = asyncio.Event() self.watcher_event = asyncio.Event()
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package) self.update_data_package(network_data_package)
# execution # execution
@@ -380,13 +377,10 @@ class CommonContext:
def on_print_json(self, args: dict): def on_print_json(self, args: dict):
if self.ui: if self.ui:
# send copy to UI self.ui.print_json(args["data"])
self.ui.print_json(copy.deepcopy(args["data"])) else:
text = self.jsontotextparser(args["data"])
logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])), logger.info(text)
extra={"NoStream": True})
logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])),
extra={"NoFile": True})
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
"""For custom package handling in subclasses.""" """For custom package handling in subclasses."""
@@ -737,8 +731,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif 'InvalidGame' in errors: elif 'InvalidGame' in errors:
ctx.event_invalid_game() ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors: elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible. ' raise Exception('Server reported your client version as incompatible')
'This probably means you have to update.')
elif 'InvalidItemsHandling' in errors: elif 'InvalidItemsHandling' in errors:
raise Exception('The item handling flags requested by the client are not supported') raise Exception('The item handling flags requested by the client are not supported')
# last to check, recoverable problem # last to check, recoverable problem
@@ -759,7 +752,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()} ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.hint_points = args.get("hint_points", 0) ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"]) ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
msgs = [] msgs = []
if ctx.locations_checked: if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks", msgs.append({"cmd": "LocationChecks",
@@ -838,14 +830,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == "Retrieved": elif cmd == "Retrieved":
ctx.stored_data.update(args["keys"]) ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints()
elif cmd == "SetReply": elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"] ctx.stored_data[args["key"]] = args["value"]
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]: if args["key"].startswith("EnergyLink"):
ctx.ui.update_hints()
elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"] ctx.current_energy_link_value = args["value"]
if ctx.ui: if ctx.ui:
ctx.ui.set_new_energy_link_value() ctx.ui.set_new_energy_link_value()
@@ -888,7 +876,7 @@ def get_base_parser(description: typing.Optional[str] = None):
def run_as_textclient(): def run_as_textclient():
class TextContext(CommonContext): class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry # Text Mode to use !hint and such with games that have no text entry
tags = CommonContext.tags | {"TextOnly"} tags = {"AP", "TextOnly"}
game = "" # empty matches any game since 0.3.2 game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received items_handling = 0b111 # receive all items for /received
want_slot_data = False # Can't use game specific slot_data want_slot_data = False # Can't use game specific slot_data

140
Fill.py
View File

@@ -5,8 +5,6 @@ import typing
from collections import Counter, deque from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Options import Accessibility
from worlds.AutoWorld import call_all from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule from worlds.generic.Rules import add_item_rule
@@ -15,10 +13,6 @@ class FillError(RuntimeError):
pass 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: def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
new_state = base_state.copy() new_state = base_state.copy()
for item in itempool: for item in itempool:
@@ -30,7 +24,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], 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, 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, swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None: allow_partial: bool = False, allow_excluded: bool = False) -> None:
""" """
:param world: Multiworld to be filled. :param world: Multiworld to be filled.
:param base_state: State assumed before fill. :param base_state: State assumed before fill.
@@ -42,20 +36,16 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
:param on_place: callback that is called when a placement happens :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_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 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] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
cleanup_required = False cleanup_required = False
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter() swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {} reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool: for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item) 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: while any(reachable_items.values()) and locations:
# grab one item per player # grab one item per player
items_to_place = [items.pop() items_to_place = [items.pop()
@@ -80,7 +70,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill: typing.Optional[Location] = None spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable # if minimal accessibility, only check whether location is reachable if game not beatable
if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: if world.accessibility[item_to_place.player] == 'minimal':
perform_access_check = not world.has_beaten_game(maximum_exploration_state, perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \ item_to_place.player) \
if single_player_placement else not has_beaten_game if single_player_placement else not has_beaten_game
@@ -112,7 +102,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
location.item = None location.item = None
placed_item.location = None placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool) swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place # unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic # by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails. # to clean that up later, so there is a chance generation fails.
@@ -160,15 +150,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill.locked = lock spot_to_fill.locked = lock
placements.append(spot_to_fill) placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement spot_to_fill.event = item_to_place.advancement
placed += 1
if not placed % 1000:
_log_fill_progress(name, placed, total)
if on_place: if on_place:
on_place(spot_to_fill) on_place(spot_to_fill)
if total > 1000:
_log_fill_progress(name, placed, total)
if cleanup_required: if cleanup_required:
# validate all placements and remove invalid ones # validate all placements and remove invalid ones
state = sweep_from_pool(base_state, []) state = sweep_from_pool(base_state, [])
@@ -212,8 +196,6 @@ def remaining_fill(world: MultiWorld,
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
placed = 0
while locations and itempool: while locations and itempool:
item_to_place = itempool.pop() item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None spot_to_fill: typing.Optional[Location] = None
@@ -263,12 +245,6 @@ def remaining_fill(world: MultiWorld,
world.push_item(spot_to_fill, item_to_place, False) world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill) 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: if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them # There are leftover unplaceable items and locations that won't accept them
@@ -289,7 +265,7 @@ def fast_fill(world: MultiWorld,
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]): def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool) maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"} minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)] not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations: for location in unreachable_locations:
@@ -304,7 +280,7 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio
locations.append(location) locations.append(location)
if pool and locations: if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(world, state, locations, pool, name="Accessibility Corrections") fill_restrictive(world, state, locations, pool)
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations): def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
@@ -312,7 +288,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)] unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations: if unreachable_locations:
def forbid_important_item_rule(item: Item): def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal') return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
for location in unreachable_locations: for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule) add_item_rule(location, forbid_important_item_rule)
@@ -374,25 +350,23 @@ def distribute_early_items(world: MultiWorld,
player_local = early_local_rest_items[player] player_local = early_local_rest_items[player]
fill_restrictive(world, base_state, fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player], [loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}") player_local, lock=True, allow_partial=True)
if player_local: if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}") logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player]) early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item] 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 early_locations += early_priority_locations
for player in world.player_ids: for player in world.player_ids:
player_local = early_local_prog_items[player] player_local = early_local_prog_items[player]
fill_restrictive(world, base_state, fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player], [loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}") player_local, lock=True, allow_partial=True)
if player_local: if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}") logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local) early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item] 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 unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items: if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place " logging.warning("Ran out of early locations for early items. Failed to place "
@@ -446,14 +420,13 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
if prioritylocations: if prioritylocations:
# "priority fill" # "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) accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "advancement/progression fill" # "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression") fill_restrictive(world, world.state, defaultlocations, progitempool)
if progitempool: if progitempool:
raise FillError( raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
@@ -471,7 +444,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
raise FillError( raise FillError(
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items") f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
restitempool = filleritempool + usefulitempool restitempool = usefulitempool + filleritempool
remaining_fill(world, defaultlocations, restitempool) remaining_fill(world, defaultlocations, restitempool)
@@ -550,7 +523,7 @@ def flood_items(world: MultiWorld) -> None:
break break
def balance_multiworld_progression(multiworld: MultiWorld) -> None: def balance_multiworld_progression(world: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode." # A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm: # Overall progression balancing algorithm:
# Gather up all locations in a sphere. # Gather up all locations in a sphere.
@@ -558,28 +531,28 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres, # If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere. # which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = { balanceable_players: typing.Dict[int, float] = {
player: multiworld.worlds[player].options.progression_balancing / 100 player: world.progression_balancing[player] / 100
for player in multiworld.player_ids for player in world.player_ids
if multiworld.worlds[player].options.progression_balancing > 0 if world.progression_balancing[player] > 0
} }
if not balanceable_players: if not balanceable_players:
logging.info('Skipping multiworld progression balancing.') logging.info('Skipping multiworld progression balancing.')
else: else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.') logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players) logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld) state: CollectionState = CollectionState(world)
checked_locations: typing.Set[Location] = set() checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations()) unchecked_locations: typing.Set[Location] = set(world.get_locations())
total_locations_count: typing.Counter[int] = Counter( total_locations_count: typing.Counter[int] = Counter(
location.player location.player
for location in multiworld.get_locations() for location in world.get_locations()
if not location.locked if not location.locked
) )
reachable_locations_count: typing.Dict[int, int] = { reachable_locations_count: typing.Dict[int, int] = {
player: 0 player: 0
for player in multiworld.player_ids for player in world.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0 if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
} }
balanceable_players = { balanceable_players = {
player: balanceable_players[player] player: balanceable_players[player]
@@ -658,7 +631,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
balancing_unchecked_locations.remove(location) balancing_unchecked_locations.remove(location)
if not location.locked: if not location.locked:
balancing_reachables[location.player] += 1 balancing_reachables[location.player] += 1
if multiworld.has_beaten_game(balancing_state) or all( if world.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player] item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items() for player, reachables in balancing_reachables.items()
if player in threshold_percentages): if player in threshold_percentages):
@@ -675,7 +648,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
locations_to_test = unlocked_locations[player] locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player]) items_to_test = list(candidate_items[player])
items_to_test.sort() items_to_test.sort()
multiworld.random.shuffle(items_to_test) world.random.shuffle(items_to_test)
while items_to_test: while items_to_test:
testing = items_to_test.pop() testing = items_to_test.pop()
reducing_state = state.copy() reducing_state = state.copy()
@@ -687,8 +660,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
reducing_state.sweep_for_events(locations=locations_to_test) reducing_state.sweep_for_events(locations=locations_to_test)
if multiworld.has_beaten_game(balancing_state): if world.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state): if not world.has_beaten_game(reducing_state):
items_to_replace.append(testing) items_to_replace.append(testing)
else: else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test) reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
@@ -696,32 +669,33 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if p < threshold_percentages[player]: if p < threshold_percentages[player]:
items_to_replace.append(testing) items_to_replace.append(testing)
old_moved_item_count = moved_item_count replaced_items = False
# sort then shuffle to maintain deterministic behaviour, # sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere # while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked) replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
multiworld.random.shuffle(replacement_locations) world.random.shuffle(replacement_locations)
items_to_replace.sort() items_to_replace.sort()
multiworld.random.shuffle(items_to_replace) world.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks. # Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace: while replacement_locations and items_to_replace:
old_location = items_to_replace.pop() old_location = items_to_replace.pop()
for i, new_location in enumerate(replacement_locations): for new_location in replacement_locations:
if new_location.can_fill(state, old_location.item, False) and \ if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False): old_location.can_fill(state, new_location.item, False):
replacement_locations.pop(i) replacement_locations.remove(new_location)
swap_location_item(old_location, new_location) swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, " logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}") f"displacing {old_location.item} into {old_location}")
moved_item_count += 1 moved_item_count += 1
state.collect(new_location.item, True, new_location) state.collect(new_location.item, True, new_location)
replaced_items = True
break break
else: else:
logging.warning(f"Could not Progression Balance {old_location.item}") logging.warning(f"Could not Progression Balance {old_location.item}")
if old_moved_item_count < moved_item_count: if replaced_items:
logging.debug(f"Moved {moved_item_count} items so far\n") logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]} unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked): for location in get_sphere_locations(state, unlocked):
@@ -735,7 +709,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
state.collect(location.item, True, location) state.collect(location.item, True, location)
checked_locations |= sphere_locations checked_locations |= sphere_locations
if multiworld.has_beaten_game(state): if world.has_beaten_game(state):
break break
elif not sphere_locations: elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.") logging.warning("Progression Balancing ran out of paths.")
@@ -779,6 +753,8 @@ def distribute_planned(world: MultiWorld) -> None:
else: # not reachable with swept state else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name) 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 world_name_lookup = world.world_name_lookup
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
@@ -791,9 +767,6 @@ def distribute_planned(world: MultiWorld) -> None:
block['force'] = 'silent' block['force'] = 'silent'
if 'from_pool' not in block: if 'from_pool' not in block:
block['from_pool'] = True block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block: if 'world' not in block:
target_world = False target_world = False
else: else:
@@ -867,14 +840,14 @@ def distribute_planned(world: MultiWorld) -> None:
if "early_locations" in locations: if "early_locations" in locations:
locations.remove("early_locations") locations.remove("early_locations")
for target_player in worlds: for player in worlds:
locations += early_locations[target_player] locations += early_locations[player]
if "non_early_locations" in locations: if "non_early_locations" in locations:
locations.remove("non_early_locations") locations.remove("non_early_locations")
for target_player in worlds: for player in worlds:
locations += non_early_locations[target_player] locations += non_early_locations[player]
block['locations'] = list(dict.fromkeys(locations)) block['locations'] = locations
if not block['count']: if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if block['count'] = (min(len(block['items']), len(block['locations'])) if
@@ -924,22 +897,23 @@ def distribute_planned(world: MultiWorld) -> None:
for item_name in items: for item_name in items:
item = world.worlds[player].create_item(item_name) item = world.worlds[player].create_item(item_name)
for location in reversed(candidates): for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None if location in key_drop_data:
if not location.item: warn(
if location.item_rule(item): f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
if location.can_fill(world.state, item, False): continue
successful_pairs.append((item, location)) if not location.item:
candidates.remove(location) if location.item_rule(item):
count = count + 1 if location.can_fill(world.state, item, False):
break successful_pairs.append((item, location))
else: candidates.remove(location)
err.append(f"Can't place item at {location} due to fill condition not met.") count = count + 1
break
else: else:
err.append(f"{item_name} not allowed at {location}.") err.append(f"Can't place item at {location} due to fill condition not met.")
else: else:
err.append(f"Cannot place {item_name} into already filled location {location}.") err.append(f"{item_name} not allowed at {location}.")
else: else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.") err.append(f"Cannot place {item_name} into already filled location {location}.")
if count == maxcount: if count == maxcount:
break break
if count < placement['count']['min']: if count < placement['count']['min']:

View File

@@ -7,8 +7,8 @@ import random
import string import string
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections import Counter from collections import ChainMap, Counter
from typing import Any, Dict, Tuple, Union from typing import Any, Callable, Dict, Tuple, Union
import ModuleUpdate import ModuleUpdate
@@ -20,7 +20,7 @@ import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions from BaseClasses import seeddigits, get_seed, PlandoOptions
from Main import main as ERmain from Main import main as ERmain
from settings import get_settings from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
from worlds.alttp import Options as LttPOptions from worlds.alttp import Options as LttPOptions
from worlds.alttp.EntranceRandomizer import parse_arguments from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable from worlds.alttp.Text import TextTable
@@ -53,9 +53,6 @@ def mystery_argparse():
help='List of options that can be set manually. Can be combined, for example "bosses, items"') help='List of options that can be set manually. Can be combined, for example "bosses, items"')
parser.add_argument("--skip_prog_balancing", action="store_true", parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.") help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"Intended for debugging and testing purposes.")
args = parser.parse_args() args = parser.parse_args()
if not os.path.isabs(args.weights_file_path): if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
@@ -130,13 +127,6 @@ def main(args=None, callback=ERmain):
player_id += 1 player_id += 1
args.multi = max(player_id - 1, args.multi) args.multi = max(player_id - 1, args.multi)
if args.multi == 0:
raise ValueError(
"No individual player files found and number of players is 0. "
"Provide individual player files or specify the number of players via host.yaml or --multi."
)
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, " logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
f"{seed_name} Seed {seed} with plando: {args.plando}") f"{seed_name} Seed {seed} with plando: {args.plando}")
@@ -153,7 +143,6 @@ def main(args=None, callback=ERmain):
erargs.outputname = seed_name erargs.outputname = seed_name
erargs.outputpath = args.outputpath erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
@@ -168,8 +157,7 @@ def main(args=None, callback=ERmain):
for yaml in weights_cache[path]: for yaml in weights_cache[path]:
if category_name is None: if category_name is None:
for category in yaml: for category in yaml:
if category in AutoWorldRegister.world_types and \ if category in AutoWorldRegister.world_types and key in Options.common_options:
key in Options.CommonOptions.type_hints:
yaml[category][key] = option yaml[category][key] = option
elif category_name not in yaml: elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.") logging.warning(f"Meta: Category {category_name} is not present in {path}.")
@@ -180,7 +168,7 @@ def main(args=None, callback=ERmain):
for player in range(1, args.multi + 1): for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path) player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter() name_counter = Counter()
erargs.player_options = {} erargs.player_settings = {}
player = 1 player = 1
while player <= args.multi: while player <= args.multi:
@@ -236,7 +224,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: with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f) yaml.dump(important, f)
return callback(erargs, seed) callback(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]: def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -352,7 +340,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types: if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game] game_world = AutoWorldRegister.world_types[game]
options = game_world.options_dataclass.type_hints options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
if option_key in options: if option_key in options:
if options[option_key].supports_weighting: if options[option_key].supports_weighting:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
@@ -457,8 +445,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
f"which is not enabled.") f"which is not enabled.")
ret = argparse.Namespace() ret = argparse.Namespace()
for option_key in Options.PerGameCommonOptions.type_hints: for option_key in Options.per_game_common_options:
if option_key in weights and option_key not in Options.CommonOptions.type_hints: if option_key in weights and option_key not in Options.common_options:
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.") raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights) ret.game = get_choice("game", weights)
@@ -478,11 +466,16 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
game_weights = weights[ret.game] game_weights = weights[ret.game]
ret.name = get_choice('name', weights) ret.name = get_choice('name', weights)
for option_key, option in Options.CommonOptions.type_hints.items(): for option_key, option in Options.common_options.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option, plando_options) 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: if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", []) ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time": if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
@@ -650,15 +643,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if __name__ == '__main__': if __name__ == '__main__':
import atexit import atexit
confirmation = atexit.register(input, "Press enter to close.") confirmation = atexit.register(input, "Press enter to close.")
multiworld = main() 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 # in case of error-free exit should not need confirmation
atexit.unregister(confirmation) atexit.unregister(confirmation)

View File

@@ -1,8 +1,894 @@
import os
import asyncio
import ModuleUpdate import ModuleUpdate
import json
import Utils import Utils
from worlds.kh2.Client import launch from pymem import pymem
from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
from worlds.kh2.WorldLocations import *
from worlds import network_data_package
if __name__ == "__main__":
Utils.init_logging("KH2Client", exception_logger="Client")
from NetUtils import ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
ModuleUpdate.update() ModuleUpdate.update()
kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
# class KH2CommandProcessor(ClientCommandProcessor):
class KH2Context(CommonContext):
# command_processor: int = KH2CommandProcessor
game = "Kingdom Hearts 2"
items_handling = 0b101 # Indicates you get items sent from other worlds.
def __init__(self, server_address, password):
super(KH2Context, self).__init__(server_address, password)
self.kh2LocalItems = None
self.ability = None
self.growthlevel = None
self.KH2_sync_task = None
self.syncing = False
self.kh2connected = False
self.serverconneced = False
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
item_dictionary_table.items() if data.code}
self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
all_locations.items() if data.code}
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
self.location_table = {}
self.collectible_table = {}
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.sending = []
# list used to keep track of locations+items player has. Used for disoneccting
self.kh2seedsave = None
self.slotDataProgressionNames = {}
self.kh2seedname = None
self.kh2slotdata = None
self.itemamount = {}
# sora equipped, valor equipped, master equipped, final equipped
self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
if "localappdata" in os.environ:
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
self.amountOfPieces = 0
# hooked object
self.kh2 = None
self.ItemIsSafe = False
self.game_connected = False
self.finalxemnas = False
self.worldid = {
# 1: {}, # world of darkness (story cutscenes)
2: TT_Checks,
# 3: {}, # destiny island doesn't have checks to ima put tt checks here
4: HB_Checks,
5: BC_Checks,
6: Oc_Checks,
7: AG_Checks,
8: LoD_Checks,
9: HundredAcreChecks,
10: PL_Checks,
11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
12: DC_Checks,
13: TR_Checks,
14: HT_Checks,
15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
16: PR_Checks,
17: SP_Checks,
18: TWTNW_Checks,
# 255: {}, # starting screen
}
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
self.sveroom = 0x2A09C00 + 0x41
# 0 not in battle 1 in yellow battle 2 red battle #short
self.inBattle = 0x2A0EAC4 + 0x40
self.onDeath = 0xAB9078
# PC Address anchors
self.Now = 0x0714DB8
self.Save = 0x09A70B0
self.Sys3 = 0x2A59DF0
self.Bt10 = 0x2A74880
self.BtlEnd = 0x2A0D3E0
self.Slot1 = 0x2A20C98
self.chest_set = set(exclusion_table["Chests"])
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
self.equipment_categories = CheckDupingItems["Equipment"]
self.armor_set = set(self.equipment_categories["Armor"])
self.accessories_set = set(self.equipment_categories["Accessories"])
self.all_equipment = self.armor_set.union(self.accessories_set)
self.Equipment_Anchor_Dict = {
"Armor": [0x2504, 0x2506, 0x2508, 0x250A],
"Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
self.AbilityQuantityDict = {}
self.ability_categories = CheckDupingItems["Abilities"]
self.sora_ability_set = set(self.ability_categories["Sora"])
self.donald_ability_set = set(self.ability_categories["Donald"])
self.goofy_ability_set = set(self.ability_categories["Goofy"])
self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
self.boost_set = set(CheckDupingItems["Boosts"])
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
# Growth:[level 1,level 4,slot]
self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA],
"Quick Run": [0x62, 0x65, 0x25DC],
"Dodge Roll": [0x234, 0x237, 0x25DE],
"Aerial Dodge": [0x066, 0x069, 0x25E0],
"Glide": [0x6A, 0x6D, 0x25E2]}
self.boost_to_anchor_dict = {
"Power Boost": 0x24F9,
"Magic Boost": 0x24FA,
"Defense Boost": 0x24FB,
"AP Boost": 0x24F8}
self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
self.bitmask_item_code = [
0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
, 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
, 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
, 0x13002A, 0x13002B, 0x13002C, 0x13002D]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(KH2Context, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
async def connection_closed(self):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname is not None and self.auth is not None:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).connection_closed()
async def disconnect(self, allow_autoreconnect: bool = False):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).disconnect()
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).shutdown()
def on_package(self, cmd: str, args: dict):
if cmd in {"RoomInfo"}:
self.kh2seedname = args['seed_name']
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
self.kh2seedsave = {"itemIndex": -1,
# back of soras invo is 0x25E2. Growth should be moved there
# Character: [back of invo, front of invo]
"SoraInvo": [0x25D8, 0x2546],
"DonaldInvo": [0x26F4, 0x2658],
"GoofyInvo": [0x280A, 0x276C],
"AmountInvo": {
"ServerItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0,
"Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
},
"LocalItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0, "Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
}},
# 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
"LocationsChecked": [],
"Levels": {
"SoraLevel": 0,
"ValorLevel": 0,
"WisdomLevel": 0,
"LimitLevel": 0,
"MasterLevel": 0,
"FinalLevel": 0,
},
"SoldEquipment": [],
"SoldBoosts": {"Power Boost": 0,
"Magic Boost": 0,
"Defense Boost": 0,
"AP Boost": 0}
}
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'wt') as f:
pass
self.locations_checked = set()
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
self.kh2seedsave = json.load(f)
self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
self.serverconneced = True
if cmd in {"Connected"}:
self.kh2slotdata = args['slot_data']
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
try:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
logger.info("You are now auto-tracking")
self.kh2connected = True
except Exception as e:
logger.info("Line 247")
if self.kh2connected:
logger.info("Connection Lost")
self.kh2connected = False
logger.info(e)
if cmd in {"ReceivedItems"}:
start_index = args["index"]
if start_index == 0:
# resetting everything that were sent from the server
self.kh2seedsave["SoraInvo"][0] = 0x25D8
self.kh2seedsave["DonaldInvo"][0] = 0x26F4
self.kh2seedsave["GoofyInvo"][0] = 0x280A
self.kh2seedsave["itemIndex"] = - 1
self.kh2seedsave["AmountInvo"]["ServerItems"] = {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0,
"Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
}
if start_index > self.kh2seedsave["itemIndex"]:
self.kh2seedsave["itemIndex"] = start_index
for item in args['items']:
asyncio.create_task(self.give_item(item.item))
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
new_locations = set(args["checked_locations"])
# TODO: make this take locations from other players on the same slot so proper coop happens
# items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
# location_id in self.kh2LocalItems.keys()]
self.checked_locations |= new_locations
async def checkWorldLocations(self):
try:
currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
if currentworldint in self.worldid:
curworldid = self.worldid[currentworldint]
for location, data in curworldid.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and (int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex) > 0:
self.sending = self.sending + [(int(locationId))]
except Exception as e:
logger.info("Line 285")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkLevels(self):
try:
for location, data in SoraLevels.items():
currentLevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and currentLevel >= data.bitIndex:
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
self.sending = self.sending + [(int(locationId))]
formDict = {
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
for i in range(5):
for location, data in formDict[i][1].items():
formlevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and formlevel >= data.bitIndex:
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
self.sending = self.sending + [(int(locationId))]
except Exception as e:
logger.info("Line 312")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkSlots(self):
try:
for location, data in weaponSlots.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") > 0:
self.sending = self.sending + [(int(locationId))]
for location, data in formSlots.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
# self.locations_checked
self.sending = self.sending + [(int(locationId))]
except Exception as e:
if self.kh2connected:
logger.info("Line 333")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyChests(self):
try:
for location in self.locations_checked:
locationName = self.lookup_id_to_Location[location]
if locationName in self.chest_set:
if locationName in self.location_name_to_worlddata.keys():
locationData = self.location_name_to_worlddata[locationName]
if int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") & 0x1 << locationData.bitIndex == 0:
roomData = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
1), "big")
self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
(roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
except Exception as e:
if self.kh2connected:
logger.info("Line 350")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyLevel(self):
for leveltype, anchor in {"SoraLevel": 0x24FF,
"ValorLevel": 0x32F6,
"WisdomLevel": 0x332E,
"LimitLevel": 0x3366,
"MasterLevel": 0x339E,
"FinalLevel": 0x33D6}.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
self.kh2seedsave["Levels"][leveltype]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
async def give_item(self, item, ItemType="ServerItems"):
try:
itemname = self.lookup_id_to_item[item]
itemcode = self.item_name_to_data[itemname]
if itemcode.ability:
abilityInvoType = 0
TwilightZone = 2
if ItemType == "LocalItems":
abilityInvoType = 1
TwilightZone = -2
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
return
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
# appending the slot that the ability should be in
if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
self.AbilityQuantityDict[itemname]:
if itemname in self.sora_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["SoraInvo"][abilityInvoType])
self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
elif itemname in self.donald_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["DonaldInvo"][abilityInvoType])
self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
else:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["GoofyInvo"][abilityInvoType])
self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
elif itemcode.code in self.bitmask_item_code:
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
elif itemname in self.all_equipment:
self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
elif itemname in self.all_weapons:
if itemname in self.keyblade_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
elif itemname in self.staff_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
else:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
elif itemname in self.boost_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
elif itemname in self.stat_increase_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
else:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
except Exception as e:
if self.kh2connected:
logger.info("Line 398")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class KH2Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago KH2 Client"
self.ui = KH2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def IsInShop(self, sellable, master_boost):
# journal = 0x741230 shop = 0x741320
# if journal=-1 and shop = 5 then in shop
# if journam !=-1 and shop = 10 then journal
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
# print("your in the shop")
sellable_dict = {}
for itemName in sellable:
itemdata = self.item_name_to_data[itemName]
amount = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
sellable_dict[itemName] = amount
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
await asyncio.sleep(0.5)
for item, amount in sellable_dict.items():
itemdata = self.item_name_to_data[item]
afterShop = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
if afterShop < amount:
if item in master_boost:
self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
else:
self.kh2seedsave["SoldEquipment"].append(item)
async def verifyItems(self):
try:
local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
master_amount = local_amount | server_amount
local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
master_ability = local_ability | server_ability
local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
master_bitmask = local_bitmask | server_bitmask
local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
master_keyblade = local_keyblade | server_keyblade
master_staff = local_staff | server_staff
master_shield = local_shield | server_shield
local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
master_equipment = local_equipment | server_equipment
local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
master_magic = local_magic | server_magic
local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
master_stat = local_stat | server_stat
local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
master_boost = local_boost | server_boost
master_sell = master_equipment | master_staff | master_shield | master_boost
await asyncio.create_task(self.IsInShop(master_sell, master_boost))
for itemName in master_amount:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
if itemName in server_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
if itemName == "Torn Page":
# Torn Pages are handled differently because they can be consumed.
# Will check the progression in 100 acre and - the amount of visits
# amountofitems-amount of visits done
for location, data in tornPageLocks.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
amountOfItems -= 1
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems and amountOfItems >= 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_keyblade:
itemData = self.item_name_to_data[itemName]
# if the inventory slot for that keyblade is less than the amount they should have
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
"big") != 13:
# Checking form anchors for the keyblade
if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
else:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_staff:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_shield:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_ability:
itemData = self.item_name_to_data[itemName]
ability_slot = []
if itemName in local_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
if itemName in server_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
for slot in ability_slot:
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current & 0x0FFF
if ability | 0x8000 != (0x8000 + itemData.memaddr):
if current - 0x8000 > 0:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
else:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
# removes the duped ability if client gave faster than the game.
for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
# remove the dummy level 1 growths if they are in these invo slots.
for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
ability = current & 0x0FFF
if 0x05E <= ability <= 0x06D:
self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
for itemName in self.master_growth:
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
+ self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
if growthLevel > 0:
slot = self.growth_values_dict[itemName][2]
min_growth = self.growth_values_dict[itemName][0]
max_growth = self.growth_values_dict[itemName][1]
if growthLevel > 4:
growthLevel = 4
current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current_growth_level & 0x0FFF
# if the player should be getting a growth ability
if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
# if it should be level one of that growth
if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
# if it is already in the inventory
elif ability | 0x8000 < (0x8000 + max_growth):
self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
for itemName in master_bitmask:
itemData = self.item_name_to_data[itemName]
itemMemory = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") & 0x1 << itemData.bitmask) == 0:
# when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
(0).to_bytes(1, 'big'), 1)
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
for itemName in master_equipment:
itemData = self.item_name_to_data[itemName]
isThere = False
if itemName in self.accessories_set:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
else:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
# Checking form anchors for the equipment
for slot in Equipment_Anchor_List:
if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
isThere = True
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
break
if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_magic:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
if itemName in server_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_stat:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
if itemName in server_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
# 0x130293 is Crit_1's location id for touching the computer
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
"big") >= 5 and int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
"big") > 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_boost:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
if itemName in server_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
amountOfBoostsInInvo = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big")
amountOfUsedBoosts = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
"big")
# Ap Boots start at +50 for some reason
if itemName == "AP Boost":
amountOfUsedBoosts -= 50
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][
itemName] and amountOfBoostsInInvo < 255:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
except Exception as e:
logger.info("Line 573")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def finishedGame(ctx: KH2Context, message):
if ctx.kh2slotdata['FinalXemnas'] == 1:
if 0x1301ED in message[0]["locations"]:
ctx.finalxemnas = True
# three proofs
if ctx.kh2slotdata['Goal'] == 0:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 1:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
ctx.kh2slotdata['LuckyEmblemsRequired']:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 2:
for boss in ctx.kh2slotdata["hitlist"]:
if boss in message[0]["locations"]:
ctx.amountOfPieces += 1
if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
async def kh2_watcher(ctx: KH2Context):
while not ctx.exit_event.is_set():
try:
if ctx.kh2connected and ctx.serverconneced:
ctx.sending = []
await asyncio.create_task(ctx.checkWorldLocations())
await asyncio.create_task(ctx.checkLevels())
await asyncio.create_task(ctx.checkSlots())
await asyncio.create_task(ctx.verifyChests())
await asyncio.create_task(ctx.verifyItems())
await asyncio.create_task(ctx.verifyLevel())
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
if finishedGame(ctx, message):
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
location_ids = []
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
for location in location_ids:
if location not in ctx.locations_checked:
ctx.locations_checked.add(location)
ctx.kh2seedsave["LocationsChecked"].append(location)
if location in ctx.kh2LocalItems:
item = ctx.kh2slotdata["LocalItems"][str(location)]
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
await ctx.send_msgs(message)
elif not ctx.kh2connected and ctx.serverconneced:
logger.info("Game is not open. Disconnecting from Server.")
await ctx.disconnect()
except Exception as e:
logger.info("Line 661")
if ctx.kh2connected:
logger.info("Connection Lost.")
ctx.kh2connected = False
logger.info(e)
await asyncio.sleep(0.5)
if __name__ == '__main__': if __name__ == '__main__':
Utils.init_logging("KH2Client", exception_logger="Client") async def main(args):
launch() ctx = KH2Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
kh2_watcher(ctx), name="KH2ProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
import colorama
parser = get_base_parser(description="KH2 Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -50,22 +50,17 @@ def open_host_yaml():
def open_patch(): def open_patch():
suffixes = [] suffixes = []
for c in components: for c in components:
if c.type == Type.CLIENT and \ if isfile(get_exe(c)[-1]):
isinstance(c.file_identifier, SuffixIdentifier) and \ suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
(c.script_name is None or isfile(get_exe(c)[-1])): isinstance(c.file_identifier, SuffixIdentifier) else []
suffixes += c.file_identifier.suffixes
try: try:
filename = open_filename("Select patch", (("Patches", suffixes),)) filename = open_filename('Select patch', (('Patches', suffixes),))
except Exception as e: except Exception as e:
messagebox("Error", str(e), error=True) messagebox('Error', str(e), error=True)
else: else:
file, component = identify(filename) file, component = identify(filename)
if file and component: if file and component:
exe = get_exe(component) launch([*get_exe(component), file], component.cli)
if exe is None or not isfile(exe[-1]):
exe = get_exe("Launcher")
launch([*exe, file], component.cli)
def generate_yamls(): def generate_yamls():
@@ -112,7 +107,7 @@ def identify(path: Union[None, str]):
return None, None return None, None
for component in components: for component in components:
if component.handles_file(path): if component.handles_file(path):
return path, component return path, component
elif path == component.display_name or path == component.script_name: elif path == component.display_name or path == component.script_name:
return None, component return None, component
return None, None return None, None
@@ -122,25 +117,25 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str): if isinstance(component, str):
name = component name = component
component = None component = None
if name.startswith("Archipelago"): if name.startswith('Archipelago'):
name = name[11:] name = name[11:]
if name.endswith(".exe"): if name.endswith('.exe'):
name = name[:-4] name = name[:-4]
if name.endswith(".py"): if name.endswith('.py'):
name = name[:-3] name = name[:-3]
if not name: if not name:
return None return None
for c in components: 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 component = c
break break
if not component: if not component:
return None return None
if is_frozen(): if is_frozen():
suffix = ".exe" if is_windows else "" suffix = '.exe' if is_windows else ''
return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None return [local_path(f'{component.frozen_name}{suffix}')]
else: else:
return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None return [sys.executable, local_path(f'{component.script_name}.py')]
def launch(exe, in_terminal=False): def launch(exe, in_terminal=False):

View File

@@ -23,7 +23,8 @@ from urllib.request import urlopen
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from worlds.alttp.Rom import LocalRom, apply_rom_settings, get_base_rom_bytes
from worlds.alttp.Sprites import Sprite
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
@@ -1004,7 +1005,6 @@ class SpriteSelector():
self.add_to_sprite_pool(sprite) self.add_to_sprite_pool(sprite)
def icon_section(self, frame_label, path, no_results_label): 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 = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
frame.pack(side=TOP, fill=X) frame.pack(side=TOP, fill=X)

View File

@@ -58,7 +58,7 @@ class MMBN3CommandProcessor(ClientCommandProcessor):
class MMBN3Context(CommonContext): class MMBN3Context(CommonContext):
command_processor = MMBN3CommandProcessor command_processor = MMBN3CommandProcessor
game = "MegaMan Battle Network 3" game = "MegaMan Battle Network 3"
items_handling = 0b101 # full local except starting items items_handling = 0b001 # full local
def __init__(self, server_address, password): def __init__(self, server_address, password):
super().__init__(server_address, password) super().__init__(server_address, password)

89
Main.py
View File

@@ -13,8 +13,8 @@ import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
from Options import StartInventoryPool from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple
from settings import get_settings from settings import get_settings
from Utils import __version__, output_path, version_tuple
from worlds import AutoWorld from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -101,33 +101,20 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
del item_digits, location_digits, item_count, location_count del item_digits, location_digits, item_count, location_count
# This assertion method should not be necessary to run if we are not outputting any multidata. AutoWorld.call_stage(world, "assert_generate")
if not args.skip_output:
AutoWorld.call_stage(world, "assert_generate")
AutoWorld.call_all(world, "generate_early") AutoWorld.call_all(world, "generate_early")
logger.info('') logger.info('')
for player in world.player_ids: for player in world.player_ids:
for item_name, count in world.worlds[player].options.start_inventory.value.items(): for item_name, count in world.start_inventory[player].value.items():
for _ in range(count): for _ in range(count):
world.push_precollected(world.create_item(item_name, player)) world.push_precollected(world.create_item(item_name, player))
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items(): for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
for _ in range(count): for _ in range(count):
world.push_precollected(world.create_item(item_name, player)) world.push_precollected(world.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
early = world.early_items[player].get(item_name, 0)
if early:
world.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = world.early_local_items[player].get(item_name, 0)
if local_early:
world.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early
logger.info('Creating World.') logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions") AutoWorld.call_all(world, "create_regions")
@@ -135,48 +122,44 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Creating Items.') logger.info('Creating Items.')
AutoWorld.call_all(world, "create_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.') logger.info('Calculating Access Rules.')
for player in world.player_ids: for player in world.player_ids:
# items can't be both local and non-local, prefer local # items can't be both local and non-local, prefer local
world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value world.non_local_items[player].value -= world.local_items[player].value
world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player]) world.non_local_items[player].value -= set(world.local_early_items[player])
AutoWorld.call_all(world, "set_rules") AutoWorld.call_all(world, "set_rules")
for player in world.player_ids: for player in world.player_ids:
exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value) exclusion_rules(world, player, world.exclude_locations[player].value)
world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value world.priority_locations[player].value -= world.exclude_locations[player].value
for location_name in world.worlds[player].options.priority_locations.value: for location_name in world.priority_locations[player].value:
try: world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
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. # Set local and non-local item rules.
if world.players > 1: if world.players > 1:
locality_rules(world) locality_rules(world)
else: else:
world.worlds[1].options.non_local_items.value = set() world.non_local_items[1].value = set()
world.worlds[1].options.local_items.value = set() world.local_items[1].value = set()
AutoWorld.call_all(world, "generate_basic") AutoWorld.call_all(world, "generate_basic")
# remove starting inventory from pool items. # remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible. # Because some worlds don't actually create items during create_items this has to be as late as possible.
if any(getattr(world.worlds[player].options, "start_inventory_from_pool", StartInventoryPool({})).value for player in world.player_ids): if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
new_items: List[Item] = [] new_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = { depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(world.worlds[player].options, "start_inventory_from_pool", StartInventoryPool({})).value.copy() player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
for player in world.player_ids}
for player, items in depletion_pool.items(): for player, items in depletion_pool.items():
player_world: AutoWorld.World = world.worlds[player] player_world: AutoWorld.World = world.worlds[player]
for count in items.values(): for count in items.values():
for _ in range(count): new_items.append(player_world.create_filler())
new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values()) target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(world.itempool): for i, item in enumerate(world.itempool):
if depletion_pool[item.player].get(item.name, 0): if depletion_pool[item.player].get(item.name, 0):
@@ -196,7 +179,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if remaining_items: if remaining_items:
raise Exception(f"{world.get_player_name(player)}" raise Exception(f"{world.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}") 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 world.itempool[:] = new_items
# temporary home for item links, should be moved out of Main # temporary home for item links, should be moved out of Main
@@ -243,7 +225,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
region = Region("Menu", group_id, world, "ItemLink") region = Region("Menu", group_id, world, "ItemLink")
world.regions.append(region) world.regions.append(region)
locations = region.locations locations = region.locations = []
for item in world.itempool: for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0) count = common_item_count.get(item.player, {}).get(item.name, 0)
if count: if count:
@@ -277,9 +259,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)]) world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
if any(world.item_links.values()): if any(world.item_links.values()):
world._recache()
world._all_state = None world._all_state = None
logger.info("Running Item Plando.") logger.info("Running Item Plando")
distribute_planned(world) distribute_planned(world)
@@ -301,28 +284,24 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else: else:
logger.info("Progression balancing skipped.") logger.info("Progression balancing skipped.")
logger.info(f'Beginning output...')
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use # we're about to output using multithreading, so we're removing the global random state to prevent accidental use
world.random.passthrough = False world.random.passthrough = False
if args.skip_output:
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
return world
logger.info(f'Beginning output...')
outfilebase = 'AP_' + world.seed_name outfilebase = 'AP_' + world.seed_name
output = tempfile.TemporaryDirectory() output = tempfile.TemporaryDirectory()
with output as temp_dir: with output as temp_dir:
output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__ with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
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) check_accessibility_task = pool.submit(world.fulfills_accessibility)
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)] output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
for player in output_players: for player in world.player_ids:
# skip starting a thread for methods that say "pass". # skip starting a thread for methods that say "pass".
output_file_futures.append( if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
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 # collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {} er_hint_data: Dict[int, Dict[int, str]] = {}
@@ -371,16 +350,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
assert location.item.code is not None, "item code None should be event, " \ assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \ "location.address should then also be None. Location: " \
f" {location}" f" {location}"
assert location.address not in locations_data[location.player], (
f"Locations with duplicate address. {location} and "
f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \ locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags location.item.code, location.item.player, location.item.flags
if location.name in world.worlds[location.player].options.start_location_hints: if location.name in world.start_location_hints[location.player]:
precollect_hint(location) precollect_hint(location)
elif location.item.name in world.worlds[location.item.player].options.start_hints: elif location.item.name in world.start_hints[location.item.player]:
precollect_hint(location) precollect_hint(location)
elif any([location.item.name in world.worlds[player].options.start_hints elif any([location.item.name in world.start_hints[player]
for player in world.groups.get(location.item.player, {}).get("players", [])]): for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location) precollect_hint(location)
@@ -416,7 +392,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
f.write(bytes([3])) # version of format f.write(bytes([3])) # version of format
f.write(multidata) f.write(multidata)
output_file_futures.append(pool.submit(write_multidata)) multidata_task = pool.submit(write_multidata)
if not check_accessibility_task.result(): if not check_accessibility_task.result():
if not world.can_beat_game(): if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.") raise Exception("Game appears as unbeatable. Aborting.")
@@ -424,6 +400,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.warning("Location Accessibility requirements not fulfilled.") logger.warning("Location Accessibility requirements not fulfilled.")
# retrieve exceptions via .result() if they occurred. # retrieve exceptions via .result() if they occurred.
multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1): for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
if i % 10 == 0 or i == len(output_file_futures): if i % 10 == 0 or i == len(output_file_futures):
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).') logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')

View File

@@ -67,23 +67,14 @@ def update(yes=False, force=False):
install_pkg_resources(yes=yes) install_pkg_resources(yes=yes)
import pkg_resources import pkg_resources
prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files: for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file) path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path): if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), req_file) path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile: with open(path) as requirementsfile:
for line in requirementsfile: for line in requirementsfile:
if not line or line.lstrip(" \t")[0] == "#": if not line or line[0] == "#":
if not prev: continue # ignore comments
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://")): if line.startswith(("https://", "git+https://")):
# extract name and version for url # extract name and version for url
rest = line.split('/')[-1] rest = line.split('/')[-1]

View File

@@ -2,15 +2,14 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import collections
import copy import copy
import collections
import datetime import datetime
import functools import functools
import hashlib import hashlib
import inspect import inspect
import itertools import itertools
import logging import logging
import math
import operator import operator
import pickle import pickle
import random import random
@@ -68,25 +67,21 @@ def update_dict(dictionary, entries):
# functions callable on storable data on the server by clients # functions callable on storable data on the server by clients
modify_functions = { modify_functions = {
# generic:
"replace": lambda old, new: new,
"default": lambda old, new: old,
# numeric:
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append) "add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
"mul": operator.mul, "mul": operator.mul,
"pow": operator.pow,
"mod": operator.mod, "mod": operator.mod,
"floor": lambda value, _: math.floor(value),
"ceil": lambda value, _: math.ceil(value),
"max": max, "max": max,
"min": min, "min": min,
"replace": lambda old, new: new,
"default": lambda old, new: old,
"pow": operator.pow,
# bitwise: # bitwise:
"xor": operator.xor, "xor": operator.xor,
"or": operator.or_, "or": operator.or_,
"and": operator.and_, "and": operator.and_,
"left_shift": operator.lshift, "left_shift": operator.lshift,
"right_shift": operator.rshift, "right_shift": operator.rshift,
# lists/dicts: # lists/dicts
"remove": remove_from_list, "remove": remove_from_list,
"pop": pop_from_container, "pop": pop_from_container,
"update": update_dict, "update": update_dict,
@@ -417,8 +412,6 @@ class Context:
self.player_name_lookup[slot_info.name] = 0, slot_id self.player_name_lookup[slot_info.name] = 0, slot_id
self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \ self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
list(self.get_rechecked_hints(local_team, local_player)) list(self.get_rechecked_hints(local_team, local_player))
self.read_data[f"client_status_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
self.client_game_state[local_team, local_player]
self.seed_name = decoded_obj["seed_name"] self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name) self.random.seed(self.seed_name)
@@ -714,12 +707,6 @@ class Context:
"hint_points": get_slot_points(self, team, slot) "hint_points": get_slot_points(self, team, slot)
}]) }])
def on_client_status_change(self, team: int, slot: int):
key: str = f"_read_client_status_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.client_game_state[team, slot]}])
def update_aliases(ctx: Context, team: int): def update_aliases(ctx: Context, team: int):
cmd = ctx.dumper([{"cmd": "RoomUpdate", cmd = ctx.dumper([{"cmd": "RoomUpdate",
@@ -1827,7 +1814,6 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
ctx.on_goal_achieved(client) ctx.on_goal_achieved(client)
ctx.client_game_state[client.team, client.slot] = new_status ctx.client_game_state[client.team, client.slot] = new_status
ctx.on_client_status_change(client.team, client.slot)
ctx.save() ctx.save()

View File

@@ -408,21 +408,13 @@ if typing.TYPE_CHECKING: # type-check with pure python implementation until we
LocationStore = _LocationStore LocationStore = _LocationStore
else: else:
try: try:
from _speedups import LocationStore import pyximport
import _speedups pyximport.install()
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: except ImportError:
try: pyximport = None
import pyximport try:
pyximport.install() from _speedups import LocationStore
except ImportError: except ImportError:
pyximport = None warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
try: "Install a matching C++ compiler for your platform to compile _speedups.")
from _speedups import LocationStore LocationStore = _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

@@ -2,9 +2,6 @@ from __future__ import annotations
import abc import abc
import logging import logging
from copy import deepcopy
from dataclasses import dataclass
import functools
import math import math
import numbers import numbers
import random import random
@@ -214,12 +211,6 @@ class NumericOption(Option[int], numbers.Integral, abc.ABC):
else: else:
return self.value > other 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: def __bool__(self) -> bool:
return bool(self.value) return bool(self.value)
@@ -696,19 +687,11 @@ class Range(NumericOption):
return int(round(random.triangular(lower, end, tri), 0)) return int(round(random.triangular(lower, end, tri), 0))
class NamedRange(Range): class SpecialRange(Range):
special_range_cutoff = 0
special_range_names: typing.Dict[str, int] = {} special_range_names: typing.Dict[str, int] = {}
"""Special Range names have to be all lowercase as matching is done with text.lower()""" """Special Range names have to be all lowercase as matching is done with text.lower()"""
def __init__(self, value: int) -> None:
if value < self.range_start and value not in self.special_range_names.values():
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
self.value = value
@classmethod @classmethod
def from_text(cls, text: str) -> Range: def from_text(cls, text: str) -> Range:
text = text.lower() text = text.lower()
@@ -716,19 +699,6 @@ class NamedRange(Range):
return cls(cls.special_range_names[text]) return cls(cls.special_range_names[text])
return super().from_text(text) return super().from_text(text)
class SpecialRange(NamedRange):
special_range_cutoff = 0
# TODO: remove class SpecialRange, earliest 3 releases after 0.4.3
def __new__(cls, value: int) -> SpecialRange:
from Utils import deprecate
deprecate(f"Option type {cls.__name__} is a subclass of SpecialRange, which is deprecated and pending removal. "
"Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In "
"NamedRange, range_start specifies the lower end of the regular range, while special values can be "
"placed anywhere (below, inside, or above the regular range).")
return super().__new__(cls, value)
@classmethod @classmethod
def weighted_range(cls, text) -> Range: def weighted_range(cls, text) -> Range:
if text == "random-low": if text == "random-low":
@@ -912,7 +882,7 @@ class Accessibility(Choice):
default = 1 default = 1
class ProgressionBalancing(NamedRange): class ProgressionBalancing(SpecialRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck.""" A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50 default = 50
@@ -926,58 +896,10 @@ class ProgressionBalancing(NamedRange):
} }
class OptionsMetaProperty(type): common_options = {
def __new__(mcs, "progression_balancing": ProgressionBalancing,
name: str, "accessibility": Accessibility
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): class LocalItems(ItemSet):
@@ -1098,16 +1020,17 @@ class ItemLinks(OptionList):
link.setdefault("link_replacement", None) link.setdefault("link_replacement", None)
@dataclass per_game_common_options = {
class PerGameCommonOptions(CommonOptions): **common_options, # can be overwritten per-game
local_items: LocalItems "local_items": LocalItems,
non_local_items: NonLocalItems "non_local_items": NonLocalItems,
start_inventory: StartInventory "start_inventory": StartInventory,
start_hints: StartHints "start_hints": StartHints,
start_location_hints: StartLocationHints "start_location_hints": StartLocationHints,
exclude_locations: ExcludeLocations "exclude_locations": ExcludeLocations,
priority_locations: PriorityLocations "priority_locations": PriorityLocations,
item_links: ItemLinks "item_links": ItemLinks
}
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
@@ -1129,7 +1052,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"): if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path) os.unlink(full_path)
def dictify_range(option: Range): def dictify_range(option: typing.Union[Range, SpecialRange]):
data = {option.default: 50} data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]: for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default: if sub_option != option.default:
@@ -1148,7 +1071,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden: if not world.hidden or generate_hidden:
all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints all_options: typing.Dict[str, AssembleOptions] = {
**per_game_common_options,
**world.option_definitions
}
with open(local_path("data", "options.yaml")) as f: with open(local_path("data", "options.yaml")) as f:
file_data = f.read() file_data = f.read()

382
PokemonClient.py Normal file
View File

@@ -0,0 +1,382 @@
import asyncio
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
if type(location.ram_address) == list:
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
else:
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"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 3
class GBCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_gb(self):
"""Check Gameboy Connection State"""
if isinstance(self.ctx, GBContext):
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
class GBContext(CommonContext):
command_processor = GBCommandProcessor
game = 'Pokemon Red and Blue'
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gb_streams: (StreamReader, StreamWriter) = None
self.gb_sync_task = None
self.messages = {}
self.locations_array = None
self.gb_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
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:
await super(GBContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to EmuHawk to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
self.set_deathlink = True
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class GBManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Pokémon Client"
self.ui = GBManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: GBContext):
current_time = time.time()
ret = json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending,
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
else:
flags["DexSanityFlag"] = [0] * 19
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
if flag_type == "list":
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
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",
"status": 30}
])
ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
async def gb_sync_task(ctx: GBContext):
logger.info("Starting GB connector. Use /gb for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.gb_streams:
(reader, writer) = ctx.gb_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
"and PokemonClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
if ctx.client_compatibility_mode == 0:
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
msg = "Invalid ROM detected. No player name built into the ROM."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
if 'options' in data_decoded:
msgs = []
if data_decoded['options'] & 4 and not ctx.sent_release:
ctx.sent_release = True
msgs.append({"cmd": "Say", "text": "!release"})
if data_decoded['options'] & 8 and not ctx.sent_collect:
ctx.sent_collect = True
msgs.append({"cmd": "Say", "text": "!collect"})
if msgs:
await ctx.send_msgs(msgs)
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to Gameboy")
ctx.gb_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.gb_status = error_status
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
else:
try:
logger.debug("Attempting to connect to Gameboy")
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gb_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(game_version, patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.gb'
if game_version == "blue":
delta_patch = BlueDeltaPatch
else:
delta_patch = RedDeltaPatch
try:
base_rom = delta_patch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
with patch_archive.open('delta.bsdiff4', 'r') as stream:
patch = stream.read()
patched_rom_data = bsdiff4.patch(base_rom, patch)
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("PokemonClient")
options = Utils.get_options()
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an APRED or APBLUE patch file')
args = parser.parse_args()
ctx = GBContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apred":
logger.info("APRED file supplied, beginning patching process...")
async_start(patch_and_run_game("red", args.patch_file, ctx))
elif ext == "apblue":
logger.info("APBLUE file supplied, beginning patching process...")
async_start(patch_and_run_game("blue", args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gb_sync_task:
await ctx.gb_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -51,13 +51,6 @@ Currently, the following games are supported:
* Muse Dash * Muse Dash
* DOOM 1993 * DOOM 1993
* Terraria * Terraria
* Lingo
* Pokémon Emerald
* DOOM II
* Shivers
* Heretic
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -68,11 +68,12 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
options = snes_options.split() options = snes_options.split()
num_options = len(options) num_options = len(options)
if num_options > 0:
snes_device_number = int(options[0])
if num_options > 1: if num_options > 1:
snes_address = options[0] snes_address = options[0]
snes_device_number = int(options[1]) snes_device_number = int(options[1])
elif num_options > 0:
snes_device_number = int(options[0])
self.ctx.snes_reconnect_address = None self.ctx.snes_reconnect_address = None
if self.ctx.snes_connect_task: if self.ctx.snes_connect_task:
@@ -207,12 +208,12 @@ class SNIContext(CommonContext):
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self)) self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
super(SNIContext, self).on_deathlink(data) super(SNIContext, self).on_deathlink(data)
async def handle_deathlink_state(self, currently_dead: bool, death_text: str = "") -> None: async def handle_deathlink_state(self, currently_dead: bool) -> None:
# in this state we only care about triggering a death send # in this state we only care about triggering a death send
if self.death_state == DeathState.alive: if self.death_state == DeathState.alive:
if currently_dead: if currently_dead:
self.death_state = DeathState.dead self.death_state = DeathState.dead
await self.send_death(death_text) await self.send_death()
# in this state we care about confirming a kill, to move state to dead # in this state we care about confirming a kill, to move state to dead
elif self.death_state == DeathState.killing_player: elif self.death_state == DeathState.killing_player:
# this is being handled in deathlink_kill_player(ctx) already # this is being handled in deathlink_kill_player(ctx) already
@@ -564,16 +565,14 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try: try:
for address, data in write_list: for address, data in write_list:
while data: PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
# Divide the write into packets of 256 bytes. # REVIEW: above: `if snes_socket is None: return False`
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]] # Does it need to be checked again?
if ctx.snes_socket is not None: if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data[:256]) await ctx.snes_socket.send(data)
address += 256 else:
data = data[256:] snes_logger.warning(f"Could not send data to SNES: {data}")
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed: except ConnectionClosed:
return False return False

File diff suppressed because it is too large Load Diff

View File

@@ -27,33 +27,33 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
self.ctx.syncing = True self.ctx.syncing = True
def _cmd_patch(self): def _cmd_patch(self):
"""Patch the game. Only use this command if /auto_patch fails.""" """Patch the game."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
self.ctx.patch_game() self.ctx.patch_game()
self.output("Patched.") self.output("Patched.")
def _cmd_savepath(self, directory: str): def _cmd_savepath(self, directory: str):
"""Redirect to proper save data folder. This is necessary for Linux users to use before connecting.""" """Redirect to proper save data folder. (Use before connecting!)"""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
self.ctx.save_game_folder = directory UndertaleContext.save_game_folder = directory
self.output("Changed to the following directory: " + self.ctx.save_game_folder) self.output("Changed to the following directory: " + directory)
@mark_raw @mark_raw
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically.""" """Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
tempInstall = steaminstall tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")): if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None tempInstall = None
if tempInstall is None: if tempInstall is None:
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall): if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
elif not os.path.exists(tempInstall): elif not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall): if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" 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")): 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." self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
@@ -61,13 +61,13 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
else: else:
for file_name in os.listdir(tempInstall): for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll": if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name), shutil.copy(tempInstall+"\\"+file_name,
os.path.join(os.getcwd(), "Undertale", file_name)) os.getcwd() + "\\Undertale\\" + file_name)
self.ctx.patch_game() self.ctx.patch_game()
self.output("Patching successful!") self.output("Patching successful!")
def _cmd_online(self): def _cmd_online(self):
"""Toggles seeing other Undertale players.""" """Makes you no longer able to see other Undertale players."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
self.ctx.update_online_mode(not ("Online" in self.ctx.tags)) self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
if "Online" in self.ctx.tags: if "Online" in self.ctx.tags:
@@ -111,13 +111,13 @@ class UndertaleContext(CommonContext):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self): def patch_game(self):
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f: with open(os.getcwd() + "/Undertale/data.win", "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f: with open(os.getcwd() + "/Undertale/data.win", "wb") as f:
f.write(patchedFile) f.write(patchedFile)
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True) os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True)
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites", with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" +
"Which Character.txt")), "w") as f: "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 " 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"]) "line other than this one.\n", "frisk"])
f.close() f.close()
@@ -385,7 +385,7 @@ async def multi_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path): for root, dirs, files in os.walk(path):
for file in files: for file in files:
if "spots.mine" in file and "Online" in ctx.tags: if "spots.mine" in file and "Online" in ctx.tags:
with open(os.path.join(root, file), "r") as mine: with open(root + "/" + file, "r") as mine:
this_x = mine.readline() this_x = mine.readline()
this_y = mine.readline() this_y = mine.readline()
this_room = mine.readline() this_room = mine.readline()
@@ -408,7 +408,7 @@ async def game_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path): for root, dirs, files in os.walk(path):
for file in files: for file in files:
if ".item" in file: if ".item" in file:
os.remove(os.path.join(root, file)) os.remove(root+"/"+file)
sync_msg = [{"cmd": "Sync"}] sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked: if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
@@ -424,13 +424,13 @@ async def game_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path): for root, dirs, files in os.walk(path):
for file in files: for file in files:
if "DontBeMad.mad" in file: if "DontBeMad.mad" in file:
os.remove(os.path.join(root, file)) os.remove(root+"/"+file)
if "DeathLink" in ctx.tags: if "DeathLink" in ctx.tags:
await ctx.send_death() await ctx.send_death()
if "scout" == file: if "scout" == file:
sending = [] sending = []
try: try:
with open(os.path.join(root, file), "r") as f: with open(root+"/"+file, "r") as f:
lines = f.readlines() lines = f.readlines()
for l in lines: for l in lines:
if ctx.server_locations.__contains__(int(l)+12000): if ctx.server_locations.__contains__(int(l)+12000):
@@ -438,11 +438,11 @@ async def game_watcher(ctx: UndertaleContext):
finally: finally:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending, await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
"create_as_hint": int(2)}]) "create_as_hint": int(2)}])
os.remove(os.path.join(root, file)) os.remove(root+"/"+file)
if "check.spot" in file: if "check.spot" in file:
sending = [] sending = []
try: try:
with open(os.path.join(root, file), "r") as f: with open(root+"/"+file, "r") as f:
lines = f.readlines() lines = f.readlines()
for l in lines: for l in lines:
sending = sending+[(int(l.rstrip('\n')))+12000] sending = sending+[(int(l.rstrip('\n')))+12000]
@@ -451,7 +451,7 @@ async def game_watcher(ctx: UndertaleContext):
if "victory" in file and str(ctx.route) in file: if "victory" in file and str(ctx.route) in file:
victory = True victory = True
if ".playerspot" in file and "Online" not in ctx.tags: if ".playerspot" in file and "Online" not in ctx.tags:
os.remove(os.path.join(root, file)) os.remove(root+"/"+file)
if "victory" in file: if "victory" in file:
if str(ctx.route) == "all_routes": if str(ctx.route) == "all_routes":
if "neutral" in file and ctx.completed_routes["neutral"] != 1: if "neutral" in file and ctx.completed_routes["neutral"] != 1:

261
Utils.py
View File

@@ -5,7 +5,6 @@ import json
import typing import typing
import builtins import builtins
import os import os
import itertools
import subprocess import subprocess
import sys import sys
import pickle import pickle
@@ -14,7 +13,6 @@ import io
import collections import collections
import importlib import importlib
import logging import logging
import warnings
from argparse import Namespace from argparse import Namespace
from settings import Settings, get_settings from settings import Settings, get_settings
@@ -31,7 +29,6 @@ except ImportError:
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import tkinter import tkinter
import pathlib import pathlib
from BaseClasses import Region
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
@@ -47,7 +44,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.4.4" __version__ = "0.4.2"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -74,8 +71,6 @@ def snes_to_pc(value: int) -> int:
RetType = typing.TypeVar("RetType") RetType = typing.TypeVar("RetType")
S = typing.TypeVar("S")
T = typing.TypeVar("T")
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]: def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
@@ -93,31 +88,6 @@ def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[]
return _wrap 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: def is_frozen() -> bool:
return typing.cast(bool, getattr(sys, 'frozen', False)) return typing.cast(bool, getattr(sys, 'frozen', False))
@@ -174,16 +144,12 @@ def user_path(*path: str) -> str:
if user_path.cached_path != local_path(): if user_path.cached_path != local_path():
import filecmp import filecmp
if not os.path.exists(user_path("manifest.json")) or \ if not os.path.exists(user_path("manifest.json")) or \
not os.path.exists(local_path("manifest.json")) or \
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
import shutil import shutil
for dn in ("Players", "data/sprites", "data/lua"): for dn in ("Players", "data/sprites"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
if not os.path.exists(local_path("manifest.json")): for fn in ("manifest.json",):
warnings.warn(f"Upgrading {user_path()} from something that is not a proper install") shutil.copy2(local_path(fn), user_path(fn))
else:
shutil.copy2(local_path("manifest.json"), user_path("manifest.json"))
os.makedirs(user_path("worlds"), exist_ok=True)
return os.path.join(user_path.cached_path, *path) return os.path.join(user_path.cached_path, *path)
@@ -249,13 +215,7 @@ def get_cert_none_ssl_context():
def get_public_ipv4() -> str: def get_public_ipv4() -> str:
import socket import socket
import urllib.request import urllib.request
try: ip = socket.gethostbyname(socket.gethostname())
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() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip() ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -273,13 +233,7 @@ def get_public_ipv4() -> str:
def get_public_ipv6() -> str: def get_public_ipv6() -> str:
import socket import socket
import urllib.request import urllib.request
try: ip = socket.gethostbyname(socket.gethostname())
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() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -289,13 +243,15 @@ def get_public_ipv6() -> str:
return ip return ip
OptionsType = Settings # TODO: remove when removing get_options OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
def get_options() -> Settings: @cache_argsless
# TODO: switch to Utils.deprecate after 0.4.4 def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning) return Settings(None)
return get_settings()
get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
def persistent_store(category: str, key: typing.Any, value: typing.Any): def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -403,13 +359,11 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler): class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(RestrictedUnpickler, self).__init__(*args, **kwargs) super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options") self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils") self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None self.generic_properties_module = importlib.import_module("worlds.generic")
def find_class(self, module, name): def find_class(self, module, name):
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
@@ -419,8 +373,6 @@ class RestrictedUnpickler(pickle.Unpickler):
return getattr(self.net_utils_module, name) return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: 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) return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options) # pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"): if module.lower().endswith("options"):
@@ -489,21 +441,11 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
write_mode, write_mode,
encoding="utf-8-sig") encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter(log_format)) 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) root_logger.addHandler(file_handler)
if sys.stdout: if sys.stdout:
stream_handler = logging.StreamHandler(sys.stdout) root_logger.addHandler(
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False))) logging.StreamHandler(sys.stdout)
root_logger.addHandler(stream_handler) )
# Relay unhandled exceptions to logger. # Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -630,7 +572,7 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) 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) return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
@@ -642,10 +584,7 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
f'This attempt was made because open_filename was used for "{title}".') f'This attempt was made because open_filename was used for "{title}".')
raise e raise e
else: else:
try: root = tkinter.Tk()
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw() root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None) initialfile=suggest or None)
@@ -658,14 +597,13 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = None#which("kdialog")
if kdialog: if kdialog:
return run(kdialog, f"--title={title}", "--getexistingdirectory", return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".")
os.path.abspath(suggest) if suggest else ".") zenity = None#which("zenity")
zenity = which("zenity")
if zenity: if zenity:
z_filters = ("--directory",) z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () selection = (f'--filename="{suggest}',) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
@@ -677,10 +615,7 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
f'This attempt was made because open_filename was used for "{title}".') f'This attempt was made because open_filename was used for "{title}".')
raise e raise e
else: else:
try: root = tkinter.Tk()
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw() root.withdraw()
return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None) return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
@@ -710,11 +645,6 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
if zenity: if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") 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 # fall back to tk
try: try:
import tkinter import tkinter
@@ -779,25 +709,6 @@ def deprecate(message: str):
import warnings import warnings
warnings.warn(message) warnings.warn(message)
class DeprecateDict(dict):
log_message: str
should_error: bool
def __init__(self, message, error: bool = False) -> None:
self.log_message = message
self.should_error = error
super().__init__()
def __getitem__(self, item: Any) -> Any:
if self.should_error:
deprecate(self.log_message)
elif __debug__:
import warnings
warnings.warn(self.log_message)
return super().__getitem__(item)
def _extend_freeze_support() -> None: def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn.""" """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327 # upstream issue: https://github.com/python/cpython/issues/76327
@@ -844,127 +755,3 @@ def freeze_support() -> None:
import multiprocessing import multiprocessing
_extend_freeze_support() _extend_freeze_support()
multiprocessing.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

@@ -113,9 +113,6 @@ class WargrooveContext(CommonContext):
async def connection_closed(self): async def connection_closed(self):
await super(WargrooveContext, self).connection_closed() await super(WargrooveContext, self).connection_closed()
self.remove_communication_files() self.remove_communication_files()
self.checked_locations.clear()
self.server_locations.clear()
self.finished_game = False
@property @property
def endpoints(self): def endpoints(self):
@@ -127,9 +124,6 @@ class WargrooveContext(CommonContext):
async def shutdown(self): async def shutdown(self):
await super(WargrooveContext, self).shutdown() await super(WargrooveContext, self).shutdown()
self.remove_communication_files() self.remove_communication_files()
self.checked_locations.clear()
self.server_locations.clear()
self.finished_game = False
def remove_communication_files(self): def remove_communication_files(self):
for root, dirs, files in os.walk(self.game_communication_path): for root, dirs, files in os.walk(self.game_communication_path):
@@ -408,10 +402,8 @@ async def game_watcher(ctx: WargrooveContext):
if file.find("send") > -1: if file.find("send") > -1:
st = file.split("send", -1)[1] st = file.split("send", -1)[1]
sending = sending+[(int(st))] sending = sending+[(int(st))]
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("victory") > -1: if file.find("victory") > -1:
victory = True victory = True
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}] message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message) await ctx.send_msgs(message)

View File

@@ -13,6 +13,15 @@ import Utils
import settings import settings
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 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.options import create as create_options_files
import worlds
settings.no_gui = True settings.no_gui = True
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home if not os.path.exists(configpath): # fall back to config.yaml in home
@@ -20,9 +29,6 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
def get_app(): def get_app():
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db
register() register()
app = raw_app app = raw_app
if os.path.exists(configpath) and not app.config["TESTING"]: if os.path.exists(configpath) and not app.config["TESTING"]:
@@ -34,9 +40,15 @@ def get_app():
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}") logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
cache.init_app(app)
db.bind(**app.config["PONY"]) db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True) db.generate_mapping(create_tables=True)
for world in worlds.AutoWorldRegister.world_types.values():
try:
world.web.run_webhost_app_setup(app)
except Exception as e:
logging.exception(e)
return app return app
@@ -116,16 +128,16 @@ if __name__ == "__main__":
multiprocessing.set_start_method('spawn') multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp for world in worlds.AutoWorldRegister.world_types.values():
from WebHostLib.autolauncher import autohost, autogen try:
from WebHostLib.options import create as create_options_files world.web.run_webhost_setup()
except Exception as e:
logging.exception(e)
try:
update_sprites_lttp()
except Exception as e:
logging.exception(e)
logging.warning("Could not update LttP sprites.")
app = get_app() app = get_app()
del world, worlds
create_options_files() create_options_files()
create_ordered_tutorials_file() create_ordered_tutorials_file()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:
@@ -136,5 +148,4 @@ if __name__ == "__main__":
if app.config["DEBUG"]: if app.config["DEBUG"]:
app.run(debug=True, port=app.config["PORT"]) app.run(debug=True, port=app.config["PORT"])
else: else:
from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])

View File

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

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import json import json
import logging import logging
import multiprocessing import multiprocessing
import os
import sys
import threading import threading
import time import time
import typing import typing
@@ -11,7 +13,55 @@ from datetime import timedelta, datetime
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
from Utils import restricted_loads from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
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()
def launch_room(room: Room, config: dict): def launch_room(room: Room, config: dict):

View File

@@ -1,13 +1,17 @@
import os
import zipfile import zipfile
import base64 from typing import *
from typing import Union, Dict, Set, Tuple
from flask import request, flash, redirect, url_for, render_template from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup from markupsafe import Markup
from WebHostLib import app from WebHostLib import app
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
banned_zip_contents = (".sfc",)
def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from Generate import roll_settings, PlandoOptions from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls from Utils import parse_yamls
@@ -20,21 +24,13 @@ def check():
if 'file' not in request.files: if 'file' not in request.files:
flash('No file part') flash('No file part')
else: else:
files = request.files.getlist('file') file = request.files['file']
options = get_yaml_data(files) options = get_yaml_data(file)
if isinstance(options, str): if isinstance(options, str):
flash(options) flash(options)
else: else:
results, _ = roll_options(options) results, _ = roll_options(options)
if len(options) > 1: return render_template("checkResult.html", results=results)
# 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") return render_template("check.html")
@@ -43,44 +39,32 @@ def mysterycheck():
return redirect(url_for("check"), 301) return redirect(url_for("check"), 301)
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
options = {} options = {}
for uploaded_file in files: # if user does not select file, browser also
if banned_file(uploaded_file.filename): # submit an empty part without filename
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. " if file.filename == '':
"Your file was deleted.") return 'No selected file'
# If the user does not select file, the browser will still submit an empty string without a file name. elif file and allowed_file(file.filename):
elif uploaded_file.filename == "": if file.filename.endswith(".zip"):
return "No selected file."
elif uploaded_file.filename in options:
return f"Conflicting files named {uploaded_file.filename} submitted."
elif uploaded_file and allowed_options(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"):
if not zipfile.is_zipfile(uploaded_file):
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
uploaded_file.seek(0) # offset from is_zipfile check with zipfile.ZipFile(file, 'r') as zfile:
with zipfile.ZipFile(uploaded_file, "r") as zfile: infolist = zfile.infolist()
for file in zfile.infolist():
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
base_filename = os.path.basename(file.filename)
if base_filename.endswith(".archipelago"): if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. " return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?') 'Did you mean to <a href="/uploads">host a game</a>?')
elif base_filename.endswith(".zip"):
return "Nested .zip files inside a .zip are not supported."
elif banned_file(base_filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
"material. Your file was deleted.")
# Ignore dot-files.
elif not base_filename.startswith(".") and allowed_options(base_filename):
options[file.filename] = zfile.open(file, "r").read()
else:
options[uploaded_file.filename] = uploaded_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 = {file.filename: file.read()}
if not options: if not options:
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}" return "Did not find a .yaml file to process."
return options return options

View File

@@ -11,7 +11,6 @@ import socket
import threading import threading
import time import time
import typing import typing
import sys
import websockets import websockets
from pony.orm import commit, db_session, select from pony.orm import commit, db_session, select
@@ -20,17 +19,14 @@ import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import restricted_loads, cache_argsless from Utils import restricted_loads, cache_argsless
from .locker import Locker
from .models import Command, GameDataPackage, Room, db from .models import Command, GameDataPackage, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor): class CustomClientMessageProcessor(ClientMessageProcessor):
ctx: WebHostContext ctx: WebHostContext
def _cmd_video(self, platform: str, user: str): def _cmd_video(self, platform, user):
"""Set a link for your name in the WebHostLib tracker pointing to a video stream. """Set a link for your name in the WebHostLib tracker pointing to a video stream"""
Currently, only YouTube and Twitch platforms are supported.
"""
if platform.lower().startswith("t"): # twitch if platform.lower().startswith("t"): # twitch
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
self.ctx.save() self.ctx.save()
@@ -167,21 +163,16 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
db.generate_mapping(check_tables=False) db.generate_mapping(check_tables=False)
async def main(): 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") Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext(static_server_data) ctx = WebHostContext(static_server_data)
ctx.load(room_id) ctx.load(room_id)
ctx.init_save() ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
gc.collect() # free intermediate objects used during setup
try: try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server await ctx.server
except OSError: # likely port in use except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server await ctx.server
@@ -205,23 +196,18 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
ctx.auto_shutdown = Room.get(id=room_id).timeout ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task await ctx.shutdown_task
# ensure auto launch is on the same page in regard to room activity.
with db_session:
room: Room = Room.get(id=ctx.room_id)
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
logging.info("Shutting down") logging.info("Shutting down")
from .autolauncher import Locker
with Locker(room_id): with Locker(room_id):
try: try:
asyncio.run(main()) asyncio.run(main())
except (KeyboardInterrupt, SystemExit): except KeyboardInterrupt:
with db_session: with db_session:
room = Room.get(id=room_id) room = Room.get(id=room_id)
# ensure the Room does not spin up again on its own, minute of safety buffer # 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) room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
except Exception: except:
with db_session: with db_session:
room = Room.get(id=room_id) room = Room.get(id=room_id)
room.last_port = -1 room.last_port = -1

View File

@@ -90,8 +90,6 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
elif slot_data.game == "Kingdom Hearts 2": elif slot_data.game == "Kingdom Hearts 2":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
elif slot_data.game == "Final Fantasy Mystic Quest":
fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
else: else:
return "Game download not supported." return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname) return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)

View File

@@ -1,18 +1,18 @@
import concurrent.futures
import json import json
import os import os
import pickle import pickle
import random import random
import tempfile import tempfile
import zipfile import zipfile
import concurrent.futures
from collections import Counter from collections import Counter
from typing import Any, Dict, List, Optional, Union from typing import Dict, Optional, Any, Union, List
from flask import flash, redirect, render_template, request, session, url_for from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, db_session from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits from BaseClasses import seeddigits, get_seed
from Generate import PlandoOptions, handle_name from Generate import handle_name, PlandoOptions
from Main import main as ERmain from Main import main as ERmain
from Utils import __version__ from Utils import __version__
from WebHostLib import app from WebHostLib import app
@@ -64,8 +64,8 @@ def generate(race=False):
if 'file' not in request.files: if 'file' not in request.files:
flash('No file part') flash('No file part')
else: else:
files = request.files.getlist('file') file = request.files['file']
options = get_yaml_data(files) options = get_yaml_data(file)
if isinstance(options, str): if isinstance(options, str):
flash(options) flash(options)
else: else:
@@ -131,7 +131,6 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"})) {"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False erargs.skip_prog_balancing = False
erargs.skip_output = False
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

View File

@@ -1,51 +0,0 @@
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

@@ -1,50 +0,0 @@
import os
import threading
import json
from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp():
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress
from LttPAdjuster import BackgroundTaskProgressNullWindow
from LttPAdjuster import update_sprites
# Target directories
input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
# update sprites through gui.py's functions
done = threading.Event()
try:
top = Tk()
except:
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
else:
top.withdraw()
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.isSet():
task.do_events()
spriteData = []
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
sprite = Sprite(os.path.join(input_dir, file))
if not sprite.name:
print("Warning:", file, "has no name.")
sprite.name = file.split(".", 1)[0]
if sprite.valid:
with open(os.path.join(output_dir, "sprites", f"{os.path.splitext(file)[0]}.gif"), 'wb') as image:
image.write(get_image_for_sprite(sprite, True))
spriteData.append({"file": file, "author": sprite.author_name, "name": sprite.name})
else:
print(file, "dropped, as it has no valid sprite data.")
spriteData.sort(key=lambda entry: entry["name"])
with open(f'{output_dir}/spriteData.json', 'w') as file:
json.dump({"sprites": spriteData}, file, indent=1)
return spriteData

View File

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

View File

@@ -3,8 +3,11 @@ import logging
import os import os
import typing import typing
import yaml
from jinja2 import Template
import Options import Options
from Utils import local_path from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
@@ -22,10 +25,10 @@ def create():
return "Please document me!" return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip() return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
weighted_options = { weighted_settings = {
"baseOptions": { "baseOptions": {
"description": "Generated by https://archipelago.gg/", "description": "Generated by https://archipelago.gg/",
"name": "", "name": "Player",
"game": {}, "game": {},
}, },
"games": {}, "games": {},
@@ -33,14 +36,17 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints all_options: typing.Dict[str, Options.AssembleOptions] = {
**Options.per_game_common_options,
**world.option_definitions
}
# Generate JSON files for player-options pages # Generate JSON files for player-settings pages
player_options = { player_settings = {
"baseOptions": { "baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}", "description": f"Generated by https://archipelago.gg/ for {game_name}",
"game": game_name, "game": game_name,
"name": "", "name": "Player",
}, },
} }
@@ -81,8 +87,8 @@ def create():
"max": option.range_end, "max": option.range_end,
} }
if issubclass(option, Options.NamedRange): if issubclass(option, Options.SpecialRange):
game_options[option_name]["type"] = 'named_range' game_options[option_name]["type"] = 'special_range'
game_options[option_name]["value_names"] = {} game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items(): for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val game_options[option_name]["value_names"][key] = val
@@ -114,53 +120,17 @@ def create():
} }
else: else:
logging.debug(f"{option} not exported to Web Options.") logging.debug(f"{option} not exported to Web Settings.")
player_options["gameOptions"] = game_options player_settings["gameOptions"] = game_options
player_options["presetOptions"] = {} os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
for preset_name, preset in world.web.options_presets.items():
player_options["presetOptions"][preset_name] = {}
for option_name, option_value in preset.items():
# Random range type settings are not valid.
assert (not str(option_value).startswith("random-")), \
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \
f"values are not supported for presets."
# Normal random is supported, but needs to be handled explicitly. with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
if option_value == "random": json.dump(player_settings, f, indent=2, separators=(',', ': '))
player_options["presetOptions"][preset_name][option_name] = option_value
continue
option = world.options_dataclass.type_hints[option_name].from_any(option_value) if not world.hidden and world.web.settings_page is True:
if isinstance(option, Options.NamedRange) and isinstance(option_value, str): # Add the random option to Choice, TextChoice, and Toggle settings
assert option_value in option.special_range_names, \
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
# Still use the true value for the option, not the name.
player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option, Options.Range):
player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option_value, str):
# For Choice and Toggle options, the value should be the name of the option. This is to prevent
# setting a preset for an option with an overridden from_text method that would normally be okay,
# but would not be okay for the webhost's current implementation of player options UI.
assert option.name_lookup[option.value] == option_value, \
f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
player_options["presetOptions"][preset_name][option_name] = option.current_key
else:
# int and bool values are fine, just resolve them to the current key for webhost.
player_options["presetOptions"][preset_name][option_name] = option.current_key
os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
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.options_page is True:
# Add the random option to Choice, TextChoice, and Toggle options
for option in game_options.values(): for option in game_options.values():
if option["type"] == "select": if option["type"] == "select":
option["options"].append({"name": "Random", "value": "random"}) option["options"].append({"name": "Random", "value": "random"})
@@ -168,21 +138,11 @@ def create():
if not option["defaultValue"]: if not option["defaultValue"]:
option["defaultValue"] = "random" option["defaultValue"] = "random"
weighted_options["baseOptions"]["game"][game_name] = 0 weighted_settings["baseOptions"]["game"][game_name] = 0
weighted_options["games"][game_name] = { weighted_settings["games"][game_name] = {}
"gameSettings": game_options, weighted_settings["games"][game_name]["gameSettings"] = game_options
"gameItems": tuple(world.item_names), weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
"gameItemGroups": [ weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
group for group in world.item_name_groups.keys() if group != "Everything"
],
"gameItemDescriptions": world.item_descriptions,
"gameLocations": tuple(world.location_names),
"gameLocationGroups": [
group for group in world.location_name_groups.keys() if group != "Everywhere"
],
"gameLocationDescriptions": world.location_descriptions,
}
with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
json.dump(weighted_options, f, indent=2, separators=(',', ': '))
with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
json.dump(weighted_settings, f, indent=2, separators=(',', ': '))

View File

@@ -1,9 +1,9 @@
flask>=3.0.0 flask>=2.2.3
pony>=0.7.17 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'
waitress>=2.1.2 waitress>=2.1.2
Flask-Caching>=2.1.0 Flask-Caching>=2.0.2
Flask-Compress>=1.14 Flask-Compress>=1.13
Flask-Limiter>=3.5.0 Flask-Limiter>=3.3.0
bokeh>=3.1.1; python_version <= '3.8' bokeh>=3.1.1
bokeh>=3.3.2; python_version >= '3.9'
markupsafe>=2.1.3 markupsafe>=2.1.3

View File

@@ -2,62 +2,13 @@
## What is a randomizer? ## What is a randomizer?
A randomizer is a modification of a game which reorganizes the items required to progress through that game. A A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized 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
game, you might first find item C, then A, then B. game, you might first find item C, then A, then B.
This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they This transforms games 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 play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
encounter in new and interesting ways. 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? ## What happens if an item is placed somewhere it is impossible to get?
@@ -66,15 +17,53 @@ 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 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. 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? ## 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: The best way to get started is to take a look at our code on GitHub
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago). at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
There, you will find examples of games in the `worlds` folder: There you will find examples of games in the worlds folder
[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds). at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
You may also find developer documentation in the `docs` folder: You may also find developer documentation in the docs folder
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). at [/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. If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.

View File

@@ -1,523 +0,0 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-options').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerText = gameName;
fetchOptionData().then((results) => {
let optionHash = localStorage.getItem(`${gameName}-hash`);
if (!optionHash) {
// If no hash data has been set before, set it now
optionHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, optionHash);
localStorage.removeItem(gameName);
}
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', resetOptions);
}
// Page setup
createDefaultOptions(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
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 playerOptions = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
nameInput.value = playerOptions.name;
// Presets
const presetSelect = document.getElementById('game-options-preset');
presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value));
for (const preset in results['presetOptions']) {
const presetOption = document.createElement('option');
presetOption.innerText = preset;
presetSelect.appendChild(presetOption);
}
presetSelect.value = localStorage.getItem(`${gameName}-preset`);
results['presetOptions']['__default'] = {};
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const resetOptions = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`);
localStorage.removeItem(`${gameName}-preset`);
window.location.reload();
};
const fetchOptionData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true);
ajax.send();
});
const createDefaultOptions = (optionData) => {
if (!localStorage.getItem(gameName)) {
const newOptions = {
[gameName]: {},
};
for (let baseOption of Object.keys(optionData.baseOptions)){
newOptions[baseOption] = optionData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(optionData.gameOptions)){
newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newOptions));
}
if (!localStorage.getItem(`${gameName}-preset`)) {
localStorage.setItem(`${gameName}-preset`, '__default');
}
};
const buildUI = (optionData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
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 = (options, romOpts = false) => {
const currentOptions = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(options).forEach((option) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${options[option].displayName}: `;
label.setAttribute('for', option);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', options[option].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(options[option].type) {
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', option);
select.setAttribute('data-key', option);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
options[option].options.forEach((opt) => {
const optionElement = document.createElement('option');
optionElement.setAttribute('value', opt.value);
optionElement.innerText = opt.name;
if ((isNaN(currentOptions[gameName][option]) &&
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
(opt.value === currentOptions[gameName][option]))
{
optionElement.selected = true;
}
select.appendChild(optionElement);
});
select.addEventListener('change', (event) => updateGameOption(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
let range = document.createElement('input');
range.setAttribute('id', option);
range.setAttribute('type', 'range');
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(`${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', `${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', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'named_range':
element = document.createElement('div');
element.classList.add('named-range-container');
// Build the select element
let namedRangeSelect = document.createElement('select');
namedRangeSelect.setAttribute('data-key', option);
Object.keys(options[option].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = 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);
}
presetOption.innerText = words.join(' ');
namedRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
namedRangeSelect.appendChild(customOption);
if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) {
namedRangeSelect.value = Number(currentOptions[gameName][option]);
}
// Build range element
let namedRangeWrapper = document.createElement('div');
namedRangeWrapper.classList.add('named-range-wrapper');
let namedRange = document.createElement('input');
namedRange.setAttribute('type', 'range');
namedRange.setAttribute('data-key', option);
namedRange.setAttribute('min', options[option].min);
namedRange.setAttribute('max', options[option].max);
namedRange.value = currentOptions[gameName][option];
// Build rage value element
let namedRangeVal = document.createElement('span');
namedRangeVal.classList.add('range-value');
namedRangeVal.setAttribute('id', `${option}-value`);
namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
// Configure select event listener
namedRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
namedRange.value = event.target.value;
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
// Configure range event handler
namedRange.addEventListener('change', (event) => {
// Update select element
namedRangeSelect.value =
(Object.values(options[option].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(namedRangeSelect);
namedRangeWrapper.appendChild(namedRange);
namedRangeWrapper.appendChild(namedRangeVal);
element.appendChild(namedRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, namedRange, namedRangeSelect)
);
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
namedRange.disabled = true;
namedRangeSelect.disabled = true;
}
namedRangeWrapper.appendChild(randomButton);
break;
default:
console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const setPresets = (optionsData, presetName) => {
const defaults = optionsData['gameOptions'];
const preset = optionsData['presetOptions'][presetName];
localStorage.setItem(`${gameName}-preset`, presetName);
if (!preset) {
console.error(`No presets defined for preset name: '${presetName}'`);
return;
}
const updateOptionElement = (option, presetValue) => {
const optionElement = document.querySelector(`#${option}[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
optionElement.disabled = true;
updateGameOption(randomElement, false);
} else {
optionElement.value = presetValue;
randomElement.classList.remove('active');
optionElement.disabled = undefined;
updateGameOption(optionElement, false);
}
};
for (const option in defaults) {
let presetValue = preset[option];
if (presetValue === undefined) {
// Using the default value if not set in presets.
presetValue = defaults[option]['defaultValue'];
}
switch (defaults[option].type) {
case 'range':
const numberElement = document.querySelector(`#${option}-value`);
if (presetValue === 'random') {
numberElement.innerText = defaults[option]['defaultValue'] === 'random'
? defaults[option]['min'] // A fallback so we don't print 'random' in the UI.
: defaults[option]['defaultValue'];
} else {
numberElement.innerText = presetValue;
}
updateOptionElement(option, presetValue);
break;
case 'select': {
updateOptionElement(option, presetValue);
break;
}
case 'named_range': {
const selectElement = document.querySelector(`select[data-key='${option}']`);
const rangeElement = document.querySelector(`input[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
selectElement.disabled = true;
rangeElement.disabled = true;
updateGameOption(randomElement, false);
} else {
rangeElement.value = presetValue;
selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ?
parseInt(presetValue) : 'custom';
document.getElementById(`${option}-value`).innerText = presetValue;
randomElement.classList.remove('active');
selectElement.disabled = undefined;
rangeElement.disabled = undefined;
updateGameOption(rangeElement, false);
}
break;
}
default:
console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`);
break;
}
}
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameOption(active ? inputElement : randomButton);
};
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 updateGameOption = (optionElement, toggleCustomPreset = true) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (toggleCustomPreset) {
localStorage.setItem(`${gameName}-preset`, '__custom');
const presetElement = document.getElementById('game-options-preset');
presetElement.value = '__custom';
}
if (optionElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][optionElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ?
optionElement.value : parseInt(optionElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportOptions = () => {
const options = JSON.parse(localStorage.getItem(gameName));
const preset = localStorage.getItem(`${gameName}-preset`);
switch (preset) {
case '__default':
options['description'] = `Generated by https://archipelago.gg with the default preset.`;
break;
case '__custom':
options['description'] = `Generated by https://archipelago.gg.`;
break;
default:
options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`;
}
if (!options.name || options.name.toString().trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
const 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: options },
presetData: { player: options },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage += ' ' + error.response.data.text;
}
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -0,0 +1,398 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-settings').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) {
// If no hash data has been set before, set it now
settingHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, settingHash);
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 " +
"them all to default.");
document.getElementById('user-message').addEventListener('click', resetSettings);
}
// Page setup
createDefaultSettings(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerSettings = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const resetSettings = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`)
window.location.reload();
};
const fetchSettingData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
ajax.send();
});
const createDefaultSettings = (settingData) => {
if (!localStorage.getItem(gameName)) {
const newSettings = {
[gameName]: {},
};
for (let baseOption of Object.keys(settingData.baseOptions)){
newSettings[baseOption] = settingData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newSettings));
}
};
const buildUI = (settingData) => {
// 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]; }
});
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 table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(settings).forEach((setting) => {
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);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', settings[setting].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].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);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
settings[setting].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]))
{
option.selected = true;
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateGameSetting(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
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.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(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;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'special_range':
element = document.createElement('div');
element.classList.add('special-range-container');
// Build the select element
let specialRangeSelect = document.createElement('select');
specialRangeSelect.setAttribute('data-key', setting);
Object.keys(settings[setting].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].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);
}
presetOption.innerText = words.join(" ");
specialRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
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]);
}
// Build range element
let specialRangeWrapper = document.createElement('div');
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];
// 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;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(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))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
});
element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, specialRange, specialRangeSelect)
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
specialRange.disabled = true;
specialRangeSelect.disabled = true;
}
specialRangeWrapper.appendChild(randomButton);
break;
default:
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameSetting(randomButton);
};
const updateBaseSetting = (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 options = JSON.parse(localStorage.getItem(gameName));
if (settingElement.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';
} else {
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
settingElement.value : parseInt(settingElement.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) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: settings },
presetData: { player: settings },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage += ' ' + error.response.data.text;
}
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -1,64 +0,0 @@
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

@@ -4,34 +4,16 @@ const adjustTableHeight = () => {
return; return;
const upperDistance = tablesContainer.getBoundingClientRect().top; const upperDistance = tablesContainer.getBoundingClientRect().top;
const containerHeight = window.innerHeight - upperDistance;
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
const tableWrappers = document.getElementsByClassName('table-wrapper'); const tableWrappers = document.getElementsByClassName('table-wrapper');
for (let i = 0; i < tableWrappers.length; i++) { for(let i=0; i < tableWrappers.length; i++){
// Ensure we are starting from maximum size prior to calculation. const maxHeight = (window.innerHeight - upperDistance) / 2;
tableWrappers[i].style.height = null; tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
tableWrappers[i].style.maxHeight = null;
// Set as a reasonable height, but still allows the user to resize element if they desire.
const currentHeight = tableWrappers[i].offsetHeight;
const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4);
if (currentHeight > maxHeight) {
tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`;
}
tableWrappers[i].style.maxHeight = `${currentHeight}px`;
} }
}; };
/**
* 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', () => { window.addEventListener('load', () => {
const tables = $(".table").DataTable({ const tables = $(".table").DataTable({
paging: false, paging: false,
@@ -45,31 +27,24 @@ window.addEventListener('load', () => {
stateLoadCallback: function(settings) { stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); 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: [ columnDefs: [
{
targets: 'last-activity',
name: 'lastActivity'
},
{ {
targets: 'hours', targets: 'hours',
render: function (data, type, row) { render: function (data, type, row) {
if (type === "sort" || type === 'type') { if (type === "sort" || type === 'type') {
if (data === "None") if (data === "None")
return Number.MAX_VALUE; return -1;
return parseInt(data); return parseInt(data);
} }
if (data === "None") if (data === "None")
return data; return data;
return secondsToHours(data); let hours = Math.floor(data / 3600);
let minutes = Math.floor((data - (hours * 3600)) / 60);
if (minutes < 10) {minutes = "0"+minutes;}
return hours+':'+minutes;
} }
}, },
{ {
@@ -139,16 +114,11 @@ window.addEventListener('load', () => {
if (status === "success") { if (status === "success") {
target.find(".table").each(function (i, new_table) { target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr"); const new_trs = $(new_table).find("tbody>tr");
const footer_tr = $(new_table).find("tfoot>tr");
const old_table = tables.eq(i); const old_table = tables.eq(i);
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.clear(); old_table.clear();
if (footer_tr.length) { old_table.rows.add(new_trs).draw();
$(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).scrollTop(topscroll);
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); $(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.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

@@ -4,7 +4,7 @@ html{
background-size: 650px 650px; background-size: 650px 650px;
} }
#player-options{ #player-settings{
box-sizing: border-box; box-sizing: border-box;
max-width: 1024px; max-width: 1024px;
margin-left: auto; margin-left: auto;
@@ -15,14 +15,14 @@ html{
color: #eeffeb; color: #eeffeb;
} }
#player-options #player-options-button-row{ #player-settings #player-settings-button-row{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
margin-top: 15px; margin-top: 15px;
} }
#player-options code{ #player-settings code{
background-color: #d9cd8e; background-color: #d9cd8e;
border-radius: 4px; border-radius: 4px;
padding-left: 0.25rem; padding-left: 0.25rem;
@@ -30,7 +30,7 @@ html{
color: #000000; color: #000000;
} }
#player-options #user-message{ #player-settings #user-message{
display: none; display: none;
width: calc(100% - 8px); width: calc(100% - 8px);
background-color: #ffe86b; background-color: #ffe86b;
@@ -40,12 +40,12 @@ html{
text-align: center; text-align: center;
} }
#player-options #user-message.visible{ #player-settings #user-message.visible{
display: block; display: block;
cursor: pointer; cursor: pointer;
} }
#player-options h1{ #player-settings h1{
font-size: 2.5rem; font-size: 2.5rem;
font-weight: normal; font-weight: normal;
width: 100%; width: 100%;
@@ -53,7 +53,7 @@ html{
text-shadow: 1px 1px 4px #000000; text-shadow: 1px 1px 4px #000000;
} }
#player-options h2{ #player-settings h2{
font-size: 40px; font-size: 40px;
font-weight: normal; font-weight: normal;
width: 100%; width: 100%;
@@ -62,22 +62,22 @@ html{
text-shadow: 1px 1px 2px #000000; text-shadow: 1px 1px 2px #000000;
} }
#player-options h3, #player-options h4, #player-options h5, #player-options h6{ #player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
} }
#player-options input:not([type]){ #player-settings input:not([type]){
border: 1px solid #000000; border: 1px solid #000000;
padding: 3px; padding: 3px;
border-radius: 3px; border-radius: 3px;
min-width: 150px; min-width: 150px;
} }
#player-options input:not([type]):focus{ #player-settings input:not([type]):focus{
border: 1px solid #ffffff; border: 1px solid #ffffff;
} }
#player-options select{ #player-settings select{
border: 1px solid #000000; border: 1px solid #000000;
padding: 3px; padding: 3px;
border-radius: 3px; border-radius: 3px;
@@ -85,97 +85,72 @@ html{
background-color: #ffffff; background-color: #ffffff;
} }
#player-options #game-options, #player-options #rom-options{ #player-settings #game-options, #player-settings #rom-options{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
#player-options #meta-options { #player-settings .left, #player-settings .right{
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px;
}
#player-options div {
display: flex;
flex-grow: 1; flex-grow: 1;
} }
#player-options #meta-options label { #player-settings .left{
display: inline-block;
min-width: 180px;
flex-grow: 1;
}
#player-options #meta-options input,
#player-options #meta-options select {
box-sizing: border-box;
min-width: 150px;
width: 50%;
}
#player-options .left, #player-options .right{
flex-grow: 1;
}
#player-options .left{
margin-right: 10px; margin-right: 10px;
} }
#player-options .right{ #player-settings .right{
margin-left: 10px; margin-left: 10px;
} }
#player-options table{ #player-settings table{
margin-bottom: 30px; margin-bottom: 30px;
width: 100%; width: 100%;
} }
#player-options table .select-container{ #player-settings table .select-container{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
#player-options table .select-container select{ #player-settings table .select-container select{
min-width: 200px; min-width: 200px;
flex-grow: 1; flex-grow: 1;
} }
#player-options table select:disabled{ #player-settings table select:disabled{
background-color: lightgray; background-color: lightgray;
} }
#player-options table .range-container{ #player-settings table .range-container{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
#player-options table .range-container input[type=range]{ #player-settings table .range-container input[type=range]{
flex-grow: 1; flex-grow: 1;
} }
#player-options table .range-value{ #player-settings table .range-value{
min-width: 20px; min-width: 20px;
margin-left: 0.25rem; margin-left: 0.25rem;
} }
#player-options table .named-range-container{ #player-settings table .special-range-container{
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
#player-options table .named-range-wrapper{ #player-settings table .special-range-wrapper{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
#player-options table .named-range-wrapper input[type=range]{ #player-settings table .special-range-wrapper input[type=range]{
flex-grow: 1; flex-grow: 1;
} }
#player-options table .randomize-button { #player-settings table .randomize-button {
max-height: 24px; max-height: 24px;
line-height: 16px; line-height: 16px;
padding: 2px 8px; padding: 2px 8px;
@@ -185,23 +160,23 @@ html{
border-radius: 3px; border-radius: 3px;
} }
#player-options table .randomize-button.active { #player-settings table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */ background-color: #ffef00; /* Same as .interactive in globalStyles.css */
} }
#player-options table .randomize-button[data-tooltip]::after { #player-settings table .randomize-button[data-tooltip]::after {
left: unset; left: unset;
right: 0; right: 0;
} }
#player-options table label{ #player-settings table label{
display: block; display: block;
min-width: 200px; min-width: 200px;
margin-right: 4px; margin-right: 4px;
cursor: default; cursor: default;
} }
#player-options th, #player-options td{ #player-settings th, #player-settings td{
border: none; border: none;
padding: 3px; padding: 3px;
font-size: 17px; font-size: 17px;
@@ -209,23 +184,17 @@ html{
} }
@media all and (max-width: 1024px) { @media all and (max-width: 1024px) {
#player-options { #player-settings {
border-radius: 0; border-radius: 0;
} }
#player-options #meta-options { #player-settings #game-options{
flex-direction: column;
justify-content: flex-start;
gap: 6px;
}
#player-options #game-options{
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
} }
#player-options .left, #player-settings .left,
#player-options .right { #player-settings .right {
margin: 0; margin: 0;
} }

View File

@@ -9,7 +9,7 @@
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
padding: 3px 3px 10px; padding: 3px 3px 10px;
width: 710px; width: 500px;
background-color: #525494; background-color: #525494;
} }
@@ -34,12 +34,10 @@
max-height: 40px; max-height: 40px;
border: 1px solid #000000; border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%); filter: grayscale(100%) contrast(75%) brightness(20%);
background-color: black;
} }
#inventory-table img.acquired{ #inventory-table img.acquired{
filter: none; filter: none;
background-color: black;
} }
#inventory-table div.counted-item { #inventory-table div.counted-item {
@@ -54,7 +52,7 @@
} }
#location-table{ #location-table{
width: 710px; width: 500px;
border-left: 2px solid #000000; border-left: 2px solid #000000;
border-right: 2px solid #000000; border-right: 2px solid #000000;
border-bottom: 2px solid #000000; border-bottom: 2px solid #000000;

View File

@@ -18,22 +18,6 @@
margin-bottom: 2px; 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{ #games a{
font-size: 16px; font-size: 16px;
} }
@@ -47,13 +31,3 @@
line-height: 25px; line-height: 25px;
margin-bottom: 7px; margin-bottom: 7px;
} }
#games .page-controls{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#games .page-controls button{
margin-left: 0.5rem;
}

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