Compare commits

..

14 Commits

Author SHA1 Message Date
Chris Wilson
9f5fceba2d Merge branch 'main' into player-tracker 2022-09-24 18:35:59 -04:00
Chris Wilson
e9e5511583 Merge branch 'main' into player-tracker 2022-08-17 21:48:40 -04:00
Chris Wilson
c546dcd5ff Fix merge conflicts into player-tracker 2022-08-15 21:37:44 -04:00
alwaysintreble
053fb14495 rename variables to fix invalid int loading (#858) 2022-08-03 21:36:26 -04:00
Chris Wilson
ed77d14618 PEP8 Fix 2022-08-03 21:34:59 -04:00
Chris Wilson
3fb287e82b Fix a bug causing the stylized tracker link to point to the wrong player 2022-08-03 19:54:25 -04:00
Chris Wilson
32431cfe04 Merge branch 'main' into player-tracker 2022-08-03 19:15:10 -04:00
Chris Wilson
ca8f4c38ec Merge branch 'main' into player-tracker 2022-07-31 11:17:59 -04:00
Chris Wilson
eb52454ccc Merge branch 'main' into player-tracker 2022-07-31 11:13:14 -04:00
Chris Wilson
14e5f54f59 Merge branch 'main' into player-tracker 2022-07-26 17:19:00 -04:00
Chris Wilson
2052cc55af Merge branch 'main' into player-tracker 2022-07-18 20:06:04 -04:00
Chris Wilson
63a8436240 Merge branch 'main' into player-tracker 2022-07-12 20:03:32 -04:00
Chris Wilson
e60719a20a Merge branch 'main' into player-tracker 2022-06-27 19:19:27 -04:00
alwaysintreble
8742aadc72 Player tracker (#710)
* Player tracker: implement a stylized tracker (#447)

* Move generic tracker to a WebWorld method

* render both a generic tracker at generic_tracker and the specific tracker at /tracker

* create a base template for generic specific tracker and instantiate some information before callng it

* some baseline for the playerTracker.html. update information fed from tracker.py

* playerTracker: finish implementing icons and generic locations rendering. hide any unacquired progression items when not using icons. Place the name of the progression item under its icon.

* player tracker: starting work on regions table

* player tracker: change method calls

* Move generic tracker to a WebWorld method

* render both a generic tracker at generic_tracker and the specific tracker at /tracker

* create a base template for generic specific tracker and instantiate some information before callng it

* some baseline for the playerTracker.html. update information fed from tracker.py

* playerTracker: finish implementing icons and generic locations rendering. hide any unacquired progression items when not using icons. Place the name of the progression item under its icon.

* player tracker: starting work on regions table

* player tracker: change method calls

* Move generic tracker to a WebWorld method

* create a base template for generic specific tracker and instantiate some information before callng it

* some baseline for the playerTracker.html. update information fed from tracker.py

* playerTracker: finish implementing icons and generic locations rendering. hide any unacquired progression items when not using icons. Place the name of the progression item under its icon.

* player tracker: starting work on regions table

* player tracker: switch item, icon and location tables to flex views. Some styling based on theme

* Player Tracker: Finish building html template for all blocks. Set groundwork for theme styling

* Player Tracker: Implement tracker class. Document tracker usage.

* Player Tracker: Add button to switch between trackers. Some styling for styled tracker.

* Player Tracker: reword some text. Attempt to fix page refreshing.

* Player Tracker: reremove the TODOs that got merged back in accidentally.

* player tracker: move render_template import to webworld so it isn't required outside of webhost

* Player Tracker: code cleanup, typing. Add inventory with names to PlayerTracker class in case custom trackers want to use it to change their prog_items attribute.

* Player Tracker: delete a line I forgot about. Add typing to theme.

* Player Tracker: Generate checks_done automatically so worlds don't have to do it

* Player Tracker: Add typing to PlayerTracker class in webworld method. Update documentation

* Player Tracker: code cleanup

* Player Tracker: Sort of implement fetch (works but could be better). Make playerTracker.html more readable.

* specific trackers: significant html cleanup. DOM Endpoint auto updating page every 30 seconds

* Changes by Kono

* specific trackers: cache and only load the data once every minute

* specific tracker: allow for one icon placement to be used for multiple items.

* Player tracker fixes/updates (#635)

* Move generic tracker to a WebWorld method

* render both a generic tracker at generic_tracker and the specific tracker at /tracker

* create a base template for generic specific tracker and instantiate some information before callng it

* some baseline for the playerTracker.html. update information fed from tracker.py

* playerTracker: finish implementing icons and generic locations rendering. hide any unacquired progression items when not using icons. Place the name of the progression item under its icon.

* player tracker: starting work on regions table

* player tracker: change method calls

* Move generic tracker to a WebWorld method

* render both a generic tracker at generic_tracker and the specific tracker at /tracker

* create a base template for generic specific tracker and instantiate some information before callng it

* some baseline for the playerTracker.html. update information fed from tracker.py

* playerTracker: finish implementing icons and generic locations rendering. hide any unacquired progression items when not using icons. Place the name of the progression item under its icon.

* player tracker: starting work on regions table

* player tracker: change method calls

* Move generic tracker to a WebWorld method

* create a base template for generic specific tracker and instantiate some information before callng it

* some baseline for the playerTracker.html. update information fed from tracker.py

* playerTracker: finish implementing icons and generic locations rendering. hide any unacquired progression items when not using icons. Place the name of the progression item under its icon.

* player tracker: starting work on regions table

* player tracker: switch item, icon and location tables to flex views. Some styling based on theme

* Player Tracker: Finish building html template for all blocks. Set groundwork for theme styling

* Player Tracker: Implement tracker class. Document tracker usage.

* Player Tracker: Add button to switch between trackers. Some styling for styled tracker.

* Player Tracker: reword some text. Attempt to fix page refreshing.

* Player Tracker: reremove the TODOs that got merged back in accidentally.

* player tracker: move render_template import to webworld so it isn't required outside of webhost

* Player Tracker: code cleanup, typing. Add inventory with names to PlayerTracker class in case custom trackers want to use it to change their prog_items attribute.

* Player Tracker: delete a line I forgot about. Add typing to theme.

* Player Tracker: Generate checks_done automatically so worlds don't have to do it

* Player Tracker: Add typing to PlayerTracker class in webworld method. Update documentation

* Player Tracker: code cleanup

* Player Tracker: Sort of implement fetch (works but could be better). Make playerTracker.html more readable.

* specific trackers: significant html cleanup. DOM Endpoint auto updating page every 30 seconds

* Changes by Kono

* specific trackers: cache and only load the data once every minute

* specific tracker: allow for one icon placement to be used for multiple items.

* lttp: move tracker to new format. will need more modification to generic solution to handle region keys tracking. likely a new html template that inherits the current

* lttp: fix broken icons rendering, add in progressive mail that i forgor. reorder some icons

* tracker: fix non edited trackers being broken from changes.

* tracker: move theme application before modify method so trackers can use a different theme than the world if desired.

* tracker: starting work on key tracking.

* tracker: styling and cleanup by Farrak

* tracker: styling and cleanup by Farrak

* tracker: styling and cleanup of playerTracker.html

* Revert playerTracker.html

* trackers: rename some files for clarity. move trackers into their own subdirectory

* small tracker.py cleanup

* move minecraft tracker to new system

* add item link attributing from upstream

* change getPlayerTracker to get_player_tracker. refactor broken linkings

* refactor styling files to trackers folders

* fix broken image in minecraft tracker. move oot tracker to new system

* clean up my oot nightmare

* rename lttpKeysTracker to zeldaKeysTracker. Move oot to keys tracker

* implement zeldaKeysTracker.js. fix table locations hiding/showing
2022-06-25 17:01:42 -04:00
374 changed files with 7212 additions and 31709 deletions

View File

@@ -30,7 +30,7 @@ jobs:
run: | run: |
python -m pip install --upgrade pip setuptools python -m pip install --upgrade pip setuptools
pip install -r requirements.txt pip install -r requirements.txt
python setup.py build_exe --yes python setup.py build --yes
$NAME="$(ls build)".Split('.',2)[1] $NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z" $ZIP_NAME="Archipelago_$NAME.7z"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
@@ -82,7 +82,7 @@ jobs:
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python setup.py build_exe --yes bdist_appimage --yes python setup.py build --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..

6
.gitignore vendored
View File

@@ -13,10 +13,6 @@
*.z64 *.z64
*.n64 *.n64
*.nes *.nes
*.sms
*.gb
*.gbc
*.gba
*.wixobj *.wixobj
*.lck *.lck
*.db3 *.db3
@@ -129,7 +125,7 @@ ipython_config.py
# Environments # Environments
.env .env
.venv* .venv
env/ env/
venv/ venv/
ENV/ ENV/

View File

@@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from argparse import Namespace
import copy import copy
from enum import unique, IntEnum, IntFlag from enum import unique, IntEnum, IntFlag
@@ -41,23 +40,16 @@ 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: 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]]
state: CollectionState state: CollectionState
accessibility: Dict[int, Options.Accessibility] accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]]
local_items: Dict[int, Options.LocalItems] local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems] non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing] progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, Callable[[CollectionState], bool]] completion_condition: Dict[int, Callable[[CollectionState], bool]]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
game: Dict[int, str]
class AttributeProxy(): class AttributeProxy():
def __init__(self, rule): def __init__(self, rule):
@@ -95,9 +87,6 @@ class MultiWorld():
self.customitemarray = [] self.customitemarray = []
self.shuffle_ganon = True self.shuffle_ganon = True
self.spoiler = Spoiler(self) self.spoiler = Spoiler(self)
self.early_items = {player: {} for player in self.player_ids}
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.fix_trock_doors = self.AttributeProxy( self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted') lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy( self.fix_skullwoods_exit = self.AttributeProxy(
@@ -206,7 +195,7 @@ class MultiWorld():
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
range(1, self.players + 1)} range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None: def set_options(self, args):
for option_key in Options.common_options: for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {})) setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.per_game_common_options: for option_key in Options.per_game_common_options:
@@ -306,16 +295,9 @@ class MultiWorld():
def get_file_safe_player_name(self, player: int) -> str: def get_file_safe_player_name(self, player: int) -> str:
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*') return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
def get_out_file_name_base(self, player: int) -> str:
""" the base name (without file extension) for each player's output file for a seed """
return f"AP_{self.seed_name}_P{player}" \
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
if (self.player_name[player] != f"Player{player}")
else '')
def initialize_regions(self, regions=None): def initialize_regions(self, regions=None):
for region in regions if regions else self.regions: for region in regions if regions else self.regions:
region.multiworld = self region.world = self
self._region_cache[region.player][region.name] = region self._region_cache[region.player][region.name] = region
@functools.cached_property @functools.cached_property
@@ -422,11 +404,6 @@ class MultiWorld():
def clear_entrance_cache(self): def clear_entrance_cache(self):
self._cached_entrances = None self._cached_entrances = None
def register_indirect_condition(self, region: Region, entrance: Entrance):
"""Report that access to this Region can result in unlocking this Entrance,
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance)
def get_locations(self) -> List[Location]: def get_locations(self) -> List[Location]:
if self._cached_locations is None: if self._cached_locations is None:
self._cached_locations = [location for region in self.regions for location in region.locations] self._cached_locations = [location for region in self.regions for location in region.locations]
@@ -544,17 +521,15 @@ class MultiWorld():
"""Check if accessibility rules are fulfilled with current or supplied state.""" """Check if accessibility rules are fulfilled with current or supplied state."""
if not state: if not state:
state = CollectionState(self) state = CollectionState(self)
players: Dict[str, Set[int]] = { players = {"minimal": set(),
"minimal": set(), "items": set(),
"items": set(), "locations": set()}
"locations": set()
}
for player, access in self.accessibility.items(): for player, access in self.accessibility.items():
players[access.current_key].add(player) players[access.current_key].add(player)
beatable_fulfilled = False beatable_fulfilled = False
def location_condition(location: Location): def location_conditition(location: Location):
"""Determine if this location has to be accessible, location is already filtered by location_relevant""" """Determine if this location has to be accessible, location is already filtered by location_relevant"""
if location.player in players["minimal"]: if location.player in players["minimal"]:
return False return False
@@ -568,21 +543,20 @@ class MultiWorld():
return True return True
return False return False
def all_done() -> bool: def all_done():
"""Check if all access rules are fulfilled""" """Check if all access rules are fulfilled"""
if not beatable_fulfilled: if beatable_fulfilled:
return False if any(location_conditition(location) for location in locations):
if any(location_condition(location) for location in locations): return False # still locations required to be collected
return False # still locations required to be collected return True
return True
locations = [location for location in self.get_locations() if location_relevant(location)] locations = {location for location in self.get_locations() if location_relevant(location)}
while locations: while locations:
sphere: List[Location] = [] sphere = set()
for n in range(len(locations) - 1, -1, -1): for location in locations:
if locations[n].can_reach(state): if location.can_reach(state):
sphere.append(locations.pop(n)) sphere.add(location)
if not sphere: if not sphere:
# ran out of places and did not finish yet, quit # ran out of places and did not finish yet, quit
@@ -591,8 +565,8 @@ class MultiWorld():
return False return False
for location in sphere: for location in sphere:
if location.item: locations.remove(location)
state.collect(location.item, True, location) state.collect(location.item, True, location)
if self.has_beaten_game(state): if self.has_beaten_game(state):
beatable_fulfilled = True beatable_fulfilled = True
@@ -608,7 +582,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState(): class CollectionState():
prog_items: typing.Counter[Tuple[str, int]] prog_items: typing.Counter[Tuple[str, int]]
multiworld: MultiWorld world: 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]]
events: Set[Location] events: Set[Location]
@@ -620,7 +594,7 @@ class CollectionState():
def __init__(self, parent: MultiWorld): def __init__(self, parent: MultiWorld):
self.prog_items = Counter() self.prog_items = Counter()
self.multiworld = parent self.world = 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()}
self.events = set() self.events = set()
@@ -634,14 +608,15 @@ class CollectionState():
self.collect(item, True) self.collect(item, True)
def update_reachable_regions(self, player: int): def update_reachable_regions(self, player: int):
from worlds.alttp.EntranceShuffle import indirect_connections
self.stale[player] = False self.stale[player] = False
rrp = self.reachable_regions[player] rrp = self.reachable_regions[player]
bc = 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.world.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 rrp: if not start in rrp:
rrp.add(start) rrp.add(start)
bc.update(start.exits) bc.update(start.exits)
queue.extend(start.exits) queue.extend(start.exits)
@@ -653,7 +628,7 @@ class CollectionState():
if new_region in rrp: if new_region in rrp:
bc.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, "tried to search through an Entrance with no Region"
rrp.add(new_region) rrp.add(new_region)
bc.remove(connection) bc.remove(connection)
bc.update(new_region.exits) bc.update(new_region.exits)
@@ -661,12 +636,13 @@ class CollectionState():
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()): if new_region.name in indirect_connections:
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
if new_entrance in bc 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.world)
ret.prog_items = self.prog_items.copy() 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}
@@ -687,25 +663,25 @@ class CollectionState():
assert isinstance(player, int), "can_reach: player is required if spot is str" assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name # try to resolve a name
if resolution_hint == 'Location': if resolution_hint == 'Location':
spot = self.multiworld.get_location(spot, player) spot = self.world.get_location(spot, player)
elif resolution_hint == 'Entrance': elif resolution_hint == 'Entrance':
spot = self.multiworld.get_entrance(spot, player) spot = self.world.get_entrance(spot, player)
else: else:
# default to Region # default to Region
spot = self.multiworld.get_region(spot, player) spot = self.world.get_region(spot, player)
return spot.can_reach(self) return spot.can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None: def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None: if locations is None:
locations = self.multiworld.get_filled_locations() locations = self.world.get_filled_locations()
reachable_events = True new_locations = True
# since the loop has a good chance to run more than once, only filter the events once # since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.event and location not in self.events and locations = {location for location in locations if location.event and
not key_only or getattr(location.item, "locked_dungeon_item", False)} not key_only or getattr(location.item, "locked_dungeon_item", False)}
while reachable_events: while new_locations:
reachable_events = {location for location in locations if location.can_reach(self)} reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_events new_locations = reachable_events - self.events
for event in reachable_events: for event in new_locations:
self.events.add(event) self.events.add(event)
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)
@@ -724,7 +700,7 @@ class CollectionState():
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
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player] found += self.prog_items[item_name, player]
if found >= count: if found >= count:
return True return True
@@ -732,17 +708,17 @@ class CollectionState():
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
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]: for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player] found += self.prog_items[item_name, player]
return found return found
def can_buy_unlimited(self, item: str, player: int) -> bool: def can_buy_unlimited(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
shop in self.multiworld.shops) shop in self.world.shops)
def can_buy(self, item: str, player: int) -> bool: def can_buy(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
shop in self.multiworld.shops) shop in self.world.shops)
def item_count(self, item: str, player: int) -> int: def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player] return self.prog_items[item, player]
@@ -762,7 +738,7 @@ class CollectionState():
return self.has('Power Glove', player) or self.has('Titans Mitts', player) return self.has('Power Glove', player) or self.has('Titans Mitts', player)
def bottle_count(self, player: int) -> int: def bottle_count(self, player: int) -> int:
return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit, return min(self.world.difficulty_requirements[player].progressive_bottle_limit,
self.count_group("Bottles", player)) self.count_group("Bottles", player))
def has_hearts(self, player: int, count: int) -> int: def has_hearts(self, player: int, count: int) -> int:
@@ -771,7 +747,7 @@ class CollectionState():
def heart_count(self, player: int) -> int: def heart_count(self, player: int) -> int:
# Warning: This only considers items that are marked as advancement items # Warning: This only considers items that are marked as advancement items
diff = self.multiworld.difficulty_requirements[player] diff = self.world.difficulty_requirements[player]
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \ return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
+ self.item_count('Sanctuary Heart Container', player) \ + self.item_count('Sanctuary Heart Container', player) \
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \ + min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
@@ -788,9 +764,9 @@ class CollectionState():
elif self.has('Magic Upgrade (1/2)', player): elif self.has('Magic Upgrade (1/2)', player):
basemagic = 16 basemagic = 16
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player): if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
if self.multiworld.item_functionality[player] == 'hard' and not fullrefill: if self.world.item_functionality[player] == 'hard' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player)) basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill: elif self.world.item_functionality[player] == 'expert' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player)) basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
else: else:
basemagic = basemagic + basemagic * self.bottle_count(player) basemagic = basemagic + basemagic * self.bottle_count(player)
@@ -805,12 +781,12 @@ class CollectionState():
or (self.has('Bombs (10)', player) and enemies < 6)) or (self.has('Bombs (10)', player) and enemies < 6))
def can_shoot_arrows(self, player: int) -> bool: def can_shoot_arrows(self, player: int) -> bool:
if self.multiworld.retro_bow[player]: if self.world.retro_bow[player]:
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player) return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
return self.has('Bow', player) or self.has('Silver Bow', player) return self.has('Bow', player) or self.has('Silver Bow', player)
def can_get_good_bee(self, player: int) -> bool: def can_get_good_bee(self, player: int) -> bool:
cave = self.multiworld.get_region('Good Bee Cave', player) cave = self.world.get_region('Good Bee Cave', player)
return ( return (
self.has_group("Bottles", player) and self.has_group("Bottles", player) and
self.has('Bug Catching Net', player) and self.has('Bug Catching Net', player) and
@@ -821,7 +797,7 @@ class CollectionState():
def can_retrieve_tablet(self, player: int) -> bool: def can_retrieve_tablet(self, player: int) -> bool:
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
(self.multiworld.swordless[player] and (self.world.swordless[player] and
self.has("Hammer", player))) self.has("Hammer", player)))
def has_sword(self, player: int) -> bool: def has_sword(self, player: int) -> bool:
@@ -843,7 +819,7 @@ class CollectionState():
def can_melt_things(self, player: int) -> bool: def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \ return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and (self.has('Bombos', player) and
(self.multiworld.swordless[player] or (self.world.swordless[player] or
self.has_sword(player))) self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool: def can_avoid_lasers(self, player: int) -> bool:
@@ -853,7 +829,7 @@ class CollectionState():
if self.has('Moon Pearl', player): if self.has('Moon Pearl', player):
return True return True
return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world return region.is_light_world if self.world.mode[player] != 'inverted' else region.is_dark_world
def can_reach_light_world(self, player: int) -> bool: def can_reach_light_world(self, player: int) -> bool:
if True in [i.is_light_world for i in self.reachable_regions[player]]: if True in [i.is_light_world for i in self.reachable_regions[player]]:
@@ -866,24 +842,24 @@ class CollectionState():
return False return False
def has_misery_mire_medallion(self, player: int) -> bool: def has_misery_mire_medallion(self, player: int) -> bool:
return self.has(self.multiworld.required_medallions[player][0], player) return self.has(self.world.required_medallions[player][0], player)
def has_turtle_rock_medallion(self, player: int) -> bool: def has_turtle_rock_medallion(self, player: int) -> bool:
return self.has(self.multiworld.required_medallions[player][1], player) return self.has(self.world.required_medallions[player][1], player)
def can_boots_clip_lw(self, player: int) -> bool: def can_boots_clip_lw(self, player: int) -> bool:
if self.multiworld.mode[player] == 'inverted': if self.world.mode[player] == 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player) return self.has('Pegasus Boots', player)
def can_boots_clip_dw(self, player: int) -> bool: def can_boots_clip_dw(self, player: int) -> bool:
if self.multiworld.mode[player] != 'inverted': if self.world.mode[player] != 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player) return self.has('Pegasus Boots', player)
def can_get_glitched_speed_lw(self, player: int) -> bool: def can_get_glitched_speed_lw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.multiworld.mode[player] == 'inverted': if self.world.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player)) rules.append(self.has('Moon Pearl', player))
return all(rules) return all(rules)
@@ -892,7 +868,7 @@ class CollectionState():
def can_get_glitched_speed_dw(self, player: int) -> bool: def can_get_glitched_speed_dw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.multiworld.mode[player] != 'inverted': if self.world.mode[player] != 'inverted':
rules.append(self.has('Moon Pearl', player)) rules.append(self.has('Moon Pearl', player))
return all(rules) return all(rules)
@@ -903,7 +879,7 @@ class CollectionState():
if location: if location:
self.locations_checked.add(location) self.locations_checked.add(location)
changed = self.multiworld.worlds[item.player].collect(self, item) changed = self.world.worlds[item.player].collect(self, item)
if not changed and event: if not changed and event:
self.prog_items[item.name, item.player] += 1 self.prog_items[item.name, item.player] += 1
@@ -917,7 +893,7 @@ class CollectionState():
return changed return changed
def remove(self, item: Item): def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item) changed = self.world.worlds[item.player].remove(self, item)
if changed: if changed:
# invalidate caches, nothing can be trusted anymore now # invalidate caches, nothing can be trusted anymore now
self.reachable_regions[item.player] = set() self.reachable_regions[item.player] = set()
@@ -944,7 +920,7 @@ class Region:
type: RegionType type: RegionType
hint_text: str hint_text: str
player: int player: int
multiworld: Optional[MultiWorld] world: Optional[MultiWorld]
entrances: List[Entrance] entrances: List[Entrance]
exits: List[Entrance] exits: List[Entrance]
locations: List[Location] locations: List[Location]
@@ -962,7 +938,7 @@ class Region:
self.entrances = [] self.entrances = []
self.exits = [] self.exits = []
self.locations = [] self.locations = []
self.multiworld = world self.world = world
self.hint_text = hint self.hint_text = hint
self.player = player self.player = player
@@ -990,7 +966,7 @@ class Region:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
class Entrance: class Entrance:
@@ -1017,7 +993,7 @@ class Entrance:
return False return False
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None: def connect(self, region: Region, addresses=None, target=None):
self.connected_region = region self.connected_region = region
self.target = target self.target = target
self.addresses = addresses self.addresses = addresses
@@ -1027,7 +1003,7 @@ class Entrance:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
world = self.parent_region.multiworld if self.parent_region else None world = self.parent_region.world if self.parent_region else None
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
@@ -1041,7 +1017,7 @@ class Dungeon(object):
self.dungeon_items = dungeon_items self.dungeon_items = dungeon_items
self.bosses = dict() self.bosses = dict()
self.player = player self.player = player
self.multiworld = None self.world = None
@property @property
def boss(self) -> Optional[Boss]: def boss(self) -> Optional[Boss]:
@@ -1071,7 +1047,7 @@ class Dungeon(object):
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
class Boss(): class Boss():
@@ -1105,7 +1081,7 @@ class Location:
show_in_spoiler: bool = True show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow = staticmethod(lambda item, state: False) always_allow = staticmethod(lambda item, state: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) access_rule = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True) item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None item: Optional[Item] = None
@@ -1116,15 +1092,13 @@ class Location:
self.parent_region = parent self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return (self.always_allow(state, item) return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))))
def can_reach(self, state: CollectionState) -> bool: def can_reach(self, state: CollectionState) -> bool:
# self.access_rule computes faster on average, so placing it first for faster abort # self.access_rule computes faster on average, so placing it first for faster abort
assert self.parent_region, "Can't reach location without region" if self.access_rule(state) and self.parent_region.can_reach(state):
return self.access_rule(state) and self.parent_region.can_reach(state) return True
return False
def place_locked_item(self, item: Item): def place_locked_item(self, item: Item):
if self.item: if self.item:
@@ -1138,7 +1112,7 @@ class Location:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None world = self.parent_region.world if self.parent_region and self.parent_region.world else None
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
def __hash__(self): def __hash__(self):
@@ -1235,17 +1209,17 @@ class Item:
return self.__str__() return self.__str__()
def __str__(self) -> str: def __str__(self) -> str:
if self.location and self.location.parent_region and self.location.parent_region.multiworld: if self.location and self.location.parent_region and self.location.parent_region.world:
return self.location.parent_region.multiworld.get_name_string_for_object(self) return self.location.parent_region.world.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})" return f"{self.name} (Player {self.player})"
class Spoiler(): class Spoiler():
multiworld: MultiWorld world: MultiWorld
unreachables: Set[Location] unreachables: Set[Location]
def __init__(self, world): def __init__(self, world):
self.multiworld = world self.world = world
self.hashes = {} self.hashes = {}
self.entrances = OrderedDict() self.entrances = OrderedDict()
self.medallions = {} self.medallions = {}
@@ -1257,7 +1231,7 @@ class Spoiler():
self.bosses = OrderedDict() self.bosses = OrderedDict()
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int): def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
if self.multiworld.players == 1: if self.world.players == 1:
self.entrances[(entrance, direction, player)] = OrderedDict( self.entrances[(entrance, direction, player)] = OrderedDict(
[('entrance', entrance), ('exit', exit_), ('direction', direction)]) [('entrance', entrance), ('exit', exit_), ('direction', direction)])
else: else:
@@ -1266,45 +1240,45 @@ class Spoiler():
def parse_data(self): def parse_data(self):
self.medallions = OrderedDict() self.medallions = OrderedDict()
for player in self.multiworld.get_game_players("A Link to the Past"): for player in self.world.get_game_players("A Link to the Past"):
self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \ self.medallions[f'Misery Mire ({self.world.get_player_name(player)})'] = \
self.multiworld.required_medallions[player][0] self.world.required_medallions[player][0]
self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \ self.medallions[f'Turtle Rock ({self.world.get_player_name(player)})'] = \
self.multiworld.required_medallions[player][1] self.world.required_medallions[player][1]
self.locations = OrderedDict() self.locations = OrderedDict()
listed_locations = set() listed_locations = set()
lw_locations = [loc for loc in self.multiworld.get_locations() if lw_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
self.locations['Light World'] = OrderedDict( self.locations['Light World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
lw_locations]) lw_locations])
listed_locations.update(lw_locations) listed_locations.update(lw_locations)
dw_locations = [loc for loc in self.multiworld.get_locations() if dw_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
self.locations['Dark World'] = OrderedDict( self.locations['Dark World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
dw_locations]) dw_locations])
listed_locations.update(dw_locations) listed_locations.update(dw_locations)
cave_locations = [loc for loc in self.multiworld.get_locations() if cave_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
self.locations['Caves'] = OrderedDict( self.locations['Caves'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
cave_locations]) cave_locations])
listed_locations.update(cave_locations) listed_locations.update(cave_locations)
for dungeon in self.multiworld.dungeons.values(): for dungeon in self.world.dungeons.values():
dungeon_locations = [loc for loc in self.multiworld.get_locations() if dungeon_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
self.locations[str(dungeon)] = OrderedDict( self.locations[str(dungeon)] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
dungeon_locations]) dungeon_locations])
listed_locations.update(dungeon_locations) listed_locations.update(dungeon_locations)
other_locations = [loc for loc in self.multiworld.get_locations() if other_locations = [loc for loc in self.world.get_locations() if
loc not in listed_locations and loc.show_in_spoiler] loc not in listed_locations and loc.show_in_spoiler]
if other_locations: if other_locations:
self.locations['Other Locations'] = OrderedDict( self.locations['Other Locations'] = OrderedDict(
@@ -1314,7 +1288,7 @@ class Spoiler():
self.shops = [] self.shops = []
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
for shop in self.multiworld.shops: for shop in self.world.shops:
if not shop.custom: if not shop.custom:
continue continue
shopdata = { shopdata = {
@@ -1343,34 +1317,34 @@ class Spoiler():
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}" index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
self.shops.append(shopdata) self.shops.append(shopdata)
for player in self.multiworld.get_game_players("A Link to the Past"): for player in self.world.get_game_players("A Link to the Past"):
self.bosses[str(player)] = OrderedDict() self.bosses[str(player)] = OrderedDict()
self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name
self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name
self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name self.bosses[str(player)]["Tower Of Hera"] = self.world.get_dungeon("Tower of Hera", player).boss.name
self.bosses[str(player)]["Hyrule Castle"] = "Agahnim" self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness", self.bosses[str(player)]["Palace Of Darkness"] = self.world.get_dungeon("Palace of Darkness",
player).boss.name player).boss.name
self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name self.bosses[str(player)]["Swamp Palace"] = self.world.get_dungeon("Swamp Palace", player).boss.name
self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name self.bosses[str(player)]["Skull Woods"] = self.world.get_dungeon("Skull Woods", player).boss.name
self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name self.bosses[str(player)]["Thieves Town"] = self.world.get_dungeon("Thieves Town", player).boss.name
self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name self.bosses[str(player)]["Ice Palace"] = self.world.get_dungeon("Ice Palace", player).boss.name
self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name self.bosses[str(player)]["Misery Mire"] = self.world.get_dungeon("Misery Mire", player).boss.name
self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name self.bosses[str(player)]["Turtle Rock"] = self.world.get_dungeon("Turtle Rock", player).boss.name
if self.multiworld.mode[player] != 'inverted': if self.world.mode[player] != 'inverted':
self.bosses[str(player)]["Ganons Tower Basement"] = \ self.bosses[str(player)]["Ganons Tower Basement"] = \
self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name self.world.get_dungeon('Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[ self.bosses[str(player)]["Ganons Tower Middle"] = self.world.get_dungeon('Ganons Tower', player).bosses[
'middle'].name 'middle'].name
self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[ self.bosses[str(player)]["Ganons Tower Top"] = self.world.get_dungeon('Ganons Tower', player).bosses[
'top'].name 'top'].name
else: else:
self.bosses[str(player)]["Ganons Tower Basement"] = \ self.bosses[str(player)]["Ganons Tower Basement"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name self.world.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = \ self.bosses[str(player)]["Ganons Tower Middle"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name self.world.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
self.bosses[str(player)]["Ganons Tower Top"] = \ self.bosses[str(player)]["Ganons Tower Top"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name self.world.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2" self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
self.bosses[str(player)]["Ganon"] = "Ganon" self.bosses[str(player)]["Ganon"] = "Ganon"
@@ -1400,7 +1374,7 @@ class Spoiler():
return 'Yes' if variable else 'No' return 'Yes' if variable else 'No'
def write_option(option_key: str, option_obj: type(Options.Option)): def write_option(option_key: str, option_obj: type(Options.Option)):
res = getattr(self.multiworld, option_key)[player] res = getattr(self.world, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key) display_name = getattr(option_obj, "display_name", option_key)
try: try:
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n') outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
@@ -1410,59 +1384,59 @@ class Spoiler():
with open(filename, 'w', encoding="utf-8-sig") as outfile: with open(filename, 'w', encoding="utf-8-sig") as outfile:
outfile.write( outfile.write(
'Archipelago Version %s - Seed: %s\n\n' % ( 'Archipelago Version %s - Seed: %s\n\n' % (
Utils.__version__, self.multiworld.seed)) Utils.__version__, self.world.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm) outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players) outfile.write('Players: %d\n' % self.world.players)
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile) AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
for player in range(1, self.multiworld.players + 1): for player in range(1, self.world.players + 1):
if self.multiworld.players > 1: if self.world.players > 1:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('\nPlayer %d: %s\n' % (player, self.world.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player]) outfile.write('Game: %s\n' % self.world.game[player])
for f_option, option in Options.per_game_common_options.items(): for f_option, option in Options.per_game_common_options.items():
write_option(f_option, option) write_option(f_option, option)
options = self.multiworld.worlds[player].option_definitions options = self.world.worlds[player].option_definitions
if options: if options:
for f_option, option in options.items(): 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.world, "write_spoiler_header", player, outfile)
if player in self.multiworld.get_game_players("A Link to the Past"): if player in self.world.get_game_players("A Link to the Past"):
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player])) outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
outfile.write('Logic: %s\n' % self.multiworld.logic[player]) outfile.write('Logic: %s\n' % self.world.logic[player])
outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player]) outfile.write('Dark Room Logic: %s\n' % self.world.dark_room_logic[player])
outfile.write('Mode: %s\n' % self.multiworld.mode[player]) outfile.write('Mode: %s\n' % self.world.mode[player])
outfile.write('Goal: %s\n' % self.multiworld.goal[player]) outfile.write('Goal: %s\n' % self.world.goal[player])
if "triforce" in self.multiworld.goal[player]: # triforce hunt if "triforce" in self.world.goal[player]: # triforce hunt
outfile.write("Pieces available for Triforce: %s\n" % outfile.write("Pieces available for Triforce: %s\n" %
self.multiworld.triforce_pieces_available[player]) self.world.triforce_pieces_available[player])
outfile.write("Pieces required for Triforce: %s\n" % outfile.write("Pieces required for Triforce: %s\n" %
self.multiworld.triforce_pieces_required[player]) self.world.triforce_pieces_required[player])
outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player]) outfile.write('Difficulty: %s\n' % self.world.difficulty[player])
outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player]) outfile.write('Item Functionality: %s\n' % self.world.item_functionality[player])
outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player]) outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
if self.multiworld.shuffle[player] != "vanilla": if self.world.shuffle[player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed) outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
outfile.write('Shop inventory shuffle: %s\n' % outfile.write('Shop inventory shuffle: %s\n' %
bool_to_text("i" in self.multiworld.shop_shuffle[player])) bool_to_text("i" in self.world.shop_shuffle[player]))
outfile.write('Shop price shuffle: %s\n' % outfile.write('Shop price shuffle: %s\n' %
bool_to_text("p" in self.multiworld.shop_shuffle[player])) bool_to_text("p" in self.world.shop_shuffle[player]))
outfile.write('Shop upgrade shuffle: %s\n' % outfile.write('Shop upgrade shuffle: %s\n' %
bool_to_text("u" in self.multiworld.shop_shuffle[player])) bool_to_text("u" in self.world.shop_shuffle[player]))
outfile.write('New Shop inventory: %s\n' % outfile.write('New Shop inventory: %s\n' %
bool_to_text("g" in self.multiworld.shop_shuffle[player] or bool_to_text("g" in self.world.shop_shuffle[player] or
"f" in self.multiworld.shop_shuffle[player])) "f" in self.world.shop_shuffle[player]))
outfile.write('Custom Potion Shop: %s\n' % outfile.write('Custom Potion Shop: %s\n' %
bool_to_text("w" in self.multiworld.shop_shuffle[player])) bool_to_text("w" in self.world.shop_shuffle[player]))
outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player]) outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player]) outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
outfile.write('Prize shuffle %s\n' % outfile.write('Prize shuffle %s\n' %
self.multiworld.shuffle_prizes[player]) self.world.shuffle_prizes[player])
if self.entrances: if self.entrances:
outfile.write('\n\nEntrances:\n\n') outfile.write('\n\nEntrances:\n\n')
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: ' outfile.write('\n'.join(['%s%s %s %s' % (f'{self.world.get_player_name(entry["player"])}: '
if self.multiworld.players > 1 else '', entry['entrance'], if self.world.players > 1 else '', entry['entrance'],
'<=>' if entry['direction'] == 'both' else '<=>' if entry['direction'] == 'both' else
'<=' if entry['direction'] == 'exit' else '=>', '<=' if entry['direction'] == 'exit' else '=>',
entry['exit']) for entry in self.entrances.values()])) entry['exit']) for entry in self.entrances.values()]))
@@ -1472,7 +1446,7 @@ class Spoiler():
for dungeon, medallion in self.medallions.items(): for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}') outfile.write(f'\n{dungeon}: {medallion}')
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile) AutoWorld.call_all(self.world, "write_spoiler", outfile)
outfile.write('\n\nLocations:\n\n') outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join( outfile.write('\n'.join(
@@ -1485,11 +1459,11 @@ class Spoiler():
item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
item)) for shop in self.shops)) item)) for shop in self.shops))
for player in self.multiworld.get_game_players("A Link to the Past"): for player in self.world.get_game_players("A Link to the Past"):
if self.multiworld.boss_shuffle[player] != 'none': if self.world.boss_shuffle[player] != 'none':
bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
outfile.write( outfile.write(
f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n') f'\n\nBosses{(f" ({self.world.get_player_name(player)})" if self.world.players > 1 else "")}:\n')
outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()])) outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
outfile.write('\n\nPlaythrough:\n\n') outfile.write('\n\nPlaythrough:\n\n')
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join( outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
@@ -1513,7 +1487,7 @@ class Spoiler():
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines))) path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
outfile.write('\n'.join(path_listings)) outfile.write('\n'.join(path_listings))
AutoWorld.call_all(self.multiworld, "write_spoiler_end", outfile) AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
class Tutorial(NamedTuple): class Tutorial(NamedTuple):

View File

@@ -20,13 +20,10 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \ from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
from Utils import Version, stream_input, async_start from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
if typing.TYPE_CHECKING:
import kvui
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
# without terminal, we have to use gui mode # without terminal, we have to use gui mode
@@ -47,18 +44,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool: def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server""" """Connect to a MultiWorld Server"""
if address: self.ctx.server_address = None
self.ctx.server_address = None self.ctx.username = None
self.ctx.username = None asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
async_start(self.ctx.connect(address if address else None), name="connecting")
return True return True
def _cmd_disconnect(self) -> bool: def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server""" """Disconnect from a MultiWorld Server"""
async_start(self.ctx.disconnect(), name="disconnecting") self.ctx.server_address = None
self.ctx.username = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True return True
def _cmd_received(self) -> bool: def _cmd_received(self) -> bool:
@@ -96,18 +91,12 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_items(self): def _cmd_items(self):
"""List all item names for the currently running game.""" """List all item names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing items.")
return False
self.output(f"Item Names for {self.ctx.game}") self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name) self.output(item_name)
def _cmd_locations(self): def _cmd_locations(self):
"""List all location names for the currently running game.""" """List all location names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}") self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name) self.output(location_name)
@@ -121,12 +110,12 @@ class ClientCommandProcessor(CommandProcessor):
else: else:
state = ClientStatus.CLIENT_CONNECTED state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.") self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str): def default(self, raw: str):
raw = self.ctx.on_user_say(raw) raw = self.ctx.on_user_say(raw)
if raw: if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext: class CommonContext:
@@ -143,36 +132,28 @@ class CommonContext:
# defaults # defaults
starting_reconnect_delay: int = 5 starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional["asyncio.Task[None]"] = None input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None keep_alive_task: typing.Optional[asyncio.Task] = None
server_task: typing.Optional["asyncio.Task[None]"] = None server_task: typing.Optional[asyncio.Task] = None
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
disconnected_intentionally: bool = False
server: typing.Optional[Endpoint] = None server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0) server_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server current_energy_link_value: int = 0 # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info # remaining type info
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str] server_address: str
password: typing.Optional[str] password: typing.Optional[str]
hint_cost: typing.Optional[int] hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str] player_names: typing.Dict[int, str]
finished_game: bool
ready: bool
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations # locations
locations_checked: typing.Set[int] # local state locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int] locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem]
missing_locations: typing.Set[int] # server state missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
@@ -180,11 +161,9 @@ class CommonContext:
# internals # internals
# current message box through kvui # current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None _messagebox = None
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: def __init__(self, server_address, password):
# server state # server state
self.server_address = server_address self.server_address = server_address
self.username = None self.username = None
@@ -227,12 +206,6 @@ class CommonContext:
# execution # execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property
def suggested_address(self) -> str:
if self.server_address:
return self.server_address
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
@functools.cached_property @functools.cached_property
def raw_text_parser(self) -> RawJSONtoTextParser: def raw_text_parser(self) -> RawJSONtoTextParser:
return RawJSONtoTextParser(self) return RawJSONtoTextParser(self)
@@ -244,9 +217,9 @@ class CommonContext:
return len(self.checked_locations | self.missing_locations) return len(self.checked_locations | self.missing_locations)
async def connection_closed(self): async def connection_closed(self):
self.reset_server_state()
if self.server and self.server.socket is not None: if self.server and self.server.socket is not None:
await self.server.socket.close() await self.server.socket.close()
self.reset_server_state()
def reset_server_state(self): def reset_server_state(self):
self.auth = None self.auth = None
@@ -264,18 +237,13 @@ class CommonContext:
"remaining": "disabled", "remaining": "disabled",
} }
async def disconnect(self, allow_autoreconnect: bool = False): async def disconnect(self):
if not allow_autoreconnect:
self.disconnected_intentionally = True
if self.cancel_autoreconnect():
logger.info("Cancelled auto-reconnect.")
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
if self.server_task is not None: if self.server_task is not None:
await self.server_task await self.server_task
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: async def send_msgs(self, msgs):
""" `msgs` JSON serializable """
if not self.server or not self.server.socket.open or self.server.socket.closed: if not self.server or not self.server.socket.open or self.server.socket.closed:
return return
await self.server.socket.send(encode(msgs)) await self.server.socket.send(encode(msgs))
@@ -303,8 +271,7 @@ class CommonContext:
logger.info('Enter slot name:') logger.info('Enter slot name:')
self.auth = await self.console_input() self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None: async def send_connect(self, **kwargs):
""" send `Connect` packet to log in to server """
payload = { payload = {
'cmd': 'Connect', 'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -315,24 +282,14 @@ class CommonContext:
payload.update(kwargs) payload.update(kwargs)
await self.send_msgs([payload]) await self.send_msgs([payload])
async def console_input(self) -> str: async def console_input(self):
if self.ui:
self.ui.focus_textinput()
self.input_requests += 1 self.input_requests += 1
return await self.input_queue.get() return await self.input_queue.get()
async def connect(self, address: typing.Optional[str] = None) -> None: async def connect(self, address=None):
""" disconnect any previous connection, and open new connection to the server """
await self.disconnect() await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def cancel_autoreconnect(self) -> bool:
if self.autoreconnect_task:
self.autoreconnect_task.cancel()
self.autoreconnect_task = None
return True
return False
def slot_concerns_self(self, slot) -> bool: def slot_concerns_self(self, slot) -> bool:
if slot == self.slot: if slot == self.slot:
return True return True
@@ -340,12 +297,6 @@ class CommonContext:
return self.slot in self.slot_info[slot].group_members return self.slot in self.slot_info[slot].group_members
return False return False
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def on_print(self, args: dict): def on_print(self, args: dict):
logger.info(args["text"]) logger.info(args["text"])
@@ -377,7 +328,6 @@ class CommonContext:
async def shutdown(self): async def shutdown(self):
self.server_address = "" self.server_address = ""
self.username = None self.username = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
if self.server_task: if self.server_task:
@@ -440,7 +390,7 @@ class CommonContext:
# DeathLink hooks # DeathLink hooks
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: def on_deathlink(self, data: dict):
"""Gets dispatched when a new DeathLink is triggered by another linked player.""" """Gets dispatched when a new DeathLink is triggered by another linked player."""
self.last_death_link = max(data["time"], self.last_death_link) self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "") text = data.get("cause", "")
@@ -471,10 +421,10 @@ class CommonContext:
if old_tags != self.tags and self.server and not self.server.socket.closed: if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]: def gui_error(self, title: str, text: typing.Union[Exception, str]):
"""Displays an error messagebox""" """Displays an error messagebox"""
if not self.ui: if not self.ui:
return None return
title = title or "Error" title = title or "Error"
from kvui import MessageBox from kvui import MessageBox
if self._messagebox: if self._messagebox:
@@ -491,13 +441,6 @@ class CommonContext:
# display error # display error
self._messagebox = MessageBox(title, text, error=True) self._messagebox = MessageBox(title, text, error=True)
self._messagebox.open() self._messagebox.open()
return self._messagebox
def _handle_connection_loss(self, msg: str) -> None:
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
exc_info = sys.exc_info()
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def run_gui(self): def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task.""" """Import kivy UI system and start running it as self.ui_task."""
@@ -534,7 +477,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
seconds_elapsed = 0 seconds_elapsed = 0
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None: async def server_loop(ctx: CommonContext, address=None):
if ctx.server and ctx.server.socket: if ctx.server and ctx.server.socket:
logger.error('Already connected') logger.error('Already connected')
return return
@@ -547,11 +490,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info('Please connect to an Archipelago server.') logger.info('Please connect to an Archipelago server.')
return return
ctx.cancel_autoreconnect()
if ctx._messagebox_connection_loss:
ctx._messagebox_connection_loss.dismiss()
ctx._messagebox_connection_loss = None
address = f"ws://{address}" if "://" not in address \ address = f"ws://{address}" if "://" not in address \
else address.replace("archipelago://", "ws://") else address.replace("archipelago://", "ws://")
@@ -562,9 +500,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
ctx.password = server_url.password ctx.password = server_url.password
port = server_url.port or 38281 port = server_url.port or 38281
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
logger.info(f'Connecting to Archipelago server at {address}') logger.info(f'Connecting to Archipelago server at {address}')
try: try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
@@ -574,25 +509,31 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info('Connected') logger.info('Connected')
ctx.server_address = address ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay ctx.current_reconnect_delay = ctx.starting_reconnect_delay
ctx.disconnected_intentionally = False
async for data in ctx.server.socket: async for data in ctx.server.socket:
for msg in decode(data): for msg in decode(data):
await process_server_cmd(ctx, msg) await process_server_cmd(ctx, msg)
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}") logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError: except ConnectionRefusedError as e:
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.") msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
except websockets.InvalidURI: logger.exception(msg, extra={'compact_gui': True})
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)") ctx.gui_error(msg, e)
except OSError: except websockets.InvalidURI as e:
ctx._handle_connection_loss("Failed to connect to the multiworld server") msg = 'Failed to connect to the multiworld server (invalid URI)'
except Exception: logger.exception(msg, extra={'compact_gui': True})
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}") ctx.gui_error(msg, e)
except OSError as e:
msg = 'Failed to connect to the multiworld server'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except Exception as e:
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
finally: finally:
await ctx.connection_closed() await ctx.connection_closed()
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally: if ctx.server_address:
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds") logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
assert ctx.autoreconnect_task is None asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2 ctx.current_reconnect_delay *= 2
@@ -703,9 +644,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.checked_locations = set(args["checked_locations"]) ctx.checked_locations = set(args["checked_locations"])
ctx.server_locations = ctx.missing_locations | ctx. checked_locations ctx.server_locations = ctx.missing_locations | ctx. checked_locations
server_url = urllib.parse.urlparse(ctx.server_address)
Utils.persistent_store("client", "last_server_address", server_url.netloc)
elif cmd == 'ReceivedItems': elif cmd == 'ReceivedItems':
start_index = args["index"] start_index = args["index"]
@@ -784,7 +722,7 @@ async def console_loop(ctx: CommonContext):
logger.exception(e) logger.exception(e)
def get_base_parser(description: typing.Optional[str] = None): def get_base_parser(description=None):
import argparse import argparse
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.') parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -816,6 +754,7 @@ if __name__ == '__main__':
async def main(args): async def main(args):
ctx = TextContext(args.connect, args.password) ctx = TextContext(args.connect, args.password)
ctx.auth = args.name ctx.auth = args.name
ctx.server_address = args.connect
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled: if gui_enabled:

