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

6
.gitignore vendored
View File

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

View File

@@ -1,5 +1,4 @@
from __future__ import annotations
from argparse import Namespace
import copy
from enum import unique, IntEnum, IntFlag
@@ -41,23 +40,16 @@ class MultiWorld():
plando_connections: List
worlds: Dict[int, auto_world]
groups: Dict[int, Group]
regions: List[Region]
itempool: List[Item]
is_race: bool = False
precollected_items: Dict[int, List[Item]]
state: CollectionState
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]
non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing]
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():
def __init__(self, rule):
@@ -95,9 +87,6 @@ class MultiWorld():
self.customitemarray = []
self.shuffle_ganon = True
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(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
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
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
def set_options(self, args):
for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {}))
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:
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):
for region in regions if regions else self.regions:
region.multiworld = self
region.world = self
self._region_cache[region.player][region.name] = region
@functools.cached_property
@@ -422,11 +404,6 @@ class MultiWorld():
def clear_entrance_cache(self):
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]:
if self._cached_locations is None:
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."""
if not state:
state = CollectionState(self)
players: Dict[str, Set[int]] = {
"minimal": set(),
"items": set(),
"locations": set()
}
players = {"minimal": set(),
"items": set(),
"locations": set()}
for player, access in self.accessibility.items():
players[access.current_key].add(player)
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"""
if location.player in players["minimal"]:
return False
@@ -568,21 +543,20 @@ class MultiWorld():
return True
return False
def all_done() -> bool:
def all_done():
"""Check if all access rules are fulfilled"""
if not beatable_fulfilled:
return False
if any(location_condition(location) for location in locations):
return False # still locations required to be collected
return True
if beatable_fulfilled:
if any(location_conditition(location) for location in locations):
return False # still locations required to be collected
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:
sphere: List[Location] = []
for n in range(len(locations) - 1, -1, -1):
if locations[n].can_reach(state):
sphere.append(locations.pop(n))
sphere = set()
for location in locations:
if location.can_reach(state):
sphere.add(location)
if not sphere:
# ran out of places and did not finish yet, quit
@@ -591,8 +565,8 @@ class MultiWorld():
return False
for location in sphere:
if location.item:
state.collect(location.item, True, location)
locations.remove(location)
state.collect(location.item, True, location)
if self.has_beaten_game(state):
beatable_fulfilled = True
@@ -608,7 +582,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState():
prog_items: typing.Counter[Tuple[str, int]]
multiworld: MultiWorld
world: MultiWorld
reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]]
events: Set[Location]
@@ -620,7 +594,7 @@ class CollectionState():
def __init__(self, parent: MultiWorld):
self.prog_items = Counter()
self.multiworld = parent
self.world = parent
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.events = set()
@@ -634,14 +608,15 @@ class CollectionState():
self.collect(item, True)
def update_reachable_regions(self, player: int):
from worlds.alttp.EntranceShuffle import indirect_connections
self.stale[player] = False
rrp = self.reachable_regions[player]
bc = 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
if start not in rrp:
if not start in rrp:
rrp.add(start)
bc.update(start.exits)
queue.extend(start.exits)
@@ -653,7 +628,7 @@ class CollectionState():
if new_region in rrp:
bc.remove(connection)
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)
bc.remove(connection)
bc.update(new_region.exits)
@@ -661,12 +636,13 @@ class CollectionState():
self.path[new_region] = (new_region.name, self.path.get(connection, None))
# 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:
queue.append(new_entrance)
def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld)
ret = CollectionState(self.world)
ret.prog_items = self.prog_items.copy()
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions}
@@ -687,25 +663,25 @@ class CollectionState():
assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name
if resolution_hint == 'Location':
spot = self.multiworld.get_location(spot, player)
spot = self.world.get_location(spot, player)
elif resolution_hint == 'Entrance':
spot = self.multiworld.get_entrance(spot, player)
spot = self.world.get_entrance(spot, player)
else:
# default to Region
spot = self.multiworld.get_region(spot, player)
spot = self.world.get_region(spot, player)
return spot.can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.multiworld.get_filled_locations()
reachable_events = True
locations = self.world.get_filled_locations()
new_locations = True
# 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)}
while reachable_events:
while new_locations:
reachable_events = {location for location in locations if location.can_reach(self)}
locations -= reachable_events
for event in reachable_events:
new_locations = reachable_events - self.events
for event in new_locations:
self.events.add(event)
assert isinstance(event.item, Item), "tried to collect Event with no Item"
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:
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]
if found >= count:
return True
@@ -732,17 +708,17 @@ class CollectionState():
def count_group(self, item_name_group: str, player: int) -> int:
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]
return found
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
shop in self.multiworld.shops)
shop in self.world.shops)
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
shop in self.multiworld.shops)
shop in self.world.shops)
def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
@@ -762,7 +738,7 @@ class CollectionState():
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
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))
def has_hearts(self, player: int, count: int) -> int:
@@ -771,7 +747,7 @@ class CollectionState():
def heart_count(self, player: int) -> int:
# 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) \
+ self.item_count('Sanctuary Heart Container', player) \
+ 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):
basemagic = 16
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))
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))
else:
basemagic = basemagic + basemagic * self.bottle_count(player)
@@ -805,12 +781,12 @@ class CollectionState():
or (self.has('Bombs (10)', player) and enemies < 6))
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)
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 (
self.has_group("Bottles", player) and
self.has('Bug Catching Net', player) and
@@ -821,7 +797,7 @@ class CollectionState():
def can_retrieve_tablet(self, player: int) -> bool:
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)))
def has_sword(self, player: int) -> bool:
@@ -843,7 +819,7 @@ class CollectionState():
def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and
(self.multiworld.swordless[player] or
(self.world.swordless[player] or
self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool:
@@ -853,7 +829,7 @@ class CollectionState():
if self.has('Moon Pearl', player):
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:
if True in [i.is_light_world for i in self.reachable_regions[player]]:
@@ -866,24 +842,24 @@ class CollectionState():
return False
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:
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:
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)
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)
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)])]
if self.multiworld.mode[player] == 'inverted':
if self.world.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
@@ -892,7 +868,7 @@ class CollectionState():
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)])]
if self.multiworld.mode[player] != 'inverted':
if self.world.mode[player] != 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
@@ -903,7 +879,7 @@ class CollectionState():
if 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:
self.prog_items[item.name, item.player] += 1
@@ -917,7 +893,7 @@ class CollectionState():
return changed
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:
# invalidate caches, nothing can be trusted anymore now
self.reachable_regions[item.player] = set()
@@ -944,7 +920,7 @@ class Region:
type: RegionType
hint_text: str
player: int
multiworld: Optional[MultiWorld]
world: Optional[MultiWorld]
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
@@ -962,7 +938,7 @@ class Region:
self.entrances = []
self.exits = []
self.locations = []
self.multiworld = world
self.world = world
self.hint_text = hint
self.player = player
@@ -990,7 +966,7 @@ class Region:
return self.__str__()
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:
@@ -1017,7 +993,7 @@ class Entrance:
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.target = target
self.addresses = addresses
@@ -1027,7 +1003,7 @@ class Entrance:
return self.__str__()
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})'
@@ -1041,7 +1017,7 @@ class Dungeon(object):
self.dungeon_items = dungeon_items
self.bosses = dict()
self.player = player
self.multiworld = None
self.world = None
@property
def boss(self) -> Optional[Boss]:
@@ -1071,7 +1047,7 @@ class Dungeon(object):
return self.__str__()
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():
@@ -1105,7 +1081,7 @@ class Location:
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
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: Optional[Item] = None
@@ -1116,15 +1092,13 @@ class Location:
self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return (self.always_allow(state, item)
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))))
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
def can_reach(self, state: CollectionState) -> bool:
# self.access_rule computes faster on average, so placing it first for faster abort
assert self.parent_region, "Can't reach location without region"
return self.access_rule(state) and self.parent_region.can_reach(state)
if self.access_rule(state) and self.parent_region.can_reach(state):
return True
return False
def place_locked_item(self, item: Item):
if self.item:
@@ -1138,7 +1112,7 @@ class Location:
return self.__str__()
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})'
def __hash__(self):
@@ -1235,17 +1209,17 @@ class Item:
return self.__str__()
def __str__(self) -> str:
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
return self.location.parent_region.multiworld.get_name_string_for_object(self)
if self.location and self.location.parent_region and self.location.parent_region.world:
return self.location.parent_region.world.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})"
class Spoiler():
multiworld: MultiWorld
world: MultiWorld
unreachables: Set[Location]
def __init__(self, world):
self.multiworld = world
self.world = world
self.hashes = {}
self.entrances = OrderedDict()
self.medallions = {}
@@ -1257,7 +1231,7 @@ class Spoiler():
self.bosses = OrderedDict()
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(
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
else:
@@ -1266,45 +1240,45 @@ class Spoiler():
def parse_data(self):
self.medallions = OrderedDict()
for player in self.multiworld.get_game_players("A Link to the Past"):
self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \
self.multiworld.required_medallions[player][0]
self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \
self.multiworld.required_medallions[player][1]
for player in self.world.get_game_players("A Link to the Past"):
self.medallions[f'Misery Mire ({self.world.get_player_name(player)})'] = \
self.world.required_medallions[player][0]
self.medallions[f'Turtle Rock ({self.world.get_player_name(player)})'] = \
self.world.required_medallions[player][1]
self.locations = OrderedDict()
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]
self.locations['Light World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
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]
self.locations['Dark World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
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]
self.locations['Caves'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
cave_locations])
listed_locations.update(cave_locations)
for dungeon in self.multiworld.dungeons.values():
dungeon_locations = [loc for loc in self.multiworld.get_locations() if
for dungeon in self.world.dungeons.values():
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]
self.locations[str(dungeon)] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
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]
if other_locations:
self.locations['Other Locations'] = OrderedDict(
@@ -1314,7 +1288,7 @@ class Spoiler():
self.shops = []
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:
continue
shopdata = {
@@ -1343,34 +1317,34 @@ class Spoiler():
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
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)]["Eastern Palace"] = self.multiworld.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)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", 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.world.get_dungeon("Desert Palace", 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)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness",
player).boss.name
self.bosses[str(player)]["Swamp Palace"] = self.multiworld.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)]["Thieves Town"] = self.multiworld.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)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name
self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name
if self.multiworld.mode[player] != 'inverted':
self.bosses[str(player)]["Palace Of Darkness"] = self.world.get_dungeon("Palace of Darkness",
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.world.get_dungeon("Skull Woods", 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.world.get_dungeon("Ice Palace", 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.world.get_dungeon("Turtle Rock", player).boss.name
if self.world.mode[player] != 'inverted':
self.bosses[str(player)]["Ganons Tower Basement"] = \
self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
self.world.get_dungeon('Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = self.world.get_dungeon('Ganons Tower', player).bosses[
'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
else:
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.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.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)]["Ganon"] = "Ganon"
@@ -1400,7 +1374,7 @@ class Spoiler():
return 'Yes' if variable else 'No'
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)
try:
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:
outfile.write(
'Archipelago Version %s - Seed: %s\n\n' % (
Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players)
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
Utils.__version__, self.world.seed))
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
outfile.write('Players: %d\n' % self.world.players)
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
for player in range(1, self.multiworld.players + 1):
if self.multiworld.players > 1:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
for player in range(1, self.world.players + 1):
if self.world.players > 1:
outfile.write('\nPlayer %d: %s\n' % (player, self.world.get_player_name(player)))
outfile.write('Game: %s\n' % self.world.game[player])
for f_option, option in Options.per_game_common_options.items():
write_option(f_option, option)
options = self.multiworld.worlds[player].option_definitions
options = self.world.worlds[player].option_definitions
if options:
for f_option, option in options.items():
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('Logic: %s\n' % self.multiworld.logic[player])
outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player])
outfile.write('Mode: %s\n' % self.multiworld.mode[player])
outfile.write('Goal: %s\n' % self.multiworld.goal[player])
if "triforce" in self.multiworld.goal[player]: # triforce hunt
outfile.write('Logic: %s\n' % self.world.logic[player])
outfile.write('Dark Room Logic: %s\n' % self.world.dark_room_logic[player])
outfile.write('Mode: %s\n' % self.world.mode[player])
outfile.write('Goal: %s\n' % self.world.goal[player])
if "triforce" in self.world.goal[player]: # triforce hunt
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" %
self.multiworld.triforce_pieces_required[player])
outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player])
outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player])
outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player])
if self.multiworld.shuffle[player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed)
self.world.triforce_pieces_required[player])
outfile.write('Difficulty: %s\n' % self.world.difficulty[player])
outfile.write('Item Functionality: %s\n' % self.world.item_functionality[player])
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
if self.world.shuffle[player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
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' %
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' %
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' %
bool_to_text("g" in self.multiworld.shop_shuffle[player] or
"f" in self.multiworld.shop_shuffle[player]))
bool_to_text("g" in self.world.shop_shuffle[player] or
"f" in self.world.shop_shuffle[player]))
outfile.write('Custom Potion Shop: %s\n' %
bool_to_text("w" in self.multiworld.shop_shuffle[player]))
outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player])
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
bool_to_text("w" in self.world.shop_shuffle[player]))
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
outfile.write('Prize shuffle %s\n' %
self.multiworld.shuffle_prizes[player])
self.world.shuffle_prizes[player])
if self.entrances:
outfile.write('\n\nEntrances:\n\n')
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: '
if self.multiworld.players > 1 else '', entry['entrance'],
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.world.get_player_name(entry["player"])}: '
if self.world.players > 1 else '', entry['entrance'],
'<=>' if entry['direction'] == 'both' else
'<=' if entry['direction'] == 'exit' else '=>',
entry['exit']) for entry in self.entrances.values()]))
@@ -1472,7 +1446,7 @@ class Spoiler():
for dungeon, medallion in self.medallions.items():
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'.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 shop in self.shops))
for player in self.multiworld.get_game_players("A Link to the Past"):
if self.multiworld.boss_shuffle[player] != 'none':
bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses
for player in self.world.get_game_players("A Link to the Past"):
if self.world.boss_shuffle[player] != 'none':
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
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\nPlaythrough:\n\n')
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)))
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):

