mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-04-02 02:53:25 -07:00
Compare commits
14 Commits
0.3.6
...
player-tra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f5fceba2d | ||
|
|
e9e5511583 | ||
|
|
c546dcd5ff | ||
|
|
053fb14495 | ||
|
|
ed77d14618 | ||
|
|
3fb287e82b | ||
|
|
32431cfe04 | ||
|
|
ca8f4c38ec | ||
|
|
eb52454ccc | ||
|
|
14e5f54f59 | ||
|
|
2052cc55af | ||
|
|
63a8436240 | ||
|
|
e60719a20a | ||
|
|
8742aadc72 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip setuptools
|
python -m pip install --upgrade pip setuptools
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python setup.py build_exe --yes
|
python setup.py build --yes
|
||||||
$NAME="$(ls build)".Split('.',2)[1]
|
$NAME="$(ls build)".Split('.',2)[1]
|
||||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -13,10 +13,6 @@
|
|||||||
*.z64
|
*.z64
|
||||||
*.n64
|
*.n64
|
||||||
*.nes
|
*.nes
|
||||||
*.sms
|
|
||||||
*.gb
|
|
||||||
*.gbc
|
|
||||||
*.gba
|
|
||||||
*.wixobj
|
*.wixobj
|
||||||
*.lck
|
*.lck
|
||||||
*.db3
|
*.db3
|
||||||
@@ -129,7 +125,7 @@ ipython_config.py
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.venv*
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
|
|||||||
320
BaseClasses.py
320
BaseClasses.py
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
from enum import unique, IntEnum, IntFlag
|
from enum import unique, IntEnum, IntFlag
|
||||||
@@ -41,23 +40,16 @@ class MultiWorld():
|
|||||||
plando_connections: List
|
plando_connections: List
|
||||||
worlds: Dict[int, auto_world]
|
worlds: Dict[int, auto_world]
|
||||||
groups: Dict[int, Group]
|
groups: Dict[int, Group]
|
||||||
regions: List[Region]
|
|
||||||
itempool: List[Item]
|
itempool: List[Item]
|
||||||
is_race: bool = False
|
is_race: bool = False
|
||||||
precollected_items: Dict[int, List[Item]]
|
precollected_items: Dict[int, List[Item]]
|
||||||
state: CollectionState
|
state: CollectionState
|
||||||
|
|
||||||
accessibility: Dict[int, Options.Accessibility]
|
accessibility: Dict[int, Options.Accessibility]
|
||||||
early_items: Dict[int, Dict[str, int]]
|
|
||||||
local_early_items: Dict[int, Dict[str, int]]
|
|
||||||
local_items: Dict[int, Options.LocalItems]
|
local_items: Dict[int, Options.LocalItems]
|
||||||
non_local_items: Dict[int, Options.NonLocalItems]
|
non_local_items: Dict[int, Options.NonLocalItems]
|
||||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||||
indirect_connections: Dict[Region, Set[Entrance]]
|
|
||||||
exclude_locations: Dict[int, Options.ExcludeLocations]
|
|
||||||
|
|
||||||
game: Dict[int, str]
|
|
||||||
|
|
||||||
class AttributeProxy():
|
class AttributeProxy():
|
||||||
def __init__(self, rule):
|
def __init__(self, rule):
|
||||||
@@ -95,9 +87,6 @@ class MultiWorld():
|
|||||||
self.customitemarray = []
|
self.customitemarray = []
|
||||||
self.shuffle_ganon = True
|
self.shuffle_ganon = True
|
||||||
self.spoiler = Spoiler(self)
|
self.spoiler = Spoiler(self)
|
||||||
self.early_items = {player: {} for player in self.player_ids}
|
|
||||||
self.local_early_items = {player: {} for player in self.player_ids}
|
|
||||||
self.indirect_connections = {}
|
|
||||||
self.fix_trock_doors = self.AttributeProxy(
|
self.fix_trock_doors = self.AttributeProxy(
|
||||||
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
||||||
self.fix_skullwoods_exit = self.AttributeProxy(
|
self.fix_skullwoods_exit = self.AttributeProxy(
|
||||||
@@ -206,7 +195,7 @@ class MultiWorld():
|
|||||||
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
|
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
|
||||||
range(1, self.players + 1)}
|
range(1, self.players + 1)}
|
||||||
|
|
||||||
def set_options(self, args: Namespace) -> None:
|
def set_options(self, args):
|
||||||
for option_key in Options.common_options:
|
for option_key in Options.common_options:
|
||||||
setattr(self, option_key, getattr(args, option_key, {}))
|
setattr(self, option_key, getattr(args, option_key, {}))
|
||||||
for option_key in Options.per_game_common_options:
|
for option_key in Options.per_game_common_options:
|
||||||
@@ -306,16 +295,9 @@ class MultiWorld():
|
|||||||
def get_file_safe_player_name(self, player: int) -> str:
|
def get_file_safe_player_name(self, player: int) -> str:
|
||||||
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
||||||
|
|
||||||
def get_out_file_name_base(self, player: int) -> str:
|
|
||||||
""" the base name (without file extension) for each player's output file for a seed """
|
|
||||||
return f"AP_{self.seed_name}_P{player}" \
|
|
||||||
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
|
||||||
if (self.player_name[player] != f"Player{player}")
|
|
||||||
else '')
|
|
||||||
|
|
||||||
def initialize_regions(self, regions=None):
|
def initialize_regions(self, regions=None):
|
||||||
for region in regions if regions else self.regions:
|
for region in regions if regions else self.regions:
|
||||||
region.multiworld = self
|
region.world = self
|
||||||
self._region_cache[region.player][region.name] = region
|
self._region_cache[region.player][region.name] = region
|
||||||
|
|
||||||
@functools.cached_property
|
@functools.cached_property
|
||||||
@@ -422,11 +404,6 @@ class MultiWorld():
|
|||||||
def clear_entrance_cache(self):
|
def clear_entrance_cache(self):
|
||||||
self._cached_entrances = None
|
self._cached_entrances = None
|
||||||
|
|
||||||
def register_indirect_condition(self, region: Region, entrance: Entrance):
|
|
||||||
"""Report that access to this Region can result in unlocking this Entrance,
|
|
||||||
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
|
|
||||||
self.indirect_connections.setdefault(region, set()).add(entrance)
|
|
||||||
|
|
||||||
def get_locations(self) -> List[Location]:
|
def get_locations(self) -> List[Location]:
|
||||||
if self._cached_locations is None:
|
if self._cached_locations is None:
|
||||||
self._cached_locations = [location for region in self.regions for location in region.locations]
|
self._cached_locations = [location for region in self.regions for location in region.locations]
|
||||||
@@ -544,17 +521,15 @@ class MultiWorld():
|
|||||||
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
||||||
if not state:
|
if not state:
|
||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
players: Dict[str, Set[int]] = {
|
players = {"minimal": set(),
|
||||||
"minimal": set(),
|
"items": set(),
|
||||||
"items": set(),
|
"locations": set()}
|
||||||
"locations": set()
|
|
||||||
}
|
|
||||||
for player, access in self.accessibility.items():
|
for player, access in self.accessibility.items():
|
||||||
players[access.current_key].add(player)
|
players[access.current_key].add(player)
|
||||||
|
|
||||||
beatable_fulfilled = False
|
beatable_fulfilled = False
|
||||||
|
|
||||||
def location_condition(location: Location):
|
def location_conditition(location: Location):
|
||||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||||
if location.player in players["minimal"]:
|
if location.player in players["minimal"]:
|
||||||
return False
|
return False
|
||||||
@@ -568,21 +543,20 @@ class MultiWorld():
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def all_done() -> bool:
|
def all_done():
|
||||||
"""Check if all access rules are fulfilled"""
|
"""Check if all access rules are fulfilled"""
|
||||||
if not beatable_fulfilled:
|
if beatable_fulfilled:
|
||||||
return False
|
if any(location_conditition(location) for location in locations):
|
||||||
if any(location_condition(location) for location in locations):
|
return False # still locations required to be collected
|
||||||
return False # still locations required to be collected
|
return True
|
||||||
return True
|
|
||||||
|
|
||||||
locations = [location for location in self.get_locations() if location_relevant(location)]
|
locations = {location for location in self.get_locations() if location_relevant(location)}
|
||||||
|
|
||||||
while locations:
|
while locations:
|
||||||
sphere: List[Location] = []
|
sphere = set()
|
||||||
for n in range(len(locations) - 1, -1, -1):
|
for location in locations:
|
||||||
if locations[n].can_reach(state):
|
if location.can_reach(state):
|
||||||
sphere.append(locations.pop(n))
|
sphere.add(location)
|
||||||
|
|
||||||
if not sphere:
|
if not sphere:
|
||||||
# ran out of places and did not finish yet, quit
|
# ran out of places and did not finish yet, quit
|
||||||
@@ -591,8 +565,8 @@ class MultiWorld():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
if location.item:
|
locations.remove(location)
|
||||||
state.collect(location.item, True, location)
|
state.collect(location.item, True, location)
|
||||||
|
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
beatable_fulfilled = True
|
beatable_fulfilled = True
|
||||||
@@ -608,7 +582,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
|
|||||||
|
|
||||||
class CollectionState():
|
class CollectionState():
|
||||||
prog_items: typing.Counter[Tuple[str, int]]
|
prog_items: typing.Counter[Tuple[str, int]]
|
||||||
multiworld: MultiWorld
|
world: MultiWorld
|
||||||
reachable_regions: Dict[int, Set[Region]]
|
reachable_regions: Dict[int, Set[Region]]
|
||||||
blocked_connections: Dict[int, Set[Entrance]]
|
blocked_connections: Dict[int, Set[Entrance]]
|
||||||
events: Set[Location]
|
events: Set[Location]
|
||||||
@@ -620,7 +594,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def __init__(self, parent: MultiWorld):
|
def __init__(self, parent: MultiWorld):
|
||||||
self.prog_items = Counter()
|
self.prog_items = Counter()
|
||||||
self.multiworld = parent
|
self.world = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||||
self.events = set()
|
self.events = set()
|
||||||
@@ -634,14 +608,15 @@ class CollectionState():
|
|||||||
self.collect(item, True)
|
self.collect(item, True)
|
||||||
|
|
||||||
def update_reachable_regions(self, player: int):
|
def update_reachable_regions(self, player: int):
|
||||||
|
from worlds.alttp.EntranceShuffle import indirect_connections
|
||||||
self.stale[player] = False
|
self.stale[player] = False
|
||||||
rrp = self.reachable_regions[player]
|
rrp = self.reachable_regions[player]
|
||||||
bc = self.blocked_connections[player]
|
bc = self.blocked_connections[player]
|
||||||
queue = deque(self.blocked_connections[player])
|
queue = deque(self.blocked_connections[player])
|
||||||
start = self.multiworld.get_region('Menu', player)
|
start = self.world.get_region('Menu', player)
|
||||||
|
|
||||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||||
if start not in rrp:
|
if not start in rrp:
|
||||||
rrp.add(start)
|
rrp.add(start)
|
||||||
bc.update(start.exits)
|
bc.update(start.exits)
|
||||||
queue.extend(start.exits)
|
queue.extend(start.exits)
|
||||||
@@ -653,7 +628,7 @@ class CollectionState():
|
|||||||
if new_region in rrp:
|
if new_region in rrp:
|
||||||
bc.remove(connection)
|
bc.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
assert new_region, "tried to search through an Entrance with no Region"
|
||||||
rrp.add(new_region)
|
rrp.add(new_region)
|
||||||
bc.remove(connection)
|
bc.remove(connection)
|
||||||
bc.update(new_region.exits)
|
bc.update(new_region.exits)
|
||||||
@@ -661,12 +636,13 @@ class CollectionState():
|
|||||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||||
|
|
||||||
# Retry connections if the new region can unblock them
|
# Retry connections if the new region can unblock them
|
||||||
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
|
if new_region.name in indirect_connections:
|
||||||
|
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
|
||||||
if new_entrance in bc and new_entrance not in queue:
|
if new_entrance in bc and new_entrance not in queue:
|
||||||
queue.append(new_entrance)
|
queue.append(new_entrance)
|
||||||
|
|
||||||
def copy(self) -> CollectionState:
|
def copy(self) -> CollectionState:
|
||||||
ret = CollectionState(self.multiworld)
|
ret = CollectionState(self.world)
|
||||||
ret.prog_items = self.prog_items.copy()
|
ret.prog_items = self.prog_items.copy()
|
||||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||||
self.reachable_regions}
|
self.reachable_regions}
|
||||||
@@ -687,25 +663,25 @@ class CollectionState():
|
|||||||
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
||||||
# try to resolve a name
|
# try to resolve a name
|
||||||
if resolution_hint == 'Location':
|
if resolution_hint == 'Location':
|
||||||
spot = self.multiworld.get_location(spot, player)
|
spot = self.world.get_location(spot, player)
|
||||||
elif resolution_hint == 'Entrance':
|
elif resolution_hint == 'Entrance':
|
||||||
spot = self.multiworld.get_entrance(spot, player)
|
spot = self.world.get_entrance(spot, player)
|
||||||
else:
|
else:
|
||||||
# default to Region
|
# default to Region
|
||||||
spot = self.multiworld.get_region(spot, player)
|
spot = self.world.get_region(spot, player)
|
||||||
return spot.can_reach(self)
|
return spot.can_reach(self)
|
||||||
|
|
||||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||||
if locations is None:
|
if locations is None:
|
||||||
locations = self.multiworld.get_filled_locations()
|
locations = self.world.get_filled_locations()
|
||||||
reachable_events = True
|
new_locations = True
|
||||||
# since the loop has a good chance to run more than once, only filter the events once
|
# since the loop has a good chance to run more than once, only filter the events once
|
||||||
locations = {location for location in locations if location.event and location not in self.events and
|
locations = {location for location in locations if location.event and
|
||||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||||
while reachable_events:
|
while new_locations:
|
||||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||||
locations -= reachable_events
|
new_locations = reachable_events - self.events
|
||||||
for event in reachable_events:
|
for event in new_locations:
|
||||||
self.events.add(event)
|
self.events.add(event)
|
||||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||||
self.collect(event.item, True, event)
|
self.collect(event.item, True, event)
|
||||||
@@ -724,7 +700,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||||
found: int = 0
|
found: int = 0
|
||||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||||
found += self.prog_items[item_name, player]
|
found += self.prog_items[item_name, player]
|
||||||
if found >= count:
|
if found >= count:
|
||||||
return True
|
return True
|
||||||
@@ -732,17 +708,17 @@ class CollectionState():
|
|||||||
|
|
||||||
def count_group(self, item_name_group: str, player: int) -> int:
|
def count_group(self, item_name_group: str, player: int) -> int:
|
||||||
found: int = 0
|
found: int = 0
|
||||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||||
found += self.prog_items[item_name, player]
|
found += self.prog_items[item_name, player]
|
||||||
return found
|
return found
|
||||||
|
|
||||||
def can_buy_unlimited(self, item: str, player: int) -> bool:
|
def can_buy_unlimited(self, item: str, player: int) -> bool:
|
||||||
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
|
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
|
||||||
shop in self.multiworld.shops)
|
shop in self.world.shops)
|
||||||
|
|
||||||
def can_buy(self, item: str, player: int) -> bool:
|
def can_buy(self, item: str, player: int) -> bool:
|
||||||
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
|
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
|
||||||
shop in self.multiworld.shops)
|
shop in self.world.shops)
|
||||||
|
|
||||||
def item_count(self, item: str, player: int) -> int:
|
def item_count(self, item: str, player: int) -> int:
|
||||||
return self.prog_items[item, player]
|
return self.prog_items[item, player]
|
||||||
@@ -762,7 +738,7 @@ class CollectionState():
|
|||||||
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
|
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
|
||||||
|
|
||||||
def bottle_count(self, player: int) -> int:
|
def bottle_count(self, player: int) -> int:
|
||||||
return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit,
|
return min(self.world.difficulty_requirements[player].progressive_bottle_limit,
|
||||||
self.count_group("Bottles", player))
|
self.count_group("Bottles", player))
|
||||||
|
|
||||||
def has_hearts(self, player: int, count: int) -> int:
|
def has_hearts(self, player: int, count: int) -> int:
|
||||||
@@ -771,7 +747,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def heart_count(self, player: int) -> int:
|
def heart_count(self, player: int) -> int:
|
||||||
# Warning: This only considers items that are marked as advancement items
|
# Warning: This only considers items that are marked as advancement items
|
||||||
diff = self.multiworld.difficulty_requirements[player]
|
diff = self.world.difficulty_requirements[player]
|
||||||
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
||||||
+ self.item_count('Sanctuary Heart Container', player) \
|
+ self.item_count('Sanctuary Heart Container', player) \
|
||||||
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
||||||
@@ -788,9 +764,9 @@ class CollectionState():
|
|||||||
elif self.has('Magic Upgrade (1/2)', player):
|
elif self.has('Magic Upgrade (1/2)', player):
|
||||||
basemagic = 16
|
basemagic = 16
|
||||||
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
|
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
|
||||||
if self.multiworld.item_functionality[player] == 'hard' and not fullrefill:
|
if self.world.item_functionality[player] == 'hard' and not fullrefill:
|
||||||
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
|
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
|
||||||
elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill:
|
elif self.world.item_functionality[player] == 'expert' and not fullrefill:
|
||||||
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
|
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
|
||||||
else:
|
else:
|
||||||
basemagic = basemagic + basemagic * self.bottle_count(player)
|
basemagic = basemagic + basemagic * self.bottle_count(player)
|
||||||
@@ -805,12 +781,12 @@ class CollectionState():
|
|||||||
or (self.has('Bombs (10)', player) and enemies < 6))
|
or (self.has('Bombs (10)', player) and enemies < 6))
|
||||||
|
|
||||||
def can_shoot_arrows(self, player: int) -> bool:
|
def can_shoot_arrows(self, player: int) -> bool:
|
||||||
if self.multiworld.retro_bow[player]:
|
if self.world.retro_bow[player]:
|
||||||
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
|
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
|
||||||
return self.has('Bow', player) or self.has('Silver Bow', player)
|
return self.has('Bow', player) or self.has('Silver Bow', player)
|
||||||
|
|
||||||
def can_get_good_bee(self, player: int) -> bool:
|
def can_get_good_bee(self, player: int) -> bool:
|
||||||
cave = self.multiworld.get_region('Good Bee Cave', player)
|
cave = self.world.get_region('Good Bee Cave', player)
|
||||||
return (
|
return (
|
||||||
self.has_group("Bottles", player) and
|
self.has_group("Bottles", player) and
|
||||||
self.has('Bug Catching Net', player) and
|
self.has('Bug Catching Net', player) and
|
||||||
@@ -821,7 +797,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def can_retrieve_tablet(self, player: int) -> bool:
|
def can_retrieve_tablet(self, player: int) -> bool:
|
||||||
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
|
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
|
||||||
(self.multiworld.swordless[player] and
|
(self.world.swordless[player] and
|
||||||
self.has("Hammer", player)))
|
self.has("Hammer", player)))
|
||||||
|
|
||||||
def has_sword(self, player: int) -> bool:
|
def has_sword(self, player: int) -> bool:
|
||||||
@@ -843,7 +819,7 @@ class CollectionState():
|
|||||||
def can_melt_things(self, player: int) -> bool:
|
def can_melt_things(self, player: int) -> bool:
|
||||||
return self.has('Fire Rod', player) or \
|
return self.has('Fire Rod', player) or \
|
||||||
(self.has('Bombos', player) and
|
(self.has('Bombos', player) and
|
||||||
(self.multiworld.swordless[player] or
|
(self.world.swordless[player] or
|
||||||
self.has_sword(player)))
|
self.has_sword(player)))
|
||||||
|
|
||||||
def can_avoid_lasers(self, player: int) -> bool:
|
def can_avoid_lasers(self, player: int) -> bool:
|
||||||
@@ -853,7 +829,7 @@ class CollectionState():
|
|||||||
if self.has('Moon Pearl', player):
|
if self.has('Moon Pearl', player):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world
|
return region.is_light_world if self.world.mode[player] != 'inverted' else region.is_dark_world
|
||||||
|
|
||||||
def can_reach_light_world(self, player: int) -> bool:
|
def can_reach_light_world(self, player: int) -> bool:
|
||||||
if True in [i.is_light_world for i in self.reachable_regions[player]]:
|
if True in [i.is_light_world for i in self.reachable_regions[player]]:
|
||||||
@@ -866,24 +842,24 @@ class CollectionState():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def has_misery_mire_medallion(self, player: int) -> bool:
|
def has_misery_mire_medallion(self, player: int) -> bool:
|
||||||
return self.has(self.multiworld.required_medallions[player][0], player)
|
return self.has(self.world.required_medallions[player][0], player)
|
||||||
|
|
||||||
def has_turtle_rock_medallion(self, player: int) -> bool:
|
def has_turtle_rock_medallion(self, player: int) -> bool:
|
||||||
return self.has(self.multiworld.required_medallions[player][1], player)
|
return self.has(self.world.required_medallions[player][1], player)
|
||||||
|
|
||||||
def can_boots_clip_lw(self, player: int) -> bool:
|
def can_boots_clip_lw(self, player: int) -> bool:
|
||||||
if self.multiworld.mode[player] == 'inverted':
|
if self.world.mode[player] == 'inverted':
|
||||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||||
return self.has('Pegasus Boots', player)
|
return self.has('Pegasus Boots', player)
|
||||||
|
|
||||||
def can_boots_clip_dw(self, player: int) -> bool:
|
def can_boots_clip_dw(self, player: int) -> bool:
|
||||||
if self.multiworld.mode[player] != 'inverted':
|
if self.world.mode[player] != 'inverted':
|
||||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||||
return self.has('Pegasus Boots', player)
|
return self.has('Pegasus Boots', player)
|
||||||
|
|
||||||
def can_get_glitched_speed_lw(self, player: int) -> bool:
|
def can_get_glitched_speed_lw(self, player: int) -> bool:
|
||||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||||
if self.multiworld.mode[player] == 'inverted':
|
if self.world.mode[player] == 'inverted':
|
||||||
rules.append(self.has('Moon Pearl', player))
|
rules.append(self.has('Moon Pearl', player))
|
||||||
return all(rules)
|
return all(rules)
|
||||||
|
|
||||||
@@ -892,7 +868,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def can_get_glitched_speed_dw(self, player: int) -> bool:
|
def can_get_glitched_speed_dw(self, player: int) -> bool:
|
||||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||||
if self.multiworld.mode[player] != 'inverted':
|
if self.world.mode[player] != 'inverted':
|
||||||
rules.append(self.has('Moon Pearl', player))
|
rules.append(self.has('Moon Pearl', player))
|
||||||
return all(rules)
|
return all(rules)
|
||||||
|
|
||||||
@@ -903,7 +879,7 @@ class CollectionState():
|
|||||||
if location:
|
if location:
|
||||||
self.locations_checked.add(location)
|
self.locations_checked.add(location)
|
||||||
|
|
||||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
changed = self.world.worlds[item.player].collect(self, item)
|
||||||
|
|
||||||
if not changed and event:
|
if not changed and event:
|
||||||
self.prog_items[item.name, item.player] += 1
|
self.prog_items[item.name, item.player] += 1
|
||||||
@@ -917,7 +893,7 @@ class CollectionState():
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
def remove(self, item: Item):
|
def remove(self, item: Item):
|
||||||
changed = self.multiworld.worlds[item.player].remove(self, item)
|
changed = self.world.worlds[item.player].remove(self, item)
|
||||||
if changed:
|
if changed:
|
||||||
# invalidate caches, nothing can be trusted anymore now
|
# invalidate caches, nothing can be trusted anymore now
|
||||||
self.reachable_regions[item.player] = set()
|
self.reachable_regions[item.player] = set()
|
||||||
@@ -944,7 +920,7 @@ class Region:
|
|||||||
type: RegionType
|
type: RegionType
|
||||||
hint_text: str
|
hint_text: str
|
||||||
player: int
|
player: int
|
||||||
multiworld: Optional[MultiWorld]
|
world: Optional[MultiWorld]
|
||||||
entrances: List[Entrance]
|
entrances: List[Entrance]
|
||||||
exits: List[Entrance]
|
exits: List[Entrance]
|
||||||
locations: List[Location]
|
locations: List[Location]
|
||||||
@@ -962,7 +938,7 @@ class Region:
|
|||||||
self.entrances = []
|
self.entrances = []
|
||||||
self.exits = []
|
self.exits = []
|
||||||
self.locations = []
|
self.locations = []
|
||||||
self.multiworld = world
|
self.world = world
|
||||||
self.hint_text = hint
|
self.hint_text = hint
|
||||||
self.player = player
|
self.player = player
|
||||||
|
|
||||||
@@ -990,7 +966,7 @@ class Region:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
|
|
||||||
class Entrance:
|
class Entrance:
|
||||||
@@ -1017,7 +993,7 @@ class Entrance:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
|
def connect(self, region: Region, addresses=None, target=None):
|
||||||
self.connected_region = region
|
self.connected_region = region
|
||||||
self.target = target
|
self.target = target
|
||||||
self.addresses = addresses
|
self.addresses = addresses
|
||||||
@@ -1027,7 +1003,7 @@ class Entrance:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
world = self.parent_region.multiworld if self.parent_region else None
|
world = self.parent_region.world if self.parent_region else None
|
||||||
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
|
|
||||||
@@ -1041,7 +1017,7 @@ class Dungeon(object):
|
|||||||
self.dungeon_items = dungeon_items
|
self.dungeon_items = dungeon_items
|
||||||
self.bosses = dict()
|
self.bosses = dict()
|
||||||
self.player = player
|
self.player = player
|
||||||
self.multiworld = None
|
self.world = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def boss(self) -> Optional[Boss]:
|
def boss(self) -> Optional[Boss]:
|
||||||
@@ -1071,7 +1047,7 @@ class Dungeon(object):
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
|
|
||||||
class Boss():
|
class Boss():
|
||||||
@@ -1105,7 +1081,7 @@ class Location:
|
|||||||
show_in_spoiler: bool = True
|
show_in_spoiler: bool = True
|
||||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||||
always_allow = staticmethod(lambda item, state: False)
|
always_allow = staticmethod(lambda item, state: False)
|
||||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
access_rule = staticmethod(lambda state: True)
|
||||||
item_rule = staticmethod(lambda item: True)
|
item_rule = staticmethod(lambda item: True)
|
||||||
item: Optional[Item] = None
|
item: Optional[Item] = None
|
||||||
|
|
||||||
@@ -1116,15 +1092,13 @@ class Location:
|
|||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
|
|
||||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||||
return (self.always_allow(state, item)
|
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
|
||||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
|
||||||
and self.item_rule(item)
|
|
||||||
and (not check_access or self.can_reach(state))))
|
|
||||||
|
|
||||||
def can_reach(self, state: CollectionState) -> bool:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
# self.access_rule computes faster on average, so placing it first for faster abort
|
# self.access_rule computes faster on average, so placing it first for faster abort
|
||||||
assert self.parent_region, "Can't reach location without region"
|
if self.access_rule(state) and self.parent_region.can_reach(state):
|
||||||
return self.access_rule(state) and self.parent_region.can_reach(state)
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def place_locked_item(self, item: Item):
|
def place_locked_item(self, item: Item):
|
||||||
if self.item:
|
if self.item:
|
||||||
@@ -1138,7 +1112,7 @@ class Location:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
world = self.parent_region.world if self.parent_region and self.parent_region.world else None
|
||||||
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
@@ -1235,17 +1209,17 @@ class Item:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
if self.location and self.location.parent_region and self.location.parent_region.world:
|
||||||
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
return self.location.parent_region.world.get_name_string_for_object(self)
|
||||||
return f"{self.name} (Player {self.player})"
|
return f"{self.name} (Player {self.player})"
|
||||||
|
|
||||||
|
|
||||||
class Spoiler():
|
class Spoiler():
|
||||||
multiworld: MultiWorld
|
world: MultiWorld
|
||||||
unreachables: Set[Location]
|
unreachables: Set[Location]
|
||||||
|
|
||||||
def __init__(self, world):
|
def __init__(self, world):
|
||||||
self.multiworld = world
|
self.world = world
|
||||||
self.hashes = {}
|
self.hashes = {}
|
||||||
self.entrances = OrderedDict()
|
self.entrances = OrderedDict()
|
||||||
self.medallions = {}
|
self.medallions = {}
|
||||||
@@ -1257,7 +1231,7 @@ class Spoiler():
|
|||||||
self.bosses = OrderedDict()
|
self.bosses = OrderedDict()
|
||||||
|
|
||||||
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
||||||
if self.multiworld.players == 1:
|
if self.world.players == 1:
|
||||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||||
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||||
else:
|
else:
|
||||||
@@ -1266,45 +1240,45 @@ class Spoiler():
|
|||||||
|
|
||||||
def parse_data(self):
|
def parse_data(self):
|
||||||
self.medallions = OrderedDict()
|
self.medallions = OrderedDict()
|
||||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
for player in self.world.get_game_players("A Link to the Past"):
|
||||||
self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \
|
self.medallions[f'Misery Mire ({self.world.get_player_name(player)})'] = \
|
||||||
self.multiworld.required_medallions[player][0]
|
self.world.required_medallions[player][0]
|
||||||
self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \
|
self.medallions[f'Turtle Rock ({self.world.get_player_name(player)})'] = \
|
||||||
self.multiworld.required_medallions[player][1]
|
self.world.required_medallions[player][1]
|
||||||
|
|
||||||
self.locations = OrderedDict()
|
self.locations = OrderedDict()
|
||||||
listed_locations = set()
|
listed_locations = set()
|
||||||
|
|
||||||
lw_locations = [loc for loc in self.multiworld.get_locations() if
|
lw_locations = [loc for loc in self.world.get_locations() if
|
||||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
|
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
|
||||||
self.locations['Light World'] = OrderedDict(
|
self.locations['Light World'] = OrderedDict(
|
||||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||||
lw_locations])
|
lw_locations])
|
||||||
listed_locations.update(lw_locations)
|
listed_locations.update(lw_locations)
|
||||||
|
|
||||||
dw_locations = [loc for loc in self.multiworld.get_locations() if
|
dw_locations = [loc for loc in self.world.get_locations() if
|
||||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
|
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
|
||||||
self.locations['Dark World'] = OrderedDict(
|
self.locations['Dark World'] = OrderedDict(
|
||||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||||
dw_locations])
|
dw_locations])
|
||||||
listed_locations.update(dw_locations)
|
listed_locations.update(dw_locations)
|
||||||
|
|
||||||
cave_locations = [loc for loc in self.multiworld.get_locations() if
|
cave_locations = [loc for loc in self.world.get_locations() if
|
||||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
|
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
|
||||||
self.locations['Caves'] = OrderedDict(
|
self.locations['Caves'] = OrderedDict(
|
||||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||||
cave_locations])
|
cave_locations])
|
||||||
listed_locations.update(cave_locations)
|
listed_locations.update(cave_locations)
|
||||||
|
|
||||||
for dungeon in self.multiworld.dungeons.values():
|
for dungeon in self.world.dungeons.values():
|
||||||
dungeon_locations = [loc for loc in self.multiworld.get_locations() if
|
dungeon_locations = [loc for loc in self.world.get_locations() if
|
||||||
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
|
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
|
||||||
self.locations[str(dungeon)] = OrderedDict(
|
self.locations[str(dungeon)] = OrderedDict(
|
||||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||||
dungeon_locations])
|
dungeon_locations])
|
||||||
listed_locations.update(dungeon_locations)
|
listed_locations.update(dungeon_locations)
|
||||||
|
|
||||||
other_locations = [loc for loc in self.multiworld.get_locations() if
|
other_locations = [loc for loc in self.world.get_locations() if
|
||||||
loc not in listed_locations and loc.show_in_spoiler]
|
loc not in listed_locations and loc.show_in_spoiler]
|
||||||
if other_locations:
|
if other_locations:
|
||||||
self.locations['Other Locations'] = OrderedDict(
|
self.locations['Other Locations'] = OrderedDict(
|
||||||
@@ -1314,7 +1288,7 @@ class Spoiler():
|
|||||||
|
|
||||||
self.shops = []
|
self.shops = []
|
||||||
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
|
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
|
||||||
for shop in self.multiworld.shops:
|
for shop in self.world.shops:
|
||||||
if not shop.custom:
|
if not shop.custom:
|
||||||
continue
|
continue
|
||||||
shopdata = {
|
shopdata = {
|
||||||
@@ -1343,34 +1317,34 @@ class Spoiler():
|
|||||||
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
|
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
|
||||||
self.shops.append(shopdata)
|
self.shops.append(shopdata)
|
||||||
|
|
||||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
for player in self.world.get_game_players("A Link to the Past"):
|
||||||
self.bosses[str(player)] = OrderedDict()
|
self.bosses[str(player)] = OrderedDict()
|
||||||
self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name
|
self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name
|
||||||
self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name
|
self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name
|
||||||
self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name
|
self.bosses[str(player)]["Tower Of Hera"] = self.world.get_dungeon("Tower of Hera", player).boss.name
|
||||||
self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
|
self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
|
||||||
self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness",
|
self.bosses[str(player)]["Palace Of Darkness"] = self.world.get_dungeon("Palace of Darkness",
|
||||||
player).boss.name
|
player).boss.name
|
||||||
self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name
|
self.bosses[str(player)]["Swamp Palace"] = self.world.get_dungeon("Swamp Palace", player).boss.name
|
||||||
self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name
|
self.bosses[str(player)]["Skull Woods"] = self.world.get_dungeon("Skull Woods", player).boss.name
|
||||||
self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name
|
self.bosses[str(player)]["Thieves Town"] = self.world.get_dungeon("Thieves Town", player).boss.name
|
||||||
self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name
|
self.bosses[str(player)]["Ice Palace"] = self.world.get_dungeon("Ice Palace", player).boss.name
|
||||||
self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name
|
self.bosses[str(player)]["Misery Mire"] = self.world.get_dungeon("Misery Mire", player).boss.name
|
||||||
self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name
|
self.bosses[str(player)]["Turtle Rock"] = self.world.get_dungeon("Turtle Rock", player).boss.name
|
||||||
if self.multiworld.mode[player] != 'inverted':
|
if self.world.mode[player] != 'inverted':
|
||||||
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
||||||
self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name
|
self.world.get_dungeon('Ganons Tower', player).bosses['bottom'].name
|
||||||
self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
|
self.bosses[str(player)]["Ganons Tower Middle"] = self.world.get_dungeon('Ganons Tower', player).bosses[
|
||||||
'middle'].name
|
'middle'].name
|
||||||
self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
|
self.bosses[str(player)]["Ganons Tower Top"] = self.world.get_dungeon('Ganons Tower', player).bosses[
|
||||||
'top'].name
|
'top'].name
|
||||||
else:
|
else:
|
||||||
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
||||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
|
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
|
||||||
self.bosses[str(player)]["Ganons Tower Middle"] = \
|
self.bosses[str(player)]["Ganons Tower Middle"] = \
|
||||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
|
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
|
||||||
self.bosses[str(player)]["Ganons Tower Top"] = \
|
self.bosses[str(player)]["Ganons Tower Top"] = \
|
||||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
|
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
|
||||||
|
|
||||||
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
|
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
|
||||||
self.bosses[str(player)]["Ganon"] = "Ganon"
|
self.bosses[str(player)]["Ganon"] = "Ganon"
|
||||||
@@ -1400,7 +1374,7 @@ class Spoiler():
|
|||||||
return 'Yes' if variable else 'No'
|
return 'Yes' if variable else 'No'
|
||||||
|
|
||||||
def write_option(option_key: str, option_obj: type(Options.Option)):
|
def write_option(option_key: str, option_obj: type(Options.Option)):
|
||||||
res = getattr(self.multiworld, option_key)[player]
|
res = getattr(self.world, option_key)[player]
|
||||||
display_name = getattr(option_obj, "display_name", option_key)
|
display_name = getattr(option_obj, "display_name", option_key)
|
||||||
try:
|
try:
|
||||||
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
|
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
|
||||||
@@ -1410,59 +1384,59 @@ class Spoiler():
|
|||||||
with open(filename, 'w', encoding="utf-8-sig") as outfile:
|
with open(filename, 'w', encoding="utf-8-sig") as outfile:
|
||||||
outfile.write(
|
outfile.write(
|
||||||
'Archipelago Version %s - Seed: %s\n\n' % (
|
'Archipelago Version %s - Seed: %s\n\n' % (
|
||||||
Utils.__version__, self.multiworld.seed))
|
Utils.__version__, self.world.seed))
|
||||||
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
|
||||||
outfile.write('Players: %d\n' % self.multiworld.players)
|
outfile.write('Players: %d\n' % self.world.players)
|
||||||
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
|
||||||
|
|
||||||
for player in range(1, self.multiworld.players + 1):
|
for player in range(1, self.world.players + 1):
|
||||||
if self.multiworld.players > 1:
|
if self.world.players > 1:
|
||||||
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
outfile.write('\nPlayer %d: %s\n' % (player, self.world.get_player_name(player)))
|
||||||
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
outfile.write('Game: %s\n' % self.world.game[player])
|
||||||
for f_option, option in Options.per_game_common_options.items():
|
for f_option, option in Options.per_game_common_options.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
options = self.multiworld.worlds[player].option_definitions
|
options = self.world.worlds[player].option_definitions
|
||||||
if options:
|
if options:
|
||||||
for f_option, option in options.items():
|
for f_option, option in options.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
|
AutoWorld.call_single(self.world, "write_spoiler_header", player, outfile)
|
||||||
|
|
||||||
if player in self.multiworld.get_game_players("A Link to the Past"):
|
if player in self.world.get_game_players("A Link to the Past"):
|
||||||
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
|
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
|
||||||
|
|
||||||
outfile.write('Logic: %s\n' % self.multiworld.logic[player])
|
outfile.write('Logic: %s\n' % self.world.logic[player])
|
||||||
outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player])
|
outfile.write('Dark Room Logic: %s\n' % self.world.dark_room_logic[player])
|
||||||
outfile.write('Mode: %s\n' % self.multiworld.mode[player])
|
outfile.write('Mode: %s\n' % self.world.mode[player])
|
||||||
outfile.write('Goal: %s\n' % self.multiworld.goal[player])
|
outfile.write('Goal: %s\n' % self.world.goal[player])
|
||||||
if "triforce" in self.multiworld.goal[player]: # triforce hunt
|
if "triforce" in self.world.goal[player]: # triforce hunt
|
||||||
outfile.write("Pieces available for Triforce: %s\n" %
|
outfile.write("Pieces available for Triforce: %s\n" %
|
||||||
self.multiworld.triforce_pieces_available[player])
|
self.world.triforce_pieces_available[player])
|
||||||
outfile.write("Pieces required for Triforce: %s\n" %
|
outfile.write("Pieces required for Triforce: %s\n" %
|
||||||
self.multiworld.triforce_pieces_required[player])
|
self.world.triforce_pieces_required[player])
|
||||||
outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player])
|
outfile.write('Difficulty: %s\n' % self.world.difficulty[player])
|
||||||
outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player])
|
outfile.write('Item Functionality: %s\n' % self.world.item_functionality[player])
|
||||||
outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player])
|
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
|
||||||
if self.multiworld.shuffle[player] != "vanilla":
|
if self.world.shuffle[player] != "vanilla":
|
||||||
outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed)
|
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
|
||||||
outfile.write('Shop inventory shuffle: %s\n' %
|
outfile.write('Shop inventory shuffle: %s\n' %
|
||||||
bool_to_text("i" in self.multiworld.shop_shuffle[player]))
|
bool_to_text("i" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Shop price shuffle: %s\n' %
|
outfile.write('Shop price shuffle: %s\n' %
|
||||||
bool_to_text("p" in self.multiworld.shop_shuffle[player]))
|
bool_to_text("p" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Shop upgrade shuffle: %s\n' %
|
outfile.write('Shop upgrade shuffle: %s\n' %
|
||||||
bool_to_text("u" in self.multiworld.shop_shuffle[player]))
|
bool_to_text("u" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('New Shop inventory: %s\n' %
|
outfile.write('New Shop inventory: %s\n' %
|
||||||
bool_to_text("g" in self.multiworld.shop_shuffle[player] or
|
bool_to_text("g" in self.world.shop_shuffle[player] or
|
||||||
"f" in self.multiworld.shop_shuffle[player]))
|
"f" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Custom Potion Shop: %s\n' %
|
outfile.write('Custom Potion Shop: %s\n' %
|
||||||
bool_to_text("w" in self.multiworld.shop_shuffle[player]))
|
bool_to_text("w" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player])
|
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
|
||||||
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
|
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
|
||||||
outfile.write('Prize shuffle %s\n' %
|
outfile.write('Prize shuffle %s\n' %
|
||||||
self.multiworld.shuffle_prizes[player])
|
self.world.shuffle_prizes[player])
|
||||||
if self.entrances:
|
if self.entrances:
|
||||||
outfile.write('\n\nEntrances:\n\n')
|
outfile.write('\n\nEntrances:\n\n')
|
||||||
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: '
|
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.world.get_player_name(entry["player"])}: '
|
||||||
if self.multiworld.players > 1 else '', entry['entrance'],
|
if self.world.players > 1 else '', entry['entrance'],
|
||||||
'<=>' if entry['direction'] == 'both' else
|
'<=>' if entry['direction'] == 'both' else
|
||||||
'<=' if entry['direction'] == 'exit' else '=>',
|
'<=' if entry['direction'] == 'exit' else '=>',
|
||||||
entry['exit']) for entry in self.entrances.values()]))
|
entry['exit']) for entry in self.entrances.values()]))
|
||||||
@@ -1472,7 +1446,7 @@ class Spoiler():
|
|||||||
for dungeon, medallion in self.medallions.items():
|
for dungeon, medallion in self.medallions.items():
|
||||||
outfile.write(f'\n{dungeon}: {medallion}')
|
outfile.write(f'\n{dungeon}: {medallion}')
|
||||||
|
|
||||||
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
|
AutoWorld.call_all(self.world, "write_spoiler", outfile)
|
||||||
|
|
||||||
outfile.write('\n\nLocations:\n\n')
|
outfile.write('\n\nLocations:\n\n')
|
||||||
outfile.write('\n'.join(
|
outfile.write('\n'.join(
|
||||||
@@ -1485,11 +1459,11 @@ class Spoiler():
|
|||||||
item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
|
item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
|
||||||
item)) for shop in self.shops))
|
item)) for shop in self.shops))
|
||||||
|
|
||||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
for player in self.world.get_game_players("A Link to the Past"):
|
||||||
if self.multiworld.boss_shuffle[player] != 'none':
|
if self.world.boss_shuffle[player] != 'none':
|
||||||
bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses
|
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
|
||||||
outfile.write(
|
outfile.write(
|
||||||
f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n')
|
f'\n\nBosses{(f" ({self.world.get_player_name(player)})" if self.world.players > 1 else "")}:\n')
|
||||||
outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
|
outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
|
||||||
outfile.write('\n\nPlaythrough:\n\n')
|
outfile.write('\n\nPlaythrough:\n\n')
|
||||||
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
|
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
|
||||||
@@ -1513,7 +1487,7 @@ class Spoiler():
|
|||||||
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
||||||
|
|
||||||
outfile.write('\n'.join(path_listings))
|
outfile.write('\n'.join(path_listings))
|
||||||
AutoWorld.call_all(self.multiworld, "write_spoiler_end", outfile)
|
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
|
||||||
|
|
||||||
|
|
||||||
class Tutorial(NamedTuple):
|
class Tutorial(NamedTuple):
|
||||||
|
|||||||
161
CommonClient.py
161
CommonClient.py
@@ -20,13 +20,10 @@ if __name__ == "__main__":
|
|||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor
|
||||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||||
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||||
from Utils import Version, stream_input, async_start
|
from Utils import Version, stream_input
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
import kvui
|
|
||||||
|
|
||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
|
|
||||||
# without terminal, we have to use gui mode
|
# without terminal, we have to use gui mode
|
||||||
@@ -47,18 +44,16 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_connect(self, address: str = "") -> bool:
|
def _cmd_connect(self, address: str = "") -> bool:
|
||||||
"""Connect to a MultiWorld Server"""
|
"""Connect to a MultiWorld Server"""
|
||||||
if address:
|
self.ctx.server_address = None
|
||||||
self.ctx.server_address = None
|
self.ctx.username = None
|
||||||
self.ctx.username = None
|
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
||||||
elif not self.ctx.server_address:
|
|
||||||
self.output("Please specify an address.")
|
|
||||||
return False
|
|
||||||
async_start(self.ctx.connect(address if address else None), name="connecting")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_disconnect(self) -> bool:
|
def _cmd_disconnect(self) -> bool:
|
||||||
"""Disconnect from a MultiWorld Server"""
|
"""Disconnect from a MultiWorld Server"""
|
||||||
async_start(self.ctx.disconnect(), name="disconnecting")
|
self.ctx.server_address = None
|
||||||
|
self.ctx.username = None
|
||||||
|
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_received(self) -> bool:
|
def _cmd_received(self) -> bool:
|
||||||
@@ -96,18 +91,12 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_items(self):
|
def _cmd_items(self):
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
if not self.ctx.game:
|
|
||||||
self.output("No game set, cannot determine existing items.")
|
|
||||||
return False
|
|
||||||
self.output(f"Item Names for {self.ctx.game}")
|
self.output(f"Item Names for {self.ctx.game}")
|
||||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||||
self.output(item_name)
|
self.output(item_name)
|
||||||
|
|
||||||
def _cmd_locations(self):
|
def _cmd_locations(self):
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
if not self.ctx.game:
|
|
||||||
self.output("No game set, cannot determine existing locations.")
|
|
||||||
return False
|
|
||||||
self.output(f"Location Names for {self.ctx.game}")
|
self.output(f"Location Names for {self.ctx.game}")
|
||||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||||
self.output(location_name)
|
self.output(location_name)
|
||||||
@@ -121,12 +110,12 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
else:
|
else:
|
||||||
state = ClientStatus.CLIENT_CONNECTED
|
state = ClientStatus.CLIENT_CONNECTED
|
||||||
self.output("Unreadied.")
|
self.output("Unreadied.")
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||||
|
|
||||||
def default(self, raw: str):
|
def default(self, raw: str):
|
||||||
raw = self.ctx.on_user_say(raw)
|
raw = self.ctx.on_user_say(raw)
|
||||||
if raw:
|
if raw:
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||||
|
|
||||||
|
|
||||||
class CommonContext:
|
class CommonContext:
|
||||||
@@ -143,36 +132,28 @@ class CommonContext:
|
|||||||
# defaults
|
# defaults
|
||||||
starting_reconnect_delay: int = 5
|
starting_reconnect_delay: int = 5
|
||||||
current_reconnect_delay: int = starting_reconnect_delay
|
current_reconnect_delay: int = starting_reconnect_delay
|
||||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
command_processor: type(CommandProcessor) = ClientCommandProcessor
|
||||||
ui = None
|
ui = None
|
||||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
ui_task: typing.Optional[asyncio.Task] = None
|
||||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
input_task: typing.Optional[asyncio.Task] = None
|
||||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
keep_alive_task: typing.Optional[asyncio.Task] = None
|
||||||
server_task: typing.Optional["asyncio.Task[None]"] = None
|
server_task: typing.Optional[asyncio.Task] = None
|
||||||
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
|
|
||||||
disconnected_intentionally: bool = False
|
|
||||||
server: typing.Optional[Endpoint] = None
|
server: typing.Optional[Endpoint] = None
|
||||||
server_version: Version = Version(0, 0, 0)
|
server_version: Version = Version(0, 0, 0)
|
||||||
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
|
current_energy_link_value: int = 0 # to display in UI, gets set by server
|
||||||
|
|
||||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
server_address: typing.Optional[str]
|
server_address: str
|
||||||
password: typing.Optional[str]
|
password: typing.Optional[str]
|
||||||
hint_cost: typing.Optional[int]
|
hint_cost: typing.Optional[int]
|
||||||
player_names: typing.Dict[int, str]
|
player_names: typing.Dict[int, str]
|
||||||
|
|
||||||
finished_game: bool
|
|
||||||
ready: bool
|
|
||||||
auth: typing.Optional[str]
|
|
||||||
seed_name: typing.Optional[str]
|
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: typing.Set[int] # local state
|
locations_checked: typing.Set[int] # local state
|
||||||
locations_scouted: typing.Set[int]
|
locations_scouted: typing.Set[int]
|
||||||
items_received: typing.List[NetworkItem]
|
|
||||||
missing_locations: typing.Set[int] # server state
|
missing_locations: typing.Set[int] # server state
|
||||||
checked_locations: typing.Set[int] # server state
|
checked_locations: typing.Set[int] # server state
|
||||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||||
@@ -180,11 +161,9 @@ class CommonContext:
|
|||||||
|
|
||||||
# internals
|
# internals
|
||||||
# current message box through kvui
|
# current message box through kvui
|
||||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
_messagebox = None
|
||||||
# message box reporting a loss of connection
|
|
||||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
|
def __init__(self, server_address, password):
|
||||||
# server state
|
# server state
|
||||||
self.server_address = server_address
|
self.server_address = server_address
|
||||||
self.username = None
|
self.username = None
|
||||||
@@ -227,12 +206,6 @@ class CommonContext:
|
|||||||
# execution
|
# execution
|
||||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||||
|
|
||||||
@property
|
|
||||||
def suggested_address(self) -> str:
|
|
||||||
if self.server_address:
|
|
||||||
return self.server_address
|
|
||||||
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
|
|
||||||
|
|
||||||
@functools.cached_property
|
@functools.cached_property
|
||||||
def raw_text_parser(self) -> RawJSONtoTextParser:
|
def raw_text_parser(self) -> RawJSONtoTextParser:
|
||||||
return RawJSONtoTextParser(self)
|
return RawJSONtoTextParser(self)
|
||||||
@@ -244,9 +217,9 @@ class CommonContext:
|
|||||||
return len(self.checked_locations | self.missing_locations)
|
return len(self.checked_locations | self.missing_locations)
|
||||||
|
|
||||||
async def connection_closed(self):
|
async def connection_closed(self):
|
||||||
|
self.reset_server_state()
|
||||||
if self.server and self.server.socket is not None:
|
if self.server and self.server.socket is not None:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
self.reset_server_state()
|
|
||||||
|
|
||||||
def reset_server_state(self):
|
def reset_server_state(self):
|
||||||
self.auth = None
|
self.auth = None
|
||||||
@@ -264,18 +237,13 @@ class CommonContext:
|
|||||||
"remaining": "disabled",
|
"remaining": "disabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
async def disconnect(self):
|
||||||
if not allow_autoreconnect:
|
|
||||||
self.disconnected_intentionally = True
|
|
||||||
if self.cancel_autoreconnect():
|
|
||||||
logger.info("Cancelled auto-reconnect.")
|
|
||||||
if self.server and not self.server.socket.closed:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
|
|
||||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
async def send_msgs(self, msgs):
|
||||||
""" `msgs` JSON serializable """
|
|
||||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||||
return
|
return
|
||||||
await self.server.socket.send(encode(msgs))
|
await self.server.socket.send(encode(msgs))
|
||||||
@@ -303,8 +271,7 @@ class CommonContext:
|
|||||||
logger.info('Enter slot name:')
|
logger.info('Enter slot name:')
|
||||||
self.auth = await self.console_input()
|
self.auth = await self.console_input()
|
||||||
|
|
||||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
async def send_connect(self, **kwargs):
|
||||||
""" send `Connect` packet to log in to server """
|
|
||||||
payload = {
|
payload = {
|
||||||
'cmd': 'Connect',
|
'cmd': 'Connect',
|
||||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||||
@@ -315,24 +282,14 @@ class CommonContext:
|
|||||||
payload.update(kwargs)
|
payload.update(kwargs)
|
||||||
await self.send_msgs([payload])
|
await self.send_msgs([payload])
|
||||||
|
|
||||||
async def console_input(self) -> str:
|
async def console_input(self):
|
||||||
if self.ui:
|
|
||||||
self.ui.focus_textinput()
|
|
||||||
self.input_requests += 1
|
self.input_requests += 1
|
||||||
return await self.input_queue.get()
|
return await self.input_queue.get()
|
||||||
|
|
||||||
async def connect(self, address: typing.Optional[str] = None) -> None:
|
async def connect(self, address=None):
|
||||||
""" disconnect any previous connection, and open new connection to the server """
|
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||||
|
|
||||||
def cancel_autoreconnect(self) -> bool:
|
|
||||||
if self.autoreconnect_task:
|
|
||||||
self.autoreconnect_task.cancel()
|
|
||||||
self.autoreconnect_task = None
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def slot_concerns_self(self, slot) -> bool:
|
def slot_concerns_self(self, slot) -> bool:
|
||||||
if slot == self.slot:
|
if slot == self.slot:
|
||||||
return True
|
return True
|
||||||
@@ -340,12 +297,6 @@ class CommonContext:
|
|||||||
return self.slot in self.slot_info[slot].group_members
|
return self.slot in self.slot_info[slot].group_members
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
|
|
||||||
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
|
|
||||||
return print_json_packet.get("type", "") == "ItemSend" \
|
|
||||||
and not self.slot_concerns_self(print_json_packet["receiving"]) \
|
|
||||||
and not self.slot_concerns_self(print_json_packet["item"].player)
|
|
||||||
|
|
||||||
def on_print(self, args: dict):
|
def on_print(self, args: dict):
|
||||||
logger.info(args["text"])
|
logger.info(args["text"])
|
||||||
|
|
||||||
@@ -377,7 +328,6 @@ class CommonContext:
|
|||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
self.server_address = ""
|
self.server_address = ""
|
||||||
self.username = None
|
self.username = None
|
||||||
self.cancel_autoreconnect()
|
|
||||||
if self.server and not self.server.socket.closed:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task:
|
if self.server_task:
|
||||||
@@ -440,7 +390,7 @@ class CommonContext:
|
|||||||
|
|
||||||
# DeathLink hooks
|
# DeathLink hooks
|
||||||
|
|
||||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
def on_deathlink(self, data: dict):
|
||||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||||
self.last_death_link = max(data["time"], self.last_death_link)
|
self.last_death_link = max(data["time"], self.last_death_link)
|
||||||
text = data.get("cause", "")
|
text = data.get("cause", "")
|
||||||
@@ -471,10 +421,10 @@ class CommonContext:
|
|||||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||||
|
|
||||||
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
def gui_error(self, title: str, text: typing.Union[Exception, str]):
|
||||||
"""Displays an error messagebox"""
|
"""Displays an error messagebox"""
|
||||||
if not self.ui:
|
if not self.ui:
|
||||||
return None
|
return
|
||||||
title = title or "Error"
|
title = title or "Error"
|
||||||
from kvui import MessageBox
|
from kvui import MessageBox
|
||||||
if self._messagebox:
|
if self._messagebox:
|
||||||
@@ -491,13 +441,6 @@ class CommonContext:
|
|||||||
# display error
|
# display error
|
||||||
self._messagebox = MessageBox(title, text, error=True)
|
self._messagebox = MessageBox(title, text, error=True)
|
||||||
self._messagebox.open()
|
self._messagebox.open()
|
||||||
return self._messagebox
|
|
||||||
|
|
||||||
def _handle_connection_loss(self, msg: str) -> None:
|
|
||||||
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
|
|
||||||
exc_info = sys.exc_info()
|
|
||||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
|
||||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
"""Import kivy UI system and start running it as self.ui_task."""
|
||||||
@@ -534,7 +477,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
|||||||
seconds_elapsed = 0
|
seconds_elapsed = 0
|
||||||
|
|
||||||
|
|
||||||
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None:
|
async def server_loop(ctx: CommonContext, address=None):
|
||||||
if ctx.server and ctx.server.socket:
|
if ctx.server and ctx.server.socket:
|
||||||
logger.error('Already connected')
|
logger.error('Already connected')
|
||||||
return
|
return
|
||||||
@@ -547,11 +490,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
logger.info('Please connect to an Archipelago server.')
|
logger.info('Please connect to an Archipelago server.')
|
||||||
return
|
return
|
||||||
|
|
||||||
ctx.cancel_autoreconnect()
|
|
||||||
if ctx._messagebox_connection_loss:
|
|
||||||
ctx._messagebox_connection_loss.dismiss()
|
|
||||||
ctx._messagebox_connection_loss = None
|
|
||||||
|
|
||||||
address = f"ws://{address}" if "://" not in address \
|
address = f"ws://{address}" if "://" not in address \
|
||||||
else address.replace("archipelago://", "ws://")
|
else address.replace("archipelago://", "ws://")
|
||||||
|
|
||||||
@@ -562,9 +500,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
ctx.password = server_url.password
|
ctx.password = server_url.password
|
||||||
port = server_url.port or 38281
|
port = server_url.port or 38281
|
||||||
|
|
||||||
def reconnect_hint() -> str:
|
|
||||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
|
||||||
|
|
||||||
logger.info(f'Connecting to Archipelago server at {address}')
|
logger.info(f'Connecting to Archipelago server at {address}')
|
||||||
try:
|
try:
|
||||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||||
@@ -574,25 +509,31 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
logger.info('Connected')
|
logger.info('Connected')
|
||||||
ctx.server_address = address
|
ctx.server_address = address
|
||||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||||
ctx.disconnected_intentionally = False
|
|
||||||
async for data in ctx.server.socket:
|
async for data in ctx.server.socket:
|
||||||
for msg in decode(data):
|
for msg in decode(data):
|
||||||
await process_server_cmd(ctx, msg)
|
await process_server_cmd(ctx, msg)
|
||||||
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
|
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError as e:
|
||||||
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.")
|
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
|
||||||
except websockets.InvalidURI:
|
logger.exception(msg, extra={'compact_gui': True})
|
||||||
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
ctx.gui_error(msg, e)
|
||||||
except OSError:
|
except websockets.InvalidURI as e:
|
||||||
ctx._handle_connection_loss("Failed to connect to the multiworld server")
|
msg = 'Failed to connect to the multiworld server (invalid URI)'
|
||||||
except Exception:
|
logger.exception(msg, extra={'compact_gui': True})
|
||||||
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
ctx.gui_error(msg, e)
|
||||||
|
except OSError as e:
|
||||||
|
msg = 'Failed to connect to the multiworld server'
|
||||||
|
logger.exception(msg, extra={'compact_gui': True})
|
||||||
|
ctx.gui_error(msg, e)
|
||||||
|
except Exception as e:
|
||||||
|
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
|
||||||
|
logger.exception(msg, extra={'compact_gui': True})
|
||||||
|
ctx.gui_error(msg, e)
|
||||||
finally:
|
finally:
|
||||||
await ctx.connection_closed()
|
await ctx.connection_closed()
|
||||||
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
|
if ctx.server_address:
|
||||||
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
|
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
||||||
assert ctx.autoreconnect_task is None
|
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||||
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
|
||||||
ctx.current_reconnect_delay *= 2
|
ctx.current_reconnect_delay *= 2
|
||||||
|
|
||||||
|
|
||||||
@@ -703,9 +644,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.checked_locations = set(args["checked_locations"])
|
ctx.checked_locations = set(args["checked_locations"])
|
||||||
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
||||||
|
|
||||||
server_url = urllib.parse.urlparse(ctx.server_address)
|
|
||||||
Utils.persistent_store("client", "last_server_address", server_url.netloc)
|
|
||||||
|
|
||||||
elif cmd == 'ReceivedItems':
|
elif cmd == 'ReceivedItems':
|
||||||
start_index = args["index"]
|
start_index = args["index"]
|
||||||
|
|
||||||
@@ -784,7 +722,7 @@ async def console_loop(ctx: CommonContext):
|
|||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
|
||||||
|
|
||||||
def get_base_parser(description: typing.Optional[str] = None):
|
def get_base_parser(description=None):
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description=description)
|
parser = argparse.ArgumentParser(description=description)
|
||||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||||
@@ -816,6 +754,7 @@ if __name__ == '__main__':
|
|||||||
async def main(args):
|
async def main(args):
|
||||||
ctx = TextContext(args.connect, args.password)
|
ctx = TextContext(args.connect, args.password)
|
||||||
ctx.auth = args.name
|
ctx.auth = args.name
|
||||||
|
ctx.server_address = args.connect
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
|
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
get_base_parser
|
get_base_parser
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ class FF1Context(CommonContext):
|
|||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
async_start(parse_locations(self.locations_array, self, True))
|
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
||||||
elif cmd == 'Print':
|
elif cmd == 'Print':
|
||||||
msg = args['text']
|
msg = args['text']
|
||||||
if ': !' not in msg:
|
if ': !' not in msg:
|
||||||
@@ -181,7 +180,7 @@ async def nes_sync_task(ctx: FF1Context):
|
|||||||
# print(data_decoded)
|
# print(data_decoded)
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
||||||
if not ctx.auth:
|
if not ctx.auth:
|
||||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||||
if ctx.auth == '':
|
if ctx.auth == '':
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ import logging
|
|||||||
import json
|
import json
|
||||||
import string
|
import string
|
||||||
import copy
|
import copy
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
import typing
|
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -20,18 +17,12 @@ import asyncio
|
|||||||
from queue import Queue
|
from queue import Queue
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
def check_stdin() -> None:
|
|
||||||
if Utils.is_windows and sys.stdin:
|
|
||||||
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||||
check_stdin()
|
|
||||||
|
|
||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||||
from Utils import async_start
|
|
||||||
|
|
||||||
from worlds.factorio import Factorio
|
from worlds.factorio import Factorio
|
||||||
|
|
||||||
@@ -39,10 +30,6 @@ from worlds.factorio import Factorio
|
|||||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||||
ctx: FactorioContext
|
ctx: FactorioContext
|
||||||
|
|
||||||
def _cmd_energy_link(self):
|
|
||||||
"""Print the status of the energy link."""
|
|
||||||
self.output(f"Energy Link: {self.ctx.energy_link_status}")
|
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_factorio(self, text: str) -> bool:
|
def _cmd_factorio(self, text: str) -> bool:
|
||||||
"""Send the following command to the bound Factorio Server."""
|
"""Send the following command to the bound Factorio Server."""
|
||||||
@@ -59,13 +46,6 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
|||||||
"""Manually trigger a resync."""
|
"""Manually trigger a resync."""
|
||||||
self.ctx.awaiting_bridge = True
|
self.ctx.awaiting_bridge = True
|
||||||
|
|
||||||
def _cmd_toggle_send_filter(self):
|
|
||||||
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
|
|
||||||
self.ctx.toggle_filter_item_sends()
|
|
||||||
|
|
||||||
def _cmd_toggle_chat(self):
|
|
||||||
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
|
|
||||||
self.ctx.toggle_bridge_chat_out()
|
|
||||||
|
|
||||||
class FactorioContext(CommonContext):
|
class FactorioContext(CommonContext):
|
||||||
command_processor = FactorioCommandProcessor
|
command_processor = FactorioCommandProcessor
|
||||||
@@ -85,9 +65,6 @@ class FactorioContext(CommonContext):
|
|||||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||||
self.energy_link_increment = 0
|
self.energy_link_increment = 0
|
||||||
self.last_deplete = 0
|
self.last_deplete = 0
|
||||||
self.filter_item_sends: bool = False
|
|
||||||
self.multiplayer: bool = False # whether multiple different players have connected
|
|
||||||
self.bridge_chat_out: bool = True
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -104,15 +81,12 @@ class FactorioContext(CommonContext):
|
|||||||
def on_print(self, args: dict):
|
def on_print(self, args: dict):
|
||||||
super(FactorioContext, self).on_print(args)
|
super(FactorioContext, self).on_print(args)
|
||||||
if self.rcon_client:
|
if self.rcon_client:
|
||||||
if not args['text'].startswith(self.player_names[self.slot] + ":"):
|
self.print_to_game(args['text'])
|
||||||
self.print_to_game(args['text'])
|
|
||||||
|
|
||||||
def on_print_json(self, args: dict):
|
def on_print_json(self, args: dict):
|
||||||
if self.rcon_client:
|
if self.rcon_client:
|
||||||
if not self.filter_item_sends or not self.is_uninteresting_item_send(args):
|
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
self.print_to_game(text)
|
||||||
if not text.startswith(self.player_names[self.slot] + ":"):
|
|
||||||
self.print_to_game(text)
|
|
||||||
super(FactorioContext, self).on_print_json(args)
|
super(FactorioContext, self).on_print_json(args)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -123,15 +97,6 @@ class FactorioContext(CommonContext):
|
|||||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||||
f"{text}")
|
f"{text}")
|
||||||
|
|
||||||
@property
|
|
||||||
def energy_link_status(self) -> str:
|
|
||||||
if not self.energy_link_increment:
|
|
||||||
return "Disabled"
|
|
||||||
elif self.current_energy_link_value is None:
|
|
||||||
return "Standby"
|
|
||||||
else:
|
|
||||||
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
|
|
||||||
|
|
||||||
def on_deathlink(self, data: dict):
|
def on_deathlink(self, data: dict):
|
||||||
if self.rcon_client:
|
if self.rcon_client:
|
||||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||||
@@ -144,7 +109,7 @@ class FactorioContext(CommonContext):
|
|||||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||||
item_name in args["checked_locations"]})
|
item_name in args["checked_locations"]})
|
||||||
if cmd == "Connected" and self.energy_link_increment:
|
if cmd == "Connected" and self.energy_link_increment:
|
||||||
async_start(self.send_msgs([{
|
asyncio.create_task(self.send_msgs([{
|
||||||
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
||||||
}]))
|
}]))
|
||||||
elif cmd == "SetReply":
|
elif cmd == "SetReply":
|
||||||
@@ -158,45 +123,6 @@ class FactorioContext(CommonContext):
|
|||||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||||
|
|
||||||
def on_user_say(self, text: str) -> typing.Optional[str]:
|
|
||||||
# Mirror chat sent from the UI to the Factorio server.
|
|
||||||
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
|
|
||||||
return text
|
|
||||||
|
|
||||||
async def chat_from_factorio(self, user: str, message: str) -> None:
|
|
||||||
if not self.bridge_chat_out:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Pass through commands
|
|
||||||
if message.startswith("!"):
|
|
||||||
await self.send_msgs([{"cmd": "Say", "text": message}])
|
|
||||||
return
|
|
||||||
|
|
||||||
# Omit messages that contain local coordinates
|
|
||||||
if "[gps=" in message:
|
|
||||||
return
|
|
||||||
|
|
||||||
prefix = f"({user}) " if self.multiplayer else ""
|
|
||||||
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
|
|
||||||
|
|
||||||
def toggle_filter_item_sends(self) -> None:
|
|
||||||
self.filter_item_sends = not self.filter_item_sends
|
|
||||||
if self.filter_item_sends:
|
|
||||||
announcement = "Item sends are now filtered."
|
|
||||||
else:
|
|
||||||
announcement = "Item sends are no longer filtered."
|
|
||||||
logger.info(announcement)
|
|
||||||
self.print_to_game(announcement)
|
|
||||||
|
|
||||||
def toggle_bridge_chat_out(self) -> None:
|
|
||||||
self.bridge_chat_out = not self.bridge_chat_out
|
|
||||||
if self.bridge_chat_out:
|
|
||||||
announcement = "Chat is now bridged to Archipelago."
|
|
||||||
else:
|
|
||||||
announcement = "Chat is no longer bridged to Archipelago."
|
|
||||||
logger.info(announcement)
|
|
||||||
self.print_to_game(announcement)
|
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
|
|
||||||
@@ -214,6 +140,7 @@ class FactorioContext(CommonContext):
|
|||||||
|
|
||||||
async def game_watcher(ctx: FactorioContext):
|
async def game_watcher(ctx: FactorioContext):
|
||||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||||
|
from worlds.factorio.Technologies import lookup_id_to_name
|
||||||
next_bridge = time.perf_counter() + 1
|
next_bridge = time.perf_counter() + 1
|
||||||
try:
|
try:
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
@@ -235,7 +162,6 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||||
victory = data["victory"]
|
victory = data["victory"]
|
||||||
await ctx.update_death_link(data["death_link"])
|
await ctx.update_death_link(data["death_link"])
|
||||||
ctx.multiplayer = data.get("multiplayer", False)
|
|
||||||
|
|
||||||
if not ctx.finished_game and victory:
|
if not ctx.finished_game and victory:
|
||||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||||
@@ -244,14 +170,14 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
if ctx.locations_checked != research_data:
|
if ctx.locations_checked != research_data:
|
||||||
bridge_logger.debug(
|
bridge_logger.debug(
|
||||||
f"New researches done: "
|
f"New researches done: "
|
||||||
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}")
|
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
|
||||||
ctx.locations_checked = research_data
|
ctx.locations_checked = research_data
|
||||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||||
death_link_tick = data.get("death_link_tick", 0)
|
death_link_tick = data.get("death_link_tick", 0)
|
||||||
if death_link_tick != ctx.death_link_tick:
|
if death_link_tick != ctx.death_link_tick:
|
||||||
ctx.death_link_tick = death_link_tick
|
ctx.death_link_tick = death_link_tick
|
||||||
if "DeathLink" in ctx.tags:
|
if "DeathLink" in ctx.tags:
|
||||||
async_start(ctx.send_death())
|
asyncio.create_task(ctx.send_death())
|
||||||
if ctx.energy_link_increment:
|
if ctx.energy_link_increment:
|
||||||
in_world_bridges = data["energy_bridges"]
|
in_world_bridges = data["energy_bridges"]
|
||||||
if in_world_bridges:
|
if in_world_bridges:
|
||||||
@@ -259,7 +185,7 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
||||||
# attempt to refill
|
# attempt to refill
|
||||||
ctx.last_deplete = time.time()
|
ctx.last_deplete = time.time()
|
||||||
async_start(ctx.send_msgs([{
|
asyncio.create_task(ctx.send_msgs([{
|
||||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||||
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
||||||
{"operation": "max", "value": 0}],
|
{"operation": "max", "value": 0}],
|
||||||
@@ -269,7 +195,7 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
||||||
ctx.energy_link_increment*in_world_bridges:
|
ctx.energy_link_increment*in_world_bridges:
|
||||||
value = ctx.energy_link_increment * in_world_bridges
|
value = ctx.energy_link_increment * in_world_bridges
|
||||||
async_start(ctx.send_msgs([{
|
asyncio.create_task(ctx.send_msgs([{
|
||||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||||
[{"operation": "add", "value": value}]
|
[{"operation": "add", "value": value}]
|
||||||
}]))
|
}]))
|
||||||
@@ -285,8 +211,6 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
|
|
||||||
|
|
||||||
def stream_factorio_output(pipe, queue, process):
|
def stream_factorio_output(pipe, queue, process):
|
||||||
pipe.reconfigure(errors="replace")
|
|
||||||
|
|
||||||
def queuer():
|
def queuer():
|
||||||
while process.poll() is None:
|
while process.poll() is None:
|
||||||
text = pipe.readline().strip()
|
text = pipe.readline().strip()
|
||||||
@@ -319,7 +243,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
|||||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||||
try:
|
try:
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
if factorio_process.poll() is not None:
|
if factorio_process.poll():
|
||||||
factorio_server_logger.info("Factorio server has exited.")
|
factorio_server_logger.info("Factorio server has exited.")
|
||||||
ctx.exit_event.set()
|
ctx.exit_event.set()
|
||||||
|
|
||||||
@@ -332,25 +256,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
|||||||
if not ctx.server:
|
if not ctx.server:
|
||||||
logger.info("Established bridge to Factorio Server. "
|
logger.info("Established bridge to Factorio Server. "
|
||||||
"Ready to connect to Archipelago via /connect")
|
"Ready to connect to Archipelago via /connect")
|
||||||
check_stdin()
|
|
||||||
|
|
||||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||||
ctx.awaiting_bridge = True
|
ctx.awaiting_bridge = True
|
||||||
factorio_server_logger.debug(msg)
|
factorio_server_logger.debug(msg)
|
||||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
|
|
||||||
factorio_server_logger.debug(msg)
|
|
||||||
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
|
|
||||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
|
|
||||||
factorio_server_logger.debug(msg)
|
|
||||||
ctx.toggle_filter_item_sends()
|
|
||||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
|
|
||||||
factorio_server_logger.debug(msg)
|
|
||||||
ctx.toggle_bridge_chat_out()
|
|
||||||
else:
|
else:
|
||||||
factorio_server_logger.info(msg)
|
factorio_server_logger.info(msg)
|
||||||
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
|
|
||||||
if match:
|
|
||||||
await ctx.chat_from_factorio(match.group(1), match.group(2))
|
|
||||||
if ctx.rcon_client:
|
if ctx.rcon_client:
|
||||||
commands = {}
|
commands = {}
|
||||||
while ctx.send_index < len(ctx.items_received):
|
while ctx.send_index < len(ctx.items_received):
|
||||||
@@ -371,34 +282,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
logging.error("Aborted Factorio Server Bridge")
|
logging.error("Aborted Factorio Server Bridge")
|
||||||
|
ctx.rcon_client = None
|
||||||
ctx.exit_event.set()
|
ctx.exit_event.set()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if factorio_process.poll() is not None:
|
factorio_process.terminate()
|
||||||
if ctx.rcon_client:
|
factorio_process.wait(5)
|
||||||
ctx.rcon_client.close()
|
|
||||||
ctx.rcon_client = None
|
|
||||||
return
|
|
||||||
|
|
||||||
sent_quit = False
|
|
||||||
if ctx.rcon_client:
|
|
||||||
# Attempt clean quit through RCON.
|
|
||||||
try:
|
|
||||||
ctx.rcon_client.send_command("/quit")
|
|
||||||
except factorio_rcon.RCONNetworkError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
sent_quit = True
|
|
||||||
ctx.rcon_client.close()
|
|
||||||
ctx.rcon_client = None
|
|
||||||
if not sent_quit:
|
|
||||||
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
|
|
||||||
factorio_process.terminate()
|
|
||||||
|
|
||||||
try:
|
|
||||||
factorio_process.wait(10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
factorio_process.kill()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
||||||
@@ -472,8 +361,6 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
|||||||
|
|
||||||
async def main(args):
|
async def main(args):
|
||||||
ctx = FactorioContext(args.connect, args.password)
|
ctx = FactorioContext(args.connect, args.password)
|
||||||
ctx.filter_item_sends = initial_filter_item_sends
|
|
||||||
ctx.bridge_chat_out = initial_bridge_chat_out
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||||
|
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
@@ -526,12 +413,6 @@ if __name__ == '__main__':
|
|||||||
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||||
if server_settings:
|
if server_settings:
|
||||||
server_settings = os.path.abspath(server_settings)
|
server_settings = os.path.abspath(server_settings)
|
||||||
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
|
|
||||||
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
|
|
||||||
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
|
|
||||||
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
|
|
||||||
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
|
|
||||||
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
|
|
||||||
|
|
||||||
if not os.path.exists(os.path.dirname(executable)):
|
if not os.path.exists(os.path.dirname(executable)):
|
||||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||||
|
|||||||
320
Fill.py
320
Fill.py
@@ -4,10 +4,9 @@ import collections
|
|||||||
import itertools
|
import itertools
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification
|
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
|
||||||
|
|
||||||
from worlds.AutoWorld import call_all
|
from worlds.AutoWorld import call_all
|
||||||
from worlds.generic.Rules import add_item_rule
|
|
||||||
|
|
||||||
|
|
||||||
class FillError(RuntimeError):
|
class FillError(RuntimeError):
|
||||||
@@ -23,8 +22,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
|||||||
|
|
||||||
|
|
||||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
|
||||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
|
|
||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
|
|
||||||
@@ -71,66 +69,60 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# we filled all reachable spots.
|
# we filled all reachable spots.
|
||||||
if swap:
|
# try swapping this item with previously placed items
|
||||||
# try swapping this item with previously placed items
|
for (i, location) in enumerate(placements):
|
||||||
for (i, location) in enumerate(placements):
|
placed_item = location.item
|
||||||
placed_item = location.item
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
# number of times we will swap an individual item to prevent this
|
||||||
# number of times we will swap an individual item to prevent this
|
swap_count = swapped_items[placed_item.player,
|
||||||
swap_count = swapped_items[placed_item.player,
|
placed_item.name]
|
||||||
placed_item.name]
|
if swap_count > 1:
|
||||||
if swap_count > 1:
|
|
||||||
continue
|
|
||||||
|
|
||||||
location.item = None
|
|
||||||
placed_item.location = None
|
|
||||||
swap_state = sweep_from_pool(base_state, [placed_item])
|
|
||||||
# swap_state assumes we can collect placed item before item_to_place
|
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
|
||||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
|
||||||
|
|
||||||
# Verify that placing this item won't reduce available locations, which could happen with rules
|
|
||||||
# that want to not have both items. Left in until removal is proven useful.
|
|
||||||
prev_state = swap_state.copy()
|
|
||||||
prev_loc_count = len(
|
|
||||||
world.get_reachable_locations(prev_state))
|
|
||||||
|
|
||||||
swap_state.collect(item_to_place, True)
|
|
||||||
new_loc_count = len(
|
|
||||||
world.get_reachable_locations(swap_state))
|
|
||||||
|
|
||||||
if new_loc_count >= prev_loc_count:
|
|
||||||
# Add this item to the existing placement, and
|
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swap_count += 1
|
|
||||||
swapped_items[placed_item.player,
|
|
||||||
placed_item.name] = swap_count
|
|
||||||
|
|
||||||
reachable_items[placed_item.player].appendleft(
|
|
||||||
placed_item)
|
|
||||||
itempool.append(placed_item)
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
|
||||||
location.item = placed_item
|
|
||||||
placed_item.location = location
|
|
||||||
|
|
||||||
if spot_to_fill is None:
|
|
||||||
# Can't place this item, move on to the next
|
|
||||||
unplaced_items.append(item_to_place)
|
|
||||||
continue
|
continue
|
||||||
else:
|
|
||||||
|
location.item = None
|
||||||
|
placed_item.location = None
|
||||||
|
swap_state = sweep_from_pool(base_state)
|
||||||
|
if (not single_player_placement or location.player == item_to_place.player) \
|
||||||
|
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||||
|
|
||||||
|
# Verify that placing this item won't reduce available locations
|
||||||
|
prev_state = swap_state.copy()
|
||||||
|
prev_state.collect(placed_item)
|
||||||
|
prev_loc_count = len(
|
||||||
|
world.get_reachable_locations(prev_state))
|
||||||
|
|
||||||
|
swap_state.collect(item_to_place, True)
|
||||||
|
new_loc_count = len(
|
||||||
|
world.get_reachable_locations(swap_state))
|
||||||
|
|
||||||
|
if new_loc_count >= prev_loc_count:
|
||||||
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
|
swap_count += 1
|
||||||
|
swapped_items[placed_item.player,
|
||||||
|
placed_item.name] = swap_count
|
||||||
|
|
||||||
|
reachable_items[placed_item.player].appendleft(
|
||||||
|
placed_item)
|
||||||
|
itempool.append(placed_item)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
# Item can't be placed here, restore original item
|
||||||
|
location.item = placed_item
|
||||||
|
placed_item.location = location
|
||||||
|
|
||||||
|
if spot_to_fill is None:
|
||||||
|
# Can't place this item, move on to the next
|
||||||
unplaced_items.append(item_to_place)
|
unplaced_items.append(item_to_place)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
world.push_item(spot_to_fill, item_to_place, False)
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
spot_to_fill.locked = lock
|
spot_to_fill.locked = lock
|
||||||
placements.append(spot_to_fill)
|
placements.append(spot_to_fill)
|
||||||
spot_to_fill.event = item_to_place.advancement
|
spot_to_fill.event = item_to_place.advancement
|
||||||
if on_place:
|
|
||||||
on_place(spot_to_fill)
|
|
||||||
|
|
||||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||||
# There are leftover unplaceable items and locations that won't accept them
|
# There are leftover unplaceable items and locations that won't accept them
|
||||||
@@ -217,121 +209,12 @@ def fast_fill(world: MultiWorld,
|
|||||||
return item_pool[placing:], fill_locations[placing:]
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
|
|
||||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
|
||||||
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
|
|
||||||
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
|
|
||||||
not location.can_reach(maximum_exploration_state)]
|
|
||||||
for location in unreachable_locations:
|
|
||||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
|
||||||
location.locked and location.item.player not in minimal_players):
|
|
||||||
pool.append(location.item)
|
|
||||||
state.remove(location.item)
|
|
||||||
location.item = None
|
|
||||||
location.event = False
|
|
||||||
if location in state.events:
|
|
||||||
state.events.remove(location)
|
|
||||||
locations.append(location)
|
|
||||||
if pool and locations:
|
|
||||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
|
||||||
fill_restrictive(world, state, locations, pool)
|
|
||||||
|
|
||||||
|
|
||||||
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
|
|
||||||
maximum_exploration_state = sweep_from_pool(state)
|
|
||||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
|
||||||
if unreachable_locations:
|
|
||||||
def forbid_important_item_rule(item: Item):
|
|
||||||
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
|
|
||||||
|
|
||||||
for location in unreachable_locations:
|
|
||||||
add_item_rule(location, forbid_important_item_rule)
|
|
||||||
|
|
||||||
|
|
||||||
def distribute_early_items(world: MultiWorld,
|
|
||||||
fill_locations: typing.List[Location],
|
|
||||||
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
|
||||||
""" returns new fill_locations and itempool """
|
|
||||||
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
|
|
||||||
for player in world.player_ids:
|
|
||||||
items = itertools.chain(world.early_items[player], world.local_early_items[player])
|
|
||||||
for item in items:
|
|
||||||
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
|
|
||||||
if early_items_count:
|
|
||||||
early_locations: typing.List[Location] = []
|
|
||||||
early_priority_locations: typing.List[Location] = []
|
|
||||||
loc_indexes_to_remove: typing.Set[int] = set()
|
|
||||||
base_state = world.state.copy()
|
|
||||||
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
|
|
||||||
for i, loc in enumerate(fill_locations):
|
|
||||||
if loc.can_reach(base_state):
|
|
||||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
|
||||||
early_priority_locations.append(loc)
|
|
||||||
else:
|
|
||||||
early_locations.append(loc)
|
|
||||||
loc_indexes_to_remove.add(i)
|
|
||||||
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
|
|
||||||
|
|
||||||
early_prog_items: typing.List[Item] = []
|
|
||||||
early_rest_items: typing.List[Item] = []
|
|
||||||
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
|
||||||
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
|
||||||
item_indexes_to_remove: typing.Set[int] = set()
|
|
||||||
for i, item in enumerate(itempool):
|
|
||||||
if (item.name, item.player) in early_items_count:
|
|
||||||
if item.advancement:
|
|
||||||
if early_items_count[(item.name, item.player)][1]:
|
|
||||||
early_local_prog_items[item.player].append(item)
|
|
||||||
early_items_count[(item.name, item.player)][1] -= 1
|
|
||||||
else:
|
|
||||||
early_prog_items.append(item)
|
|
||||||
early_items_count[(item.name, item.player)][0] -= 1
|
|
||||||
else:
|
|
||||||
if early_items_count[(item.name, item.player)][1]:
|
|
||||||
early_local_rest_items[item.player].append(item)
|
|
||||||
early_items_count[(item.name, item.player)][1] -= 1
|
|
||||||
else:
|
|
||||||
early_rest_items.append(item)
|
|
||||||
early_items_count[(item.name, item.player)][0] -= 1
|
|
||||||
item_indexes_to_remove.add(i)
|
|
||||||
if early_items_count[(item.name, item.player)] == [0, 0]:
|
|
||||||
del early_items_count[(item.name, item.player)]
|
|
||||||
if len(early_items_count) == 0:
|
|
||||||
break
|
|
||||||
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
|
||||||
for player in world.player_ids:
|
|
||||||
fill_restrictive(world, base_state,
|
|
||||||
[loc for loc in early_locations if loc.player == player],
|
|
||||||
early_local_rest_items[player], lock=True)
|
|
||||||
early_locations = [loc for loc in early_locations if not loc.item]
|
|
||||||
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True)
|
|
||||||
early_locations += early_priority_locations
|
|
||||||
for player in world.player_ids:
|
|
||||||
fill_restrictive(world, base_state,
|
|
||||||
[loc for loc in early_locations if loc.player == player],
|
|
||||||
early_local_prog_items[player], lock=True)
|
|
||||||
early_locations = [loc for loc in early_locations if not loc.item]
|
|
||||||
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True)
|
|
||||||
unplaced_early_items = early_rest_items + early_prog_items
|
|
||||||
if unplaced_early_items:
|
|
||||||
logging.warning("Ran out of early locations for early items. Failed to place "
|
|
||||||
f"{len(unplaced_early_items)} items early.")
|
|
||||||
itempool += unplaced_early_items
|
|
||||||
|
|
||||||
fill_locations.extend(early_locations)
|
|
||||||
world.random.shuffle(fill_locations)
|
|
||||||
return fill_locations, itempool
|
|
||||||
|
|
||||||
|
|
||||||
def distribute_items_restrictive(world: MultiWorld) -> None:
|
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||||
fill_locations = sorted(world.get_unfilled_locations())
|
fill_locations = sorted(world.get_unfilled_locations())
|
||||||
world.random.shuffle(fill_locations)
|
world.random.shuffle(fill_locations)
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
itempool = sorted(world.itempool)
|
itempool = sorted(world.itempool)
|
||||||
world.random.shuffle(itempool)
|
world.random.shuffle(itempool)
|
||||||
|
|
||||||
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
|
|
||||||
|
|
||||||
progitempool: typing.List[Item] = []
|
progitempool: typing.List[Item] = []
|
||||||
usefulitempool: typing.List[Item] = []
|
usefulitempool: typing.List[Item] = []
|
||||||
filleritempool: typing.List[Item] = []
|
filleritempool: typing.List[Item] = []
|
||||||
@@ -356,33 +239,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
|||||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||||
|
|
||||||
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
|
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
|
||||||
lock_later = []
|
|
||||||
|
|
||||||
def mark_for_locking(location: Location):
|
|
||||||
nonlocal lock_later
|
|
||||||
lock_later.append(location)
|
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
# "priority fill"
|
|
||||||
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
|
|
||||||
accessibility_corrections(world, world.state, prioritylocations, progitempool)
|
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
# "progression fill"
|
|
||||||
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||||
accessibility_corrections(world, world.state, defaultlocations)
|
|
||||||
|
|
||||||
for location in lock_later:
|
|
||||||
if location.item:
|
|
||||||
location.locked = True
|
|
||||||
del mark_for_locking, lock_later
|
|
||||||
|
|
||||||
inaccessible_location_rules(world, world.state, defaultlocations)
|
|
||||||
|
|
||||||
remaining_fill(world, excludedlocations, filleritempool)
|
remaining_fill(world, excludedlocations, filleritempool)
|
||||||
if excludedlocations:
|
if excludedlocations:
|
||||||
@@ -683,17 +548,6 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
swept_state = world.state.copy()
|
|
||||||
swept_state.sweep_for_events()
|
|
||||||
reachable = frozenset(world.get_reachable_locations(swept_state))
|
|
||||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
|
||||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
|
||||||
for loc in world.get_unfilled_locations():
|
|
||||||
if loc in reachable:
|
|
||||||
early_locations[loc.player].append(loc.name)
|
|
||||||
else: # not reachable with swept state
|
|
||||||
non_early_locations[loc.player].append(loc.name)
|
|
||||||
|
|
||||||
# TODO: remove. Preferably by implementing key drop
|
# TODO: remove. Preferably by implementing key drop
|
||||||
from worlds.alttp.Regions import key_drop_data
|
from worlds.alttp.Regions import key_drop_data
|
||||||
world_name_lookup = world.world_name_lookup
|
world_name_lookup = world.world_name_lookup
|
||||||
@@ -709,39 +563,7 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
if 'from_pool' not in block:
|
if 'from_pool' not in block:
|
||||||
block['from_pool'] = True
|
block['from_pool'] = True
|
||||||
if 'world' not in block:
|
if 'world' not in block:
|
||||||
target_world = False
|
block['world'] = False
|
||||||
else:
|
|
||||||
target_world = block['world']
|
|
||||||
|
|
||||||
if target_world is False or world.players == 1: # target own world
|
|
||||||
worlds: typing.Set[int] = {player}
|
|
||||||
elif target_world is True: # target any worlds besides own
|
|
||||||
worlds = set(world.player_ids) - {player}
|
|
||||||
elif target_world is None: # target all worlds
|
|
||||||
worlds = set(world.player_ids)
|
|
||||||
elif type(target_world) == list: # list of target worlds
|
|
||||||
worlds = set()
|
|
||||||
for listed_world in target_world:
|
|
||||||
if listed_world not in world_name_lookup:
|
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
|
||||||
block['force'])
|
|
||||||
continue
|
|
||||||
worlds.add(world_name_lookup[listed_world])
|
|
||||||
elif type(target_world) == int: # target world by slot number
|
|
||||||
if target_world not in range(1, world.players + 1):
|
|
||||||
failed(
|
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
|
||||||
block['force'])
|
|
||||||
continue
|
|
||||||
worlds = {target_world}
|
|
||||||
else: # target world by slot name
|
|
||||||
if target_world not in world_name_lookup:
|
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
|
||||||
block['force'])
|
|
||||||
continue
|
|
||||||
worlds = {world_name_lookup[target_world]}
|
|
||||||
block['world'] = worlds
|
|
||||||
|
|
||||||
items: block_value = []
|
items: block_value = []
|
||||||
if "items" in block:
|
if "items" in block:
|
||||||
items = block["items"]
|
items = block["items"]
|
||||||
@@ -778,17 +600,6 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
for key, value in locations.items():
|
for key, value in locations.items():
|
||||||
location_list += [key] * value
|
location_list += [key] * value
|
||||||
locations = location_list
|
locations = location_list
|
||||||
|
|
||||||
if "early_locations" in locations:
|
|
||||||
locations.remove("early_locations")
|
|
||||||
for player in worlds:
|
|
||||||
locations += early_locations[player]
|
|
||||||
if "non_early_locations" in locations:
|
|
||||||
locations.remove("non_early_locations")
|
|
||||||
for player in worlds:
|
|
||||||
locations += non_early_locations[player]
|
|
||||||
|
|
||||||
|
|
||||||
block['locations'] = locations
|
block['locations'] = locations
|
||||||
|
|
||||||
if not block['count']:
|
if not block['count']:
|
||||||
@@ -824,11 +635,38 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement['player']
|
player = placement['player']
|
||||||
try:
|
try:
|
||||||
worlds = placement['world']
|
target_world = placement['world']
|
||||||
locations = placement['locations']
|
locations = placement['locations']
|
||||||
items = placement['items']
|
items = placement['items']
|
||||||
maxcount = placement['count']['target']
|
maxcount = placement['count']['target']
|
||||||
from_pool = placement['from_pool']
|
from_pool = placement['from_pool']
|
||||||
|
if target_world is False or world.players == 1: # target own world
|
||||||
|
worlds: typing.Set[int] = {player}
|
||||||
|
elif target_world is True: # target any worlds besides own
|
||||||
|
worlds = set(world.player_ids) - {player}
|
||||||
|
elif target_world is None: # target all worlds
|
||||||
|
worlds = set(world.player_ids)
|
||||||
|
elif type(target_world) == list: # list of target worlds
|
||||||
|
worlds = set()
|
||||||
|
for listed_world in target_world:
|
||||||
|
if listed_world not in world_name_lookup:
|
||||||
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
|
placement['force'])
|
||||||
|
continue
|
||||||
|
worlds.add(world_name_lookup[listed_world])
|
||||||
|
elif type(target_world) == int: # target world by slot number
|
||||||
|
if target_world not in range(1, world.players + 1):
|
||||||
|
failed(
|
||||||
|
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||||
|
placement['force'])
|
||||||
|
continue
|
||||||
|
worlds = {target_world}
|
||||||
|
else: # target world by slot name
|
||||||
|
if target_world not in world_name_lookup:
|
||||||
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
|
placement['force'])
|
||||||
|
continue
|
||||||
|
worlds = {world_name_lookup[target_world]}
|
||||||
|
|
||||||
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
|
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
|
||||||
worlds))
|
worlds))
|
||||||
|
|||||||
25
Generate.py
25
Generate.py
@@ -154,12 +154,11 @@ def main(args=None, callback=ERmain):
|
|||||||
# sort dict for consistent results across platforms:
|
# sort dict for consistent results across platforms:
|
||||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||||
for filename, yaml_data in weights_cache.items():
|
for filename, yaml_data in weights_cache.items():
|
||||||
if filename not in {args.meta_file_path, args.weights_file_path}:
|
for yaml in yaml_data:
|
||||||
for yaml in yaml_data:
|
print(f"P{player_id} Weights: {filename} >> "
|
||||||
print(f"P{player_id} Weights: {filename} >> "
|
f"{get_choice('description', yaml, 'No description specified')}")
|
||||||
f"{get_choice('description', yaml, 'No description specified')}")
|
player_files[player_id] = filename
|
||||||
player_files[player_id] = filename
|
player_id += 1
|
||||||
player_id += 1
|
|
||||||
|
|
||||||
args.multi = max(player_id - 1, args.multi)
|
args.multi = max(player_id - 1, args.multi)
|
||||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||||
@@ -233,8 +232,8 @@ def main(args=None, callback=ERmain):
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
|
|
||||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
if len(set(erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||||
|
|
||||||
if args.yaml_output:
|
if args.yaml_output:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -317,11 +316,11 @@ class SafeDict(dict):
|
|||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name.lower()] += 1
|
name_counter[name] += 1
|
||||||
number = name_counter[name.lower()]
|
|
||||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||||
NUMBER=(number if number > 1 else ''),
|
NUMBER=(name_counter[name] if name_counter[
|
||||||
|
name] > 1 else ''),
|
||||||
player=player,
|
player=player,
|
||||||
PLAYER=(player if player > 1 else '')))
|
PLAYER=(player if player > 1 else '')))
|
||||||
new_name = new_name.strip()[:16]
|
new_name = new_name.strip()[:16]
|
||||||
@@ -378,7 +377,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
|||||||
if option_key in options:
|
if option_key in options:
|
||||||
if options[option_key].supports_weighting:
|
if options[option_key].supports_weighting:
|
||||||
return get_choice(option_key, category_dict)
|
return get_choice(option_key, category_dict)
|
||||||
return category_dict[option_key]
|
return options[option_key]
|
||||||
if game == "A Link to the Past": # TODO wow i hate this
|
if game == "A Link to the Past": # TODO wow i hate this
|
||||||
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||||
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ components: Iterable[Component] = (
|
|||||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||||
# SNI
|
# SNI
|
||||||
Component('SNI Client', 'SNIClient',
|
Component('SNI Client', 'SNIClient',
|
||||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
|
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
||||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||||
# Factorio
|
# Factorio
|
||||||
Component('Factorio Client', 'FactorioClient'),
|
Component('Factorio Client', 'FactorioClient'),
|
||||||
@@ -145,15 +145,10 @@ components: Iterable[Component] = (
|
|||||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||||
# FF1
|
# FF1
|
||||||
Component('FF1 Client', 'FF1Client'),
|
Component('FF1 Client', 'FF1Client'),
|
||||||
# Pokémon
|
|
||||||
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
|
|
||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||||
# Starcraft 2
|
# Starcraft 2
|
||||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||||
# Zillion
|
|
||||||
Component('Zillion Client', 'ZillionClient',
|
|
||||||
file_identifier=SuffixIdentifier('.apzl')),
|
|
||||||
# Functions
|
# Functions
|
||||||
Component('Open host.yaml', func=open_host_yaml),
|
Component('Open host.yaml', func=open_host_yaml),
|
||||||
Component('Open Patch', func=open_patch),
|
Component('Open Patch', func=open_patch),
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ ModuleUpdate.update()
|
|||||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||||
get_adjuster_settings, tkinter_center_window, init_logging
|
get_adjuster_settings, tkinter_center_window, init_logging
|
||||||
|
from Patch import GAME_ALTTP
|
||||||
|
|
||||||
GAME_ALTTP = "A Link to the Past"
|
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
@@ -141,7 +139,7 @@ def adjust(args):
|
|||||||
vanillaRom = args.baserom
|
vanillaRom = args.baserom
|
||||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||||
vanillaRom = local_path(vanillaRom)
|
vanillaRom = local_path(vanillaRom)
|
||||||
if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
|
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
||||||
import Patch
|
import Patch
|
||||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||||
|
|
||||||
@@ -197,7 +195,7 @@ def adjustGUI():
|
|||||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||||
|
|
||||||
def RomSelect2():
|
def RomSelect2():
|
||||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")])
|
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
|
||||||
romVar2.set(rom)
|
romVar2.set(rom)
|
||||||
|
|
||||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||||
@@ -727,7 +725,7 @@ def get_rom_options_frame(parent=None):
|
|||||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||||
autoApplyFrame = Frame(romOptionsFrame)
|
autoApplyFrame = Frame(romOptionsFrame)
|
||||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
|
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
||||||
filler.pack(side=TOP, expand=True, fill=X)
|
filler.pack(side=TOP, expand=True, fill=X)
|
||||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||||
|
|||||||
55
Main.py
55
Main.py
@@ -8,15 +8,15 @@ import concurrent.futures
|
|||||||
import pickle
|
import pickle
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from typing import Dict, List, Tuple, Optional, Set
|
from typing import Dict, Tuple, Optional, Set
|
||||||
|
|
||||||
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||||
from worlds.alttp.Items import item_name_groups
|
from worlds.alttp.Items import item_name_groups
|
||||||
from worlds.alttp.Regions import is_main_entrance
|
from worlds.alttp.Regions import is_main_entrance
|
||||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||||
from Utils import output_path, get_options, __version__, version_tuple
|
from Utils import output_path, get_options, __version__, version_tuple
|
||||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
ordered_areas = (
|
ordered_areas = (
|
||||||
@@ -80,30 +80,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
logger.info("Found World Types:")
|
logger.info("Found World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
|
numlength = 8
|
||||||
max_item = 0
|
|
||||||
max_location = 0
|
|
||||||
for cls in AutoWorld.AutoWorldRegister.world_types.values():
|
|
||||||
if cls.item_id_to_name:
|
|
||||||
max_item = max(max_item, max(cls.item_id_to_name))
|
|
||||||
max_location = max(max_location, max(cls.location_id_to_name))
|
|
||||||
|
|
||||||
item_digits = len(str(max_item))
|
|
||||||
location_digits = len(str(max_location))
|
|
||||||
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
|
||||||
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
|
||||||
del max_item, max_location
|
|
||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden and len(cls.item_names) > 0:
|
if not cls.hidden:
|
||||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
||||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
f"{max(cls.item_id_to_name):{numlength}}) | "
|
||||||
f"{len(cls.location_names):{location_count}} "
|
f"{len(cls.location_names):3} "
|
||||||
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||||
f"{max(cls.location_id_to_name):{location_digits}})")
|
f"{max(cls.location_id_to_name):{numlength}})")
|
||||||
|
|
||||||
del item_digits, location_digits, item_count, location_count
|
|
||||||
|
|
||||||
AutoWorld.call_stage(world, "assert_generate")
|
AutoWorld.call_stage(world, "assert_generate")
|
||||||
|
|
||||||
@@ -122,7 +107,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||||
world.local_items[player].value.add('Triforce Piece')
|
world.local_items[player].value.add('Triforce Piece')
|
||||||
|
|
||||||
# Not possible to place pendants/crystals outside boss prizes yet.
|
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||||
|
|
||||||
@@ -137,7 +122,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
if world.players > 1:
|
if world.players > 1:
|
||||||
locality_rules(world)
|
for player in world.player_ids:
|
||||||
|
locality_rules(world, player)
|
||||||
|
group_locality_rules(world)
|
||||||
else:
|
else:
|
||||||
world.non_local_items[1].value = set()
|
world.non_local_items[1].value = set()
|
||||||
world.local_items[1].value = set()
|
world.local_items[1].value = set()
|
||||||
@@ -154,10 +141,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
# temporary home for item links, should be moved out of Main
|
# temporary home for item links, should be moved out of Main
|
||||||
for group_id, group in world.groups.items():
|
for group_id, group in world.groups.items():
|
||||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
||||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
classifications = collections.defaultdict(int)
|
||||||
]:
|
|
||||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
|
||||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||||
for item in world.itempool:
|
for item in world.itempool:
|
||||||
if item.player in counters and item.name in shared_pool:
|
if item.player in counters and item.name in shared_pool:
|
||||||
@@ -167,7 +152,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in players.copy():
|
for player in players.copy():
|
||||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||||
players.remove(player)
|
players.remove(player)
|
||||||
del (counters[player])
|
del(counters[player])
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -179,14 +164,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
counters[player][item] = count
|
counters[player][item] = count
|
||||||
else:
|
else:
|
||||||
for player in players:
|
for player in players:
|
||||||
del (counters[player][item])
|
del(counters[player][item])
|
||||||
return counters, classifications
|
return counters, classifications
|
||||||
|
|
||||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||||
if not common_item_count:
|
if not common_item_count:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
new_itempool: List[Item] = []
|
new_itempool = []
|
||||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||||
for _ in range(item_count):
|
for _ in range(item_count):
|
||||||
new_item = group["world"].create_item(item_name)
|
new_item = group["world"].create_item(item_name)
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
|
|||||||
|
|
||||||
if not update_ran:
|
if not update_ran:
|
||||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||||
# skip .* (hidden / disabled) folders
|
if entry.is_dir():
|
||||||
if not entry.name.startswith("."):
|
req_file = os.path.join(entry.path, "requirements.txt")
|
||||||
if entry.is_dir():
|
if os.path.exists(req_file):
|
||||||
req_file = os.path.join(entry.path, "requirements.txt")
|
requirements_files.add(req_file)
|
||||||
if os.path.exists(req_file):
|
|
||||||
requirements_files.add(req_file)
|
|
||||||
|
|
||||||
|
|
||||||
def update_command():
|
def update_command():
|
||||||
@@ -39,25 +37,11 @@ def update(yes=False, force=False):
|
|||||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||||
with open(path) as requirementsfile:
|
with open(path) as requirementsfile:
|
||||||
for line in requirementsfile:
|
for line in requirementsfile:
|
||||||
if line.startswith(("https://", "git+https://")):
|
if line.startswith('https://'):
|
||||||
# extract name and version for url
|
# extract name and version from url
|
||||||
rest = line.split('/')[-1]
|
wheel = line.split('/')[-1]
|
||||||
line = ""
|
name, version, _ = wheel.split('-', 2)
|
||||||
if "#egg=" in rest:
|
line = f'{name}=={version}'
|
||||||
# from egg info
|
|
||||||
rest, egg = rest.split("#egg=", 1)
|
|
||||||
egg = egg.split(";", 1)[0]
|
|
||||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
|
||||||
line = egg
|
|
||||||
else:
|
|
||||||
egg = ""
|
|
||||||
if "@" in rest and not line:
|
|
||||||
raise ValueError("Can't deduce version from requirement")
|
|
||||||
elif not line:
|
|
||||||
# from filename
|
|
||||||
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
|
|
||||||
name, version, _ = rest.split("-", 2)
|
|
||||||
line = f'{egg or name}=={version}'
|
|
||||||
requirements = pkg_resources.parse_requirements(line)
|
requirements = pkg_resources.parse_requirements(line)
|
||||||
for requirement in requirements:
|
for requirement in requirements:
|
||||||
requirement = str(requirement)
|
requirement = str(requirement)
|
||||||
|
|||||||
110
MultiServer.py
110
MultiServer.py
@@ -31,7 +31,7 @@ except ImportError:
|
|||||||
|
|
||||||
import NetUtils
|
import NetUtils
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import version_tuple, restricted_loads, Version, async_start
|
from Utils import version_tuple, restricted_loads, Version
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType
|
SlotType
|
||||||
|
|
||||||
@@ -273,16 +273,16 @@ class Context:
|
|||||||
def broadcast_all(self, msgs: typing.List[dict]):
|
def broadcast_all(self, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||||
|
|
||||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||||
|
|
||||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||||
|
|
||||||
async def disconnect(self, endpoint: Client):
|
async def disconnect(self, endpoint: Client):
|
||||||
if endpoint in self.endpoints:
|
if endpoint in self.endpoints:
|
||||||
@@ -302,18 +302,18 @@ class Context:
|
|||||||
return
|
return
|
||||||
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
if client.version >= print_command_compatability_threshold:
|
if client.version >= print_command_compatability_threshold:
|
||||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
|
asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
|
||||||
else:
|
else:
|
||||||
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
||||||
|
|
||||||
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
||||||
if not client.auth:
|
if not client.auth:
|
||||||
return
|
return
|
||||||
if client.version >= print_command_compatability_threshold:
|
if client.version >= print_command_compatability_threshold:
|
||||||
async_start(self.send_msgs(client,
|
asyncio.create_task(self.send_msgs(client,
|
||||||
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
|
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
|
||||||
else:
|
else:
|
||||||
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
||||||
|
|
||||||
# loading
|
# loading
|
||||||
|
|
||||||
@@ -627,7 +627,7 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
|
|||||||
continue
|
continue
|
||||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
||||||
for client in clients:
|
for client in clients:
|
||||||
async_start(ctx.send_msgs(client, client_hints))
|
asyncio.create_task(ctx.send_msgs(client, client_hints))
|
||||||
|
|
||||||
|
|
||||||
def update_aliases(ctx: Context, team: int):
|
def update_aliases(ctx: Context, team: int):
|
||||||
@@ -636,7 +636,7 @@ def update_aliases(ctx: Context, team: int):
|
|||||||
|
|
||||||
for clients in ctx.clients[team].values():
|
for clients in ctx.clients[team].values():
|
||||||
for client in clients:
|
for client in clients:
|
||||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
|
||||||
|
|
||||||
|
|
||||||
async def server(websocket, path: str = "/", ctx: Context = None):
|
async def server(websocket, path: str = "/", ctx: Context = None):
|
||||||
@@ -814,7 +814,7 @@ def send_new_items(ctx: Context):
|
|||||||
items = get_received_items(ctx, team, slot, client.remote_items)
|
items = get_received_items(ctx, team, slot, client.remote_items)
|
||||||
if len(start_inventory) + len(items) > client.send_index:
|
if len(start_inventory) + len(items) > client.send_index:
|
||||||
first_new_item = max(0, client.send_index - len(start_inventory))
|
first_new_item = max(0, client.send_index - len(start_inventory))
|
||||||
async_start(ctx.send_msgs(client, [{
|
asyncio.create_task(ctx.send_msgs(client, [{
|
||||||
"cmd": "ReceivedItems",
|
"cmd": "ReceivedItems",
|
||||||
"index": client.send_index,
|
"index": client.send_index,
|
||||||
"items": start_inventory[client.send_index:] + items[first_new_item:]}]))
|
"items": start_inventory[client.send_index:] + items[first_new_item:]}]))
|
||||||
@@ -998,11 +998,7 @@ class CommandMeta(type):
|
|||||||
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
|
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
|
||||||
|
|
||||||
|
|
||||||
_Return = typing.TypeVar("_Return")
|
def mark_raw(function):
|
||||||
# TODO: when python 3.10 is lowest supported, typing.ParamSpec
|
|
||||||
|
|
||||||
|
|
||||||
def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]:
|
|
||||||
function.raw_text = True
|
function.raw_text = True
|
||||||
return function
|
return function
|
||||||
|
|
||||||
@@ -1090,7 +1086,7 @@ class CommonCommandProcessor(CommandProcessor):
|
|||||||
timer = int(seconds, 10)
|
timer = int(seconds, 10)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
timer = 10
|
timer = 10
|
||||||
async_start(countdown(self.ctx, timer))
|
asyncio.create_task(countdown(self.ctx, timer))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_options(self):
|
def _cmd_options(self):
|
||||||
@@ -1332,8 +1328,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
|
||||||
|
|
||||||
if not input_text:
|
if not input_text:
|
||||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||||
self.ctx.hints[self.client.team, self.client.slot]}
|
self.ctx.hints[self.client.team, self.client.slot]}
|
||||||
@@ -1388,6 +1382,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||||
old_hints = set(hints) - new_hints
|
old_hints = set(hints) - new_hints
|
||||||
if old_hints:
|
if old_hints:
|
||||||
@@ -1437,12 +1432,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if points_available >= cost:
|
self.output("Nothing found. Item/Location may not exist.")
|
||||||
self.output("Nothing found. Item/Location may not exist.")
|
|
||||||
else:
|
|
||||||
self.output(f"You can't afford the hint. "
|
|
||||||
f"You have {points_available} points and need at least "
|
|
||||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
@@ -1771,7 +1761,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
def _cmd_exit(self) -> bool:
|
def _cmd_exit(self) -> bool:
|
||||||
"""Shutdown the server"""
|
"""Shutdown the server"""
|
||||||
async_start(self.ctx.server.ws_server._close())
|
asyncio.create_task(self.ctx.server.ws_server._close())
|
||||||
if self.ctx.shutdown_task:
|
if self.ctx.shutdown_task:
|
||||||
self.ctx.shutdown_task.cancel()
|
self.ctx.shutdown_task.cancel()
|
||||||
self.ctx.exit_event.set()
|
self.ctx.exit_event.set()
|
||||||
@@ -1802,33 +1792,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def resolve_player(self, input_name: str) -> typing.Optional[typing.Tuple[int, int, str]]:
|
|
||||||
""" returns (team, slot, player name) """
|
|
||||||
# TODO: clean up once we disallow multidata < 0.3.6, which has CI unique names
|
|
||||||
# first match case
|
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
|
||||||
if name == input_name:
|
|
||||||
return team, slot, name
|
|
||||||
|
|
||||||
# if no case-sensitive match, then match without case only if there's only 1 match
|
|
||||||
input_lower = input_name.lower()
|
|
||||||
match: typing.Optional[typing.Tuple[int, int, str]] = None
|
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
|
||||||
lowered = name.lower()
|
|
||||||
if lowered == input_lower:
|
|
||||||
if match:
|
|
||||||
return None # ambiguous input_name
|
|
||||||
match = (team, slot, name)
|
|
||||||
return match
|
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_collect(self, player_name: str) -> bool:
|
def _cmd_collect(self, player_name: str) -> bool:
|
||||||
"""Send out the remaining items to player."""
|
"""Send out the remaining items to player."""
|
||||||
player = self.resolve_player(player_name)
|
seeked_player = player_name.lower()
|
||||||
if player:
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
team, slot, _ = player
|
if name.lower() == seeked_player:
|
||||||
collect_player(self.ctx, team, slot)
|
collect_player(self.ctx, team, slot)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to collect")
|
self.output(f"Could not find player {player_name} to collect")
|
||||||
return False
|
return False
|
||||||
@@ -1841,11 +1812,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_forfeit(self, player_name: str) -> bool:
|
def _cmd_forfeit(self, player_name: str) -> bool:
|
||||||
"""Send out the remaining items from a player to their intended recipients."""
|
"""Send out the remaining items from a player to their intended recipients."""
|
||||||
player = self.resolve_player(player_name)
|
seeked_player = player_name.lower()
|
||||||
if player:
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
team, slot, _ = player
|
if name.lower() == seeked_player:
|
||||||
forfeit_player(self.ctx, team, slot)
|
forfeit_player(self.ctx, team, slot)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to release")
|
self.output(f"Could not find player {player_name} to release")
|
||||||
return False
|
return False
|
||||||
@@ -1853,12 +1824,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
||||||
"""Allow the specified player to use the !release command."""
|
"""Allow the specified player to use the !release command."""
|
||||||
player = self.resolve_player(player_name)
|
seeked_player = player_name.lower()
|
||||||
if player:
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
team, slot, name = player
|
if name.lower() == seeked_player:
|
||||||
self.ctx.allow_forfeits[(team, slot)] = True
|
self.ctx.allow_forfeits[(team, slot)] = True
|
||||||
self.output(f"Player {name} is now allowed to use the !release command at any time.")
|
self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to allow the !release command for.")
|
self.output(f"Could not find player {player_name} to allow the !release command for.")
|
||||||
return False
|
return False
|
||||||
@@ -1866,12 +1837,13 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
||||||
""""Disallow the specified player from using the !release command."""
|
""""Disallow the specified player from using the !release command."""
|
||||||
player = self.resolve_player(player_name)
|
seeked_player = player_name.lower()
|
||||||
if player:
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
team, slot, name = player
|
if name.lower() == seeked_player:
|
||||||
self.ctx.allow_forfeits[(team, slot)] = False
|
self.ctx.allow_forfeits[(team, slot)] = False
|
||||||
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.")
|
self.output(
|
||||||
return True
|
f"Player {player_name} has to follow the server restrictions on use of the !release command.")
|
||||||
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to forbid the !release command for.")
|
self.output(f"Could not find player {player_name} to forbid the !release command for.")
|
||||||
return False
|
return False
|
||||||
@@ -2084,7 +2056,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
|||||||
await asyncio.sleep(ctx.auto_shutdown)
|
await asyncio.sleep(ctx.auto_shutdown)
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
if not ctx.client_activity_timers.values():
|
if not ctx.client_activity_timers.values():
|
||||||
async_start(ctx.server.ws_server._close())
|
asyncio.create_task(ctx.server.ws_server._close())
|
||||||
ctx.exit_event.set()
|
ctx.exit_event.set()
|
||||||
if to_cancel:
|
if to_cancel:
|
||||||
for task in to_cancel:
|
for task in to_cancel:
|
||||||
@@ -2095,7 +2067,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
|||||||
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
|
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
|
||||||
seconds = ctx.auto_shutdown - delta.total_seconds()
|
seconds = ctx.auto_shutdown - delta.total_seconds()
|
||||||
if seconds < 0:
|
if seconds < 0:
|
||||||
async_start(ctx.server.ws_server._close())
|
asyncio.create_task(ctx.server.ws_server._close())
|
||||||
ctx.exit_event.set()
|
ctx.exit_event.set()
|
||||||
if to_cancel:
|
if to_cancel:
|
||||||
for task in to_cancel:
|
for task in to_cancel:
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ _encode = JSONEncoder(
|
|||||||
).encode
|
).encode
|
||||||
|
|
||||||
|
|
||||||
def encode(obj: typing.Any) -> str:
|
def encode(obj):
|
||||||
return _encode(_scan_for_TypedTuples(obj))
|
return _encode(_scan_for_TypedTuples(obj))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
25
OoTClient.py
25
OoTClient.py
@@ -5,11 +5,9 @@ import multiprocessing
|
|||||||
import subprocess
|
import subprocess
|
||||||
from asyncio import StreamReader, StreamWriter
|
from asyncio import StreamReader, StreamWriter
|
||||||
|
|
||||||
# CommonClient import first to trigger ModuleUpdater
|
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
|
||||||
ClientCommandProcessor, logger, get_base_parser
|
ClientCommandProcessor, logger, get_base_parser
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
@@ -70,7 +68,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
|
|||||||
if isinstance(self.ctx, OoTContext):
|
if isinstance(self.ctx, OoTContext):
|
||||||
self.ctx.deathlink_client_override = True
|
self.ctx.deathlink_client_override = True
|
||||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||||
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||||
|
|
||||||
|
|
||||||
class OoTContext(CommonContext):
|
class OoTContext(CommonContext):
|
||||||
@@ -134,19 +132,6 @@ def get_payload(ctx: OoTContext):
|
|||||||
|
|
||||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||||
|
|
||||||
# Refuse to do anything if ROM is detected as changed
|
|
||||||
if ctx.auth and payload['playerName'] != ctx.auth:
|
|
||||||
logger.warning("ROM change detected. Disconnecting and reconnecting...")
|
|
||||||
ctx.deathlink_enabled = False
|
|
||||||
ctx.deathlink_client_override = False
|
|
||||||
ctx.finished_game = False
|
|
||||||
ctx.location_table = {}
|
|
||||||
ctx.deathlink_pending = False
|
|
||||||
ctx.deathlink_sent_this_death = False
|
|
||||||
ctx.auth = payload['playerName']
|
|
||||||
await ctx.send_connect()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||||
await ctx.update_death_link(True)
|
await ctx.update_death_link(True)
|
||||||
@@ -204,7 +189,7 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
if reported_version >= script_version:
|
if reported_version >= script_version:
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
async_start(parse_payload(data_decoded, ctx, False))
|
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||||
if not ctx.auth:
|
if not ctx.auth:
|
||||||
ctx.auth = data_decoded['playerName']
|
ctx.auth = data_decoded['playerName']
|
||||||
if ctx.awaiting_rom:
|
if ctx.awaiting_rom:
|
||||||
@@ -280,7 +265,7 @@ async def patch_and_run_game(apz5_file):
|
|||||||
os.chdir(data_path("Compress"))
|
os.chdir(data_path("Compress"))
|
||||||
compress_rom_file(decomp_path, comp_path)
|
compress_rom_file(decomp_path, comp_path)
|
||||||
os.remove(decomp_path)
|
os.remove(decomp_path)
|
||||||
async_start(run_game(comp_path))
|
asyncio.create_task(run_game(comp_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -296,7 +281,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
if args.apz5_file:
|
if args.apz5_file:
|
||||||
logger.info("APZ5 file supplied, beginning patching process...")
|
logger.info("APZ5 file supplied, beginning patching process...")
|
||||||
async_start(patch_and_run_game(args.apz5_file))
|
asyncio.create_task(patch_and_run_game(args.apz5_file))
|
||||||
|
|
||||||
ctx = OoTContext(args.connect, args.password)
|
ctx = OoTContext(args.connect, args.password)
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||||
|
|||||||
140
Options.py
140
Options.py
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import abc
|
import abc
|
||||||
from copy import deepcopy
|
|
||||||
import math
|
import math
|
||||||
import numbers
|
import numbers
|
||||||
import typing
|
import typing
|
||||||
@@ -79,9 +78,6 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
|
|
||||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
@abc.abstractclassmethod
|
|
||||||
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
|
|
||||||
|
|
||||||
|
|
||||||
T = typing.TypeVar('T')
|
T = typing.TypeVar('T')
|
||||||
|
|
||||||
@@ -169,7 +165,6 @@ class FreeText(Option):
|
|||||||
|
|
||||||
|
|
||||||
class NumericOption(Option[int], numbers.Integral):
|
class NumericOption(Option[int], numbers.Integral):
|
||||||
default = 0
|
|
||||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||||
# (even though isinstance(5, numbers.Integral) == True)
|
# (even though isinstance(5, numbers.Integral) == True)
|
||||||
@@ -431,6 +426,7 @@ class TextChoice(Choice):
|
|||||||
assert isinstance(value, str) or isinstance(value, int), \
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||||
self.value = value
|
self.value = value
|
||||||
|
super(TextChoice, self).__init__()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_key(self) -> str:
|
def current_key(self) -> str:
|
||||||
@@ -470,124 +466,6 @@ class TextChoice(Choice):
|
|||||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
|
|
||||||
class BossMeta(AssembleOptions):
|
|
||||||
def __new__(mcs, name, bases, attrs):
|
|
||||||
if name != "PlandoBosses":
|
|
||||||
assert "bosses" in attrs, f"Please define valid bosses for {name}"
|
|
||||||
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
|
|
||||||
assert "locations" in attrs, f"Please define valid locations for {name}"
|
|
||||||
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
|
|
||||||
cls = super().__new__(mcs, name, bases, attrs)
|
|
||||||
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
|
|
||||||
return cls
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoBosses(TextChoice, metaclass=BossMeta):
|
|
||||||
"""Generic boss shuffle option that supports plando. Format expected is
|
|
||||||
'location1-boss1;location2-boss2;shuffle_mode'.
|
|
||||||
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
|
|
||||||
which passes a plando boss and location. Check if the placement is valid for your game here."""
|
|
||||||
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
|
||||||
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
|
||||||
|
|
||||||
duplicate_bosses: bool = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_text(cls, text: str):
|
|
||||||
# set all of our text to lower case for name checking
|
|
||||||
text = text.lower()
|
|
||||||
if text == "random":
|
|
||||||
return cls(random.choice(list(cls.options.values())))
|
|
||||||
for option_name, value in cls.options.items():
|
|
||||||
if option_name == text:
|
|
||||||
return cls(value)
|
|
||||||
options = text.split(";")
|
|
||||||
|
|
||||||
# since plando exists in the option verify the plando values given are valid
|
|
||||||
cls.validate_plando_bosses(options)
|
|
||||||
return cls.get_shuffle_mode(options)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_shuffle_mode(cls, option_list: typing.List[str]):
|
|
||||||
# find out what mode of boss shuffle we should use for placing bosses after plando
|
|
||||||
# and add as a string to look nice in the spoiler
|
|
||||||
if "random" in option_list:
|
|
||||||
shuffle = random.choice(list(cls.options))
|
|
||||||
option_list.remove("random")
|
|
||||||
options = ";".join(option_list) + f";{shuffle}"
|
|
||||||
boss_class = cls(options)
|
|
||||||
else:
|
|
||||||
for option in option_list:
|
|
||||||
if option in cls.options:
|
|
||||||
options = ";".join(option_list)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
if cls.duplicate_bosses and len(option_list) == 1:
|
|
||||||
if cls.valid_boss_name(option_list[0]):
|
|
||||||
# this doesn't exist in this class but it's a forced option for classes where this is called
|
|
||||||
options = option_list[0] + ";singularity"
|
|
||||||
else:
|
|
||||||
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
|
|
||||||
else:
|
|
||||||
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
|
|
||||||
boss_class = cls(options)
|
|
||||||
return boss_class
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
|
|
||||||
used_locations = []
|
|
||||||
used_bosses = []
|
|
||||||
for option in options:
|
|
||||||
# check if a shuffle mode was provided in the incorrect location
|
|
||||||
if option == "random" or option in cls.options:
|
|
||||||
if option != options[-1]:
|
|
||||||
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
|
|
||||||
elif "-" in option:
|
|
||||||
location, boss = option.split("-")
|
|
||||||
if location in used_locations:
|
|
||||||
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
|
|
||||||
if not cls.duplicate_bosses and boss in used_bosses:
|
|
||||||
raise ValueError(f"Duplicate Boss {boss} not allowed.")
|
|
||||||
used_locations.append(location)
|
|
||||||
used_bosses.append(boss)
|
|
||||||
if not cls.valid_boss_name(boss):
|
|
||||||
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
|
||||||
if not cls.valid_location_name(location):
|
|
||||||
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
|
||||||
if not cls.can_place_boss(boss, location):
|
|
||||||
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
|
||||||
else:
|
|
||||||
if cls.duplicate_bosses:
|
|
||||||
if not cls.valid_boss_name(option):
|
|
||||||
raise ValueError(f"{option} is not a valid boss name.")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{option.title()} is not formatted correctly.")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def valid_boss_name(cls, value: str) -> bool:
|
|
||||||
return value in cls.bosses
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def valid_location_name(cls, value: str) -> bool:
|
|
||||||
return value in cls.locations
|
|
||||||
|
|
||||||
def verify(self, world, player_name: str, plando_options) -> None:
|
|
||||||
if isinstance(self.value, int):
|
|
||||||
return
|
|
||||||
from Generate import PlandoSettings
|
|
||||||
if not(PlandoSettings.bosses & plando_options):
|
|
||||||
import logging
|
|
||||||
# plando is disabled but plando options were given so pull the option and change it to an int
|
|
||||||
option = self.value.split(";")[-1]
|
|
||||||
self.value = self.options[option]
|
|
||||||
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
|
|
||||||
f"boss shuffle will be used for player {player_name}.")
|
|
||||||
|
|
||||||
|
|
||||||
class Range(NumericOption):
|
class Range(NumericOption):
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 1
|
range_end = 1
|
||||||
@@ -750,11 +628,11 @@ class VerifyKeys:
|
|||||||
|
|
||||||
|
|
||||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||||
default: typing.Dict[str, typing.Any] = {}
|
default = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||||
self.value = deepcopy(value)
|
self.value = value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||||
@@ -781,11 +659,11 @@ class ItemDict(OptionDict):
|
|||||||
|
|
||||||
|
|
||||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||||
default: typing.List[typing.Any] = []
|
default = []
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.List[typing.Any]):
|
def __init__(self, value: typing.List[typing.Any]):
|
||||||
self.value = deepcopy(value)
|
self.value = value or []
|
||||||
super(OptionList, self).__init__()
|
super(OptionList, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -807,11 +685,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
|
|
||||||
|
|
||||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||||
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
|
default = frozenset()
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[str]):
|
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
||||||
self.value = set(deepcopy(value))
|
self.value = set(value)
|
||||||
super(OptionSet, self).__init__()
|
super(OptionSet, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -850,7 +728,7 @@ class Accessibility(Choice):
|
|||||||
|
|
||||||
class ProgressionBalancing(SpecialRange):
|
class ProgressionBalancing(SpecialRange):
|
||||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||||
A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||||
default = 50
|
default = 50
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 99
|
range_end = 99
|
||||||
|
|||||||
428
Patch.py
428
Patch.py
@@ -1,23 +1,266 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
import bsdiff4
|
||||||
|
import yaml
|
||||||
import os
|
import os
|
||||||
|
import lzma
|
||||||
|
import threading
|
||||||
|
import concurrent.futures
|
||||||
|
import zipfile
|
||||||
import sys
|
import sys
|
||||||
from typing import Tuple, Optional, TypedDict
|
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||||
|
|
||||||
if __name__ == "__main__":
|
import ModuleUpdate
|
||||||
import ModuleUpdate
|
ModuleUpdate.update()
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
import Utils
|
||||||
|
|
||||||
|
current_patch_version = 5
|
||||||
|
|
||||||
|
|
||||||
class RomMeta(TypedDict):
|
class AutoPatchRegister(type):
|
||||||
server: str
|
patch_types: Dict[str, APDeltaPatch] = {}
|
||||||
|
file_endings: Dict[str, APDeltaPatch] = {}
|
||||||
|
|
||||||
|
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
||||||
|
# construct class
|
||||||
|
new_class = super().__new__(cls, name, bases, dct)
|
||||||
|
if "game" in dct:
|
||||||
|
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||||
|
if not dct["patch_file_ending"]:
|
||||||
|
raise Exception(f"Need an expected file ending for {name}")
|
||||||
|
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||||
|
return new_class
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
||||||
|
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
||||||
|
if file.endswith(file_ending):
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
class APContainer:
|
||||||
|
"""A zipfile containing at least archipelago.json"""
|
||||||
|
version: int = current_patch_version
|
||||||
|
compression_level: int = 9
|
||||||
|
compression_method: int = zipfile.ZIP_DEFLATED
|
||||||
|
game: Optional[str] = None
|
||||||
|
|
||||||
|
# instance attributes:
|
||||||
|
path: Optional[str]
|
||||||
player: Optional[int]
|
player: Optional[int]
|
||||||
player_name: str
|
player_name: str
|
||||||
|
server: str
|
||||||
|
|
||||||
|
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
||||||
|
player_name: str = "", server: str = ""):
|
||||||
|
self.path = path
|
||||||
|
self.player = player
|
||||||
|
self.player_name = player_name
|
||||||
|
self.server = server
|
||||||
|
|
||||||
|
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||||
|
if not self.path and not file:
|
||||||
|
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
||||||
|
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
||||||
|
as zf:
|
||||||
|
if file:
|
||||||
|
self.path = zf.filename
|
||||||
|
self.write_contents(zf)
|
||||||
|
|
||||||
|
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||||
|
manifest = self.get_manifest()
|
||||||
|
try:
|
||||||
|
manifest = json.dumps(manifest)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||||
|
else:
|
||||||
|
opened_zipfile.writestr("archipelago.json", manifest)
|
||||||
|
|
||||||
|
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||||
|
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||||
|
if not self.path and not file:
|
||||||
|
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
||||||
|
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
||||||
|
if file:
|
||||||
|
self.path = zf.filename
|
||||||
|
self.read_contents(zf)
|
||||||
|
|
||||||
|
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||||
|
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
if manifest["compatible_version"] > self.version:
|
||||||
|
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||||
|
f"for this handler (version: {self.version})")
|
||||||
|
self.player = manifest["player"]
|
||||||
|
self.server = manifest["server"]
|
||||||
|
self.player_name = manifest["player_name"]
|
||||||
|
|
||||||
|
def get_manifest(self) -> dict:
|
||||||
|
return {
|
||||||
|
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||||
|
"player": self.player,
|
||||||
|
"player_name": self.player_name,
|
||||||
|
"game": self.game,
|
||||||
|
# minimum version of patch system expected for patching to be successful
|
||||||
|
"compatible_version": 4,
|
||||||
|
"version": current_patch_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
||||||
|
"""An APContainer that additionally has delta.bsdiff4
|
||||||
|
containing a delta patch to get the desired file, often a rom."""
|
||||||
|
|
||||||
|
hash = Optional[str] # base checksum of source file
|
||||||
|
patch_file_ending: str = ""
|
||||||
|
delta: Optional[bytes] = None
|
||||||
|
result_file_ending: str = ".sfc"
|
||||||
|
source_data: bytes
|
||||||
|
|
||||||
|
def __init__(self, *args, patched_path: str = "", **kwargs):
|
||||||
|
self.patched_path = patched_path
|
||||||
|
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_manifest(self) -> dict:
|
||||||
|
manifest = super(APDeltaPatch, self).get_manifest()
|
||||||
|
manifest["base_checksum"] = self.hash
|
||||||
|
manifest["result_file_ending"] = self.result_file_ending
|
||||||
|
manifest["patch_file_ending"] = self.patch_file_ending
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_source_data(cls) -> bytes:
|
||||||
|
"""Get Base data"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_source_data_with_cache(cls) -> bytes:
|
||||||
|
if not hasattr(cls, "source_data"):
|
||||||
|
cls.source_data = cls.get_source_data()
|
||||||
|
return cls.source_data
|
||||||
|
|
||||||
|
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||||
|
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
||||||
|
# write Delta
|
||||||
|
opened_zipfile.writestr("delta.bsdiff4",
|
||||||
|
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
||||||
|
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
||||||
|
|
||||||
|
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||||
|
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
||||||
|
self.delta = opened_zipfile.read("delta.bsdiff4")
|
||||||
|
|
||||||
|
def patch(self, target: str):
|
||||||
|
"""Base + Delta -> Patched"""
|
||||||
|
if not self.delta:
|
||||||
|
self.read()
|
||||||
|
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
||||||
|
with open(target, "wb") as f:
|
||||||
|
f.write(result)
|
||||||
|
|
||||||
|
|
||||||
|
# legacy patch handling follows:
|
||||||
|
GAME_ALTTP = "A Link to the Past"
|
||||||
|
GAME_SM = "Super Metroid"
|
||||||
|
GAME_SOE = "Secret of Evermore"
|
||||||
|
GAME_SMZ3 = "SMZ3"
|
||||||
|
GAME_DKC3 = "Donkey Kong Country 3"
|
||||||
|
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
||||||
|
|
||||||
|
preferred_endings = {
|
||||||
|
GAME_ALTTP: "apbp",
|
||||||
|
GAME_SM: "apm3",
|
||||||
|
GAME_SOE: "apsoe",
|
||||||
|
GAME_SMZ3: "apsmz",
|
||||||
|
GAME_DKC3: "apdkc3"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||||
|
if game == GAME_ALTTP:
|
||||||
|
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
||||||
|
elif game == GAME_SM:
|
||||||
|
from worlds.sm.Rom import SMJUHASH as HASH
|
||||||
|
elif game == GAME_SOE:
|
||||||
|
from worlds.soe.Patch import USHASH as HASH
|
||||||
|
elif game == GAME_SMZ3:
|
||||||
|
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
||||||
|
from worlds.sm.Rom import SMJUHASH as SMHASH
|
||||||
|
HASH = ALTTPHASH + SMHASH
|
||||||
|
elif game == GAME_DKC3:
|
||||||
|
from worlds.dkc3.Rom import USHASH as HASH
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||||
|
|
||||||
|
patch = yaml.dump({"meta": metadata,
|
||||||
|
"patch": patch,
|
||||||
|
"game": game,
|
||||||
|
# minimum version of patch system expected for patching to be successful
|
||||||
|
"compatible_version": 3,
|
||||||
|
"version": current_patch_version,
|
||||||
|
"base_checksum": HASH})
|
||||||
|
return patch.encode(encoding="utf-8-sig")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||||
|
if metadata is None:
|
||||||
|
metadata = {}
|
||||||
|
patch = bsdiff4.diff(get_base_rom_data(game), rom)
|
||||||
|
return generate_yaml(patch, metadata, game)
|
||||||
|
|
||||||
|
|
||||||
|
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
|
||||||
|
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
|
||||||
|
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||||
|
"player_id": player,
|
||||||
|
"player_name": player_name}
|
||||||
|
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
||||||
|
meta,
|
||||||
|
game)
|
||||||
|
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||||
|
".apbp" if game == GAME_ALTTP
|
||||||
|
else ".apsmz" if game == GAME_SMZ3
|
||||||
|
else ".apdkc3" if game == GAME_DKC3
|
||||||
|
else ".apm3")
|
||||||
|
write_lzma(bytes, target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
||||||
|
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
||||||
|
game_name = data["game"]
|
||||||
|
if not ignore_version and data["compatible_version"] > current_patch_version:
|
||||||
|
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
|
||||||
|
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
|
||||||
|
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
|
||||||
|
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
|
||||||
|
target = os.path.splitext(patch_file)[0] + ".sfc"
|
||||||
|
return data["meta"], target, patched_data
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_rom_data(game: str):
|
||||||
|
if game == GAME_ALTTP:
|
||||||
|
from worlds.alttp.Rom import get_base_rom_bytes
|
||||||
|
elif game == "alttp": # old version for A Link to the Past
|
||||||
|
from worlds.alttp.Rom import get_base_rom_bytes
|
||||||
|
elif game == GAME_SM:
|
||||||
|
from worlds.sm.Rom import get_base_rom_bytes
|
||||||
|
elif game == GAME_SOE:
|
||||||
|
from worlds.soe.Patch import get_base_rom_path
|
||||||
|
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
||||||
|
elif game == GAME_SMZ3:
|
||||||
|
from worlds.smz3.Rom import get_base_rom_bytes
|
||||||
|
elif game == GAME_DKC3:
|
||||||
|
from worlds.dkc3.Rom import get_base_rom_bytes
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Selected game for base rom not found.")
|
||||||
|
return get_base_rom_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||||
if auto_handler:
|
if auto_handler:
|
||||||
handler: APDeltaPatch = auto_handler(patch_file)
|
handler: APDeltaPatch = auto_handler(patch_file)
|
||||||
@@ -26,10 +269,171 @@ def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
|||||||
return {"server": handler.server,
|
return {"server": handler.server,
|
||||||
"player": handler.player,
|
"player": handler.player,
|
||||||
"player_name": handler.player_name}, target
|
"player_name": handler.player_name}, target
|
||||||
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
else:
|
||||||
|
data, target, patched_data = create_rom_bytes(patch_file)
|
||||||
|
with open(target, "wb") as f:
|
||||||
|
f.write(patched_data)
|
||||||
|
return data, target
|
||||||
|
|
||||||
|
|
||||||
|
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
|
||||||
|
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
|
||||||
|
data["meta"]["server"] = server
|
||||||
|
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
|
||||||
|
return lzma.compress(bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def load_bytes(path: str) -> bytes:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def write_lzma(data: bytes, path: str):
|
||||||
|
with lzma.LZMAFile(path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def read_rom(stream, strip_header=True) -> bytearray:
|
||||||
|
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||||
|
buffer = bytearray(stream.read())
|
||||||
|
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||||
|
return buffer[0x200:]
|
||||||
|
return buffer
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for file in sys.argv[1:]:
|
host = Utils.get_public_ipv4()
|
||||||
meta_data, result_file = create_rom_file(file)
|
options = Utils.get_options()['server_options']
|
||||||
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
if options['host']:
|
||||||
|
host = options['host']
|
||||||
|
|
||||||
|
address = f"{host}:{options['port']}"
|
||||||
|
ziplock = threading.Lock()
|
||||||
|
print(f"Host for patches to be created is {address}")
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||||
|
for rom in sys.argv:
|
||||||
|
try:
|
||||||
|
if rom.endswith(".sfc"):
|
||||||
|
print(f"Creating patch for {rom}")
|
||||||
|
result = pool.submit(create_patch_file, rom, address)
|
||||||
|
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
|
||||||
|
|
||||||
|
elif rom.endswith(".apbp"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
#romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||||
|
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||||
|
adjusted = False
|
||||||
|
if adjuster_settings:
|
||||||
|
import pprint
|
||||||
|
from worlds.alttp.Rom import get_base_rom_path
|
||||||
|
adjuster_settings.rom = target
|
||||||
|
adjuster_settings.baserom = get_base_rom_path()
|
||||||
|
adjuster_settings.world = None
|
||||||
|
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||||
|
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||||
|
"reduceflashing", "deathlink"}
|
||||||
|
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
||||||
|
if hasattr(adjuster_settings, "sprite_pool"):
|
||||||
|
sprite_pool = {}
|
||||||
|
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
||||||
|
if sprite in sprite_pool:
|
||||||
|
sprite_pool[sprite] += 1
|
||||||
|
else:
|
||||||
|
sprite_pool[sprite] = 1
|
||||||
|
if sprite_pool:
|
||||||
|
printed_options["sprite_pool"] = sprite_pool
|
||||||
|
|
||||||
|
adjust_wanted = str('no')
|
||||||
|
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
|
||||||
|
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||||
|
f"{pprint.pformat(printed_options)}\n"
|
||||||
|
f"Enter yes, no, always or never: ")
|
||||||
|
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
|
||||||
|
adjust_wanted = 'no'
|
||||||
|
elif adjuster_settings.auto_apply == 'always':
|
||||||
|
adjust_wanted = 'yes'
|
||||||
|
|
||||||
|
if adjust_wanted and "never" in adjust_wanted:
|
||||||
|
adjuster_settings.auto_apply = 'never'
|
||||||
|
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||||
|
|
||||||
|
elif adjust_wanted and "always" in adjust_wanted:
|
||||||
|
adjuster_settings.auto_apply = 'always'
|
||||||
|
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||||
|
|
||||||
|
if adjust_wanted and adjust_wanted.startswith("y"):
|
||||||
|
if hasattr(adjuster_settings, "sprite_pool"):
|
||||||
|
from LttPAdjuster import AdjusterWorld
|
||||||
|
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
||||||
|
|
||||||
|
adjusted = True
|
||||||
|
import LttPAdjuster
|
||||||
|
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
||||||
|
|
||||||
|
if hasattr(adjuster_settings, "world"):
|
||||||
|
delattr(adjuster_settings, "world")
|
||||||
|
else:
|
||||||
|
adjusted = False
|
||||||
|
if adjusted:
|
||||||
|
try:
|
||||||
|
shutil.move(romfile, target)
|
||||||
|
romfile = target
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
print(f"Created rom {romfile if adjusted else target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
elif rom.endswith(".apm3"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
print(f"Created rom {target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
elif rom.endswith(".apsmz"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
print(f"Created rom {target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
elif rom.endswith(".apdkc3"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
print(f"Created rom {target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
|
||||||
|
elif rom.endswith(".zip"):
|
||||||
|
print(f"Updating host in patch files contained in {rom}")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||||
|
data = zfr.read(zfinfo)
|
||||||
|
if zfinfo.filename.endswith(".apbp") or \
|
||||||
|
zfinfo.filename.endswith(".apm3") or \
|
||||||
|
zfinfo.filename.endswith(".apdkc3"):
|
||||||
|
data = update_patch_data(data, server)
|
||||||
|
with ziplock:
|
||||||
|
zfw.writestr(zfinfo, data)
|
||||||
|
return zfinfo.filename
|
||||||
|
|
||||||
|
|
||||||
|
futures = []
|
||||||
|
with zipfile.ZipFile(rom, "r") as zfr:
|
||||||
|
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
|
||||||
|
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
|
||||||
|
compresslevel=9) as zfw:
|
||||||
|
for zfname in zfr.namelist():
|
||||||
|
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
|
||||||
|
for future in futures:
|
||||||
|
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
|
||||||
|
|
||||||
|
except:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
input("Press enter to close.")
|
||||||
|
|||||||
304
PokemonClient.py
304
PokemonClient.py
@@ -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()
|
|
||||||
@@ -28,11 +28,6 @@ Currently, the following games are supported:
|
|||||||
* Starcraft 2: Wings of Liberty
|
* Starcraft 2: Wings of Liberty
|
||||||
* Donkey Kong Country 3
|
* Donkey Kong Country 3
|
||||||
* Dark Souls 3
|
* Dark Souls 3
|
||||||
* Super Mario World
|
|
||||||
* Pokémon Red and Blue
|
|
||||||
* Hylics 2
|
|
||||||
* Overcooked! 2
|
|
||||||
* Zillion
|
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
1175
SNIClient.py
1175
SNIClient.py
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,21 @@ import typing
|
|||||||
import queue
|
import queue
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# CommonClient import first to trigger ModuleUpdater
|
import nest_asyncio
|
||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
import sc2
|
||||||
|
from sc2.bot_ai import BotAI
|
||||||
|
from sc2.data import Race
|
||||||
|
from sc2.main import run_game
|
||||||
|
from sc2.player import Bot
|
||||||
|
|
||||||
|
import NetUtils
|
||||||
|
from MultiServer import mark_raw
|
||||||
from Utils import init_logging, is_windows
|
from Utils import init_logging, is_windows
|
||||||
|
from worlds.sc2wol import SC2WoLWorld
|
||||||
|
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
|
||||||
|
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
||||||
|
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
||||||
|
from worlds.sc2wol.Regions import MissionInfo
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_logging("SC2Client", exception_logger="Client")
|
init_logging("SC2Client", exception_logger="Client")
|
||||||
@@ -22,21 +34,10 @@ if __name__ == "__main__":
|
|||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
sc2_logger = logging.getLogger("Starcraft2")
|
sc2_logger = logging.getLogger("Starcraft2")
|
||||||
|
|
||||||
import nest_asyncio
|
|
||||||
import sc2
|
|
||||||
from sc2.bot_ai import BotAI
|
|
||||||
from sc2.data import Race
|
|
||||||
from sc2.main import run_game
|
|
||||||
from sc2.player import Bot
|
|
||||||
from worlds.sc2wol import SC2WoLWorld
|
|
||||||
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
|
|
||||||
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
|
||||||
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
|
||||||
from worlds.sc2wol.Regions import MissionInfo
|
|
||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser
|
|
||||||
from MultiServer import mark_raw
|
from NetUtils import ClientStatus, RawJSONtoTextParser
|
||||||
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
|
|
||||||
nest_asyncio.apply()
|
nest_asyncio.apply()
|
||||||
max_bonus: int = 8
|
max_bonus: int = 8
|
||||||
@@ -114,40 +115,12 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
|||||||
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
||||||
if path:
|
if path:
|
||||||
os.environ["SC2PATH"] = path
|
os.environ["SC2PATH"] = path
|
||||||
is_mod_installed_correctly()
|
check_mod_install()
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_download_data(self, force: bool = False) -> bool:
|
|
||||||
"""Download the most recent release of the necessary files for playing SC2 with
|
|
||||||
Archipelago. force should be True or False. force=True will overwrite your files."""
|
|
||||||
if "SC2PATH" not in os.environ:
|
|
||||||
check_game_install_path()
|
|
||||||
|
|
||||||
if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"):
|
|
||||||
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f:
|
|
||||||
current_ver = f.read()
|
|
||||||
else:
|
|
||||||
current_ver = None
|
|
||||||
|
|
||||||
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', current_version=current_ver, force_download=force)
|
|
||||||
|
|
||||||
if tempzip != '':
|
|
||||||
try:
|
|
||||||
import zipfile
|
|
||||||
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
|
|
||||||
sc2_logger.info(f"Download complete. Version {version} installed.")
|
|
||||||
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
|
|
||||||
f.write(version)
|
|
||||||
finally:
|
|
||||||
os.remove(tempzip)
|
|
||||||
else:
|
|
||||||
sc2_logger.warning("Download aborted/failed. Read the log for more information.")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class SC2Context(CommonContext):
|
class SC2Context(CommonContext):
|
||||||
command_processor = StarcraftClientProcessor
|
command_processor = StarcraftClientProcessor
|
||||||
@@ -155,9 +128,7 @@ class SC2Context(CommonContext):
|
|||||||
items_handling = 0b111
|
items_handling = 0b111
|
||||||
difficulty = -1
|
difficulty = -1
|
||||||
all_in_choice = 0
|
all_in_choice = 0
|
||||||
mission_order = 0
|
|
||||||
mission_req_table: typing.Dict[str, MissionInfo] = {}
|
mission_req_table: typing.Dict[str, MissionInfo] = {}
|
||||||
final_mission: int = 29
|
|
||||||
announcements = queue.Queue()
|
announcements = queue.Queue()
|
||||||
sc2_run_task: typing.Optional[asyncio.Task] = None
|
sc2_run_task: typing.Optional[asyncio.Task] = None
|
||||||
missions_unlocked: bool = False # allow launching missions ignoring requirements
|
missions_unlocked: bool = False # allow launching missions ignoring requirements
|
||||||
@@ -182,25 +153,16 @@ class SC2Context(CommonContext):
|
|||||||
self.difficulty = args["slot_data"]["game_difficulty"]
|
self.difficulty = args["slot_data"]["game_difficulty"]
|
||||||
self.all_in_choice = args["slot_data"]["all_in_map"]
|
self.all_in_choice = args["slot_data"]["all_in_map"]
|
||||||
slot_req_table = args["slot_data"]["mission_req"]
|
slot_req_table = args["slot_data"]["mission_req"]
|
||||||
# Maintaining backwards compatibility with older slot data
|
|
||||||
self.mission_req_table = {
|
self.mission_req_table = {
|
||||||
mission: MissionInfo(
|
mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
|
||||||
**{field: value for field, value in mission_info.items() if field in MissionInfo._fields}
|
|
||||||
)
|
|
||||||
for mission, mission_info in slot_req_table.items()
|
|
||||||
}
|
}
|
||||||
self.mission_order = args["slot_data"].get("mission_order", 0)
|
|
||||||
self.final_mission = args["slot_data"].get("final_mission", 29)
|
|
||||||
|
|
||||||
self.build_location_to_mission_mapping()
|
self.build_location_to_mission_mapping()
|
||||||
|
|
||||||
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
|
# Look for and set SC2PATH.
|
||||||
is_mod_installed_correctly()
|
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
|
||||||
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
|
if "SC2PATH" not in os.environ and check_game_install_path():
|
||||||
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
|
check_mod_install()
|
||||||
current_ver = f.read()
|
|
||||||
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
|
|
||||||
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
|
|
||||||
|
|
||||||
def on_print_json(self, args: dict):
|
def on_print_json(self, args: dict):
|
||||||
# goes to this world
|
# goes to this world
|
||||||
@@ -312,6 +274,7 @@ class SC2Context(CommonContext):
|
|||||||
self.refresh_from_launching = True
|
self.refresh_from_launching = True
|
||||||
|
|
||||||
self.mission_panel.clear_widgets()
|
self.mission_panel.clear_widgets()
|
||||||
|
|
||||||
if self.ctx.mission_req_table:
|
if self.ctx.mission_req_table:
|
||||||
self.last_checked_locations = self.ctx.checked_locations.copy()
|
self.last_checked_locations = self.ctx.checked_locations.copy()
|
||||||
self.first_check = False
|
self.first_check = False
|
||||||
@@ -329,20 +292,17 @@ class SC2Context(CommonContext):
|
|||||||
|
|
||||||
for category in categories:
|
for category in categories:
|
||||||
category_panel = MissionCategory()
|
category_panel = MissionCategory()
|
||||||
if category.startswith('_'):
|
|
||||||
category_display_name = ''
|
|
||||||
else:
|
|
||||||
category_display_name = category
|
|
||||||
category_panel.add_widget(
|
category_panel.add_widget(
|
||||||
Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1))
|
Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
||||||
|
|
||||||
for mission in categories[category]:
|
for mission in categories[category]:
|
||||||
text: str = mission
|
text: str = mission
|
||||||
tooltip: str = ""
|
tooltip: str = ""
|
||||||
mission_id: int = self.ctx.mission_req_table[mission].id
|
|
||||||
# Map has uncollected locations
|
# Map has uncollected locations
|
||||||
if mission in unfinished_missions:
|
if mission in unfinished_missions:
|
||||||
text = f"[color=6495ED]{text}[/color]"
|
text = f"[color=6495ED]{text}[/color]"
|
||||||
|
|
||||||
elif mission in available_missions:
|
elif mission in available_missions:
|
||||||
text = f"[color=FFFFFF]{text}[/color]"
|
text = f"[color=FFFFFF]{text}[/color]"
|
||||||
# Map requirements not met
|
# Map requirements not met
|
||||||
@@ -361,16 +321,6 @@ class SC2Context(CommonContext):
|
|||||||
remaining_location_names: typing.List[str] = [
|
remaining_location_names: typing.List[str] = [
|
||||||
self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
|
self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
|
||||||
if loc in self.ctx.missing_locations]
|
if loc in self.ctx.missing_locations]
|
||||||
|
|
||||||
if mission_id == self.ctx.final_mission:
|
|
||||||
if mission in available_missions:
|
|
||||||
text = f"[color=FFBC95]{mission}[/color]"
|
|
||||||
else:
|
|
||||||
text = f"[color=D0C0BE]{mission}[/color]"
|
|
||||||
if tooltip:
|
|
||||||
tooltip += "\n"
|
|
||||||
tooltip += "Final Mission"
|
|
||||||
|
|
||||||
if remaining_location_names:
|
if remaining_location_names:
|
||||||
if tooltip:
|
if tooltip:
|
||||||
tooltip += "\n"
|
tooltip += "\n"
|
||||||
@@ -380,7 +330,7 @@ class SC2Context(CommonContext):
|
|||||||
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
||||||
mission_button.tooltip_text = tooltip
|
mission_button.tooltip_text = tooltip
|
||||||
mission_button.bind(on_press=self.mission_callback)
|
mission_button.bind(on_press=self.mission_callback)
|
||||||
self.mission_id_to_button[mission_id] = mission_button
|
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
|
||||||
category_panel.add_widget(mission_button)
|
category_panel.add_widget(mission_button)
|
||||||
|
|
||||||
category_panel.add_widget(Label(text=""))
|
category_panel.add_widget(Label(text=""))
|
||||||
@@ -407,9 +357,8 @@ class SC2Context(CommonContext):
|
|||||||
|
|
||||||
self.ui = SC2Manager(self)
|
self.ui = SC2Manager(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
import pkgutil
|
|
||||||
data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode()
|
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
|
||||||
Builder.load_string(data)
|
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
await super(SC2Context, self).shutdown()
|
await super(SC2Context, self).shutdown()
|
||||||
@@ -489,13 +438,10 @@ wol_default_categories = [
|
|||||||
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
||||||
"Char", "Char", "Char", "Char"
|
"Char", "Char", "Char", "Char"
|
||||||
]
|
]
|
||||||
wol_default_category_names = [
|
|
||||||
"Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]:
|
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
|
||||||
network_item: NetworkItem
|
network_item: NetUtils.NetworkItem
|
||||||
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
|
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
|
||||||
|
|
||||||
for network_item in items:
|
for network_item in items:
|
||||||
@@ -609,7 +555,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
|
|||||||
|
|
||||||
if self.can_read_game:
|
if self.can_read_game:
|
||||||
if game_state & (1 << 1) and not self.mission_completed:
|
if game_state & (1 << 1) and not self.mission_completed:
|
||||||
if self.mission_id != self.ctx.final_mission:
|
if self.mission_id != 29:
|
||||||
print("Mission Completed")
|
print("Mission Completed")
|
||||||
await self.ctx.send_msgs(
|
await self.ctx.send_msgs(
|
||||||
[{"cmd": 'LocationChecks',
|
[{"cmd": 'LocationChecks',
|
||||||
@@ -765,14 +711,13 @@ def calc_available_missions(ctx: SC2Context, unlocks=None):
|
|||||||
return available_missions
|
return available_missions
|
||||||
|
|
||||||
|
|
||||||
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int):
|
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete):
|
||||||
"""Returns a bool signifying if the mission has all requirements complete and can be done
|
"""Returns a bool signifying if the mission has all requirements complete and can be done
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
ctx -- instance of SC2Context
|
ctx -- instance of SC2Context
|
||||||
locations_to_check -- the mission string name to check
|
locations_to_check -- the mission string name to check
|
||||||
missions_complete -- an int of how many missions have been completed
|
missions_complete -- an int of how many missions have been completed
|
||||||
mission_path -- a list of missions that have already been checked
|
|
||||||
"""
|
"""
|
||||||
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
|
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
|
||||||
# A check for when the requirements are being or'd
|
# A check for when the requirements are being or'd
|
||||||
@@ -790,18 +735,7 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete
|
|||||||
else:
|
else:
|
||||||
req_success = False
|
req_success = False
|
||||||
|
|
||||||
# Grid-specific logic (to avoid long path checks and infinite recursion)
|
|
||||||
if ctx.mission_order in (3, 4):
|
|
||||||
if req_success:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
||||||
# Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
|
|
||||||
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
|
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
|
||||||
if not ctx.mission_req_table[mission_name].or_requirements:
|
if not ctx.mission_req_table[mission_name].or_requirements:
|
||||||
return False
|
return False
|
||||||
@@ -886,53 +820,18 @@ def check_game_install_path() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def is_mod_installed_correctly() -> bool:
|
def check_mod_install() -> bool:
|
||||||
"""Searches for all required files."""
|
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
|
||||||
if "SC2PATH" not in os.environ:
|
try:
|
||||||
check_game_install_path()
|
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
|
||||||
|
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
|
||||||
mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
|
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
||||||
modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
|
return True
|
||||||
wol_required_maps = [
|
else:
|
||||||
"ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
|
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
|
||||||
"ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
|
except KeyError:
|
||||||
"ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
|
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
|
||||||
"ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map",
|
return False
|
||||||
"ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
|
|
||||||
"ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
|
|
||||||
"ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
|
|
||||||
]
|
|
||||||
needs_files = False
|
|
||||||
|
|
||||||
# Check for maps.
|
|
||||||
missing_maps = []
|
|
||||||
for mapfile in wol_required_maps:
|
|
||||||
if not os.path.isfile(mapdir / mapfile):
|
|
||||||
missing_maps.append(mapfile)
|
|
||||||
if len(missing_maps) >= 19:
|
|
||||||
sc2_logger.warning(f"All map files missing from {mapdir}.")
|
|
||||||
needs_files = True
|
|
||||||
elif len(missing_maps) > 0:
|
|
||||||
for map in missing_maps:
|
|
||||||
sc2_logger.debug(f"Missing {map} from {mapdir}.")
|
|
||||||
sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
|
|
||||||
needs_files = True
|
|
||||||
else: # Must be no maps missing
|
|
||||||
sc2_logger.info(f"All maps found in {mapdir}.")
|
|
||||||
|
|
||||||
# Check for mods.
|
|
||||||
if os.path.isfile(modfile):
|
|
||||||
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
|
||||||
else:
|
|
||||||
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
|
|
||||||
needs_files = True
|
|
||||||
|
|
||||||
# Final verdict.
|
|
||||||
if needs_files:
|
|
||||||
sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class DllDirectory:
|
class DllDirectory:
|
||||||
@@ -971,64 +870,6 @@ class DllDirectory:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
|
|
||||||
"""Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
|
|
||||||
import requests
|
|
||||||
|
|
||||||
headers = {"Accept": 'application/vnd.github.v3+json'}
|
|
||||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
|
||||||
|
|
||||||
r1 = requests.get(url, headers=headers)
|
|
||||||
if r1.status_code == 200:
|
|
||||||
latest_version = r1.json()["tag_name"]
|
|
||||||
sc2_logger.info(f"Latest version: {latest_version}.")
|
|
||||||
else:
|
|
||||||
sc2_logger.warning(f"Status code: {r1.status_code}")
|
|
||||||
sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
|
|
||||||
sc2_logger.warning(f"text: {r1.text}")
|
|
||||||
return "", current_version
|
|
||||||
|
|
||||||
if (force_download is False) and (current_version == latest_version):
|
|
||||||
sc2_logger.info("Latest version already installed.")
|
|
||||||
return "", current_version
|
|
||||||
|
|
||||||
sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
|
|
||||||
download_url = r1.json()["assets"][0]["browser_download_url"]
|
|
||||||
|
|
||||||
r2 = requests.get(download_url, headers=headers)
|
|
||||||
if r2.status_code == 200:
|
|
||||||
with open(f"{repo}.zip", "wb") as fh:
|
|
||||||
fh.write(r2.content)
|
|
||||||
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
|
|
||||||
return f"{repo}.zip", latest_version
|
|
||||||
else:
|
|
||||||
sc2_logger.warning(f"Status code: {r2.status_code}")
|
|
||||||
sc2_logger.warning("Download failed.")
|
|
||||||
sc2_logger.warning(f"text: {r2.text}")
|
|
||||||
return "", current_version
|
|
||||||
|
|
||||||
|
|
||||||
def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
headers = {"Accept": 'application/vnd.github.v3+json'}
|
|
||||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
|
||||||
|
|
||||||
r1 = requests.get(url, headers=headers)
|
|
||||||
if r1.status_code == 200:
|
|
||||||
latest_version = r1.json()["tag_name"]
|
|
||||||
if current_version != latest_version:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
else:
|
|
||||||
sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
|
|
||||||
sc2_logger.warning(f"Status code: {r1.status_code}")
|
|
||||||
sc2_logger.warning(f"text: {r1.text}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.init()
|
colorama.init()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
79
Utils.py
79
Utils.py
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
import os
|
import os
|
||||||
@@ -12,8 +11,6 @@ import io
|
|||||||
import collections
|
import collections
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
|
|
||||||
|
|
||||||
from yaml import load, load_all, dump, SafeLoader
|
from yaml import load, load_all, dump, SafeLoader
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -38,7 +35,7 @@ class Version(typing.NamedTuple):
|
|||||||
build: int
|
build: int
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.3.6"
|
__version__ = "0.3.5"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -142,7 +139,7 @@ def user_path(*path: str) -> str:
|
|||||||
return os.path.join(user_path.cached_path, *path)
|
return os.path.join(user_path.cached_path, *path)
|
||||||
|
|
||||||
|
|
||||||
def output_path(*path: str) -> str:
|
def output_path(*path: str):
|
||||||
if hasattr(output_path, 'cached_path'):
|
if hasattr(output_path, 'cached_path'):
|
||||||
return os.path.join(output_path.cached_path, *path)
|
return os.path.join(output_path.cached_path, *path)
|
||||||
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
||||||
@@ -220,11 +217,8 @@ def get_public_ipv6() -> str:
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
|
|
||||||
|
|
||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_default_options() -> OptionsType:
|
def get_default_options() -> dict:
|
||||||
# Refer to host.yaml for comments as to what all these options mean.
|
# Refer to host.yaml for comments as to what all these options mean.
|
||||||
options = {
|
options = {
|
||||||
"general_options": {
|
"general_options": {
|
||||||
@@ -232,21 +226,20 @@ def get_default_options() -> OptionsType:
|
|||||||
},
|
},
|
||||||
"factorio_options": {
|
"factorio_options": {
|
||||||
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
||||||
"filter_item_sends": False,
|
|
||||||
"bridge_chat_out": True,
|
|
||||||
},
|
|
||||||
"sni_options": {
|
|
||||||
"sni": "SNI",
|
|
||||||
"snes_rom_start": True,
|
|
||||||
},
|
},
|
||||||
"sm_options": {
|
"sm_options": {
|
||||||
"rom_file": "Super Metroid (JU).sfc",
|
"rom_file": "Super Metroid (JU).sfc",
|
||||||
|
"sni": "SNI",
|
||||||
|
"rom_start": True,
|
||||||
},
|
},
|
||||||
"soe_options": {
|
"soe_options": {
|
||||||
"rom_file": "Secret of Evermore (USA).sfc",
|
"rom_file": "Secret of Evermore (USA).sfc",
|
||||||
},
|
},
|
||||||
"lttp_options": {
|
"lttp_options": {
|
||||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||||
|
"sni": "SNI",
|
||||||
|
"rom_start": True,
|
||||||
|
|
||||||
},
|
},
|
||||||
"server_options": {
|
"server_options": {
|
||||||
"host": None,
|
"host": None,
|
||||||
@@ -289,27 +282,15 @@ def get_default_options() -> OptionsType:
|
|||||||
},
|
},
|
||||||
"dkc3_options": {
|
"dkc3_options": {
|
||||||
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||||
|
"sni": "SNI",
|
||||||
|
"rom_start": True,
|
||||||
},
|
},
|
||||||
"smw_options": {
|
|
||||||
"rom_file": "Super Mario World (USA).sfc",
|
|
||||||
},
|
|
||||||
"zillion_options": {
|
|
||||||
"rom_file": "Zillion (UE) [!].sms",
|
|
||||||
# RetroArch doesn't make it easy to launch a game from the command line.
|
|
||||||
# You have to know the path to the emulator core library on the user's computer.
|
|
||||||
"rom_start": "retroarch",
|
|
||||||
},
|
|
||||||
"pokemon_rb_options": {
|
|
||||||
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
|
|
||||||
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
|
|
||||||
"rom_start": True
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType:
|
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||||
for key, value in src.items():
|
for key, value in src.items():
|
||||||
new_keys = keys.copy()
|
new_keys = keys.copy()
|
||||||
new_keys.append(key)
|
new_keys.append(key)
|
||||||
@@ -329,9 +310,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsT
|
|||||||
|
|
||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_options() -> OptionsType:
|
def get_options() -> dict:
|
||||||
filenames = ("options.yaml", "host.yaml")
|
filenames = ("options.yaml", "host.yaml")
|
||||||
locations: typing.List[str] = []
|
locations = []
|
||||||
if os.path.join(os.getcwd()) != local_path():
|
if os.path.join(os.getcwd()) != local_path():
|
||||||
locations += filenames # use files from cwd only if it's not the local_path
|
locations += filenames # use files from cwd only if it's not the local_path
|
||||||
locations += [user_path(filename) for filename in filenames]
|
locations += [user_path(filename) for filename in filenames]
|
||||||
@@ -372,7 +353,7 @@ def persistent_load() -> typing.Dict[str, dict]:
|
|||||||
return storage
|
return storage
|
||||||
|
|
||||||
|
|
||||||
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
|
def get_adjuster_settings(game_name: str):
|
||||||
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||||
return adjuster_settings
|
return adjuster_settings
|
||||||
|
|
||||||
@@ -411,8 +392,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
# Options and Plando are unpickled by WebHost -> Generate
|
# Options and Plando are unpickled by WebHost -> Generate
|
||||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||||
return getattr(self.generic_properties_module, name)
|
return getattr(self.generic_properties_module, name)
|
||||||
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
if module.endswith("Options"):
|
||||||
if module.lower().endswith("options"):
|
|
||||||
if module == "Options":
|
if module == "Options":
|
||||||
mod = self.options_module
|
mod = self.options_module
|
||||||
else:
|
else:
|
||||||
@@ -643,32 +623,3 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
|
|||||||
else:
|
else:
|
||||||
return element.lower()
|
return element.lower()
|
||||||
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
||||||
|
|
||||||
|
|
||||||
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
|
||||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
|
||||||
buffer = bytearray(stream.read())
|
|
||||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
|
||||||
return buffer[0x200:]
|
|
||||||
return buffer
|
|
||||||
|
|
||||||
|
|
||||||
_faf_tasks: "Set[asyncio.Task[None]]" = set()
|
|
||||||
|
|
||||||
|
|
||||||
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
|
|
||||||
"""
|
|
||||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
|
||||||
to prevent early garbage collection. "fire-and-forget"
|
|
||||||
"""
|
|
||||||
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
|
||||||
# Python docs:
|
|
||||||
# ```
|
|
||||||
# Important: Save a reference to the result of [asyncio.create_task],
|
|
||||||
# to avoid a task disappearing mid-execution.
|
|
||||||
# ```
|
|
||||||
# This implementation follows the pattern given in that documentation.
|
|
||||||
|
|
||||||
task = asyncio.create_task(co, name=name)
|
|
||||||
_faf_tasks.add(task)
|
|
||||||
task.add_done_callback(_faf_tasks.discard)
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import base64
|
|
||||||
import os
|
import os
|
||||||
import socket
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import base64
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from pony.flask import Pony
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
from pony.flask import Pony
|
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from Utils import title_sorted
|
from Utils import title_sorted
|
||||||
|
from .models import *
|
||||||
|
|
||||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||||
LOGS_FOLDER = os.path.relpath('logs')
|
LOGS_FOLDER = os.path.relpath('logs')
|
||||||
@@ -31,10 +32,8 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
|||||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||||
# if you want to deploy, make sure you have a non-guessable secret key
|
# if you want to deploy, make sure you have a non-guessable secret key
|
||||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
|
||||||
app.config["JOB_THRESHOLD"] = 2
|
app.config["JOB_THRESHOLD"] = 2
|
||||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
|
||||||
app.config["JOB_TIME"] = 600
|
|
||||||
app.config['SESSION_PERMANENT'] = True
|
app.config['SESSION_PERMANENT'] = True
|
||||||
|
|
||||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||||
@@ -74,10 +73,8 @@ def register():
|
|||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
import worlds.AutoWorld
|
import Patch
|
||||||
import worlds.Files
|
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
||||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
|
||||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
# to trigger app routing picking up on it
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""API endpoints package."""
|
"""API endpoints package."""
|
||||||
from typing import List, Tuple
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
from flask import Blueprint, abort
|
from flask import Blueprint, abort
|
||||||
|
|
||||||
from .. import cache
|
|
||||||
from ..models import Room, Seed
|
from ..models import Room, Seed
|
||||||
|
from .. import cache
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -46,4 +46,4 @@ def get_datapackage_versions():
|
|||||||
return version_package
|
return version_package
|
||||||
|
|
||||||
|
|
||||||
from . import generate, user # trigger registration
|
from . import generate, user, tracker # trigger registration
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from . import api_endpoints
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
from pony.orm import commit
|
from pony.orm import commit
|
||||||
|
|
||||||
from WebHostLib import app
|
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||||
from WebHostLib.check import get_yaml_data, roll_options
|
from WebHostLib.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
from WebHostLib.generate import get_meta
|
||||||
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
|
||||||
from . import api_endpoints
|
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/generate', methods=['POST'])
|
@api_endpoints.route('/generate', methods=['POST'])
|
||||||
|
|||||||
50
WebHostLib/api/tracker.py
Normal file
50
WebHostLib/api/tracker.py
Normal 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
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from flask import session, jsonify
|
from flask import session, jsonify
|
||||||
from pony.orm import select
|
|
||||||
|
|
||||||
from WebHostLib.models import Room, Seed
|
from WebHostLib.models import *
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
import typing
|
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import typing
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit
|
||||||
|
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import zipfile
|
import zipfile
|
||||||
from typing import *
|
from typing import *
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, render_template
|
from flask import request, flash, redirect, url_for, session, render_template
|
||||||
|
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import random
|
|||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
from pony.orm import db_session, commit, select
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
from .models import db_session, Room, select, commit, Command, db
|
||||||
|
|
||||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||||
from .models import Room, Command, db
|
|
||||||
|
|
||||||
|
|
||||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||||
@@ -184,12 +183,4 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
|||||||
|
|
||||||
from .autolauncher import Locker
|
from .autolauncher import Locker
|
||||||
with Locker(room_id):
|
with Locker(room_id):
|
||||||
try:
|
asyncio.run(main())
|
||||||
asyncio.run(main())
|
|
||||||
except:
|
|
||||||
with db_session:
|
|
||||||
room = Room.get(id=room_id)
|
|
||||||
room.last_port = -1
|
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
|
||||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import json
|
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import json
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from flask import send_file, Response, render_template
|
from flask import send_file, Response, render_template
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
from worlds.Files import AutoPatchRegister
|
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
||||||
from . import app, cache
|
from WebHostLib import app, Slot, Room, Seed, cache
|
||||||
from .models import Slot, Room, Seed
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||||
@@ -42,7 +41,12 @@ def download_patch(room_id, patch_id):
|
|||||||
new_file.seek(0)
|
new_file.seek(0)
|
||||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||||
else:
|
else:
|
||||||
return "Old Patch file, no longer compatible."
|
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||||
|
patch_data = BytesIO(patch_data)
|
||||||
|
|
||||||
|
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||||
|
f"{preferred_endings[patch.game]}"
|
||||||
|
return send_file(patch_data, as_attachment=True, download_name=fname)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||||
@@ -75,8 +79,6 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||||
elif slot_data.game == "VVVVVV":
|
elif slot_data.game == "VVVVVV":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||||
elif slot_data.game == "Zillion":
|
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
|
|
||||||
elif slot_data.game == "Super Mario 64":
|
elif slot_data.game == "Super Mario 64":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||||
elif slot_data.game == "Dark Souls III":
|
elif slot_data.game == "Dark Souls III":
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
import random
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import random
|
||||||
|
import json
|
||||||
import zipfile
|
import zipfile
|
||||||
import concurrent.futures
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, Optional, Any
|
||||||
|
from Utils import __version__
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, session, render_template
|
from flask import request, flash, redirect, url_for, session, render_template
|
||||||
from pony.orm import commit, db_session
|
|
||||||
|
|
||||||
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
|
from Main import main as ERmain
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed
|
||||||
from Generate import handle_name, PlandoSettings
|
from Generate import handle_name, PlandoSettings
|
||||||
from Main import main as ERmain
|
import pickle
|
||||||
from Utils import __version__
|
|
||||||
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
|
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
|
|||||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
race = meta.setdefault("race", False)
|
race = meta.setdefault("race", False)
|
||||||
|
|
||||||
def task():
|
try:
|
||||||
target = tempfile.TemporaryDirectory()
|
target = tempfile.TemporaryDirectory()
|
||||||
playercount = len(gen_options)
|
playercount = len(gen_options)
|
||||||
seed = get_seed()
|
seed = get_seed()
|
||||||
@@ -139,23 +138,6 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
|
|||||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
||||||
thread = thread_pool.submit(task)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return thread.result(app.config["JOB_TIME"])
|
|
||||||
except concurrent.futures.TimeoutError as e:
|
|
||||||
if sid:
|
|
||||||
with db_session:
|
|
||||||
gen = Generation.get(id=sid)
|
|
||||||
if gen is not None:
|
|
||||||
gen.state = STATE_ERROR
|
|
||||||
meta = json.loads(gen.meta)
|
|
||||||
meta["error"] = (
|
|
||||||
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
|
||||||
e.__class__.__name__ + ": " + str(e))
|
|
||||||
gen.meta = json.dumps(meta)
|
|
||||||
commit()
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
from datetime import timedelta, datetime
|
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from pony.orm import count
|
|
||||||
|
|
||||||
from WebHostLib import app, cache
|
from WebHostLib import app, cache
|
||||||
from .models import Room, Seed
|
from .models import *
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def update_sprites_lttp():
|
|||||||
|
|
||||||
spriteData = []
|
spriteData = []
|
||||||
|
|
||||||
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
|
for file in os.listdir(input_dir):
|
||||||
sprite = Sprite(os.path.join(input_dir, file))
|
sprite = Sprite(os.path.join(input_dir, file))
|
||||||
|
|
||||||
if not sprite.name:
|
if not sprite.name:
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import os
|
|||||||
|
|
||||||
import jinja2.exceptions
|
import jinja2.exceptions
|
||||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||||
from pony.orm import count, commit, db_session
|
|
||||||
|
|
||||||
|
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .models import Seed, Room, Command, UUID, uuid4
|
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
def get_world_theme(game_name: str):
|
||||||
@@ -152,7 +151,7 @@ def favicon():
|
|||||||
|
|
||||||
@app.route('/discord')
|
@app.route('/discord')
|
||||||
def discord():
|
def discord():
|
||||||
return redirect("https://discord.gg/8Z65BR2")
|
return redirect("https://discord.gg/archipelago")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/datapackage')
|
@app.route('/datapackage')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
from pony.orm import *
|
||||||
|
|
||||||
db = Database()
|
db = Database()
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ class Room(db.Entity):
|
|||||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||||
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||||
tracker = Optional(UUID, index=True)
|
tracker = Optional(UUID, index=True)
|
||||||
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
|
|
||||||
last_port = Optional(int, default=lambda: 0)
|
last_port = Optional(int, default=lambda: 0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from Utils import __version__, local_path
|
||||||
|
from jinja2 import Template
|
||||||
|
import yaml
|
||||||
|
import json
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import yaml
|
|
||||||
from jinja2 import Template
|
|
||||||
|
|
||||||
import Options
|
|
||||||
from Utils import __version__, local_path
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
import Options
|
||||||
|
|
||||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||||
"exclude_locations"}
|
"exclude_locations"}
|
||||||
@@ -16,23 +15,26 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
|
|||||||
|
|
||||||
def create():
|
def create():
|
||||||
target_folder = local_path("WebHostLib", "static", "generated")
|
target_folder = local_path("WebHostLib", "static", "generated")
|
||||||
yaml_folder = os.path.join(target_folder, "configs")
|
os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
|
||||||
os.makedirs(yaml_folder, exist_ok=True)
|
|
||||||
|
|
||||||
for file in os.listdir(yaml_folder):
|
|
||||||
full_path: str = os.path.join(yaml_folder, file)
|
|
||||||
if os.path.isfile(full_path):
|
|
||||||
os.unlink(full_path)
|
|
||||||
|
|
||||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||||
data = {option.default: 50}
|
data = {}
|
||||||
for sub_option in ["random", "random-low", "random-high"]:
|
special = getattr(option, "special_range_cutoff", None)
|
||||||
if sub_option != option.default:
|
if special is not None:
|
||||||
data[sub_option] = 0
|
data[special] = 0
|
||||||
|
data.update({
|
||||||
|
option.range_start: 0,
|
||||||
|
option.range_end: 0,
|
||||||
|
"random": 0, "random-low": 0, "random-high": 0,
|
||||||
|
option.default: 50
|
||||||
|
})
|
||||||
|
notes = {
|
||||||
|
special: "minimum value without special meaning",
|
||||||
|
option.range_start: "minimum value",
|
||||||
|
option.range_end: "maximum value"
|
||||||
|
}
|
||||||
|
|
||||||
notes = {}
|
|
||||||
for name, number in getattr(option, "special_range_names", {}).items():
|
for name, number in getattr(option, "special_range_names", {}).items():
|
||||||
notes[name] = f"equivalent to {number}"
|
|
||||||
if number in data:
|
if number in data:
|
||||||
data[name] = data[number]
|
data[name] = data[number]
|
||||||
del data[number]
|
del data[number]
|
||||||
@@ -41,6 +43,11 @@ def create():
|
|||||||
|
|
||||||
return data, notes
|
return data, notes
|
||||||
|
|
||||||
|
def default_converter(default_value):
|
||||||
|
if isinstance(default_value, (set, frozenset)):
|
||||||
|
return list(default_value)
|
||||||
|
return default_value
|
||||||
|
|
||||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||||
if not option_type.__doc__:
|
if not option_type.__doc__:
|
||||||
return "Please document me!"
|
return "Please document me!"
|
||||||
@@ -57,16 +64,13 @@ def create():
|
|||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
|
|
||||||
all_options: typing.Dict[str, Options.AssembleOptions] = {
|
all_options = {**Options.per_game_common_options, **world.option_definitions}
|
||||||
**Options.per_game_common_options,
|
|
||||||
**world.option_definitions
|
|
||||||
}
|
|
||||||
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
||||||
file_data = f.read()
|
file_data = f.read()
|
||||||
res = Template(file_data).render(
|
res = Template(file_data).render(
|
||||||
options=all_options,
|
options=all_options,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range, default_converter=default_converter,
|
||||||
)
|
)
|
||||||
|
|
||||||
del file_data
|
del file_data
|
||||||
@@ -106,6 +110,11 @@ def create():
|
|||||||
if sub_option_id == option.default:
|
if sub_option_id == option.default:
|
||||||
this_option["defaultValue"] = sub_option_name
|
this_option["defaultValue"] = sub_option_name
|
||||||
|
|
||||||
|
this_option["options"].append({
|
||||||
|
"name": "Random",
|
||||||
|
"value": "random",
|
||||||
|
})
|
||||||
|
|
||||||
if option.default == "random":
|
if option.default == "random":
|
||||||
this_option["defaultValue"] = "random"
|
this_option["defaultValue"] = "random"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ flask>=2.2.2
|
|||||||
pony>=0.7.16
|
pony>=0.7.16
|
||||||
waitress>=2.1.2
|
waitress>=2.1.2
|
||||||
Flask-Caching>=2.0.1
|
Flask-Caching>=2.0.1
|
||||||
Flask-Compress>=1.13
|
Flask-Compress>=1.12
|
||||||
Flask-Limiter>=2.7.0
|
Flask-Limiter>=2.6.2
|
||||||
bokeh>=3.0.0
|
bokeh>=2.4.3
|
||||||
|
|||||||
@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
header.setAttribute('id', headerId);
|
for (let i=0; i < headers.length; i++){
|
||||||
header.addEventListener('click', () => {
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
window.location.hash = `#${headerId}`;
|
headers[i].setAttribute('id', headerId);
|
||||||
header.scrollIntoView();
|
headers[i].addEventListener('click', () =>
|
||||||
});
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
document.fonts.ready.finally(() => {
|
if (scrollTargetIndex > -1) {
|
||||||
if (window.location.hash) {
|
try{
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
scrollTarget?.scrollIntoView();
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ the website is not required to generate them.
|
|||||||
## How do I get started?
|
## How do I get started?
|
||||||
|
|
||||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||||
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
|
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||||
any questions you might have.
|
any questions you might have.
|
||||||
|
|
||||||
## What are some common terms I should know?
|
## What are some common terms I should know?
|
||||||
|
|||||||
@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
header.setAttribute('id', headerId);
|
for (let i=0; i < headers.length; i++){
|
||||||
header.addEventListener('click', () => {
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
window.location.hash = `#${headerId}`;
|
headers[i].setAttribute('id', headerId);
|
||||||
header.scrollIntoView();
|
headers[i].addEventListener('click', () =>
|
||||||
});
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
document.fonts.ready.finally(() => {
|
if (scrollTargetIndex > -1) {
|
||||||
if (window.location.hash) {
|
try{
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
scrollTarget?.scrollIntoView();
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
gameInfo.innerHTML =
|
gameInfo.innerHTML =
|
||||||
|
|||||||
@@ -26,22 +26,24 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
header.setAttribute('id', headerId);
|
for (let i=0; i < headers.length; i++){
|
||||||
header.addEventListener('click', () => {
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
window.location.hash = `#${headerId}`;
|
headers[i].setAttribute('id', headerId);
|
||||||
header.scrollIntoView();
|
headers[i].addEventListener('click', () =>
|
||||||
});
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
document.fonts.ready.finally(() => {
|
if (scrollTargetIndex > -1) {
|
||||||
if (window.location.hash) {
|
try{
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
scrollTarget?.scrollIntoView();
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -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)
|
|
||||||
});
|
|
||||||
@@ -118,8 +118,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
const tdr = document.createElement('td');
|
const tdr = document.createElement('td');
|
||||||
let element = null;
|
let element = null;
|
||||||
|
|
||||||
const randomButton = document.createElement('button');
|
|
||||||
|
|
||||||
switch(settings[setting].type){
|
switch(settings[setting].type){
|
||||||
case 'select':
|
case 'select':
|
||||||
element = document.createElement('div');
|
element = document.createElement('div');
|
||||||
@@ -140,21 +138,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
}
|
}
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
select.addEventListener('change', (event) => updateGameSetting(event.target));
|
select.addEventListener('change', (event) => updateGameSetting(event));
|
||||||
element.appendChild(select);
|
element.appendChild(select);
|
||||||
|
|
||||||
// Randomize button
|
|
||||||
randomButton.innerText = '🎲';
|
|
||||||
randomButton.classList.add('randomize-button');
|
|
||||||
randomButton.setAttribute('data-key', setting);
|
|
||||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
|
||||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
|
|
||||||
if (currentSettings[gameName][setting] === 'random') {
|
|
||||||
randomButton.classList.add('active');
|
|
||||||
select.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
element.appendChild(randomButton);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'range':
|
case 'range':
|
||||||
@@ -169,29 +154,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
range.value = currentSettings[gameName][setting];
|
range.value = currentSettings[gameName][setting];
|
||||||
range.addEventListener('change', (event) => {
|
range.addEventListener('change', (event) => {
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event.target);
|
updateGameSetting(event);
|
||||||
});
|
});
|
||||||
element.appendChild(range);
|
element.appendChild(range);
|
||||||
|
|
||||||
let rangeVal = document.createElement('span');
|
let rangeVal = document.createElement('span');
|
||||||
rangeVal.classList.add('range-value');
|
rangeVal.classList.add('range-value');
|
||||||
rangeVal.setAttribute('id', `${setting}-value`);
|
rangeVal.setAttribute('id', `${setting}-value`);
|
||||||
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||||
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
|
||||||
element.appendChild(rangeVal);
|
element.appendChild(rangeVal);
|
||||||
|
|
||||||
// Randomize button
|
|
||||||
randomButton.innerText = '🎲';
|
|
||||||
randomButton.classList.add('randomize-button');
|
|
||||||
randomButton.setAttribute('data-key', setting);
|
|
||||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
|
||||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
|
|
||||||
if (currentSettings[gameName][setting] === 'random') {
|
|
||||||
randomButton.classList.add('active');
|
|
||||||
range.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
element.appendChild(randomButton);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'special_range':
|
case 'special_range':
|
||||||
@@ -230,8 +201,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
let specialRangeVal = document.createElement('span');
|
let specialRangeVal = document.createElement('span');
|
||||||
specialRangeVal.classList.add('range-value');
|
specialRangeVal.classList.add('range-value');
|
||||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||||
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||||
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
|
||||||
|
|
||||||
// Configure select event listener
|
// Configure select event listener
|
||||||
specialRangeSelect.addEventListener('change', (event) => {
|
specialRangeSelect.addEventListener('change', (event) => {
|
||||||
@@ -240,7 +210,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
// Update range slider
|
// Update range slider
|
||||||
specialRange.value = event.target.value;
|
specialRange.value = event.target.value;
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event.target);
|
updateGameSetting(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure range event handler
|
// Configure range event handler
|
||||||
@@ -250,29 +220,13 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||||
parseInt(event.target.value) : 'custom';
|
parseInt(event.target.value) : 'custom';
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event.target);
|
updateGameSetting(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
element.appendChild(specialRangeSelect);
|
element.appendChild(specialRangeSelect);
|
||||||
specialRangeWrapper.appendChild(specialRange);
|
specialRangeWrapper.appendChild(specialRange);
|
||||||
specialRangeWrapper.appendChild(specialRangeVal);
|
specialRangeWrapper.appendChild(specialRangeVal);
|
||||||
element.appendChild(specialRangeWrapper);
|
element.appendChild(specialRangeWrapper);
|
||||||
|
|
||||||
// Randomize button
|
|
||||||
randomButton.innerText = '🎲';
|
|
||||||
randomButton.classList.add('randomize-button');
|
|
||||||
randomButton.setAttribute('data-key', setting);
|
|
||||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
|
||||||
randomButton.addEventListener('click', (event) => toggleRandomize(
|
|
||||||
event, [specialRange, specialRangeSelect])
|
|
||||||
);
|
|
||||||
if (currentSettings[gameName][setting] === 'random') {
|
|
||||||
randomButton.classList.add('active');
|
|
||||||
specialRange.disabled = true;
|
|
||||||
specialRangeSelect.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
specialRangeWrapper.appendChild(randomButton);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -289,25 +243,6 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
return table;
|
return table;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRandomize = (event, inputElements) => {
|
|
||||||
const active = event.target.classList.contains('active');
|
|
||||||
const randomButton = event.target;
|
|
||||||
|
|
||||||
if (active) {
|
|
||||||
randomButton.classList.remove('active');
|
|
||||||
for (const element of inputElements) {
|
|
||||||
element.disabled = undefined;
|
|
||||||
updateGameSetting(element);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
randomButton.classList.add('active');
|
|
||||||
for (const element of inputElements) {
|
|
||||||
element.disabled = true;
|
|
||||||
updateGameSetting(randomButton);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateBaseSetting = (event) => {
|
const updateBaseSetting = (event) => {
|
||||||
const options = JSON.parse(localStorage.getItem(gameName));
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
@@ -315,17 +250,10 @@ const updateBaseSetting = (event) => {
|
|||||||
localStorage.setItem(gameName, JSON.stringify(options));
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateGameSetting = (settingElement) => {
|
const updateGameSetting = (event) => {
|
||||||
const options = JSON.parse(localStorage.getItem(gameName));
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
|
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
if (settingElement.classList.contains('randomize-button')) {
|
event.target.value : parseInt(event.target.value, 10);
|
||||||
// If the event passed in is the randomize button, then we know what we must do.
|
|
||||||
options[gameName][settingElement.getAttribute('data-key')] = 'random';
|
|
||||||
} else {
|
|
||||||
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
|
|
||||||
settingElement.value : parseInt(settingElement.value, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(gameName, JSON.stringify(options));
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
82
WebHostLib/static/assets/trackers/playerTracker.js
Normal file
82
WebHostLib/static/assets/trackers/playerTracker.js
Normal 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('▲', '▼');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
82
WebHostLib/static/assets/trackers/zeldaKeysTracker.js
Normal file
82
WebHostLib/static/assets/trackers/zeldaKeysTracker.js
Normal 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('▲', '▼');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -27,28 +27,25 @@ window.addEventListener('load', () => {
|
|||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
const title = document.querySelector('h1')
|
|
||||||
if (title) {
|
|
||||||
document.title = title.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
header.setAttribute('id', headerId);
|
for (let i=0; i < headers.length; i++){
|
||||||
header.addEventListener('click', () => {
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
window.location.hash = `#${headerId}`;
|
headers[i].setAttribute('id', headerId);
|
||||||
header.scrollIntoView();
|
headers[i].addEventListener('click', () =>
|
||||||
});
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
document.fonts.ready.finally(() => {
|
if (scrollTargetIndex > -1) {
|
||||||
if (window.location.hash) {
|
try{
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
scrollTarget?.scrollIntoView();
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -78,16 +78,13 @@ const createDefaultSettings = (settingData) => {
|
|||||||
break;
|
break;
|
||||||
case 'range':
|
case 'range':
|
||||||
case 'special_range':
|
case 'special_range':
|
||||||
newSettings[game][gameSetting][setting.min] = 0;
|
for (let i = setting.min; i <= setting.max; ++i){
|
||||||
newSettings[game][gameSetting][setting.max] = 0;
|
newSettings[game][gameSetting][i] =
|
||||||
|
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
|
||||||
|
}
|
||||||
newSettings[game][gameSetting]['random'] = 0;
|
newSettings[game][gameSetting]['random'] = 0;
|
||||||
newSettings[game][gameSetting]['random-low'] = 0;
|
newSettings[game][gameSetting]['random-low'] = 0;
|
||||||
newSettings[game][gameSetting]['random-high'] = 0;
|
newSettings[game][gameSetting]['random-high'] = 0;
|
||||||
if (setting.hasOwnProperty('defaultValue')) {
|
|
||||||
newSettings[game][gameSetting][setting.defaultValue] = 25;
|
|
||||||
} else {
|
|
||||||
newSettings[game][gameSetting][setting.min] = 25;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'items-list':
|
case 'items-list':
|
||||||
@@ -404,17 +401,11 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
tr.appendChild(tdDelete);
|
tr.appendChild(tdDelete);
|
||||||
|
|
||||||
rangeTbody.appendChild(tr);
|
rangeTbody.appendChild(tr);
|
||||||
|
|
||||||
// Save new option to settings
|
|
||||||
range.dispatchEvent(new Event('change'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
||||||
// These options are statically generated below, and should always appear even if they are deleted
|
if (currentSettings[game][settingName][option] > 0) {
|
||||||
// from localStorage
|
const tr = document.createElement('tr');
|
||||||
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
|
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
const tdLeft = document.createElement('td');
|
const tdLeft = document.createElement('td');
|
||||||
tdLeft.classList.add('td-left');
|
tdLeft.classList.add('td-left');
|
||||||
tdLeft.innerText = option;
|
tdLeft.innerText = option;
|
||||||
@@ -448,15 +439,14 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
deleteButton.innerText = '❌';
|
deleteButton.innerText = '❌';
|
||||||
deleteButton.addEventListener('click', () => {
|
deleteButton.addEventListener('click', () => {
|
||||||
range.value = 0;
|
range.value = 0;
|
||||||
const changeEvent = new Event('change');
|
range.dispatchEvent(new Event('change'));
|
||||||
changeEvent.action = 'rangeDelete';
|
|
||||||
range.dispatchEvent(changeEvent);
|
|
||||||
rangeTbody.removeChild(tr);
|
rangeTbody.removeChild(tr);
|
||||||
});
|
});
|
||||||
tdDelete.appendChild(deleteButton);
|
tdDelete.appendChild(deleteButton);
|
||||||
tr.appendChild(tdDelete);
|
tr.appendChild(tdDelete);
|
||||||
|
|
||||||
rangeTbody.appendChild(tr);
|
rangeTbody.appendChild(tr);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,12 +904,8 @@ const updateGameSetting = (evt) => {
|
|||||||
const setting = evt.target.getAttribute('data-setting');
|
const setting = evt.target.getAttribute('data-setting');
|
||||||
const option = evt.target.getAttribute('data-option');
|
const option = evt.target.getAttribute('data-option');
|
||||||
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
||||||
console.log(event);
|
options[game][setting][option] = isNaN(evt.target.value) ?
|
||||||
if (evt.action && evt.action === 'rangeDelete') {
|
evt.target.value : parseInt(evt.target.value, 10);
|
||||||
delete options[game][setting][option];
|
|
||||||
} else {
|
|
||||||
options[game][setting][option] = parseInt(evt.target.value, 10);
|
|
||||||
}
|
|
||||||
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,4 @@
|
|||||||
border: 1px solid #2a6c2f;
|
border: 1px solid #2a6c2f;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,10 +116,6 @@ html{
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-settings table select:disabled{
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-settings table .range-container{
|
#player-settings table .range-container{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -142,27 +138,12 @@ html{
|
|||||||
#player-settings table .special-range-wrapper{
|
#player-settings table .special-range-wrapper{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-settings table .special-range-wrapper input[type=range]{
|
#player-settings table .special-range-wrapper input[type=range]{
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-settings table .randomize-button {
|
|
||||||
max-height: 24px;
|
|
||||||
line-height: 16px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
margin: 0 0 0 0.25rem;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px solid black;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-settings table .randomize-button.active {
|
|
||||||
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
|
|
||||||
}
|
|
||||||
|
|
||||||
#player-settings table label{
|
#player-settings table label{
|
||||||
display: block;
|
display: block;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
html{
|
html{
|
||||||
padding-top: 110px;
|
padding-top: 110px;
|
||||||
scroll-padding-top: 100px;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#base-header{
|
#base-header{
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
150
WebHostLib/static/styles/trackers/playerTracker.css
Normal file
150
WebHostLib/static/styles/trackers/playerTracker.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -51,6 +51,17 @@ table.dataTable{
|
|||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table.dataTable img.icon{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 60px;
|
||||||
|
max-height: 60px;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable img.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
table.dataTable thead{
|
table.dataTable thead{
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import typing
|
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from colorsys import hsv_to_rgb
|
from colorsys import hsv_to_rgb
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from math import tau
|
from math import tau
|
||||||
|
import typing
|
||||||
|
|
||||||
from bokeh.colors import RGB
|
|
||||||
from bokeh.embed import components
|
from bokeh.embed import components
|
||||||
from bokeh.models import HoverTool
|
from bokeh.models import HoverTool
|
||||||
from bokeh.plotting import figure, ColumnDataSource
|
from bokeh.plotting import figure, ColumnDataSource
|
||||||
from bokeh.resources import INLINE
|
from bokeh.resources import INLINE
|
||||||
|
from bokeh.colors import RGB
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
@@ -18,8 +18,7 @@ from .models import Room
|
|||||||
PLOT_WIDTH = 600
|
PLOT_WIDTH = 600
|
||||||
|
|
||||||
|
|
||||||
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
|
def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
|
||||||
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
|
||||||
games_played = defaultdict(Counter)
|
games_played = defaultdict(Counter)
|
||||||
total_games = Counter()
|
total_games = Counter()
|
||||||
cutoff = date.today()-timedelta(days=30)
|
cutoff = date.today()-timedelta(days=30)
|
||||||
@@ -94,7 +93,7 @@ def stats():
|
|||||||
occurences, legend_label=game, line_width=2, color=game_to_color[game])
|
occurences, legend_label=game, line_width=2, color=game_to_color[game])
|
||||||
|
|
||||||
total = sum(total_games.values())
|
total = sum(total_games.values())
|
||||||
pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
||||||
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
||||||
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
||||||
pie.axis.visible = False
|
pie.axis.visible = False
|
||||||
@@ -122,8 +121,7 @@ def stats():
|
|||||||
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
||||||
source=ColumnDataSource(data=data), legend_field="games")
|
source=ColumnDataSource(data=data), legend_field="games")
|
||||||
|
|
||||||
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in
|
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games
|
||||||
sorted(total_games, key=lambda game: total_games[game])
|
|
||||||
if total_games[game] > 1]
|
if total_games[game] > 1]
|
||||||
|
|
||||||
script, charts = components((plot, pie, *per_game_charts))
|
script, charts = components((plot, pie, *per_game_charts))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
<title>Mystery Check Result</title>
|
<title>Mystery Check Result</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
<title>Generate Game</title>
|
<title>Generate Game</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
<title>Upload Multidata</title>
|
<title>Upload Multidata</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>
|
||||||
|
|||||||
@@ -20,16 +20,12 @@
|
|||||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||||
Should you wish to continue later,
|
Should you wish to continue later,
|
||||||
anyone can simply refresh this page and the server will resume.<br>
|
anyone can simply refresh this page and the server will resume.<br>
|
||||||
{% if room.last_port == -1 %}
|
{% if room.last_port %}
|
||||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
|
||||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
|
||||||
{% elif room.last_port %}
|
|
||||||
You can connect to this room by using <span class="interactive"
|
You can connect to this room by using <span class="interactive"
|
||||||
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
||||||
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
|
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
|
||||||
</span>
|
</span>
|
||||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
|
||||||
{% endif %}
|
|
||||||
{{ macros.list_patches_room(room) }}
|
{{ macros.list_patches_room(room) }}
|
||||||
{% if room.owner == session["_id"] %}
|
{% if room.owner == session["_id"] %}
|
||||||
<form method=post>
|
<form method=post>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>{{ player_name }}'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>
|
|
||||||
@@ -43,14 +43,14 @@
|
|||||||
{% elif patch.game | supports_apdeltapatch %}
|
{% elif patch.game | supports_apdeltapatch %}
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
{% elif patch.game == "Dark Souls III" and patch.data %}
|
{% elif patch.game == "Dark Souls III" %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download JSON File...</a>
|
Download JSON File...</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
No file to download for this game.
|
No file to download for this game.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,85 +1,56 @@
|
|||||||
# Q. What is this file?
|
# What is this file?
|
||||||
# A. This file contains options which allow you to configure your multiworld experience while allowing
|
# This file contains options which allow you to configure your multiworld experience while allowing others
|
||||||
# others to play how they want as well.
|
# to play how they want as well.
|
||||||
#
|
|
||||||
# Q. How do I use it?
|
|
||||||
# A. The options in this file are weighted. This means the higher number you assign to a value, the
|
|
||||||
# more chances you have for that option to be chosen. For example, an option like this:
|
|
||||||
#
|
|
||||||
# map_shuffle:
|
|
||||||
# on: 5
|
|
||||||
# off: 15
|
|
||||||
#
|
|
||||||
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned
|
|
||||||
# off.
|
|
||||||
#
|
|
||||||
# Q. I've never seen a file like this before. What characters am I allowed to use?
|
|
||||||
# A. This is a .yaml file. You are allowed to use most characters.
|
|
||||||
# To test if your yaml is valid or not, you can use this website:
|
|
||||||
# http://www.yamllint.com/
|
|
||||||
# You can also verify your Archipelago settings are valid at this site:
|
|
||||||
# https://archipelago.gg/check
|
|
||||||
|
|
||||||
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit.
|
# How do I use it?
|
||||||
# {player} will be replaced with the player's slot number.
|
# The options in this file are weighted. This means the higher number you assign to a value, the more
|
||||||
# {PLAYER} will be replaced with the player's slot number, if that slot number is greater than 1.
|
# chances you have for that option to be chosen. For example, an option like this:
|
||||||
# {number} will be replaced with the counter value of the name.
|
#
|
||||||
# {NUMBER} will be replaced with the counter value of the name, if the counter value is greater than 1.
|
# map_shuffle:
|
||||||
name: Player{number}
|
# on: 5
|
||||||
|
# off: 15
|
||||||
|
#
|
||||||
|
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned off
|
||||||
|
|
||||||
# Used to describe your yaml. Useful if you have multiple files.
|
# I've never seen a file like this before. What characters am I allowed to use?
|
||||||
description: Default {{ game }} Template
|
# This is a .yaml file. You are allowed to use most characters.
|
||||||
|
# To test if your yaml is valid or not, you can use this website:
|
||||||
|
# http://www.yamllint.com/
|
||||||
|
|
||||||
game: {{ game }}
|
description: Default {{ game }} Template # Used to describe your yaml. Useful if you have multiple files
|
||||||
|
# Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
|
||||||
|
name: YourName{number}
|
||||||
|
#{player} will be replaced with the player's slot number.
|
||||||
|
#{PLAYER} will be replaced with the player's slot number if that slot number is greater than 1.
|
||||||
|
#{number} will be replaced with the counter value of the name.
|
||||||
|
#{NUMBER} will be replaced with the counter value of the name if the counter value is greater than 1.
|
||||||
|
game:
|
||||||
|
{{ game }}: 1
|
||||||
requires:
|
requires:
|
||||||
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
||||||
|
# Shared Options supported by all games:
|
||||||
|
|
||||||
{%- macro range_option(option) %}
|
{%- macro range_option(option) %}
|
||||||
# You can define additional values between the minimum and maximum values.
|
# you can add additional values between minimum and maximum
|
||||||
# Minimum value is {{ option.range_start }}
|
|
||||||
# Maximum value is {{ option.range_end }}
|
|
||||||
{%- set data, notes = dictify_range(option) %}
|
{%- set data, notes = dictify_range(option) %}
|
||||||
{%- for entry, default in data.items() %}
|
{%- for entry, default in data.items() %}
|
||||||
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
|
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{{ game }}:
|
{{ game }}:
|
||||||
{%- for option_key, option in options.items() %}
|
{%- for option_key, option in options.items() %}
|
||||||
{{ option_key }}:
|
{{ option_key }}:{% if option.__doc__ %} # {{ option.__doc__ | replace('\n', '\n#') | indent(4, first=False) }}{% endif %}
|
||||||
{%- if option.__doc__ %}
|
|
||||||
# {{ option.__doc__
|
|
||||||
| trim
|
|
||||||
| replace('\n\n', '\n \n')
|
|
||||||
| replace('\n ', '\n# ')
|
|
||||||
| indent(4, first=False)
|
|
||||||
}}
|
|
||||||
{%- endif -%}
|
|
||||||
|
|
||||||
{%- if option.__doc__ and option.range_start is defined %}
|
|
||||||
#
|
|
||||||
{%- endif -%}
|
|
||||||
|
|
||||||
{%- if option.range_start is defined and option.range_start is number %}
|
{%- if option.range_start is defined and option.range_start is number %}
|
||||||
{{- range_option(option) -}}
|
{{- range_option(option) -}}
|
||||||
|
|
||||||
{%- elif option.options -%}
|
{%- elif option.options -%}
|
||||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||||
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
|
{% if option.default == "random" %}
|
||||||
{%- if option.name_lookup[option.default] not in option.options %}
|
random: 50
|
||||||
{{ option.default }}: 50
|
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|
||||||
{%- elif option.default is string %}
|
|
||||||
{{ option.default }}: 50
|
|
||||||
|
|
||||||
{%- elif option.default is iterable and option.default is not mapping %}
|
|
||||||
{{ option.default | list }}
|
|
||||||
|
|
||||||
{%- else %}
|
{%- else %}
|
||||||
{{ yaml_dump(option.default) | trim | indent(4, first=false) }}
|
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{{ "\n" }}
|
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
|
{% if not options %}{}{% endif %}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
<title>Start Playing</title>
|
<title>Start Playing</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/trackers/tracker.css") }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackers/tracker.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
<div id="tracker-header-bar">
|
<div id="tracker-header-bar">
|
||||||
<input placeholder="Search" id="search"/>
|
<input placeholder="Search" id="search"/>
|
||||||
<span class="info">This tracker will automatically update itself periodically.</span>
|
<span class="info">This tracker will automatically update itself periodically.</span>
|
||||||
|
<a href="/tracker/{{ room.tracker|suuid }}/{{ team }}/{{ player }}" class="button-link">
|
||||||
|
Go to Styled Tracker
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table class="table non-unique-item-table">
|
<table class="table non-unique-item-table">
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/minecraftTracker.css') }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/minecraftTracker.js') }}"></script>
|
||||||
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
|
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -43,19 +43,6 @@
|
|||||||
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
|
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
|
||||||
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
||||||
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
|
|
||||||
<div class="item-count">{{ shard_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
|
|
||||||
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
|
|
||||||
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
|
|
||||||
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
|
|
||||||
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table id="location-table">
|
<table id="location-table">
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<title>Multiworld Tracker</title>
|
<title>Multiworld Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/trackers/tracker.css") }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackers/tracker.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{%- for player, items in players.items() -%}
|
{%- for player, items in players.items() -%}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||||
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||||
{%- if (team, loop.index) in video -%}
|
{%- if (team, loop.index) in video -%}
|
||||||
{%- if video[(team, loop.index)][0] == "Twitch" -%}
|
{%- if video[(team, loop.index)][0] == "Twitch" -%}
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{%- for player, checks in players.items() -%}
|
{%- for player, checks in players.items() -%}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||||
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||||
<td>{{ player_names[(team, loop.index)]|e }}</td>
|
<td>{{ player_names[(team, loop.index)]|e }}</td>
|
||||||
{%- for area in ordered_areas -%}
|
{%- for area in ordered_areas -%}
|
||||||
99
WebHostLib/templates/trackers/playerTracker.html
Normal file
99
WebHostLib/templates/trackers/playerTracker.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% block head %}
|
||||||
|
<!--suppress XmlDuplicatedId -->
|
||||||
|
<title>{{ player_name }}'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 %}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/supermetroidTracker.css') }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/supermetroidTracker.css') }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/supermetroidTracker.js') }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/supermetroidTracker.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/timespinnerTracker.css') }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/timespinnerTracker.css') }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/timespinnerTracker.js') }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/timespinnerTracker.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<td></td>
|
<td></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
|
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
|
||||||
{% if 'EyeSpy' in options %}
|
{% if 'FacebookMode' in options %}
|
||||||
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
|
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td></td>
|
<td></td>
|
||||||
77
WebHostLib/templates/trackers/zeldaKeysTracker.html
Normal file
77
WebHostLib/templates/trackers/zeldaKeysTracker.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% block head %}
|
||||||
|
<!--suppress XmlDuplicatedId -->
|
||||||
|
<title>{{ player_name }}'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 %}
|
||||||
@@ -1,19 +1,62 @@
|
|||||||
import collections
|
import collections
|
||||||
import datetime
|
|
||||||
import typing
|
import typing
|
||||||
from typing import Counter, Optional, Dict, Any, Tuple
|
from typing import Counter, Optional, Dict, Any, Tuple, Set, List, TYPE_CHECKING
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from werkzeug.exceptions import abort
|
from werkzeug.exceptions import abort
|
||||||
|
import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from MultiServer import Context
|
from worlds.alttp import Items
|
||||||
from NetUtils import SlotType
|
from WebHostLib import app, cache, Room
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||||
from worlds.alttp import Items
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from MultiServer import get_item_name_from_id, Context
|
||||||
from .models import Room
|
from NetUtils import SlotType
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerTracker:
|
||||||
|
"""This class will create a basic 'prettier' tracker for each world using their themes automatically. This
|
||||||
|
can be overridden to customize how it will appear. Can provide icons and custom regions. The html used is also
|
||||||
|
a jinja template that can be overridden if you want your tracker to look different in certain aspects. To render
|
||||||
|
icons and regions add dictionaries to the relevant attributes of the tracker_info. To customize the layout of
|
||||||
|
your icons you can create a new html in your world and extend playerTracker.html and overwrite the icons_render
|
||||||
|
block then change the tracker_info template attribute to your template."""
|
||||||
|
|
||||||
|
template: str = 'playerTracker.html'
|
||||||
|
icons: Dict[str, str] = {}
|
||||||
|
progressive_items: List[str] = []
|
||||||
|
progressive_names: Dict[str, List[str]] = {}
|
||||||
|
regions: Dict[str, List[str]] = {}
|
||||||
|
checks_done: Dict[str, Set[str]] = {}
|
||||||
|
room: Any
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
name: str
|
||||||
|
all_locations: Set[str]
|
||||||
|
checked_locations: Set[str]
|
||||||
|
all_prog_items: Counter[str]
|
||||||
|
items_received: Counter[str]
|
||||||
|
received_prog_items: Counter[str]
|
||||||
|
slot_data: Dict[any, any]
|
||||||
|
theme: str
|
||||||
|
|
||||||
|
region_keys: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def __init__(self, room: Any, team: int, player: int, name: str, all_locations: Set[str],
|
||||||
|
checked_locations: set, all_progression_items: Counter[str], items_received: Counter[str],
|
||||||
|
slot_data: Dict[any, any]):
|
||||||
|
self.room = room
|
||||||
|
self.team = team
|
||||||
|
self.player = player
|
||||||
|
self.name = name
|
||||||
|
self.all_locations = all_locations
|
||||||
|
self.checked_locations = checked_locations
|
||||||
|
self.all_prog_items = all_progression_items
|
||||||
|
self.items_received = items_received
|
||||||
|
self.slot_data = slot_data
|
||||||
|
|
||||||
|
|
||||||
alttp_icons = {
|
alttp_icons = {
|
||||||
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
|
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
|
||||||
@@ -289,7 +332,7 @@ def get_static_room_data(room: Room):
|
|||||||
|
|
||||||
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||||
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
|
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
|
||||||
# Team and player must be positive and greater than zero
|
# Team and player must be positive and greater than zero
|
||||||
if tracked_team < 0 or tracked_player < 1:
|
if tracked_team < 0 or tracked_player < 1:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -298,13 +341,78 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
|||||||
if not room:
|
if not room:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# Collect seed information and pare it down to a single player
|
player_tracker, multisave, inventory, seed_checks_in_area, lttp_checks_done, \
|
||||||
|
slot_data, games, player_name, display_icons = fill_tracker_data(room, tracked_team, tracked_player)
|
||||||
|
|
||||||
|
game_name = games[tracked_player]
|
||||||
|
# TODO move all games in game_specific_trackers to new system
|
||||||
|
if game_name in game_specific_trackers and not want_generic:
|
||||||
|
specific_tracker = game_specific_trackers.get(game_name, None)
|
||||||
|
return specific_tracker(multisave, room, player_tracker.all_locations, inventory, tracked_team, tracked_player,
|
||||||
|
player_name, seed_checks_in_area, lttp_checks_done, slot_data[tracked_player])
|
||||||
|
elif game_name in AutoWorldRegister.world_types and not want_generic:
|
||||||
|
return render_template(
|
||||||
|
"trackers/" + player_tracker.template,
|
||||||
|
all_progression_items=player_tracker.all_prog_items,
|
||||||
|
player=tracked_player,
|
||||||
|
team=tracked_team,
|
||||||
|
room=player_tracker.room,
|
||||||
|
player_name=player_tracker.name,
|
||||||
|
checked_locations=sorted(player_tracker.checked_locations),
|
||||||
|
locations=sorted(player_tracker.all_locations),
|
||||||
|
theme=player_tracker.theme,
|
||||||
|
icons=display_icons,
|
||||||
|
regions=player_tracker.regions,
|
||||||
|
checks_done=player_tracker.checks_done,
|
||||||
|
region_keys=player_tracker.region_keys
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return __renderGenericTracker(multisave, room, player_tracker.all_locations, inventory, tracked_team, tracked_player, player_name, seed_checks_in_area, lttp_checks_done)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
|
||||||
|
return get_player_tracker(tracker, tracked_team, tracked_player, True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tracker_icons_and_regions(player_tracker: PlayerTracker) -> Dict[str, str]:
|
||||||
|
"""this function allows multiple icons to be used for the same item but it does require the world to submit both
|
||||||
|
a progressive_items list and the icons dict together"""
|
||||||
|
display_icons: Dict[str, str] = {}
|
||||||
|
if player_tracker.progressive_names and player_tracker.icons:
|
||||||
|
for item in player_tracker.progressive_items:
|
||||||
|
if item in player_tracker.progressive_names:
|
||||||
|
level = min(player_tracker.items_received[item], len(player_tracker.progressive_names[item]) - 1)
|
||||||
|
display_name = player_tracker.progressive_names[item][level]
|
||||||
|
if display_name in player_tracker.icons:
|
||||||
|
display_icons[item] = player_tracker.icons[display_name]
|
||||||
|
else:
|
||||||
|
display_icons[item] = player_tracker.icons[item]
|
||||||
|
else:
|
||||||
|
display_icons[item] = player_tracker.icons[item]
|
||||||
|
else:
|
||||||
|
if player_tracker.progressive_items and player_tracker.icons:
|
||||||
|
for item in player_tracker.progressive_items:
|
||||||
|
display_icons[item] = player_tracker.icons[item]
|
||||||
|
|
||||||
|
if player_tracker.regions:
|
||||||
|
for region in player_tracker.regions:
|
||||||
|
for location in region:
|
||||||
|
if location in player_tracker.checked_locations:
|
||||||
|
player_tracker.checks_done.setdefault(region, set()).add(location)
|
||||||
|
|
||||||
|
return display_icons
|
||||||
|
|
||||||
|
|
||||||
|
def fill_tracker_data(room: Room, tracked_team: int, tracked_player: int) -> Tuple:
|
||||||
|
"""Collect seed information and pare it down to a single player"""
|
||||||
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
||||||
precollected_items, games, slot_data, groups = get_static_room_data(room)
|
precollected_items, games, slot_data, groups = get_static_room_data(room)
|
||||||
player_name = names[tracked_team][tracked_player - 1]
|
player_name = names[tracked_team][tracked_player - 1]
|
||||||
location_to_area = player_location_to_area[tracked_player]
|
location_to_area = player_location_to_area[tracked_player]
|
||||||
inventory = collections.Counter()
|
inventory = collections.Counter()
|
||||||
checks_done = {loc_name: 0 for loc_name in default_locations}
|
lttp_checks_done = {loc_name: 0 for loc_name in default_locations}
|
||||||
|
|
||||||
# Add starting items to inventory
|
# Add starting items to inventory
|
||||||
starting_items = precollected_items[tracked_player]
|
starting_items = precollected_items[tracked_player]
|
||||||
@@ -322,6 +430,7 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
|||||||
if tracked_player in group_members:
|
if tracked_player in group_members:
|
||||||
slots_aimed_at_player.add(group_id)
|
slots_aimed_at_player.add(group_id)
|
||||||
|
|
||||||
|
checked_locations = set()
|
||||||
# Add items to player inventory
|
# Add items to player inventory
|
||||||
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
|
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
|
||||||
# Skip teams and players not matching the request
|
# Skip teams and players not matching the request
|
||||||
@@ -333,390 +442,52 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
|||||||
item, recipient, flags = player_locations[location]
|
item, recipient, flags = player_locations[location]
|
||||||
if recipient in slots_aimed_at_player: # a check done for the tracked player
|
if recipient in slots_aimed_at_player: # a check done for the tracked player
|
||||||
attribute_item_solo(inventory, item)
|
attribute_item_solo(inventory, item)
|
||||||
|
|
||||||
if ms_player == tracked_player: # a check done by the tracked player
|
if ms_player == tracked_player: # a check done by the tracked player
|
||||||
checks_done[location_to_area[location]] += 1
|
lttp_checks_done[location_to_area[location]] += 1
|
||||||
checks_done["Total"] += 1
|
lttp_checks_done["Total"] += 1
|
||||||
specific_tracker = game_specific_trackers.get(games[tracked_player], None)
|
checked_locations.add(lookup_any_location_id_to_name[location])
|
||||||
if specific_tracker and not want_generic:
|
|
||||||
return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
prog_items = collections.Counter
|
||||||
seed_checks_in_area, checks_done, slot_data[tracked_player])
|
all_location_names = set()
|
||||||
else:
|
|
||||||
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
all_location_names = {lookup_any_location_id_to_name[id] for id in locations[tracked_player]}
|
||||||
seed_checks_in_area, checks_done)
|
prog_items = collections.Counter()
|
||||||
|
for player in locations:
|
||||||
|
for location in locations[player]:
|
||||||
|
item, recipient, flags = locations[player][location]
|
||||||
|
if recipient == player:
|
||||||
|
if flags & 1:
|
||||||
|
item_name = lookup_any_item_id_to_name[item]
|
||||||
|
prog_items[item_name] += 1
|
||||||
|
|
||||||
|
items_received = collections.Counter()
|
||||||
|
for id in inventory:
|
||||||
|
items_received[lookup_any_item_id_to_name[id]] = inventory[id]
|
||||||
|
|
||||||
|
player_tracker = PlayerTracker(
|
||||||
|
room,
|
||||||
|
tracked_team,
|
||||||
|
tracked_player,
|
||||||
|
player_name,
|
||||||
|
all_location_names,
|
||||||
|
checked_locations,
|
||||||
|
prog_items,
|
||||||
|
items_received,
|
||||||
|
slot_data[tracked_player]
|
||||||
|
)
|
||||||
|
|
||||||
|
# grab webworld and apply its theme to the tracker
|
||||||
|
webworld = AutoWorldRegister.world_types[games[tracked_player]].web
|
||||||
|
player_tracker.theme = webworld.theme
|
||||||
|
# allow the world to add information to the tracker class
|
||||||
|
webworld.modify_tracker(player_tracker)
|
||||||
|
display_icons = get_tracker_icons_and_regions(player_tracker)
|
||||||
|
|
||||||
|
return player_tracker, multisave, inventory, seed_checks_in_area, lttp_checks_done, slot_data, games, player_name, display_icons
|
||||||
|
|
||||||
|
|
||||||
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: set,
|
||||||
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
|
|
||||||
return getPlayerTracker(tracker, tracked_team, tracked_player, True)
|
|
||||||
|
|
||||||
|
|
||||||
def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
inventory: Counter, team: int, player: int, player_name: str,
|
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
|
||||||
|
|
||||||
# Note the presence of the triforce item
|
|
||||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
|
||||||
if game_state == 30:
|
|
||||||
inventory[106] = 1 # Triforce
|
|
||||||
|
|
||||||
# Progressive items need special handling for icons and class
|
|
||||||
progressive_items = {
|
|
||||||
"Progressive Sword": 94,
|
|
||||||
"Progressive Glove": 97,
|
|
||||||
"Progressive Bow": 100,
|
|
||||||
"Progressive Mail": 96,
|
|
||||||
"Progressive Shield": 95,
|
|
||||||
}
|
|
||||||
progressive_names = {
|
|
||||||
"Progressive Sword": [None, 'Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword'],
|
|
||||||
"Progressive Glove": [None, 'Power Glove', 'Titan Mitts'],
|
|
||||||
"Progressive Bow": [None, "Bow", "Silver Bow"],
|
|
||||||
"Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"],
|
|
||||||
"Progressive Shield": [None, "Blue Shield", "Red Shield", "Mirror Shield"]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Determine which icon to use
|
|
||||||
display_data = {}
|
|
||||||
for item_name, item_id in progressive_items.items():
|
|
||||||
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
|
|
||||||
display_name = progressive_names[item_name][level]
|
|
||||||
acquired = True
|
|
||||||
if not display_name:
|
|
||||||
acquired = False
|
|
||||||
display_name = progressive_names[item_name][level + 1]
|
|
||||||
base_name = item_name.split(maxsplit=1)[1].lower()
|
|
||||||
display_data[base_name + "_acquired"] = acquired
|
|
||||||
display_data[base_name + "_url"] = alttp_icons[display_name]
|
|
||||||
|
|
||||||
# The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should?
|
|
||||||
sp_areas = ordered_areas[2:15]
|
|
||||||
|
|
||||||
player_big_key_locations = set()
|
|
||||||
player_small_key_locations = set()
|
|
||||||
for loc_data in locations.values():
|
|
||||||
for values in loc_data.values():
|
|
||||||
item_id, item_player, flags = values
|
|
||||||
if item_player == player:
|
|
||||||
if item_id in ids_big_key:
|
|
||||||
player_big_key_locations.add(ids_big_key[item_id])
|
|
||||||
elif item_id in ids_small_key:
|
|
||||||
player_small_key_locations.add(ids_small_key[item_id])
|
|
||||||
|
|
||||||
return render_template("lttpTracker.html", inventory=inventory,
|
|
||||||
player_name=player_name, room=room, icons=alttp_icons, checks_done=checks_done,
|
|
||||||
checks_in_area=seed_checks_in_area[player],
|
|
||||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
|
|
||||||
small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas,
|
|
||||||
key_locations=player_small_key_locations,
|
|
||||||
big_key_locations=player_big_key_locations,
|
|
||||||
**display_data)
|
|
||||||
|
|
||||||
|
|
||||||
def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
|
||||||
|
|
||||||
icons = {
|
|
||||||
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
|
|
||||||
"Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png",
|
|
||||||
"Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png",
|
|
||||||
"Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png",
|
|
||||||
"Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png",
|
|
||||||
"Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png",
|
|
||||||
"Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png",
|
|
||||||
"Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png",
|
|
||||||
"Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png",
|
|
||||||
"Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png",
|
|
||||||
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
|
|
||||||
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
|
|
||||||
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
|
|
||||||
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
|
|
||||||
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
|
|
||||||
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
|
|
||||||
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
|
|
||||||
"Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png",
|
|
||||||
"Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png",
|
|
||||||
"Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png",
|
|
||||||
"Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png",
|
|
||||||
"Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif",
|
|
||||||
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
|
|
||||||
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
|
|
||||||
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
|
|
||||||
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
|
|
||||||
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
|
|
||||||
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
|
|
||||||
"Saddle": "https://i.imgur.com/2QtDyR0.png",
|
|
||||||
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
|
|
||||||
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
|
|
||||||
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
minecraft_location_ids = {
|
|
||||||
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
|
||||||
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
|
||||||
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
|
|
||||||
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
|
|
||||||
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
|
|
||||||
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
|
|
||||||
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100],
|
|
||||||
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112,
|
|
||||||
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
|
|
||||||
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
|
|
||||||
}
|
|
||||||
|
|
||||||
display_data = {}
|
|
||||||
|
|
||||||
# Determine display for progressive items
|
|
||||||
progressive_items = {
|
|
||||||
"Progressive Tools": 45013,
|
|
||||||
"Progressive Weapons": 45012,
|
|
||||||
"Progressive Armor": 45014,
|
|
||||||
"Progressive Resource Crafting": 45001
|
|
||||||
}
|
|
||||||
progressive_names = {
|
|
||||||
"Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"],
|
|
||||||
"Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"],
|
|
||||||
"Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"],
|
|
||||||
"Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"]
|
|
||||||
}
|
|
||||||
for item_name, item_id in progressive_items.items():
|
|
||||||
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
|
|
||||||
display_name = progressive_names[item_name][level]
|
|
||||||
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
|
|
||||||
display_data[base_name + "_url"] = icons[display_name]
|
|
||||||
|
|
||||||
# Multi-items
|
|
||||||
multi_items = {
|
|
||||||
"3 Ender Pearls": 45029,
|
|
||||||
"8 Netherite Scrap": 45015,
|
|
||||||
"Dragon Egg Shard": 45043
|
|
||||||
}
|
|
||||||
for item_name, item_id in multi_items.items():
|
|
||||||
base_name = item_name.split()[-1].lower()
|
|
||||||
count = inventory[item_id]
|
|
||||||
if count >= 0:
|
|
||||||
display_data[base_name + "_count"] = count
|
|
||||||
|
|
||||||
# Victory condition
|
|
||||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
|
||||||
display_data['game_finished'] = game_state == 30
|
|
||||||
|
|
||||||
# Turn location IDs into advancement tab counts
|
|
||||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
|
|
||||||
lookup_name = lambda id: lookup_any_location_id_to_name[id]
|
|
||||||
location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations}
|
|
||||||
for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations])
|
|
||||||
for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_done['Total'] = len(checked_locations)
|
|
||||||
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
|
||||||
|
|
||||||
return render_template("minecraftTracker.html",
|
|
||||||
inventory=inventory, icons=icons,
|
|
||||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
|
||||||
id in lookup_any_item_id_to_name},
|
|
||||||
player=player, team=team, room=room, player_name=playerName,
|
|
||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
|
||||||
**display_data)
|
|
||||||
|
|
||||||
|
|
||||||
def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
|
||||||
|
|
||||||
icons = {
|
|
||||||
"Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png",
|
|
||||||
"Ocarina of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Ocarina_of_Time_Icon.png",
|
|
||||||
"Slingshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/32/OoT_Fairy_Slingshot_Icon.png",
|
|
||||||
"Boomerang": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/d5/OoT_Boomerang_Icon.png",
|
|
||||||
"Bottle": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/fc/OoT_Bottle_Icon.png",
|
|
||||||
"Rutos Letter": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/OoT_Letter_Icon.png",
|
|
||||||
"Bombs": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/11/OoT_Bomb_Icon.png",
|
|
||||||
"Bombchus": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/36/OoT_Bombchu_Icon.png",
|
|
||||||
"Lens of Truth": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/05/OoT_Lens_of_Truth_Icon.png",
|
|
||||||
"Bow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9a/OoT_Fairy_Bow_Icon.png",
|
|
||||||
"Hookshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/77/OoT_Hookshot_Icon.png",
|
|
||||||
"Longshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/OoT_Longshot_Icon.png",
|
|
||||||
"Megaton Hammer": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/93/OoT_Megaton_Hammer_Icon.png",
|
|
||||||
"Fire Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1e/OoT_Fire_Arrow_Icon.png",
|
|
||||||
"Ice Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3c/OoT_Ice_Arrow_Icon.png",
|
|
||||||
"Light Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/76/OoT_Light_Arrow_Icon.png",
|
|
||||||
"Dins Fire": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/da/OoT_Din%27s_Fire_Icon.png",
|
|
||||||
"Farores Wind": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/7a/OoT_Farore%27s_Wind_Icon.png",
|
|
||||||
"Nayrus Love": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/be/OoT_Nayru%27s_Love_Icon.png",
|
|
||||||
"Kokiri Sword": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/5/53/OoT_Kokiri_Sword_Icon.png",
|
|
||||||
"Biggoron Sword": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2e/OoT_Giant%27s_Knife_Icon.png",
|
|
||||||
"Mirror Shield": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b0/OoT_Mirror_Shield_Icon_2.png",
|
|
||||||
"Goron Bracelet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b7/OoT_Goron%27s_Bracelet_Icon.png",
|
|
||||||
"Silver Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b9/OoT_Silver_Gauntlets_Icon.png",
|
|
||||||
"Golden Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/6a/OoT_Golden_Gauntlets_Icon.png",
|
|
||||||
"Goron Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1c/OoT_Goron_Tunic_Icon.png",
|
|
||||||
"Zora Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2c/OoT_Zora_Tunic_Icon.png",
|
|
||||||
"Silver Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Silver_Scale_Icon.png",
|
|
||||||
"Gold Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/95/OoT_Golden_Scale_Icon.png",
|
|
||||||
"Iron Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/34/OoT_Iron_Boots_Icon.png",
|
|
||||||
"Hover Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/22/OoT_Hover_Boots_Icon.png",
|
|
||||||
"Adults Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f9/OoT_Adult%27s_Wallet_Icon.png",
|
|
||||||
"Giants Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/8/87/OoT_Giant%27s_Wallet_Icon.png",
|
|
||||||
"Small Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9f/OoT3D_Magic_Jar_Icon.png",
|
|
||||||
"Large Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3e/OoT3D_Large_Magic_Jar_Icon.png",
|
|
||||||
"Gerudo Membership Card": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Gerudo_Token_Icon.png",
|
|
||||||
"Gold Skulltula Token": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/47/OoT_Token_Icon.png",
|
|
||||||
"Triforce Piece": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0b/SS_Triforce_Piece_Icon.png",
|
|
||||||
"Triforce": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/68/ALttP_Triforce_Title_Sprite.png",
|
|
||||||
"Zeldas Lullaby": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Eponas Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Sarias Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Suns Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Song of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Song of Storms": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Minuet of Forest": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e4/Green_Note.png",
|
|
||||||
"Bolero of Fire": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f0/Red_Note.png",
|
|
||||||
"Serenade of Water": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0f/Blue_Note.png",
|
|
||||||
"Requiem of Spirit": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/Orange_Note.png",
|
|
||||||
"Nocturne of Shadow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/Purple_Note.png",
|
|
||||||
"Prelude of Light": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/90/Yellow_Note.png",
|
|
||||||
"Small Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e5/OoT_Small_Key_Icon.png",
|
|
||||||
"Boss Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/40/OoT_Boss_Key_Icon.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
display_data = {}
|
|
||||||
|
|
||||||
# Determine display for progressive items
|
|
||||||
progressive_items = {
|
|
||||||
"Progressive Hookshot": 66128,
|
|
||||||
"Progressive Strength Upgrade": 66129,
|
|
||||||
"Progressive Wallet": 66133,
|
|
||||||
"Progressive Scale": 66134,
|
|
||||||
"Magic Meter": 66138,
|
|
||||||
"Ocarina": 66139,
|
|
||||||
}
|
|
||||||
|
|
||||||
progressive_names = {
|
|
||||||
"Progressive Hookshot": ["Hookshot", "Hookshot", "Longshot"],
|
|
||||||
"Progressive Strength Upgrade": ["Goron Bracelet", "Goron Bracelet", "Silver Gauntlets", "Golden Gauntlets"],
|
|
||||||
"Progressive Wallet": ["Adults Wallet", "Adults Wallet", "Giants Wallet", "Giants Wallet"],
|
|
||||||
"Progressive Scale": ["Silver Scale", "Silver Scale", "Gold Scale"],
|
|
||||||
"Magic Meter": ["Small Magic", "Small Magic", "Large Magic"],
|
|
||||||
"Ocarina": ["Fairy Ocarina", "Fairy Ocarina", "Ocarina of Time"]
|
|
||||||
}
|
|
||||||
|
|
||||||
for item_name, item_id in progressive_items.items():
|
|
||||||
level = min(inventory[item_id], len(progressive_names[item_name])-1)
|
|
||||||
display_name = progressive_names[item_name][level]
|
|
||||||
if item_name.startswith("Progressive"):
|
|
||||||
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
|
|
||||||
else:
|
|
||||||
base_name = item_name.lower().replace(' ', '_')
|
|
||||||
display_data[base_name+"_url"] = icons[display_name]
|
|
||||||
|
|
||||||
if base_name == "hookshot":
|
|
||||||
display_data['hookshot_length'] = {0: '', 1: 'H', 2: 'L'}.get(level)
|
|
||||||
if base_name == "wallet":
|
|
||||||
display_data['wallet_size'] = {0: '99', 1: '200', 2: '500', 3: '999'}.get(level)
|
|
||||||
|
|
||||||
# Determine display for bottles. Show letter if it's obtained, determine bottle count
|
|
||||||
bottle_ids = [66015, 66020, 66021, 66140, 66141, 66142, 66143, 66144, 66145, 66146, 66147, 66148]
|
|
||||||
display_data['bottle_count'] = min(sum(map(lambda item_id: inventory[item_id], bottle_ids)), 4)
|
|
||||||
display_data['bottle_url'] = icons['Rutos Letter'] if inventory[66021] > 0 else icons['Bottle']
|
|
||||||
|
|
||||||
# Determine bombchu display
|
|
||||||
display_data['has_bombchus'] = any(map(lambda item_id: inventory[item_id] > 0, [66003, 66106, 66107, 66137]))
|
|
||||||
|
|
||||||
# Multi-items
|
|
||||||
multi_items = {
|
|
||||||
"Gold Skulltula Token": 66091,
|
|
||||||
"Triforce Piece": 66202,
|
|
||||||
}
|
|
||||||
for item_name, item_id in multi_items.items():
|
|
||||||
base_name = item_name.split()[-1].lower()
|
|
||||||
count = inventory[item_id]
|
|
||||||
display_data[base_name+"_count"] = inventory[item_id]
|
|
||||||
|
|
||||||
# Gather dungeon locations
|
|
||||||
area_id_ranges = {
|
|
||||||
"Overworld": (67000, 67280),
|
|
||||||
"Deku Tree": (67281, 67303),
|
|
||||||
"Dodongo's Cavern": (67304, 67334),
|
|
||||||
"Jabu Jabu's Belly": (67335, 67359),
|
|
||||||
"Bottom of the Well": (67360, 67384),
|
|
||||||
"Forest Temple": (67385, 67420),
|
|
||||||
"Fire Temple": (67421, 67457),
|
|
||||||
"Water Temple": (67458, 67484),
|
|
||||||
"Shadow Temple": (67485, 67532),
|
|
||||||
"Spirit Temple": (67533, 67582),
|
|
||||||
"Ice Cavern": (67583, 67596),
|
|
||||||
"Gerudo Training Ground": (67597, 67635),
|
|
||||||
"Thieves' Hideout": (67259, 67263),
|
|
||||||
"Ganon's Castle": (67636, 67673),
|
|
||||||
}
|
|
||||||
|
|
||||||
def lookup_and_trim(id, area):
|
|
||||||
full_name = lookup_any_location_id_to_name[id]
|
|
||||||
if id == 67673:
|
|
||||||
return full_name[13:] # Ganons Tower Boss Key Chest
|
|
||||||
if area not in ["Overworld", "Thieves' Hideout"]:
|
|
||||||
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
|
|
||||||
return full_name[len(area):]
|
|
||||||
return full_name
|
|
||||||
|
|
||||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player]))
|
|
||||||
location_info = {area: {lookup_and_trim(id, area): id in checked_locations for id in range(min_id, max_id+1) if id in locations[player]}
|
|
||||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
|
||||||
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
|
|
||||||
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
|
|
||||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
|
||||||
|
|
||||||
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
|
|
||||||
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
|
|
||||||
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
|
|
||||||
for loc in location_info["Thieves' Hideout"]:
|
|
||||||
del location_info["Overworld"][loc]
|
|
||||||
|
|
||||||
checks_done['Total'] = sum(checks_done.values())
|
|
||||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
|
||||||
|
|
||||||
# Give skulltulas on non-tracked locations
|
|
||||||
non_tracked_locations = multisave.get("location_checks", {}).get((team, player), set()).difference(set(locations[player]))
|
|
||||||
for id in non_tracked_locations:
|
|
||||||
if "GS" in lookup_and_trim(id, ''):
|
|
||||||
display_data["token_count"] += 1
|
|
||||||
|
|
||||||
# Gather small and boss key info
|
|
||||||
small_key_counts = {
|
|
||||||
"Forest Temple": inventory[66175],
|
|
||||||
"Fire Temple": inventory[66176],
|
|
||||||
"Water Temple": inventory[66177],
|
|
||||||
"Spirit Temple": inventory[66178],
|
|
||||||
"Shadow Temple": inventory[66179],
|
|
||||||
"Bottom of the Well": inventory[66180],
|
|
||||||
"Gerudo Training Ground": inventory[66181],
|
|
||||||
"Thieves' Hideout": inventory[66182],
|
|
||||||
"Ganon's Castle": inventory[66183],
|
|
||||||
}
|
|
||||||
boss_key_counts = {
|
|
||||||
"Forest Temple": '✔' if inventory[66149] else '✕',
|
|
||||||
"Fire Temple": '✔' if inventory[66150] else '✕',
|
|
||||||
"Water Temple": '✔' if inventory[66151] else '✕',
|
|
||||||
"Spirit Temple": '✔' if inventory[66152] else '✕',
|
|
||||||
"Shadow Temple": '✔' if inventory[66153] else '✕',
|
|
||||||
"Ganon's Castle": '✔' if inventory[66154] else '✕',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Victory condition
|
|
||||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
|
||||||
display_data['game_finished'] = game_state == 30
|
|
||||||
|
|
||||||
return render_template("ootTracker.html",
|
|
||||||
inventory=inventory, player=player, team=team, room=room, player_name=playerName,
|
|
||||||
icons=icons, acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
|
|
||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
|
||||||
small_key_counts=small_key_counts, boss_key_counts=boss_key_counts,
|
|
||||||
**display_data)
|
|
||||||
|
|
||||||
|
|
||||||
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
inventory: Counter, team: int, player: int, playerName: str,
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
|
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
|
||||||
|
|
||||||
@@ -753,7 +524,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
|||||||
}
|
}
|
||||||
|
|
||||||
timespinner_location_ids = {
|
timespinner_location_ids = {
|
||||||
"Present": [
|
"Present": [
|
||||||
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
|
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
|
||||||
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
|
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
|
||||||
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
|
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
|
||||||
@@ -774,20 +545,20 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
|||||||
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
|
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
|
||||||
1337171, 1337172, 1337173, 1337174, 1337175],
|
1337171, 1337172, 1337173, 1337174, 1337175],
|
||||||
"Ancient Pyramid": [
|
"Ancient Pyramid": [
|
||||||
1337236,
|
1337236,
|
||||||
1337246, 1337247, 1337248, 1337249]
|
1337246, 1337247, 1337248, 1337249]
|
||||||
}
|
}
|
||||||
|
|
||||||
if(slot_data["DownloadableItems"]):
|
if(slot_data["DownloadableItems"]):
|
||||||
timespinner_location_ids["Present"] += [
|
timespinner_location_ids["Present"] += [
|
||||||
1337156, 1337157, 1337159,
|
1337156, 1337157, 1337159,
|
||||||
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
|
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
|
||||||
1337170]
|
1337170]
|
||||||
if(slot_data["Cantoran"]):
|
if(slot_data["Cantoran"]):
|
||||||
timespinner_location_ids["Past"].append(1337176)
|
timespinner_location_ids["Past"].append(1337176)
|
||||||
if(slot_data["LoreChecks"]):
|
if(slot_data["LoreChecks"]):
|
||||||
timespinner_location_ids["Present"] += [
|
timespinner_location_ids["Present"] += [
|
||||||
1337177, 1337178, 1337179,
|
1337177, 1337178, 1337179,
|
||||||
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
|
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
|
||||||
timespinner_location_ids["Past"] += [
|
timespinner_location_ids["Past"] += [
|
||||||
1337188, 1337189,
|
1337188, 1337189,
|
||||||
@@ -816,38 +587,38 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
|||||||
acquired_items = {lookup_any_item_id_to_name[id] for id in inventory if id in lookup_any_item_id_to_name}
|
acquired_items = {lookup_any_item_id_to_name[id] for id in inventory if id in lookup_any_item_id_to_name}
|
||||||
options = {k for k, v in slot_data.items() if v}
|
options = {k for k, v in slot_data.items() if v}
|
||||||
|
|
||||||
return render_template("timespinnerTracker.html",
|
return render_template("trackers/" + "timespinnerTracker.html",
|
||||||
inventory=inventory, icons=icons, acquired_items=acquired_items,
|
inventory=inventory, icons=icons, acquired_items=acquired_items,
|
||||||
player=player, team=team, room=room, player_name=playerName,
|
player=player, team=team, room=room, player_name=playerName,
|
||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||||
options=options, **display_data)
|
options=options, **display_data)
|
||||||
|
|
||||||
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: set,
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
inventory: Counter, team: int, player: int, playerName: str,
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||||
|
|
||||||
icons = {
|
icons = {
|
||||||
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png",
|
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ETank.png",
|
||||||
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png",
|
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Missile.png",
|
||||||
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png",
|
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Super.png",
|
||||||
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png",
|
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/PowerBomb.png",
|
||||||
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png",
|
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Bomb.png",
|
||||||
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png",
|
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Charge.png",
|
||||||
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png",
|
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Ice.png",
|
||||||
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png",
|
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/HiJump.png",
|
||||||
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png",
|
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpeedBooster.png",
|
||||||
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png",
|
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Wave.png",
|
||||||
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png",
|
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Spazer.png",
|
||||||
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png",
|
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpringBall.png",
|
||||||
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png",
|
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Varia.png",
|
||||||
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png",
|
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Plasma.png",
|
||||||
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png",
|
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Grapple.png",
|
||||||
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png",
|
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Morph.png",
|
||||||
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png",
|
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Reserve.png",
|
||||||
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png",
|
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Gravity.png",
|
||||||
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png",
|
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/XRayScope.png",
|
||||||
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png",
|
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpaceJump.png",
|
||||||
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png",
|
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ScrewAttack.png",
|
||||||
"Nothing": "",
|
"Nothing": "",
|
||||||
"No Energy": "",
|
"No Energy": "",
|
||||||
"Kraid": "",
|
"Kraid": "",
|
||||||
@@ -897,6 +668,7 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
|||||||
|
|
||||||
for item_name, item_id in multi_items.items():
|
for item_name, item_id in multi_items.items():
|
||||||
base_name = item_name.split()[0].lower()
|
base_name = item_name.split()[0].lower()
|
||||||
|
count = inventory[item_id]
|
||||||
display_data[base_name+"_count"] = inventory[item_id]
|
display_data[base_name+"_count"] = inventory[item_id]
|
||||||
|
|
||||||
# Victory condition
|
# Victory condition
|
||||||
@@ -914,7 +686,7 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
|||||||
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in supermetroid_location_ids.items()}
|
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in supermetroid_location_ids.items()}
|
||||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
checks_in_area['Total'] = sum(checks_in_area.values())
|
||||||
|
|
||||||
return render_template("supermetroidTracker.html",
|
return render_template("trackers/" + "supermetroidTracker.html",
|
||||||
inventory=inventory, icons=icons,
|
inventory=inventory, icons=icons,
|
||||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
||||||
id in lookup_any_item_id_to_name},
|
id in lookup_any_item_id_to_name},
|
||||||
@@ -922,7 +694,8 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
|||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||||
**display_data)
|
**display_data)
|
||||||
|
|
||||||
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
|
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: set,
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
inventory: Counter, team: int, player: int, playerName: str,
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
|
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
|
||||||
|
|
||||||
@@ -937,11 +710,11 @@ def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dic
|
|||||||
for order_index, networkItem in enumerate(ordered_items, start=1):
|
for order_index, networkItem in enumerate(ordered_items, start=1):
|
||||||
player_received_items[networkItem.item] = order_index
|
player_received_items[networkItem.item] = order_index
|
||||||
|
|
||||||
return render_template("genericTracker.html",
|
return render_template("trackers/" + "genericTracker.html",
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
player=player, team=team, room=room, player_name=playerName,
|
player=player, team=team, room=room, player_name=playerName,
|
||||||
checked_locations=checked_locations,
|
checked_locations=checked_locations,
|
||||||
not_checked_locations=set(locations[player]) - checked_locations,
|
not_checked_locations=locations - checked_locations,
|
||||||
received_items=player_received_items)
|
received_items=player_received_items)
|
||||||
|
|
||||||
|
|
||||||
@@ -983,9 +756,9 @@ def getTracker(tracker: UUID):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
item, recipient, flags = player_locations[location]
|
item, recipient, flags = player_locations[location]
|
||||||
|
|
||||||
if recipient in names:
|
if recipient in names:
|
||||||
attribute_item(inventory, team, recipient, item)
|
attribute_item(inventory, team, recipient, item)
|
||||||
|
|
||||||
checks_done[team][player][player_location_to_area[player][location]] += 1
|
checks_done[team][player][player_location_to_area[player][location]] += 1
|
||||||
checks_done[team][player]["Total"] += 1
|
checks_done[team][player]["Total"] += 1
|
||||||
|
|
||||||
@@ -1029,7 +802,7 @@ def getTracker(tracker: UUID):
|
|||||||
for (team, player), data in multisave.get("video", []):
|
for (team, player), data in multisave.get("video", []):
|
||||||
video[(team, player)] = data
|
video[(team, player)] = data
|
||||||
|
|
||||||
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
|
return render_template("trackers/" + "multiworldTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
|
||||||
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
|
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
|
||||||
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
|
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
|
||||||
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
|
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
|
||||||
@@ -1040,9 +813,6 @@ def getTracker(tracker: UUID):
|
|||||||
|
|
||||||
|
|
||||||
game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
||||||
"Minecraft": __renderMinecraftTracker,
|
|
||||||
"Ocarina of Time": __renderOoTTracker,
|
|
||||||
"Timespinner": __renderTimespinnerTracker,
|
"Timespinner": __renderTimespinnerTracker,
|
||||||
"A Link to the Past": __renderAlttpTracker,
|
|
||||||
"Super Metroid": __renderSuperMetroidTracker
|
"Super Metroid": __renderSuperMetroidTracker
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import base64
|
|
||||||
import json
|
|
||||||
import typing
|
import typing
|
||||||
import uuid
|
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import lzma
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import MultiServer
|
||||||
|
import uuid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, session, render_template
|
from flask import request, flash, redirect, url_for, session, render_template
|
||||||
from pony.orm import flush, select
|
from pony.orm import flush, select
|
||||||
|
|
||||||
import MultiServer
|
from WebHostLib import app, Seed, Room, Slot
|
||||||
|
from Utils import parse_yaml, VersionException, __version__
|
||||||
|
from Patch import preferred_endings, AutoPatchRegister
|
||||||
from NetUtils import NetworkSlot, SlotType
|
from NetUtils import NetworkSlot, SlotType
|
||||||
from Utils import VersionException, __version__
|
|
||||||
from worlds.Files import AutoPatchRegister
|
|
||||||
from . import app
|
|
||||||
from .models import Seed, Room, Slot
|
|
||||||
|
|
||||||
banned_zip_contents = (".sfc",)
|
banned_zip_contents = (".sfc",)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
|||||||
if not owner:
|
if not owner:
|
||||||
owner = session["_id"]
|
owner = session["_id"]
|
||||||
infolist = zfile.infolist()
|
infolist = zfile.infolist()
|
||||||
slots: typing.Set[Slot] = set()
|
slots = set()
|
||||||
spoiler = ""
|
spoiler = ""
|
||||||
multidata = None
|
multidata = None
|
||||||
for file in infolist:
|
for file in infolist:
|
||||||
@@ -38,6 +38,17 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
|||||||
player_name=patch.player_name,
|
player_name=patch.player_name,
|
||||||
player_id=patch.player,
|
player_id=patch.player,
|
||||||
game=patch.game))
|
game=patch.game))
|
||||||
|
elif file.filename.endswith(tuple(preferred_endings.values())):
|
||||||
|
data = zfile.open(file, "r").read()
|
||||||
|
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
|
||||||
|
if yaml_data["version"] < 2:
|
||||||
|
return "Old format cannot be uploaded (outdated .apbp)"
|
||||||
|
metadata = yaml_data["meta"]
|
||||||
|
|
||||||
|
slots.add(Slot(data=data,
|
||||||
|
player_name=metadata["player_name"],
|
||||||
|
player_id=metadata["player_id"],
|
||||||
|
game=yaml_data["game"]))
|
||||||
|
|
||||||
elif file.filename.endswith(".apmc"):
|
elif file.filename.endswith(".apmc"):
|
||||||
data = zfile.open(file, "r").read()
|
data = zfile.open(file, "r").read()
|
||||||
|
|||||||
495
ZillionClient.py
495
ZillionClient.py
@@ -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
BIN
data/basepatch.apbp
Normal file
Binary file not shown.
Binary file not shown.
@@ -15,8 +15,6 @@
|
|||||||
<UILog>:
|
<UILog>:
|
||||||
viewclass: 'SelectableLabel'
|
viewclass: 'SelectableLabel'
|
||||||
scroll_y: 0
|
scroll_y: 0
|
||||||
scroll_type: ["content", "bars"]
|
|
||||||
bar_width: dp(12)
|
|
||||||
effect_cls: "ScrollEffect"
|
effect_cls: "ScrollEffect"
|
||||||
SelectableRecycleBoxLayout:
|
SelectableRecycleBoxLayout:
|
||||||
default_size: None, dp(20)
|
default_size: None, dp(20)
|
||||||
|
|||||||
@@ -77,13 +77,12 @@ local scrub_sanity_check = function(scene_offset, bit_to_check)
|
|||||||
return scene_check(scene_offset, bit_to_check, 0x10)
|
return scene_check(scene_offset, bit_to_check, 0x10)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Why is there an extra offset of 3 for temp context checks? Who knows.
|
|
||||||
local cow_check = function(scene_offset, bit_to_check)
|
local cow_check = function(scene_offset, bit_to_check)
|
||||||
return scene_check(scene_offset, bit_to_check, 0xC)
|
return scene_check(scene_offset, bit_to_check, 0xC)
|
||||||
or check_temp_context({scene_offset, 0x00, bit_to_check - 0x03})
|
or check_temp_context({scene_offset, 0x00, bit_to_check})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- DMT and DMC fairies are weird, their temp context check is special-coded for them
|
-- Haven't been able to get DMT and DMC fairy to send instantly
|
||||||
local great_fairy_magic_check = function(scene_offset, bit_to_check)
|
local great_fairy_magic_check = function(scene_offset, bit_to_check)
|
||||||
return scene_check(scene_offset, bit_to_check, 0x4)
|
return scene_check(scene_offset, bit_to_check, 0x4)
|
||||||
or check_temp_context({scene_offset, 0x05, bit_to_check})
|
or check_temp_context({scene_offset, 0x05, bit_to_check})
|
||||||
@@ -101,18 +100,6 @@ local bean_sale_check = function(scene_offset, bit_to_check)
|
|||||||
or check_temp_context({scene_offset, 0x00, 0x16})
|
or check_temp_context({scene_offset, 0x00, 0x16})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Medigoron reports 0x00620028 to 0x40002C
|
|
||||||
local medigoron_check = function(scene_offset, bit_to_check)
|
|
||||||
return scene_check(scene_offset, bit_to_check, 0xC)
|
|
||||||
or check_temp_context({scene_offset, 0x00, 0x28})
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Bombchu salesman reports 0x005E0003 to 0x40002C
|
|
||||||
local salesman_check = function(scene_offset, bit_to_check)
|
|
||||||
return scene_check(scene_offset, bit_to_check, 0xC)
|
|
||||||
or check_temp_context({scene_offset, 0x00, 0x03})
|
|
||||||
end
|
|
||||||
|
|
||||||
--Helper method to resolve skulltula lookup location
|
--Helper method to resolve skulltula lookup location
|
||||||
local function skulltula_scene_to_array_index(i)
|
local function skulltula_scene_to_array_index(i)
|
||||||
return (i + 3) - 2 * (i % 4)
|
return (i + 3) - 2 * (i % 4)
|
||||||
@@ -588,7 +575,7 @@ local read_death_mountain_trail_checks = function()
|
|||||||
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
|
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
|
||||||
checks["DMT Chest"] = chest_check(0x60, 0x01)
|
checks["DMT Chest"] = chest_check(0x60, 0x01)
|
||||||
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
|
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
|
||||||
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18) or check_temp_context({0xFF, 0x05, 0x13})
|
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18)
|
||||||
checks["DMT Biggoron"] = big_goron_sword_check()
|
checks["DMT Biggoron"] = big_goron_sword_check()
|
||||||
checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18)
|
checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18)
|
||||||
|
|
||||||
@@ -605,7 +592,7 @@ local read_goron_city_checks = function()
|
|||||||
checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F)
|
checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F)
|
||||||
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
|
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
|
||||||
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
|
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
|
||||||
checks["GC Medigoron"] = medigoron_check(0x62, 0x1)
|
checks["GC Medigoron"] = on_the_ground_check(0x62, 0x1)
|
||||||
checks["GC Maze Left Chest"] = chest_check(0x62, 0x00)
|
checks["GC Maze Left Chest"] = chest_check(0x62, 0x00)
|
||||||
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
|
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
|
||||||
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
|
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
|
||||||
@@ -627,7 +614,7 @@ local read_death_mountain_crater_checks = function()
|
|||||||
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
|
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
|
||||||
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
|
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
|
||||||
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
|
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
|
||||||
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10) or check_temp_context({0xFF, 0x05, 0x14})
|
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10)
|
||||||
|
|
||||||
checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6)
|
checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6)
|
||||||
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
|
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
|
||||||
@@ -974,7 +961,7 @@ end
|
|||||||
|
|
||||||
local read_haunted_wasteland_checks = function()
|
local read_haunted_wasteland_checks = function()
|
||||||
local checks = {}
|
local checks = {}
|
||||||
checks["Wasteland Bombchu Salesman"] = salesman_check(0x5E, 0x01)
|
checks["Wasteland Bombchu Salesman"] = on_the_ground_check(0x5E, 0x01)
|
||||||
checks["Wasteland Chest"] = chest_check(0x5E, 0x00)
|
checks["Wasteland Chest"] = chest_check(0x5E, 0x00)
|
||||||
checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
|
checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
|
||||||
return checks
|
return checks
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -221,7 +221,7 @@ Starting with version 4 of the APBP format, this is a ZIP file containing metada
|
|||||||
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
|
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
|
||||||
bsdiff between the original and the randomized ROM.
|
bsdiff between the original and the randomized ROM.
|
||||||
|
|
||||||
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`.
|
To make using APBP easy, they can be generated by inheriting from `Patch.APDeltaPatch`.
|
||||||
|
|
||||||
### Mod files
|
### Mod files
|
||||||
Games which support modding will usually just let you drag and drop the mod’s files into a folder somewhere.
|
Games which support modding will usually just let you drag and drop the mod’s files into a folder somewhere.
|
||||||
@@ -230,7 +230,7 @@ They can either be generic and modify the game using a seed or `slot_data` from
|
|||||||
generated per seed.
|
generated per seed.
|
||||||
|
|
||||||
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
|
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
|
||||||
integration into the Webhost by inheriting from `worlds.Files.APContainer`.
|
integration into the Webhost by inheriting from `Patch.APContainer`.
|
||||||
|
|
||||||
|
|
||||||
## Archipelago Integration
|
## Archipelago Integration
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user