View File

@@ -7,7 +7,6 @@ from typing import List
import Utils import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser get_base_parser
@@ -70,7 +69,7 @@ class FF1Context(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True)) asyncio.create_task(parse_locations(self.locations_array, self, True))
elif cmd == 'Print': elif cmd == 'Print':
msg = args['text'] msg = args['text']
if ': !' not in msg: if ': !' not in msg:
@@ -181,7 +180,7 @@ async def nes_sync_task(ctx: FF1Context):
# print(data_decoded) # print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded: if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse # Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False)) asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth: if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '': if ctx.auth == '':

View File

@@ -4,12 +4,9 @@ import logging
import json import json
import string import string
import copy import copy
import re
import subprocess import subprocess
import sys
import time import time
import random import random
import typing
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -20,18 +17,12 @@ import asyncio
from queue import Queue from queue import Queue
import Utils import Utils
def check_stdin() -> None:
if Utils.is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client") Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
from MultiServer import mark_raw from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from Utils import async_start
from worlds.factorio import Factorio from worlds.factorio import Factorio
@@ -39,10 +30,6 @@ from worlds.factorio import Factorio
class FactorioCommandProcessor(ClientCommandProcessor): class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext ctx: FactorioContext
def _cmd_energy_link(self):
"""Print the status of the energy link."""
self.output(f"Energy Link: {self.ctx.energy_link_status}")
@mark_raw @mark_raw
def _cmd_factorio(self, text: str) -> bool: def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server.""" """Send the following command to the bound Factorio Server."""
@@ -59,13 +46,6 @@ class FactorioCommandProcessor(ClientCommandProcessor):
"""Manually trigger a resync.""" """Manually trigger a resync."""
self.ctx.awaiting_bridge = True self.ctx.awaiting_bridge = True
def _cmd_toggle_send_filter(self):
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
self.ctx.toggle_filter_item_sends()
def _cmd_toggle_chat(self):
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
self.ctx.toggle_bridge_chat_out()
class FactorioContext(CommonContext): class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor command_processor = FactorioCommandProcessor
@@ -85,9 +65,6 @@ class FactorioContext(CommonContext):
self.factorio_json_text_parser = FactorioJSONtoTextParser(self) self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 0 self.energy_link_increment = 0
self.last_deplete = 0 self.last_deplete = 0
self.filter_item_sends: bool = False
self.multiplayer: bool = False # whether multiple different players have connected
self.bridge_chat_out: bool = True
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -104,15 +81,12 @@ class FactorioContext(CommonContext):
def on_print(self, args: dict): def on_print(self, args: dict):
super(FactorioContext, self).on_print(args) super(FactorioContext, self).on_print(args)
if self.rcon_client: if self.rcon_client:
if not args['text'].startswith(self.player_names[self.slot] + ":"): self.print_to_game(args['text'])
self.print_to_game(args['text'])
def on_print_json(self, args: dict): def on_print_json(self, args: dict):
if self.rcon_client: if self.rcon_client:
if not self.filter_item_sends or not self.is_uninteresting_item_send(args): text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) self.print_to_game(text)
if not text.startswith(self.player_names[self.slot] + ":"):
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args) super(FactorioContext, self).on_print_json(args)
@property @property
@@ -123,15 +97,6 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}") f"{text}")
@property
def energy_link_status(self) -> str:
if not self.energy_link_increment:
return "Disabled"
elif self.current_energy_link_value is None:
return "Standby"
else:
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict): def on_deathlink(self, data: dict):
if self.rcon_client: if self.rcon_client:
self.rcon_client.send_command(f"/ap-deathlink {data['source']}") self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
@@ -144,7 +109,7 @@ class FactorioContext(CommonContext):
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]}) item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment: if cmd == "Connected" and self.energy_link_increment:
async_start(self.send_msgs([{ asyncio.create_task(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"] "cmd": "SetNotify", "keys": ["EnergyLink"]
}])) }]))
elif cmd == "SetReply": elif cmd == "SetReply":
@@ -158,45 +123,6 @@ class FactorioContext(CommonContext):
f"{Utils.format_SI_prefix(args['value'])}J remaining.") f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}") self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]:
# Mirror chat sent from the UI to the Factorio server.
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
return text
async def chat_from_factorio(self, user: str, message: str) -> None:
if not self.bridge_chat_out:
return
# Pass through commands
if message.startswith("!"):
await self.send_msgs([{"cmd": "Say", "text": message}])
return
# Omit messages that contain local coordinates
if "[gps=" in message:
return
prefix = f"({user}) " if self.multiplayer else ""
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
def toggle_filter_item_sends(self) -> None:
self.filter_item_sends = not self.filter_item_sends
if self.filter_item_sends:
announcement = "Item sends are now filtered."
else:
announcement = "Item sends are no longer filtered."
logger.info(announcement)
self.print_to_game(announcement)
def toggle_bridge_chat_out(self) -> None:
self.bridge_chat_out = not self.bridge_chat_out
if self.bridge_chat_out:
announcement = "Chat is now bridged to Archipelago."
else:
announcement = "Chat is no longer bridged to Archipelago."
logger.info(announcement)
self.print_to_game(announcement)
def run_gui(self): def run_gui(self):
from kvui import GameManager from kvui import GameManager
@@ -214,6 +140,7 @@ class FactorioContext(CommonContext):
async def game_watcher(ctx: FactorioContext): async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher") bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
next_bridge = time.perf_counter() + 1 next_bridge = time.perf_counter() + 1
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
@@ -235,7 +162,6 @@ async def game_watcher(ctx: FactorioContext):
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"] victory = data["victory"]
await ctx.update_death_link(data["death_link"]) await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
if not ctx.finished_game and victory: if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
@@ -244,14 +170,14 @@ async def game_watcher(ctx: FactorioContext):
if ctx.locations_checked != research_data: if ctx.locations_checked != research_data:
bridge_logger.debug( bridge_logger.debug(
f"New researches done: " f"New researches done: "
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}") f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}]) await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0) death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick: if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags: if "DeathLink" in ctx.tags:
async_start(ctx.send_death()) asyncio.create_task(ctx.send_death())
if ctx.energy_link_increment: if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"] in_world_bridges = data["energy_bridges"]
if in_world_bridges: if in_world_bridges:
@@ -259,7 +185,7 @@ async def game_watcher(ctx: FactorioContext):
if in_world_energy < (ctx.energy_link_increment * in_world_bridges): if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill # attempt to refill
ctx.last_deplete = time.time() ctx.last_deplete = time.time()
async_start(ctx.send_msgs([{ asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations": "cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges}, [{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}], {"operation": "max", "value": 0}],
@@ -269,7 +195,7 @@ async def game_watcher(ctx: FactorioContext):
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \ elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges: ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges value = ctx.energy_link_increment * in_world_bridges
async_start(ctx.send_msgs([{ asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations": "cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}] [{"operation": "add", "value": value}]
}])) }]))
@@ -285,8 +211,6 @@ async def game_watcher(ctx: FactorioContext):
def stream_factorio_output(pipe, queue, process): def stream_factorio_output(pipe, queue, process):
pipe.reconfigure(errors="replace")
def queuer(): def queuer():
while process.poll() is None: while process.poll() is None:
text = pipe.readline().strip() text = pipe.readline().strip()
@@ -319,7 +243,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if factorio_process.poll() is not None: if factorio_process.poll():
factorio_server_logger.info("Factorio server has exited.") factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set() ctx.exit_event.set()
@@ -332,25 +256,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
if not ctx.server: if not ctx.server:
logger.info("Established bridge to Factorio Server. " logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect") "Ready to connect to Archipelago via /connect")
check_stdin()
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True ctx.awaiting_bridge = True
factorio_server_logger.debug(msg) factorio_server_logger.debug(msg)
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
factorio_server_logger.debug(msg)
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_filter_item_sends()
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_bridge_chat_out()
else: else:
factorio_server_logger.info(msg) factorio_server_logger.info(msg)
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
if match:
await ctx.chat_from_factorio(match.group(1), match.group(2))
if ctx.rcon_client: if ctx.rcon_client:
commands = {} commands = {}
while ctx.send_index < len(ctx.items_received): while ctx.send_index < len(ctx.items_received):
@@ -371,34 +282,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
logging.error("Aborted Factorio Server Bridge") logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set() ctx.exit_event.set()
finally: finally:
if factorio_process.poll() is not None: factorio_process.terminate()
if ctx.rcon_client: factorio_process.wait(5)
ctx.rcon_client.close()
ctx.rcon_client = None
return
sent_quit = False
if ctx.rcon_client:
# Attempt clean quit through RCON.
try:
ctx.rcon_client.send_command("/quit")
except factorio_rcon.RCONNetworkError:
pass
else:
sent_quit = True
ctx.rcon_client.close()
ctx.rcon_client = None
if not sent_quit:
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
factorio_process.terminate()
try:
factorio_process.wait(10)
except subprocess.TimeoutExpired:
factorio_process.kill()
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient): async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
@@ -472,8 +361,6 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
async def main(args): async def main(args):
ctx = FactorioContext(args.connect, args.password) ctx = FactorioContext(args.connect, args.password)
ctx.filter_item_sends = initial_filter_item_sends
ctx.bridge_chat_out = initial_bridge_chat_out
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled: if gui_enabled:
@@ -526,12 +413,6 @@ if __name__ == '__main__':
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None) server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
if server_settings: if server_settings:
server_settings = os.path.abspath(server_settings) server_settings = os.path.abspath(server_settings)
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
if not os.path.exists(os.path.dirname(executable)): if not os.path.exists(os.path.dirname(executable)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")

320
Fill.py
View File

@@ -4,10 +4,9 @@ import collections
import itertools import itertools
from collections import Counter, deque from collections import Counter, deque
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
from worlds.AutoWorld import call_all from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError): class FillError(RuntimeError):
@@ -23,8 +22,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],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
@@ -71,66 +69,60 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else: else:
# we filled all reachable spots. # we filled all reachable spots.
if swap: # try swapping this item with previously placed items
# try swapping this item with previously placed items for (i, location) in enumerate(placements):
for (i, location) in enumerate(placements): placed_item = location.item
placed_item = location.item # Unplaceable items can sometimes be swapped infinitely. Limit the
# Unplaceable items can sometimes be swapped infinitely. Limit the # number of times we will swap an individual item to prevent this
# number of times we will swap an individual item to prevent this swap_count = swapped_items[placed_item.player,
swap_count = swapped_items[placed_item.player, placed_item.name]
placed_item.name] if swap_count > 1:
if swap_count > 1:
continue
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item])
# swap_state assumes we can collect placed item before item_to_place
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations, which could happen with rules
# that want to not have both items. Left in until removal is proven useful.
prev_state = swap_state.copy()
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue continue
else:
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state)
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations
prev_state = swap_state.copy()
prev_state.collect(placed_item)
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place) unplaced_items.append(item_to_place)
continue continue
world.push_item(spot_to_fill, item_to_place, False) world.push_item(spot_to_fill, item_to_place, False)
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
if on_place:
on_place(spot_to_fill)
if len(unplaced_items) > 0 and len(locations) > 0: if len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them # There are leftover unplaceable items and locations that won't accept them
@@ -217,121 +209,12 @@ def fast_fill(world: MultiWorld,
return item_pool[placing:], fill_locations[placing:] return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
pool.append(location.item)
state.remove(location.item)
location.item = None
location.event = False
if location in state.events:
state.events.remove(location)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(world, state, locations, pool)
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
for player in world.player_ids:
items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items:
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
base_state = world.state.copy()
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
early_priority_locations.append(loc)
else:
early_locations.append(loc)
loc_indexes_to_remove.add(i)
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
if item.advancement:
if early_items_count[(item.name, item.player)][1]:
early_local_prog_items[item.player].append(item)
early_items_count[(item.name, item.player)][1] -= 1
else:
early_prog_items.append(item)
early_items_count[(item.name, item.player)][0] -= 1
else:
if early_items_count[(item.name, item.player)][1]:
early_local_rest_items[item.player].append(item)
early_items_count[(item.name, item.player)][1] -= 1
else:
early_rest_items.append(item)
early_items_count[(item.name, item.player)][0] -= 1
item_indexes_to_remove.add(i)
if early_items_count[(item.name, item.player)] == [0, 0]:
del early_items_count[(item.name, item.player)]
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in world.player_ids:
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
early_local_rest_items[player], lock=True)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True)
early_locations += early_priority_locations
for player in world.player_ids:
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
early_local_prog_items[player], lock=True)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True)
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
f"{len(unplaced_early_items)} items early.")
itempool += unplaced_early_items
fill_locations.extend(early_locations)
world.random.shuffle(fill_locations)
return fill_locations, itempool
def distribute_items_restrictive(world: MultiWorld) -> None: def distribute_items_restrictive(world: MultiWorld) -> None:
fill_locations = sorted(world.get_unfilled_locations()) fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations) world.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
itempool = sorted(world.itempool) itempool = sorted(world.itempool)
world.random.shuffle(itempool) world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = [] progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = [] usefulitempool: typing.List[Item] = []
filleritempool: typing.List[Item] = [] filleritempool: typing.List[Item] = []
@@ -356,33 +239,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
defaultlocations = locations[LocationProgressType.DEFAULT] defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED] excludedlocations = locations[LocationProgressType.EXCLUDED]
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
lock_later = []
def mark_for_locking(location: Location):
nonlocal lock_later
lock_later.append(location)
if prioritylocations: if prioritylocations:
# "priority fill"
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool) 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')
accessibility_corrections(world, world.state, defaultlocations)
for location in lock_later:
if location.item:
location.locked = True
del mark_for_locking, lock_later
inaccessible_location_rules(world, world.state, defaultlocations)
remaining_fill(world, excludedlocations, filleritempool) remaining_fill(world, excludedlocations, filleritempool)
if excludedlocations: if excludedlocations:
@@ -683,17 +548,6 @@ def distribute_planned(world: MultiWorld) -> None:
else: else:
warn(warning, force) warn(warning, force)
swept_state = world.state.copy()
swept_state.sweep_for_events()
reachable = frozenset(world.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in world.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
# TODO: remove. Preferably by implementing key drop # TODO: remove. Preferably by implementing key drop
from worlds.alttp.Regions import key_drop_data from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup world_name_lookup = world.world_name_lookup
@@ -709,39 +563,7 @@ def distribute_planned(world: MultiWorld) -> None:
if 'from_pool' not in block: if 'from_pool' not in block:
block['from_pool'] = True block['from_pool'] = True
if 'world' not in block: if 'world' not in block:
target_world = False block['world'] = False
else:
target_world = block['world']
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
block['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds = {world_name_lookup[target_world]}
block['world'] = worlds
items: block_value = [] items: block_value = []
if "items" in block: if "items" in block:
items = block["items"] items = block["items"]
@@ -778,17 +600,6 @@ def distribute_planned(world: MultiWorld) -> None:
for key, value in locations.items(): for key, value in locations.items():
location_list += [key] * value location_list += [key] * value
locations = location_list locations = location_list
if "early_locations" in locations:
locations.remove("early_locations")
for player in worlds:
locations += early_locations[player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for player in worlds:
locations += non_early_locations[player]
block['locations'] = locations block['locations'] = locations
if not block['count']: if not block['count']:
@@ -824,11 +635,38 @@ def distribute_planned(world: MultiWorld) -> None:
for placement in plando_blocks: for placement in plando_blocks:
player = placement['player'] player = placement['player']
try: try:
worlds = placement['world'] target_world = placement['world']
locations = placement['locations'] locations = placement['locations']
items = placement['items'] items = placement['items']
maxcount = placement['count']['target'] maxcount = placement['count']['target']
from_pool = placement['from_pool'] from_pool = placement['from_pool']
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
placement['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds = {world_name_lookup[target_world]}
candidates = list(location for location in world.get_unfilled_locations_for_players(locations, candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
worlds)) worlds))

View File

@@ -154,12 +154,11 @@ def main(args=None, callback=ERmain):
# sort dict for consistent results across platforms: # sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items())} weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items(): for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}: for yaml in yaml_data:
for yaml in yaml_data: print(f"P{player_id} Weights: {filename} >> "
print(f"P{player_id} Weights: {filename} >> " f"{get_choice('description', yaml, 'No description specified')}")
f"{get_choice('description', yaml, 'No description specified')}") player_files[player_id] = filename
player_files[player_id] = filename player_id += 1
player_id += 1
args.multi = max(player_id - 1, args.multi) args.multi = max(player_id - 1, args.multi)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: " print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
@@ -233,8 +232,8 @@ def main(args=None, callback=ERmain):
else: else:
raise RuntimeError(f'No weights specified for player {player}') raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name): if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
if args.yaml_output: if args.yaml_output:
import yaml import yaml
@@ -317,11 +316,11 @@ class SafeDict(dict):
def handle_name(name: str, player: int, name_counter: Counter): def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1 name_counter[name] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number, new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
NUMBER=(number if number > 1 else ''), NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player, player=player,
PLAYER=(player if player > 1 else ''))) PLAYER=(player if player > 1 else '')))
new_name = new_name.strip()[:16] new_name = new_name.strip()[:16]
@@ -378,7 +377,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
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)
return category_dict[option_key] return options[option_key]
if game == "A Link to the Past": # TODO wow i hate this if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode", if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra", "triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",

View File

@@ -132,7 +132,7 @@ components: Iterable[Component] = (
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI # SNI
Component('SNI Client', 'SNIClient', Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')), file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio # Factorio
Component('Factorio Client', 'FactorioClient'), Component('Factorio Client', 'FactorioClient'),
@@ -145,15 +145,10 @@ components: Iterable[Component] = (
Component('OoT Adjuster', 'OoTAdjuster'), Component('OoT Adjuster', 'OoTAdjuster'),
# FF1 # FF1
Component('FF1 Client', 'FF1Client'), Component('FF1 Client', 'FF1Client'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# ChecksFinder # ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'), Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2 # Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'), Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# Functions # Functions
Component('Open host.yaml', func=open_host_yaml), Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch), Component('Open Patch', func=open_patch),

View File

@@ -26,9 +26,7 @@ ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, tkinter_center_window, init_logging get_adjuster_settings, tkinter_center_window, init_logging
from Patch import GAME_ALTTP
GAME_ALTTP = "A Link to the Past"
class AdjusterWorld(object): class AdjusterWorld(object):
@@ -141,7 +139,7 @@ def adjust(args):
vanillaRom = args.baserom vanillaRom = args.baserom
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom): if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
vanillaRom = local_path(vanillaRom) vanillaRom = local_path(vanillaRom)
if os.path.splitext(args.rom)[-1].lower() == '.aplttp': if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
import Patch import Patch
meta, args.rom = Patch.create_rom_file(args.rom) meta, args.rom = Patch.create_rom_file(args.rom)
@@ -197,7 +195,7 @@ def adjustGUI():
romEntry2 = Entry(romDialogFrame, textvariable=romVar2) romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
def RomSelect2(): def RomSelect2():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")]) rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
romVar2.set(rom) romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2) romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
@@ -727,7 +725,7 @@ def get_rom_options_frame(parent=None):
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply) vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame) autoApplyFrame = Frame(romOptionsFrame)
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W) autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files") filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
filler.pack(side=TOP, expand=True, fill=X) filler.pack(side=TOP, expand=True, fill=X)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask') askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
askRadio.pack(side=LEFT, padx=5, pady=5) askRadio.pack(side=LEFT, padx=5, pady=5)

55
Main.py
View File

@@ -8,15 +8,15 @@ import concurrent.futures
import pickle import pickle
import tempfile import tempfile
import zipfile import zipfile
from typing import Dict, List, Tuple, Optional, Set from typing import Dict, Tuple, Optional, Set
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
from worlds.alttp.Items import item_name_groups from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import is_main_entrance from worlds.alttp.Regions import is_main_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from Utils import output_path, get_options, __version__, version_tuple from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
from worlds import AutoWorld from worlds import AutoWorld
ordered_areas = ( ordered_areas = (
@@ -80,30 +80,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Found World Types:") logger.info("Found World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
numlength = 8
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
item_digits = len(str(max_item))
location_digits = len(str(max_location))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
del max_item, max_location
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0: if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} " logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - " f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{item_digits}}) | " f"{max(cls.item_id_to_name):{numlength}}) | "
f"{len(cls.location_names):{location_count}} " f"{len(cls.location_names):3} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - " f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{location_digits}})") f"{max(cls.location_id_to_name):{numlength}})")
del item_digits, location_digits, item_count, location_count
AutoWorld.call_stage(world, "assert_generate") AutoWorld.call_stage(world, "assert_generate")
@@ -122,7 +107,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]: if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].value.add('Triforce Piece') world.local_items[player].value.add('Triforce Piece')
# Not possible to place pendants/crystals outside boss prizes yet. # Not possible to place pendants/crystals out side of boss prizes yet.
world.non_local_items[player].value -= item_name_groups['Pendants'] world.non_local_items[player].value -= item_name_groups['Pendants']
world.non_local_items[player].value -= item_name_groups['Crystals'] world.non_local_items[player].value -= item_name_groups['Crystals']
@@ -137,7 +122,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Calculating Access Rules.') logger.info('Calculating Access Rules.')
if world.players > 1: if world.players > 1:
locality_rules(world) for player in world.player_ids:
locality_rules(world, player)
group_locality_rules(world)
else: else:
world.non_local_items[1].value = set() world.non_local_items[1].value = set()
world.local_items[1].value = set() world.local_items[1].value = set()
@@ -154,10 +141,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# temporary home for item links, should be moved out of Main # temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items(): for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ def find_common_pool(players: Set[int], shared_pool: Set[str]):
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]] classifications = collections.defaultdict(int)
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players} counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool: for item in world.itempool:
if item.player in counters and item.name in shared_pool: if item.player in counters and item.name in shared_pool:
@@ -167,7 +152,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in players.copy(): for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]): if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player) players.remove(player)
del (counters[player]) del(counters[player])
if not players: if not players:
return None, None return None, None
@@ -179,14 +164,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
counters[player][item] = count counters[player][item] = count
else: else:
for player in players: for player in players:
del (counters[player][item]) del(counters[player][item])
return counters, classifications return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"]) common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count: if not common_item_count:
continue continue
new_itempool: List[Item] = [] new_itempool = []
for item_name, item_count in next(iter(common_item_count.values())).items(): for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count): for _ in range(item_count):
new_item = group["world"].create_item(item_name) new_item = group["world"].create_item(item_name)

View File

@@ -13,12 +13,10 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
if not update_ran: if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")): for entry in os.scandir(os.path.join(local_dir, "worlds")):
# skip .* (hidden / disabled) folders if entry.is_dir():
if not entry.name.startswith("."): req_file = os.path.join(entry.path, "requirements.txt")
if entry.is_dir(): if os.path.exists(req_file):
req_file = os.path.join(entry.path, "requirements.txt") requirements_files.add(req_file)
if os.path.exists(req_file):
requirements_files.add(req_file)
def update_command(): def update_command():
@@ -39,25 +37,11 @@ def update(yes=False, force=False):
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 line.startswith(("https://", "git+https://")): if line.startswith('https://'):
# extract name and version for url # extract name and version from url
rest = line.split('/')[-1] wheel = line.split('/')[-1]
line = "" name, version, _ = wheel.split('-', 2)
if "#egg=" in rest: line = f'{name}=={version}'
# from egg info
rest, egg = rest.split("#egg=", 1)
egg = egg.split(";", 1)[0]
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
line = egg
else:
egg = ""
if "@" in rest and not line:
raise ValueError("Can't deduce version from requirement")
elif not line:
# from filename
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
name, version, _ = rest.split("-", 2)
line = f'{egg or name}=={version}'
requirements = pkg_resources.parse_requirements(line) requirements = pkg_resources.parse_requirements(line)
for requirement in requirements: for requirement in requirements:
requirement = str(requirement) requirement = str(requirement)

View File

@@ -31,7 +31,7 @@ except ImportError:
import NetUtils import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start from Utils import version_tuple, restricted_loads, Version
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType SlotType
@@ -273,16 +273,16 @@ class Context:
def broadcast_all(self, msgs: typing.List[dict]): def broadcast_all(self, msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth) endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_team(self, team: int, msgs: typing.List[dict]): def broadcast_team(self, team: int, msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values())) endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]): def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
async def disconnect(self, endpoint: Client): async def disconnect(self, endpoint: Client):
if endpoint in self.endpoints: if endpoint in self.endpoints:
@@ -302,18 +302,18 @@ class Context:
return return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
if client.version >= print_command_compatability_threshold: if client.version >= print_command_compatability_threshold:
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}])) asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
else: else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text}])) asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str]): def notify_client_multiple(self, client: Client, texts: typing.List[str]):
if not client.auth: if not client.auth:
return return
if client.version >= print_command_compatability_threshold: if client.version >= print_command_compatability_threshold:
async_start(self.send_msgs(client, asyncio.create_task(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts])) [{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
else: else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts])) asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
# loading # loading
@@ -627,7 +627,7 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
continue continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)] client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients: for client in clients:
async_start(ctx.send_msgs(client, client_hints)) asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int): def update_aliases(ctx: Context, team: int):
@@ -636,7 +636,7 @@ def update_aliases(ctx: Context, team: int):
for clients in ctx.clients[team].values(): for clients in ctx.clients[team].values():
for client in clients: for client in clients:
async_start(ctx.send_encoded_msgs(client, cmd)) asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
async def server(websocket, path: str = "/", ctx: Context = None): async def server(websocket, path: str = "/", ctx: Context = None):
@@ -814,7 +814,7 @@ def send_new_items(ctx: Context):
items = get_received_items(ctx, team, slot, client.remote_items) items = get_received_items(ctx, team, slot, client.remote_items)
if len(start_inventory) + len(items) > client.send_index: if len(start_inventory) + len(items) > client.send_index:
first_new_item = max(0, client.send_index - len(start_inventory)) first_new_item = max(0, client.send_index - len(start_inventory))
async_start(ctx.send_msgs(client, [{ asyncio.create_task(ctx.send_msgs(client, [{
"cmd": "ReceivedItems", "cmd": "ReceivedItems",
"index": client.send_index, "index": client.send_index,
"items": start_inventory[client.send_index:] + items[first_new_item:]}])) "items": start_inventory[client.send_index:] + items[first_new_item:]}]))
@@ -998,11 +998,7 @@ class CommandMeta(type):
return super(CommandMeta, cls).__new__(cls, name, bases, attrs) return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
_Return = typing.TypeVar("_Return") def mark_raw(function):
# TODO: when python 3.10 is lowest supported, typing.ParamSpec
def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]:
function.raw_text = True function.raw_text = True
return function return function
@@ -1090,7 +1086,7 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10) timer = int(seconds, 10)
except ValueError: except ValueError:
timer = 10 timer = 10
async_start(countdown(self.ctx, timer)) asyncio.create_task(countdown(self.ctx, timer))
return True return True
def _cmd_options(self): def _cmd_options(self):
@@ -1332,8 +1328,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool: def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client) points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
if not input_text: if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot]}
@@ -1388,6 +1382,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False return False
if hints: if hints:
cost = self.ctx.get_hint_cost(self.client.slot)
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot] new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints old_hints = set(hints) - new_hints
if old_hints: if old_hints:
@@ -1437,12 +1432,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
return True return True
else: else:
if points_available >= cost: self.output("Nothing found. Item/Location may not exist.")
self.output("Nothing found. Item/Location may not exist.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
return False return False
@mark_raw @mark_raw
@@ -1771,7 +1761,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Shutdown the server""" """Shutdown the server"""
async_start(self.ctx.server.ws_server._close()) asyncio.create_task(self.ctx.server.ws_server._close())
if self.ctx.shutdown_task: if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel() self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set() self.ctx.exit_event.set()
@@ -1802,33 +1792,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response) self.output(response)
return False return False
def resolve_player(self, input_name: str) -> typing.Optional[typing.Tuple[int, int, str]]:
""" returns (team, slot, player name) """
# TODO: clean up once we disallow multidata < 0.3.6, which has CI unique names
# first match case
for (team, slot), name in self.ctx.player_names.items():
if name == input_name:
return team, slot, name
# if no case-sensitive match, then match without case only if there's only 1 match
input_lower = input_name.lower()
match: typing.Optional[typing.Tuple[int, int, str]] = None
for (team, slot), name in self.ctx.player_names.items():
lowered = name.lower()
if lowered == input_lower:
if match:
return None # ambiguous input_name
match = (team, slot, name)
return match
@mark_raw @mark_raw
def _cmd_collect(self, player_name: str) -> bool: def _cmd_collect(self, player_name: str) -> bool:
"""Send out the remaining items to player.""" """Send out the remaining items to player."""
player = self.resolve_player(player_name) seeked_player = player_name.lower()
if player: for (team, slot), name in self.ctx.player_names.items():
team, slot, _ = player if name.lower() == seeked_player:
collect_player(self.ctx, team, slot) collect_player(self.ctx, team, slot)
return True return True
self.output(f"Could not find player {player_name} to collect") self.output(f"Could not find player {player_name} to collect")
return False return False
@@ -1841,11 +1812,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_forfeit(self, player_name: str) -> bool: def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients.""" """Send out the remaining items from a player to their intended recipients."""
player = self.resolve_player(player_name) seeked_player = player_name.lower()
if player: for (team, slot), name in self.ctx.player_names.items():
team, slot, _ = player if name.lower() == seeked_player:
forfeit_player(self.ctx, team, slot) forfeit_player(self.ctx, team, slot)
return True return True
self.output(f"Could not find player {player_name} to release") self.output(f"Could not find player {player_name} to release")
return False return False
@@ -1853,12 +1824,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_allow_forfeit(self, player_name: str) -> bool: def _cmd_allow_forfeit(self, player_name: str) -> bool:
"""Allow the specified player to use the !release command.""" """Allow the specified player to use the !release command."""
player = self.resolve_player(player_name) seeked_player = player_name.lower()
if player: for (team, slot), name in self.ctx.player_names.items():
team, slot, name = player if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = True self.ctx.allow_forfeits[(team, slot)] = True
self.output(f"Player {name} is now allowed to use the !release command at any time.") self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
return True return True
self.output(f"Could not find player {player_name} to allow the !release command for.") self.output(f"Could not find player {player_name} to allow the !release command for.")
return False return False
@@ -1866,12 +1837,13 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_forbid_forfeit(self, player_name: str) -> bool: def _cmd_forbid_forfeit(self, player_name: str) -> bool:
""""Disallow the specified player from using the !release command.""" """"Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name) seeked_player = player_name.lower()
if player: for (team, slot), name in self.ctx.player_names.items():
team, slot, name = player if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = False self.ctx.allow_forfeits[(team, slot)] = False
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.") self.output(
return True f"Player {player_name} has to follow the server restrictions on use of the !release command.")
return True
self.output(f"Could not find player {player_name} to forbid the !release command for.") self.output(f"Could not find player {player_name} to forbid the !release command for.")
return False return False
@@ -2084,7 +2056,7 @@ async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown) await asyncio.sleep(ctx.auto_shutdown)
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values(): if not ctx.client_activity_timers.values():
async_start(ctx.server.ws_server._close()) asyncio.create_task(ctx.server.ws_server._close())
ctx.exit_event.set() ctx.exit_event.set()
if to_cancel: if to_cancel:
for task in to_cancel: for task in to_cancel:
@@ -2095,7 +2067,7 @@ async def auto_shutdown(ctx, to_cancel=None):
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds() seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0: if seconds < 0:
async_start(ctx.server.ws_server._close()) asyncio.create_task(ctx.server.ws_server._close())
ctx.exit_event.set() ctx.exit_event.set()
if to_cancel: if to_cancel:
for task in to_cancel: for task in to_cancel:

View File

@@ -100,7 +100,7 @@ _encode = JSONEncoder(
).encode ).encode
def encode(obj: typing.Any) -> str: def encode(obj):
return _encode(_scan_for_TypedTuples(obj)) return _encode(_scan_for_TypedTuples(obj))

View File

@@ -5,11 +5,9 @@ import multiprocessing
import subprocess import subprocess
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
# CommonClient import first to trigger ModuleUpdater from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser ClientCommandProcessor, logger, get_base_parser
import Utils import Utils
from Utils import async_start
from worlds import network_data_package from worlds import network_data_package
from worlds.oot.Rom import Rom, compress_rom_file from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file from worlds.oot.N64Patch import apply_patch_file
@@ -70,7 +68,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, OoTContext): if isinstance(self.ctx, OoTContext):
self.ctx.deathlink_client_override = True self.ctx.deathlink_client_override = True
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink") asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
class OoTContext(CommonContext): class OoTContext(CommonContext):
@@ -134,19 +132,6 @@ def get_payload(ctx: OoTContext):
async def parse_payload(payload: dict, ctx: OoTContext, force: bool): async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
# Refuse to do anything if ROM is detected as changed
if ctx.auth and payload['playerName'] != ctx.auth:
logger.warning("ROM change detected. Disconnecting and reconnecting...")
ctx.deathlink_enabled = False
ctx.deathlink_client_override = False
ctx.finished_game = False
ctx.location_table = {}
ctx.deathlink_pending = False
ctx.deathlink_sent_this_death = False
ctx.auth = payload['playerName']
await ctx.send_connect()
return
# Turn on deathlink if it is on, and if the client hasn't overriden it # Turn on deathlink if it is on, and if the client hasn't overriden it
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override: if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
await ctx.update_death_link(True) await ctx.update_death_link(True)
@@ -204,7 +189,7 @@ async def n64_sync_task(ctx: OoTContext):
if reported_version >= script_version: if reported_version >= script_version:
if ctx.game is not None and 'locations' in data_decoded: if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse # Not just a keep alive ping, parse
async_start(parse_payload(data_decoded, ctx, False)) asyncio.create_task(parse_payload(data_decoded, ctx, False))
if not ctx.auth: if not ctx.auth:
ctx.auth = data_decoded['playerName'] ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom: if ctx.awaiting_rom:
@@ -280,7 +265,7 @@ async def patch_and_run_game(apz5_file):
os.chdir(data_path("Compress")) os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path) compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path) os.remove(decomp_path)
async_start(run_game(comp_path)) asyncio.create_task(run_game(comp_path))
if __name__ == '__main__': if __name__ == '__main__':
@@ -296,7 +281,7 @@ if __name__ == '__main__':
if args.apz5_file: if args.apz5_file:
logger.info("APZ5 file supplied, beginning patching process...") logger.info("APZ5 file supplied, beginning patching process...")
async_start(patch_and_run_game(args.apz5_file)) asyncio.create_task(patch_and_run_game(args.apz5_file))
ctx = OoTContext(args.connect, args.password) ctx = OoTContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
from copy import deepcopy
import math import math
import numbers import numbers
import typing import typing
@@ -79,9 +78,6 @@ class AssembleOptions(abc.ABCMeta):
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs) return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
@abc.abstractclassmethod
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
T = typing.TypeVar('T') T = typing.TypeVar('T')
@@ -169,7 +165,6 @@ class FreeText(Option):
class NumericOption(Option[int], numbers.Integral): class NumericOption(Option[int], numbers.Integral):
default = 0
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards # note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs # `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True) # (even though isinstance(5, numbers.Integral) == True)
@@ -431,6 +426,7 @@ class TextChoice(Choice):
assert isinstance(value, str) or isinstance(value, int), \ assert isinstance(value, str) or isinstance(value, int), \
f"{value} is not a valid option for {self.__class__.__name__}" f"{value} is not a valid option for {self.__class__.__name__}"
self.value = value self.value = value
super(TextChoice, self).__init__()
@property @property
def current_key(self) -> str: def current_key(self) -> str:
@@ -470,124 +466,6 @@ class TextChoice(Choice):
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class BossMeta(AssembleOptions):
def __new__(mcs, name, bases, attrs):
if name != "PlandoBosses":
assert "bosses" in attrs, f"Please define valid bosses for {name}"
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
assert "locations" in attrs, f"Please define valid locations for {name}"
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
cls = super().__new__(mcs, name, bases, attrs)
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
return cls
class PlandoBosses(TextChoice, metaclass=BossMeta):
"""Generic boss shuffle option that supports plando. Format expected is
'location1-boss1;location2-boss2;shuffle_mode'.
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
which passes a plando boss and location. Check if the placement is valid for your game here."""
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
duplicate_bosses: bool = False
@classmethod
def from_text(cls, text: str):
# set all of our text to lower case for name checking
text = text.lower()
if text == "random":
return cls(random.choice(list(cls.options.values())))
for option_name, value in cls.options.items():
if option_name == text:
return cls(value)
options = text.split(";")
# since plando exists in the option verify the plando values given are valid
cls.validate_plando_bosses(options)
return cls.get_shuffle_mode(options)
@classmethod
def get_shuffle_mode(cls, option_list: typing.List[str]):
# find out what mode of boss shuffle we should use for placing bosses after plando
# and add as a string to look nice in the spoiler
if "random" in option_list:
shuffle = random.choice(list(cls.options))
option_list.remove("random")
options = ";".join(option_list) + f";{shuffle}"
boss_class = cls(options)
else:
for option in option_list:
if option in cls.options:
options = ";".join(option_list)
break
else:
if cls.duplicate_bosses and len(option_list) == 1:
if cls.valid_boss_name(option_list[0]):
# this doesn't exist in this class but it's a forced option for classes where this is called
options = option_list[0] + ";singularity"
else:
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
else:
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
boss_class = cls(options)
return boss_class
@classmethod
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
used_locations = []
used_bosses = []
for option in options:
# check if a shuffle mode was provided in the incorrect location
if option == "random" or option in cls.options:
if option != options[-1]:
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
elif "-" in option:
location, boss = option.split("-")
if location in used_locations:
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
if not cls.duplicate_bosses and boss in used_bosses:
raise ValueError(f"Duplicate Boss {boss} not allowed.")
used_locations.append(location)
used_bosses.append(boss)
if not cls.valid_boss_name(boss):
raise ValueError(f"{boss.title()} is not a valid boss name.")
if not cls.valid_location_name(location):
raise ValueError(f"{location.title()} is not a valid boss location name.")
if not cls.can_place_boss(boss, location):
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
else:
if cls.duplicate_bosses:
if not cls.valid_boss_name(option):
raise ValueError(f"{option} is not a valid boss name.")
else:
raise ValueError(f"{option.title()} is not formatted correctly.")
@classmethod
def can_place_boss(cls, boss: str, location: str) -> bool:
raise NotImplementedError
@classmethod
def valid_boss_name(cls, value: str) -> bool:
return value in cls.bosses
@classmethod
def valid_location_name(cls, value: str) -> bool:
return value in cls.locations
def verify(self, world, player_name: str, plando_options) -> None:
if isinstance(self.value, int):
return
from Generate import PlandoSettings
if not(PlandoSettings.bosses & plando_options):
import logging
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
f"boss shuffle will be used for player {player_name}.")
class Range(NumericOption): class Range(NumericOption):
range_start = 0 range_start = 0
range_end = 1 range_end = 1
@@ -750,11 +628,11 @@ class VerifyKeys:
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default: typing.Dict[str, typing.Any] = {} default = {}
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]): def __init__(self, value: typing.Dict[str, typing.Any]):
self.value = deepcopy(value) self.value = value
@classmethod @classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
@@ -781,11 +659,11 @@ class ItemDict(OptionDict):
class OptionList(Option[typing.List[typing.Any]], VerifyKeys): class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
default: typing.List[typing.Any] = [] default = []
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.List[typing.Any]): def __init__(self, value: typing.List[typing.Any]):
self.value = deepcopy(value) self.value = value or []
super(OptionList, self).__init__() super(OptionList, self).__init__()
@classmethod @classmethod
@@ -807,11 +685,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys): class OptionSet(Option[typing.Set[str]], VerifyKeys):
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset() default = frozenset()
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.Iterable[str]): def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
self.value = set(deepcopy(value)) self.value = set(value)
super(OptionSet, self).__init__() super(OptionSet, self).__init__()
@classmethod @classmethod
@@ -850,7 +728,7 @@ class Accessibility(Choice):
class ProgressionBalancing(SpecialRange): 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.""" [0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50 default = 50
range_start = 0 range_start = 0
range_end = 99 range_end = 99

428
Patch.py
View File

@@ -1,23 +1,266 @@
from __future__ import annotations from __future__ import annotations
import shutil
import json
import bsdiff4
import yaml
import os import os
import lzma
import threading
import concurrent.futures
import zipfile
import sys import sys
from typing import Tuple, Optional, TypedDict from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
if __name__ == "__main__": import ModuleUpdate
import ModuleUpdate ModuleUpdate.update()
ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APDeltaPatch import Utils
current_patch_version = 5
class RomMeta(TypedDict): class AutoPatchRegister(type):
server: str patch_types: Dict[str, APDeltaPatch] = {}
file_endings: Dict[str, APDeltaPatch] = {}
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
# construct class
new_class = super().__new__(cls, name, bases, dct)
if "game" in dct:
AutoPatchRegister.patch_types[dct["game"]] = new_class
if not dct["patch_file_ending"]:
raise Exception(f"Need an expected file ending for {name}")
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
return new_class
@staticmethod
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
for file_ending, handler in AutoPatchRegister.file_endings.items():
if file.endswith(file_ending):
return handler
class APContainer:
"""A zipfile containing at least archipelago.json"""
version: int = current_patch_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
# instance attributes:
path: Optional[str]
player: Optional[int] player: Optional[int]
player_name: str player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None):
if not self.path and not file:
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
as zf:
if file:
self.path = zf.filename
self.write_contents(zf)
def write_contents(self, opened_zipfile: zipfile.ZipFile):
manifest = self.get_manifest()
try:
manifest = json.dumps(manifest)
except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e
else:
opened_zipfile.writestr("archipelago.json", manifest)
def read(self, file: Optional[Union[str, BinaryIO]] = None):
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
if not self.path and not file:
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "r") as zf:
if file:
self.path = zf.filename
self.read_contents(zf)
def read_contents(self, opened_zipfile: zipfile.ZipFile):
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})")
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
def get_manifest(self) -> dict:
return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 4,
"version": current_patch_version,
}
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]: class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
"""An APContainer that additionally has delta.bsdiff4
containing a delta patch to get the desired file, often a rom."""
hash = Optional[str] # base checksum of source file
patch_file_ending: str = ""
delta: Optional[bytes] = None
result_file_ending: str = ".sfc"
source_data: bytes
def __init__(self, *args, patched_path: str = "", **kwargs):
self.patched_path = patched_path
super(APDeltaPatch, self).__init__(*args, **kwargs)
def get_manifest(self) -> dict:
manifest = super(APDeltaPatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
manifest["patch_file_ending"] = self.patch_file_ending
return manifest
@classmethod
def get_source_data(cls) -> bytes:
"""Get Base data"""
raise NotImplementedError()
@classmethod
def get_source_data_with_cache(cls) -> bytes:
if not hasattr(cls, "source_data"):
cls.source_data = cls.get_source_data()
return cls.source_data
def write_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).write_contents(opened_zipfile)
# write Delta
opened_zipfile.writestr("delta.bsdiff4",
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
def read_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).read_contents(opened_zipfile)
self.delta = opened_zipfile.read("delta.bsdiff4")
def patch(self, target: str):
"""Base + Delta -> Patched"""
if not self.delta:
self.read()
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
with open(target, "wb") as f:
f.write(result)
# legacy patch handling follows:
GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid"
GAME_SOE = "Secret of Evermore"
GAME_SMZ3 = "SMZ3"
GAME_DKC3 = "Donkey Kong Country 3"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe",
GAME_SMZ3: "apsmz",
GAME_DKC3: "apdkc3"
}
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
elif game == GAME_SM:
from worlds.sm.Rom import SMJUHASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
elif game == GAME_SMZ3:
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
from worlds.sm.Rom import SMJUHASH as SMHASH
HASH = ALTTPHASH + SMHASH
elif game == GAME_DKC3:
from worlds.dkc3.Rom import USHASH as HASH
else:
raise RuntimeError(f"Selected game {game} for base rom not found.")
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 3,
"version": current_patch_version,
"base_checksum": HASH})
return patch.encode(encoding="utf-8-sig")
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if metadata is None:
metadata = {}
patch = bsdiff4.diff(get_base_rom_data(game), rom)
return generate_yaml(patch, metadata, game)
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
"player_id": player,
"player_name": player_name}
bytes = generate_patch(load_bytes(rom_file_to_patch),
meta,
game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP
else ".apsmz" if game == GAME_SMZ3
else ".apdkc3" if game == GAME_DKC3
else ".apm3")
write_lzma(bytes, target)
return target
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
game_name = data["game"]
if not ignore_version and data["compatible_version"] > current_patch_version:
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
target = os.path.splitext(patch_file)[0] + ".sfc"
return data["meta"], target, patched_data
def get_base_rom_data(game: str):
if game == GAME_ALTTP:
from worlds.alttp.Rom import get_base_rom_bytes
elif game == "alttp": # old version for A Link to the Past
from worlds.alttp.Rom import get_base_rom_bytes
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
elif game == GAME_SOE:
from worlds.soe.Patch import get_base_rom_path
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
elif game == GAME_SMZ3:
from worlds.smz3.Rom import get_base_rom_bytes
elif game == GAME_DKC3:
from worlds.dkc3.Rom import get_base_rom_bytes
else:
raise RuntimeError("Selected game for base rom not found.")
return get_base_rom_bytes()
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
auto_handler = AutoPatchRegister.get_handler(patch_file) auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler: if auto_handler:
handler: APDeltaPatch = auto_handler(patch_file) handler: APDeltaPatch = auto_handler(patch_file)
@@ -26,10 +269,171 @@ def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
return {"server": handler.server, return {"server": handler.server,
"player": handler.player, "player": handler.player,
"player_name": handler.player_name}, target "player_name": handler.player_name}, target
raise NotImplementedError(f"No Handler for {patch_file} found.") else:
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:
f.write(patched_data)
return data, target
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
data["meta"]["server"] = server
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
return lzma.compress(bytes)
def load_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def write_lzma(data: bytes, path: str):
with lzma.LZMAFile(path, 'wb') as f:
f.write(data)
def read_rom(stream, strip_header=True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
if __name__ == "__main__": if __name__ == "__main__":
for file in sys.argv[1:]: host = Utils.get_public_ipv4()
meta_data, result_file = create_rom_file(file) options = Utils.get_options()['server_options']
print(f"Patch with meta-data {meta_data} was written to {result_file}") if options['host']:
host = options['host']
address = f"{host}:{options['port']}"
ziplock = threading.Lock()
print(f"Host for patches to be created is {address}")
with concurrent.futures.ThreadPoolExecutor() as pool:
for rom in sys.argv:
try:
if rom.endswith(".sfc"):
print(f"Creating patch for {rom}")
result = pool.submit(create_patch_file, rom, address)
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
elif rom.endswith(".apbp"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
#romfile, adjusted = Utils.get_adjuster_settings(target)
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
adjusted = False
if adjuster_settings:
import pprint
from worlds.alttp.Rom import get_base_rom_path
adjuster_settings.rom = target
adjuster_settings.baserom = get_base_rom_path()
adjuster_settings.world = None
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
if hasattr(adjuster_settings, "sprite_pool"):
sprite_pool = {}
for sprite in getattr(adjuster_settings, "sprite_pool"):
if sprite in sprite_pool:
sprite_pool[sprite] += 1
else:
sprite_pool[sprite] = 1
if sprite_pool:
printed_options["sprite_pool"] = sprite_pool
adjust_wanted = str('no')
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
f"{pprint.pformat(printed_options)}\n"
f"Enter yes, no, always or never: ")
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
adjust_wanted = 'no'
elif adjuster_settings.auto_apply == 'always':
adjust_wanted = 'yes'
if adjust_wanted and "never" in adjust_wanted:
adjuster_settings.auto_apply = 'never'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
elif adjust_wanted and "always" in adjust_wanted:
adjuster_settings.auto_apply = 'always'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
else:
adjusted = False
if adjusted:
try:
shutil.move(romfile, target)
romfile = target
except Exception as e:
print(e)
print(f"Created rom {romfile if adjusted else target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apm3"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apsmz"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apdkc3"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".zip"):
print(f"Updating host in patch files contained in {rom}")
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
data = zfr.read(zfinfo)
if zfinfo.filename.endswith(".apbp") or \
zfinfo.filename.endswith(".apm3") or \
zfinfo.filename.endswith(".apdkc3"):
data = update_patch_data(data, server)
with ziplock:
zfw.writestr(zfinfo, data)
return zfinfo.filename
futures = []
with zipfile.ZipFile(rom, "r") as zfr:
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zfw:
for zfname in zfr.namelist():
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
for future in futures:
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
except:
import traceback
traceback.print_exc()
input("Press enter to close.")

View File

@@ -1,304 +0,0 @@
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
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
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}
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
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'
items_handling = 0b101
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
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 Bizhawk 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
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 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()
return 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}
}
)
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:]}
# Check for clear problems
if len(flags['Rod']) > 1:
return
if flags["EventFlag"][1] + flags["EventFlag"][8] + flags["EventFlag"][9] + flags["EventFlag"][12] \
+ flags["EventFlag"][61] + flags["EventFlag"][62] + flags["EventFlag"][63] + flags["EventFlag"][64] \
+ flags["EventFlag"][65] + flags["EventFlag"][66] + flags["EventFlag"][67] + flags["EventFlag"][68] \
+ flags["EventFlag"][69] + flags["EventFlag"][70] != 0:
return
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)
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())
#print(data_decoded)
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 == '':
logger.info("Invalid ROM detected. No player name built into the ROM.")
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))
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'
with open(Utils.local_path(Utils.get_options()["pokemon_rb_options"][f"{game_version}_rom_file"]), "rb") as stream:
base_rom = bytes(stream.read())
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