View File

@@ -20,13 +20,10 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
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
import os
if typing.TYPE_CHECKING:
import kvui
logger = logging.getLogger("Client")
# without terminal, we have to use gui mode
@@ -47,18 +44,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
if address:
self.ctx.server_address = None
self.ctx.username = None
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")
self.ctx.server_address = None
self.ctx.username = None
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
return True
def _cmd_disconnect(self) -> bool:
"""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
def _cmd_received(self) -> bool:
@@ -96,18 +91,12 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_items(self):
"""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}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_locations(self):
"""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}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
@@ -121,12 +110,12 @@ class ClientCommandProcessor(CommandProcessor):
else:
state = ClientStatus.CLIENT_CONNECTED
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):
raw = self.ctx.on_user_say(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:
@@ -143,36 +132,28 @@ class CommonContext:
# defaults
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
command_processor: type(CommandProcessor) = ClientCommandProcessor
ui = None
ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
server_task: typing.Optional["asyncio.Task[None]"] = None
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
disconnected_intentionally: bool = False
ui_task: typing.Optional[asyncio.Task] = None
input_task: typing.Optional[asyncio.Task] = None
keep_alive_task: typing.Optional[asyncio.Task] = None
server_task: typing.Optional[asyncio.Task] = None
server: typing.Optional[Endpoint] = None
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
# remaining type info
slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str]
server_address: str
password: typing.Optional[str]
hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str]
finished_game: bool
ready: bool
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem]
missing_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
@@ -180,11 +161,9 @@ class CommonContext:
# internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
_messagebox = None
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
def __init__(self, server_address, password):
# server state
self.server_address = server_address
self.username = None
@@ -227,12 +206,6 @@ class CommonContext:
# execution
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
def raw_text_parser(self) -> RawJSONtoTextParser:
return RawJSONtoTextParser(self)
@@ -244,9 +217,9 @@ class CommonContext:
return len(self.checked_locations | self.missing_locations)
async def connection_closed(self):
self.reset_server_state()
if self.server and self.server.socket is not None:
await self.server.socket.close()
self.reset_server_state()
def reset_server_state(self):
self.auth = None
@@ -264,18 +237,13 @@ class CommonContext:
"remaining": "disabled",
}
async def disconnect(self, allow_autoreconnect: bool = False):
if not allow_autoreconnect:
self.disconnected_intentionally = True
if self.cancel_autoreconnect():
logger.info("Cancelled auto-reconnect.")
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """
async def send_msgs(self, msgs):
if not self.server or not self.server.socket.open or self.server.socket.closed:
return
await self.server.socket.send(encode(msgs))
@@ -303,8 +271,7 @@ class CommonContext:
logger.info('Enter slot name:')
self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None:
""" send `Connect` packet to log in to server """
async def send_connect(self, **kwargs):
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -315,24 +282,14 @@ class CommonContext:
payload.update(kwargs)
await self.send_msgs([payload])
async def console_input(self) -> str:
if self.ui:
self.ui.focus_textinput()
async def console_input(self):
self.input_requests += 1
return await self.input_queue.get()
async def connect(self, address: typing.Optional[str] = None) -> None:
""" disconnect any previous connection, and open new connection to the server """
async def connect(self, address=None):
await self.disconnect()
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:
if slot == self.slot:
return True
@@ -340,12 +297,6 @@ class CommonContext:
return self.slot in self.slot_info[slot].group_members
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):
logger.info(args["text"])
@@ -377,7 +328,6 @@ class CommonContext:
async def shutdown(self):
self.server_address = ""
self.username = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
@@ -440,7 +390,7 @@ class CommonContext:
# 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."""
self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "")
@@ -471,10 +421,10 @@ class CommonContext:
if old_tags != self.tags and self.server and not self.server.socket.closed:
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"""
if not self.ui:
return None
return
title = title or "Error"
from kvui import MessageBox
if self._messagebox:
@@ -491,13 +441,6 @@ class CommonContext:
# display error
self._messagebox = MessageBox(title, text, error=True)
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):
"""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
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:
logger.error('Already connected')
return
@@ -547,11 +490,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
logger.info('Please connect to an Archipelago server.')
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 \
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
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}')
try:
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')
ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
ctx.disconnected_intentionally = False
async for data in ctx.server.socket:
for msg in decode(data):
await process_server_cmd(ctx, msg)
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
except ConnectionRefusedError:
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.")
except websockets.InvalidURI:
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
except OSError:
ctx._handle_connection_loss("Failed to connect to the multiworld server")
except Exception:
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError as e:
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except websockets.InvalidURI as e:
msg = 'Failed to connect to the multiworld server (invalid URI)'
logger.exception(msg, extra={'compact_gui': True})
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:
await ctx.connection_closed()
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
assert ctx.autoreconnect_task is None
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
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.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':
start_index = args["index"]
@@ -784,7 +722,7 @@ async def console_loop(ctx: CommonContext):
logger.exception(e)
def get_base_parser(description: typing.Optional[str] = None):
def get_base_parser(description=None):
import argparse
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -816,6 +754,7 @@ if __name__ == '__main__':
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.auth = args.name
ctx.server_address = args.connect
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:

View File

@@ -7,7 +7,6 @@ from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
@@ -70,7 +69,7 @@ class FF1Context(CommonContext):
def on_package(self, cmd: str, args: dict):
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':
msg = args['text']
if ': !' not in msg:
@@ -181,7 +180,7 @@ async def nes_sync_task(ctx: FF1Context):
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# 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:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':

View File

@@ -4,12 +4,9 @@ import logging
import json
import string
import copy
import re
import subprocess
import sys
import time
import random
import typing
import ModuleUpdate
ModuleUpdate.update()
@@ -20,18 +17,12 @@ import asyncio
from queue import Queue
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__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from Utils import async_start
from worlds.factorio import Factorio
@@ -39,10 +30,6 @@ from worlds.factorio import Factorio
class FactorioCommandProcessor(ClientCommandProcessor):
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
def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server."""
@@ -59,13 +46,6 @@ class FactorioCommandProcessor(ClientCommandProcessor):
"""Manually trigger a resync."""
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):
command_processor = FactorioCommandProcessor
@@ -85,9 +65,6 @@ class FactorioContext(CommonContext):
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 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):
if password_requested and not self.password:
@@ -104,15 +81,12 @@ class FactorioContext(CommonContext):
def on_print(self, args: dict):
super(FactorioContext, self).on_print(args)
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):
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"]))
if not text.startswith(self.player_names[self.slot] + ":"):
self.print_to_game(text)
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args)
@property
@@ -123,15 +97,6 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
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):
if self.rcon_client:
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
item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment:
async_start(self.send_msgs([{
asyncio.create_task(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"]
}]))
elif cmd == "SetReply":
@@ -158,45 +123,6 @@ class FactorioContext(CommonContext):
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
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):
from kvui import GameManager
@@ -214,6 +140,7 @@ class FactorioContext(CommonContext):
async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
next_bridge = time.perf_counter() + 1
try:
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}
victory = data["victory"]
await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
if not ctx.finished_game and victory:
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:
bridge_logger.debug(
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
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags:
async_start(ctx.send_death())
asyncio.create_task(ctx.send_death())
if ctx.energy_link_increment:
in_world_bridges = data["energy_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):
# attempt to refill
ctx.last_deplete = time.time()
async_start(ctx.send_msgs([{
asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"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) - \
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":
[{"operation": "add", "value": value}]
}]))
@@ -285,8 +211,6 @@ async def game_watcher(ctx: FactorioContext):
def stream_factorio_output(pipe, queue, process):
pipe.reconfigure(errors="replace")
def queuer():
while process.poll() is None:
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)
try:
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.")
ctx.exit_event.set()
@@ -332,25 +256,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
if not ctx.server:
logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect")
check_stdin()
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True
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:
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:
commands = {}
while ctx.send_index < len(ctx.items_received):
@@ -371,34 +282,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set()
finally:
if factorio_process.poll() is not None:
if ctx.rcon_client:
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()
factorio_process.terminate()
factorio_process.wait(5)
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):
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")
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)
if 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)):
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
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.generic.Rules import add_item_rule
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],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
@@ -71,66 +69,60 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else:
# we filled all reachable spots.
if swap:
# try swapping this item with previously placed items
for (i, location) in enumerate(placements):
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player,
placed_item.name]
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)
# try swapping this item with previously placed items
for (i, location) in enumerate(placements):
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player,
placed_item.name]
if swap_count > 1:
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)
continue
world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
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:
# 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:]
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:
fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations)
# get items to distribute
itempool = sorted(world.itempool)
world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
filleritempool: typing.List[Item] = []
@@ -356,33 +239,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED]
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
lock_later = []
def mark_for_locking(location: Location):
nonlocal lock_later
lock_later.append(location)
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
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
if progitempool:
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool)
if progitempool:
raise FillError(
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)
if excludedlocations:
@@ -683,17 +548,6 @@ def distribute_planned(world: MultiWorld) -> None:
else:
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
from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup
@@ -709,39 +563,7 @@ def distribute_planned(world: MultiWorld) -> None:
if 'from_pool' not in block:
block['from_pool'] = True
if 'world' not in block:
target_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
block['world'] = False
items: block_value = []
if "items" in block:
items = block["items"]
@@ -778,17 +600,6 @@ def distribute_planned(world: MultiWorld) -> None:
for key, value in locations.items():
location_list += [key] * value
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
if not block['count']:
@@ -824,11 +635,38 @@ def distribute_planned(world: MultiWorld) -> None:
for placement in plando_blocks:
player = placement['player']
try:
worlds = placement['world']
target_world = placement['world']
locations = placement['locations']
items = placement['items']
maxcount = placement['count']['target']
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,
worlds))

View File

@@ -154,12 +154,11 @@ def main(args=None, callback=ERmain):
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(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:
print(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = filename
player_id += 1
for yaml in yaml_data:
print(f"P{player_id} Weights: {filename} >> "
f"{get_choice('description', yaml, 'No description specified')}")
player_files[player_id] = filename
player_id += 1
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: "
@@ -233,8 +232,8 @@ def main(args=None, callback=ERmain):
else:
raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
if args.yaml_output:
import yaml
@@ -317,11 +316,11 @@ class SafeDict(dict):
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
name_counter[name] += 1
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,
NUMBER=(number if number > 1 else ''),
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
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 options[option_key].supports_weighting:
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 option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",

View File

@@ -132,7 +132,7 @@ components: Iterable[Component] = (
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
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'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
@@ -145,15 +145,10 @@ components: Iterable[Component] = (
Component('OoT Adjuster', 'OoTAdjuster'),
# FF1
Component('FF1 Client', 'FF1Client'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# Functions
Component('Open host.yaml', func=open_host_yaml),
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 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
GAME_ALTTP = "A Link to the Past"
from Patch import GAME_ALTTP
class AdjusterWorld(object):
@@ -141,7 +139,7 @@ def adjust(args):
vanillaRom = args.baserom
if not os.path.exists(vanillaRom) and not os.path.isabs(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
meta, args.rom = Patch.create_rom_file(args.rom)
@@ -197,7 +195,7 @@ def adjustGUI():
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
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)
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)
autoApplyFrame = Frame(romOptionsFrame)
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)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
askRadio.pack(side=LEFT, padx=5, pady=5)

55
Main.py
View File

@@ -8,15 +8,15 @@ import concurrent.futures
import pickle
import tempfile
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.Regions import is_main_entrance
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 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
ordered_areas = (
@@ -80,30 +80,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Found World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
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
numlength = 8
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
f"{max(cls.item_id_to_name):{item_digits}}) | "
f"{len(cls.location_names):{location_count}} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
f"{max(cls.location_id_to_name):{location_digits}})")
del item_digits, location_digits, item_count, location_count
if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{numlength}}) | "
f"{len(cls.location_names):3} "
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{numlength}})")
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"]:
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['Crystals']
@@ -137,7 +122,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Calculating Access Rules.')
if world.players > 1:
locality_rules(world)
for player in world.player_ids:
locality_rules(world, player)
group_locality_rules(world)
else:
world.non_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
for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
def find_common_pool(players: Set[int], shared_pool: Set[str]):
classifications = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool:
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():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del (counters[player])
del(counters[player])
if not players:
return None, None
@@ -179,14 +164,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
counters[player][item] = count
else:
for player in players:
del (counters[player][item])
del(counters[player][item])
return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool: List[Item] = []
new_itempool = []
for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count):
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:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
# skip .* (hidden / disabled) folders
if not entry.name.startswith("."):
if entry.is_dir():
req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):
requirements_files.add(req_file)
if entry.is_dir():
req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):
requirements_files.add(req_file)
def update_command():
@@ -39,25 +37,11 @@ def update(yes=False, force=False):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
for line in requirementsfile:
if line.startswith(("https://", "git+https://")):
# extract name and version for url
rest = line.split('/')[-1]
line = ""
if "#egg=" in rest:
# 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}'
if line.startswith('https://'):
# extract name and version from url
wheel = line.split('/')[-1]
name, version, _ = wheel.split('-', 2)
line = f'{name}=={version}'
requirements = pkg_resources.parse_requirements(line)
for requirement in requirements:
requirement = str(requirement)

View File

@@ -31,7 +31,7 @@ except ImportError:
import NetUtils
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, \
SlotType
@@ -273,16 +273,16 @@ class Context:
def broadcast_all(self, msgs: typing.List[dict]):
msgs = self.dumper(msgs)
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]):
msgs = self.dumper(msgs)
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]):
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):
if endpoint in self.endpoints:
@@ -302,18 +302,18 @@ class Context:
return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
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:
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]):
if not client.auth:
return
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]))
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
@@ -627,7 +627,7 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
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):
@@ -636,7 +636,7 @@ def update_aliases(ctx: Context, team: int):
for clients in ctx.clients[team].values():
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):
@@ -814,7 +814,7 @@ def send_new_items(ctx: Context):
items = get_received_items(ctx, team, slot, client.remote_items)
if len(start_inventory) + len(items) > client.send_index:
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",
"index": client.send_index,
"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 = typing.TypeVar("_Return")
# TODO: when python 3.10 is lowest supported, typing.ParamSpec
def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]:
def mark_raw(function):
function.raw_text = True
return function
@@ -1090,7 +1086,7 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10)
except ValueError:
timer = 10
async_start(countdown(self.ctx, timer))
asyncio.create_task(countdown(self.ctx, timer))
return True
def _cmd_options(self):
@@ -1332,8 +1328,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -1388,6 +1382,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False
if hints:
cost = self.ctx.get_hint_cost(self.client.slot)
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
if old_hints:
@@ -1437,12 +1432,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
return True
else:
if points_available >= cost:
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)}.")
self.output("Nothing found. Item/Location may not exist.")
return False
@mark_raw
@@ -1771,7 +1761,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool:
"""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:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set()
@@ -1802,33 +1792,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response)
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
def _cmd_collect(self, player_name: str) -> bool:
"""Send out the remaining items to player."""
player = self.resolve_player(player_name)
if player:
team, slot, _ = player
collect_player(self.ctx, team, slot)
return True
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
collect_player(self.ctx, team, slot)
return True
self.output(f"Could not find player {player_name} to collect")
return False
@@ -1841,11 +1812,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
player = self.resolve_player(player_name)
if player:
team, slot, _ = player
forfeit_player(self.ctx, team, slot)
return True
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
forfeit_player(self.ctx, team, slot)
return True
self.output(f"Could not find player {player_name} to release")
return False
@@ -1853,12 +1824,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_allow_forfeit(self, player_name: str) -> bool:
"""Allow the specified player to use the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player
self.ctx.allow_forfeits[(team, slot)] = True
self.output(f"Player {name} is now allowed to use the !release command at any time.")
return True
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = True
self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
return True
self.output(f"Could not find player {player_name} to allow the !release command for.")
return False
@@ -1866,12 +1837,13 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
""""Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player
self.ctx.allow_forfeits[(team, slot)] = False
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.")
return True
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = False
self.output(
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.")
return False
@@ -2084,7 +2056,7 @@ async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown)
while not ctx.exit_event.is_set():
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()
if 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
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
async_start(ctx.server.ws_server._close())
asyncio.create_task(ctx.server.ws_server._close())
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:

View File

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

View File

@@ -5,11 +5,9 @@ import multiprocessing
import subprocess
from asyncio import StreamReader, StreamWriter
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
ClientCommandProcessor, logger, get_base_parser
import Utils
from Utils import async_start
from worlds import network_data_package
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
@@ -70,7 +68,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, OoTContext):
self.ctx.deathlink_client_override = True
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):
@@ -134,19 +132,6 @@ def get_payload(ctx: OoTContext):
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
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
await ctx.update_death_link(True)
@@ -204,7 +189,7 @@ async def n64_sync_task(ctx: OoTContext):
if reported_version >= script_version:
if ctx.game is not None and 'locations' in data_decoded:
# 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:
ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom:
@@ -280,7 +265,7 @@ async def patch_and_run_game(apz5_file):
os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path)
async_start(run_game(comp_path))
asyncio.create_task(run_game(comp_path))
if __name__ == '__main__':
@@ -296,7 +281,7 @@ if __name__ == '__main__':
if args.apz5_file:
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.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import abc
from copy import deepcopy
import math
import numbers
import typing
@@ -79,9 +78,6 @@ class AssembleOptions(abc.ABCMeta):
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')
@@ -169,7 +165,6 @@ class FreeText(Option):
class NumericOption(Option[int], numbers.Integral):
default = 0
# 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
# (even though isinstance(5, numbers.Integral) == True)
@@ -431,6 +426,7 @@ class TextChoice(Choice):
assert isinstance(value, str) or isinstance(value, int), \
f"{value} is not a valid option for {self.__class__.__name__}"
self.value = value
super(TextChoice, self).__init__()
@property
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__}")
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):
range_start = 0
range_end = 1
@@ -750,11 +628,11 @@ class VerifyKeys:
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default: typing.Dict[str, typing.Any] = {}
default = {}
supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]):
self.value = deepcopy(value)
self.value = value
@classmethod
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):
default: typing.List[typing.Any] = []
default = []
supports_weighting = False
def __init__(self, value: typing.List[typing.Any]):
self.value = deepcopy(value)
self.value = value or []
super(OptionList, self).__init__()
@classmethod
@@ -807,11 +685,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
default = frozenset()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
self.value = set(deepcopy(value))
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
self.value = set(value)
super(OptionSet, self).__init__()
@classmethod
@@ -850,7 +728,7 @@ class Accessibility(Choice):
class ProgressionBalancing(SpecialRange):
"""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
range_start = 0
range_end = 99

428
Patch.py
View File

@@ -1,23 +1,266 @@
from __future__ import annotations
import shutil
import json
import bsdiff4
import yaml
import os
import lzma
import threading
import concurrent.futures
import zipfile
import sys
from typing import Tuple, Optional, TypedDict
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
import ModuleUpdate
ModuleUpdate.update()
from worlds.Files import AutoPatchRegister, APDeltaPatch
import Utils
current_patch_version = 5
class RomMeta(TypedDict):
server: str
class AutoPatchRegister(type):
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_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)
if auto_handler:
handler: APDeltaPatch = auto_handler(patch_file)
@@ -26,10 +269,171 @@ def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
return {"server": handler.server,
"player": handler.player,
"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__":
for file in sys.argv[1:]:
meta_data, result_file = create_rom_file(file)
print(f"Patch with meta-data {meta_data} was written to {result_file}")
host = Utils.get_public_ipv4()
options = Utils.get_options()['server_options']
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
* Donkey Kong Country 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/).
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
from pathlib import Path
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
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
import NetUtils
from MultiServer import mark_raw
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__":
init_logging("SC2Client", exception_logger="Client")
@@ -22,21 +34,10 @@ if __name__ == "__main__":
logger = logging.getLogger("Client")
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
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()
max_bonus: int = 8
@@ -114,40 +115,12 @@ class StarcraftClientProcessor(ClientCommandProcessor):
"""Manually set the SC2 install directory (if the automatic detection fails)."""
if path:
os.environ["SC2PATH"] = path
is_mod_installed_correctly()
check_mod_install()
return True
else:
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
return False
def _cmd_download_data(self, 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):
command_processor = StarcraftClientProcessor
@@ -155,9 +128,7 @@ class SC2Context(CommonContext):
items_handling = 0b111
difficulty = -1
all_in_choice = 0
mission_order = 0
mission_req_table: typing.Dict[str, MissionInfo] = {}
final_mission: int = 29
announcements = queue.Queue()
sc2_run_task: typing.Optional[asyncio.Task] = None
missions_unlocked: bool = False # allow launching missions ignoring requirements
@@ -182,25 +153,16 @@ class SC2Context(CommonContext):
self.difficulty = args["slot_data"]["game_difficulty"]
self.all_in_choice = args["slot_data"]["all_in_map"]
slot_req_table = args["slot_data"]["mission_req"]
# Maintaining backwards compatibility with older slot data
self.mission_req_table = {
mission: MissionInfo(
**{field: value for field, value in mission_info.items() if field in MissionInfo._fields}
)
for mission, mission_info in slot_req_table.items()
mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
}
self.mission_order = args["slot_data"].get("mission_order", 0)
self.final_mission = args["slot_data"].get("final_mission", 29)
self.build_location_to_mission_mapping()
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
is_mod_installed_correctly()
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
current_ver = f.read()
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
# Look for and set SC2PATH.
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
if "SC2PATH" not in os.environ and check_game_install_path():
check_mod_install()
def on_print_json(self, args: dict):
# goes to this world
@@ -312,6 +274,7 @@ class SC2Context(CommonContext):
self.refresh_from_launching = True
self.mission_panel.clear_widgets()
if self.ctx.mission_req_table:
self.last_checked_locations = self.ctx.checked_locations.copy()
self.first_check = False
@@ -329,20 +292,17 @@ class SC2Context(CommonContext):
for category in categories:
category_panel = MissionCategory()
if category.startswith('_'):
category_display_name = ''
else:
category_display_name = category
category_panel.add_widget(
Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1))
Label(text=category, size_hint_y=None, height=50, outline_width=1))
for mission in categories[category]:
text: str = mission
tooltip: str = ""
mission_id: int = self.ctx.mission_req_table[mission].id
# Map has uncollected locations
if mission in unfinished_missions:
text = f"[color=6495ED]{text}[/color]"
elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met
@@ -361,16 +321,6 @@ class SC2Context(CommonContext):
remaining_location_names: typing.List[str] = [
self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
if loc in self.ctx.missing_locations]
if mission_id == self.ctx.final_mission:
if mission in available_missions:
text = f"[color=FFBC95]{mission}[/color]"
else:
text = f"[color=D0C0BE]{mission}[/color]"
if tooltip:
tooltip += "\n"
tooltip += "Final Mission"
if remaining_location_names:
if tooltip:
tooltip += "\n"
@@ -380,7 +330,7 @@ class SC2Context(CommonContext):
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
mission_button.tooltip_text = tooltip
mission_button.bind(on_press=self.mission_callback)
self.mission_id_to_button[mission_id] = mission_button
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
category_panel.add_widget(mission_button)
category_panel.add_widget(Label(text=""))
@@ -407,9 +357,8 @@ class SC2Context(CommonContext):
self.ui = SC2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
import pkgutil
data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode()
Builder.load_string(data)
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
async def shutdown(self):
await super(SC2Context, self).shutdown()
@@ -489,13 +438,10 @@ wol_default_categories = [
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
"Char", "Char", "Char", "Char"
]
wol_default_category_names = [
"Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char"
]
def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]:
network_item: NetworkItem
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
network_item: NetUtils.NetworkItem
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
for network_item in items:
@@ -609,7 +555,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
if self.can_read_game:
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")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
@@ -765,14 +711,13 @@ def calc_available_missions(ctx: SC2Context, unlocks=None):
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
Arguments:
ctx -- instance of SC2Context
locations_to_check -- the mission string name to check
missions_complete -- an int of how many missions have been completed
mission_path -- a list of missions that have already been checked
"""
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
# A check for when the requirements are being or'd
@@ -790,18 +735,7 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete
else:
req_success = False
# Grid-specific logic (to avoid long path checks and infinite recursion)
if ctx.mission_order in (3, 4):
if req_success:
return True
else:
if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
return False
else:
continue
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
# Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
if not ctx.mission_req_table[mission_name].or_requirements:
return False
@@ -886,53 +820,18 @@ def check_game_install_path() -> bool:
return False
def is_mod_installed_correctly() -> bool:
"""Searches for all required files."""
if "SC2PATH" not in os.environ:
check_game_install_path()
mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
wol_required_maps = [
"ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
"ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
"ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
"ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map",
"ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
"ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
"ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
]
needs_files = False
# Check for maps.
missing_maps = []
for mapfile in wol_required_maps:
if not os.path.isfile(mapdir / mapfile):
missing_maps.append(mapfile)
if len(missing_maps) >= 19:
sc2_logger.warning(f"All map files missing from {mapdir}.")
needs_files = True
elif len(missing_maps) > 0:
for map in missing_maps:
sc2_logger.debug(f"Missing {map} from {mapdir}.")
sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
needs_files = True
else: # Must be no maps missing
sc2_logger.info(f"All maps found in {mapdir}.")
# Check for mods.
if os.path.isfile(modfile):
sc2_logger.info(f"Archipelago mod found at {modfile}.")
else:
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
needs_files = True
# Final verdict.
if needs_files:
sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
return False
else:
return True
def check_mod_install() -> bool:
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
try:
# 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"))):
sc2_logger.info(f"Archipelago mod found at {modfile}.")
return True
else:
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
except KeyError:
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
return False
class DllDirectory:
@@ -971,64 +870,6 @@ class DllDirectory:
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__':
colorama.init()
asyncio.run(main())

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import typing
import builtins
import os
@@ -12,8 +11,6 @@ import io
import collections
import importlib
import logging
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
from yaml import load, load_all, dump, SafeLoader
try:
@@ -38,7 +35,7 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.6"
__version__ = "0.3.5"
version_tuple = tuplize_version(__version__)
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)
def output_path(*path: str) -> str:
def output_path(*path: str):
if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
@@ -220,11 +217,8 @@ def get_public_ipv6() -> str:
return ip
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
@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.
options = {
"general_options": {
@@ -232,21 +226,20 @@ def get_default_options() -> OptionsType:
},
"factorio_options": {
"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": {
"rom_file": "Super Metroid (JU).sfc",
"sni": "SNI",
"rom_start": True,
},
"soe_options": {
"rom_file": "Secret of Evermore (USA).sfc",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
"rom_start": True,
},
"server_options": {
"host": None,
@@ -289,27 +282,15 @@ def get_default_options() -> OptionsType:
},
"dkc3_options": {
"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
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():
new_keys = keys.copy()
new_keys.append(key)
@@ -329,9 +310,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsT
@cache_argsless
def get_options() -> OptionsType:
def get_options() -> dict:
filenames = ("options.yaml", "host.yaml")
locations: typing.List[str] = []
locations = []
if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames]
@@ -372,7 +353,7 @@ def persistent_load() -> typing.Dict[str, dict]:
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, {})
return adjuster_settings
@@ -411,8 +392,7 @@ class RestrictedUnpickler(pickle.Unpickler):
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
if module.endswith("Options"):
if module == "Options":
mod = self.options_module
else:
@@ -643,32 +623,3 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
else:
return element.lower()
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 sys
import multiprocessing
import logging
import typing

View File

@@ -1,15 +1,16 @@
import base64
import os
import socket
import uuid
import base64
import socket
from pony.flask import Pony
from flask import Flask
from flask_caching import Cache
from flask_compress import Compress
from pony.flask import Pony
from werkzeug.routing import BaseConverter
from Utils import title_sorted
from .models import *
UPLOAD_FOLDER = os.path.relpath('uploads')
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
# if you want to deploy, make sure you have a non-guessable secret key
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
# 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
# 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.
Note: initializes worlds subsystem."""
# has automatic patch integration
import worlds.AutoWorld
import worlds.Files
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
import Patch
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it

View File

@@ -1,11 +1,11 @@
"""API endpoints package."""
from typing import List, Tuple
from uuid import UUID
from typing import List, Tuple
from flask import Blueprint, abort
from .. import cache
from ..models import Room, Seed
from .. import cache
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
@@ -46,4 +46,4 @@ def get_datapackage_versions():
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 pickle
from uuid import UUID
from . import api_endpoints
from flask import request, session, url_for
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.generate import get_meta
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
from . import api_endpoints
@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 pony.orm import select
from WebHostLib.models import Room, Seed
from WebHostLib.models import *
from . import api_endpoints, get_players

View File

@@ -1,15 +1,15 @@
from __future__ import annotations
import json
import logging
import json
import multiprocessing
import os
import sys
import threading
import time
import typing
from datetime import timedelta, datetime
import sys
import typing
import time
import os
from pony.orm import db_session, select, commit
from Utils import restricted_loads

View File

@@ -1,7 +1,7 @@
import zipfile
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

View File

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

View File

@@ -1,13 +1,12 @@
import json
import zipfile
import json
from io import BytesIO
from flask import send_file, Response, render_template
from pony.orm import select
from worlds.Files import AutoPatchRegister
from . import app, cache
from .models import Slot, Room, Seed
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
from WebHostLib import app, Slot, Room, Seed, cache
@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)
return send_file(new_file, as_attachment=True, download_name=fname)
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>")
@@ -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"
elif slot_data.game == "VVVVVV":
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":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III":

View File

@@ -1,24 +1,23 @@
import json
import os
import pickle
import random
import tempfile
import random
import json
import zipfile
import concurrent.futures
from collections import Counter
from typing import Dict, Optional, Any
from Utils import __version__
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 Generate import handle_name, PlandoSettings
from Main import main as ERmain
from Utils import __version__
import pickle
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
from WebHostLib import app
from worlds.alttp.EntranceRandomizer import parse_arguments
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
@@ -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)
race = meta.setdefault("race", False)
def task():
try:
target = tempfile.TemporaryDirectory()
playercount = len(gen_options)
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"])
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:
if sid:
with db_session:

View File

@@ -1,11 +1,7 @@
from datetime import timedelta, datetime
from flask import render_template
from pony.orm import count
from WebHostLib import app, cache
from .models import Room, Seed
from .models import *
from datetime import timedelta
@app.route('/', methods=['GET', 'POST'])
@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 = []
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))
if not sprite.name:

View File

@@ -3,11 +3,10 @@ import os
import jinja2.exceptions
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 . import app, cache
from .models import Seed, Room, Command, UUID, uuid4
def get_world_theme(game_name: str):
@@ -152,7 +151,7 @@ def favicon():
@app.route('/discord')
def discord():
return redirect("https://discord.gg/8Z65BR2")
return redirect("https://discord.gg/archipelago")
@app.route('/datapackage')

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from uuid import UUID, uuid4
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
from pony.orm import *
db = Database()
@@ -29,7 +29,6 @@ class Room(db.Entity):
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
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)

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ the website is not required to generate them.
## How do I get started?
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?

View File

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

View File

@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
for (let i=0; i < headers.length; i++){
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
headers[i].setAttribute('id', headerId);
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
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
if (scrollTargetIndex > -1) {
try{
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
}
});
}
}).catch((error) => {
console.error(error);
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');
let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].type){
case 'select':
element = document.createElement('div');
@@ -140,21 +138,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateGameSetting(event.target));
select.addEventListener('change', (event) => updateGameSetting(event));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
@@ -169,29 +154,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
updateGameSetting(event);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'special_range':
@@ -230,8 +201,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
@@ -240,7 +210,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
updateGameSetting(event);
});
// Configure range event handler
@@ -250,29 +220,13 @@ const buildOptionsTable = (settings, romOpts = false) => {
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
updateGameSetting(event);
});
element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, [specialRange, specialRangeSelect])
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
specialRange.disabled = true;
specialRangeSelect.disabled = true;
}
specialRangeWrapper.appendChild(randomButton);
break;
default:
@@ -289,25 +243,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
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 options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
@@ -315,17 +250,10 @@ const updateBaseSetting = (event) => {
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameSetting = (settingElement) => {
const updateGameSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (settingElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][settingElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
settingElement.value : parseInt(settingElement.value, 10);
}
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
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);
adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
for (let i=0; i < headers.length; i++){
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
headers[i].setAttribute('id', headerId);
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
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
if (scrollTargetIndex > -1) {
try{
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
}
});
}
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =

View File

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

View File

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

View File

@@ -116,10 +116,6 @@ html{
flex-grow: 1;
}
#player-settings table select:disabled{
background-color: lightgray;
}
#player-settings table .range-container{
display: flex;
flex-direction: row;
@@ -142,27 +138,12 @@ html{
#player-settings table .special-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#player-settings table .special-range-wrapper input[type=range]{
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{
display: block;
min-width: 200px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,6 @@ pre{
pre code{
border: none;
display: block;
}
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;
}
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{
font-family: LexendDeca-Regular, sans-serif;
}

View File

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

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{{ super() }}
<title>Mystery Check Result</title>
<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>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{{ super() }}
<title>Generate Game</title>
<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>

View File

@@ -1,6 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{{ super() }}
<title>Upload Multidata</title>
<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>

View File

@@ -20,16 +20,12 @@
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
Should you wish to continue later,
anyone can simply refresh this page and the server will resume.<br>
{% if room.last_port == -1 %}
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 %}
{% if room.last_port %}
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 }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
</span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
{% endif %}
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}
<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 %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
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>
Download JSON File...</a>
{% else %}
No file to download for this game.
{% endif %}
</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>
{% endfor %}
</tbody>

View File

@@ -1,85 +1,56 @@
# Q. What is this file?
# A. This file contains options which allow you to configure your multiworld experience while allowing
# others 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
# What is this file?
# This file contains options which allow you to configure your multiworld experience while allowing others
# to play how they want as well.
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit.
# {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.
name: Player{number}
# How do I use it?
# 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
# Used to describe your yaml. Useful if you have multiple files.
description: Default {{ game }} Template
# I've never seen a file like this before. What characters am I allowed to use?
# 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:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
{%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values.
# Minimum value is {{ option.range_start }}
# Maximum value is {{ option.range_end }}
# you can add additional values between minimum and maximum
{%- set data, notes = dictify_range(option) %}
{%- for entry, default in data.items() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%}
{% endmacro %}
{{ game }}:
{%- for option_key, option in options.items() %}
{{ option_key }}:
{%- 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 -%}
{{ option_key }}:{% if option.__doc__ %} # {{ option.__doc__ | replace('\n', '\n#') | indent(4, first=False) }}{% endif %}
{%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option) -}}
{%- elif option.options -%}
{%- 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 %}
{%- endfor -%}
{%- if option.name_lookup[option.default] not in option.options %}
{{ option.default }}: 50
{% if option.default == "random" %}
random: 50
{%- endif -%}
{%- elif option.default is string %}
{{ option.default }}: 50
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- else %}
{{ yaml_dump(option.default) | trim | indent(4, first=false) }}
{%- endif -%}
{{ "\n" }}
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
{%- endif -%}
{%- endfor %}
{% if not options %}{}{% endif %}

View File

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

View File

@@ -2,9 +2,9 @@
{% block head %}
{{ super() }}
<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/tracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackers/tracker.js") }}"></script>
{% endblock %}
{% block body %}
@@ -13,6 +13,9 @@
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<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 class="table-wrapper">
<table class="table non-unique-item-table">

View File

@@ -2,8 +2,8 @@
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
<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/trackers/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</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['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>
<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>
</table>
<table id="location-table">

View File

@@ -2,9 +2,9 @@
{% block head %}
{{ super() }}
<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/tracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackers/tracker.js") }}"></script>
{% endblock %}
{% block body %}
@@ -44,7 +44,7 @@
<tbody>
{%- for player, items in players.items() -%}
<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>
{%- if (team, loop.index) in video -%}
{%- if video[(team, loop.index)][0] == "Twitch" -%}
@@ -121,7 +121,7 @@
<tbody>
{%- for player, checks in players.items() -%}
<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>
<td>{{ player_names[(team, loop.index)]|e }}</td>
{%- 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">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/supermetroidTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/supermetroidTracker.js') }}"></script>
<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/trackers/supermetroidTracker.js') }}"></script>
</head>
<body>

View File

@@ -2,8 +2,8 @@
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/timespinnerTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/timespinnerTracker.js') }}"></script>
<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/trackers/timespinnerTracker.js') }}"></script>
</head>
<body>
@@ -41,7 +41,7 @@
<td></td>
{% endif %}
<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>
{% else %}
<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 datetime
import typing
from typing import Counter, Optional, Dict, Any, Tuple
from uuid import UUID
from typing import Counter, Optional, Dict, Any, Tuple, Set, List, TYPE_CHECKING
from flask import render_template
from werkzeug.exceptions import abort
import datetime
from uuid import UUID
from MultiServer import Context
from NetUtils import SlotType
from worlds.alttp import Items
from WebHostLib import app, cache, Room
from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from worlds.alttp import Items
from . import app, cache
from .models import Room
from worlds.AutoWorld import AutoWorldRegister
from MultiServer import get_item_name_from_id, Context
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 = {
"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>')
@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
if tracked_team < 0 or tracked_player < 1:
abort(404)
@@ -298,13 +341,78 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
if not room:
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, \
precollected_items, games, slot_data, groups = get_static_room_data(room)
player_name = names[tracked_team][tracked_player - 1]
location_to_area = player_location_to_area[tracked_player]
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
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:
slots_aimed_at_player.add(group_id)
checked_locations = set()
# Add items to player inventory
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
# 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]
if recipient in slots_aimed_at_player: # a check done for the tracked player
attribute_item_solo(inventory, item)
if ms_player == tracked_player: # a check done by the tracked player
checks_done[location_to_area[location]] += 1
checks_done["Total"] += 1
specific_tracker = game_specific_trackers.get(games[tracked_player], None)
if specific_tracker and not want_generic:
return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done, slot_data[tracked_player])
else:
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done)
lttp_checks_done[location_to_area[location]] += 1
lttp_checks_done["Total"] += 1
checked_locations.add(lookup_any_location_id_to_name[location])
prog_items = collections.Counter
all_location_names = set()
all_location_names = {lookup_any_location_id_to_name[id] for id in locations[tracked_player]}
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 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]]],
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: set,
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:
@@ -753,7 +524,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
}
timespinner_location_ids = {
"Present": [
"Present": [
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
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,
1337171, 1337172, 1337173, 1337174, 1337175],
"Ancient Pyramid": [
1337236,
1337236,
1337246, 1337247, 1337248, 1337249]
}
if(slot_data["DownloadableItems"]):
timespinner_location_ids["Present"] += [
1337156, 1337157, 1337159,
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
1337170]
if(slot_data["Cantoran"]):
timespinner_location_ids["Past"].append(1337176)
if(slot_data["LoreChecks"]):
timespinner_location_ids["Present"] += [
1337177, 1337178, 1337179,
1337177, 1337178, 1337179,
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
timespinner_location_ids["Past"] += [
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}
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,
player=player, team=team, room=room, player_name=playerName,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
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,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
icons = {
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png",
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png",
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png",
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png",
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png",
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png",
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png",
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png",
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png",
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png",
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png",
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png",
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png",
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png",
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png",
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png",
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png",
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png",
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png",
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png",
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png",
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ETank.png",
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Missile.png",
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Super.png",
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/PowerBomb.png",
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Bomb.png",
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Charge.png",
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Ice.png",
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/HiJump.png",
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpeedBooster.png",
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Wave.png",
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Spazer.png",
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpringBall.png",
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Varia.png",
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Plasma.png",
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Grapple.png",
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Morph.png",
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Reserve.png",
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Gravity.png",
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/XRayScope.png",
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpaceJump.png",
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ScrewAttack.png",
"Nothing": "",
"No Energy": "",
"Kraid": "",
@@ -897,6 +668,7 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
for item_name, item_id in multi_items.items():
base_name = item_name.split()[0].lower()
count = inventory[item_id]
display_data[base_name+"_count"] = inventory[item_id]
# 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['Total'] = sum(checks_in_area.values())
return render_template("supermetroidTracker.html",
return render_template("trackers/" + "supermetroidTracker.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},
@@ -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,
**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,
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):
player_received_items[networkItem.item] = order_index
return render_template("genericTracker.html",
return render_template("trackers/" + "genericTracker.html",
inventory=inventory,
player=player, team=team, room=room, player_name=playerName,
checked_locations=checked_locations,
not_checked_locations=set(locations[player]) - checked_locations,
not_checked_locations=locations - checked_locations,
received_items=player_received_items)
@@ -983,9 +756,9 @@ def getTracker(tracker: UUID):
continue
item, recipient, flags = player_locations[location]
if recipient in names:
attribute_item(inventory, team, recipient, item)
checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1
@@ -1029,7 +802,7 @@ def getTracker(tracker: UUID):
for (team, player), data in multisave.get("video", []):
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,
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,
@@ -1040,9 +813,6 @@ def getTracker(tracker: UUID):
game_specific_trackers: typing.Dict[str, typing.Callable] = {
"Minecraft": __renderMinecraftTracker,
"Ocarina of Time": __renderOoTTracker,
"Timespinner": __renderTimespinnerTracker,
"A Link to the Past": __renderAlttpTracker,
"Super Metroid": __renderSuperMetroidTracker
}

View File

@@ -1,19 +1,19 @@
import base64
import json
import typing
import uuid
import zipfile
import lzma
import json
import base64
import MultiServer
import uuid
from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template
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 Utils import VersionException, __version__
from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot
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:
owner = session["_id"]
infolist = zfile.infolist()
slots: typing.Set[Slot] = set()
slots = set()
spoiler = ""
multidata = None
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_id=patch.player,
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"):
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>:
viewclass: 'SelectableLabel'
scroll_y: 0
scroll_type: ["content", "bars"]
bar_width: dp(12)
effect_cls: "ScrollEffect"
SelectableRecycleBoxLayout:
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)
end
-- Why is there an extra offset of 3 for temp context checks? Who knows.
local cow_check = function(scene_offset, bit_to_check)
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
-- 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)
return scene_check(scene_offset, bit_to_check, 0x4)
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})
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
local function skulltula_scene_to_array_index(i)
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 Chest"] = chest_check(0x60, 0x01)
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 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 Rolling Goron as Child"] = info_table_check(0x22, 0x6)
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 Right Chest"] = chest_check(0x62, 0x01)
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 Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
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 Grotto Left"] = scrub_sanity_check(0x23, 0x1)
@@ -974,7 +961,7 @@ end
local read_haunted_wasteland_checks = function()
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 GS"] = skulltula_check(0x15, 0x1)
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
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
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.
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

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