mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-04-13 20:23:29 -07:00
Compare commits
1 Commits
0.3.6
...
appimage-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53ef2aa786 |
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/
|
||||||
|
|||||||
328
BaseClasses.py
328
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
|
||||||
|
|
||||||
@@ -979,18 +955,11 @@ class Region:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance:
|
|
||||||
for entrance in self.entrances:
|
|
||||||
if is_main_entrance(entrance):
|
|
||||||
return entrance
|
|
||||||
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
|
||||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
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 +986,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 +996,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 +1010,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 +1040,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 +1074,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 +1085,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 +1105,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 +1202,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 +1224,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 +1233,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 +1281,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 +1310,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 +1367,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 +1377,60 @@ 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('Boss shuffle: %s\n' % self.world.boss_shuffle[player])
|
||||||
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
|
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
|
||||||
|
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
|
||||||
outfile.write('Prize shuffle %s\n' %
|
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 +1440,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 +1453,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 +1481,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.")
|
||||||
|
|||||||
466
Fill.py
466
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
|
||||||
@@ -144,207 +136,33 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
itempool.extend(unplaced_items)
|
itempool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
def remaining_fill(world: MultiWorld,
|
|
||||||
locations: typing.List[Location],
|
|
||||||
itempool: typing.List[Item]) -> None:
|
|
||||||
unplaced_items: typing.List[Item] = []
|
|
||||||
placements: typing.List[Location] = []
|
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
|
||||||
while locations and itempool:
|
|
||||||
item_to_place = itempool.pop()
|
|
||||||
spot_to_fill: typing.Optional[Location] = None
|
|
||||||
|
|
||||||
for i, location in enumerate(locations):
|
|
||||||
if location.item_rule(item_to_place):
|
|
||||||
# popping by index is faster than removing by content,
|
|
||||||
spot_to_fill = locations.pop(i)
|
|
||||||
# skipping a scan for the element
|
|
||||||
break
|
|
||||||
|
|
||||||
else:
|
|
||||||
# we filled all reachable spots.
|
|
||||||
# try swapping this item with previously placed items
|
|
||||||
|
|
||||||
for (i, location) in enumerate(placements):
|
|
||||||
placed_item = location.item
|
|
||||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
|
||||||
# number of times we will swap an individual item to prevent this
|
|
||||||
|
|
||||||
if swapped_items[placed_item.player,
|
|
||||||
placed_item.name] > 1:
|
|
||||||
continue
|
|
||||||
|
|
||||||
location.item = None
|
|
||||||
placed_item.location = None
|
|
||||||
if location.item_rule(item_to_place):
|
|
||||||
# Add this item to the existing placement, and
|
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swapped_items[placed_item.player,
|
|
||||||
placed_item.name] += 1
|
|
||||||
|
|
||||||
itempool.append(placed_item)
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
|
||||||
location.item = placed_item
|
|
||||||
placed_item.location = location
|
|
||||||
|
|
||||||
if spot_to_fill is None:
|
|
||||||
# Can't place this item, move on to the next
|
|
||||||
unplaced_items.append(item_to_place)
|
|
||||||
continue
|
|
||||||
|
|
||||||
world.push_item(spot_to_fill, item_to_place, False)
|
|
||||||
placements.append(spot_to_fill)
|
|
||||||
|
|
||||||
if unplaced_items and locations:
|
|
||||||
# There are leftover unplaceable items and locations that won't accept them
|
|
||||||
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
|
||||||
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
|
||||||
|
|
||||||
itempool.extend(unplaced_items)
|
|
||||||
|
|
||||||
|
|
||||||
def fast_fill(world: MultiWorld,
|
|
||||||
item_pool: typing.List[Item],
|
|
||||||
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
|
||||||
placing = min(len(item_pool), len(fill_locations))
|
|
||||||
for item, location in zip(item_pool, fill_locations):
|
|
||||||
world.push_item(location, item, False)
|
|
||||||
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] = []
|
nonexcludeditempool: typing.List[Item] = []
|
||||||
filleritempool: typing.List[Item] = []
|
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
|
||||||
|
nonlocalrestitempool: typing.List[Item] = []
|
||||||
|
restitempool: typing.List[Item] = []
|
||||||
|
|
||||||
for item in itempool:
|
for item in itempool:
|
||||||
if item.advancement:
|
if item.advancement:
|
||||||
progitempool.append(item)
|
progitempool.append(item)
|
||||||
elif item.useful:
|
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
|
||||||
usefulitempool.append(item)
|
nonexcludeditempool.append(item)
|
||||||
|
elif item.name in world.local_items[item.player].value:
|
||||||
|
localrestitempool[item.player].append(item)
|
||||||
|
elif item.name in world.non_local_items[item.player].value:
|
||||||
|
nonlocalrestitempool.append(item)
|
||||||
else:
|
else:
|
||||||
filleritempool.append(item)
|
restitempool.append(item)
|
||||||
|
|
||||||
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
|
||||||
|
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
||||||
|
|
||||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||||
loc_type: [] for loc_type in LocationProgressType}
|
loc_type: [] for loc_type in LocationProgressType}
|
||||||
@@ -356,44 +174,60 @@ 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 nonexcludeditempool:
|
||||||
if location.item:
|
world.random.shuffle(defaultlocations)
|
||||||
location.locked = True
|
# needs logical fill to not conflict with local items
|
||||||
del mark_for_locking, lock_later
|
fill_restrictive(
|
||||||
|
world, world.state, defaultlocations, nonexcludeditempool)
|
||||||
|
if nonexcludeditempool:
|
||||||
|
raise FillError(
|
||||||
|
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
|
||||||
|
|
||||||
inaccessible_location_rules(world, world.state, defaultlocations)
|
defaultlocations = defaultlocations + excludedlocations
|
||||||
|
world.random.shuffle(defaultlocations)
|
||||||
|
|
||||||
remaining_fill(world, excludedlocations, filleritempool)
|
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
||||||
if excludedlocations:
|
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
|
||||||
raise FillError(
|
for location in defaultlocations:
|
||||||
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
|
local_locations[location.player].append(location)
|
||||||
|
for player_locations in local_locations.values():
|
||||||
|
world.random.shuffle(player_locations)
|
||||||
|
|
||||||
restitempool = usefulitempool + filleritempool
|
for player, items in localrestitempool.items(): # items already shuffled
|
||||||
|
player_local_locations = local_locations[player]
|
||||||
|
for item_to_place in items:
|
||||||
|
if not player_local_locations:
|
||||||
|
logging.warning(f"Ran out of local locations for player {player}, "
|
||||||
|
f"cannot place {item_to_place}.")
|
||||||
|
break
|
||||||
|
spot_to_fill = player_local_locations.pop()
|
||||||
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
|
defaultlocations.remove(spot_to_fill)
|
||||||
|
|
||||||
remaining_fill(world, defaultlocations, restitempool)
|
for item_to_place in nonlocalrestitempool:
|
||||||
|
for i, location in enumerate(defaultlocations):
|
||||||
|
if location.player != item_to_place.player:
|
||||||
|
world.push_item(defaultlocations.pop(i), item_to_place, False)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. "
|
||||||
|
f"Too many non-local items for too few remaining locations.")
|
||||||
|
|
||||||
unplaced = restitempool
|
world.random.shuffle(defaultlocations)
|
||||||
|
|
||||||
|
restitempool, defaultlocations = fast_fill(
|
||||||
|
world, restitempool, defaultlocations)
|
||||||
|
unplaced = progitempool + restitempool
|
||||||
unfilled = defaultlocations
|
unfilled = defaultlocations
|
||||||
|
|
||||||
if unplaced or unfilled:
|
if unplaced or unfilled:
|
||||||
@@ -407,6 +241,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
|||||||
logging.info(f'Per-Player counts: {print_data})')
|
logging.info(f'Per-Player counts: {print_data})')
|
||||||
|
|
||||||
|
|
||||||
|
def fast_fill(world: MultiWorld,
|
||||||
|
item_pool: typing.List[Item],
|
||||||
|
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
||||||
|
placing = min(len(item_pool), len(fill_locations))
|
||||||
|
for item, location in zip(item_pool, fill_locations):
|
||||||
|
world.push_item(location, item, False)
|
||||||
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
def flood_items(world: MultiWorld) -> None:
|
def flood_items(world: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
world.random.shuffle(world.itempool)
|
world.random.shuffle(world.itempool)
|
||||||
@@ -683,17 +526,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 +541,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 +578,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 +613,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))
|
||||||
|
|||||||
87
Generate.py
87
Generate.py
@@ -23,6 +23,7 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
|
|||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed
|
||||||
import Options
|
import Options
|
||||||
|
from worlds.alttp import Bosses
|
||||||
from worlds.alttp.Text import TextTable
|
from worlds.alttp.Text import TextTable
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
import copy
|
import copy
|
||||||
@@ -154,12 +155,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 +233,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 +317,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]
|
||||||
@@ -337,6 +337,19 @@ def prefer_int(input_data: str) -> Union[str, int]:
|
|||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
|
|
||||||
|
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||||
|
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||||
|
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||||
|
Bosses.boss_location_table}
|
||||||
|
|
||||||
|
boss_shuffle_options = {None: 'none',
|
||||||
|
'none': 'none',
|
||||||
|
'basic': 'basic',
|
||||||
|
'full': 'full',
|
||||||
|
'chaos': 'chaos',
|
||||||
|
'singularity': 'singularity'
|
||||||
|
}
|
||||||
|
|
||||||
goals = {
|
goals = {
|
||||||
'ganon': 'ganon',
|
'ganon': 'ganon',
|
||||||
'crystals': 'crystals',
|
'crystals': 'crystals',
|
||||||
@@ -378,7 +391,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",
|
||||||
@@ -443,7 +456,42 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
|
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
||||||
|
if boss_shuffle in boss_shuffle_options:
|
||||||
|
return boss_shuffle_options[boss_shuffle]
|
||||||
|
elif PlandoSettings.bosses in plando_options:
|
||||||
|
options = boss_shuffle.lower().split(";")
|
||||||
|
remainder_shuffle = "none" # vanilla
|
||||||
|
bosses = []
|
||||||
|
for boss in options:
|
||||||
|
if boss in boss_shuffle_options:
|
||||||
|
remainder_shuffle = boss_shuffle_options[boss]
|
||||||
|
elif "-" in boss:
|
||||||
|
loc, boss_name = boss.split("-")
|
||||||
|
if boss_name not in available_boss_names:
|
||||||
|
raise ValueError(f"Unknown Boss name {boss_name}")
|
||||||
|
if loc not in available_boss_locations:
|
||||||
|
raise ValueError(f"Unknown Boss Location {loc}")
|
||||||
|
level = ''
|
||||||
|
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||||
|
# split off level
|
||||||
|
loc = loc.split(" ")
|
||||||
|
level = f" {loc[-1]}"
|
||||||
|
loc = " ".join(loc[:-1])
|
||||||
|
loc = loc.title().replace("Of", "of")
|
||||||
|
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
||||||
|
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
||||||
|
bosses.append(boss)
|
||||||
|
elif boss not in available_boss_names:
|
||||||
|
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
||||||
|
else:
|
||||||
|
bosses.append(boss)
|
||||||
|
return ";".join(bosses + [remainder_shuffle])
|
||||||
|
else:
|
||||||
|
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
||||||
if option_key in game_weights:
|
if option_key in game_weights:
|
||||||
try:
|
try:
|
||||||
if not option.supports_weighting:
|
if not option.supports_weighting:
|
||||||
@@ -454,9 +502,10 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
if hasattr(player_option, "verify"):
|
||||||
|
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
||||||
else:
|
else:
|
||||||
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
setattr(ret, option_key, option(option.default))
|
||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
||||||
@@ -500,11 +549,11 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
|||||||
|
|
||||||
if ret.game in AutoWorldRegister.world_types:
|
if ret.game in AutoWorldRegister.world_types:
|
||||||
for option_key, option in world_type.option_definitions.items():
|
for option_key, option in world_type.option_definitions.items():
|
||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option)
|
||||||
for option_key, option in Options.per_game_common_options.items():
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
# skip setting this option if already set from common_options, defaulting to root option
|
# skip setting this option if already set from common_options, defaulting to root option
|
||||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option)
|
||||||
if PlandoSettings.items in plando_options:
|
if PlandoSettings.items in plando_options:
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||||
@@ -587,6 +636,8 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
|
|
||||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||||
|
|
||||||
|
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
||||||
|
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
||||||
|
|
||||||
ret.enemy_damage = {None: 'default',
|
ret.enemy_damage = {None: 'default',
|
||||||
'default': 'default',
|
'default': 'default',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
108
Main.py
108
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 lookup_vanilla_location_to_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)
|
||||||
@@ -264,9 +249,24 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
output_file_futures.append(
|
output_file_futures.append(
|
||||||
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
|
def get_entrance_to_region(region: Region):
|
||||||
|
for entrance in region.entrances:
|
||||||
|
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
||||||
|
return entrance
|
||||||
|
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
||||||
|
return get_entrance_to_region(entrance.parent_region)
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
||||||
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
|
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
|
||||||
|
|
||||||
|
for region in world.regions:
|
||||||
|
if region.player in er_hint_data and region.locations:
|
||||||
|
main_entrance = get_entrance_to_region(region)
|
||||||
|
for location in region.locations:
|
||||||
|
if type(location.address) == int: # skips events and crystals
|
||||||
|
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
||||||
|
er_hint_data[region.player][location.address] = main_entrance.name
|
||||||
|
|
||||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||||
for player in range(1, world.players + 1)}
|
for player in range(1, world.players + 1)}
|
||||||
@@ -276,23 +276,22 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
for location in world.get_filled_locations():
|
for location in world.get_filled_locations():
|
||||||
if type(location.address) is int:
|
if type(location.address) is int:
|
||||||
|
main_entrance = get_entrance_to_region(location.parent_region)
|
||||||
if location.game != "A Link to the Past":
|
if location.game != "A Link to the Past":
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
else:
|
elif location.parent_region.dungeon:
|
||||||
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
|
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||||
if location.parent_region.dungeon:
|
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
checks_in_area[location.player][dungeonname].append(location.address)
|
||||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
elif location.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[location.player][dungeonname].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
elif location.parent_region.type == RegionType.LightWorld:
|
elif location.parent_region.type == RegionType.DarkWorld:
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
elif location.parent_region.type == RegionType.DarkWorld:
|
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
|
||||||
checks_in_area[location.player]["Total"] += 1
|
checks_in_area[location.player]["Total"] += 1
|
||||||
|
|
||||||
oldmancaves = []
|
oldmancaves = []
|
||||||
@@ -306,7 +305,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
player = region.player
|
player = region.player
|
||||||
location_id = SHOP_ID_START + total_shop_slots + index
|
location_id = SHOP_ID_START + total_shop_slots + index
|
||||||
|
|
||||||
main_entrance = region.get_connecting_entrance(is_main_entrance)
|
main_entrance = get_entrance_to_region(region)
|
||||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[player]["Light World"].append(location_id)
|
checks_in_area[player]["Light World"].append(location_id)
|
||||||
else:
|
else:
|
||||||
@@ -341,6 +340,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player, world_precollected in world.precollected_items.items()}
|
for player, world_precollected in world.precollected_items.items()}
|
||||||
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||||
|
|
||||||
|
|
||||||
for slot in world.player_ids:
|
for slot in world.player_ids:
|
||||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
309
MultiServer.py
309
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
|
||||||
|
|
||||||
@@ -126,7 +126,6 @@ class Context:
|
|||||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
forced_auto_forfeits: typing.Dict[str, bool]
|
forced_auto_forfeits: typing.Dict[str, bool]
|
||||||
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
|
||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
|
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
|
||||||
@@ -197,7 +196,7 @@ class Context:
|
|||||||
self.item_name_groups = {}
|
self.item_name_groups = {}
|
||||||
self.all_item_and_group_names = {}
|
self.all_item_and_group_names = {}
|
||||||
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
|
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
|
||||||
self.non_hintable_names = collections.defaultdict(frozenset)
|
self.non_hintable_names = {}
|
||||||
|
|
||||||
self._load_game_data()
|
self._load_game_data()
|
||||||
self._init_game_data()
|
self._init_game_data()
|
||||||
@@ -222,11 +221,11 @@ class Context:
|
|||||||
self.all_item_and_group_names[game_name] = \
|
self.all_item_and_group_names[game_name] = \
|
||||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||||
|
|
||||||
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
def item_names_for_game(self, game: str) -> typing.Dict[str, int]:
|
||||||
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
return self.gamespackage[game]["item_name_to_id"]
|
||||||
|
|
||||||
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
def location_names_for_game(self, game: str) -> typing.Dict[str, int]:
|
||||||
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
|
return self.gamespackage[game]["location_name_to_id"]
|
||||||
|
|
||||||
# General networking
|
# General networking
|
||||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
||||||
@@ -273,16 +272,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 +301,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 +626,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 +635,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 +813,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:]}]))
|
||||||
@@ -901,14 +900,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
|
def collect_hints(ctx: Context, team: int, slot: int, item_name: str) -> typing.List[NetUtils.Hint]:
|
||||||
hints = []
|
hints = []
|
||||||
slots: typing.Set[int] = {slot}
|
slots: typing.Set[int] = {slot}
|
||||||
for group_id, group in ctx.groups.items():
|
for group_id, group in ctx.groups.items():
|
||||||
if slot in group:
|
if slot in group:
|
||||||
slots.add(group_id)
|
slots.add(group_id)
|
||||||
|
|
||||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
seeked_item_id = ctx.item_names_for_game(ctx.games[slot])[item_name]
|
||||||
for finding_player, check_data in ctx.locations.items():
|
for finding_player, check_data in ctx.locations.items():
|
||||||
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
||||||
if receiving_player in slots and item_id == seeked_item_id:
|
if receiving_player in slots and item_id == seeked_item_id:
|
||||||
@@ -998,11 +997,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 +1085,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 +1327,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]}
|
||||||
@@ -1342,33 +1335,13 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
||||||
f"You have {points_available} points.")
|
f"You have {points_available} points.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif input_text.isnumeric():
|
|
||||||
game = self.ctx.games[self.client.slot]
|
|
||||||
hint_id = int(input_text)
|
|
||||||
hint_name = self.ctx.item_names[hint_id] \
|
|
||||||
if not for_location and hint_id in self.ctx.item_names \
|
|
||||||
else self.ctx.location_names[hint_id] \
|
|
||||||
if for_location and hint_id in self.ctx.location_names \
|
|
||||||
else None
|
|
||||||
if hint_name in self.ctx.non_hintable_names[game]:
|
|
||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
|
||||||
hints = []
|
|
||||||
elif not for_location:
|
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
|
||||||
else:
|
|
||||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
if game not in self.ctx.all_item_and_group_names:
|
|
||||||
self.output("Can't look up item/location for unknown game. Hint for ID instead.")
|
|
||||||
return False
|
|
||||||
names = self.ctx.location_names_for_game(game) \
|
names = self.ctx.location_names_for_game(game) \
|
||||||
if for_location else \
|
if for_location else \
|
||||||
self.ctx.all_item_and_group_names[game]
|
self.ctx.all_item_and_group_names[game]
|
||||||
hint_name, usable, response = get_intended_text(input_text, names)
|
hint_name, usable, response = get_intended_text(input_text,
|
||||||
|
names)
|
||||||
if usable:
|
if usable:
|
||||||
if hint_name in self.ctx.non_hintable_names[game]:
|
if hint_name in self.ctx.non_hintable_names[game]:
|
||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
@@ -1382,69 +1355,63 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
|
if hints:
|
||||||
|
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||||
|
old_hints = set(hints) - new_hints
|
||||||
|
if old_hints:
|
||||||
|
notify_hints(self.ctx, self.client.team, list(old_hints))
|
||||||
|
if not new_hints:
|
||||||
|
self.output("Hint was previously used, no points deducted.")
|
||||||
|
if new_hints:
|
||||||
|
found_hints = [hint for hint in new_hints if hint.found]
|
||||||
|
not_found_hints = [hint for hint in new_hints if not hint.found]
|
||||||
|
|
||||||
|
if not not_found_hints: # everything's been found, no need to pay
|
||||||
|
can_pay = 1000
|
||||||
|
elif cost:
|
||||||
|
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
|
||||||
|
else:
|
||||||
|
can_pay = 1000
|
||||||
|
|
||||||
|
self.ctx.random.shuffle(not_found_hints)
|
||||||
|
# By popular vote, make hints prefer non-local placements
|
||||||
|
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
||||||
|
|
||||||
|
hints = found_hints
|
||||||
|
while can_pay > 0:
|
||||||
|
if not not_found_hints:
|
||||||
|
break
|
||||||
|
hint = not_found_hints.pop()
|
||||||
|
hints.append(hint)
|
||||||
|
can_pay -= 1
|
||||||
|
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
||||||
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
|
|
||||||
|
if not_found_hints:
|
||||||
|
if hints and cost and int((points_available // cost) == 0):
|
||||||
|
self.output(
|
||||||
|
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
||||||
|
f" You have {points_available} and need at least "
|
||||||
|
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
||||||
|
elif hints:
|
||||||
|
self.output(
|
||||||
|
"There may be more hintables, you can rerun the command to find more.")
|
||||||
|
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)}.")
|
||||||
|
notify_hints(self.ctx, self.client.team, hints)
|
||||||
|
self.ctx.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.output("Nothing found. Item/Location may not exist.")
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if hints:
|
|
||||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
|
||||||
old_hints = set(hints) - new_hints
|
|
||||||
if old_hints:
|
|
||||||
notify_hints(self.ctx, self.client.team, list(old_hints))
|
|
||||||
if not new_hints:
|
|
||||||
self.output("Hint was previously used, no points deducted.")
|
|
||||||
if new_hints:
|
|
||||||
found_hints = [hint for hint in new_hints if hint.found]
|
|
||||||
not_found_hints = [hint for hint in new_hints if not hint.found]
|
|
||||||
|
|
||||||
if not not_found_hints: # everything's been found, no need to pay
|
|
||||||
can_pay = 1000
|
|
||||||
elif cost:
|
|
||||||
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
|
|
||||||
else:
|
|
||||||
can_pay = 1000
|
|
||||||
|
|
||||||
self.ctx.random.shuffle(not_found_hints)
|
|
||||||
# By popular vote, make hints prefer non-local placements
|
|
||||||
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
|
||||||
|
|
||||||
hints = found_hints
|
|
||||||
while can_pay > 0:
|
|
||||||
if not not_found_hints:
|
|
||||||
break
|
|
||||||
hint = not_found_hints.pop()
|
|
||||||
hints.append(hint)
|
|
||||||
can_pay -= 1
|
|
||||||
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
|
||||||
|
|
||||||
if not_found_hints:
|
|
||||||
if hints and cost and int((points_available // cost) == 0):
|
|
||||||
self.output(
|
|
||||||
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
|
||||||
f" You have {points_available} and need at least "
|
|
||||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
|
||||||
elif hints:
|
|
||||||
self.output(
|
|
||||||
"There may be more hintables, you can rerun the command to find more.")
|
|
||||||
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)}.")
|
|
||||||
notify_hints(self.ctx, self.client.team, hints)
|
|
||||||
self.ctx.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
else:
|
|
||||||
if points_available >= cost:
|
|
||||||
self.output("Nothing found. Item/Location may not exist.")
|
|
||||||
else:
|
|
||||||
self.output(f"You can't afford the hint. "
|
|
||||||
f"You have {points_available} points and need at least "
|
|
||||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_hint(self, item_name: str = "") -> bool:
|
def _cmd_hint(self, item_name: str = "") -> bool:
|
||||||
"""Use !hint {item_name},
|
"""Use !hint {item_name},
|
||||||
@@ -1771,7 +1738,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 +1769,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 +1789,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 +1801,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 +1814,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
|
||||||
@@ -1910,25 +1859,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
|
item_name = " ".join(item_name)
|
||||||
game = self.ctx.games[slot]
|
game = self.ctx.games[slot]
|
||||||
full_name = " ".join(item_name)
|
item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game])
|
||||||
|
|
||||||
if full_name.isnumeric():
|
|
||||||
item, usable, response = int(full_name), True, None
|
|
||||||
elif game in self.ctx.all_item_and_group_names:
|
|
||||||
item, usable, response = get_intended_text(full_name, self.ctx.all_item_and_group_names[game])
|
|
||||||
else:
|
|
||||||
self.output("Can't look up item for unknown game. Hint for ID instead.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if game in self.ctx.item_name_groups and item in self.ctx.item_name_groups[game]:
|
if item_name in self.ctx.item_name_groups[game]:
|
||||||
hints = []
|
hints = []
|
||||||
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
for item_name_from_group in self.ctx.item_name_groups[game][item_name]:
|
||||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||||
else: # item name or id
|
else: # item name
|
||||||
hints = collect_hints(self.ctx, team, slot, item)
|
hints = collect_hints(self.ctx, team, slot, item_name)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
notify_hints(self.ctx, team, hints)
|
notify_hints(self.ctx, team, hints)
|
||||||
@@ -1949,22 +1890,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
game = self.ctx.games[slot]
|
location_name = " ".join(location_name)
|
||||||
full_name = " ".join(location_name)
|
location_name, usable, response = get_intended_text(location_name,
|
||||||
|
self.ctx.location_names_for_game(self.ctx.games[slot]))
|
||||||
if full_name.isnumeric():
|
|
||||||
location, usable, response = int(full_name), True, None
|
|
||||||
elif self.ctx.location_names_for_game(game) is not None:
|
|
||||||
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
|
|
||||||
else:
|
|
||||||
self.output("Can't look up location for unknown game. Hint for ID instead.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if isinstance(location, int):
|
hints = collect_hint_location_name(self.ctx, team, slot, location_name)
|
||||||
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
|
||||||
else:
|
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
|
||||||
if hints:
|
if hints:
|
||||||
notify_hints(self.ctx, team, hints)
|
notify_hints(self.ctx, team, hints)
|
||||||
else:
|
else:
|
||||||
@@ -2084,7 +2014,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 +2025,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:
|
||||||
@@ -2114,28 +2044,15 @@ async def main(args: argparse.Namespace):
|
|||||||
args.auto_shutdown, args.compatibility, args.log_network)
|
args.auto_shutdown, args.compatibility, args.log_network)
|
||||||
data_filename = args.multidata
|
data_filename = args.multidata
|
||||||
|
|
||||||
if not data_filename:
|
try:
|
||||||
try:
|
if not data_filename:
|
||||||
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
|
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
|
||||||
data_filename = Utils.open_filename("Select multiworld data", filetypes)
|
data_filename = Utils.open_filename("Select multiworld data", filetypes)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if isinstance(e, ImportError) or (e.__class__.__name__ == "TclError" and "no display" in str(e)):
|
|
||||||
if not isinstance(e, ImportError):
|
|
||||||
logging.error(f"Failed to load tkinter ({e})")
|
|
||||||
logging.info("Pass a multidata filename on command line to run headless.")
|
|
||||||
exit(1)
|
|
||||||
raise
|
|
||||||
|
|
||||||
if not data_filename:
|
|
||||||
logging.info("No file selected. Exiting.")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
ctx.load(data_filename, args.use_embedded_options)
|
ctx.load(data_filename, args.use_embedded_options)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(f"Failed to read multiworld data ({e})")
|
logging.exception('Failed to read multiworld data (%s)' % e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
ctx.init_save(not args.disable_save)
|
ctx.init_save(not args.disable_save)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
253
Options.py
253
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
|
||||||
@@ -27,31 +26,15 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
|
|
||||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||||
options.update(new_options)
|
options.update(new_options)
|
||||||
|
|
||||||
# apply aliases, without name_lookup
|
# apply aliases, without name_lookup
|
||||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||||
name.startswith("alias_")}
|
name.startswith("alias_")}
|
||||||
|
|
||||||
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
||||||
|
|
||||||
# auto-alias Off and On being parsed as True and False
|
|
||||||
if "off" in options:
|
|
||||||
options["false"] = options["off"]
|
|
||||||
if "on" in options:
|
|
||||||
options["true"] = options["on"]
|
|
||||||
|
|
||||||
options.update(aliases)
|
options.update(aliases)
|
||||||
|
|
||||||
if "verify" not in attrs:
|
|
||||||
# not overridden by class -> look up bases
|
|
||||||
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
|
|
||||||
if len(verifiers) > 1: # verify multiple bases/mixins
|
|
||||||
def verify(self, *args, **kwargs) -> None:
|
|
||||||
for f in verifiers:
|
|
||||||
f(self, *args, **kwargs)
|
|
||||||
attrs["verify"] = verify
|
|
||||||
else:
|
|
||||||
assert verifiers, "class Option is supposed to implement def verify"
|
|
||||||
|
|
||||||
# auto-validate schema on __init__
|
# auto-validate schema on __init__
|
||||||
if "schema" in attrs.keys():
|
if "schema" in attrs.keys():
|
||||||
|
|
||||||
@@ -79,9 +62,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')
|
||||||
|
|
||||||
@@ -132,44 +112,8 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
from Generate import PlandoSettings
|
|
||||||
from worlds.AutoWorld import World
|
|
||||||
|
|
||||||
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
def verify(self, *args, **kwargs) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class FreeText(Option):
|
|
||||||
"""Text option that allows users to enter strings.
|
|
||||||
Needs to be validated by the world or option definition."""
|
|
||||||
|
|
||||||
def __init__(self, value: str):
|
|
||||||
assert isinstance(value, str), "value of FreeText must be a string"
|
|
||||||
self.value = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_key(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_text(cls, text: str) -> FreeText:
|
|
||||||
return cls(text)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_any(cls, data: typing.Any) -> FreeText:
|
|
||||||
return cls.from_text(str(data))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: T) -> str:
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -424,170 +368,6 @@ class Choice(NumericOption):
|
|||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
class TextChoice(Choice):
|
|
||||||
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Union[str, int]):
|
|
||||||
assert isinstance(value, str) or isinstance(value, int), \
|
|
||||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
|
||||||
self.value = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_key(self) -> str:
|
|
||||||
if isinstance(self.value, str):
|
|
||||||
return self.value
|
|
||||||
else:
|
|
||||||
return self.name_lookup[self.value]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_text(cls, text: str) -> TextChoice:
|
|
||||||
if text.lower() == "random": # chooses a random defined option but won't use any free text options
|
|
||||||
return cls(random.choice(list(cls.name_lookup)))
|
|
||||||
for option_name, value in cls.options.items():
|
|
||||||
if option_name.lower() == text.lower():
|
|
||||||
return cls(value)
|
|
||||||
return cls(text)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: T) -> str:
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
return cls.name_lookup[value]
|
|
||||||
|
|
||||||
def __eq__(self, other: typing.Any):
|
|
||||||
if isinstance(other, self.__class__):
|
|
||||||
return other.value == self.value
|
|
||||||
elif isinstance(other, str):
|
|
||||||
if other in self.options:
|
|
||||||
return other == self.current_key
|
|
||||||
return other == self.value
|
|
||||||
elif isinstance(other, int):
|
|
||||||
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
|
||||||
return other == self.value
|
|
||||||
elif isinstance(other, bool):
|
|
||||||
return other == bool(self.value)
|
|
||||||
else:
|
|
||||||
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
|
||||||
@@ -605,7 +385,7 @@ class Range(NumericOption):
|
|||||||
if text.startswith("random"):
|
if text.startswith("random"):
|
||||||
return cls.weighted_range(text)
|
return cls.weighted_range(text)
|
||||||
elif text == "default" and hasattr(cls, "default"):
|
elif text == "default" and hasattr(cls, "default"):
|
||||||
return cls.from_any(cls.default)
|
return cls(cls.default)
|
||||||
elif text == "high":
|
elif text == "high":
|
||||||
return cls(cls.range_end)
|
return cls(cls.range_end)
|
||||||
elif text == "low":
|
elif text == "low":
|
||||||
@@ -616,7 +396,7 @@ class Range(NumericOption):
|
|||||||
and text in ("true", "false"):
|
and text in ("true", "false"):
|
||||||
# these are the conditions where "true" and "false" make sense
|
# these are the conditions where "true" and "false" make sense
|
||||||
if text == "true":
|
if text == "true":
|
||||||
return cls.from_any(cls.default)
|
return cls(cls.default)
|
||||||
else: # "false"
|
else: # "false"
|
||||||
return cls(0)
|
return cls(0)
|
||||||
return cls(int(text))
|
return cls(int(text))
|
||||||
@@ -727,7 +507,7 @@ class VerifyKeys:
|
|||||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||||
f"Allowed keys: {cls.valid_keys}.")
|
f"Allowed keys: {cls.valid_keys}.")
|
||||||
|
|
||||||
def verify(self, world, player_name: str, plando_options) -> None:
|
def verify(self, world):
|
||||||
if self.convert_name_groups and self.verify_item_name:
|
if self.convert_name_groups and self.verify_item_name:
|
||||||
new_value = type(self.value)() # empty container of whatever value is
|
new_value = type(self.value)() # empty container of whatever value is
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
@@ -750,11 +530,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 +561,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 +587,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
|
||||||
@@ -820,7 +600,10 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if isinstance(data, (list, set, frozenset)):
|
if type(data) == list:
|
||||||
|
cls.verify_keys(data)
|
||||||
|
return cls(data)
|
||||||
|
elif type(data) == set:
|
||||||
cls.verify_keys(data)
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
@@ -850,7 +633,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
|
||||||
@@ -949,8 +732,8 @@ class ItemLinks(OptionList):
|
|||||||
pool |= {item_name}
|
pool |= {item_name}
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
def verify(self, world, player_name: str, plando_options) -> None:
|
def verify(self, world):
|
||||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
super(ItemLinks, self).verify(world)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
|
|||||||
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,58 +292,42 @@ 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))
|
||||||
|
|
||||||
|
# Map is completed
|
||||||
for mission in categories[category]:
|
for mission in categories[category]:
|
||||||
text: str = mission
|
text = mission
|
||||||
tooltip: str = ""
|
tooltip = ""
|
||||||
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]"
|
||||||
|
|
||||||
|
tooltip = f"Uncollected locations:\n"
|
||||||
|
tooltip += "\n".join([self.ctx.location_names[loc] for loc in
|
||||||
|
self.ctx.locations_for_mission(mission)
|
||||||
|
if loc in self.ctx.missing_locations])
|
||||||
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
|
||||||
else:
|
else:
|
||||||
text = f"[color=a9a9a9]{text}[/color]"
|
text = f"[color=a9a9a9]{text}[/color]"
|
||||||
tooltip = f"Requires: "
|
tooltip = f"Requires: "
|
||||||
if self.ctx.mission_req_table[mission].required_world:
|
if len(self.ctx.mission_req_table[mission].required_world) > 0:
|
||||||
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
|
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
|
||||||
req_mission in
|
req_mission in
|
||||||
self.ctx.mission_req_table[mission].required_world)
|
self.ctx.mission_req_table[mission].required_world)
|
||||||
|
|
||||||
if self.ctx.mission_req_table[mission].number:
|
if self.ctx.mission_req_table[mission].number > 0:
|
||||||
tooltip += " and "
|
tooltip += " and "
|
||||||
if self.ctx.mission_req_table[mission].number:
|
if self.ctx.mission_req_table[mission].number > 0:
|
||||||
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
|
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
|
||||||
remaining_location_names: typing.List[str] = [
|
|
||||||
self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
|
|
||||||
if loc in self.ctx.missing_locations]
|
|
||||||
|
|
||||||
if mission_id == self.ctx.final_mission:
|
|
||||||
if mission in available_missions:
|
|
||||||
text = f"[color=FFBC95]{mission}[/color]"
|
|
||||||
else:
|
|
||||||
text = f"[color=D0C0BE]{mission}[/color]"
|
|
||||||
if tooltip:
|
|
||||||
tooltip += "\n"
|
|
||||||
tooltip += "Final Mission"
|
|
||||||
|
|
||||||
if remaining_location_names:
|
|
||||||
if tooltip:
|
|
||||||
tooltip += "\n"
|
|
||||||
tooltip += f"Uncollected locations:\n"
|
|
||||||
tooltip += "\n".join(remaining_location_names)
|
|
||||||
|
|
||||||
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 +354,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 +435,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 +552,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 +708,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 +732,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
|
||||||
@@ -859,12 +790,7 @@ def check_game_install_path() -> bool:
|
|||||||
with open(einfo) as f:
|
with open(einfo) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if content:
|
if content:
|
||||||
try:
|
base = re.search(r" = (.*)Versions", content).group(1)
|
||||||
base = re.search(r" = (.*)Versions", content).group(1)
|
|
||||||
except AttributeError:
|
|
||||||
sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then "
|
|
||||||
f"try again.")
|
|
||||||
return False
|
|
||||||
if os.path.exists(base):
|
if os.path.exists(base):
|
||||||
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
|
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
|
||||||
|
|
||||||
@@ -881,58 +807,22 @@ def check_game_install_path() -> bool:
|
|||||||
else:
|
else:
|
||||||
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
|
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
|
||||||
else:
|
else:
|
||||||
sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. "
|
sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.")
|
||||||
f"If that fails, please run /set_path with your SC2 install directory.")
|
|
||||||
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 +861,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")
|
||||||
|
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import collections
|
|
||||||
import datetime
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import websockets
|
||||||
import pickle
|
import asyncio
|
||||||
import random
|
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import random
|
||||||
import websockets
|
import pickle
|
||||||
from pony.orm import db_session, commit, select
|
import logging
|
||||||
|
import datetime
|
||||||
|
|
||||||
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):
|
||||||
@@ -51,8 +49,6 @@ class DBCommandProcessor(ServerCommandProcessor):
|
|||||||
|
|
||||||
|
|
||||||
class WebHostContext(Context):
|
class WebHostContext(Context):
|
||||||
room_id: int
|
|
||||||
|
|
||||||
def __init__(self, static_server_data: dict):
|
def __init__(self, static_server_data: dict):
|
||||||
# static server data is used during _load_game_data to load required data,
|
# static server data is used during _load_game_data to load required data,
|
||||||
# without needing to import worlds system, which takes quite a bit of memory
|
# without needing to import worlds system, which takes quite a bit of memory
|
||||||
@@ -66,8 +62,6 @@ class WebHostContext(Context):
|
|||||||
def _load_game_data(self):
|
def _load_game_data(self):
|
||||||
for key, value in self.static_server_data.items():
|
for key, value in self.static_server_data.items():
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
|
|
||||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
|
||||||
|
|
||||||
def listen_to_db_commands(self):
|
def listen_to_db_commands(self):
|
||||||
cmdprocessor = DBCommandProcessor(self)
|
cmdprocessor = DBCommandProcessor(self)
|
||||||
@@ -184,12 +178,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 =
|
||||||
|
|||||||
@@ -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));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
{% 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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
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
|
||||||
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 MultiServer import Context
|
||||||
from . import app, cache
|
from NetUtils import SlotType
|
||||||
from .models import Room
|
|
||||||
|
|
||||||
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",
|
||||||
@@ -443,23 +442,17 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
|||||||
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
|
"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",
|
"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",
|
"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 = {
|
minecraft_location_ids = {
|
||||||
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
||||||
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
||||||
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
|
"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],
|
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42014],
|
||||||
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
|
"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,
|
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42035, 42016, 42020,
|
||||||
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100],
|
42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42099, 42100],
|
||||||
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112,
|
"Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028, 42036,
|
||||||
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
|
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
|
||||||
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
|
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
|
||||||
}
|
}
|
||||||
@@ -488,8 +481,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
|||||||
# Multi-items
|
# Multi-items
|
||||||
multi_items = {
|
multi_items = {
|
||||||
"3 Ender Pearls": 45029,
|
"3 Ender Pearls": 45029,
|
||||||
"8 Netherite Scrap": 45015,
|
"8 Netherite Scrap": 45015
|
||||||
"Dragon Egg Shard": 45043
|
|
||||||
}
|
}
|
||||||
for item_name, item_id in multi_items.items():
|
for item_name, item_id in multi_items.items():
|
||||||
base_name = item_name.split()[-1].lower()
|
base_name = item_name.split()[-1].lower()
|
||||||
@@ -827,27 +819,27 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
|||||||
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": "",
|
||||||
@@ -1045,4 +1037,4 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
|||||||
"Timespinner": __renderTimespinnerTracker,
|
"Timespinner": __renderTimespinnerTracker,
|
||||||
"A Link to the Past": __renderAlttpTracker,
|
"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
|
||||||
|
|||||||
@@ -23,10 +23,3 @@ No metadata is specified yet.
|
|||||||
## Extra Data
|
## Extra Data
|
||||||
|
|
||||||
The zip can contain arbitrary files in addition what was specified above.
|
The zip can contain arbitrary files in addition what was specified above.
|
||||||
|
|
||||||
|
|
||||||
## Caveats
|
|
||||||
|
|
||||||
Imports from other files inside the apworld have to use relative imports.
|
|
||||||
|
|
||||||
Imports from AP base have to use absolute imports, e.g. Options.py and worlds/AutoWorld.py.
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ Contributions are welcome. We have a few requests of any new contributors.
|
|||||||
Otherwise, we tend to judge code on a case to case basis.
|
Otherwise, we tend to judge code on a case to case basis.
|
||||||
|
|
||||||
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
|
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
|
||||||
[the docs folder](/docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
|
[the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
|
||||||
channel in our [Discord](https://archipelago.gg/discord).
|
channel in our [Discord](https://archipelago.gg/discord).
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ There are also a number of community-supported libraries available that implemen
|
|||||||
| | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). |
|
| | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). |
|
||||||
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
|
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
|
||||||
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
|
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
|
||||||
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | header-only |
|
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | almost-header-only |
|
||||||
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
|
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
|
||||||
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
|
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
|
||||||
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
|
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
|
||||||
| Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | |
|
|
||||||
|
|
||||||
## Synchronizing Items
|
## Synchronizing Items
|
||||||
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
|
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
|
||||||
@@ -235,8 +234,6 @@ Sent to clients as a response the a [Get](#Get) package.
|
|||||||
| ---- | ---- | ----- |
|
| ---- | ---- | ----- |
|
||||||
| keys | dict\[str\, any] | A key-value collection containing all the values for the keys requested in the [Get](#Get) package. |
|
| keys | dict\[str\, any] | A key-value collection containing all the values for the keys requested in the [Get](#Get) package. |
|
||||||
|
|
||||||
If a requested key was not present in the server's data, the associated value will be `null`.
|
|
||||||
|
|
||||||
Additional arguments added to the [Get](#Get) package that triggered this [Retrieved](#Retrieved) will also be passed along.
|
Additional arguments added to the [Get](#Get) package that triggered this [Retrieved](#Retrieved) will also be passed along.
|
||||||
|
|
||||||
### SetReply
|
### SetReply
|
||||||
@@ -374,7 +371,7 @@ Used to write data to the server's data storage, that data can then be shared ac
|
|||||||
| ------ | ----- | ------ |
|
| ------ | ----- | ------ |
|
||||||
| key | str | The key to manipulate. |
|
| key | str | The key to manipulate. |
|
||||||
| default | any | The default value to use in case the key has no value on the server. |
|
| default | any | The default value to use in case the key has no value on the server. |
|
||||||
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
|
| want_reply | bool | If set, the server will send a [SetReply](#SetReply) response back to the client. |
|
||||||
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
|
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
|
||||||
|
|
||||||
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
|
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
|
||||||
|
|||||||
@@ -16,14 +16,6 @@ Then run any of the starting point scripts, like Generate.py, and the included M
|
|||||||
required modules and after pressing enter proceed to install everything automatically.
|
required modules and after pressing enter proceed to install everything automatically.
|
||||||
After this, you should be able to run the programs.
|
After this, you should be able to run the programs.
|
||||||
|
|
||||||
* With yaml(s) in the `Players` folder, `Generate.py` will generate the multiworld archive.
|
|
||||||
* `MultiServer.py`, with the filename of the generated archive as a command line parameter, will host the multiworld locally.
|
|
||||||
* `--log_network` is a command line parameter useful for debugging.
|
|
||||||
* `WebHost.py` will host the website on your computer.
|
|
||||||
* You can copy `docs/webhost configuration sample.yaml` to `config.yaml`
|
|
||||||
to change WebHost options (like the web hosting port number).
|
|
||||||
* As a side effect, `WebHost.py` creates the template yamls for all the games in `WebHostLib/static/generated`.
|
|
||||||
|
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,7 @@
|
|||||||
* Strings in worlds should use double quotes as well, but imported code may differ.
|
* Strings in worlds should use double quotes as well, but imported code may differ.
|
||||||
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
||||||
use single quotes inside them: `f"Like {dct['key']}"`
|
use single quotes inside them: `f"Like {dct['key']}"`
|
||||||
* Use type annotations where possible for function signatures and class members.
|
* Use type annotation where possible.
|
||||||
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
|
|
||||||
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
|
|
||||||
|
|
||||||
|
|
||||||
## Markdown
|
## Markdown
|
||||||
|
|||||||
@@ -102,18 +102,13 @@ Locations are places where items can be located in your game. This may be chests
|
|||||||
or boss drops for RPG-like games but could also be progress in a research tree.
|
or boss drops for RPG-like games but could also be progress in a research tree.
|
||||||
|
|
||||||
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
|
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
|
||||||
in a Region, has access rules and a classification.
|
in a Region and has access rules.
|
||||||
The name needs to be unique in each game and must not be numeric (has to
|
The name needs to be unique in each game, the ID needs to be unique across all
|
||||||
contain least 1 letter or symbol). The ID needs to be unique across all games
|
games and is best in the same range as the item IDs.
|
||||||
and is best in the same range as the item IDs.
|
|
||||||
World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
|
World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
|
||||||
|
|
||||||
Special locations with ID `None` can hold events.
|
Special locations with ID `None` can hold events.
|
||||||
|
|
||||||
Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`.
|
|
||||||
The Fill algorithm will fill priority first, giving higher chance of it being
|
|
||||||
required, and not place progression or useful items in excluded locations.
|
|
||||||
|
|
||||||
### Items
|
### Items
|
||||||
|
|
||||||
Items are all things that can "drop" for your game. This may be RPG items like
|
Items are all things that can "drop" for your game. This may be RPG items like
|
||||||
@@ -126,9 +121,6 @@ their world. Progression items will be assigned to locations with higher
|
|||||||
priority and moved around to meet defined rules and accomplish progression
|
priority and moved around to meet defined rules and accomplish progression
|
||||||
balancing.
|
balancing.
|
||||||
|
|
||||||
The name needs to be unique in each game, meaning a duplicate item has the
|
|
||||||
same ID. Name must not be numeric (has to contain at least 1 letter or symbol).
|
|
||||||
|
|
||||||
Special items with ID `None` can mark events (read below).
|
Special items with ID `None` can mark events (read below).
|
||||||
|
|
||||||
Other classifications include
|
Other classifications include
|
||||||
@@ -196,17 +188,15 @@ the `/worlds` directory. The starting point for the package is `__init.py__`.
|
|||||||
Conventionally, your world class is placed in that file.
|
Conventionally, your world class is placed in that file.
|
||||||
|
|
||||||
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
|
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
|
||||||
which can be imported as `worlds.AutoWorld.World` from your package.
|
which can be imported as `..AutoWorld.World` from your package.
|
||||||
|
|
||||||
AP will pick up your world automatically due to the `AutoWorld` implementation.
|
AP will pick up your world automatically due to the `AutoWorld` implementation.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
If your world needs specific python packages, they can be listed in
|
If your world needs specific python packages, they can be listed in
|
||||||
`world/[world_name]/requirements.txt`. ModuleUpdate.py will automatically
|
`world/[world_name]/requirements.txt`.
|
||||||
pick up and install them.
|
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format)
|
||||||
|
|
||||||
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format).
|
|
||||||
|
|
||||||
### Relative Imports
|
### Relative Imports
|
||||||
|
|
||||||
@@ -219,10 +209,6 @@ e.g. `from .Options import mygame_options` from your `__init__.py` will load
|
|||||||
When imported names pile up it may be easier to use `from . import Options`
|
When imported names pile up it may be easier to use `from . import Options`
|
||||||
and access the variable as `Options.mygame_options`.
|
and access the variable as `Options.mygame_options`.
|
||||||
|
|
||||||
Imports from directories outside your world should use absolute imports.
|
|
||||||
Correct use of relative / absolute imports is required for zipped worlds to
|
|
||||||
function, see [apworld specification.md](apworld%20specification.md).
|
|
||||||
|
|
||||||
### Your Item Type
|
### Your Item Type
|
||||||
|
|
||||||
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
|
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
|
||||||
@@ -288,12 +274,14 @@ Define a property `option_<name> = <number>` per selectable value and
|
|||||||
`default = <number>` to set the default selection. Aliases can be set by
|
`default = <number>` to set the default selection. Aliases can be set by
|
||||||
defining a property `alias_<name> = <same number>`.
|
defining a property `alias_<name> = <same number>`.
|
||||||
|
|
||||||
|
One special case where aliases are required is when option name is `yes`, `no`,
|
||||||
|
`on` or `off` because they parse to `True` or `False`:
|
||||||
```python
|
```python
|
||||||
option_off = 0
|
option_off = 0
|
||||||
option_on = 1
|
option_on = 1
|
||||||
option_some = 2
|
option_some = 2
|
||||||
alias_disabled = 0
|
alias_false = 0
|
||||||
alias_enabled = 1
|
alias_true = 1
|
||||||
default = 0
|
default = 0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -335,7 +323,7 @@ mygame_options: typing.Dict[str, type(Option)] = {
|
|||||||
```python
|
```python
|
||||||
# __init__.py
|
# __init__.py
|
||||||
|
|
||||||
from worlds.AutoWorld import World
|
from ..AutoWorld import World
|
||||||
from .Options import mygame_options # import the options dict
|
from .Options import mygame_options # import the options dict
|
||||||
|
|
||||||
class MyGameWorld(World):
|
class MyGameWorld(World):
|
||||||
@@ -364,7 +352,7 @@ more natural. These games typically have been edited to 'bake in' the items.
|
|||||||
from .Options import mygame_options # the options we defined earlier
|
from .Options import mygame_options # the options we defined earlier
|
||||||
from .Items import mygame_items # data used below to add items to the World
|
from .Items import mygame_items # data used below to add items to the World
|
||||||
from .Locations import mygame_locations # same as above
|
from .Locations import mygame_locations # same as above
|
||||||
from worlds.AutoWorld import World
|
from ..AutoWorld import World
|
||||||
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
|
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
|
||||||
from Utils import get_options, output_path
|
from Utils import get_options, output_path
|
||||||
|
|
||||||
@@ -459,7 +447,7 @@ In addition, the following methods can be implemented and attributes can be set
|
|||||||
```python
|
```python
|
||||||
def generate_early(self) -> None:
|
def generate_early(self) -> None:
|
||||||
# read player settings to world instance
|
# read player settings to world instance
|
||||||
self.final_boss_hp = self.multiworld.final_boss_hp[self.player].value
|
self.final_boss_hp = self.world.final_boss_hp[self.player].value
|
||||||
```
|
```
|
||||||
|
|
||||||
#### create_item
|
#### create_item
|
||||||
@@ -494,19 +482,19 @@ def create_items(self) -> None:
|
|||||||
# If an item can't have duplicates it has to be excluded manually.
|
# If an item can't have duplicates it has to be excluded manually.
|
||||||
|
|
||||||
# List of items to exclude, as a copy since it will be destroyed below
|
# List of items to exclude, as a copy since it will be destroyed below
|
||||||
exclude = [item for item in self.multiworld.precollected_items[self.player]]
|
exclude = [item for item in self.world.precollected_items[self.player]]
|
||||||
|
|
||||||
for item in map(self.create_item, mygame_items):
|
for item in map(self.create_item, mygame_items):
|
||||||
if item in exclude:
|
if item in exclude:
|
||||||
exclude.remove(item) # this is destructive. create unique list above
|
exclude.remove(item) # this is destructive. create unique list above
|
||||||
self.multiworld.itempool.append(self.create_item("nothing"))
|
self.world.itempool.append(self.create_item("nothing"))
|
||||||
else:
|
else:
|
||||||
self.multiworld.itempool.append(item)
|
self.world.itempool.append(item)
|
||||||
|
|
||||||
# itempool and number of locations should match up.
|
# itempool and number of locations should match up.
|
||||||
# If this is not the case we want to fill the itempool with junk.
|
# If this is not the case we want to fill the itempool with junk.
|
||||||
junk = 0 # calculate this based on player settings
|
junk = 0 # calculate this based on player settings
|
||||||
self.multiworld.itempool += [self.create_item("nothing") for _ in range(junk)]
|
self.world.itempool += [self.create_item("nothing") for _ in range(junk)]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### create_regions
|
#### create_regions
|
||||||
@@ -515,30 +503,30 @@ def create_items(self) -> None:
|
|||||||
def create_regions(self) -> None:
|
def create_regions(self) -> None:
|
||||||
# Add regions to the multiworld. "Menu" is the required starting point.
|
# Add regions to the multiworld. "Menu" is the required starting point.
|
||||||
# Arguments to Region() are name, type, human_readable_name, player, world
|
# Arguments to Region() are name, type, human_readable_name, player, world
|
||||||
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.multiworld)
|
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.world)
|
||||||
# Set Region.exits to a list of entrances that are reachable from region
|
# Set Region.exits to a list of entrances that are reachable from region
|
||||||
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
|
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
|
||||||
# Append region to MultiWorld's regions
|
# Append region to MultiWorld's regions
|
||||||
self.multiworld.regions.append(r) # or use += [r...]
|
self.world.regions.append(r) # or use += [r...]
|
||||||
|
|
||||||
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.multiworld)
|
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.world)
|
||||||
# Add main area's locations to main area (all but final boss)
|
# Add main area's locations to main area (all but final boss)
|
||||||
r.locations = [MyGameLocation(self.player, location.name,
|
r.locations = [MyGameLocation(self.player, location.name,
|
||||||
self.location_name_to_id[location.name], r)]
|
self.location_name_to_id[location.name], r)]
|
||||||
r.exits = [Entrance(self.player, "Boss Door", r)]
|
r.exits = [Entrance(self.player, "Boss Door", r)]
|
||||||
self.multiworld.regions.append(r)
|
self.world.regions.append(r)
|
||||||
|
|
||||||
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.multiworld)
|
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.world)
|
||||||
# add event to Boss Room
|
# add event to Boss Room
|
||||||
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
|
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
|
||||||
self.multiworld.regions.append(r)
|
self.world.regions.append(r)
|
||||||
|
|
||||||
# If entrances are not randomized, they should be connected here, otherwise
|
# If entrances are not randomized, they should be connected here, otherwise
|
||||||
# they can also be connected at a later stage.
|
# they can also be connected at a later stage.
|
||||||
self.multiworld.get_entrance("New Game", self.player)
|
self.world.get_entrance("New Game", self.player)\
|
||||||
.connect(self.multiworld.get_region("Main Area", self.player))
|
.connect(self.world.get_region("Main Area", self.player))
|
||||||
self.multiworld.get_entrance("Boss Door", self.player)
|
self.world.get_entrance("Boss Door", self.player)\
|
||||||
.connect(self.multiworld.get_region("Boss Room", self.player))
|
.connect(self.world.get_region("Boss Room", self.player))
|
||||||
|
|
||||||
# If setting location access rules from data is easier here, set_rules can
|
# If setting location access rules from data is easier here, set_rules can
|
||||||
# possibly omitted.
|
# possibly omitted.
|
||||||
@@ -549,14 +537,14 @@ def create_regions(self) -> None:
|
|||||||
```python
|
```python
|
||||||
def generate_basic(self) -> None:
|
def generate_basic(self) -> None:
|
||||||
# place "Victory" at "Final Boss" and set collection as win condition
|
# place "Victory" at "Final Boss" and set collection as win condition
|
||||||
self.multiworld.get_location("Final Boss", self.player)
|
self.world.get_location("Final Boss", self.player)\
|
||||||
.place_locked_item(self.create_event("Victory"))
|
.place_locked_item(self.create_event("Victory"))
|
||||||
self.multiworld.completion_condition[self.player] =
|
self.world.completion_condition[self.player] = \
|
||||||
lambda state: state.has("Victory", self.player)
|
lambda state: state.has("Victory", self.player)
|
||||||
|
|
||||||
# place item Herb into location Chest1 for some reason
|
# place item Herb into location Chest1 for some reason
|
||||||
item = self.create_item("Herb")
|
item = self.create_item("Herb")
|
||||||
self.multiworld.get_location("Chest1", self.player).place_locked_item(item)
|
self.world.get_location("Chest1", self.player).place_locked_item(item)
|
||||||
# in most cases it's better to do this at the same time the itempool is
|
# in most cases it's better to do this at the same time the itempool is
|
||||||
# filled to avoid accidental duplicates:
|
# filled to avoid accidental duplicates:
|
||||||
# manually placed and still in the itempool
|
# manually placed and still in the itempool
|
||||||
@@ -565,45 +553,44 @@ def generate_basic(self) -> None:
|
|||||||
### Setting Rules
|
### Setting Rules
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from worlds.generic.Rules import add_rule, set_rule, forbid_item
|
from ..generic.Rules import add_rule, set_rule, forbid_item
|
||||||
from Items import get_item_type
|
from Items import get_item_type
|
||||||
|
|
||||||
|
|
||||||
def set_rules(self) -> None:
|
def set_rules(self) -> None:
|
||||||
# For some worlds this step can be omitted if either a Logic mixin
|
# For some worlds this step can be omitted if either a Logic mixin
|
||||||
# (see below) is used, it's easier to apply the rules from data during
|
# (see below) is used, it's easier to apply the rules from data during
|
||||||
# location generation or everything is in generate_basic
|
# location generation or everything is in generate_basic
|
||||||
|
|
||||||
# set a simple rule for an region
|
# set a simple rule for an region
|
||||||
set_rule(self.multiworld.get_entrance("Boss Door", self.player),
|
set_rule(self.world.get_entrance("Boss Door", self.player),
|
||||||
lambda state: state.has("Boss Key", self.player))
|
lambda state: state.has("Boss Key", self.player))
|
||||||
# combine rules to require two items
|
# combine rules to require two items
|
||||||
add_rule(self.multiworld.get_location("Chest2", self.player),
|
add_rule(self.world.get_location("Chest2", self.player),
|
||||||
lambda state: state.has("Sword", self.player))
|
lambda state: state.has("Sword", self.player))
|
||||||
add_rule(self.multiworld.get_location("Chest2", self.player),
|
add_rule(self.world.get_location("Chest2", self.player),
|
||||||
lambda state: state.has("Shield", self.player))
|
lambda state: state.has("Shield", self.player))
|
||||||
# or simply combine yourself
|
# or simply combine yourself
|
||||||
set_rule(self.multiworld.get_location("Chest2", self.player),
|
set_rule(self.world.get_location("Chest2", self.player),
|
||||||
lambda state: state.has("Sword", self.player) and
|
lambda state: state.has("Sword", self.player) and
|
||||||
state.has("Shield", self.player))
|
state.has("Shield", self.player))
|
||||||
# require two of an item
|
# require two of an item
|
||||||
set_rule(self.multiworld.get_location("Chest3", self.player),
|
set_rule(self.world.get_location("Chest3", self.player),
|
||||||
lambda state: state.has("Key", self.player, 2))
|
lambda state: state.has("Key", self.player, 2))
|
||||||
# require one item from an item group
|
# require one item from an item group
|
||||||
add_rule(self.multiworld.get_location("Chest3", self.player),
|
add_rule(self.world.get_location("Chest3", self.player),
|
||||||
lambda state: state.has_group("weapons", self.player))
|
lambda state: state.has_group("weapons", self.player))
|
||||||
# state also has .item_count() for items, .has_any() and.has_all() for sets
|
# state also has .item_count() for items, .has_any() and.has_all() for sets
|
||||||
# and .count_group() for groups
|
# and .count_group() for groups
|
||||||
# set_rule is likely to be a bit faster than add_rule
|
# set_rule is likely to be a bit faster than add_rule
|
||||||
|
|
||||||
# disallow placing a specific local item at a specific location
|
# disallow placing a specific local item at a specific location
|
||||||
forbid_item(self.multiworld.get_location("Chest4", self.player), "Sword")
|
forbid_item(self.world.get_location("Chest4", self.player), "Sword")
|
||||||
# disallow placing items with a specific property
|
# disallow placing items with a specific property
|
||||||
add_item_rule(self.multiworld.get_location("Chest5", self.player),
|
add_item_rule(self.world.get_location("Chest5", self.player),
|
||||||
lambda item: get_item_type(item) == "weapon")
|
lambda item: get_item_type(item) == "weapon")
|
||||||
# get_item_type needs to take player/world into account
|
# get_item_type needs to take player/world into account
|
||||||
# if MyGameItem has a type property, a more direct implementation would be
|
# if MyGameItem has a type property, a more direct implementation would be
|
||||||
add_item_rule(self.multiworld.get_location("Chest5", self.player),
|
add_item_rule(self.world.get_location("Chest5", self.player),
|
||||||
lambda item: item.player != self.player or\
|
lambda item: item.player != self.player or\
|
||||||
item.my_type == "weapon")
|
item.my_type == "weapon")
|
||||||
# location.item_rule = ... is likely to be a bit faster
|
# location.item_rule = ... is likely to be a bit faster
|
||||||
@@ -616,16 +603,14 @@ implement more complex logic in logic mixins, even if there is no need to add
|
|||||||
properties to the `BaseClasses.CollectionState` state object.
|
properties to the `BaseClasses.CollectionState` state object.
|
||||||
|
|
||||||
When importing a file that defines a class that inherits from
|
When importing a file that defines a class that inherits from
|
||||||
`worlds.AutoWorld.LogicMixin` the state object's class is automatically extended by
|
`..AutoWorld.LogicMixin` the state object's class is automatically extended by
|
||||||
the mixin's members. These members should be prefixed with underscore following
|
the mixin's members. These members should be prefixed with underscore following
|
||||||
the name of the implementing world. This is due to sharing a namespace with all
|
the name of the implementing world. This is due to sharing a namespace with all
|
||||||
other logic mixins.
|
other logic mixins.
|
||||||
|
|
||||||
Typical uses are defining methods that are used instead of `state.has`
|
Typical uses are defining methods that are used instead of `state.has`
|
||||||
in lambdas, e.g.`state.mygame_has(custom, player)` or recurring checks
|
in lambdas, e.g.`state._mygame_has(custom, world, player)` or recurring checks
|
||||||
like `state.mygame_can_do_something(player)` to simplify lambdas.
|
like `state._mygame_can_do_something(world, player)` to simplify lambdas.
|
||||||
Private members, only accessible from mixins, should start with `_mygame_`,
|
|
||||||
public members with `mygame_`.
|
|
||||||
|
|
||||||
More advanced uses could be to add additional variables to the state object,
|
More advanced uses could be to add additional variables to the state object,
|
||||||
override `World.collect(self, state, item)` and `remove(self, state, item)`
|
override `World.collect(self, state, item)` and `remove(self, state, item)`
|
||||||
@@ -637,26 +622,25 @@ Please do this with caution and only when neccessary.
|
|||||||
```python
|
```python
|
||||||
# Logic.py
|
# Logic.py
|
||||||
|
|
||||||
from worlds.AutoWorld import LogicMixin
|
from ..AutoWorld import LogicMixin
|
||||||
|
|
||||||
class MyGameLogic(LogicMixin):
|
class MyGameLogic(LogicMixin):
|
||||||
def mygame_has_key(self, player: int):
|
def _mygame_has_key(self, world: MultiWorld, player: int):
|
||||||
# Arguments above are free to choose
|
# Arguments above are free to choose
|
||||||
# MultiWorld can be accessed through self.world, explicitly passing in
|
# it may make sense to use World as argument instead of MultiWorld
|
||||||
# MyGameWorld instance for easy options access is also a valid approach
|
|
||||||
return self.has("key", player) # or whatever
|
return self.has("key", player) # or whatever
|
||||||
```
|
```
|
||||||
```python
|
```python
|
||||||
# __init__.py
|
# __init__.py
|
||||||
|
|
||||||
from worlds.generic.Rules import set_rule
|
from ..generic.Rules import set_rule
|
||||||
import .Logic # apply the mixin by importing its file
|
import .Logic # apply the mixin by importing its file
|
||||||
|
|
||||||
class MyGameWorld(World):
|
class MyGameWorld(World):
|
||||||
# ...
|
# ...
|
||||||
def set_rules(self):
|
def set_rules(self):
|
||||||
set_rule(self.world.get_location("A Door", self.player),
|
set_rule(self.world.get_location("A Door", self.player),
|
||||||
lamda state: state.mygame_has_key(self.player))
|
lamda state: state._mygame_has_key(self.world, self.player))
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generate Output
|
### Generate Output
|
||||||
@@ -664,33 +648,32 @@ class MyGameWorld(World):
|
|||||||
```python
|
```python
|
||||||
from .Mod import generate_mod
|
from .Mod import generate_mod
|
||||||
|
|
||||||
|
|
||||||
def generate_output(self, output_directory: str):
|
def generate_output(self, output_directory: str):
|
||||||
# How to generate the mod or ROM highly depends on the game
|
# How to generate the mod or ROM highly depends on the game
|
||||||
# if the mod is written in Lua, Jinja can be used to fill a template
|
# if the mod is written in Lua, Jinja can be used to fill a template
|
||||||
# if the mod reads a json file, `json.dump()` can be used to generate that
|
# if the mod reads a json file, `json.dump()` can be used to generate that
|
||||||
# code below is a dummy
|
# code below is a dummy
|
||||||
data = {
|
data = {
|
||||||
"seed": self.multiworld.seed_name, # to verify the server's multiworld
|
"seed": self.world.seed_name, # to verify the server's multiworld
|
||||||
"slot": self.multiworld.player_name[self.player], # to connect to server
|
"slot": self.world.player_name[self.player], # to connect to server
|
||||||
"items": {location.name: location.item.name
|
"items": {location.name: location.item.name
|
||||||
if location.item.player == self.player else "Remote"
|
if location.item.player == self.player else "Remote"
|
||||||
for location in self.multiworld.get_filled_locations(self.player)},
|
for location in self.world.get_filled_locations(self.player)},
|
||||||
# store start_inventory from player's .yaml
|
# store start_inventory from player's .yaml
|
||||||
"starter_items": [item.name for item
|
"starter_items": [item.name for item
|
||||||
in self.multiworld.precollected_items[self.player]],
|
in self.world.precollected_items[self.player]],
|
||||||
"final_boss_hp": self.final_boss_hp,
|
"final_boss_hp": self.final_boss_hp,
|
||||||
# store option name "easy", "normal" or "hard" for difficuly
|
# store option name "easy", "normal" or "hard" for difficuly
|
||||||
"difficulty": self.multiworld.difficulty[self.player].current_key,
|
"difficulty": self.world.difficulty[self.player].current_key,
|
||||||
# store option value True or False for fixing a glitch
|
# store option value True or False for fixing a glitch
|
||||||
"fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value
|
"fix_xyz_glitch": self.world.fix_xyz_glitch[self.player].value
|
||||||
}
|
}
|
||||||
# point to a ROM specified by the installation
|
# point to a ROM specified by the installation
|
||||||
src = Utils.get_options()["mygame_options"]["rom_file"]
|
src = Utils.get_options()["mygame_options"]["rom_file"]
|
||||||
# or point to worlds/mygame/data/mod_template
|
# or point to worlds/mygame/data/mod_template
|
||||||
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
|
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
|
||||||
# generate output path
|
# generate output path
|
||||||
mod_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.multiworld.player_name[self.player]}"
|
mod_name = f"AP-{self.world.seed_name}-P{self.player}-{self.world.player_name[self.player]}"
|
||||||
out_file = os.path.join(output_directory, mod_name + ".zip")
|
out_file = os.path.join(output_directory, mod_name + ".zip")
|
||||||
# generate the file
|
# generate the file
|
||||||
generate_mod(src, out_file, data)
|
generate_mod(src, out_file, data)
|
||||||
|
|||||||
56
host.yaml
56
host.yaml
@@ -82,27 +82,28 @@ generator:
|
|||||||
# List of options that can be plando'd. Can be combined, for example "bosses, items"
|
# List of options that can be plando'd. Can be combined, for example "bosses, items"
|
||||||
# Available options: bosses, items, texts, connections
|
# Available options: bosses, items, texts, connections
|
||||||
plando_options: "bosses"
|
plando_options: "bosses"
|
||||||
sni_options:
|
|
||||||
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
|
||||||
sni_path: "SNI"
|
|
||||||
# Set this to false to never autostart a rom (such as after patching)
|
|
||||||
# True for operating system default program
|
|
||||||
# Alternatively, a path to a program to open the .sfc file with
|
|
||||||
snes_rom_start: true
|
|
||||||
lttp_options:
|
lttp_options:
|
||||||
# File name of the v1.0 J rom
|
# File name of the v1.0 J rom
|
||||||
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||||
|
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||||
|
sni: "SNI"
|
||||||
|
# Set this to false to never autostart a rom (such as after patching)
|
||||||
|
# True for operating system default program
|
||||||
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
|
rom_start: true
|
||||||
sm_options:
|
sm_options:
|
||||||
# File name of the v1.0 J rom
|
# File name of the v1.0 J rom
|
||||||
rom_file: "Super Metroid (JU).sfc"
|
rom_file: "Super Metroid (JU).sfc"
|
||||||
|
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||||
|
sni: "SNI"
|
||||||
|
# Set this to false to never autostart a rom (such as after patching)
|
||||||
|
# True for operating system default program
|
||||||
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
|
rom_start: true
|
||||||
factorio_options:
|
factorio_options:
|
||||||
executable: "factorio/bin/x64/factorio"
|
executable: "factorio/bin/x64/factorio"
|
||||||
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
|
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
|
||||||
# server_settings: "factorio\\data\\server-settings.json"
|
# server_settings: "factorio\\data\\server-settings.json"
|
||||||
# Whether to filter item send messages displayed in-game to only those that involve you.
|
|
||||||
filter_item_sends: false
|
|
||||||
# Whether to send chat messages from players on the Factorio server to Archipelago.
|
|
||||||
bridge_chat_out: true
|
|
||||||
minecraft_options:
|
minecraft_options:
|
||||||
forge_directory: "Minecraft Forge server"
|
forge_directory: "Minecraft Forge server"
|
||||||
max_heap_size: "2G"
|
max_heap_size: "2G"
|
||||||
@@ -121,26 +122,19 @@ soe_options:
|
|||||||
rom_file: "Secret of Evermore (USA).sfc"
|
rom_file: "Secret of Evermore (USA).sfc"
|
||||||
ffr_options:
|
ffr_options:
|
||||||
display_msgs: true
|
display_msgs: true
|
||||||
dkc3_options:
|
smz3_options:
|
||||||
# File name of the DKC3 US rom
|
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||||
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
sni: "SNI"
|
||||||
smw_options:
|
|
||||||
# File name of the SMW US rom
|
|
||||||
rom_file: "Super Mario World (USA).sfc"
|
|
||||||
pokemon_rb_options:
|
|
||||||
# File names of the Pokemon Red and Blue roms
|
|
||||||
red_rom_file: "Pokemon Red (UE) [S][!].gb"
|
|
||||||
blue_rom_file: "Pokemon Blue (UE) [S][!].gb"
|
|
||||||
# Set this to false to never autostart a rom (such as after patching)
|
|
||||||
# True for operating system default program
|
|
||||||
# Alternatively, a path to a program to open the .gb file with
|
|
||||||
rom_start: true
|
|
||||||
zillion_options:
|
|
||||||
# File name of the Zillion US rom
|
|
||||||
rom_file: "Zillion (UE) [!].sms"
|
|
||||||
# Set this to false to never autostart a rom (such as after patching)
|
# Set this to false to never autostart a rom (such as after patching)
|
||||||
# True for operating system default program
|
# True for operating system default program
|
||||||
# Alternatively, a path to a program to open the .sfc file with
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
# RetroArch doesn't make it easy to launch a game from the command line.
|
rom_start: true
|
||||||
# You have to know the path to the emulator core library on the user's computer.
|
dkc3_options:
|
||||||
rom_start: "retroarch"
|
# File name of the DKC3 US rom
|
||||||
|
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
||||||
|
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||||
|
sni: "SNI"
|
||||||
|
# Set this to false to never autostart a rom (such as after patching)
|
||||||
|
# True for operating system default program
|
||||||
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
|
rom_start: true
|
||||||
|
|||||||
206
inno_setup.iss
206
inno_setup.iss
@@ -55,30 +55,21 @@ Name: "core"; Description: "Core Files"; Types: full hosting playing
|
|||||||
Name: "generator"; Description: "Generator"; Types: full hosting
|
Name: "generator"; Description: "Generator"; Types: full hosting
|
||||||
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
|
||||||
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
||||||
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
||||||
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
|
|
||||||
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
|
|
||||||
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
|
|
||||||
Name: "server"; Description: "Server"; Types: full hosting
|
Name: "server"; Description: "Server"; Types: full hosting
|
||||||
Name: "client"; Description: "Clients"; Types: full playing
|
Name: "client"; Description: "Clients"; Types: full playing
|
||||||
Name: "client/sni"; Description: "SNI Client"; Types: full playing
|
Name: "client/sni"; Description: "SNI Client"; Types: full playing
|
||||||
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
|
||||||
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
||||||
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||||
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
||||||
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
|
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
|
||||||
Name: "client/pkmn"; Description: "Pokemon Client"
|
|
||||||
Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
|
|
||||||
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
|
|
||||||
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
|
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
|
||||||
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
|
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
|
||||||
Name: "client/zl"; Description: "Zillion"; Types: full playing
|
|
||||||
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
|
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
|
||||||
|
|
||||||
[Dirs]
|
[Dirs]
|
||||||
@@ -88,12 +79,8 @@ NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-mod
|
|||||||
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
|
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
|
||||||
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
|
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
|
||||||
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
|
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
|
||||||
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
|
|
||||||
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
||||||
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
||||||
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
|
|
||||||
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
|
|
||||||
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
|
|
||||||
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
|
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
|
||||||
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
|
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
|
||||||
@@ -107,9 +94,7 @@ Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: i
|
|||||||
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
|
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
|
||||||
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
||||||
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
||||||
Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl
|
|
||||||
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
|
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
|
||||||
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
|
|
||||||
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
|
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
|
||||||
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
|
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
|
||||||
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
|
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
|
||||||
@@ -122,9 +107,7 @@ Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.e
|
|||||||
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
|
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
|
||||||
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
|
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
|
||||||
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
|
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
|
||||||
Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl
|
|
||||||
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
|
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
|
||||||
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
|
|
||||||
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
|
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
|
||||||
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
|
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
|
||||||
|
|
||||||
@@ -134,9 +117,7 @@ Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNI
|
|||||||
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
|
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
|
||||||
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
|
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
|
||||||
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
|
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
|
||||||
Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl
|
|
||||||
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
|
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
|
||||||
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
|
|
||||||
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
|
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
|
||||||
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
|
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
|
||||||
|
|
||||||
@@ -170,16 +151,6 @@ Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Arc
|
|||||||
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl
|
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
@@ -200,16 +171,6 @@ Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archip
|
|||||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
|
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
|
||||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
|
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
|
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
|
||||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
|
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
|
||||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
|
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
|
||||||
@@ -235,7 +196,7 @@ begin
|
|||||||
begin
|
begin
|
||||||
// Is the installed version at least the packaged one ?
|
// Is the installed version at least the packaged one ?
|
||||||
Log('VC Redist x64 Version : found ' + strVersion);
|
Log('VC Redist x64 Version : found ' + strVersion);
|
||||||
Result := (CompareStr(strVersion, 'v14.32.31332') < 0);
|
Result := (CompareStr(strVersion, 'v14.29.30037') < 0);
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
begin
|
begin
|
||||||
@@ -256,24 +217,12 @@ var SMRomFilePage: TInputFileWizardPage;
|
|||||||
var dkc3rom: string;
|
var dkc3rom: string;
|
||||||
var DKC3RomFilePage: TInputFileWizardPage;
|
var DKC3RomFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
var smwrom: string;
|
|
||||||
var SMWRomFilePage: TInputFileWizardPage;
|
|
||||||
|
|
||||||
var soerom: string;
|
var soerom: string;
|
||||||
var SoERomFilePage: TInputFileWizardPage;
|
var SoERomFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
var ootrom: string;
|
var ootrom: string;
|
||||||
var OoTROMFilePage: TInputFileWizardPage;
|
var OoTROMFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
var zlrom: string;
|
|
||||||
var ZlROMFilePage: TInputFileWizardPage;
|
|
||||||
|
|
||||||
var redrom: string;
|
|
||||||
var RedROMFilePage: TInputFileWizardPage;
|
|
||||||
|
|
||||||
var bluerom: string;
|
|
||||||
var BlueROMFilePage: TInputFileWizardPage;
|
|
||||||
|
|
||||||
function GetSNESMD5OfFile(const rom: string): string;
|
function GetSNESMD5OfFile(const rom: string): string;
|
||||||
var data: AnsiString;
|
var data: AnsiString;
|
||||||
begin
|
begin
|
||||||
@@ -287,15 +236,6 @@ begin
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function GetSMSMD5OfFile(const rom: string): string;
|
|
||||||
var data: AnsiString;
|
|
||||||
begin
|
|
||||||
if LoadStringFromFile(rom, data) then
|
|
||||||
begin
|
|
||||||
Result := GetMD5OfString(data);
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
|
|
||||||
function CheckRom(name: string; hash: string): string;
|
function CheckRom(name: string; hash: string): string;
|
||||||
var rom: string;
|
var rom: string;
|
||||||
begin
|
begin
|
||||||
@@ -315,25 +255,6 @@ begin
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function CheckSMSRom(name: string; hash: string): string;
|
|
||||||
var rom: string;
|
|
||||||
begin
|
|
||||||
log('Handling ' + name)
|
|
||||||
rom := FileSearch(name, WizardDirValue());
|
|
||||||
if Length(rom) > 0 then
|
|
||||||
begin
|
|
||||||
log('existing ROM found');
|
|
||||||
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
|
|
||||||
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
|
|
||||||
begin
|
|
||||||
log('existing ROM verified');
|
|
||||||
Result := rom;
|
|
||||||
exit;
|
|
||||||
end;
|
|
||||||
log('existing ROM failed verification');
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
|
|
||||||
function AddRomPage(name: string): TInputFileWizardPage;
|
function AddRomPage(name: string): TInputFileWizardPage;
|
||||||
begin
|
begin
|
||||||
Result :=
|
Result :=
|
||||||
@@ -349,37 +270,6 @@ begin
|
|||||||
'.sfc');
|
'.sfc');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|
||||||
function AddGBRomPage(name: string): TInputFileWizardPage;
|
|
||||||
begin
|
|
||||||
Result :=
|
|
||||||
CreateInputFilePage(
|
|
||||||
wpSelectComponents,
|
|
||||||
'Select ROM File',
|
|
||||||
'Where is your ' + name + ' located?',
|
|
||||||
'Select the file, then click Next.');
|
|
||||||
|
|
||||||
Result.Add(
|
|
||||||
'Location of ROM file:',
|
|
||||||
'GB ROM files|*.gb;*.gbc|All files|*.*',
|
|
||||||
'.gb');
|
|
||||||
end;
|
|
||||||
|
|
||||||
function AddSMSRomPage(name: string): TInputFileWizardPage;
|
|
||||||
begin
|
|
||||||
Result :=
|
|
||||||
CreateInputFilePage(
|
|
||||||
wpSelectComponents,
|
|
||||||
'Select ROM File',
|
|
||||||
'Where is your ' + name + ' located?',
|
|
||||||
'Select the file, then click Next.');
|
|
||||||
|
|
||||||
Result.Add(
|
|
||||||
'Location of ROM file:',
|
|
||||||
'SMS ROM files|*.sms|All files|*.*',
|
|
||||||
'.sms');
|
|
||||||
end;
|
|
||||||
|
|
||||||
procedure AddOoTRomPage();
|
procedure AddOoTRomPage();
|
||||||
begin
|
begin
|
||||||
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
|
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
|
||||||
@@ -418,14 +308,10 @@ begin
|
|||||||
Result := not (SMROMFilePage.Values[0] = '')
|
Result := not (SMROMFilePage.Values[0] = '')
|
||||||
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
|
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
|
||||||
Result := not (DKC3ROMFilePage.Values[0] = '')
|
Result := not (DKC3ROMFilePage.Values[0] = '')
|
||||||
else if (assigned(SMWROMFilePage)) and (CurPageID = SMWROMFilePage.ID) then
|
|
||||||
Result := not (SMWROMFilePage.Values[0] = '')
|
|
||||||
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
||||||
Result := not (SoEROMFilePage.Values[0] = '')
|
Result := not (SoEROMFilePage.Values[0] = '')
|
||||||
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
||||||
Result := not (OoTROMFilePage.Values[0] = '')
|
Result := not (OoTROMFilePage.Values[0] = '')
|
||||||
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
|
|
||||||
Result := not (ZlROMFilePage.Values[0] = '')
|
|
||||||
else
|
else
|
||||||
Result := True;
|
Result := True;
|
||||||
end;
|
end;
|
||||||
@@ -478,22 +364,6 @@ begin
|
|||||||
Result := '';
|
Result := '';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function GetSMWROMPath(Param: string): string;
|
|
||||||
begin
|
|
||||||
if Length(smwrom) > 0 then
|
|
||||||
Result := smwrom
|
|
||||||
else if Assigned(SMWRomFilePage) then
|
|
||||||
begin
|
|
||||||
R := CompareStr(GetSNESMD5OfFile(SMWROMFilePage.Values[0]), 'cdd3c8c37322978ca8669b34bc89c804')
|
|
||||||
if R <> 0 then
|
|
||||||
MsgBox('Super Mario World ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
|
||||||
|
|
||||||
Result := SMWROMFilePage.Values[0]
|
|
||||||
end
|
|
||||||
else
|
|
||||||
Result := '';
|
|
||||||
end;
|
|
||||||
|
|
||||||
function GetSoEROMPath(Param: string): string;
|
function GetSoEROMPath(Param: string): string;
|
||||||
begin
|
begin
|
||||||
if Length(soerom) > 0 then
|
if Length(soerom) > 0 then
|
||||||
@@ -526,54 +396,6 @@ begin
|
|||||||
Result := '';
|
Result := '';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function GetZlROMPath(Param: string): string;
|
|
||||||
begin
|
|
||||||
if Length(zlrom) > 0 then
|
|
||||||
Result := zlrom
|
|
||||||
else if Assigned(ZlROMFilePage) then
|
|
||||||
begin
|
|
||||||
R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270');
|
|
||||||
if R <> 0 then
|
|
||||||
MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
|
||||||
|
|
||||||
Result := ZlROMFilePage.Values[0]
|
|
||||||
end
|
|
||||||
else
|
|
||||||
Result := '';
|
|
||||||
end;
|
|
||||||
|
|
||||||
function GetRedROMPath(Param: string): string;
|
|
||||||
begin
|
|
||||||
if Length(redrom) > 0 then
|
|
||||||
Result := redrom
|
|
||||||
else if Assigned(RedRomFilePage) then
|
|
||||||
begin
|
|
||||||
R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc')
|
|
||||||
if R <> 0 then
|
|
||||||
MsgBox('Pokemon Red ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
|
||||||
|
|
||||||
Result := RedROMFilePage.Values[0]
|
|
||||||
end
|
|
||||||
else
|
|
||||||
Result := '';
|
|
||||||
end;
|
|
||||||
|
|
||||||
function GetBlueROMPath(Param: string): string;
|
|
||||||
begin
|
|
||||||
if Length(bluerom) > 0 then
|
|
||||||
Result := bluerom
|
|
||||||
else if Assigned(BlueRomFilePage) then
|
|
||||||
begin
|
|
||||||
R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b')
|
|
||||||
if R <> 0 then
|
|
||||||
MsgBox('Pokemon Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
|
||||||
|
|
||||||
Result := BlueROMFilePage.Values[0]
|
|
||||||
end
|
|
||||||
else
|
|
||||||
Result := '';
|
|
||||||
end;
|
|
||||||
|
|
||||||
procedure InitializeWizard();
|
procedure InitializeWizard();
|
||||||
begin
|
begin
|
||||||
AddOoTRomPage();
|
AddOoTRomPage();
|
||||||
@@ -590,25 +412,9 @@ begin
|
|||||||
if Length(dkc3rom) = 0 then
|
if Length(dkc3rom) = 0 then
|
||||||
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
|
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
|
||||||
|
|
||||||
smwrom := CheckRom('Super Mario World (USA).sfc', 'cdd3c8c37322978ca8669b34bc89c804');
|
|
||||||
if Length(smwrom) = 0 then
|
|
||||||
SMWRomFilePage:= AddRomPage('Super Mario World (USA).sfc');
|
|
||||||
|
|
||||||
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
|
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
|
||||||
if Length(soerom) = 0 then
|
if Length(soerom) = 0 then
|
||||||
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
|
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
|
||||||
|
|
||||||
zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270');
|
|
||||||
if Length(zlrom) = 0 then
|
|
||||||
ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms');
|
|
||||||
|
|
||||||
redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc');
|
|
||||||
if Length(redrom) = 0 then
|
|
||||||
RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb');
|
|
||||||
|
|
||||||
bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b');
|
|
||||||
if Length(bluerom) = 0 then
|
|
||||||
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
|
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|
||||||
@@ -621,16 +427,8 @@ begin
|
|||||||
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
|
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
|
||||||
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
|
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
|
||||||
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
|
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
|
||||||
if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then
|
|
||||||
Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw'));
|
|
||||||
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
||||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||||
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
|
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
|
||||||
if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then
|
end;
|
||||||
Result := not (WizardIsComponentSelected('generator/zl') or WizardIsComponentSelected('client/zl'));
|
|
||||||
if (assigned(RedROMFilePage)) and (PageID = RedROMFilePage.ID) then
|
|
||||||
Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red'));
|
|
||||||
if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then
|
|
||||||
Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue'));
|
|
||||||
end;
|
|
||||||
46
kvui.py
46
kvui.py
@@ -28,7 +28,6 @@ from kivy.factory import Factory
|
|||||||
from kivy.properties import BooleanProperty, ObjectProperty
|
from kivy.properties import BooleanProperty, ObjectProperty
|
||||||
from kivy.uix.button import Button
|
from kivy.uix.button import Button
|
||||||
from kivy.uix.gridlayout import GridLayout
|
from kivy.uix.gridlayout import GridLayout
|
||||||
from kivy.uix.layout import Layout
|
|
||||||
from kivy.uix.textinput import TextInput
|
from kivy.uix.textinput import TextInput
|
||||||
from kivy.uix.recycleview import RecycleView
|
from kivy.uix.recycleview import RecycleView
|
||||||
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
||||||
@@ -49,7 +48,6 @@ fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, dura
|
|||||||
|
|
||||||
|
|
||||||
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
||||||
from Utils import async_start
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import CommonClient
|
import CommonClient
|
||||||
@@ -301,9 +299,6 @@ class GameManager(App):
|
|||||||
base_title: str = "Archipelago Client"
|
base_title: str = "Archipelago Client"
|
||||||
last_autofillable_command: str
|
last_autofillable_command: str
|
||||||
|
|
||||||
main_area_container: GridLayout
|
|
||||||
""" subclasses can add more columns beside the tabs """
|
|
||||||
|
|
||||||
def __init__(self, ctx: context_type):
|
def __init__(self, ctx: context_type):
|
||||||
self.title = self.base_title
|
self.title = self.base_title
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
@@ -330,7 +325,7 @@ class GameManager(App):
|
|||||||
|
|
||||||
super(GameManager, self).__init__()
|
super(GameManager, self).__init__()
|
||||||
|
|
||||||
def build(self) -> Layout:
|
def build(self):
|
||||||
self.container = ContainerLayout()
|
self.container = ContainerLayout()
|
||||||
|
|
||||||
self.grid = MainLayout()
|
self.grid = MainLayout()
|
||||||
@@ -339,12 +334,9 @@ class GameManager(App):
|
|||||||
# top part
|
# top part
|
||||||
server_label = ServerLabel()
|
server_label = ServerLabel()
|
||||||
self.connect_layout.add_widget(server_label)
|
self.connect_layout.add_widget(server_label)
|
||||||
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", size_hint_y=None,
|
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.server_address or "archipelago.gg", size_hint_y=None,
|
||||||
height=30, multiline=False, write_tab=False)
|
height=30, multiline=False, write_tab=False)
|
||||||
def connect_bar_validate(sender):
|
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
|
||||||
if not self.ctx.server:
|
|
||||||
self.connect_button_action(sender)
|
|
||||||
self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
|
|
||||||
self.connect_layout.add_widget(self.server_connect_bar)
|
self.connect_layout.add_widget(self.server_connect_bar)
|
||||||
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
|
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
|
||||||
self.server_connect_button.bind(on_press=self.connect_button_action)
|
self.server_connect_button.bind(on_press=self.connect_button_action)
|
||||||
@@ -366,10 +358,7 @@ class GameManager(App):
|
|||||||
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
|
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
|
||||||
self.tabs.add_widget(panel)
|
self.tabs.add_widget(panel)
|
||||||
|
|
||||||
self.main_area_container = GridLayout(size_hint_y=1, rows=1)
|
self.grid.add_widget(self.tabs)
|
||||||
self.main_area_container.add_widget(self.tabs)
|
|
||||||
|
|
||||||
self.grid.add_widget(self.main_area_container)
|
|
||||||
|
|
||||||
if len(self.logging_pairs) == 1:
|
if len(self.logging_pairs) == 1:
|
||||||
# Hide Tab selection if only one tab
|
# Hide Tab selection if only one tab
|
||||||
@@ -385,19 +374,17 @@ class GameManager(App):
|
|||||||
bottom_layout.add_widget(info_button)
|
bottom_layout.add_widget(info_button)
|
||||||
self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
|
self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
|
||||||
self.textinput.bind(on_text_validate=self.on_message)
|
self.textinput.bind(on_text_validate=self.on_message)
|
||||||
self.textinput.text_validate_unfocus = False
|
|
||||||
|
def text_focus(event):
|
||||||
|
"""Needs to be set via delay, as unfocusing happens after on_message"""
|
||||||
|
self.textinput.focus = True
|
||||||
|
|
||||||
|
self.textinput.text_focus = text_focus
|
||||||
bottom_layout.add_widget(self.textinput)
|
bottom_layout.add_widget(self.textinput)
|
||||||
self.grid.add_widget(bottom_layout)
|
self.grid.add_widget(bottom_layout)
|
||||||
self.commandprocessor("/help")
|
self.commandprocessor("/help")
|
||||||
Clock.schedule_interval(self.update_texts, 1 / 30)
|
Clock.schedule_interval(self.update_texts, 1 / 30)
|
||||||
self.container.add_widget(self.grid)
|
self.container.add_widget(self.grid)
|
||||||
|
|
||||||
self.server_connect_bar.focus = True
|
|
||||||
self.server_connect_bar.select_text(
|
|
||||||
self.server_connect_bar.text.find(":") + 1,
|
|
||||||
len(self.server_connect_bar.text)
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.container
|
return self.container
|
||||||
|
|
||||||
def update_texts(self, dt):
|
def update_texts(self, dt):
|
||||||
@@ -408,12 +395,10 @@ class GameManager(App):
|
|||||||
f" | Connected to: {self.ctx.server_address} " \
|
f" | Connected to: {self.ctx.server_address} " \
|
||||||
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
|
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
|
||||||
self.server_connect_button.text = "Disconnect"
|
self.server_connect_button.text = "Disconnect"
|
||||||
self.server_connect_bar.readonly = True
|
|
||||||
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
|
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
|
||||||
self.progressbar.value = len(self.ctx.checked_locations)
|
self.progressbar.value = len(self.ctx.checked_locations)
|
||||||
else:
|
else:
|
||||||
self.server_connect_button.text = "Connect"
|
self.server_connect_button.text = "Connect"
|
||||||
self.server_connect_bar.readonly = False
|
|
||||||
self.title = self.base_title + " " + Utils.__version__
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
self.progressbar.value = 0
|
self.progressbar.value = 0
|
||||||
|
|
||||||
@@ -426,10 +411,11 @@ class GameManager(App):
|
|||||||
|
|
||||||
def connect_button_action(self, button):
|
def connect_button_action(self, button):
|
||||||
if self.ctx.server:
|
if self.ctx.server:
|
||||||
|
self.ctx.server_address = None
|
||||||
self.ctx.username = None
|
self.ctx.username = None
|
||||||
async_start(self.ctx.disconnect())
|
asyncio.create_task(self.ctx.disconnect())
|
||||||
else:
|
else:
|
||||||
async_start(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
|
asyncio.create_task(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
|
||||||
|
|
||||||
def on_stop(self):
|
def on_stop(self):
|
||||||
# "kill" input tasks
|
# "kill" input tasks
|
||||||
@@ -450,6 +436,8 @@ class GameManager(App):
|
|||||||
elif input_text:
|
elif input_text:
|
||||||
self.commandprocessor(input_text)
|
self.commandprocessor(input_text)
|
||||||
|
|
||||||
|
Clock.schedule_once(textinput.text_focus)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger("Client").exception(e)
|
logging.getLogger("Client").exception(e)
|
||||||
|
|
||||||
@@ -458,10 +446,6 @@ class GameManager(App):
|
|||||||
self.log_panels["Archipelago"].on_message_markup(text)
|
self.log_panels["Archipelago"].on_message_markup(text)
|
||||||
self.log_panels["All"].on_message_markup(text)
|
self.log_panels["All"].on_message_markup(text)
|
||||||
|
|
||||||
def focus_textinput(self):
|
|
||||||
if hasattr(self, "textinput"):
|
|
||||||
self.textinput.focus = True
|
|
||||||
|
|
||||||
def update_address_bar(self, text: str):
|
def update_address_bar(self, text: str):
|
||||||
if hasattr(self, "server_connect_bar"):
|
if hasattr(self, "server_connect_bar"):
|
||||||
self.server_connect_bar.text = text
|
self.server_connect_bar.text = text
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ A Link to the Past:
|
|||||||
death_link:
|
death_link:
|
||||||
false: 50
|
false: 50
|
||||||
true: 0
|
true: 0
|
||||||
|
|
||||||
allow_collect: # Allows for !collect / co-op to auto-open chests containing items for other players.
|
allow_collect: # Allows for !collect / co-op to auto-open chests containing items for other players.
|
||||||
# Off by default, because it currently crashes on real hardware.
|
# Off by default, because it currently crashes on real hardware.
|
||||||
false: 50
|
false: 50
|
||||||
|
|||||||
9
setup.py
9
setup.py
@@ -17,7 +17,7 @@ from Launcher import components, icon_paths
|
|||||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||||
import subprocess
|
import subprocess
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
requirement = 'cx-Freeze>=6.13.1'
|
requirement = 'cx-Freeze==6.10'
|
||||||
try:
|
try:
|
||||||
pkg_resources.require(requirement)
|
pkg_resources.require(requirement)
|
||||||
import cx_Freeze
|
import cx_Freeze
|
||||||
@@ -70,7 +70,7 @@ def _threaded_hash(filepath):
|
|||||||
|
|
||||||
|
|
||||||
# cx_Freeze's build command runs other commands. Override to accept --yes and store that.
|
# cx_Freeze's build command runs other commands. Override to accept --yes and store that.
|
||||||
class BuildCommand(cx_Freeze.command.build.Build):
|
class BuildCommand(cx_Freeze.dist.build):
|
||||||
user_options = [
|
user_options = [
|
||||||
('yes', 'y', 'Answer "yes" to all questions.'),
|
('yes', 'y', 'Answer "yes" to all questions.'),
|
||||||
]
|
]
|
||||||
@@ -87,8 +87,8 @@ class BuildCommand(cx_Freeze.command.build.Build):
|
|||||||
|
|
||||||
|
|
||||||
# Override cx_Freeze's build_exe command for pre and post build steps
|
# Override cx_Freeze's build_exe command for pre and post build steps
|
||||||
class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
class BuildExeCommand(cx_Freeze.dist.build_exe):
|
||||||
user_options = cx_Freeze.command.build_exe.BuildEXE.user_options + [
|
user_options = cx_Freeze.dist.build_exe.user_options + [
|
||||||
('yes', 'y', 'Answer "yes" to all questions.'),
|
('yes', 'y', 'Answer "yes" to all questions.'),
|
||||||
('extra-data=', None, 'Additional files to add.'),
|
('extra-data=', None, 'Additional files to add.'),
|
||||||
]
|
]
|
||||||
@@ -289,7 +289,6 @@ tmp="${{exe#*/}}"
|
|||||||
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
||||||
exe="{default_exe.parent}/$exe"
|
exe="{default_exe.parent}/$exe"
|
||||||
fi
|
fi
|
||||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
|
|
||||||
$APPDIR/$exe "$@"
|
$APPDIR/$exe "$@"
|
||||||
""")
|
""")
|
||||||
launcher_filename.chmod(0o755)
|
launcher_filename.chmod(0o755)
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ from worlds.alttp.Items import ItemFactory
|
|||||||
|
|
||||||
|
|
||||||
class TestBase(unittest.TestCase):
|
class TestBase(unittest.TestCase):
|
||||||
multiworld: MultiWorld
|
world: MultiWorld
|
||||||
_state_cache = {}
|
_state_cache = {}
|
||||||
|
|
||||||
def get_state(self, items):
|
def get_state(self, items):
|
||||||
if (self.multiworld, tuple(items)) in self._state_cache:
|
if (self.world, tuple(items)) in self._state_cache:
|
||||||
return self._state_cache[self.multiworld, tuple(items)]
|
return self._state_cache[self.world, tuple(items)]
|
||||||
state = CollectionState(self.multiworld)
|
state = CollectionState(self.world)
|
||||||
for item in items:
|
for item in items:
|
||||||
item.classification = ItemClassification.progression
|
item.classification = ItemClassification.progression
|
||||||
state.collect(item)
|
state.collect(item)
|
||||||
state.sweep_for_events()
|
state.sweep_for_events()
|
||||||
self._state_cache[self.multiworld, tuple(items)] = state
|
self._state_cache[self.world, tuple(items)] = state
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def get_path(self, state, region):
|
def get_path(self, state, region):
|
||||||
@@ -44,11 +44,11 @@ class TestBase(unittest.TestCase):
|
|||||||
items = item_pool[0]
|
items = item_pool[0]
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
state = self._get_items(item_pool, all_except)
|
state = self._get_items(item_pool, all_except)
|
||||||
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
|
path = self.get_path(state, self.world.get_location(location, 1).parent_region)
|
||||||
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
|
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
|
||||||
all_except=all_except, path=path, entry=i):
|
all_except=all_except, path=path, entry=i):
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access)
|
self.assertEqual(self.world.get_location(location, 1).can_reach(state), access)
|
||||||
|
|
||||||
# check for partial solution
|
# check for partial solution
|
||||||
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
||||||
@@ -56,18 +56,18 @@ class TestBase(unittest.TestCase):
|
|||||||
with self.subTest(msg="Location reachable without required item", location=location,
|
with self.subTest(msg="Location reachable without required item", location=location,
|
||||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||||
state = self._get_items_partial(item_pool, missing_item)
|
state = self._get_items_partial(item_pool, missing_item)
|
||||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False)
|
self.assertEqual(self.world.get_location(location, 1).can_reach(state), False)
|
||||||
|
|
||||||
def run_entrance_tests(self, access_pool):
|
def run_entrance_tests(self, access_pool):
|
||||||
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
||||||
items = item_pool[0]
|
items = item_pool[0]
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
state = self._get_items(item_pool, all_except)
|
state = self._get_items(item_pool, all_except)
|
||||||
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
|
path = self.get_path(state, self.world.get_entrance(entrance, 1).parent_region)
|
||||||
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
|
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
|
||||||
all_except=all_except, path=path, entry=i):
|
all_except=all_except, path=path, entry=i):
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
|
self.assertEqual(self.world.get_entrance(entrance, 1).can_reach(state), access)
|
||||||
|
|
||||||
# check for partial solution
|
# check for partial solution
|
||||||
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
||||||
@@ -75,11 +75,11 @@ class TestBase(unittest.TestCase):
|
|||||||
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
|
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
|
||||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||||
state = self._get_items_partial(item_pool, missing_item)
|
state = self._get_items_partial(item_pool, missing_item)
|
||||||
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False)
|
self.assertEqual(self.world.get_entrance(entrance, 1).can_reach(state), False)
|
||||||
|
|
||||||
def _get_items(self, item_pool, all_except):
|
def _get_items(self, item_pool, all_except):
|
||||||
if all_except and len(all_except) > 0:
|
if all_except and len(all_except) > 0:
|
||||||
items = self.multiworld.itempool[:]
|
items = self.world.itempool[:]
|
||||||
items = [item for item in items if
|
items = [item for item in items if
|
||||||
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
||||||
items.extend(ItemFactory(item_pool[0], 1))
|
items.extend(ItemFactory(item_pool[0], 1))
|
||||||
|
|||||||
@@ -14,46 +14,46 @@ from worlds import AutoWorld
|
|||||||
|
|
||||||
class TestDungeon(unittest.TestCase):
|
class TestDungeon(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.multiworld = MultiWorld(1)
|
self.world = MultiWorld(1)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
self.multiworld.set_options(args)
|
self.world.set_options(args)
|
||||||
self.multiworld.set_default_common_options()
|
self.world.set_default_common_options()
|
||||||
self.starting_regions = [] # Where to start exploring
|
self.starting_regions = [] # Where to start exploring
|
||||||
self.remove_exits = [] # Block dungeon exits
|
self.remove_exits = [] # Block dungeon exits
|
||||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||||
create_regions(self.multiworld, 1)
|
create_regions(self.world, 1)
|
||||||
create_dungeons(self.multiworld, 1)
|
create_dungeons(self.world, 1)
|
||||||
create_shops(self.multiworld, 1)
|
create_shops(self.world, 1)
|
||||||
for exitname, regionname in mandatory_connections:
|
for exitname, regionname in mandatory_connections:
|
||||||
connect_simple(self.multiworld, exitname, regionname, 1)
|
connect_simple(self.world, exitname, regionname, 1)
|
||||||
connect_simple(self.multiworld, 'Big Bomb Shop', 'Big Bomb Shop', 1)
|
connect_simple(self.world, 'Big Bomb Shop', 'Big Bomb Shop', 1)
|
||||||
self.multiworld.get_region('Menu', 1).exits = []
|
self.world.get_region('Menu', 1).exits = []
|
||||||
self.multiworld.swamp_patch_required[1] = True
|
self.world.swamp_patch_required[1] = True
|
||||||
self.multiworld.worlds[1].set_rules()
|
self.world.worlds[1].set_rules()
|
||||||
self.multiworld.worlds[1].create_items()
|
self.world.worlds[1].create_items()
|
||||||
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||||
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
self.world.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
||||||
|
|
||||||
def run_tests(self, access_pool):
|
def run_tests(self, access_pool):
|
||||||
for exit in self.remove_exits:
|
for exit in self.remove_exits:
|
||||||
self.multiworld.get_entrance(exit, 1).connected_region = self.multiworld.get_region('Menu', 1)
|
self.world.get_entrance(exit, 1).connected_region = self.world.get_region('Menu', 1)
|
||||||
|
|
||||||
for location, access, *item_pool in access_pool:
|
for location, access, *item_pool in access_pool:
|
||||||
items = item_pool[0]
|
items = item_pool[0]
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
with self.subTest(location=location, access=access, items=items, all_except=all_except):
|
with self.subTest(location=location, access=access, items=items, all_except=all_except):
|
||||||
if all_except and len(all_except) > 0:
|
if all_except and len(all_except) > 0:
|
||||||
items = self.multiworld.itempool[:]
|
items = self.world.itempool[:]
|
||||||
items = [item for item in items if item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
items = [item for item in items if item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
||||||
items.extend(ItemFactory(item_pool[0], 1))
|
items.extend(ItemFactory(item_pool[0], 1))
|
||||||
else:
|
else:
|
||||||
items = ItemFactory(items, 1)
|
items = ItemFactory(items, 1)
|
||||||
state = CollectionState(self.multiworld)
|
state = CollectionState(self.world)
|
||||||
state.reachable_regions[1].add(self.multiworld.get_region('Menu', 1))
|
state.reachable_regions[1].add(self.world.get_region('Menu', 1))
|
||||||
for region_name in self.starting_regions:
|
for region_name in self.starting_regions:
|
||||||
region = self.multiworld.get_region(region_name, 1)
|
region = self.world.get_region(region_name, 1)
|
||||||
state.reachable_regions[1].add(region)
|
state.reachable_regions[1].add(region)
|
||||||
for exit in region.exits:
|
for exit in region.exits:
|
||||||
if exit.connected_region is not None:
|
if exit.connected_region is not None:
|
||||||
@@ -63,4 +63,4 @@ class TestDungeon(unittest.TestCase):
|
|||||||
item.classification = ItemClassification.progression
|
item.classification = ItemClassification.progression
|
||||||
state.collect(item)
|
state.collect(item)
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access)
|
self.assertEqual(self.world.get_location(location, 1).can_reach(state), access)
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
from typing import List, Iterable
|
from typing import List, Iterable
|
||||||
import unittest
|
import unittest
|
||||||
from worlds.AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
from Fill import FillError, balance_multiworld_progression, fill_restrictive, \
|
from Fill import FillError, balance_multiworld_progression, fill_restrictive, distribute_items_restrictive
|
||||||
distribute_early_items, distribute_items_restrictive
|
|
||||||
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \
|
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \
|
||||||
ItemClassification
|
ItemClassification
|
||||||
from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule
|
from worlds.generic.Rules import CollectionRule, locality_rules, set_rule
|
||||||
|
|
||||||
|
|
||||||
def generate_multi_world(players: int = 1) -> MultiWorld:
|
def generate_multi_world(players: int = 1) -> MultiWorld:
|
||||||
@@ -14,7 +13,7 @@ def generate_multi_world(players: int = 1) -> MultiWorld:
|
|||||||
for i in range(players):
|
for i in range(players):
|
||||||
player_id = i+1
|
player_id = i+1
|
||||||
world = World(multi_world, player_id)
|
world = World(multi_world, player_id)
|
||||||
multi_world.game[player_id] = f"Game {player_id}"
|
multi_world.game[player_id] = world
|
||||||
multi_world.worlds[player_id] = world
|
multi_world.worlds[player_id] = world
|
||||||
multi_world.player_name[player_id] = "Test Player " + str(player_id)
|
multi_world.player_name[player_id] = "Test Player " + str(player_id)
|
||||||
region = Region("Menu", RegionType.Generic,
|
region = Region("Menu", RegionType.Generic,
|
||||||
@@ -28,7 +27,7 @@ def generate_multi_world(players: int = 1) -> MultiWorld:
|
|||||||
|
|
||||||
|
|
||||||
class PlayerDefinition(object):
|
class PlayerDefinition(object):
|
||||||
multiworld: MultiWorld
|
world: MultiWorld
|
||||||
id: int
|
id: int
|
||||||
menu: Region
|
menu: Region
|
||||||
locations: List[Location]
|
locations: List[Location]
|
||||||
@@ -37,7 +36,7 @@ class PlayerDefinition(object):
|
|||||||
regions: List[Region]
|
regions: List[Region]
|
||||||
|
|
||||||
def __init__(self, world: MultiWorld, id: int, menu: Region, locations: List[Location] = [], prog_items: List[Item] = [], basic_items: List[Item] = []):
|
def __init__(self, world: MultiWorld, id: int, menu: Region, locations: List[Location] = [], prog_items: List[Item] = [], basic_items: List[Item] = []):
|
||||||
self.multiworld = world
|
self.world = world
|
||||||
self.id = id
|
self.id = id
|
||||||
self.menu = menu
|
self.menu = menu
|
||||||
self.locations = locations
|
self.locations = locations
|
||||||
@@ -49,7 +48,7 @@ class PlayerDefinition(object):
|
|||||||
region_tag = "_region" + str(len(self.regions))
|
region_tag = "_region" + str(len(self.regions))
|
||||||
region_name = "player" + str(self.id) + region_tag
|
region_name = "player" + str(self.id) + region_tag
|
||||||
region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
|
region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
|
||||||
"Region Hint", self.id, self.multiworld)
|
"Region Hint", self.id, self.world)
|
||||||
self.locations += generate_locations(size, self.id, None, region, region_tag)
|
self.locations += generate_locations(size, self.id, None, region, region_tag)
|
||||||
|
|
||||||
entrance = Entrance(self.id, region_name + "_entrance", parent)
|
entrance = Entrance(self.id, region_name + "_entrance", parent)
|
||||||
@@ -58,7 +57,7 @@ class PlayerDefinition(object):
|
|||||||
entrance.access_rule = access_rule
|
entrance.access_rule = access_rule
|
||||||
|
|
||||||
self.regions.append(region)
|
self.regions.append(region)
|
||||||
self.multiworld.regions.append(region)
|
self.world.regions.append(region)
|
||||||
|
|
||||||
return region
|
return region
|
||||||
|
|
||||||
@@ -360,46 +359,6 @@ class TestFillRestrictive(unittest.TestCase):
|
|||||||
fill_restrictive(multi_world, multi_world.state,
|
fill_restrictive(multi_world, multi_world.state,
|
||||||
locations, player1.prog_items)
|
locations, player1.prog_items)
|
||||||
|
|
||||||
def test_swap_to_earlier_location_with_item_rule(self):
|
|
||||||
# test for PR#1109
|
|
||||||
multi_world = generate_multi_world(1)
|
|
||||||
player1 = generate_player_data(multi_world, 1, 4, 4)
|
|
||||||
locations = player1.locations[:] # copy required
|
|
||||||
items = player1.prog_items[:] # copy required
|
|
||||||
# for the test to work, item and location order is relevant: Sphere 1 last, allowed_item not last
|
|
||||||
for location in locations[:-1]: # Sphere 2
|
|
||||||
# any one provides access to Sphere 2
|
|
||||||
set_rule(location, lambda state: any(state.has(item.name, player1.id) for item in items))
|
|
||||||
# forbid all but 1 item in Sphere 1
|
|
||||||
sphere1_loc = locations[-1]
|
|
||||||
allowed_item = items[1]
|
|
||||||
add_item_rule(sphere1_loc, lambda item_to_place: item_to_place == allowed_item)
|
|
||||||
# test our rules
|
|
||||||
self.assertTrue(location.can_fill(None, allowed_item, False), "Test is flawed")
|
|
||||||
self.assertTrue(location.can_fill(None, items[2], False), "Test is flawed")
|
|
||||||
self.assertTrue(sphere1_loc.can_fill(None, allowed_item, False), "Test is flawed")
|
|
||||||
self.assertFalse(sphere1_loc.can_fill(None, items[2], False), "Test is flawed")
|
|
||||||
# fill has to place items[1] in locations[0] which will result in a swap because of placement order
|
|
||||||
fill_restrictive(multi_world, multi_world.state, player1.locations, player1.prog_items)
|
|
||||||
# assert swap happened
|
|
||||||
self.assertTrue(sphere1_loc.item, "Did not swap required item into Sphere 1")
|
|
||||||
self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1")
|
|
||||||
|
|
||||||
def test_double_sweep(self):
|
|
||||||
# test for PR1114
|
|
||||||
multi_world = generate_multi_world(1)
|
|
||||||
player1 = generate_player_data(multi_world, 1, 1, 1)
|
|
||||||
location = player1.locations[0]
|
|
||||||
location.address = None
|
|
||||||
location.event = True
|
|
||||||
item = player1.prog_items[0]
|
|
||||||
item.code = None
|
|
||||||
location.place_locked_item(item)
|
|
||||||
multi_world.state.sweep_for_events()
|
|
||||||
multi_world.state.sweep_for_events()
|
|
||||||
self.assertTrue(multi_world.state.prog_items[item.name, item.player], "Sweep did not collect - Test flawed")
|
|
||||||
self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDistributeItemsRestrictive(unittest.TestCase):
|
class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||||
def test_basic_distribute(self):
|
def test_basic_distribute(self):
|
||||||
@@ -412,13 +371,13 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
|
|
||||||
distribute_items_restrictive(multi_world)
|
distribute_items_restrictive(multi_world)
|
||||||
|
|
||||||
self.assertEqual(locations[0].item, basic_items[1])
|
self.assertEqual(locations[0].item, basic_items[0])
|
||||||
self.assertFalse(locations[0].event)
|
self.assertFalse(locations[0].event)
|
||||||
self.assertEqual(locations[1].item, prog_items[0])
|
self.assertEqual(locations[1].item, prog_items[0])
|
||||||
self.assertTrue(locations[1].event)
|
self.assertTrue(locations[1].event)
|
||||||
self.assertEqual(locations[2].item, prog_items[1])
|
self.assertEqual(locations[2].item, prog_items[1])
|
||||||
self.assertTrue(locations[2].event)
|
self.assertTrue(locations[2].event)
|
||||||
self.assertEqual(locations[3].item, basic_items[0])
|
self.assertEqual(locations[3].item, basic_items[1])
|
||||||
self.assertFalse(locations[3].event)
|
self.assertFalse(locations[3].event)
|
||||||
|
|
||||||
def test_excluded_distribute(self):
|
def test_excluded_distribute(self):
|
||||||
@@ -541,8 +500,8 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
removed_item: list[Item] = []
|
removed_item: list[Item] = []
|
||||||
removed_location: list[Location] = []
|
removed_location: list[Location] = []
|
||||||
|
|
||||||
def fill_hook(progitempool, usefulitempool, filleritempool, fill_locations):
|
def fill_hook(progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, restitempool, fill_locations):
|
||||||
removed_item.append(filleritempool.pop(0))
|
removed_item.append(restitempool.pop(0))
|
||||||
removed_location.append(fill_locations.pop(0))
|
removed_location.append(fill_locations.pop(0))
|
||||||
|
|
||||||
multi_world.worlds[player1.id].fill_hook = fill_hook
|
multi_world.worlds[player1.id].fill_hook = fill_hook
|
||||||
@@ -616,7 +575,8 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
|
|
||||||
multi_world.local_items[player1.id].value = set(names(player1.basic_items))
|
multi_world.local_items[player1.id].value = set(names(player1.basic_items))
|
||||||
multi_world.local_items[player2.id].value = set(names(player2.basic_items))
|
multi_world.local_items[player2.id].value = set(names(player2.basic_items))
|
||||||
locality_rules(multi_world)
|
locality_rules(multi_world, player1.id)
|
||||||
|
locality_rules(multi_world, player2.id)
|
||||||
|
|
||||||
distribute_items_restrictive(multi_world)
|
distribute_items_restrictive(multi_world)
|
||||||
|
|
||||||
@@ -624,55 +584,6 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
self.assertEqual(item.player, item.location.player)
|
self.assertEqual(item.player, item.location.player)
|
||||||
self.assertFalse(item.location.event, False)
|
self.assertFalse(item.location.event, False)
|
||||||
|
|
||||||
def test_early_items(self) -> None:
|
|
||||||
mw = generate_multi_world(2)
|
|
||||||
player1 = generate_player_data(mw, 1, location_count=5, basic_item_count=5)
|
|
||||||
player2 = generate_player_data(mw, 2, location_count=5, basic_item_count=5)
|
|
||||||
mw.early_items[1][player1.basic_items[0].name] = 1
|
|
||||||
mw.early_items[2][player2.basic_items[2].name] = 1
|
|
||||||
mw.early_items[2][player2.basic_items[3].name] = 1
|
|
||||||
|
|
||||||
early_items = [
|
|
||||||
player1.basic_items[0],
|
|
||||||
player2.basic_items[2],
|
|
||||||
player2.basic_items[3],
|
|
||||||
]
|
|
||||||
|
|
||||||
# copied this code from the beginning of `distribute_items_restrictive`
|
|
||||||
# before `distribute_early_items` is called
|
|
||||||
fill_locations = sorted(mw.get_unfilled_locations())
|
|
||||||
mw.random.shuffle(fill_locations)
|
|
||||||
itempool = sorted(mw.itempool)
|
|
||||||
mw.random.shuffle(itempool)
|
|
||||||
|
|
||||||
fill_locations, itempool = distribute_early_items(mw, fill_locations, itempool)
|
|
||||||
|
|
||||||
remaining_p1 = [item for item in itempool if item.player == 1]
|
|
||||||
remaining_p2 = [item for item in itempool if item.player == 2]
|
|
||||||
|
|
||||||
assert len(itempool) == 7, f"number of items remaining after early_items: {len(itempool)}"
|
|
||||||
assert len(remaining_p1) == 4, f"number of p1 items after early_items: {len(remaining_p1)}"
|
|
||||||
assert len(remaining_p2) == 3, f"number of p2 items after early_items: {len(remaining_p1)}"
|
|
||||||
for i in range(5):
|
|
||||||
if i != 0:
|
|
||||||
assert player1.basic_items[i] in itempool, "non-early item to remain in itempool"
|
|
||||||
if i not in {2, 3}:
|
|
||||||
assert player2.basic_items[i] in itempool, "non-early item to remain in itempool"
|
|
||||||
for item in early_items:
|
|
||||||
assert item not in itempool, "early item to be taken out of itempool"
|
|
||||||
|
|
||||||
assert len(fill_locations) == len(mw.get_locations()) - len(early_items), \
|
|
||||||
f"early location count from {mw.get_locations()} to {len(fill_locations)} " \
|
|
||||||
f"after {len(early_items)} early items"
|
|
||||||
|
|
||||||
items_in_locations = {loc.item for loc in mw.get_locations() if loc.item}
|
|
||||||
|
|
||||||
assert len(items_in_locations) == len(early_items), \
|
|
||||||
f"{len(early_items)} early items in {len(items_in_locations)} locations"
|
|
||||||
|
|
||||||
for item in early_items:
|
|
||||||
assert item in items_in_locations, "early item to be placed in location"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBalanceMultiworldProgression(unittest.TestCase):
|
class TestBalanceMultiworldProgression(unittest.TestCase):
|
||||||
def assertRegionContains(self, region: Region, item: Item) -> bool:
|
def assertRegionContains(self, region: Region, item: Item) -> bool:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class TestImplemented(unittest.TestCase):
|
|||||||
def testCompletionCondition(self):
|
def testCompletionCondition(self):
|
||||||
"""Ensure a completion condition is set that has requirements."""
|
"""Ensure a completion condition is set that has requirements."""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy", "Sudoku"}:
|
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy"}:
|
||||||
with self.subTest(gamename):
|
with self.subTest(gamename):
|
||||||
world = setup_default_world(world_type)
|
world = setup_default_world(world_type)
|
||||||
self.assertFalse(world.completion_condition[1](world.state))
|
self.assertFalse(world.completion_condition[1](world.state))
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ class TestBase(unittest.TestCase):
|
|||||||
exclusion_dict = {
|
exclusion_dict = {
|
||||||
"A Link to the Past":
|
"A Link to the Past":
|
||||||
{"Pendants", "Crystals"},
|
{"Pendants", "Crystals"},
|
||||||
"Ocarina of Time":
|
|
||||||
{"medallions", "stones", "rewards", "logic_bottles"},
|
|
||||||
"Starcraft 2 Wings of Liberty":
|
"Starcraft 2 Wings of Liberty":
|
||||||
{"Missions"},
|
{"Missions"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
|
||||||
|
|
||||||
|
|
||||||
class TestNames(unittest.TestCase):
|
|
||||||
def testItemNamesFormat(self):
|
|
||||||
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
|
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
|
||||||
with self.subTest(game=gamename):
|
|
||||||
for item_name in world_type.item_name_to_id:
|
|
||||||
self.assertFalse(item_name.isnumeric(),
|
|
||||||
f"Item name \"{item_name}\" is invalid. It must not be numeric.")
|
|
||||||
|
|
||||||
def testLocationNameFormat(self):
|
|
||||||
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
|
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
|
||||||
with self.subTest(game=gamename):
|
|
||||||
for location_name in world_type.location_name_to_id:
|
|
||||||
self.assertFalse(location_name.isnumeric(),
|
|
||||||
f"Location name \"{location_name}\" is invalid. It must not be numeric.")
|
|
||||||
@@ -20,7 +20,7 @@ class TestBase(unittest.TestCase):
|
|||||||
for location in world.get_locations():
|
for location in world.get_locations():
|
||||||
if location.name not in excluded:
|
if location.name not in excluded:
|
||||||
with self.subTest("Location should be reached", location=location):
|
with self.subTest("Location should be reached", location=location):
|
||||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
self.assertTrue(location.can_reach(state))
|
||||||
|
|
||||||
with self.subTest("Completion Condition"):
|
with self.subTest("Completion Condition"):
|
||||||
self.assertTrue(world.can_beat_game(state))
|
self.assertTrue(world.can_beat_game(state))
|
||||||
@@ -28,7 +28,7 @@ class TestBase(unittest.TestCase):
|
|||||||
def testEmptyStateCanReachSomething(self):
|
def testEmptyStateCanReachSomething(self):
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
# Final Fantasy logic is controlled by finalfantasyrandomizer.com
|
# Final Fantasy logic is controlled by finalfantasyrandomizer.com
|
||||||
if game_name not in {"Archipelago", "Final Fantasy", "Sudoku"}:
|
if game_name != "Archipelago" and game_name != "Final Fantasy":
|
||||||
with self.subTest("Game", game=game_name):
|
with self.subTest("Game", game=game_name):
|
||||||
world = setup_default_world(world_type)
|
world = setup_default_world(world_type)
|
||||||
state = CollectionState(world)
|
state = CollectionState(world)
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "g
|
|||||||
|
|
||||||
|
|
||||||
def setup_default_world(world_type) -> MultiWorld:
|
def setup_default_world(world_type) -> MultiWorld:
|
||||||
multiworld = MultiWorld(1)
|
world = MultiWorld(1)
|
||||||
multiworld.game[1] = world_type.game
|
world.game[1] = world_type.game
|
||||||
multiworld.player_name = {1: "Tester"}
|
world.player_name = {1: "Tester"}
|
||||||
multiworld.set_seed()
|
world.set_seed()
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for name, option in world_type.option_definitions.items():
|
for name, option in world_type.option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
multiworld.set_options(args)
|
world.set_options(args)
|
||||||
multiworld.set_default_common_options()
|
world.set_default_common_options()
|
||||||
for step in gen_steps:
|
for step in gen_steps:
|
||||||
call_all(multiworld, step)
|
call_all(world, step)
|
||||||
return multiworld
|
return world
|
||||||
|
|||||||
@@ -14,23 +14,23 @@ from worlds import AutoWorld
|
|||||||
|
|
||||||
class TestInverted(TestBase):
|
class TestInverted(TestBase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.multiworld = MultiWorld(1)
|
self.world = MultiWorld(1)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
self.multiworld.set_options(args)
|
self.world.set_options(args)
|
||||||
self.multiworld.set_default_common_options()
|
self.world.set_default_common_options()
|
||||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||||
self.multiworld.mode[1] = "inverted"
|
self.world.mode[1] = "inverted"
|
||||||
create_inverted_regions(self.multiworld, 1)
|
create_inverted_regions(self.world, 1)
|
||||||
create_dungeons(self.multiworld, 1)
|
create_dungeons(self.world, 1)
|
||||||
create_shops(self.multiworld, 1)
|
create_shops(self.world, 1)
|
||||||
link_inverted_entrances(self.multiworld, 1)
|
link_inverted_entrances(self.world, 1)
|
||||||
self.multiworld.worlds[1].create_items()
|
self.world.worlds[1].create_items()
|
||||||
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
|
self.world.required_medallions[1] = ['Ether', 'Quake']
|
||||||
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||||
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
self.world.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
||||||
self.multiworld.get_location('Agahnim 1', 1).item = None
|
self.world.get_location('Agahnim 1', 1).item = None
|
||||||
self.multiworld.get_location('Agahnim 2', 1).item = None
|
self.world.get_location('Agahnim 2', 1).item = None
|
||||||
mark_light_world_regions(self.multiworld, 1)
|
mark_light_world_regions(self.world, 1)
|
||||||
self.multiworld.worlds[1].set_rules()
|
self.world.worlds[1].set_rules()
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ from worlds import AutoWorld
|
|||||||
class TestInvertedBombRules(unittest.TestCase):
|
class TestInvertedBombRules(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.multiworld = MultiWorld(1)
|
self.world = MultiWorld(1)
|
||||||
self.multiworld.mode[1] = "inverted"
|
self.world.mode[1] = "inverted"
|
||||||
args = Namespace
|
args = Namespace
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
self.multiworld.set_options(args)
|
self.world.set_options(args)
|
||||||
self.multiworld.set_default_common_options()
|
self.world.set_default_common_options()
|
||||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||||
create_inverted_regions(self.multiworld, 1)
|
create_inverted_regions(self.world, 1)
|
||||||
create_dungeons(self.multiworld, 1)
|
create_dungeons(self.world, 1)
|
||||||
|
|
||||||
#TODO: Just making sure I haven't missed an entrance. It would be good to test the rules make sense as well.
|
#TODO: Just making sure I haven't missed an entrance. It would be good to test the rules make sense as well.
|
||||||
def testInvertedBombRulesAreComplete(self):
|
def testInvertedBombRulesAreComplete(self):
|
||||||
@@ -31,9 +31,9 @@ class TestInvertedBombRules(unittest.TestCase):
|
|||||||
must_exits = list(Inverted_LW_Entrances_Must_Exit + Inverted_LW_Dungeon_Entrances_Must_Exit)
|
must_exits = list(Inverted_LW_Entrances_Must_Exit + Inverted_LW_Dungeon_Entrances_Must_Exit)
|
||||||
for entrance_name in (entrances + must_exits):
|
for entrance_name in (entrances + must_exits):
|
||||||
if entrance_name not in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
if entrance_name not in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
||||||
entrance = self.multiworld.get_entrance(entrance_name, 1)
|
entrance = self.world.get_entrance(entrance_name, 1)
|
||||||
connect_entrance(self.multiworld, entrance_name, 'Inverted Big Bomb Shop', 1)
|
connect_entrance(self.world, entrance_name, 'Inverted Big Bomb Shop', 1)
|
||||||
set_inverted_big_bomb_rules(self.multiworld, 1)
|
set_inverted_big_bomb_rules(self.world, 1)
|
||||||
entrance.connected_region.entrances.remove(entrance)
|
entrance.connected_region.entrances.remove(entrance)
|
||||||
entrance.connected_region = None
|
entrance.connected_region = None
|
||||||
|
|
||||||
@@ -45,9 +45,9 @@ class TestInvertedBombRules(unittest.TestCase):
|
|||||||
|
|
||||||
def testInvalidEntrances(self):
|
def testInvalidEntrances(self):
|
||||||
for entrance_name in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
for entrance_name in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
||||||
entrance = self.multiworld.get_entrance(entrance_name, 1)
|
entrance = self.world.get_entrance(entrance_name, 1)
|
||||||
connect_entrance(self.multiworld, entrance_name, 'Inverted Big Bomb Shop', 1)
|
connect_entrance(self.world, entrance_name, 'Inverted Big Bomb Shop', 1)
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception):
|
||||||
set_inverted_big_bomb_rules(self.multiworld, 1)
|
set_inverted_big_bomb_rules(self.world, 1)
|
||||||
entrance.connected_region.entrances.remove(entrance)
|
entrance.connected_region.entrances.remove(entrance)
|
||||||
entrance.connected_region = None
|
entrance.connected_region = None
|
||||||
|
|||||||
@@ -15,24 +15,24 @@ from worlds import AutoWorld
|
|||||||
|
|
||||||
class TestInvertedMinor(TestBase):
|
class TestInvertedMinor(TestBase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.multiworld = MultiWorld(1)
|
self.world = MultiWorld(1)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
self.multiworld.set_options(args)
|
self.world.set_options(args)
|
||||||
self.multiworld.set_default_common_options()
|
self.world.set_default_common_options()
|
||||||
self.multiworld.mode[1] = "inverted"
|
self.world.mode[1] = "inverted"
|
||||||
self.multiworld.logic[1] = "minorglitches"
|
self.world.logic[1] = "minorglitches"
|
||||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||||
create_inverted_regions(self.multiworld, 1)
|
create_inverted_regions(self.world, 1)
|
||||||
create_dungeons(self.multiworld, 1)
|
create_dungeons(self.world, 1)
|
||||||
create_shops(self.multiworld, 1)
|
create_shops(self.world, 1)
|
||||||
link_inverted_entrances(self.multiworld, 1)
|
link_inverted_entrances(self.world, 1)
|
||||||
self.multiworld.worlds[1].create_items()
|
self.world.worlds[1].create_items()
|
||||||
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
|
self.world.required_medallions[1] = ['Ether', 'Quake']
|
||||||
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||||
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
self.world.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
||||||
self.multiworld.get_location('Agahnim 1', 1).item = None
|
self.world.get_location('Agahnim 1', 1).item = None
|
||||||
self.multiworld.get_location('Agahnim 2', 1).item = None
|
self.world.get_location('Agahnim 2', 1).item = None
|
||||||
mark_light_world_regions(self.multiworld, 1)
|
mark_light_world_regions(self.world, 1)
|
||||||
self.multiworld.worlds[1].set_rules()
|
self.world.worlds[1].set_rules()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user