@@ -28,11 +28,6 @@ Currently, the following games are supported:
* Starcraft 2: Wings of Liberty * Starcraft 2: Wings of Liberty
* Donkey Kong Country 3 * Donkey Kong Country 3
* Dark Souls 3 * Dark Souls 3
* Super Mario World
* Pokémon Red and Blue
* Hylics 2
* Overcooked! 2
* Zillion
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

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,21 @@ import typing
import queue import queue
from pathlib import Path from pathlib import Path
# CommonClient import first to trigger ModuleUpdater import nest_asyncio
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser import sc2
from sc2.bot_ai import BotAI
from sc2.data import Race
from sc2.main import run_game
from sc2.player import Bot
import NetUtils
from MultiServer import mark_raw
from Utils import init_logging, is_windows from Utils import init_logging, is_windows
from worlds.sc2wol import SC2WoLWorld
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Regions import MissionInfo
if __name__ == "__main__": if __name__ == "__main__":
init_logging("SC2Client", exception_logger="Client") init_logging("SC2Client", exception_logger="Client")
@@ -22,21 +34,10 @@ if __name__ == "__main__":
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
sc2_logger = logging.getLogger("Starcraft2") sc2_logger = logging.getLogger("Starcraft2")
import nest_asyncio
import sc2
from sc2.bot_ai import BotAI
from sc2.data import Race
from sc2.main import run_game
from sc2.player import Bot
from worlds.sc2wol import SC2WoLWorld
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Regions import MissionInfo
import colorama import colorama
from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser
from MultiServer import mark_raw from NetUtils import ClientStatus, RawJSONtoTextParser
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
nest_asyncio.apply() nest_asyncio.apply()
max_bonus: int = 8 max_bonus: int = 8
@@ -114,40 +115,12 @@ class StarcraftClientProcessor(ClientCommandProcessor):
"""Manually set the SC2 install directory (if the automatic detection fails).""" """Manually set the SC2 install directory (if the automatic detection fails)."""
if path: if path:
os.environ["SC2PATH"] = path os.environ["SC2PATH"] = path
is_mod_installed_correctly() check_mod_install()
return True return True
else: else:
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.") sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
return False return False
def _cmd_download_data(self, force: bool = False) -> bool:
"""Download the most recent release of the necessary files for playing SC2 with
Archipelago. force should be True or False. force=True will overwrite your files."""
if "SC2PATH" not in os.environ:
check_game_install_path()
if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"):
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f:
current_ver = f.read()
else:
current_ver = None
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', current_version=current_ver, force_download=force)
if tempzip != '':
try:
import zipfile
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
sc2_logger.info(f"Download complete. Version {version} installed.")
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
f.write(version)
finally:
os.remove(tempzip)
else:
sc2_logger.warning("Download aborted/failed. Read the log for more information.")
return False
return True
class SC2Context(CommonContext): class SC2Context(CommonContext):
command_processor = StarcraftClientProcessor command_processor = StarcraftClientProcessor
@@ -155,9 +128,7 @@ class SC2Context(CommonContext):
items_handling = 0b111 items_handling = 0b111
difficulty = -1 difficulty = -1
all_in_choice = 0 all_in_choice = 0
mission_order = 0
mission_req_table: typing.Dict[str, MissionInfo] = {} mission_req_table: typing.Dict[str, MissionInfo] = {}
final_mission: int = 29
announcements = queue.Queue() announcements = queue.Queue()
sc2_run_task: typing.Optional[asyncio.Task] = None sc2_run_task: typing.Optional[asyncio.Task] = None
missions_unlocked: bool = False # allow launching missions ignoring requirements missions_unlocked: bool = False # allow launching missions ignoring requirements
@@ -182,25 +153,16 @@ class SC2Context(CommonContext):
self.difficulty = args["slot_data"]["game_difficulty"] self.difficulty = args["slot_data"]["game_difficulty"]
self.all_in_choice = args["slot_data"]["all_in_map"] self.all_in_choice = args["slot_data"]["all_in_map"]
slot_req_table = args["slot_data"]["mission_req"] slot_req_table = args["slot_data"]["mission_req"]
# Maintaining backwards compatibility with older slot data
self.mission_req_table = { self.mission_req_table = {
mission: MissionInfo( mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
**{field: value for field, value in mission_info.items() if field in MissionInfo._fields}
)
for mission, mission_info in slot_req_table.items()
} }
self.mission_order = args["slot_data"].get("mission_order", 0)
self.final_mission = args["slot_data"].get("final_mission", 29)
self.build_location_to_mission_mapping() self.build_location_to_mission_mapping()
# Looks for the required maps and mods for SC2. Runs check_game_install_path. # Look for and set SC2PATH.
is_mod_installed_correctly() # check_game_install_path() returns True if and only if it finds + sets SC2PATH.
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"): if "SC2PATH" not in os.environ and check_game_install_path():
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f: check_mod_install()
current_ver = f.read()
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
def on_print_json(self, args: dict): def on_print_json(self, args: dict):
# goes to this world # goes to this world
@@ -312,6 +274,7 @@ class SC2Context(CommonContext):
self.refresh_from_launching = True self.refresh_from_launching = True
self.mission_panel.clear_widgets() self.mission_panel.clear_widgets()
if self.ctx.mission_req_table: if self.ctx.mission_req_table:
self.last_checked_locations = self.ctx.checked_locations.copy() self.last_checked_locations = self.ctx.checked_locations.copy()
self.first_check = False self.first_check = False
@@ -329,20 +292,17 @@ class SC2Context(CommonContext):
for category in categories: for category in categories:
category_panel = MissionCategory() category_panel = MissionCategory()
if category.startswith('_'):
category_display_name = ''
else:
category_display_name = category
category_panel.add_widget( category_panel.add_widget(
Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1)) Label(text=category, size_hint_y=None, height=50, outline_width=1))
for mission in categories[category]: for mission in categories[category]:
text: str = mission text: str = mission
tooltip: str = "" tooltip: str = ""
mission_id: int = self.ctx.mission_req_table[mission].id
# Map has uncollected locations # Map has uncollected locations
if mission in unfinished_missions: if mission in unfinished_missions:
text = f"[color=6495ED]{text}[/color]" text = f"[color=6495ED]{text}[/color]"
elif mission in available_missions: elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]" text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met # Map requirements not met
@@ -361,16 +321,6 @@ class SC2Context(CommonContext):
remaining_location_names: typing.List[str] = [ remaining_location_names: typing.List[str] = [
self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
if loc in self.ctx.missing_locations] if loc in self.ctx.missing_locations]
if mission_id == self.ctx.final_mission:
if mission in available_missions:
text = f"[color=FFBC95]{mission}[/color]"
else:
text = f"[color=D0C0BE]{mission}[/color]"
if tooltip:
tooltip += "\n"
tooltip += "Final Mission"
if remaining_location_names: if remaining_location_names:
if tooltip: if tooltip:
tooltip += "\n" tooltip += "\n"
@@ -380,7 +330,7 @@ class SC2Context(CommonContext):
mission_button = MissionButton(text=text, size_hint_y=None, height=50) mission_button = MissionButton(text=text, size_hint_y=None, height=50)
mission_button.tooltip_text = tooltip mission_button.tooltip_text = tooltip
mission_button.bind(on_press=self.mission_callback) mission_button.bind(on_press=self.mission_callback)
self.mission_id_to_button[mission_id] = mission_button self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
category_panel.add_widget(mission_button) category_panel.add_widget(mission_button)
category_panel.add_widget(Label(text="")) category_panel.add_widget(Label(text=""))
@@ -407,9 +357,8 @@ class SC2Context(CommonContext):
self.ui = SC2Manager(self) self.ui = SC2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
import pkgutil
data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode() Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
Builder.load_string(data)
async def shutdown(self): async def shutdown(self):
await super(SC2Context, self).shutdown() await super(SC2Context, self).shutdown()
@@ -489,13 +438,10 @@ wol_default_categories = [
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
"Char", "Char", "Char", "Char" "Char", "Char", "Char", "Char"
] ]
wol_default_category_names = [
"Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char"
]
def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]: def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
network_item: NetworkItem network_item: NetUtils.NetworkItem
accumulators: typing.List[int] = [0 for _ in type_flaggroups] accumulators: typing.List[int] = [0 for _ in type_flaggroups]
for network_item in items: for network_item in items:
@@ -609,7 +555,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
if self.can_read_game: if self.can_read_game:
if game_state & (1 << 1) and not self.mission_completed: if game_state & (1 << 1) and not self.mission_completed:
if self.mission_id != self.ctx.final_mission: if self.mission_id != 29:
print("Mission Completed") print("Mission Completed")
await self.ctx.send_msgs( await self.ctx.send_msgs(
[{"cmd": 'LocationChecks', [{"cmd": 'LocationChecks',
@@ -765,14 +711,13 @@ def calc_available_missions(ctx: SC2Context, unlocks=None):
return available_missions return available_missions
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int): def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete):
"""Returns a bool signifying if the mission has all requirements complete and can be done """Returns a bool signifying if the mission has all requirements complete and can be done
Arguments: Arguments:
ctx -- instance of SC2Context ctx -- instance of SC2Context
locations_to_check -- the mission string name to check locations_to_check -- the mission string name to check
missions_complete -- an int of how many missions have been completed missions_complete -- an int of how many missions have been completed
mission_path -- a list of missions that have already been checked
""" """
if len(ctx.mission_req_table[mission_name].required_world) >= 1: if len(ctx.mission_req_table[mission_name].required_world) >= 1:
# A check for when the requirements are being or'd # A check for when the requirements are being or'd
@@ -790,18 +735,7 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete
else: else:
req_success = False req_success = False
# Grid-specific logic (to avoid long path checks and infinite recursion)
if ctx.mission_order in (3, 4):
if req_success:
return True
else:
if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
return False
else:
continue
# Recursively check required mission to see if it's requirements are met, in case !collect has been done # Recursively check required mission to see if it's requirements are met, in case !collect has been done
# Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
if not ctx.mission_req_table[mission_name].or_requirements: if not ctx.mission_req_table[mission_name].or_requirements:
return False return False
@@ -886,53 +820,18 @@ def check_game_install_path() -> bool:
return False return False
def is_mod_installed_correctly() -> bool: def check_mod_install() -> bool:
"""Searches for all required files.""" # Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
if "SC2PATH" not in os.environ: try:
check_game_install_path() # Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign') sc2_logger.info(f"Archipelago mod found at {modfile}.")
modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod") return True
wol_required_maps = [ else:
"ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map", sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
"ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map", except KeyError:
"ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map", sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
"ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map", return False
"ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
"ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
"ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
]
needs_files = False
# Check for maps.
missing_maps = []
for mapfile in wol_required_maps:
if not os.path.isfile(mapdir / mapfile):
missing_maps.append(mapfile)
if len(missing_maps) >= 19:
sc2_logger.warning(f"All map files missing from {mapdir}.")
needs_files = True
elif len(missing_maps) > 0:
for map in missing_maps:
sc2_logger.debug(f"Missing {map} from {mapdir}.")
sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
needs_files = True
else: # Must be no maps missing
sc2_logger.info(f"All maps found in {mapdir}.")
# Check for mods.
if os.path.isfile(modfile):
sc2_logger.info(f"Archipelago mod found at {modfile}.")
else:
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
needs_files = True
# Final verdict.
if needs_files:
sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
return False
else:
return True
class DllDirectory: class DllDirectory:
@@ -971,64 +870,6 @@ class DllDirectory:
return False return False
def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
"""Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
import requests
headers = {"Accept": 'application/vnd.github.v3+json'}
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
r1 = requests.get(url, headers=headers)
if r1.status_code == 200:
latest_version = r1.json()["tag_name"]
sc2_logger.info(f"Latest version: {latest_version}.")
else:
sc2_logger.warning(f"Status code: {r1.status_code}")
sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
sc2_logger.warning(f"text: {r1.text}")
return "", current_version
if (force_download is False) and (current_version == latest_version):
sc2_logger.info("Latest version already installed.")
return "", current_version
sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
download_url = r1.json()["assets"][0]["browser_download_url"]
r2 = requests.get(download_url, headers=headers)
if r2.status_code == 200:
with open(f"{repo}.zip", "wb") as fh:
fh.write(r2.content)
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
return f"{repo}.zip", latest_version
else:
sc2_logger.warning(f"Status code: {r2.status_code}")
sc2_logger.warning("Download failed.")
sc2_logger.warning(f"text: {r2.text}")
return "", current_version
def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
import requests
headers = {"Accept": 'application/vnd.github.v3+json'}
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
r1 = requests.get(url, headers=headers)
if r1.status_code == 200:
latest_version = r1.json()["tag_name"]
if current_version != latest_version:
return True
else:
return False
else:
sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
sc2_logger.warning(f"Status code: {r1.status_code}")
sc2_logger.warning(f"text: {r1.text}")
return False
if __name__ == '__main__': if __name__ == '__main__':
colorama.init() colorama.init()
asyncio.run(main()) asyncio.run(main())

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import typing import typing
import builtins import builtins
import os import os
@@ -12,8 +11,6 @@ import io
import collections import collections
import importlib import importlib
import logging import logging
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
from yaml import load, load_all, dump, SafeLoader from yaml import load, load_all, dump, SafeLoader
try: try:
@@ -38,7 +35,7 @@ class Version(typing.NamedTuple):
build: int build: int
__version__ = "0.3.6" __version__ = "0.3.5"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -142,7 +139,7 @@ def user_path(*path: str) -> str:
return os.path.join(user_path.cached_path, *path) return os.path.join(user_path.cached_path, *path)
def output_path(*path: str) -> str: def output_path(*path: str):
if hasattr(output_path, 'cached_path'): if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path) return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_options()["general_options"]["output_path"]) output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
@@ -220,11 +217,8 @@ def get_public_ipv6() -> str:
return ip return ip
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
@cache_argsless @cache_argsless
def get_default_options() -> OptionsType: def get_default_options() -> dict:
# Refer to host.yaml for comments as to what all these options mean. # Refer to host.yaml for comments as to what all these options mean.
options = { options = {
"general_options": { "general_options": {
@@ -232,21 +226,20 @@ def get_default_options() -> OptionsType:
}, },
"factorio_options": { "factorio_options": {
"executable": os.path.join("factorio", "bin", "x64", "factorio"), "executable": os.path.join("factorio", "bin", "x64", "factorio"),
"filter_item_sends": False,
"bridge_chat_out": True,
},
"sni_options": {
"sni": "SNI",
"snes_rom_start": True,
}, },
"sm_options": { "sm_options": {
"rom_file": "Super Metroid (JU).sfc", "rom_file": "Super Metroid (JU).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"soe_options": { "soe_options": {
"rom_file": "Secret of Evermore (USA).sfc", "rom_file": "Secret of Evermore (USA).sfc",
}, },
"lttp_options": { "lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"server_options": { "server_options": {
"host": None, "host": None,
@@ -289,27 +282,15 @@ def get_default_options() -> OptionsType:
}, },
"dkc3_options": { "dkc3_options": {
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"smw_options": {
"rom_file": "Super Mario World (USA).sfc",
},
"zillion_options": {
"rom_file": "Zillion (UE) [!].sms",
# RetroArch doesn't make it easy to launch a game from the command line.
# You have to know the path to the emulator core library on the user's computer.
"rom_start": "retroarch",
},
"pokemon_rb_options": {
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
"rom_start": True
}
} }
return options return options
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType: def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
for key, value in src.items(): for key, value in src.items():
new_keys = keys.copy() new_keys = keys.copy()
new_keys.append(key) new_keys.append(key)
@@ -329,9 +310,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsT
@cache_argsless @cache_argsless
def get_options() -> OptionsType: def get_options() -> dict:
filenames = ("options.yaml", "host.yaml") filenames = ("options.yaml", "host.yaml")
locations: typing.List[str] = [] locations = []
if os.path.join(os.getcwd()) != local_path(): if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames] locations += [user_path(filename) for filename in filenames]
@@ -372,7 +353,7 @@ def persistent_load() -> typing.Dict[str, dict]:
return storage return storage
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]: def get_adjuster_settings(game_name: str):
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {}) adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings return adjuster_settings
@@ -411,8 +392,7 @@ class RestrictedUnpickler(pickle.Unpickler):
# 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"}:
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) if module.endswith("Options"):
if module.lower().endswith("options"):
if module == "Options": if module == "Options":
mod = self.options_module mod = self.options_module
else: else:
@@ -643,32 +623,3 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
else: else:
return element.lower() return element.lower()
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i)) return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
_faf_tasks: "Set[asyncio.Task[None]]" = set()
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
# to avoid a task disappearing mid-execution.
# ```
# This implementation follows the pattern given in that documentation.
task = asyncio.create_task(co, name=name)
_faf_tasks.add(task)
task.add_done_callback(_faf_tasks.discard)

View File

@@ -1,4 +1,5 @@
import os import os
import sys
import multiprocessing import multiprocessing
import logging import logging
import typing import typing

View File

@@ -1,15 +1,16 @@
import base64
import os import os
import socket
import uuid import uuid
import base64
import socket
from pony.flask import Pony
from flask import Flask from flask import Flask
from flask_caching import Cache from flask_caching import Cache
from flask_compress import Compress from flask_compress import Compress
from pony.flask import Pony
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from Utils import title_sorted from Utils import title_sorted
from .models import *
UPLOAD_FOLDER = os.path.relpath('uploads') UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs') LOGS_FOLDER = os.path.relpath('logs')
@@ -31,10 +32,8 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key # if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8") app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread # at what amount of worlds should scheduling be used, instead of rolling in the webthread
app.config["JOB_THRESHOLD"] = 2 app.config["JOB_THRESHOLD"] = 2
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
app.config['SESSION_PERMANENT'] = True app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent # waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -74,10 +73,8 @@ def register():
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
# has automatic patch integration # has automatic patch integration
import worlds.AutoWorld import Patch
import worlds.Files app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it # to trigger app routing picking up on it

View File

@@ -1,11 +1,11 @@
"""API endpoints package.""" """API endpoints package."""
from typing import List, Tuple
from uuid import UUID from uuid import UUID
from typing import List, Tuple
from flask import Blueprint, abort from flask import Blueprint, abort
from .. import cache
from ..models import Room, Seed from ..models import Room, Seed
from .. import cache
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
@@ -46,4 +46,4 @@ def get_datapackage_versions():
return version_package return version_package
from . import generate, user # trigger registration from . import generate, user, tracker # trigger registration

View File

@@ -1,15 +1,15 @@
import json import json
import pickle import pickle
from uuid import UUID from uuid import UUID
from . import api_endpoints
from flask import request, session, url_for from flask import request, session, url_for
from pony.orm import commit from pony.orm import commit
from WebHostLib import app from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta from WebHostLib.generate import get_meta
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
from . import api_endpoints
@api_endpoints.route('/generate', methods=['POST']) @api_endpoints.route('/generate', methods=['POST'])

50
WebHostLib/api/tracker.py Normal file
View File

@@ -0,0 +1,50 @@
import collections
from flask import jsonify
from typing import Optional, Dict, Any, Tuple, List
from Utils import restricted_loads
from uuid import UUID
from ..models import Room
from . import api_endpoints
from ..tracker import fill_tracker_data, get_static_room_data
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from WebHostLib import cache
@api_endpoints.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
@cache.memoize(timeout=60)
def update_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
room: Optional[Room] = Room.get(tracker=tracker)
locations = get_static_room_data(room)[0]
items_counter: Dict[int, collections.Counter] = get_item_names_counter(locations)
player_tracker, multisave, inventory, seed_checks_in_area, lttp_checks_done, \
slot_data, games, player_name, display_icons = fill_tracker_data(room, tracked_team, tracked_player)
# convert numbers to string
for item in player_tracker.items_received:
if items_counter[tracked_player][item] == 1:
player_tracker.items_received[item] = ''
else:
player_tracker.items_received[item] = str(player_tracker.items_received[item])
return jsonify({
"items_received": player_tracker.items_received,
"checked_locations": list(sorted(player_tracker.checked_locations)),
"icons": display_icons,
"progressive_names": player_tracker.progressive_names
})
@cache.cached()
def get_item_names_counter(locations: Dict[int, Dict[int, Tuple[int, int, int]]]):
# create and fill dictionary of all progression items for players
items_counters: Dict[int, collections.Counter] = {}
for player in locations:
for location in locations[player]:
item, recipient, flags = locations[player][location]
item_name = lookup_any_item_id_to_name[item]
items_counters.setdefault(recipient, collections.Counter())[item_name] += 1
return items_counters

View File

@@ -1,7 +1,6 @@
from flask import session, jsonify from flask import session, jsonify
from pony.orm import select
from WebHostLib.models import Room, Seed from WebHostLib.models import *
from . import api_endpoints, get_players from . import api_endpoints, get_players

View File

@@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import json
import multiprocessing import multiprocessing
import os
import sys
import threading import threading
import time
import typing
from datetime import timedelta, datetime from datetime import timedelta, datetime
import sys
import typing
import time
import os
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

View File

@@ -1,7 +1,7 @@
import zipfile import zipfile
from typing import * from typing import *
from flask import request, flash, redirect, url_for, render_template from flask import request, flash, redirect, url_for, session, render_template
from WebHostLib import app from WebHostLib import app

View File

@@ -10,14 +10,13 @@ import random
import socket import socket
import threading import threading
import time import time
import websockets import websockets
from pony.orm import db_session, commit, select
import Utils import Utils
from .models import db_session, Room, select, commit, Command, db
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
from .models import Room, Command, db
class CustomClientMessageProcessor(ClientMessageProcessor): class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -184,12 +183,4 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
from .autolauncher import Locker from .autolauncher import Locker
with Locker(room_id): with Locker(room_id):
try: asyncio.run(main())
asyncio.run(main())
except:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
# 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)
raise

View File

@@ -1,13 +1,12 @@
import json
import zipfile import zipfile
import json
from io import BytesIO from io import BytesIO
from flask import send_file, Response, render_template from flask import send_file, Response, render_template
from pony.orm import select from pony.orm import select
from worlds.Files import AutoPatchRegister from Patch import update_patch_data, preferred_endings, AutoPatchRegister
from . import app, cache from WebHostLib import app, Slot, Room, Seed, cache
from .models import Slot, Room, Seed
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>") @app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
@@ -42,7 +41,12 @@ def download_patch(room_id, patch_id):
new_file.seek(0) new_file.seek(0)
return send_file(new_file, as_attachment=True, download_name=fname) return send_file(new_file, as_attachment=True, download_name=fname)
else: else:
return "Old Patch file, no longer compatible." patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
f"{preferred_endings[patch.game]}"
return send_file(patch_data, as_attachment=True, download_name=fname)
@app.route("/dl_spoiler/<suuid:seed_id>") @app.route("/dl_spoiler/<suuid:seed_id>")
@@ -75,8 +79,6 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV": elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Zillion":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
elif slot_data.game == "Super Mario 64": elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III": elif slot_data.game == "Dark Souls III":

View File

@@ -1,24 +1,23 @@
import json
import os import os
import pickle
import random
import tempfile import tempfile
import random
import json
import zipfile import zipfile
import concurrent.futures
from collections import Counter from collections import Counter
from typing import Dict, Optional, Any from typing import Dict, Optional, Any
from Utils import __version__
from flask import request, flash, redirect, url_for, session, render_template from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, db_session
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoSettings from Generate import handle_name, PlandoSettings
from Main import main as ERmain import pickle
from Utils import __version__
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
from WebHostLib import app from WebHostLib import app
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db from .upload import upload_zip_to_db
@@ -99,7 +98,7 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
meta.setdefault("server_options", {}).setdefault("hint_cost", 10) meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("race", False) race = meta.setdefault("race", False)
def task(): try:
target = tempfile.TemporaryDirectory() target = tempfile.TemporaryDirectory()
playercount = len(gen_options) playercount = len(gen_options)
seed = get_seed() seed = get_seed()
@@ -139,23 +138,6 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
ERmain(erargs, seed, baked_server_options=meta["server_options"]) ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task)
try:
return thread.result(app.config["JOB_TIME"])
except concurrent.futures.TimeoutError as e:
if sid:
with db_session:
gen = Generation.get(id=sid)
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = (
"Allowed time for Generation exceeded, please consider generating locally instead. " +
e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:

View File

@@ -1,11 +1,7 @@
from datetime import timedelta, datetime
from flask import render_template from flask import render_template
from pony.orm import count
from WebHostLib import app, cache from WebHostLib import app, cache
from .models import Room, Seed from .models import *
from datetime import timedelta
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
@cache.cached(timeout=300) # cache has to appear under app route for caching to work @cache.cached(timeout=300) # cache has to appear under app route for caching to work

View File

@@ -32,7 +32,7 @@ def update_sprites_lttp():
spriteData = [] spriteData = []
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")): for file in os.listdir(input_dir):
sprite = Sprite(os.path.join(input_dir, file)) sprite = Sprite(os.path.join(input_dir, file))
if not sprite.name: if not sprite.name:

View File

@@ -3,11 +3,10 @@ import os
import jinja2.exceptions import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
from .models import Seed, Room, Command, UUID, uuid4
def get_world_theme(game_name: str): def get_world_theme(game_name: str):
@@ -152,7 +151,7 @@ def favicon():
@app.route('/discord') @app.route('/discord')
def discord(): def discord():
return redirect("https://discord.gg/8Z65BR2") return redirect("https://discord.gg/archipelago")
@app.route('/datapackage') @app.route('/datapackage')

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr from pony.orm import *
db = Database() db = Database()
@@ -29,7 +29,6 @@ class Room(db.Entity):
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
tracker = Optional(UUID, index=True) tracker = Optional(UUID, index=True)
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
last_port = Optional(int, default=lambda: 0) last_port = Optional(int, default=lambda: 0)

View File

@@ -1,14 +1,13 @@
import json
import logging import logging
import os import os
from Utils import __version__, local_path
from jinja2 import Template
import yaml
import json
import typing import typing
import yaml
from jinja2 import Template
import Options
from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import Options
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",
"exclude_locations"} "exclude_locations"}
@@ -16,23 +15,26 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
def create(): def create():
target_folder = local_path("WebHostLib", "static", "generated") target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs") os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
os.makedirs(yaml_folder, exist_ok=True)
for file in os.listdir(yaml_folder):
full_path: str = os.path.join(yaml_folder, file)
if os.path.isfile(full_path):
os.unlink(full_path)
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]): def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {option.default: 50} data = {}
for sub_option in ["random", "random-low", "random-high"]: special = getattr(option, "special_range_cutoff", None)
if sub_option != option.default: if special is not None:
data[sub_option] = 0 data[special] = 0
data.update({
option.range_start: 0,
option.range_end: 0,
"random": 0, "random-low": 0, "random-high": 0,
option.default: 50
})
notes = {
special: "minimum value without special meaning",
option.range_start: "minimum value",
option.range_end: "maximum value"
}
notes = {}
for name, number in getattr(option, "special_range_names", {}).items(): for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data: if number in data:
data[name] = data[number] data[name] = data[number]
del data[number] del data[number]
@@ -41,6 +43,11 @@ def create():
return data, notes return data, notes
def default_converter(default_value):
if isinstance(default_value, (set, frozenset)):
return list(default_value)
return default_value
def get_html_doc(option_type: type(Options.Option)) -> str: def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__: if not option_type.__doc__:
return "Please document me!" return "Please document me!"
@@ -57,16 +64,13 @@ 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] = { all_options = {**Options.per_game_common_options, **world.option_definitions}
**Options.per_game_common_options,
**world.option_definitions
}
with open(local_path("WebHostLib", "templates", "options.yaml")) as f: with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
file_data = f.read() file_data = f.read()
res = Template(file_data).render( res = Template(file_data).render(
options=all_options, options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump, __version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range, dictify_range=dictify_range, default_converter=default_converter,
) )
del file_data del file_data
@@ -106,6 +110,11 @@ def create():
if sub_option_id == option.default: if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name this_option["defaultValue"] = sub_option_name
this_option["options"].append({
"name": "Random",
"value": "random",
})
if option.default == "random": if option.default == "random":
this_option["defaultValue"] = "random" this_option["defaultValue"] = "random"

View File

@@ -2,6 +2,6 @@ flask>=2.2.2
pony>=0.7.16 pony>=0.7.16
waitress>=2.1.2 waitress>=2.1.2
Flask-Caching>=2.0.1 Flask-Caching>=2.0.1
Flask-Compress>=1.13 Flask-Compress>=1.12
Flask-Limiter>=2.7.0 Flask-Limiter>=2.6.2
bokeh>=3.0.0 bokeh>=2.4.3

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -46,7 +46,7 @@ the website is not required to generate them.
## How do I get started? ## 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 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 our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
any questions you might have. any questions you might have.
## What are some common terms I should know? ## What are some common terms I should know?

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
gameInfo.innerHTML = gameInfo.innerHTML =

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -1,20 +0,0 @@
window.addEventListener('load', () => {
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item and location trackers
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
document.getElementById('location-table').innerHTML = fakeDOM.getElementById('location-table').innerHTML;
};
ajax.open('GET', url);
ajax.send();
}, 15000)
});

View File

@@ -118,8 +118,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
const tdr = document.createElement('td'); const tdr = document.createElement('td');
let element = null; let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].type){ switch(settings[setting].type){
case 'select': case 'select':
element = document.createElement('div'); element = document.createElement('div');
@@ -140,21 +138,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
} }
select.appendChild(option); select.appendChild(option);
}); });
select.addEventListener('change', (event) => updateGameSetting(event.target)); select.addEventListener('change', (event) => updateGameSetting(event));
element.appendChild(select); 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; break;
case 'range': case 'range':
@@ -169,29 +154,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
range.value = currentSettings[gameName][setting]; range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => { range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
element.appendChild(range); element.appendChild(range);
let rangeVal = document.createElement('span'); let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value'); rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`); rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
currentSettings[gameName][setting] : settings[setting].defaultValue;
element.appendChild(rangeVal); 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; break;
case 'special_range': case 'special_range':
@@ -230,8 +201,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
let specialRangeVal = document.createElement('span'); let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value'); specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`); specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ? specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
currentSettings[gameName][setting] : settings[setting].defaultValue;
// Configure select event listener // Configure select event listener
specialRangeSelect.addEventListener('change', (event) => { specialRangeSelect.addEventListener('change', (event) => {
@@ -240,7 +210,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Update range slider // Update range slider
specialRange.value = event.target.value; specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
// Configure range event handler // Configure range event handler
@@ -250,29 +220,13 @@ const buildOptionsTable = (settings, romOpts = false) => {
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ? (Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom'; parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target); updateGameSetting(event);
}); });
element.appendChild(specialRangeSelect); element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange); specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal); specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper); 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; break;
default: default:
@@ -289,25 +243,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table; return table;
}; };
const toggleRandomize = (event, inputElements) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
for (const element of inputElements) {
element.disabled = undefined;
updateGameSetting(element);
}
} else {
randomButton.classList.add('active');
for (const element of inputElements) {
element.disabled = true;
updateGameSetting(randomButton);
}
}
};
const updateBaseSetting = (event) => { const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName)); const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ? options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
@@ -315,17 +250,10 @@ const updateBaseSetting = (event) => {
localStorage.setItem(gameName, JSON.stringify(options)); localStorage.setItem(gameName, JSON.stringify(options));
}; };
const updateGameSetting = (settingElement) => { const updateGameSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName)); const options = JSON.parse(localStorage.getItem(gameName));
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
if (settingElement.classList.contains('randomize-button')) { event.target.value : parseInt(event.target.value, 10);
// 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)); localStorage.setItem(gameName, JSON.stringify(options));
}; };

View File

@@ -0,0 +1,82 @@
window.addEventListener('load', () => {
// Reload tracker
const update = () => {
const room = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
const request = new Request('/api/tracker/' + room);
fetch(request)
.then(response => response.json())
.then(data => {
// update locations blocks
for (const location of data.checked_locations) {
document.getElementById(location).classList.add('acquired');
}
// update totals checks done
let total_checks_ele = document.getElementById('total-checks');
const total_checks = document.getElementsByClassName('location').length;
let checks_done = data.checked_locations.length;
total_checks_ele.innerText = 'Total Checks Done: ' + checks_done + '/' + total_checks;
// update item and icons blocks
// update icons block
if (data.icons.length > 0) {
for (let item in data.icons) {
if (data.progressive_names.length > 0) {
for (let item_category in data.progressive_names) {
let i = 0;
for (let current_item in current_name) {
if (current_item === item) {
let doc_item = document.getElementById(item_category)
doc_item.children[0].src = data.icons[item];
if (item in data.items_received) {
doc_item.children[0].classList.add('acquired');
doc_item.children[1].innerText = item_category;
}
}
}
}
} else {
if (item in data.items_received) {
let current_item = document.getElementById(item);
current_item.children[0].classList.add('acquired');
current_item.children[0].src = data.icons[item];
current_item.children[1].innerText = item;
}
}
}
} else {
for (const item in data.items_received) {
if (document.getElementById(item)) {
let current_item = document.getElementById(item);
current_item.innerText = item + data.items_received[item];
}
}
}
});
}
update()
setInterval(update, 30000);
// Collapsible regions section
const regions = document.getElementsByClassName('regions-column');
for (let i = 0; i < regions.length; i++) {
let region_name = regions[i].id;
const tab_header = document.getElementById(region_name+'-header');
const locations = document.getElementById(region_name+'-locations');
// toggle locations display
regions[i].addEventListener('click', function(event) {
if (tab_header.innerHTML.includes("▼")) {
locations.classList.remove('hidden');
// change header text
tab_header.innerHTML = tab_header.innerHTML.replace('▼', '▲');
} else {
locations.classList.add('hidden');
// change header text
tab_header.innerHTML = tab_header.innerHTML.replace('▲', '▼');
}
});
}
});

View File

@@ -0,0 +1,82 @@
window.addEventListener('load', () => {
// Reload tracker
const update = () => {
const room = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
const request = new Request('/api/tracker/' + room);
fetch(request)
.then(response => response.json())
.then(data => {
// update locations blocks
for (const location of data.checked_locations) {
document.getElementById(location).classList.add('acquired');
}
// update totals checks done
let total_checks_ele = document.getElementById('total-checks');
const total_checks = document.getElementsByClassName('location').length;
let checks_done = data.checked_locations.length;
total_checks_ele.innerText = 'Total Checks Done: ' + checks_done + '/' + total_checks;
// update item and icons blocks
// update icons block
if (data.icons.length > 0) {
for (let item in data.icons) {
if (data.progressive_names.length > 0) {
for (let item_category in data.progressive_names) {
let i = 0;
for (let current_item in current_name) {
if (current_item === item) {
let doc_item = document.getElementById(item_category)
doc_item.children[0].src = data.icons[item];
if (item in data.items_received) {
doc_item.children[0].classList.add('acquired');
doc_item.children[1].innerText = item_category;
}
}
}
}
} else {
if (item in data.items_received) {
let current_item = document.getElementById(item);
current_item.children[0].classList.add('acquired');
current_item.children[0].src = data.icons[item];
current_item.children[1].innerText = item;
}
}
}
} else {
for (const item in data.items_received) {
if (document.getElementById(item)) {
let current_item = document.getElementById(item);
current_item.innerText = item + data.items_received[item];
}
}
}
});
}
update()
setInterval(update, 30000);
// Collapsible regions section
const regions = document.getElementsByClassName('regions-column');
for (let i = 0; i < regions.length; i++) {
let region_name = regions[i].id;
const tab_header = document.getElementById(region_name+'-header');
const locations = document.getElementById(region_name+'-locations');
// toggle locations display
regions[i].addEventListener('click', function(event) {
if (tab_header.innerHTML.includes("▼")) {
locations.classList.remove('hidden');
// change header text
tab_header.innerHTML = tab_header.innerHTML.replace('▼', '▲');
} else {
locations.classList.add('hidden');
// change header text
tab_header.innerHTML = tab_header.innerHTML.replace('▲', '▼');
}
});
}
});

View File

@@ -27,28 +27,25 @@ window.addEventListener('load', () => {
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth(); adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase(); const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
header.setAttribute('id', headerId); for (let i=0; i < headers.length; i++){
header.addEventListener('click', () => { const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
window.location.hash = `#${headerId}`; headers[i].setAttribute('id', headerId);
header.scrollIntoView(); headers[i].addEventListener('click', () =>
}); window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => { if (scrollTargetIndex > -1) {
if (window.location.hash) { try{
const scrollTarget = document.getElementById(window.location.hash.substring(1)); const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
scrollTarget?.scrollIntoView(); document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
} }
}); }
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -78,16 +78,13 @@ const createDefaultSettings = (settingData) => {
break; break;
case 'range': case 'range':
case 'special_range': case 'special_range':
newSettings[game][gameSetting][setting.min] = 0; for (let i = setting.min; i <= setting.max; ++i){
newSettings[game][gameSetting][setting.max] = 0; newSettings[game][gameSetting][i] =
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
}
newSettings[game][gameSetting]['random'] = 0; newSettings[game][gameSetting]['random'] = 0;
newSettings[game][gameSetting]['random-low'] = 0; newSettings[game][gameSetting]['random-low'] = 0;
newSettings[game][gameSetting]['random-high'] = 0; newSettings[game][gameSetting]['random-high'] = 0;
if (setting.hasOwnProperty('defaultValue')) {
newSettings[game][gameSetting][setting.defaultValue] = 25;
} else {
newSettings[game][gameSetting][setting.min] = 25;
}
break; break;
case 'items-list': case 'items-list':
@@ -404,17 +401,11 @@ const buildWeightedSettingsDiv = (game, settings) => {
tr.appendChild(tdDelete); tr.appendChild(tdDelete);
rangeTbody.appendChild(tr); rangeTbody.appendChild(tr);
// Save new option to settings
range.dispatchEvent(new Event('change'));
}); });
Object.keys(currentSettings[game][settingName]).forEach((option) => { Object.keys(currentSettings[game][settingName]).forEach((option) => {
// These options are statically generated below, and should always appear even if they are deleted if (currentSettings[game][settingName][option] > 0) {
// from localStorage const tr = document.createElement('tr');
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td'); const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left'); tdLeft.classList.add('td-left');
tdLeft.innerText = option; tdLeft.innerText = option;
@@ -448,15 +439,14 @@ const buildWeightedSettingsDiv = (game, settings) => {
deleteButton.innerText = '❌'; deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => { deleteButton.addEventListener('click', () => {
range.value = 0; range.value = 0;
const changeEvent = new Event('change'); range.dispatchEvent(new Event('change'));
changeEvent.action = 'rangeDelete';
range.dispatchEvent(changeEvent);
rangeTbody.removeChild(tr); rangeTbody.removeChild(tr);
}); });
tdDelete.appendChild(deleteButton); tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete); tr.appendChild(tdDelete);
rangeTbody.appendChild(tr); rangeTbody.appendChild(tr);
}
}); });
} }
@@ -914,12 +904,8 @@ const updateGameSetting = (evt) => {
const setting = evt.target.getAttribute('data-setting'); const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option'); const option = evt.target.getAttribute('data-option');
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value; document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
console.log(event); options[game][setting][option] = isNaN(evt.target.value) ?
if (evt.action && evt.action === 'rangeDelete') { evt.target.value : parseInt(evt.target.value, 10);
delete options[game][setting][option];
} else {
options[game][setting][option] = parseInt(evt.target.value, 10);
}
localStorage.setItem('weighted-settings', JSON.stringify(options)); localStorage.setItem('weighted-settings', JSON.stringify(options));
}; };

View File

@@ -55,6 +55,4 @@
border: 1px solid #2a6c2f; border: 1px solid #2a6c2f;
border-radius: 6px; border-radius: 6px;
color: #000000; color: #000000;
overflow-y: auto;
max-height: 400px;
} }

View File

@@ -116,10 +116,6 @@ html{
flex-grow: 1; flex-grow: 1;
} }
#player-settings table select:disabled{
background-color: lightgray;
}
#player-settings table .range-container{ #player-settings table .range-container{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -142,27 +138,12 @@ html{
#player-settings table .special-range-wrapper{ #player-settings table .special-range-wrapper{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin-top: 0.25rem;
} }
#player-settings table .special-range-wrapper input[type=range]{ #player-settings table .special-range-wrapper input[type=range]{
flex-grow: 1; flex-grow: 1;
} }
#player-settings table .randomize-button {
max-height: 24px;
line-height: 16px;
padding: 2px 8px;
margin: 0 0 0 0.25rem;
font-size: 12px;
border: 1px solid black;
border-radius: 3px;
}
#player-settings table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-settings table label{ #player-settings table label{
display: block; display: block;
min-width: 200px; min-width: 200px;

View File

@@ -1,7 +1,5 @@
html{ html{
padding-top: 110px; padding-top: 110px;
scroll-padding-top: 100px;
scroll-behavior: smooth;
} }
#base-header{ #base-header{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -53,7 +53,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,7 +52,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -50,7 +50,6 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -0,0 +1,150 @@
/* CSS Overrides */
.dirt-wrapper{
background-color: #897249;
}
.dirt-wrapper h1{}
.grass-wrapper{
background-color: #3fb24a;
}
.grass-wrapper h1{}
.grassFlowers-wrapper{
background-color: #3fb24a;
}
.grassFlowers-wrapper h1{}
.ice-wrapper{
background-color: #afe0ef;
}
.ice-wrapper h1{}
.jungle-wrapper{
background-color: #2a7808;
}
.jungle-wrapper h1{}
.ocean-wrapper{
background-color: #3667b1;
}
.ocean-wrapper h1{}
.partyTime-wrapper{
background-color: #3a0f69;
color: #ffffff;
}
.partyTime-wrapper h1{}
/* Actual Styles */
h1 {
font-size: 20px;
color: #ffffff;
padding: 5px;
text-align: center;
text-shadow: 1px 1px black;
}
h2 {
padding: 8px;
}
#player-keys-tracker{
width: 600px;
}
#items-container{
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
padding: 5px;
}
#items-container div{
margin: 0;
padding: 0;
}
.image-container{
display: absolute;
height: 75px;
width: 75px;
}
.bottom-text{
position: relative;
align-items: bottom;
text-align: center;
}
.icon{
height: 100%;
position: relative;
left: 15px;
max-width: 45px;
max-height: 45px;
filter: grayscale(100%) contrast(75%) brightness(40%);
}
.icon.acquired{
filter: none;
}
.total-checks{
text-align: center;
padding: 5px;
font-size: 18px;
}
.locations-container{
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 5px;
margin-left: 50px;
margin-right: 50px;
}
.location.acquired{
text-decoration: line-through;
filter: none;
}
.regions-container{
display: flex;
flex-direction: column;
flex-wrap: wrap;
justify-content: space-evenly;
padding: 5px;
text-align: center;
}
.regions-header{
font-size: 18px;
padding: 15px;
cursor: pointer;
text-align: center;
}
.hidden{
display: none;
}
.button-link{
display: block;
width: 100%;
height: 30px;
text-align: center;
text-decoration: none;
line-height: 30px;
background-color: lightgrey;
cursor: pointer;
color: inherit;
}

View File

@@ -51,6 +51,17 @@ table.dataTable{
color: #000000; color: #000000;
} }
table.dataTable img.icon{
height: 100%;
max-width: 60px;
max-height: 60px;
filter: grayscale(100%) contrast(75%) brightness(50%);
}
table.dataTable img.acquired{
filter: none;
}
table.dataTable thead{ table.dataTable thead{
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
} }

View File

@@ -1,14 +1,14 @@
import typing
from collections import Counter, defaultdict from collections import Counter, defaultdict
from colorsys import hsv_to_rgb from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from math import tau from math import tau
import typing
from bokeh.colors import RGB
from bokeh.embed import components from bokeh.embed import components
from bokeh.models import HoverTool from bokeh.models import HoverTool
from bokeh.plotting import figure, ColumnDataSource from bokeh.plotting import figure, ColumnDataSource
from bokeh.resources import INLINE from bokeh.resources import INLINE
from bokeh.colors import RGB
from flask import render_template from flask import render_template
from pony.orm import select from pony.orm import select
@@ -18,8 +18,7 @@ from .models import Room
PLOT_WIDTH = 600 PLOT_WIDTH = 600
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str], def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter) games_played = defaultdict(Counter)
total_games = Counter() total_games = Counter()
cutoff = date.today()-timedelta(days=30) cutoff = date.today()-timedelta(days=30)
@@ -94,7 +93,7 @@ def stats():
occurences, legend_label=game, line_width=2, color=game_to_color[game]) occurences, legend_label=game, line_width=2, color=game_to_color[game])
total = sum(total_games.values()) total = sum(total_games.values())
pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None, pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")], tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2)) sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
pie.axis.visible = False pie.axis.visible = False
@@ -122,8 +121,7 @@ def stats():
start_angle="start_angles", end_angle="end_angles", fill_color="colors", start_angle="start_angles", end_angle="end_angles", fill_color="colors",
source=ColumnDataSource(data=data), legend_field="games") source=ColumnDataSource(data=data), legend_field="games")
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games
sorted(total_games, key=lambda game: total_games[game])
if total_games[game] > 1] if total_games[game] > 1]
script, charts = components((plot, pie, *per_game_charts)) script, charts = components((plot, pie, *per_game_charts))

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Mystery Check Result</title> <title>Mystery Check Result</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Generate Game</title> <title>Generate Game</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Upload Multidata</title> <title>Upload Multidata</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>

View File

@@ -20,16 +20,12 @@
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
Should you wish to continue later, Should you wish to continue later,
anyone can simply refresh this page and the server will resume.<br> anyone can simply refresh this page and the server will resume.<br>
{% if room.last_port == -1 %} {% if room.last_port %}
There was an error hosting this Room. Another attempt will be made on refreshing this page.
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using <span class="interactive" You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}."> data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}' '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
</span> </span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br> in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{% endif %}
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}
<form method=post> <form method=post>

View File

@@ -1,86 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/lttp-tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttp-tracker.js") }}"></script>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ bow_url }}" class="{{ 'acquired' if bow_acquired }}" /></td>
<td><img src="{{ icons["Blue Boomerang"] }}" class="{{ 'acquired' if 'Blue Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Red Boomerang"] }}" class="{{ 'acquired' if 'Red Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Hookshot"] }}" class="{{ 'acquired' if 'Hookshot' in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Powder"] }}" class="powder-fix {{ 'acquired' if 'Magic Powder' in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Fire Rod"] }}" class="{{ 'acquired' if "Fire Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Ice Rod"] }}" class="{{ 'acquired' if "Ice Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Bombos"] }}" class="{{ 'acquired' if "Bombos" in acquired_items }}" /></td>
<td><img src="{{ icons["Ether"] }}" class="{{ 'acquired' if "Ether" in acquired_items }}" /></td>
<td><img src="{{ icons["Quake"] }}" class="{{ 'acquired' if "Quake" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Lamp"] }}" class="{{ 'acquired' if "Lamp" in acquired_items }}" /></td>
<td><img src="{{ icons["Hammer"] }}" class="{{ 'acquired' if "Hammer" in acquired_items }}" /></td>
<td><img src="{{ icons["Flute"] }}" class="{{ 'acquired' if "Flute" in acquired_items }}" /></td>
<td><img src="{{ icons["Bug Catching Net"] }}" class="{{ 'acquired' if "Bug Catching Net" in acquired_items }}" /></td>
<td><img src="{{ icons["Book of Mudora"] }}" class="{{ 'acquired' if "Book of Mudora" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Bottle"] }}" class="{{ 'acquired' if "Bottle" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Somaria"] }}" class="{{ 'acquired' if "Cane of Somaria" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Byrna"] }}" class="{{ 'acquired' if "Cane of Byrna" in acquired_items }}" /></td>
<td><img src="{{ icons["Cape"] }}" class="{{ 'acquired' if "Cape" in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Mirror"] }}" class="{{ 'acquired' if "Magic Mirror" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
<td><img src="{{ glove_url }}" class="{{ 'acquired' if glove_acquired }}" /></td>
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ sword_url }}" class="{{ 'acquired' if sword_acquired }}" /></td>
<td><img src="{{ shield_url }}" class="{{ 'acquired' if shield_acquired }}" /></td>
<td><img src="{{ mail_url }}" class="acquired" /></td>
<td><img src="{{ icons["Shovel"] }}" class="{{ 'acquired' if "Shovel" in acquired_items }}" /></td>
<td><img src="{{ icons["Triforce"] }}" class="{{ 'acquired' if "Triforce" in acquired_items }}" /></td>
</tr>
</table>
<table id="location-table">
<tr>
<th></th>
<th class="counter"><img src="{{ icons["Chest"] }}" /></th>
{% if key_locations and "Universal" not in key_locations %}
<th class="counter"><img src="{{ icons["Small Key"] }}" /></th>
{% endif %}
{% if big_key_locations %}
<th><img src="{{ icons["Big Key"] }}" /></th>
{% endif %}
</tr>
{% for area in sp_areas %}
<tr>
<td>{{ area }}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
{% if key_locations and "Universal" not in key_locations %}
<td class="counter">
{{ inventory[small_key_ids[area]] if area in key_locations else '—' }}
</td>
{% endif %}
{% if big_key_locations %}
<td>
{{ '✔' if area in big_key_locations and inventory[big_key_ids[area]] else ('—' if area not in big_key_locations else '') }}
</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -43,14 +43,14 @@
{% elif patch.game | supports_apdeltapatch %} {% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download> <a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a> Download Patch File...</a>
{% elif patch.game == "Dark Souls III" and patch.data %} {% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a> Download JSON File...</a>
{% else %} {% else %}
No file to download for this game. No file to download for this game.
{% endif %} {% endif %}
</td> </td>
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td> <td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -1,85 +1,56 @@
# Q. What is this file? # What is this file?
# A. This file contains options which allow you to configure your multiworld experience while allowing # This file contains options which allow you to configure your multiworld experience while allowing others
# others to play how they want as well. # to play how they want as well.
#
# Q. How do I use it?
# A. The options in this file are weighted. This means the higher number you assign to a value, the
# more chances you have for that option to be chosen. For example, an option like this:
#
# map_shuffle:
# on: 5
# off: 15
#
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned
# off.
#
# Q. I've never seen a file like this before. What characters am I allowed to use?
# A. This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
# You can also verify your Archipelago settings are valid at this site:
# https://archipelago.gg/check
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit. # How do I use it?
# {player} will be replaced with the player's slot number. # The options in this file are weighted. This means the higher number you assign to a value, the more
# {PLAYER} will be replaced with the player's slot number, if that slot number is greater than 1. # chances you have for that option to be chosen. For example, an option like this:
# {number} will be replaced with the counter value of the name. #
# {NUMBER} will be replaced with the counter value of the name, if the counter value is greater than 1. # map_shuffle:
name: Player{number} # on: 5
# off: 15
#
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned off
# Used to describe your yaml. Useful if you have multiple files. # I've never seen a file like this before. What characters am I allowed to use?
description: Default {{ game }} Template # This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
game: {{ game }} description: Default {{ game }} Template # Used to describe your yaml. Useful if you have multiple files
# Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
name: YourName{number}
#{player} will be replaced with the player's slot number.
#{PLAYER} will be replaced with the player's slot number if that slot number is greater than 1.
#{number} will be replaced with the counter value of the name.
#{NUMBER} will be replaced with the counter value of the name if the counter value is greater than 1.
game:
{{ game }}: 1
requires: requires:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected. version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
{%- macro range_option(option) %} {%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values. # you can add additional values between minimum and maximum
# Minimum value is {{ option.range_start }}
# Maximum value is {{ option.range_end }}
{%- set data, notes = dictify_range(option) %} {%- set data, notes = dictify_range(option) %}
{%- for entry, default in data.items() %} {%- for entry, default in data.items() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %} {{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%} {%- endfor -%}
{% endmacro %} {% endmacro %}
{{ game }}: {{ game }}:
{%- for option_key, option in options.items() %} {%- for option_key, option in options.items() %}
{{ option_key }}: {{ option_key }}:{% if option.__doc__ %} # {{ option.__doc__ | replace('\n', '\n#') | indent(4, first=False) }}{% endif %}
{%- if option.__doc__ %}
# {{ option.__doc__
| trim
| replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| indent(4, first=False)
}}
{%- endif -%}
{%- if option.__doc__ and option.range_start is defined %}
#
{%- endif -%}
{%- if option.range_start is defined and option.range_start is number %} {%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option) -}} {{- range_option(option) -}}
{%- elif option.options -%} {%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %} {%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %} {{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%} {%- endfor -%}
{% if option.default == "random" %}
{%- if option.name_lookup[option.default] not in option.options %} random: 50
{{ option.default }}: 50
{%- endif -%} {%- endif -%}
{%- elif option.default is string %}
{{ option.default }}: 50
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- else %} {%- else %}
{{ yaml_dump(option.default) | trim | indent(4, first=false) }} {{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
{%- endif -%} {%- endif -%}
{{ "\n" }}
{%- endfor %} {%- endfor %}
{% if not options %}{}{% endif %}

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Start Playing</title> <title>Start Playing</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
{% endblock %} {% endblock %}

View File

@@ -2,9 +2,9 @@
{% block head %} {% block head %}
{{ super() }} {{ super() }}
<title>{{ player_name }}&apos;s Tracker</title> <title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/trackers/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackers/tracker.js") }}"></script>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
@@ -13,6 +13,9 @@
<div id="tracker-header-bar"> <div id="tracker-header-bar">
<input placeholder="Search" id="search"/> <input placeholder="Search" id="search"/>
<span class="info">This tracker will automatically update itself periodically.</span> <span class="info">This tracker will automatically update itself periodically.</span>
<a href="/tracker/{{ room.tracker|suuid }}/{{ team }}/{{ player }}" class="button-link">
Go to Styled Tracker
</a>
</div> </div>
<div class="table-wrapper"> <div class="table-wrapper">
<table class="table non-unique-item-table"> <table class="table non-unique-item-table">

View File

@@ -2,8 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ player_name }}&apos;s Tracker</title> <title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/> <link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</head> </head>
@@ -43,19 +43,6 @@
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td> <td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td> <td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td> <td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr> </tr>
</table> </table>
<table id="location-table"> <table id="location-table">

View File

@@ -2,9 +2,9 @@
{% block head %} {% block head %}
{{ super() }} {{ super() }}
<title>Multiworld Tracker</title> <title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/trackers/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackers/tracker.js") }}"></script>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
@@ -44,7 +44,7 @@
<tbody> <tbody>
{%- for player, items in players.items() -%} {%- for player, items in players.items() -%}
<tr> <tr>
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, <td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td> tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
{%- if (team, loop.index) in video -%} {%- if (team, loop.index) in video -%}
{%- if video[(team, loop.index)][0] == "Twitch" -%} {%- if video[(team, loop.index)][0] == "Twitch" -%}
@@ -121,7 +121,7 @@
<tbody> <tbody>
{%- for player, checks in players.items() -%} {%- for player, checks in players.items() -%}
<tr> <tr>
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, <td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td> tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td> <td>{{ player_names[(team, loop.index)]|e }}</td>
{%- for area in ordered_areas -%} {%- for area in ordered_areas -%}

View File

@@ -0,0 +1,99 @@
{% block head %}
<!--suppress XmlDuplicatedId -->
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/playerTracker.css') }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/tooltip.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/playerTracker.js') }}"></script>
{% endblock %}
{% block body %}
<div id="tracker-wrapper" class="{{ theme }}-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
<a href="/generic_tracker/{{ room.tracker|suuid }}/{{ team }}/{{ player }}" class="button-link">
Go to Generic Tracker
</a>
{% if icons %}
{% block icons_render %}
<h1>Items</h1>
<div id="items-container">
{%- for item in icons %}
<div class="image-container tooltip" id="{{ item }}" data-tooltip="{{ item }}">
<img
src="{{ icons[item] }}"
class="icon tooltip {{ 'acquired' if item in received_items }}"
/>
</div>
{%- endfor %}
</div>
{% endblock %}
{% else %}
{% block item_names_render %}
<h1 class="items-header">Items</h1>
<div class="items-container">
{%- for item in received_items|sort -%}
<div class="item" id="{{ item }}">
{{ item }}
{% if all_progression_items[item] > 1 %}
{{ received_items[item] }}
{% else %}
{% endif %}
</div>
{%- endfor -%}
</div>
{% endblock %}
{% endif %}
{# div for total checks done as percentage. Probably needs to be put somewhere else but I liked how it looked here #}
<div class="total-checks" id="total-checks">
Total Checks Done: {{ checked_locations|length }}/{{ locations|length }}
</div>
{% if regions %}
{% block regions_render %}
<div class="regions-container">
{% for region in regions %}
<div class="regions-column" id="{{ region }}">
<h1 class="regions-header" id="{{ region }}-header">{{ region }} ▼ {{ checks_done[region]|length }} / {{ regions[region]|length }}</h1>
<div class="location-column hidden" id="{{ region }}-locations">
{%- for location in regions[region] %}
<div class="location {{ 'acquired' if location in checked_locations }}" id="{{ location }}">{{ location }}</div>
{%- endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% else %}
{% block locations_render %}
<h1>Locations</h1>
<div class="locations-container" id="locations-container">
{% for location in locations %}
<div class="location {{ 'acquired' if name in checked_locations }}" id="{{ location }}">
{{ location }}
</div>
{% endfor %}
</div>
{% endblock %}
{% endif %}
</div>
{% endblock %}

View File

@@ -2,8 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ player_name }}&apos;s Tracker</title> <title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/supermetroidTracker.css') }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/supermetroidTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/supermetroidTracker.js') }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/supermetroidTracker.js') }}"></script>
</head> </head>
<body> <body>

View File

@@ -2,8 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ player_name }}&apos;s Tracker</title> <title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/timespinnerTracker.css') }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/timespinnerTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/timespinnerTracker.js') }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/timespinnerTracker.js') }}"></script>
</head> </head>
<body> <body>
@@ -41,7 +41,7 @@
<td></td> <td></td>
{% endif %} {% endif %}
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td> <td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
{% if 'EyeSpy' in options %} {% if 'FacebookMode' in options %}
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td> <td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
{% else %} {% else %}
<td></td> <td></td>

View File

@@ -0,0 +1,77 @@
{% block head %}
<!--suppress XmlDuplicatedId -->
<title>{{ player_name }}&apos;s Keys Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/playerTracker.css') }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/tooltip.css') }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/zeldaKeysTracker.js') }}"/></script>
{% endblock %}
{# this tracker is mostly similar to the generic player tracker but
also adds a table with the key and checks counts for each region in the middle #}
{% block body %}
<div id="tracker-wrapper" class="{{ theme }}-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
<a href="/generic_tracker/{{ room.tracker|suuid }}/{{ team }}/{{ player }}" class="button-link">
Go to Generic Tracker
</a>
<h1>Items</h1>
<div id="items-container">
{% for item in icons %}
{% if item not in ['Small Key', 'Big Key'] %}
<div class="image-container tooltip" id="{{ item }}" data-tooltip="{{ item }}">
<img
src="{{ icons[item] }}"
class="icon tooltip {{ 'acquired' if item in received_items }}"
/>
</div>
{% endif %}
{% endfor %}
</div>
<div class="total-checks" id="total-checks">
Total Checks Done: {{ checked_locations|length }}/{{ locations|length }}
</div>
<table id="regions-column">
<tr class="keys-icons">
<td><img src="{{icons['Small Key']}}" class="icon tooltip acquired" id="small-key-icon"/></td>
<td><img src="{{icons['Big Key']}}" class="icon tooltip acquired" id="big-key-icon"/></td>
<td class="right-align">Total</td>
</tr>
{% for region in regions %}
<tr class="regions-column" id="{{ region }}">
<td id="{{ region }}-header">{{ region }} ▼</td>
{% if region in region_keys %}
{%- if region_keys[region]|length > 1 %}
<td class="smallkeys">{{ received_items[region_keys[region][0]] if region_keys[region][0] in received_items else '-' }}</td>
<td class="bigkeys">{{ received_items[region_keys[region][1]] if region_keys[region][1] in received_items else '-' }}</td>
{%- else %}
{% if 'Small Key' in region_keys[region][0] %}
<td class="smallkeys">{{ received_items[region_keys[region][0]] if region_keys[region][0] in received_items else '-' }}</td>
<td class="bigkeys">-</td>
{% else %}
<td class="smallkeys">-</td>
<td class="bigkeys">{{ received_items[region_keys[region][0]] if region_keys[region][0] in received_items else '-' }}</td>
{% endif %}
{%- endif%}
{% else %}
<td class="smallkeys">-</td>
<td class="bigkeys">-</td>
{% endif %}
<td class="counter">{{ checks_done[region]|length }} / {{ regions[region]|length }}</td>
</tr>
<tbody class="locations hidden" id="{{ region }}-locations">
{% for location in regions[region] %}
<tr>
<td class="location {{ 'acquired' if location in checked_locations }}" id="{{ location }}">
{{ location }}
</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -1,19 +1,62 @@
import collections import collections
import datetime
import typing import typing
from typing import Counter, Optional, Dict, Any, Tuple from typing import Counter, Optional, Dict, Any, Tuple, Set, List, TYPE_CHECKING
from uuid import UUID
from flask import render_template from flask import render_template
from werkzeug.exceptions import abort from werkzeug.exceptions import abort
import datetime
from uuid import UUID
from MultiServer import Context from worlds.alttp import Items
from NetUtils import SlotType from WebHostLib import app, cache, Room
from Utils import restricted_loads from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from worlds.alttp import Items from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from MultiServer import get_item_name_from_id, Context
from .models import Room from NetUtils import SlotType
class PlayerTracker:
"""This class will create a basic 'prettier' tracker for each world using their themes automatically. This
can be overridden to customize how it will appear. Can provide icons and custom regions. The html used is also
a jinja template that can be overridden if you want your tracker to look different in certain aspects. To render
icons and regions add dictionaries to the relevant attributes of the tracker_info. To customize the layout of
your icons you can create a new html in your world and extend playerTracker.html and overwrite the icons_render
block then change the tracker_info template attribute to your template."""
template: str = 'playerTracker.html'
icons: Dict[str, str] = {}
progressive_items: List[str] = []
progressive_names: Dict[str, List[str]] = {}
regions: Dict[str, List[str]] = {}
checks_done: Dict[str, Set[str]] = {}
room: Any
team: int
player: int
name: str
all_locations: Set[str]
checked_locations: Set[str]
all_prog_items: Counter[str]
items_received: Counter[str]
received_prog_items: Counter[str]
slot_data: Dict[any, any]
theme: str
region_keys: Dict[str, str] = {}
def __init__(self, room: Any, team: int, player: int, name: str, all_locations: Set[str],
checked_locations: set, all_progression_items: Counter[str], items_received: Counter[str],
slot_data: Dict[any, any]):
self.room = room
self.team = team
self.player = player
self.name = name
self.all_locations = all_locations
self.checked_locations = checked_locations
self.all_prog_items = all_progression_items
self.items_received = items_received
self.slot_data = slot_data
alttp_icons = { alttp_icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png", "Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -289,7 +332,7 @@ def get_static_room_data(room: Room):
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>') @app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
@cache.memoize(timeout=60) # multisave is currently created at most every minute @cache.memoize(timeout=60) # multisave is currently created at most every minute
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False): def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
# Team and player must be positive and greater than zero # Team and player must be positive and greater than zero
if tracked_team < 0 or tracked_player < 1: if tracked_team < 0 or tracked_player < 1:
abort(404) abort(404)
@@ -298,13 +341,78 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
if not room: if not room:
abort(404) abort(404)
# Collect seed information and pare it down to a single player player_tracker, multisave, inventory, seed_checks_in_area, lttp_checks_done, \
slot_data, games, player_name, display_icons = fill_tracker_data(room, tracked_team, tracked_player)
game_name = games[tracked_player]
# TODO move all games in game_specific_trackers to new system
if game_name in game_specific_trackers and not want_generic:
specific_tracker = game_specific_trackers.get(game_name, None)
return specific_tracker(multisave, room, player_tracker.all_locations, inventory, tracked_team, tracked_player,
player_name, seed_checks_in_area, lttp_checks_done, slot_data[tracked_player])
elif game_name in AutoWorldRegister.world_types and not want_generic:
return render_template(
"trackers/" + player_tracker.template,
all_progression_items=player_tracker.all_prog_items,
player=tracked_player,
team=tracked_team,
room=player_tracker.room,
player_name=player_tracker.name,
checked_locations=sorted(player_tracker.checked_locations),
locations=sorted(player_tracker.all_locations),
theme=player_tracker.theme,
icons=display_icons,
regions=player_tracker.regions,
checks_done=player_tracker.checks_done,
region_keys=player_tracker.region_keys
)
else:
return __renderGenericTracker(multisave, room, player_tracker.all_locations, inventory, tracked_team, tracked_player, player_name, seed_checks_in_area, lttp_checks_done)
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
@cache.memoize(timeout=60)
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
return get_player_tracker(tracker, tracked_team, tracked_player, True)
def get_tracker_icons_and_regions(player_tracker: PlayerTracker) -> Dict[str, str]:
"""this function allows multiple icons to be used for the same item but it does require the world to submit both
a progressive_items list and the icons dict together"""
display_icons: Dict[str, str] = {}
if player_tracker.progressive_names and player_tracker.icons:
for item in player_tracker.progressive_items:
if item in player_tracker.progressive_names:
level = min(player_tracker.items_received[item], len(player_tracker.progressive_names[item]) - 1)
display_name = player_tracker.progressive_names[item][level]
if display_name in player_tracker.icons:
display_icons[item] = player_tracker.icons[display_name]
else:
display_icons[item] = player_tracker.icons[item]
else:
display_icons[item] = player_tracker.icons[item]
else:
if player_tracker.progressive_items and player_tracker.icons:
for item in player_tracker.progressive_items:
display_icons[item] = player_tracker.icons[item]
if player_tracker.regions:
for region in player_tracker.regions:
for location in region:
if location in player_tracker.checked_locations:
player_tracker.checks_done.setdefault(region, set()).add(location)
return display_icons
def fill_tracker_data(room: Room, tracked_team: int, tracked_player: int) -> Tuple:
"""Collect seed information and pare it down to a single player"""
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \ locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups = get_static_room_data(room) precollected_items, games, slot_data, groups = get_static_room_data(room)
player_name = names[tracked_team][tracked_player - 1] player_name = names[tracked_team][tracked_player - 1]
location_to_area = player_location_to_area[tracked_player] location_to_area = player_location_to_area[tracked_player]
inventory = collections.Counter() inventory = collections.Counter()
checks_done = {loc_name: 0 for loc_name in default_locations} lttp_checks_done = {loc_name: 0 for loc_name in default_locations}
# Add starting items to inventory # Add starting items to inventory
starting_items = precollected_items[tracked_player] starting_items = precollected_items[tracked_player]
@@ -322,6 +430,7 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
if tracked_player in group_members: if tracked_player in group_members:
slots_aimed_at_player.add(group_id) slots_aimed_at_player.add(group_id)
checked_locations = set()
# Add items to player inventory # Add items to player inventory
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items(): for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
# Skip teams and players not matching the request # Skip teams and players not matching the request
@@ -333,390 +442,52 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
item, recipient, flags = player_locations[location] item, recipient, flags = player_locations[location]
if recipient in slots_aimed_at_player: # a check done for the tracked player if recipient in slots_aimed_at_player: # a check done for the tracked player
attribute_item_solo(inventory, item) attribute_item_solo(inventory, item)
if ms_player == tracked_player: # a check done by the tracked player if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1 lttp_checks_done[location_to_area[location]] += 1
checks_done["Total"] += 1 lttp_checks_done["Total"] += 1
specific_tracker = game_specific_trackers.get(games[tracked_player], None) checked_locations.add(lookup_any_location_id_to_name[location])
if specific_tracker and not want_generic:
return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, prog_items = collections.Counter
seed_checks_in_area, checks_done, slot_data[tracked_player]) all_location_names = set()
else:
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, all_location_names = {lookup_any_location_id_to_name[id] for id in locations[tracked_player]}
seed_checks_in_area, checks_done) prog_items = collections.Counter()
for player in locations:
for location in locations[player]:
item, recipient, flags = locations[player][location]
if recipient == player:
if flags & 1:
item_name = lookup_any_item_id_to_name[item]
prog_items[item_name] += 1
items_received = collections.Counter()
for id in inventory:
items_received[lookup_any_item_id_to_name[id]] = inventory[id]
player_tracker = PlayerTracker(
room,
tracked_team,
tracked_player,
player_name,
all_location_names,
checked_locations,
prog_items,
items_received,
slot_data[tracked_player]
)
# grab webworld and apply its theme to the tracker
webworld = AutoWorldRegister.world_types[games[tracked_player]].web
player_tracker.theme = webworld.theme
# allow the world to add information to the tracker class
webworld.modify_tracker(player_tracker)
display_icons = get_tracker_icons_and_regions(player_tracker)
return player_tracker, multisave, inventory, seed_checks_in_area, lttp_checks_done, slot_data, games, player_name, display_icons
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>') def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: set,
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
return getPlayerTracker(tracker, tracked_team, tracked_player, True)
def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, player_name: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
# Note the presence of the triforce item
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
if game_state == 30:
inventory[106] = 1 # Triforce
# Progressive items need special handling for icons and class
progressive_items = {
"Progressive Sword": 94,
"Progressive Glove": 97,
"Progressive Bow": 100,
"Progressive Mail": 96,
"Progressive Shield": 95,
}
progressive_names = {
"Progressive Sword": [None, 'Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword'],
"Progressive Glove": [None, 'Power Glove', 'Titan Mitts'],
"Progressive Bow": [None, "Bow", "Silver Bow"],
"Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"],
"Progressive Shield": [None, "Blue Shield", "Red Shield", "Mirror Shield"]
}
# Determine which icon to use
display_data = {}
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
display_name = progressive_names[item_name][level]
acquired = True
if not display_name:
acquired = False
display_name = progressive_names[item_name][level + 1]
base_name = item_name.split(maxsplit=1)[1].lower()
display_data[base_name + "_acquired"] = acquired
display_data[base_name + "_url"] = alttp_icons[display_name]
# The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should?
sp_areas = ordered_areas[2:15]
player_big_key_locations = set()
player_small_key_locations = set()
for loc_data in locations.values():
for values in loc_data.values():
item_id, item_player, flags = values
if item_player == player:
if item_id in ids_big_key:
player_big_key_locations.add(ids_big_key[item_id])
elif item_id in ids_small_key:
player_small_key_locations.add(ids_small_key[item_id])
return render_template("lttpTracker.html", inventory=inventory,
player_name=player_name, room=room, icons=alttp_icons, checks_done=checks_done,
checks_in_area=seed_checks_in_area[player],
acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas,
key_locations=player_small_key_locations,
big_key_locations=player_big_key_locations,
**display_data)
def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
icons = {
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
"Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png",
"Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png",
"Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png",
"Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png",
"Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png",
"Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png",
"Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png",
"Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png",
"Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png",
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
"Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png",
"Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png",
"Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png",
"Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png",
"Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif",
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
"Saddle": "https://i.imgur.com/2QtDyR0.png",
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
}
minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
}
display_data = {}
# Determine display for progressive items
progressive_items = {
"Progressive Tools": 45013,
"Progressive Weapons": 45012,
"Progressive Armor": 45014,
"Progressive Resource Crafting": 45001
}
progressive_names = {
"Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"],
"Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"],
"Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"],
"Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"]
}
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
display_name = progressive_names[item_name][level]
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
display_data[base_name + "_url"] = icons[display_name]
# Multi-items
multi_items = {
"3 Ender Pearls": 45029,
"8 Netherite Scrap": 45015,
"Dragon Egg Shard": 45043
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
if count >= 0:
display_data[base_name + "_count"] = count
# Victory condition
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
display_data['game_finished'] = game_state == 30
# Turn location IDs into advancement tab counts
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
lookup_name = lambda id: lookup_any_location_id_to_name[id]
location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations}
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations])
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done['Total'] = len(checked_locations)
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()}
checks_in_area['Total'] = sum(checks_in_area.values())
return render_template("minecraftTracker.html",
inventory=inventory, icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
id in lookup_any_item_id_to_name},
player=player, team=team, room=room, player_name=playerName,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data)
def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
icons = {
"Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png",
"Ocarina of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Ocarina_of_Time_Icon.png",
"Slingshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/32/OoT_Fairy_Slingshot_Icon.png",
"Boomerang": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/d5/OoT_Boomerang_Icon.png",
"Bottle": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/fc/OoT_Bottle_Icon.png",
"Rutos Letter": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/OoT_Letter_Icon.png",
"Bombs": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/11/OoT_Bomb_Icon.png",
"Bombchus": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/36/OoT_Bombchu_Icon.png",
"Lens of Truth": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/05/OoT_Lens_of_Truth_Icon.png",
"Bow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9a/OoT_Fairy_Bow_Icon.png",
"Hookshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/77/OoT_Hookshot_Icon.png",
"Longshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/OoT_Longshot_Icon.png",
"Megaton Hammer": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/93/OoT_Megaton_Hammer_Icon.png",
"Fire Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1e/OoT_Fire_Arrow_Icon.png",
"Ice Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3c/OoT_Ice_Arrow_Icon.png",
"Light Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/76/OoT_Light_Arrow_Icon.png",
"Dins Fire": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/da/OoT_Din%27s_Fire_Icon.png",
"Farores Wind": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/7a/OoT_Farore%27s_Wind_Icon.png",
"Nayrus Love": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/be/OoT_Nayru%27s_Love_Icon.png",
"Kokiri Sword": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/5/53/OoT_Kokiri_Sword_Icon.png",
"Biggoron Sword": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2e/OoT_Giant%27s_Knife_Icon.png",
"Mirror Shield": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b0/OoT_Mirror_Shield_Icon_2.png",
"Goron Bracelet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b7/OoT_Goron%27s_Bracelet_Icon.png",
"Silver Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b9/OoT_Silver_Gauntlets_Icon.png",
"Golden Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/6a/OoT_Golden_Gauntlets_Icon.png",
"Goron Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1c/OoT_Goron_Tunic_Icon.png",
"Zora Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2c/OoT_Zora_Tunic_Icon.png",
"Silver Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Silver_Scale_Icon.png",
"Gold Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/95/OoT_Golden_Scale_Icon.png",
"Iron Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/34/OoT_Iron_Boots_Icon.png",
"Hover Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/22/OoT_Hover_Boots_Icon.png",
"Adults Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f9/OoT_Adult%27s_Wallet_Icon.png",
"Giants Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/8/87/OoT_Giant%27s_Wallet_Icon.png",
"Small Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9f/OoT3D_Magic_Jar_Icon.png",
"Large Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3e/OoT3D_Large_Magic_Jar_Icon.png",
"Gerudo Membership Card": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Gerudo_Token_Icon.png",
"Gold Skulltula Token": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/47/OoT_Token_Icon.png",
"Triforce Piece": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0b/SS_Triforce_Piece_Icon.png",
"Triforce": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/68/ALttP_Triforce_Title_Sprite.png",
"Zeldas Lullaby": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
"Eponas Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
"Sarias Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
"Suns Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
"Song of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
"Song of Storms": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
"Minuet of Forest": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e4/Green_Note.png",
"Bolero of Fire": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f0/Red_Note.png",
"Serenade of Water": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0f/Blue_Note.png",
"Requiem of Spirit": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/Orange_Note.png",
"Nocturne of Shadow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/Purple_Note.png",
"Prelude of Light": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/90/Yellow_Note.png",
"Small Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e5/OoT_Small_Key_Icon.png",
"Boss Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/40/OoT_Boss_Key_Icon.png",
}
display_data = {}
# Determine display for progressive items
progressive_items = {
"Progressive Hookshot": 66128,
"Progressive Strength Upgrade": 66129,
"Progressive Wallet": 66133,
"Progressive Scale": 66134,
"Magic Meter": 66138,
"Ocarina": 66139,
}
progressive_names = {
"Progressive Hookshot": ["Hookshot", "Hookshot", "Longshot"],
"Progressive Strength Upgrade": ["Goron Bracelet", "Goron Bracelet", "Silver Gauntlets", "Golden Gauntlets"],
"Progressive Wallet": ["Adults Wallet", "Adults Wallet", "Giants Wallet", "Giants Wallet"],
"Progressive Scale": ["Silver Scale", "Silver Scale", "Gold Scale"],
"Magic Meter": ["Small Magic", "Small Magic", "Large Magic"],
"Ocarina": ["Fairy Ocarina", "Fairy Ocarina", "Ocarina of Time"]
}
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name])-1)
display_name = progressive_names[item_name][level]
if item_name.startswith("Progressive"):
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
else:
base_name = item_name.lower().replace(' ', '_')
display_data[base_name+"_url"] = icons[display_name]
if base_name == "hookshot":
display_data['hookshot_length'] = {0: '', 1: 'H', 2: 'L'}.get(level)
if base_name == "wallet":
display_data['wallet_size'] = {0: '99', 1: '200', 2: '500', 3: '999'}.get(level)
# Determine display for bottles. Show letter if it's obtained, determine bottle count
bottle_ids = [66015, 66020, 66021, 66140, 66141, 66142, 66143, 66144, 66145, 66146, 66147, 66148]
display_data['bottle_count'] = min(sum(map(lambda item_id: inventory[item_id], bottle_ids)), 4)
display_data['bottle_url'] = icons['Rutos Letter'] if inventory[66021] > 0 else icons['Bottle']
# Determine bombchu display
display_data['has_bombchus'] = any(map(lambda item_id: inventory[item_id] > 0, [66003, 66106, 66107, 66137]))
# Multi-items
multi_items = {
"Gold Skulltula Token": 66091,
"Triforce Piece": 66202,
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
display_data[base_name+"_count"] = inventory[item_id]
# Gather dungeon locations
area_id_ranges = {
"Overworld": (67000, 67280),
"Deku Tree": (67281, 67303),
"Dodongo's Cavern": (67304, 67334),
"Jabu Jabu's Belly": (67335, 67359),
"Bottom of the Well": (67360, 67384),
"Forest Temple": (67385, 67420),
"Fire Temple": (67421, 67457),
"Water Temple": (67458, 67484),
"Shadow Temple": (67485, 67532),
"Spirit Temple": (67533, 67582),
"Ice Cavern": (67583, 67596),
"Gerudo Training Ground": (67597, 67635),
"Thieves' Hideout": (67259, 67263),
"Ganon's Castle": (67636, 67673),
}
def lookup_and_trim(id, area):
full_name = lookup_any_location_id_to_name[id]
if id == 67673:
return full_name[13:] # Ganons Tower Boss Key Chest
if area not in ["Overworld", "Thieves' Hideout"]:
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
return full_name[len(area):]
return full_name
checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player]))
location_info = {area: {lookup_and_trim(id, area): id in checked_locations for id in range(min_id, max_id+1) if id in locations[player]}
for area, (min_id, max_id) in area_id_ranges.items()}
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
for area, (min_id, max_id) in area_id_ranges.items()}
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
for loc in location_info["Thieves' Hideout"]:
del location_info["Overworld"][loc]
checks_done['Total'] = sum(checks_done.values())
checks_in_area['Total'] = sum(checks_in_area.values())
# Give skulltulas on non-tracked locations
non_tracked_locations = multisave.get("location_checks", {}).get((team, player), set()).difference(set(locations[player]))
for id in non_tracked_locations:
if "GS" in lookup_and_trim(id, ''):
display_data["token_count"] += 1
# Gather small and boss key info
small_key_counts = {
"Forest Temple": inventory[66175],
"Fire Temple": inventory[66176],
"Water Temple": inventory[66177],
"Spirit Temple": inventory[66178],
"Shadow Temple": inventory[66179],
"Bottom of the Well": inventory[66180],
"Gerudo Training Ground": inventory[66181],
"Thieves' Hideout": inventory[66182],
"Ganon's Castle": inventory[66183],
}
boss_key_counts = {
"Forest Temple": '' if inventory[66149] else '',
"Fire Temple": '' if inventory[66150] else '',
"Water Temple": '' if inventory[66151] else '',
"Spirit Temple": '' if inventory[66152] else '',
"Shadow Temple": '' if inventory[66153] else '',
"Ganon's Castle": '' if inventory[66154] else '',
}
# Victory condition
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
display_data['game_finished'] = game_state == 30
return render_template("ootTracker.html",
inventory=inventory, player=player, team=team, room=room, player_name=playerName,
icons=icons, acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
small_key_counts=small_key_counts, boss_key_counts=boss_key_counts,
**display_data)
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str, inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str: seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
@@ -753,7 +524,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
} }
timespinner_location_ids = { timespinner_location_ids = {
"Present": [ "Present": [
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009, 1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019, 1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029, 1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
@@ -774,20 +545,20 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
1337150, 1337151, 1337152, 1337153, 1337154, 1337155, 1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
1337171, 1337172, 1337173, 1337174, 1337175], 1337171, 1337172, 1337173, 1337174, 1337175],
"Ancient Pyramid": [ "Ancient Pyramid": [
1337236, 1337236,
1337246, 1337247, 1337248, 1337249] 1337246, 1337247, 1337248, 1337249]
} }
if(slot_data["DownloadableItems"]): if(slot_data["DownloadableItems"]):
timespinner_location_ids["Present"] += [ timespinner_location_ids["Present"] += [
1337156, 1337157, 1337159, 1337156, 1337157, 1337159,
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169, 1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
1337170] 1337170]
if(slot_data["Cantoran"]): if(slot_data["Cantoran"]):
timespinner_location_ids["Past"].append(1337176) timespinner_location_ids["Past"].append(1337176)
if(slot_data["LoreChecks"]): if(slot_data["LoreChecks"]):
timespinner_location_ids["Present"] += [ timespinner_location_ids["Present"] += [
1337177, 1337178, 1337179, 1337177, 1337178, 1337179,
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187] 1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
timespinner_location_ids["Past"] += [ timespinner_location_ids["Past"] += [
1337188, 1337189, 1337188, 1337189,
@@ -816,38 +587,38 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
acquired_items = {lookup_any_item_id_to_name[id] for id in inventory if id in lookup_any_item_id_to_name} acquired_items = {lookup_any_item_id_to_name[id] for id in inventory if id in lookup_any_item_id_to_name}
options = {k for k, v in slot_data.items() if v} options = {k for k, v in slot_data.items() if v}
return render_template("timespinnerTracker.html", return render_template("trackers/" + "timespinnerTracker.html",
inventory=inventory, icons=icons, acquired_items=acquired_items, inventory=inventory, icons=icons, acquired_items=acquired_items,
player=player, team=team, room=room, player_name=playerName, player=player, team=team, room=room, player_name=playerName,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
options=options, **display_data) options=options, **display_data)
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: set,
inventory: Counter, team: int, player: int, playerName: str, inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str: seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
icons = { icons = {
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png", "Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ETank.png",
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png", "Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Missile.png",
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png", "Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Super.png",
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png", "Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/PowerBomb.png",
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png", "Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Bomb.png",
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png", "Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Charge.png",
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png", "Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Ice.png",
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png", "Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/HiJump.png",
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png", "Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpeedBooster.png",
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png", "Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Wave.png",
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png", "Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Spazer.png",
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png", "Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpringBall.png",
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png", "Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Varia.png",
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png", "Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Plasma.png",
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png", "Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Grapple.png",
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png", "Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Morph.png",
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png", "Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Reserve.png",
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png", "Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Gravity.png",
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png", "X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/XRayScope.png",
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png", "Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpaceJump.png",
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png", "Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ScrewAttack.png",
"Nothing": "", "Nothing": "",
"No Energy": "", "No Energy": "",
"Kraid": "", "Kraid": "",
@@ -897,6 +668,7 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
for item_name, item_id in multi_items.items(): for item_name, item_id in multi_items.items():
base_name = item_name.split()[0].lower() base_name = item_name.split()[0].lower()
count = inventory[item_id]
display_data[base_name+"_count"] = inventory[item_id] display_data[base_name+"_count"] = inventory[item_id]
# Victory condition # Victory condition
@@ -914,7 +686,7 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in supermetroid_location_ids.items()} checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in supermetroid_location_ids.items()}
checks_in_area['Total'] = sum(checks_in_area.values()) checks_in_area['Total'] = sum(checks_in_area.values())
return render_template("supermetroidTracker.html", return render_template("trackers/" + "supermetroidTracker.html",
inventory=inventory, icons=icons, inventory=inventory, icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
id in lookup_any_item_id_to_name}, id in lookup_any_item_id_to_name},
@@ -922,7 +694,8 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data) **display_data)
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: set,
inventory: Counter, team: int, player: int, playerName: str, inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str: seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
@@ -937,11 +710,11 @@ def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dic
for order_index, networkItem in enumerate(ordered_items, start=1): for order_index, networkItem in enumerate(ordered_items, start=1):
player_received_items[networkItem.item] = order_index player_received_items[networkItem.item] = order_index
return render_template("genericTracker.html", return render_template("trackers/" + "genericTracker.html",
inventory=inventory, inventory=inventory,
player=player, team=team, room=room, player_name=playerName, player=player, team=team, room=room, player_name=playerName,
checked_locations=checked_locations, checked_locations=checked_locations,
not_checked_locations=set(locations[player]) - checked_locations, not_checked_locations=locations - checked_locations,
received_items=player_received_items) received_items=player_received_items)
@@ -983,9 +756,9 @@ def getTracker(tracker: UUID):
continue continue
item, recipient, flags = player_locations[location] item, recipient, flags = player_locations[location]
if recipient in names: if recipient in names:
attribute_item(inventory, team, recipient, item) attribute_item(inventory, team, recipient, item)
checks_done[team][player][player_location_to_area[player][location]] += 1 checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1 checks_done[team][player]["Total"] += 1
@@ -1029,7 +802,7 @@ def getTracker(tracker: UUID):
for (team, player), data in multisave.get("video", []): for (team, player), data in multisave.get("video", []):
video[(team, player)] = data video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name, return render_template("trackers/" + "multiworldTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names, lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons, tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas, multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
@@ -1040,9 +813,6 @@ def getTracker(tracker: UUID):
game_specific_trackers: typing.Dict[str, typing.Callable] = { game_specific_trackers: typing.Dict[str, typing.Callable] = {
"Minecraft": __renderMinecraftTracker,
"Ocarina of Time": __renderOoTTracker,
"Timespinner": __renderTimespinnerTracker, "Timespinner": __renderTimespinnerTracker,
"A Link to the Past": __renderAlttpTracker,
"Super Metroid": __renderSuperMetroidTracker "Super Metroid": __renderSuperMetroidTracker
} }

View File

@@ -1,19 +1,19 @@
import base64
import json
import typing import typing
import uuid
import zipfile import zipfile
import lzma
import json
import base64
import MultiServer
import uuid
from io import BytesIO from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import flush, select from pony.orm import flush, select
import MultiServer from WebHostLib import app, Seed, Room, Slot
from Utils import parse_yaml, VersionException, __version__
from Patch import preferred_endings, AutoPatchRegister
from NetUtils import NetworkSlot, SlotType from NetUtils import NetworkSlot, SlotType
from Utils import VersionException, __version__
from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot
banned_zip_contents = (".sfc",) banned_zip_contents = (".sfc",)
@@ -22,7 +22,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
if not owner: if not owner:
owner = session["_id"] owner = session["_id"]
infolist = zfile.infolist() infolist = zfile.infolist()
slots: typing.Set[Slot] = set() slots = set()
spoiler = "" spoiler = ""
multidata = None multidata = None
for file in infolist: for file in infolist:
@@ -38,6 +38,17 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
player_name=patch.player_name, player_name=patch.player_name,
player_id=patch.player, player_id=patch.player,
game=patch.game)) game=patch.game))
elif file.filename.endswith(tuple(preferred_endings.values())):
data = zfile.open(file, "r").read()
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
if yaml_data["version"] < 2:
return "Old format cannot be uploaded (outdated .apbp)"
metadata = yaml_data["meta"]
slots.add(Slot(data=data,
player_name=metadata["player_name"],
player_id=metadata["player_id"],
game=yaml_data["game"]))
elif file.filename.endswith(".apmc"): elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read() data = zfile.open(file, "r").read()

View File

@@ -1,495 +0,0 @@
import asyncio
import base64
import platform
from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser
from NetUtils import ClientStatus
import Utils
from Utils import async_start
import colorama # type: ignore
from zilliandomizer.zri.memory import Memory
from zilliandomizer.zri import events
from zilliandomizer.utils.loc_name_maps import id_to_loc
from zilliandomizer.options import Chars
from zilliandomizer.patch import RescueInfo
from worlds.zillion.id_maps import make_id_to_others
from worlds.zillion.config import base_id, zillion_map
class ZillionCommandProcessor(ClientCommandProcessor):
ctx: "ZillionContext"
def _cmd_sms(self) -> None:
""" Tell the client that Zillion is running in RetroArch. """
logger.info("ready to look for game")
self.ctx.look_for_retroarch.set()
def _cmd_map(self) -> None:
""" Toggle view of the map tracker. """
self.ctx.ui_toggle_map()
class ToggleCallback(Protocol):
def __call__(self) -> None: ...
class SetRoomCallback(Protocol):
def __call__(self, rooms: List[List[int]]) -> None: ...
class ZillionContext(CommonContext):
game = "Zillion"
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
items_handling = 1 # receive items from other players
from_game: "asyncio.Queue[events.EventFromGame]"
to_game: "asyncio.Queue[events.EventToGame]"
ap_local_count: int
""" local checks watched by server """
next_item: int
""" index in `items_received` """
ap_id_to_name: Dict[int, str]
ap_id_to_zz_id: Dict[int, int]
start_char: Chars = "JJ"
rescues: Dict[int, RescueInfo] = {}
loc_mem_to_id: Dict[int, int] = {}
got_room_info: asyncio.Event
""" flag for connected to server """
got_slot_data: asyncio.Event
""" serves as a flag for whether I am logged in to the server """
look_for_retroarch: asyncio.Event
"""
There is a bug in Python in Windows
https://github.com/python/cpython/issues/91227
that makes it so if I look for RetroArch before it's ready,
it breaks the asyncio udp transport system.
As a workaround, we don't look for RetroArch until this event is set.
"""
ui_toggle_map: ToggleCallback
ui_set_rooms: SetRoomCallback
""" parameter is y 16 x 8 numbers to show in each room """
def __init__(self,
server_address: str,
password: str) -> None:
super().__init__(server_address, password)
self.from_game = asyncio.Queue()
self.to_game = asyncio.Queue()
self.got_room_info = asyncio.Event()
self.got_slot_data = asyncio.Event()
self.ui_toggle_map = lambda: None
self.ui_set_rooms = lambda rooms: None
self.look_for_retroarch = asyncio.Event()
if platform.system() != "Windows":
# asyncio udp bug is only on Windows
self.look_for_retroarch.set()
self.reset_game_state()
def reset_game_state(self) -> None:
for _ in range(self.from_game.qsize()):
self.from_game.get_nowait()
for _ in range(self.to_game.qsize()):
self.to_game.get_nowait()
self.got_slot_data.clear()
self.ap_local_count = 0
self.next_item = 0
self.ap_id_to_name = {}
self.ap_id_to_zz_id = {}
self.rescues = {}
self.loc_mem_to_id = {}
self.locations_checked.clear()
self.missing_locations.clear()
self.checked_locations.clear()
self.finished_game = False
self.items_received.clear()
# override
def on_deathlink(self, data: Dict[str, Any]) -> None:
self.to_game.put_nowait(events.DeathEventToGame())
return super().on_deathlink(data)
# override
async def server_auth(self, password_requested: bool = False) -> None:
if password_requested and not self.password:
await super().server_auth(password_requested)
if not self.auth:
logger.info('waiting for connection to game...')
return
logger.info("logging in to server...")
await self.send_connect()
# override
def run_gui(self) -> None:
from kvui import GameManager
from kivy.core.text import Label as CoreLabel
from kivy.graphics import Ellipse, Color, Rectangle
from kivy.uix.layout import Layout
from kivy.uix.widget import Widget
class ZillionManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Zillion Client"
class MapPanel(Widget):
MAP_WIDTH: ClassVar[int] = 281
_number_textures: List[Any] = []
rooms: List[List[int]] = []
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.rooms = [[0 for _ in range(8)] for _ in range(16)]
self._make_numbers()
self.update_map()
self.bind(pos=self.update_map)
# self.bind(size=self.update_bg)
def _make_numbers(self) -> None:
self._number_textures = []
for n in range(10):
label = CoreLabel(text=str(n), font_size=22, color=(0.1, 0.9, 0, 1))
label.refresh()
self._number_textures.append(label.texture)
def update_map(self, *args: Any) -> None:
self.canvas.clear()
with self.canvas:
Color(1, 1, 1, 1)
Rectangle(source=zillion_map,
pos=self.pos,
size=(ZillionManager.MapPanel.MAP_WIDTH,
int(ZillionManager.MapPanel.MAP_WIDTH * 1.456))) # aspect ratio of that image
for y in range(16):
for x in range(8):
num = self.rooms[15 - y][x]
if num > 0:
Color(0, 0, 0, 0.4)
pos = [self.pos[0] + 17 + x * 32, self.pos[1] + 14 + y * 24]
Ellipse(size=[22, 22], pos=pos)
Color(1, 1, 1, 1)
pos = [self.pos[0] + 22 + x * 32, self.pos[1] + 12 + y * 24]
num_texture = self._number_textures[num]
Rectangle(texture=num_texture, size=num_texture.size, pos=pos)
def build(self) -> Layout:
container = super().build()
self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=0)
self.main_area_container.add_widget(self.map_widget)
return container
def toggle_map_width(self) -> None:
if self.map_widget.width == 0:
self.map_widget.width = ZillionManager.MapPanel.MAP_WIDTH
else:
self.map_widget.width = 0
self.container.do_layout()
def set_rooms(self, rooms: List[List[int]]) -> None:
self.map_widget.rooms = rooms
self.map_widget.update_map()
self.ui = ZillionManager(self)
self.ui_toggle_map = lambda: self.ui.toggle_map_width()
self.ui_set_rooms = lambda rooms: self.ui.set_rooms(rooms)
run_co: Coroutine[Any, Any, None] = self.ui.async_run()
self.ui_task = asyncio.create_task(run_co, name="UI")
def on_package(self, cmd: str, args: Dict[str, Any]) -> None:
self.room_item_numbers_to_ui()
if cmd == "Connected":
logger.info("logged in to Archipelago server")
if "slot_data" not in args:
logger.warn("`Connected` packet missing `slot_data`")
return
slot_data = args["slot_data"]
if "start_char" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `start_char`")
return
self.start_char = slot_data['start_char']
if self.start_char not in {"Apple", "Champ", "JJ"}:
logger.warn("invalid Zillion `Connected` packet, "
f"`slot_data` `start_char` has invalid value: {self.start_char}")
if "rescues" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `rescues`")
return
rescues = slot_data["rescues"]
self.rescues = {}
for rescue_id, json_info in rescues.items():
assert rescue_id in ("0", "1"), f"invalid rescue_id in Zillion slot_data: {rescue_id}"
# TODO: just take start_char out of the RescueInfo so there's no opportunity for a mismatch?
assert json_info["start_char"] == self.start_char, \
f'mismatch in Zillion slot data: {json_info["start_char"]} {self.start_char}'
ri = RescueInfo(json_info["start_char"],
json_info["room_code"],
json_info["mask"])
self.rescues[0 if rescue_id == "0" else 1] = ri
if "loc_mem_to_id" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `loc_mem_to_id`")
return
loc_mem_to_id = slot_data["loc_mem_to_id"]
self.loc_mem_to_id = {}
for mem_str, id_str in loc_mem_to_id.items():
mem = int(mem_str)
id_ = int(id_str)
room_i = mem // 256
assert 0 <= room_i < 74
assert id_ in id_to_loc
self.loc_mem_to_id[mem] = id_
self.got_slot_data.set()
payload = {
"cmd": "Get",
"keys": [f"zillion-{self.auth}-doors"]
}
async_start(self.send_msgs([payload]))
elif cmd == "Retrieved":
if "keys" not in args:
logger.warning(f"invalid Retrieved packet to ZillionClient: {args}")
return
keys = cast(Dict[str, Optional[str]], args["keys"])
doors_b64 = keys[f"zillion-{self.auth}-doors"]
if doors_b64:
logger.info("received door data from server")
doors = base64.b64decode(doors_b64)
self.to_game.put_nowait(events.DoorEventToGame(doors))
elif cmd == "RoomInfo":
self.seed_name = args["seed_name"]
self.got_room_info.set()
def room_item_numbers_to_ui(self) -> None:
rooms = [[0 for _ in range(8)] for _ in range(16)]
for loc_id in self.missing_locations:
loc_id_small = loc_id - base_id
loc_name = id_to_loc[loc_id_small]
y = ord(loc_name[0]) - 65
x = ord(loc_name[2]) - 49
if y == 9 and x == 5:
# don't show main computer in numbers
continue
assert (0 <= y < 16) and (0 <= x < 8), f"invalid index from location name {loc_name}"
rooms[y][x] += 1
# TODO: also add locations with locals lost from loading save state or reset
self.ui_set_rooms(rooms)
def process_from_game_queue(self) -> None:
if self.from_game.qsize():
event_from_game = self.from_game.get_nowait()
if isinstance(event_from_game, events.AcquireLocationEventFromGame):
server_id = event_from_game.id + base_id
loc_name = id_to_loc[event_from_game.id]
self.locations_checked.add(server_id)
if server_id in self.missing_locations:
self.ap_local_count += 1
n_locations = len(self.missing_locations) + len(self.checked_locations) - 1 # -1 to ignore win
logger.info(f'New Check: {loc_name} ({self.ap_local_count}/{n_locations})')
async_start(self.send_msgs([
{"cmd": 'LocationChecks', "locations": [server_id]}
]))
else:
# This will happen a lot in Zillion,
# because all the key words are local and unwatched by the server.
logger.debug(f"DEBUG: {loc_name} not in missing")
elif isinstance(event_from_game, events.DeathEventFromGame):
async_start(self.send_death())
elif isinstance(event_from_game, events.WinEventFromGame):
if not self.finished_game:
async_start(self.send_msgs([
{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}
]))
self.finished_game = True
elif isinstance(event_from_game, events.DoorEventFromGame):
if self.auth:
doors_b64 = base64.b64encode(event_from_game.doors).decode()
payload = {
"cmd": "Set",
"key": f"zillion-{self.auth}-doors",
"operations": [{"operation": "replace", "value": doors_b64}]
}
async_start(self.send_msgs([payload]))
else:
logger.warning(f"WARNING: unhandled event from game {event_from_game}")
def process_items_received(self) -> None:
if len(self.items_received) > self.next_item:
zz_item_ids = [self.ap_id_to_zz_id[item.item] for item in self.items_received]
for index in range(self.next_item, len(self.items_received)):
ap_id = self.items_received[index].item
from_name = self.player_names[self.items_received[index].player]
# TODO: colors in this text, like sni client?
logger.info(f'received {self.ap_id_to_name[ap_id]} from {from_name}')
self.to_game.put_nowait(
events.ItemEventToGame(zz_item_ids)
)
self.next_item = len(self.items_received)
def name_seed_from_ram(data: bytes) -> Tuple[str, str]:
""" returns player name, and end of seed string """
if len(data) == 0:
# no connection to game
return "", "xxx"
null_index = data.find(b'\x00')
if null_index == -1:
logger.warning(f"invalid game id in rom {repr(data)}")
null_index = len(data)
name = data[:null_index].decode()
null_index_2 = data.find(b'\x00', null_index + 1)
if null_index_2 == -1:
null_index_2 = len(data)
seed_name = data[null_index + 1:null_index_2].decode()
return name, seed_name
async def zillion_sync_task(ctx: ZillionContext) -> None:
logger.info("started zillion sync task")
# to work around the Python bug where we can't check for RetroArch
if not ctx.look_for_retroarch.is_set():
logger.info("Start Zillion in RetroArch, then use the /sms command to connect to it.")
await asyncio.wait((
asyncio.create_task(ctx.look_for_retroarch.wait()),
asyncio.create_task(ctx.exit_event.wait())
), return_when=asyncio.FIRST_COMPLETED)
last_log = ""
def log_no_spam(msg: str) -> None:
nonlocal last_log
if msg != last_log:
last_log = msg
logger.info(msg)
# to only show this message once per client run
help_message_shown = False
with Memory(ctx.from_game, ctx.to_game) as memory:
while not ctx.exit_event.is_set():
ram = await memory.read()
game_id = memory.get_rom_to_ram_data(ram)
name, seed_end = name_seed_from_ram(game_id)
if len(name):
if name == ctx.auth:
# this is the name we know
if ctx.server and ctx.server.socket: # type: ignore
if ctx.got_room_info.is_set():
if ctx.seed_name and ctx.seed_name.endswith(seed_end):
# correct seed
if memory.have_generation_info():
log_no_spam("everything connected")
await memory.process_ram(ram)
ctx.process_from_game_queue()
ctx.process_items_received()
else: # no generation info
if ctx.got_slot_data.is_set():
memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id)
ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \
make_id_to_others(ctx.start_char)
ctx.next_item = 0
ctx.ap_local_count = len(ctx.checked_locations)
else: # no slot data yet
async_start(ctx.send_connect())
log_no_spam("logging in to server...")
await asyncio.wait((
ctx.got_slot_data.wait(),
ctx.exit_event.wait(),
asyncio.sleep(6)
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
else: # not correct seed name
log_no_spam("incorrect seed - did you mix up roms?")
else: # no room info
# If we get here, it looks like `RoomInfo` packet got lost
log_no_spam("waiting for room info from server...")
else: # server not connected
log_no_spam("waiting for server connection...")
else: # new game
log_no_spam("connected to new game")
await ctx.disconnect()
ctx.reset_server_state()
ctx.seed_name = None
ctx.got_room_info.clear()
ctx.reset_game_state()
memory.reset_game_state()
ctx.auth = name
async_start(ctx.connect())
await asyncio.wait((
ctx.got_room_info.wait(),
ctx.exit_event.wait(),
asyncio.sleep(6)
), return_when=asyncio.FIRST_COMPLETED)
else: # no name found in game
if not help_message_shown:
logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.')
help_message_shown = True
log_no_spam("looking for connection to game...")
await asyncio.sleep(0.3)
await asyncio.sleep(0.09375)
logger.info("zillion sync task ending")
async def main() -> None:
parser = get_base_parser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apzl Archipelago Binary Patch file')
# SNI parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
args = parser.parse_args()
print(args)
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating sms rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
ctx = ZillionContext(args.connect, args.password)
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
sync_task = asyncio.create_task(zillion_sync_task(ctx))
await ctx.exit_event.wait()
ctx.server_address = None
logger.debug("waiting for sync task to end")
await sync_task
logger.debug("sync task ended")
await ctx.shutdown()
if __name__ == "__main__":
Utils.init_logging("ZillionClient", exception_logger="Client")
colorama.init()
asyncio.run(main())
colorama.deinit()

BIN
data/basepatch.apbp Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -15,8 +15,6 @@
<UILog>: <UILog>:
viewclass: 'SelectableLabel' viewclass: 'SelectableLabel'
scroll_y: 0 scroll_y: 0
scroll_type: ["content", "bars"]
bar_width: dp(12)
effect_cls: "ScrollEffect" effect_cls: "ScrollEffect"
SelectableRecycleBoxLayout: SelectableRecycleBoxLayout:
default_size: None, dp(20) default_size: None, dp(20)

View File

@@ -77,13 +77,12 @@ local scrub_sanity_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0x10) return scene_check(scene_offset, bit_to_check, 0x10)
end end
-- Why is there an extra offset of 3 for temp context checks? Who knows.
local cow_check = function(scene_offset, bit_to_check) local cow_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC) return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, bit_to_check - 0x03}) or check_temp_context({scene_offset, 0x00, bit_to_check})
end end
-- DMT and DMC fairies are weird, their temp context check is special-coded for them -- Haven't been able to get DMT and DMC fairy to send instantly
local great_fairy_magic_check = function(scene_offset, bit_to_check) local great_fairy_magic_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0x4) return scene_check(scene_offset, bit_to_check, 0x4)
or check_temp_context({scene_offset, 0x05, bit_to_check}) or check_temp_context({scene_offset, 0x05, bit_to_check})
@@ -101,18 +100,6 @@ local bean_sale_check = function(scene_offset, bit_to_check)
or check_temp_context({scene_offset, 0x00, 0x16}) or check_temp_context({scene_offset, 0x00, 0x16})
end end
-- Medigoron reports 0x00620028 to 0x40002C
local medigoron_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, 0x28})
end
-- Bombchu salesman reports 0x005E0003 to 0x40002C
local salesman_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, 0x03})
end
--Helper method to resolve skulltula lookup location --Helper method to resolve skulltula lookup location
local function skulltula_scene_to_array_index(i) local function skulltula_scene_to_array_index(i)
return (i + 3) - 2 * (i % 4) return (i + 3) - 2 * (i % 4)
@@ -588,7 +575,7 @@ local read_death_mountain_trail_checks = function()
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E) checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
checks["DMT Chest"] = chest_check(0x60, 0x01) checks["DMT Chest"] = chest_check(0x60, 0x01)
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17) checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18) or check_temp_context({0xFF, 0x05, 0x13}) checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18)
checks["DMT Biggoron"] = big_goron_sword_check() checks["DMT Biggoron"] = big_goron_sword_check()
checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18) checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18)
@@ -605,7 +592,7 @@ local read_goron_city_checks = function()
checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F) checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F)
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6) checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1) checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
checks["GC Medigoron"] = medigoron_check(0x62, 0x1) checks["GC Medigoron"] = on_the_ground_check(0x62, 0x1)
checks["GC Maze Left Chest"] = chest_check(0x62, 0x00) checks["GC Maze Left Chest"] = chest_check(0x62, 0x00)
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01) checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02) checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
@@ -627,7 +614,7 @@ local read_death_mountain_crater_checks = function()
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08) checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02) checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A) checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10) or check_temp_context({0xFF, 0x05, 0x14}) checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10)
checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6) checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6)
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1) checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
@@ -974,7 +961,7 @@ end
local read_haunted_wasteland_checks = function() local read_haunted_wasteland_checks = function()
local checks = {} local checks = {}
checks["Wasteland Bombchu Salesman"] = salesman_check(0x5E, 0x01) checks["Wasteland Bombchu Salesman"] = on_the_ground_check(0x5E, 0x01)
checks["Wasteland Chest"] = chest_check(0x5E, 0x00) checks["Wasteland Chest"] = chest_check(0x5E, 0x00)
checks["Wasteland GS"] = skulltula_check(0x15, 0x1) checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
return checks return checks

Binary file not shown.

View File

@@ -1,389 +0,0 @@
--
-- json.lua
--
-- Copyright (c) 2015 rxi
--
-- This library is free software; you can redistribute it and/or modify it
-- under the terms of the MIT license. See LICENSE for details.
--
local json = { _version = "0.1.0" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
function error(err)
print(err)
end
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local escape_char_map_inv = { [ "\\/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if val[1] ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
print("invalid table: sparse array")
print(n)
print("VAL:")
print(val)
print("STACK:")
print(stack)
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
--local line_count = 1
--local col_count = 1
--for i = 1, idx - 1 do
-- col_count = col_count + 1
-- if str:sub(i, i) == "\n" then
-- line_count = line_count + 1
-- col_count = 1
-- end
-- end
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(3, 6), 16 )
local n2 = tonumber( s:sub(9, 12), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local has_unicode_escape = false
local has_surrogate_escape = false
local has_escape = false
local last
for j = i + 1, #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
end
if last == 92 then -- "\\" (escape char)
if x == 117 then -- "u" (unicode escape sequence)
local hex = str:sub(j + 1, j + 5)
if not hex:find("%x%x%x%x") then
decode_error(str, j, "invalid unicode escape in string")
end
if hex:find("^[dD][89aAbB]") then
has_surrogate_escape = true
else
has_unicode_escape = true
end
else
local c = string.char(x)
if not escape_chars[c] then
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
end
has_escape = true
end
last = nil
elseif x == 34 then -- '"' (end of string)
local s = str:sub(i + 1, j - 1)
if has_surrogate_escape then
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
end
if has_unicode_escape then
s = s:gsub("\\u....", parse_unicode_escape)
end
if has_escape then
s = s:gsub("\\.", escape_char_map_inv)
end
return s, j + 1
else
last = x
end
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
return ( parse(str, next_char(str, 1, space_chars, true)) )
end
return json

View File

@@ -1,226 +0,0 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local APIndex = 0x1A6E
local APItemAddress = 0x00FF
local EventFlagAddress = 0x1735
local MissableAddress = 0x161A
local HiddenItemsAddress = 0x16DE
local RodAddress = 0x1716
local InGame = 0x1A71
local ItemsReceived = nil
local playerName = nil
local seedName = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local gbSocket = nil
local frame = 0
local u8 = nil
local wU8 = nil
local u16
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
memDomain["rom"] = function() memory.usememorydomain("ROM") end
memDomain["wram"] = function() memory.usememorydomain("WRAM") end
return memDomain
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
u16 = memory.read_u16_le
function uRange(address, bytes)
data = memory.readbyterange(address - 1, bytes + 1)
data[0] = nil
return data
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
if block == nil then
return
end
local itemsBlock = block["items"]
memDomain.wram()
if itemsBlock ~= nil then-- and u8(0x116B) ~= 0x00 then
-- print(itemsBlock)
ItemsReceived = itemsBlock
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function generateLocationsChecked()
memDomain.wram()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
rod = u8(RodAddress)
data = {}
table.foreach(events, function(k, v) table.insert(data, v) end)
table.foreach(missables, function(k, v) table.insert(data, v) end)
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
table.insert(data, rod)
return data
end
function generateSerialData()
memDomain.wram()
status = u8(0x1A73)
if status == 0 then
return nil
end
return uRange(0x1A76, u8(0x1A74))
end
local function arrayEqual(a1, a2)
if #a1 ~= #a2 then
return false
end
for i, v in ipairs(a1) do
if v ~= a2[i] then
return false
end
end
return true
end
function receive()
l, e = gbSocket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
if l ~= nil then
processBlock(json.decode(l))
end
-- Determine Message to send back
memDomain.rom()
newPlayerName = uRange(0xFFF0, 0x10)
newSeedName = uRange(0xFFDB, 21)
if (playerName ~= nil and not arrayEqual(playerName, newPlayerName)) or (seedName ~= nil and not arrayEqual(seedName, newSeedName)) then
print("ROM changed, quitting")
curstate = STATE_UNINITIALIZED
return
end
playerName = newPlayerName
seedName = newSeedName
local retTable = {}
retTable["playerName"] = playerName
retTable["seedName"] = seedName
memDomain.wram()
if u8(InGame) == 0xAC then
retTable["locations"] = generateLocationsChecked()
serialData = generateSerialData()
if serialData ~= nil then
retTable["serial"] = serialData
end
end
msg = json.encode(retTable).."\n"
local ret, error = gbSocket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
curstate = STATE_OK
end
end
function main()
if (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
return
end
server, error = socket.bind('localhost', 17242)
while true do
frame = frame + 1
if not (curstate == prevstate) then
print("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
if u8(InGame) == 0xAC and u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if ItemsReceived[ItemIndex + 1] ~= nil then
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
end
end
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
print("Waiting for client.")
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
gbSocket = client
gbSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -1,132 +0,0 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

View File

@@ -221,7 +221,7 @@ Starting with version 4 of the APBP format, this is a ZIP file containing metada
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
bsdiff between the original and the randomized ROM. bsdiff between the original and the randomized ROM.
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`. To make using APBP easy, they can be generated by inheriting from `Patch.APDeltaPatch`.
### Mod files ### Mod files
Games which support modding will usually just let you drag and drop the mods files into a folder somewhere. Games which support modding will usually just let you drag and drop the mods files into a folder somewhere.
@@ -230,7 +230,7 @@ They can either be generic and modify the game using a seed or `slot_data` from
generated per seed. generated per seed.
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
integration into the Webhost by inheriting from `worlds.Files.APContainer`. integration into the Webhost by inheriting from `Patch.APContainer`.
## Archipelago Integration ## Archipelago Integration

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