mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 15:13:52 -08:00
Compare commits
168 Commits
factorio_d
...
0.3.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6c097348 | ||
|
|
8ce3fd5518 | ||
|
|
93a354cd81 | ||
|
|
774581b7ba | ||
|
|
95f90851ac | ||
|
|
1cd1bfea4d | ||
|
|
edd1fff4b7 | ||
|
|
4d79920fa6 | ||
|
|
7665935227 | ||
|
|
5139475068 | ||
|
|
adcee639a2 | ||
|
|
fde97fca5b | ||
|
|
e108b67ca5 | ||
|
|
17da06f763 | ||
|
|
2ff737175f | ||
|
|
b0b8268249 | ||
|
|
4e5c10ad66 | ||
|
|
350e1e6287 | ||
|
|
63c0d027e7 | ||
|
|
a014bb4ab7 | ||
|
|
0d10fec395 | ||
|
|
0cbee4ac3e | ||
|
|
70cab99caf | ||
|
|
c1e97bcbff | ||
|
|
e2eaafbf70 | ||
|
|
66d594e95b | ||
|
|
a9bf0008ba | ||
|
|
f2426ae603 | ||
|
|
462ddce72c | ||
|
|
d10bb3c6c1 | ||
|
|
61232ca756 | ||
|
|
8f325a4f2b | ||
|
|
d28738a918 | ||
|
|
1f3d048462 | ||
|
|
b161a5241f | ||
|
|
208a0c6b08 | ||
|
|
c3c1ce5827 | ||
|
|
889bc9d1b4 | ||
|
|
165a38dd58 | ||
|
|
88088dd054 | ||
|
|
c933fa7e34 | ||
|
|
f1123f2662 | ||
|
|
0f034ddcf7 | ||
|
|
7f3eda4623 | ||
|
|
2b0e7f05da | ||
|
|
e204deab02 | ||
|
|
56afd62175 | ||
|
|
44204ac9be | ||
|
|
6c3852a2a9 | ||
|
|
124ae198e4 | ||
|
|
030b767751 | ||
|
|
5ca724a454 | ||
|
|
af3b752093 | ||
|
|
c378933274 | ||
|
|
da392239a0 | ||
|
|
a6e1e14fee | ||
|
|
95378233fc | ||
|
|
85130f2bbd | ||
|
|
ab9f3767e2 | ||
|
|
bf142b32c9 | ||
|
|
05c06a57af | ||
|
|
0f7adaaf7b | ||
|
|
962e48c078 | ||
|
|
95ea0541e6 | ||
|
|
0ed3baabd4 | ||
|
|
2db55ac50b | ||
|
|
bea8d37a3c | ||
|
|
813015e007 | ||
|
|
c1d7abd06e | ||
|
|
655f287d42 | ||
|
|
802119502d | ||
|
|
2af510328e | ||
|
|
87f4a97f1e | ||
|
|
1bb99d391d | ||
|
|
1cad51b1af | ||
|
|
09d8c4b912 | ||
|
|
ed23a426ec | ||
|
|
c711264d1a | ||
|
|
3dfbbc5057 | ||
|
|
f298b8d6e7 | ||
|
|
53974d568b | ||
|
|
ec0389eefb | ||
|
|
0c54c47023 | ||
|
|
80db8a33af | ||
|
|
e6c6b00109 | ||
|
|
813ea6ef8b | ||
|
|
c09e089f9d | ||
|
|
cfff12d8d7 | ||
|
|
924f484be0 | ||
|
|
aeb78eaa10 | ||
|
|
6134578c60 | ||
|
|
b57ca33c31 | ||
|
|
4b18920819 | ||
|
|
700fe8b75e | ||
|
|
d5efc71344 | ||
|
|
6535836e5c | ||
|
|
89d1a80e01 | ||
|
|
ad445629bd | ||
|
|
37c5865c0e | ||
|
|
52726139b4 | ||
|
|
24105ac249 | ||
|
|
f18df4c1df | ||
|
|
04b6c31076 | ||
|
|
40c3ef35c7 | ||
|
|
28483a6c14 | ||
|
|
fa077defe0 | ||
|
|
47b4e2782b | ||
|
|
265ee7098a | ||
|
|
ed76c13961 | ||
|
|
40b7e78178 | ||
|
|
1900d9382a | ||
|
|
f12b73f487 | ||
|
|
49ae79e5ce | ||
|
|
4da6a0bb98 | ||
|
|
af0cfc5a38 | ||
|
|
1aa3e431c8 | ||
|
|
b533ffb9e8 | ||
|
|
bb46ee7fc1 | ||
|
|
acf7fda26a | ||
|
|
f7fc6fa7aa | ||
|
|
5e97463bdc | ||
|
|
ca9c3d05d6 | ||
|
|
51f65f4b9e | ||
|
|
1f01404ca4 | ||
|
|
bbb6ee89cf | ||
|
|
0aea1e780f | ||
|
|
722b3c5369 | ||
|
|
097ac189e4 | ||
|
|
7f3f886e41 | ||
|
|
3bd4ef3f3d | ||
|
|
6b9073acd7 | ||
|
|
e708bea819 | ||
|
|
b014ce082b | ||
|
|
30a4bcbbbe | ||
|
|
0afb7096de | ||
|
|
f909576813 | ||
|
|
9f684b3dc0 | ||
|
|
37a40499fa | ||
|
|
099c4fca3c | ||
|
|
106d630ad7 | ||
|
|
4c0c93b083 | ||
|
|
3cbbf905d1 | ||
|
|
414ebf2640 | ||
|
|
3297be7902 | ||
|
|
7b3ef012b9 | ||
|
|
af6a72c3c3 | ||
|
|
38b7bdfe60 | ||
|
|
4c266e6eff | ||
|
|
8a6c9ff4b8 | ||
|
|
fdd7ffb089 | ||
|
|
b8e467fbb8 | ||
|
|
411cd51a92 | ||
|
|
e9e15e854d | ||
|
|
4943d26160 | ||
|
|
060a04700d | ||
|
|
61e39f355d | ||
|
|
8ab0b410c3 | ||
|
|
d897aaade2 | ||
|
|
0191df88d7 | ||
|
|
bee1fd9b5a | ||
|
|
dd7d3a02a4 | ||
|
|
13edfa60be | ||
|
|
885c8d3fcc | ||
|
|
e6a4925f0c | ||
|
|
c96b6d7b95 | ||
|
|
8bc8b412a3 | ||
|
|
b4b9ff5d82 | ||
|
|
b21b5cceb8 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes
|
||||
python setup.py build_exe --yes
|
||||
$NAME="$(ls build)".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -13,6 +13,10 @@
|
||||
*.z64
|
||||
*.n64
|
||||
*.nes
|
||||
*.sms
|
||||
*.gb
|
||||
*.gbc
|
||||
*.gba
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
@@ -125,7 +129,7 @@ ipython_config.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
.venv*
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
320
BaseClasses.py
320
BaseClasses.py
@@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
from argparse import Namespace
|
||||
|
||||
import copy
|
||||
from enum import unique, IntEnum, IntFlag
|
||||
@@ -40,16 +41,23 @@ class MultiWorld():
|
||||
plando_connections: List
|
||||
worlds: Dict[int, auto_world]
|
||||
groups: Dict[int, Group]
|
||||
regions: List[Region]
|
||||
itempool: List[Item]
|
||||
is_race: bool = False
|
||||
precollected_items: Dict[int, List[Item]]
|
||||
state: CollectionState
|
||||
|
||||
accessibility: Dict[int, Options.Accessibility]
|
||||
early_items: Dict[int, Dict[str, int]]
|
||||
local_early_items: Dict[int, Dict[str, int]]
|
||||
local_items: Dict[int, Options.LocalItems]
|
||||
non_local_items: Dict[int, Options.NonLocalItems]
|
||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||
indirect_connections: Dict[Region, Set[Entrance]]
|
||||
exclude_locations: Dict[int, Options.ExcludeLocations]
|
||||
|
||||
game: Dict[int, str]
|
||||
|
||||
class AttributeProxy():
|
||||
def __init__(self, rule):
|
||||
@@ -87,6 +95,9 @@ class MultiWorld():
|
||||
self.customitemarray = []
|
||||
self.shuffle_ganon = True
|
||||
self.spoiler = Spoiler(self)
|
||||
self.early_items = {player: {} for player in self.player_ids}
|
||||
self.local_early_items = {player: {} for player in self.player_ids}
|
||||
self.indirect_connections = {}
|
||||
self.fix_trock_doors = self.AttributeProxy(
|
||||
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
||||
self.fix_skullwoods_exit = self.AttributeProxy(
|
||||
@@ -195,7 +206,7 @@ class MultiWorld():
|
||||
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
|
||||
range(1, self.players + 1)}
|
||||
|
||||
def set_options(self, args):
|
||||
def set_options(self, args: Namespace) -> None:
|
||||
for option_key in Options.common_options:
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
for option_key in Options.per_game_common_options:
|
||||
@@ -295,9 +306,16 @@ class MultiWorld():
|
||||
def get_file_safe_player_name(self, player: int) -> str:
|
||||
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
||||
|
||||
def get_out_file_name_base(self, player: int) -> str:
|
||||
""" the base name (without file extension) for each player's output file for a seed """
|
||||
return f"AP_{self.seed_name}_P{player}" \
|
||||
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
||||
if (self.player_name[player] != f"Player{player}")
|
||||
else '')
|
||||
|
||||
def initialize_regions(self, regions=None):
|
||||
for region in regions if regions else self.regions:
|
||||
region.world = self
|
||||
region.multiworld = self
|
||||
self._region_cache[region.player][region.name] = region
|
||||
|
||||
@functools.cached_property
|
||||
@@ -404,6 +422,11 @@ class MultiWorld():
|
||||
def clear_entrance_cache(self):
|
||||
self._cached_entrances = None
|
||||
|
||||
def register_indirect_condition(self, region: Region, entrance: Entrance):
|
||||
"""Report that access to this Region can result in unlocking this Entrance,
|
||||
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
|
||||
self.indirect_connections.setdefault(region, set()).add(entrance)
|
||||
|
||||
def get_locations(self) -> List[Location]:
|
||||
if self._cached_locations is None:
|
||||
self._cached_locations = [location for region in self.regions for location in region.locations]
|
||||
@@ -521,15 +544,17 @@ class MultiWorld():
|
||||
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
||||
if not state:
|
||||
state = CollectionState(self)
|
||||
players = {"minimal": set(),
|
||||
"items": set(),
|
||||
"locations": set()}
|
||||
players: Dict[str, Set[int]] = {
|
||||
"minimal": set(),
|
||||
"items": set(),
|
||||
"locations": set()
|
||||
}
|
||||
for player, access in self.accessibility.items():
|
||||
players[access.current_key].add(player)
|
||||
|
||||
beatable_fulfilled = False
|
||||
|
||||
def location_conditition(location: Location):
|
||||
def location_condition(location: Location):
|
||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||
if location.player in players["minimal"]:
|
||||
return False
|
||||
@@ -543,20 +568,21 @@ class MultiWorld():
|
||||
return True
|
||||
return False
|
||||
|
||||
def all_done():
|
||||
def all_done() -> bool:
|
||||
"""Check if all access rules are fulfilled"""
|
||||
if beatable_fulfilled:
|
||||
if any(location_conditition(location) for location in locations):
|
||||
return False # still locations required to be collected
|
||||
return True
|
||||
if not beatable_fulfilled:
|
||||
return False
|
||||
if any(location_condition(location) for location in locations):
|
||||
return False # still locations required to be collected
|
||||
return True
|
||||
|
||||
locations = {location for location in self.get_locations() if location_relevant(location)}
|
||||
locations = [location for location in self.get_locations() if location_relevant(location)]
|
||||
|
||||
while locations:
|
||||
sphere = set()
|
||||
for location in locations:
|
||||
if location.can_reach(state):
|
||||
sphere.add(location)
|
||||
sphere: List[Location] = []
|
||||
for n in range(len(locations) - 1, -1, -1):
|
||||
if locations[n].can_reach(state):
|
||||
sphere.append(locations.pop(n))
|
||||
|
||||
if not sphere:
|
||||
# ran out of places and did not finish yet, quit
|
||||
@@ -565,8 +591,8 @@ class MultiWorld():
|
||||
return False
|
||||
|
||||
for location in sphere:
|
||||
locations.remove(location)
|
||||
state.collect(location.item, True, location)
|
||||
if location.item:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
if self.has_beaten_game(state):
|
||||
beatable_fulfilled = True
|
||||
@@ -582,7 +608,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
|
||||
|
||||
class CollectionState():
|
||||
prog_items: typing.Counter[Tuple[str, int]]
|
||||
world: MultiWorld
|
||||
multiworld: MultiWorld
|
||||
reachable_regions: Dict[int, Set[Region]]
|
||||
blocked_connections: Dict[int, Set[Entrance]]
|
||||
events: Set[Location]
|
||||
@@ -594,7 +620,7 @@ class CollectionState():
|
||||
|
||||
def __init__(self, parent: MultiWorld):
|
||||
self.prog_items = Counter()
|
||||
self.world = parent
|
||||
self.multiworld = parent
|
||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||
self.events = set()
|
||||
@@ -608,15 +634,14 @@ class CollectionState():
|
||||
self.collect(item, True)
|
||||
|
||||
def update_reachable_regions(self, player: int):
|
||||
from worlds.alttp.EntranceShuffle import indirect_connections
|
||||
self.stale[player] = False
|
||||
rrp = self.reachable_regions[player]
|
||||
bc = self.blocked_connections[player]
|
||||
queue = deque(self.blocked_connections[player])
|
||||
start = self.world.get_region('Menu', player)
|
||||
start = self.multiworld.get_region('Menu', player)
|
||||
|
||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||
if not start in rrp:
|
||||
if start not in rrp:
|
||||
rrp.add(start)
|
||||
bc.update(start.exits)
|
||||
queue.extend(start.exits)
|
||||
@@ -628,7 +653,7 @@ class CollectionState():
|
||||
if new_region in rrp:
|
||||
bc.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, "tried to search through an Entrance with no Region"
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
rrp.add(new_region)
|
||||
bc.remove(connection)
|
||||
bc.update(new_region.exits)
|
||||
@@ -636,13 +661,12 @@ class CollectionState():
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
|
||||
# Retry connections if the new region can unblock them
|
||||
if new_region.name in indirect_connections:
|
||||
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
|
||||
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
|
||||
if new_entrance in bc and new_entrance not in queue:
|
||||
queue.append(new_entrance)
|
||||
|
||||
def copy(self) -> CollectionState:
|
||||
ret = CollectionState(self.world)
|
||||
ret = CollectionState(self.multiworld)
|
||||
ret.prog_items = self.prog_items.copy()
|
||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||
self.reachable_regions}
|
||||
@@ -663,25 +687,25 @@ class CollectionState():
|
||||
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
||||
# try to resolve a name
|
||||
if resolution_hint == 'Location':
|
||||
spot = self.world.get_location(spot, player)
|
||||
spot = self.multiworld.get_location(spot, player)
|
||||
elif resolution_hint == 'Entrance':
|
||||
spot = self.world.get_entrance(spot, player)
|
||||
spot = self.multiworld.get_entrance(spot, player)
|
||||
else:
|
||||
# default to Region
|
||||
spot = self.world.get_region(spot, player)
|
||||
spot = self.multiworld.get_region(spot, player)
|
||||
return spot.can_reach(self)
|
||||
|
||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.world.get_filled_locations()
|
||||
new_locations = True
|
||||
locations = self.multiworld.get_filled_locations()
|
||||
reachable_events = True
|
||||
# since the loop has a good chance to run more than once, only filter the events once
|
||||
locations = {location for location in locations if location.event and
|
||||
locations = {location for location in locations if location.event and location not in self.events and
|
||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||
while new_locations:
|
||||
while reachable_events:
|
||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||
new_locations = reachable_events - self.events
|
||||
for event in new_locations:
|
||||
locations -= reachable_events
|
||||
for event in reachable_events:
|
||||
self.events.add(event)
|
||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(event.item, True, event)
|
||||
@@ -700,7 +724,7 @@ class CollectionState():
|
||||
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||
found: int = 0
|
||||
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
||||
found += self.prog_items[item_name, player]
|
||||
if found >= count:
|
||||
return True
|
||||
@@ -708,17 +732,17 @@ class CollectionState():
|
||||
|
||||
def count_group(self, item_name_group: str, player: int) -> int:
|
||||
found: int = 0
|
||||
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
||||
found += self.prog_items[item_name, player]
|
||||
return found
|
||||
|
||||
def can_buy_unlimited(self, item: str, player: int) -> bool:
|
||||
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
|
||||
shop in self.world.shops)
|
||||
shop in self.multiworld.shops)
|
||||
|
||||
def can_buy(self, item: str, player: int) -> bool:
|
||||
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
|
||||
shop in self.world.shops)
|
||||
shop in self.multiworld.shops)
|
||||
|
||||
def item_count(self, item: str, player: int) -> int:
|
||||
return self.prog_items[item, player]
|
||||
@@ -738,7 +762,7 @@ class CollectionState():
|
||||
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
|
||||
|
||||
def bottle_count(self, player: int) -> int:
|
||||
return min(self.world.difficulty_requirements[player].progressive_bottle_limit,
|
||||
return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit,
|
||||
self.count_group("Bottles", player))
|
||||
|
||||
def has_hearts(self, player: int, count: int) -> int:
|
||||
@@ -747,7 +771,7 @@ class CollectionState():
|
||||
|
||||
def heart_count(self, player: int) -> int:
|
||||
# Warning: This only considers items that are marked as advancement items
|
||||
diff = self.world.difficulty_requirements[player]
|
||||
diff = self.multiworld.difficulty_requirements[player]
|
||||
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
||||
+ self.item_count('Sanctuary Heart Container', player) \
|
||||
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
||||
@@ -764,9 +788,9 @@ class CollectionState():
|
||||
elif self.has('Magic Upgrade (1/2)', player):
|
||||
basemagic = 16
|
||||
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
|
||||
if self.world.item_functionality[player] == 'hard' and not fullrefill:
|
||||
if self.multiworld.item_functionality[player] == 'hard' and not fullrefill:
|
||||
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
|
||||
elif self.world.item_functionality[player] == 'expert' and not fullrefill:
|
||||
elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill:
|
||||
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
|
||||
else:
|
||||
basemagic = basemagic + basemagic * self.bottle_count(player)
|
||||
@@ -781,12 +805,12 @@ class CollectionState():
|
||||
or (self.has('Bombs (10)', player) and enemies < 6))
|
||||
|
||||
def can_shoot_arrows(self, player: int) -> bool:
|
||||
if self.world.retro_bow[player]:
|
||||
if self.multiworld.retro_bow[player]:
|
||||
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
|
||||
return self.has('Bow', player) or self.has('Silver Bow', player)
|
||||
|
||||
def can_get_good_bee(self, player: int) -> bool:
|
||||
cave = self.world.get_region('Good Bee Cave', player)
|
||||
cave = self.multiworld.get_region('Good Bee Cave', player)
|
||||
return (
|
||||
self.has_group("Bottles", player) and
|
||||
self.has('Bug Catching Net', player) and
|
||||
@@ -797,7 +821,7 @@ class CollectionState():
|
||||
|
||||
def can_retrieve_tablet(self, player: int) -> bool:
|
||||
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
|
||||
(self.world.swordless[player] and
|
||||
(self.multiworld.swordless[player] and
|
||||
self.has("Hammer", player)))
|
||||
|
||||
def has_sword(self, player: int) -> bool:
|
||||
@@ -819,7 +843,7 @@ class CollectionState():
|
||||
def can_melt_things(self, player: int) -> bool:
|
||||
return self.has('Fire Rod', player) or \
|
||||
(self.has('Bombos', player) and
|
||||
(self.world.swordless[player] or
|
||||
(self.multiworld.swordless[player] or
|
||||
self.has_sword(player)))
|
||||
|
||||
def can_avoid_lasers(self, player: int) -> bool:
|
||||
@@ -829,7 +853,7 @@ class CollectionState():
|
||||
if self.has('Moon Pearl', player):
|
||||
return True
|
||||
|
||||
return region.is_light_world if self.world.mode[player] != 'inverted' else region.is_dark_world
|
||||
return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world
|
||||
|
||||
def can_reach_light_world(self, player: int) -> bool:
|
||||
if True in [i.is_light_world for i in self.reachable_regions[player]]:
|
||||
@@ -842,24 +866,24 @@ class CollectionState():
|
||||
return False
|
||||
|
||||
def has_misery_mire_medallion(self, player: int) -> bool:
|
||||
return self.has(self.world.required_medallions[player][0], player)
|
||||
return self.has(self.multiworld.required_medallions[player][0], player)
|
||||
|
||||
def has_turtle_rock_medallion(self, player: int) -> bool:
|
||||
return self.has(self.world.required_medallions[player][1], player)
|
||||
return self.has(self.multiworld.required_medallions[player][1], player)
|
||||
|
||||
def can_boots_clip_lw(self, player: int) -> bool:
|
||||
if self.world.mode[player] == 'inverted':
|
||||
if self.multiworld.mode[player] == 'inverted':
|
||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||
return self.has('Pegasus Boots', player)
|
||||
|
||||
def can_boots_clip_dw(self, player: int) -> bool:
|
||||
if self.world.mode[player] != 'inverted':
|
||||
if self.multiworld.mode[player] != 'inverted':
|
||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||
return self.has('Pegasus Boots', player)
|
||||
|
||||
def can_get_glitched_speed_lw(self, player: int) -> bool:
|
||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||
if self.world.mode[player] == 'inverted':
|
||||
if self.multiworld.mode[player] == 'inverted':
|
||||
rules.append(self.has('Moon Pearl', player))
|
||||
return all(rules)
|
||||
|
||||
@@ -868,7 +892,7 @@ class CollectionState():
|
||||
|
||||
def can_get_glitched_speed_dw(self, player: int) -> bool:
|
||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||
if self.world.mode[player] != 'inverted':
|
||||
if self.multiworld.mode[player] != 'inverted':
|
||||
rules.append(self.has('Moon Pearl', player))
|
||||
return all(rules)
|
||||
|
||||
@@ -879,7 +903,7 @@ class CollectionState():
|
||||
if location:
|
||||
self.locations_checked.add(location)
|
||||
|
||||
changed = self.world.worlds[item.player].collect(self, item)
|
||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
||||
|
||||
if not changed and event:
|
||||
self.prog_items[item.name, item.player] += 1
|
||||
@@ -893,7 +917,7 @@ class CollectionState():
|
||||
return changed
|
||||
|
||||
def remove(self, item: Item):
|
||||
changed = self.world.worlds[item.player].remove(self, item)
|
||||
changed = self.multiworld.worlds[item.player].remove(self, item)
|
||||
if changed:
|
||||
# invalidate caches, nothing can be trusted anymore now
|
||||
self.reachable_regions[item.player] = set()
|
||||
@@ -920,7 +944,7 @@ class Region:
|
||||
type: RegionType
|
||||
hint_text: str
|
||||
player: int
|
||||
world: Optional[MultiWorld]
|
||||
multiworld: Optional[MultiWorld]
|
||||
entrances: List[Entrance]
|
||||
exits: List[Entrance]
|
||||
locations: List[Location]
|
||||
@@ -938,7 +962,7 @@ class Region:
|
||||
self.entrances = []
|
||||
self.exits = []
|
||||
self.locations = []
|
||||
self.world = world
|
||||
self.multiworld = world
|
||||
self.hint_text = hint
|
||||
self.player = player
|
||||
|
||||
@@ -966,7 +990,7 @@ class Region:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
class Entrance:
|
||||
@@ -993,7 +1017,7 @@ class Entrance:
|
||||
|
||||
return False
|
||||
|
||||
def connect(self, region: Region, addresses=None, target=None):
|
||||
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
|
||||
self.connected_region = region
|
||||
self.target = target
|
||||
self.addresses = addresses
|
||||
@@ -1003,7 +1027,7 @@ class Entrance:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
world = self.parent_region.world if self.parent_region else None
|
||||
world = self.parent_region.multiworld if self.parent_region else None
|
||||
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
@@ -1017,7 +1041,7 @@ class Dungeon(object):
|
||||
self.dungeon_items = dungeon_items
|
||||
self.bosses = dict()
|
||||
self.player = player
|
||||
self.world = None
|
||||
self.multiworld = None
|
||||
|
||||
@property
|
||||
def boss(self) -> Optional[Boss]:
|
||||
@@ -1047,7 +1071,7 @@ class Dungeon(object):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
class Boss():
|
||||
@@ -1081,7 +1105,7 @@ class Location:
|
||||
show_in_spoiler: bool = True
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow = staticmethod(lambda item, state: False)
|
||||
access_rule = staticmethod(lambda state: True)
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
|
||||
@@ -1092,13 +1116,15 @@ class Location:
|
||||
self.parent_region = parent
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
|
||||
return (self.always_allow(state, item)
|
||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))))
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
# self.access_rule computes faster on average, so placing it first for faster abort
|
||||
if self.access_rule(state) and self.parent_region.can_reach(state):
|
||||
return True
|
||||
return False
|
||||
assert self.parent_region, "Can't reach location without region"
|
||||
return self.access_rule(state) and self.parent_region.can_reach(state)
|
||||
|
||||
def place_locked_item(self, item: Item):
|
||||
if self.item:
|
||||
@@ -1112,7 +1138,7 @@ class Location:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
world = self.parent_region.world if self.parent_region and self.parent_region.world else None
|
||||
world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||
|
||||
def __hash__(self):
|
||||
@@ -1209,17 +1235,17 @@ class Item:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.location and self.location.parent_region and self.location.parent_region.world:
|
||||
return self.location.parent_region.world.get_name_string_for_object(self)
|
||||
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
||||
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
||||
return f"{self.name} (Player {self.player})"
|
||||
|
||||
|
||||
class Spoiler():
|
||||
world: MultiWorld
|
||||
multiworld: MultiWorld
|
||||
unreachables: Set[Location]
|
||||
|
||||
def __init__(self, world):
|
||||
self.world = world
|
||||
self.multiworld = world
|
||||
self.hashes = {}
|
||||
self.entrances = OrderedDict()
|
||||
self.medallions = {}
|
||||
@@ -1231,7 +1257,7 @@ class Spoiler():
|
||||
self.bosses = OrderedDict()
|
||||
|
||||
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
||||
if self.world.players == 1:
|
||||
if self.multiworld.players == 1:
|
||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||
else:
|
||||
@@ -1240,45 +1266,45 @@ class Spoiler():
|
||||
|
||||
def parse_data(self):
|
||||
self.medallions = OrderedDict()
|
||||
for player in self.world.get_game_players("A Link to the Past"):
|
||||
self.medallions[f'Misery Mire ({self.world.get_player_name(player)})'] = \
|
||||
self.world.required_medallions[player][0]
|
||||
self.medallions[f'Turtle Rock ({self.world.get_player_name(player)})'] = \
|
||||
self.world.required_medallions[player][1]
|
||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
||||
self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \
|
||||
self.multiworld.required_medallions[player][0]
|
||||
self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \
|
||||
self.multiworld.required_medallions[player][1]
|
||||
|
||||
self.locations = OrderedDict()
|
||||
listed_locations = set()
|
||||
|
||||
lw_locations = [loc for loc in self.world.get_locations() if
|
||||
lw_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
|
||||
self.locations['Light World'] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
lw_locations])
|
||||
listed_locations.update(lw_locations)
|
||||
|
||||
dw_locations = [loc for loc in self.world.get_locations() if
|
||||
dw_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
|
||||
self.locations['Dark World'] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
dw_locations])
|
||||
listed_locations.update(dw_locations)
|
||||
|
||||
cave_locations = [loc for loc in self.world.get_locations() if
|
||||
cave_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
|
||||
self.locations['Caves'] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
cave_locations])
|
||||
listed_locations.update(cave_locations)
|
||||
|
||||
for dungeon in self.world.dungeons.values():
|
||||
dungeon_locations = [loc for loc in self.world.get_locations() if
|
||||
for dungeon in self.multiworld.dungeons.values():
|
||||
dungeon_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
|
||||
self.locations[str(dungeon)] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
dungeon_locations])
|
||||
listed_locations.update(dungeon_locations)
|
||||
|
||||
other_locations = [loc for loc in self.world.get_locations() if
|
||||
other_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.show_in_spoiler]
|
||||
if other_locations:
|
||||
self.locations['Other Locations'] = OrderedDict(
|
||||
@@ -1288,7 +1314,7 @@ class Spoiler():
|
||||
|
||||
self.shops = []
|
||||
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
|
||||
for shop in self.world.shops:
|
||||
for shop in self.multiworld.shops:
|
||||
if not shop.custom:
|
||||
continue
|
||||
shopdata = {
|
||||
@@ -1317,34 +1343,34 @@ class Spoiler():
|
||||
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
|
||||
self.shops.append(shopdata)
|
||||
|
||||
for player in self.world.get_game_players("A Link to the Past"):
|
||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
||||
self.bosses[str(player)] = OrderedDict()
|
||||
self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name
|
||||
self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name
|
||||
self.bosses[str(player)]["Tower Of Hera"] = self.world.get_dungeon("Tower of Hera", player).boss.name
|
||||
self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name
|
||||
self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name
|
||||
self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name
|
||||
self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
|
||||
self.bosses[str(player)]["Palace Of Darkness"] = self.world.get_dungeon("Palace of Darkness",
|
||||
player).boss.name
|
||||
self.bosses[str(player)]["Swamp Palace"] = self.world.get_dungeon("Swamp Palace", player).boss.name
|
||||
self.bosses[str(player)]["Skull Woods"] = self.world.get_dungeon("Skull Woods", player).boss.name
|
||||
self.bosses[str(player)]["Thieves Town"] = self.world.get_dungeon("Thieves Town", player).boss.name
|
||||
self.bosses[str(player)]["Ice Palace"] = self.world.get_dungeon("Ice Palace", player).boss.name
|
||||
self.bosses[str(player)]["Misery Mire"] = self.world.get_dungeon("Misery Mire", player).boss.name
|
||||
self.bosses[str(player)]["Turtle Rock"] = self.world.get_dungeon("Turtle Rock", player).boss.name
|
||||
if self.world.mode[player] != 'inverted':
|
||||
self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness",
|
||||
player).boss.name
|
||||
self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name
|
||||
self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name
|
||||
self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name
|
||||
self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name
|
||||
self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name
|
||||
self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name
|
||||
if self.multiworld.mode[player] != 'inverted':
|
||||
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
||||
self.world.get_dungeon('Ganons Tower', player).bosses['bottom'].name
|
||||
self.bosses[str(player)]["Ganons Tower Middle"] = self.world.get_dungeon('Ganons Tower', player).bosses[
|
||||
self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name
|
||||
self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
|
||||
'middle'].name
|
||||
self.bosses[str(player)]["Ganons Tower Top"] = self.world.get_dungeon('Ganons Tower', player).bosses[
|
||||
self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
|
||||
'top'].name
|
||||
else:
|
||||
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
||||
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
|
||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
|
||||
self.bosses[str(player)]["Ganons Tower Middle"] = \
|
||||
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
|
||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
|
||||
self.bosses[str(player)]["Ganons Tower Top"] = \
|
||||
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
|
||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
|
||||
|
||||
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
|
||||
self.bosses[str(player)]["Ganon"] = "Ganon"
|
||||
@@ -1374,7 +1400,7 @@ class Spoiler():
|
||||
return 'Yes' if variable else 'No'
|
||||
|
||||
def write_option(option_key: str, option_obj: type(Options.Option)):
|
||||
res = getattr(self.world, option_key)[player]
|
||||
res = getattr(self.multiworld, option_key)[player]
|
||||
display_name = getattr(option_obj, "display_name", option_key)
|
||||
try:
|
||||
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
|
||||
@@ -1384,59 +1410,59 @@ class Spoiler():
|
||||
with open(filename, 'w', encoding="utf-8-sig") as outfile:
|
||||
outfile.write(
|
||||
'Archipelago Version %s - Seed: %s\n\n' % (
|
||||
Utils.__version__, self.world.seed))
|
||||
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
|
||||
outfile.write('Players: %d\n' % self.world.players)
|
||||
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
|
||||
Utils.__version__, self.multiworld.seed))
|
||||
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
||||
outfile.write('Players: %d\n' % self.multiworld.players)
|
||||
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
||||
|
||||
for player in range(1, self.world.players + 1):
|
||||
if self.world.players > 1:
|
||||
outfile.write('\nPlayer %d: %s\n' % (player, self.world.get_player_name(player)))
|
||||
outfile.write('Game: %s\n' % self.world.game[player])
|
||||
for player in range(1, self.multiworld.players + 1):
|
||||
if self.multiworld.players > 1:
|
||||
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
||||
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
||||
for f_option, option in Options.per_game_common_options.items():
|
||||
write_option(f_option, option)
|
||||
options = self.world.worlds[player].option_definitions
|
||||
options = self.multiworld.worlds[player].option_definitions
|
||||
if options:
|
||||
for f_option, option in options.items():
|
||||
write_option(f_option, option)
|
||||
AutoWorld.call_single(self.world, "write_spoiler_header", player, outfile)
|
||||
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
|
||||
|
||||
if player in self.world.get_game_players("A Link to the Past"):
|
||||
if player in self.multiworld.get_game_players("A Link to the Past"):
|
||||
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
|
||||
|
||||
outfile.write('Logic: %s\n' % self.world.logic[player])
|
||||
outfile.write('Dark Room Logic: %s\n' % self.world.dark_room_logic[player])
|
||||
outfile.write('Mode: %s\n' % self.world.mode[player])
|
||||
outfile.write('Goal: %s\n' % self.world.goal[player])
|
||||
if "triforce" in self.world.goal[player]: # triforce hunt
|
||||
outfile.write('Logic: %s\n' % self.multiworld.logic[player])
|
||||
outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player])
|
||||
outfile.write('Mode: %s\n' % self.multiworld.mode[player])
|
||||
outfile.write('Goal: %s\n' % self.multiworld.goal[player])
|
||||
if "triforce" in self.multiworld.goal[player]: # triforce hunt
|
||||
outfile.write("Pieces available for Triforce: %s\n" %
|
||||
self.world.triforce_pieces_available[player])
|
||||
self.multiworld.triforce_pieces_available[player])
|
||||
outfile.write("Pieces required for Triforce: %s\n" %
|
||||
self.world.triforce_pieces_required[player])
|
||||
outfile.write('Difficulty: %s\n' % self.world.difficulty[player])
|
||||
outfile.write('Item Functionality: %s\n' % self.world.item_functionality[player])
|
||||
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
|
||||
if self.world.shuffle[player] != "vanilla":
|
||||
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
|
||||
self.multiworld.triforce_pieces_required[player])
|
||||
outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player])
|
||||
outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player])
|
||||
outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player])
|
||||
if self.multiworld.shuffle[player] != "vanilla":
|
||||
outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed)
|
||||
outfile.write('Shop inventory shuffle: %s\n' %
|
||||
bool_to_text("i" in self.world.shop_shuffle[player]))
|
||||
bool_to_text("i" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('Shop price shuffle: %s\n' %
|
||||
bool_to_text("p" in self.world.shop_shuffle[player]))
|
||||
bool_to_text("p" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('Shop upgrade shuffle: %s\n' %
|
||||
bool_to_text("u" in self.world.shop_shuffle[player]))
|
||||
bool_to_text("u" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('New Shop inventory: %s\n' %
|
||||
bool_to_text("g" in self.world.shop_shuffle[player] or
|
||||
"f" in self.world.shop_shuffle[player]))
|
||||
bool_to_text("g" in self.multiworld.shop_shuffle[player] or
|
||||
"f" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('Custom Potion Shop: %s\n' %
|
||||
bool_to_text("w" in self.world.shop_shuffle[player]))
|
||||
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
|
||||
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
|
||||
bool_to_text("w" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player])
|
||||
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
|
||||
outfile.write('Prize shuffle %s\n' %
|
||||
self.world.shuffle_prizes[player])
|
||||
self.multiworld.shuffle_prizes[player])
|
||||
if self.entrances:
|
||||
outfile.write('\n\nEntrances:\n\n')
|
||||
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.world.get_player_name(entry["player"])}: '
|
||||
if self.world.players > 1 else '', entry['entrance'],
|
||||
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: '
|
||||
if self.multiworld.players > 1 else '', entry['entrance'],
|
||||
'<=>' if entry['direction'] == 'both' else
|
||||
'<=' if entry['direction'] == 'exit' else '=>',
|
||||
entry['exit']) for entry in self.entrances.values()]))
|
||||
@@ -1446,7 +1472,7 @@ class Spoiler():
|
||||
for dungeon, medallion in self.medallions.items():
|
||||
outfile.write(f'\n{dungeon}: {medallion}')
|
||||
|
||||
AutoWorld.call_all(self.world, "write_spoiler", outfile)
|
||||
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
|
||||
|
||||
outfile.write('\n\nLocations:\n\n')
|
||||
outfile.write('\n'.join(
|
||||
@@ -1459,11 +1485,11 @@ class Spoiler():
|
||||
item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
|
||||
item)) for shop in self.shops))
|
||||
|
||||
for player in self.world.get_game_players("A Link to the Past"):
|
||||
if self.world.boss_shuffle[player] != 'none':
|
||||
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
|
||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
||||
if self.multiworld.boss_shuffle[player] != 'none':
|
||||
bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses
|
||||
outfile.write(
|
||||
f'\n\nBosses{(f" ({self.world.get_player_name(player)})" if self.world.players > 1 else "")}:\n')
|
||||
f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n')
|
||||
outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
|
||||
outfile.write('\n\nPlaythrough:\n\n')
|
||||
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
|
||||
@@ -1487,7 +1513,7 @@ class Spoiler():
|
||||
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
||||
|
||||
outfile.write('\n'.join(path_listings))
|
||||
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
|
||||
AutoWorld.call_all(self.multiworld, "write_spoiler_end", outfile)
|
||||
|
||||
|
||||
class Tutorial(NamedTuple):
|
||||
|
||||
161
CommonClient.py
161
CommonClient.py
@@ -20,10 +20,13 @@ if __name__ == "__main__":
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||
from Utils import Version, stream_input
|
||||
from Utils import Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import kvui
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal, we have to use gui mode
|
||||
@@ -44,16 +47,18 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
||||
if address:
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
elif not self.ctx.server_address:
|
||||
self.output("Please specify an address.")
|
||||
return False
|
||||
async_start(self.ctx.connect(address if address else None), name="connecting")
|
||||
return True
|
||||
|
||||
def _cmd_disconnect(self) -> bool:
|
||||
"""Disconnect from a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||
async_start(self.ctx.disconnect(), name="disconnecting")
|
||||
return True
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
@@ -91,12 +96,18 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_items(self):
|
||||
"""List all item names for the currently running game."""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine existing items.")
|
||||
return False
|
||||
self.output(f"Item Names for {self.ctx.game}")
|
||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||
self.output(item_name)
|
||||
|
||||
def _cmd_locations(self):
|
||||
"""List all location names for the currently running game."""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine existing locations.")
|
||||
return False
|
||||
self.output(f"Location Names for {self.ctx.game}")
|
||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||
self.output(location_name)
|
||||
@@ -110,12 +121,12 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
else:
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
self.output("Unreadied.")
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def default(self, raw: str):
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext:
|
||||
@@ -132,28 +143,36 @@ class CommonContext:
|
||||
# defaults
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: type(CommandProcessor) = ClientCommandProcessor
|
||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||
ui = None
|
||||
ui_task: typing.Optional[asyncio.Task] = None
|
||||
input_task: typing.Optional[asyncio.Task] = None
|
||||
keep_alive_task: typing.Optional[asyncio.Task] = None
|
||||
server_task: typing.Optional[asyncio.Task] = None
|
||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
server_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
disconnected_intentionally: bool = False
|
||||
server: typing.Optional[Endpoint] = None
|
||||
server_version: Version = Version(0, 0, 0)
|
||||
current_energy_link_value: int = 0 # to display in UI, gets set by server
|
||||
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
|
||||
|
||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# remaining type info
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
server_address: str
|
||||
server_address: typing.Optional[str]
|
||||
password: typing.Optional[str]
|
||||
hint_cost: typing.Optional[int]
|
||||
player_names: typing.Dict[int, str]
|
||||
|
||||
finished_game: bool
|
||||
ready: bool
|
||||
auth: typing.Optional[str]
|
||||
seed_name: typing.Optional[str]
|
||||
|
||||
# locations
|
||||
locations_checked: typing.Set[int] # local state
|
||||
locations_scouted: typing.Set[int]
|
||||
items_received: typing.List[NetworkItem]
|
||||
missing_locations: typing.Set[int] # server state
|
||||
checked_locations: typing.Set[int] # server state
|
||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||
@@ -161,9 +180,11 @@ class CommonContext:
|
||||
|
||||
# internals
|
||||
# current message box through kvui
|
||||
_messagebox = None
|
||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||
# message box reporting a loss of connection
|
||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
self.username = None
|
||||
@@ -206,6 +227,12 @@ class CommonContext:
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
|
||||
@property
|
||||
def suggested_address(self) -> str:
|
||||
if self.server_address:
|
||||
return self.server_address
|
||||
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
|
||||
|
||||
@functools.cached_property
|
||||
def raw_text_parser(self) -> RawJSONtoTextParser:
|
||||
return RawJSONtoTextParser(self)
|
||||
@@ -217,9 +244,9 @@ class CommonContext:
|
||||
return len(self.checked_locations | self.missing_locations)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.reset_server_state()
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
self.reset_server_state()
|
||||
|
||||
def reset_server_state(self):
|
||||
self.auth = None
|
||||
@@ -237,13 +264,18 @@ class CommonContext:
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
async def disconnect(self):
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
if not allow_autoreconnect:
|
||||
self.disconnected_intentionally = True
|
||||
if self.cancel_autoreconnect():
|
||||
logger.info("Cancelled auto-reconnect.")
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task is not None:
|
||||
await self.server_task
|
||||
|
||||
async def send_msgs(self, msgs):
|
||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||
""" `msgs` JSON serializable """
|
||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||
return
|
||||
await self.server.socket.send(encode(msgs))
|
||||
@@ -271,7 +303,8 @@ class CommonContext:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||
""" send `Connect` packet to log in to server """
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
@@ -282,14 +315,24 @@ class CommonContext:
|
||||
payload.update(kwargs)
|
||||
await self.send_msgs([payload])
|
||||
|
||||
async def console_input(self):
|
||||
async def console_input(self) -> str:
|
||||
if self.ui:
|
||||
self.ui.focus_textinput()
|
||||
self.input_requests += 1
|
||||
return await self.input_queue.get()
|
||||
|
||||
async def connect(self, address=None):
|
||||
async def connect(self, address: typing.Optional[str] = None) -> None:
|
||||
""" disconnect any previous connection, and open new connection to the server """
|
||||
await self.disconnect()
|
||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||
|
||||
def cancel_autoreconnect(self) -> bool:
|
||||
if self.autoreconnect_task:
|
||||
self.autoreconnect_task.cancel()
|
||||
self.autoreconnect_task = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def slot_concerns_self(self, slot) -> bool:
|
||||
if slot == self.slot:
|
||||
return True
|
||||
@@ -297,6 +340,12 @@ class CommonContext:
|
||||
return self.slot in self.slot_info[slot].group_members
|
||||
return False
|
||||
|
||||
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
|
||||
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
|
||||
return print_json_packet.get("type", "") == "ItemSend" \
|
||||
and not self.slot_concerns_self(print_json_packet["receiving"]) \
|
||||
and not self.slot_concerns_self(print_json_packet["item"].player)
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
|
||||
@@ -328,6 +377,7 @@ class CommonContext:
|
||||
async def shutdown(self):
|
||||
self.server_address = ""
|
||||
self.username = None
|
||||
self.cancel_autoreconnect()
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task:
|
||||
@@ -390,7 +440,7 @@ class CommonContext:
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||
self.last_death_link = max(data["time"], self.last_death_link)
|
||||
text = data.get("cause", "")
|
||||
@@ -421,10 +471,10 @@ class CommonContext:
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
def gui_error(self, title: str, text: typing.Union[Exception, str]):
|
||||
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
||||
"""Displays an error messagebox"""
|
||||
if not self.ui:
|
||||
return
|
||||
return None
|
||||
title = title or "Error"
|
||||
from kvui import MessageBox
|
||||
if self._messagebox:
|
||||
@@ -441,6 +491,13 @@ class CommonContext:
|
||||
# display error
|
||||
self._messagebox = MessageBox(title, text, error=True)
|
||||
self._messagebox.open()
|
||||
return self._messagebox
|
||||
|
||||
def _handle_connection_loss(self, msg: str) -> None:
|
||||
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
|
||||
exc_info = sys.exc_info()
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
@@ -477,7 +534,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
||||
seconds_elapsed = 0
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None:
|
||||
if ctx.server and ctx.server.socket:
|
||||
logger.error('Already connected')
|
||||
return
|
||||
@@ -490,6 +547,11 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
logger.info('Please connect to an Archipelago server.')
|
||||
return
|
||||
|
||||
ctx.cancel_autoreconnect()
|
||||
if ctx._messagebox_connection_loss:
|
||||
ctx._messagebox_connection_loss.dismiss()
|
||||
ctx._messagebox_connection_loss = None
|
||||
|
||||
address = f"ws://{address}" if "://" not in address \
|
||||
else address.replace("archipelago://", "ws://")
|
||||
|
||||
@@ -500,6 +562,9 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
ctx.password = server_url.password
|
||||
port = server_url.port or 38281
|
||||
|
||||
def reconnect_hint() -> str:
|
||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||
@@ -509,31 +574,25 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
logger.info('Connected')
|
||||
ctx.server_address = address
|
||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||
ctx.disconnected_intentionally = False
|
||||
async for data in ctx.server.socket:
|
||||
for msg in decode(data):
|
||||
await process_server_cmd(ctx, msg)
|
||||
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
||||
except ConnectionRefusedError as e:
|
||||
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except websockets.InvalidURI as e:
|
||||
msg = 'Failed to connect to the multiworld server (invalid URI)'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except OSError as e:
|
||||
msg = 'Failed to connect to the multiworld server'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except Exception as e:
|
||||
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
|
||||
except ConnectionRefusedError:
|
||||
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.")
|
||||
except websockets.InvalidURI:
|
||||
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||
except OSError:
|
||||
ctx._handle_connection_loss("Failed to connect to the multiworld server")
|
||||
except Exception:
|
||||
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address:
|
||||
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
||||
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
|
||||
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
|
||||
assert ctx.autoreconnect_task is None
|
||||
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||
ctx.current_reconnect_delay *= 2
|
||||
|
||||
|
||||
@@ -644,6 +703,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.checked_locations = set(args["checked_locations"])
|
||||
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
||||
|
||||
server_url = urllib.parse.urlparse(ctx.server_address)
|
||||
Utils.persistent_store("client", "last_server_address", server_url.netloc)
|
||||
|
||||
elif cmd == 'ReceivedItems':
|
||||
start_index = args["index"]
|
||||
|
||||
@@ -722,7 +784,7 @@ async def console_loop(ctx: CommonContext):
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
def get_base_parser(description=None):
|
||||
def get_base_parser(description: typing.Optional[str] = None):
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
@@ -754,7 +816,6 @@ if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
ctx.auth = args.name
|
||||
ctx.server_address = args.connect
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
|
||||
if gui_enabled:
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
@@ -69,7 +70,7 @@ class FF1Context(CommonContext):
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
||||
async_start(parse_locations(self.locations_array, self, True))
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
@@ -180,7 +181,7 @@ async def nes_sync_task(ctx: FF1Context):
|
||||
# print(data_decoded)
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
|
||||
@@ -4,9 +4,12 @@ import logging
|
||||
import json
|
||||
import string
|
||||
import copy
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
import typing
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
@@ -17,12 +20,18 @@ import asyncio
|
||||
from queue import Queue
|
||||
import Utils
|
||||
|
||||
def check_stdin() -> None:
|
||||
if Utils.is_windows and sys.stdin:
|
||||
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
check_stdin()
|
||||
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
||||
from MultiServer import mark_raw
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
from Utils import async_start
|
||||
|
||||
from worlds.factorio import Factorio
|
||||
|
||||
@@ -30,6 +39,10 @@ from worlds.factorio import Factorio
|
||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
ctx: FactorioContext
|
||||
|
||||
def _cmd_energy_link(self):
|
||||
"""Print the status of the energy link."""
|
||||
self.output(f"Energy Link: {self.ctx.energy_link_status}")
|
||||
|
||||
@mark_raw
|
||||
def _cmd_factorio(self, text: str) -> bool:
|
||||
"""Send the following command to the bound Factorio Server."""
|
||||
@@ -46,6 +59,13 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
"""Manually trigger a resync."""
|
||||
self.ctx.awaiting_bridge = True
|
||||
|
||||
def _cmd_toggle_send_filter(self):
|
||||
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
|
||||
self.ctx.toggle_filter_item_sends()
|
||||
|
||||
def _cmd_toggle_chat(self):
|
||||
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
|
||||
self.ctx.toggle_bridge_chat_out()
|
||||
|
||||
class FactorioContext(CommonContext):
|
||||
command_processor = FactorioCommandProcessor
|
||||
@@ -65,6 +85,9 @@ class FactorioContext(CommonContext):
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
self.energy_link_increment = 0
|
||||
self.last_deplete = 0
|
||||
self.filter_item_sends: bool = False
|
||||
self.multiplayer: bool = False # whether multiple different players have connected
|
||||
self.bridge_chat_out: bool = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -81,12 +104,15 @@ class FactorioContext(CommonContext):
|
||||
def on_print(self, args: dict):
|
||||
super(FactorioContext, self).on_print(args)
|
||||
if self.rcon_client:
|
||||
self.print_to_game(args['text'])
|
||||
if not args['text'].startswith(self.player_names[self.slot] + ":"):
|
||||
self.print_to_game(args['text'])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.rcon_client:
|
||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||
self.print_to_game(text)
|
||||
if not self.filter_item_sends or not self.is_uninteresting_item_send(args):
|
||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||
if not text.startswith(self.player_names[self.slot] + ":"):
|
||||
self.print_to_game(text)
|
||||
super(FactorioContext, self).on_print_json(args)
|
||||
|
||||
@property
|
||||
@@ -97,6 +123,15 @@ class FactorioContext(CommonContext):
|
||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
@property
|
||||
def energy_link_status(self) -> str:
|
||||
if not self.energy_link_increment:
|
||||
return "Disabled"
|
||||
elif self.current_energy_link_value is None:
|
||||
return "Standby"
|
||||
else:
|
||||
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
if self.rcon_client:
|
||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||
@@ -109,7 +144,7 @@ class FactorioContext(CommonContext):
|
||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||
item_name in args["checked_locations"]})
|
||||
if cmd == "Connected" and self.energy_link_increment:
|
||||
asyncio.create_task(self.send_msgs([{
|
||||
async_start(self.send_msgs([{
|
||||
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
||||
}]))
|
||||
elif cmd == "SetReply":
|
||||
@@ -123,6 +158,45 @@ class FactorioContext(CommonContext):
|
||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||
|
||||
def on_user_say(self, text: str) -> typing.Optional[str]:
|
||||
# Mirror chat sent from the UI to the Factorio server.
|
||||
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
|
||||
return text
|
||||
|
||||
async def chat_from_factorio(self, user: str, message: str) -> None:
|
||||
if not self.bridge_chat_out:
|
||||
return
|
||||
|
||||
# Pass through commands
|
||||
if message.startswith("!"):
|
||||
await self.send_msgs([{"cmd": "Say", "text": message}])
|
||||
return
|
||||
|
||||
# Omit messages that contain local coordinates
|
||||
if "[gps=" in message:
|
||||
return
|
||||
|
||||
prefix = f"({user}) " if self.multiplayer else ""
|
||||
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
|
||||
|
||||
def toggle_filter_item_sends(self) -> None:
|
||||
self.filter_item_sends = not self.filter_item_sends
|
||||
if self.filter_item_sends:
|
||||
announcement = "Item sends are now filtered."
|
||||
else:
|
||||
announcement = "Item sends are no longer filtered."
|
||||
logger.info(announcement)
|
||||
self.print_to_game(announcement)
|
||||
|
||||
def toggle_bridge_chat_out(self) -> None:
|
||||
self.bridge_chat_out = not self.bridge_chat_out
|
||||
if self.bridge_chat_out:
|
||||
announcement = "Chat is now bridged to Archipelago."
|
||||
else:
|
||||
announcement = "Chat is no longer bridged to Archipelago."
|
||||
logger.info(announcement)
|
||||
self.print_to_game(announcement)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
@@ -140,7 +214,6 @@ class FactorioContext(CommonContext):
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
next_bridge = time.perf_counter() + 1
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
@@ -162,6 +235,7 @@ async def game_watcher(ctx: FactorioContext):
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
ctx.multiplayer = data.get("multiplayer", False)
|
||||
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
@@ -170,14 +244,14 @@ async def game_watcher(ctx: FactorioContext):
|
||||
if ctx.locations_checked != research_data:
|
||||
bridge_logger.debug(
|
||||
f"New researches done: "
|
||||
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
|
||||
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}")
|
||||
ctx.locations_checked = research_data
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||
death_link_tick = data.get("death_link_tick", 0)
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
if "DeathLink" in ctx.tags:
|
||||
asyncio.create_task(ctx.send_death())
|
||||
async_start(ctx.send_death())
|
||||
if ctx.energy_link_increment:
|
||||
in_world_bridges = data["energy_bridges"]
|
||||
if in_world_bridges:
|
||||
@@ -185,7 +259,7 @@ async def game_watcher(ctx: FactorioContext):
|
||||
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
||||
# attempt to refill
|
||||
ctx.last_deplete = time.time()
|
||||
asyncio.create_task(ctx.send_msgs([{
|
||||
async_start(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
||||
{"operation": "max", "value": 0}],
|
||||
@@ -195,7 +269,7 @@ async def game_watcher(ctx: FactorioContext):
|
||||
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
||||
ctx.energy_link_increment*in_world_bridges:
|
||||
value = ctx.energy_link_increment * in_world_bridges
|
||||
asyncio.create_task(ctx.send_msgs([{
|
||||
async_start(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": value}]
|
||||
}]))
|
||||
@@ -211,6 +285,8 @@ async def game_watcher(ctx: FactorioContext):
|
||||
|
||||
|
||||
def stream_factorio_output(pipe, queue, process):
|
||||
pipe.reconfigure(errors="replace")
|
||||
|
||||
def queuer():
|
||||
while process.poll() is None:
|
||||
text = pipe.readline().strip()
|
||||
@@ -243,7 +319,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
if factorio_process.poll():
|
||||
if factorio_process.poll() is not None:
|
||||
factorio_server_logger.info("Factorio server has exited.")
|
||||
ctx.exit_event.set()
|
||||
|
||||
@@ -256,12 +332,25 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
if not ctx.server:
|
||||
logger.info("Established bridge to Factorio Server. "
|
||||
"Ready to connect to Archipelago via /connect")
|
||||
check_stdin()
|
||||
|
||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||
ctx.awaiting_bridge = True
|
||||
factorio_server_logger.debug(msg)
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.toggle_filter_item_sends()
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.toggle_bridge_chat_out()
|
||||
else:
|
||||
factorio_server_logger.info(msg)
|
||||
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
|
||||
if match:
|
||||
await ctx.chat_from_factorio(match.group(1), match.group(2))
|
||||
if ctx.rcon_client:
|
||||
commands = {}
|
||||
while ctx.send_index < len(ctx.items_received):
|
||||
@@ -282,12 +371,34 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
ctx.rcon_client = None
|
||||
ctx.exit_event.set()
|
||||
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
if factorio_process.poll() is not None:
|
||||
if ctx.rcon_client:
|
||||
ctx.rcon_client.close()
|
||||
ctx.rcon_client = None
|
||||
return
|
||||
|
||||
sent_quit = False
|
||||
if ctx.rcon_client:
|
||||
# Attempt clean quit through RCON.
|
||||
try:
|
||||
ctx.rcon_client.send_command("/quit")
|
||||
except factorio_rcon.RCONNetworkError:
|
||||
pass
|
||||
else:
|
||||
sent_quit = True
|
||||
ctx.rcon_client.close()
|
||||
ctx.rcon_client = None
|
||||
if not sent_quit:
|
||||
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
|
||||
factorio_process.terminate()
|
||||
|
||||
try:
|
||||
factorio_process.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
factorio_process.kill()
|
||||
|
||||
|
||||
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
||||
@@ -361,6 +472,8 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
|
||||
async def main(args):
|
||||
ctx = FactorioContext(args.connect, args.password)
|
||||
ctx.filter_item_sends = initial_filter_item_sends
|
||||
ctx.bridge_chat_out = initial_bridge_chat_out
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
@@ -413,6 +526,12 @@ if __name__ == '__main__':
|
||||
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||
if server_settings:
|
||||
server_settings = os.path.abspath(server_settings)
|
||||
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
|
||||
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
|
||||
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
|
||||
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
|
||||
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
|
||||
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
|
||||
|
||||
if not os.path.exists(os.path.dirname(executable)):
|
||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||
|
||||
320
Fill.py
320
Fill.py
@@ -4,9 +4,10 @@ import collections
|
||||
import itertools
|
||||
from collections import Counter, deque
|
||||
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification
|
||||
|
||||
from worlds.AutoWorld import call_all
|
||||
from worlds.generic.Rules import add_item_rule
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
@@ -22,7 +23,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
|
||||
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
|
||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
|
||||
@@ -69,60 +71,66 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
|
||||
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
|
||||
swap_count = swapped_items[placed_item.player,
|
||||
placed_item.name]
|
||||
if swap_count > 1:
|
||||
if swap:
|
||||
# try swapping this item with previously placed items
|
||||
for (i, location) in enumerate(placements):
|
||||
placed_item = location.item
|
||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||
# number of times we will swap an individual item to prevent this
|
||||
swap_count = swapped_items[placed_item.player,
|
||||
placed_item.name]
|
||||
if swap_count > 1:
|
||||
continue
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
swap_state = sweep_from_pool(base_state, [placed_item])
|
||||
# swap_state assumes we can collect placed item before item_to_place
|
||||
if (not single_player_placement or location.player == item_to_place.player) \
|
||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||
|
||||
# Verify that placing this item won't reduce available locations, which could happen with rules
|
||||
# that want to not have both items. Left in until removal is proven useful.
|
||||
prev_state = swap_state.copy()
|
||||
prev_loc_count = len(
|
||||
world.get_reachable_locations(prev_state))
|
||||
|
||||
swap_state.collect(item_to_place, True)
|
||||
new_loc_count = len(
|
||||
world.get_reachable_locations(swap_state))
|
||||
|
||||
if new_loc_count >= prev_loc_count:
|
||||
# Add this item to the existing placement, and
|
||||
# add the old item to the back of the queue
|
||||
spot_to_fill = placements.pop(i)
|
||||
|
||||
swap_count += 1
|
||||
swapped_items[placed_item.player,
|
||||
placed_item.name] = swap_count
|
||||
|
||||
reachable_items[placed_item.player].appendleft(
|
||||
placed_item)
|
||||
itempool.append(placed_item)
|
||||
|
||||
break
|
||||
|
||||
# Item can't be placed here, restore original item
|
||||
location.item = placed_item
|
||||
placed_item.location = location
|
||||
|
||||
if spot_to_fill is None:
|
||||
# Can't place this item, move on to the next
|
||||
unplaced_items.append(item_to_place)
|
||||
continue
|
||||
|
||||
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
|
||||
else:
|
||||
unplaced_items.append(item_to_place)
|
||||
continue
|
||||
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
spot_to_fill.locked = lock
|
||||
placements.append(spot_to_fill)
|
||||
spot_to_fill.event = item_to_place.advancement
|
||||
if on_place:
|
||||
on_place(spot_to_fill)
|
||||
|
||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
@@ -209,12 +217,121 @@ def fast_fill(world: MultiWorld,
|
||||
return item_pool[placing:], fill_locations[placing:]
|
||||
|
||||
|
||||
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
|
||||
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
|
||||
not location.can_reach(maximum_exploration_state)]
|
||||
for location in unreachable_locations:
|
||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||
location.locked and location.item.player not in minimal_players):
|
||||
pool.append(location.item)
|
||||
state.remove(location.item)
|
||||
location.item = None
|
||||
location.event = False
|
||||
if location in state.events:
|
||||
state.events.remove(location)
|
||||
locations.append(location)
|
||||
if pool and locations:
|
||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||
fill_restrictive(world, state, locations, pool)
|
||||
|
||||
|
||||
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
|
||||
maximum_exploration_state = sweep_from_pool(state)
|
||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||
if unreachable_locations:
|
||||
def forbid_important_item_rule(item: Item):
|
||||
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
|
||||
|
||||
for location in unreachable_locations:
|
||||
add_item_rule(location, forbid_important_item_rule)
|
||||
|
||||
|
||||
def distribute_early_items(world: MultiWorld,
|
||||
fill_locations: typing.List[Location],
|
||||
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
||||
""" returns new fill_locations and itempool """
|
||||
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
|
||||
for player in world.player_ids:
|
||||
items = itertools.chain(world.early_items[player], world.local_early_items[player])
|
||||
for item in items:
|
||||
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
|
||||
if early_items_count:
|
||||
early_locations: typing.List[Location] = []
|
||||
early_priority_locations: typing.List[Location] = []
|
||||
loc_indexes_to_remove: typing.Set[int] = set()
|
||||
base_state = world.state.copy()
|
||||
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
|
||||
for i, loc in enumerate(fill_locations):
|
||||
if loc.can_reach(base_state):
|
||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||
early_priority_locations.append(loc)
|
||||
else:
|
||||
early_locations.append(loc)
|
||||
loc_indexes_to_remove.add(i)
|
||||
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
|
||||
|
||||
early_prog_items: typing.List[Item] = []
|
||||
early_rest_items: typing.List[Item] = []
|
||||
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
||||
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
||||
item_indexes_to_remove: typing.Set[int] = set()
|
||||
for i, item in enumerate(itempool):
|
||||
if (item.name, item.player) in early_items_count:
|
||||
if item.advancement:
|
||||
if early_items_count[(item.name, item.player)][1]:
|
||||
early_local_prog_items[item.player].append(item)
|
||||
early_items_count[(item.name, item.player)][1] -= 1
|
||||
else:
|
||||
early_prog_items.append(item)
|
||||
early_items_count[(item.name, item.player)][0] -= 1
|
||||
else:
|
||||
if early_items_count[(item.name, item.player)][1]:
|
||||
early_local_rest_items[item.player].append(item)
|
||||
early_items_count[(item.name, item.player)][1] -= 1
|
||||
else:
|
||||
early_rest_items.append(item)
|
||||
early_items_count[(item.name, item.player)][0] -= 1
|
||||
item_indexes_to_remove.add(i)
|
||||
if early_items_count[(item.name, item.player)] == [0, 0]:
|
||||
del early_items_count[(item.name, item.player)]
|
||||
if len(early_items_count) == 0:
|
||||
break
|
||||
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
||||
for player in world.player_ids:
|
||||
fill_restrictive(world, base_state,
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
early_local_rest_items[player], lock=True)
|
||||
early_locations = [loc for loc in early_locations if not loc.item]
|
||||
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True)
|
||||
early_locations += early_priority_locations
|
||||
for player in world.player_ids:
|
||||
fill_restrictive(world, base_state,
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
early_local_prog_items[player], lock=True)
|
||||
early_locations = [loc for loc in early_locations if not loc.item]
|
||||
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True)
|
||||
unplaced_early_items = early_rest_items + early_prog_items
|
||||
if unplaced_early_items:
|
||||
logging.warning("Ran out of early locations for early items. Failed to place "
|
||||
f"{len(unplaced_early_items)} items early.")
|
||||
itempool += unplaced_early_items
|
||||
|
||||
fill_locations.extend(early_locations)
|
||||
world.random.shuffle(fill_locations)
|
||||
return fill_locations, itempool
|
||||
|
||||
|
||||
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
fill_locations = sorted(world.get_unfilled_locations())
|
||||
world.random.shuffle(fill_locations)
|
||||
# get items to distribute
|
||||
itempool = sorted(world.itempool)
|
||||
world.random.shuffle(itempool)
|
||||
|
||||
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
|
||||
|
||||
progitempool: typing.List[Item] = []
|
||||
usefulitempool: typing.List[Item] = []
|
||||
filleritempool: typing.List[Item] = []
|
||||
@@ -239,15 +356,33 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
|
||||
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
|
||||
lock_later = []
|
||||
|
||||
def mark_for_locking(location: Location):
|
||||
nonlocal lock_later
|
||||
lock_later.append(location)
|
||||
|
||||
if prioritylocations:
|
||||
# "priority fill"
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
|
||||
accessibility_corrections(world, world.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
if progitempool:
|
||||
# "progression fill"
|
||||
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||
accessibility_corrections(world, world.state, defaultlocations)
|
||||
|
||||
for location in lock_later:
|
||||
if location.item:
|
||||
location.locked = True
|
||||
del mark_for_locking, lock_later
|
||||
|
||||
inaccessible_location_rules(world, world.state, defaultlocations)
|
||||
|
||||
remaining_fill(world, excludedlocations, filleritempool)
|
||||
if excludedlocations:
|
||||
@@ -548,6 +683,17 @@ def distribute_planned(world: MultiWorld) -> None:
|
||||
else:
|
||||
warn(warning, force)
|
||||
|
||||
swept_state = world.state.copy()
|
||||
swept_state.sweep_for_events()
|
||||
reachable = frozenset(world.get_reachable_locations(swept_state))
|
||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||
for loc in world.get_unfilled_locations():
|
||||
if loc in reachable:
|
||||
early_locations[loc.player].append(loc.name)
|
||||
else: # not reachable with swept state
|
||||
non_early_locations[loc.player].append(loc.name)
|
||||
|
||||
# TODO: remove. Preferably by implementing key drop
|
||||
from worlds.alttp.Regions import key_drop_data
|
||||
world_name_lookup = world.world_name_lookup
|
||||
@@ -563,7 +709,39 @@ def distribute_planned(world: MultiWorld) -> None:
|
||||
if 'from_pool' not in block:
|
||||
block['from_pool'] = True
|
||||
if 'world' not in block:
|
||||
block['world'] = False
|
||||
target_world = False
|
||||
else:
|
||||
target_world = block['world']
|
||||
|
||||
if target_world is False or world.players == 1: # target own world
|
||||
worlds: typing.Set[int] = {player}
|
||||
elif target_world is True: # target any worlds besides own
|
||||
worlds = set(world.player_ids) - {player}
|
||||
elif target_world is None: # target all worlds
|
||||
worlds = set(world.player_ids)
|
||||
elif type(target_world) == list: # list of target worlds
|
||||
worlds = set()
|
||||
for listed_world in target_world:
|
||||
if listed_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
block['force'])
|
||||
continue
|
||||
worlds.add(world_name_lookup[listed_world])
|
||||
elif type(target_world) == int: # target world by slot number
|
||||
if target_world not in range(1, world.players + 1):
|
||||
failed(
|
||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||
block['force'])
|
||||
continue
|
||||
worlds = {target_world}
|
||||
else: # target world by slot name
|
||||
if target_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
block['force'])
|
||||
continue
|
||||
worlds = {world_name_lookup[target_world]}
|
||||
block['world'] = worlds
|
||||
|
||||
items: block_value = []
|
||||
if "items" in block:
|
||||
items = block["items"]
|
||||
@@ -600,6 +778,17 @@ def distribute_planned(world: MultiWorld) -> None:
|
||||
for key, value in locations.items():
|
||||
location_list += [key] * value
|
||||
locations = location_list
|
||||
|
||||
if "early_locations" in locations:
|
||||
locations.remove("early_locations")
|
||||
for player in worlds:
|
||||
locations += early_locations[player]
|
||||
if "non_early_locations" in locations:
|
||||
locations.remove("non_early_locations")
|
||||
for player in worlds:
|
||||
locations += non_early_locations[player]
|
||||
|
||||
|
||||
block['locations'] = locations
|
||||
|
||||
if not block['count']:
|
||||
@@ -635,38 +824,11 @@ def distribute_planned(world: MultiWorld) -> None:
|
||||
for placement in plando_blocks:
|
||||
player = placement['player']
|
||||
try:
|
||||
target_world = placement['world']
|
||||
worlds = placement['world']
|
||||
locations = placement['locations']
|
||||
items = placement['items']
|
||||
maxcount = placement['count']['target']
|
||||
from_pool = placement['from_pool']
|
||||
if target_world is False or world.players == 1: # target own world
|
||||
worlds: typing.Set[int] = {player}
|
||||
elif target_world is True: # target any worlds besides own
|
||||
worlds = set(world.player_ids) - {player}
|
||||
elif target_world is None: # target all worlds
|
||||
worlds = set(world.player_ids)
|
||||
elif type(target_world) == list: # list of target worlds
|
||||
worlds = set()
|
||||
for listed_world in target_world:
|
||||
if listed_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
placement['force'])
|
||||
continue
|
||||
worlds.add(world_name_lookup[listed_world])
|
||||
elif type(target_world) == int: # target world by slot number
|
||||
if target_world not in range(1, world.players + 1):
|
||||
failed(
|
||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||
placement['force'])
|
||||
continue
|
||||
worlds = {target_world}
|
||||
else: # target world by slot name
|
||||
if target_world not in world_name_lookup:
|
||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
placement['force'])
|
||||
continue
|
||||
worlds = {world_name_lookup[target_world]}
|
||||
|
||||
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
|
||||
worlds))
|
||||
|
||||
25
Generate.py
25
Generate.py
@@ -154,11 +154,12 @@ def main(args=None, callback=ERmain):
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||
for filename, yaml_data in weights_cache.items():
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
|
||||
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: "
|
||||
@@ -232,8 +233,8 @@ def main(args=None, callback=ERmain):
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
@@ -316,11 +317,11 @@ class SafeDict(dict):
|
||||
|
||||
|
||||
def handle_name(name: str, player: int, name_counter: Counter):
|
||||
name_counter[name] += 1
|
||||
name_counter[name.lower()] += 1
|
||||
number = name_counter[name.lower()]
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||
NUMBER=(name_counter[name] if name_counter[
|
||||
name] > 1 else ''),
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
||||
NUMBER=(number if number > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
new_name = new_name.strip()[:16]
|
||||
@@ -377,7 +378,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||
if option_key in options:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
return options[option_key]
|
||||
return category_dict[option_key]
|
||||
if game == "A Link to the Past": # TODO wow i hate this
|
||||
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||
|
||||
@@ -132,7 +132,7 @@ components: Iterable[Component] = (
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
Component('SNI Client', 'SNIClient',
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
@@ -145,10 +145,15 @@ components: Iterable[Component] = (
|
||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||
# FF1
|
||||
Component('FF1 Client', 'FF1Client'),
|
||||
# Pokémon
|
||||
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
|
||||
# ChecksFinder
|
||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||
# Starcraft 2
|
||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||
# Zillion
|
||||
Component('Zillion Client', 'ZillionClient',
|
||||
file_identifier=SuffixIdentifier('.apzl')),
|
||||
# Functions
|
||||
Component('Open host.yaml', func=open_host_yaml),
|
||||
Component('Open Patch', func=open_patch),
|
||||
|
||||
@@ -26,7 +26,9 @@ ModuleUpdate.update()
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||
get_adjuster_settings, tkinter_center_window, init_logging
|
||||
from Patch import GAME_ALTTP
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
@@ -139,7 +141,7 @@ def adjust(args):
|
||||
vanillaRom = args.baserom
|
||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||
vanillaRom = local_path(vanillaRom)
|
||||
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
||||
if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
|
||||
import Patch
|
||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||
|
||||
@@ -195,7 +197,7 @@ def adjustGUI():
|
||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||
|
||||
def RomSelect2():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")])
|
||||
romVar2.set(rom)
|
||||
|
||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||
@@ -725,7 +727,7 @@ def get_rom_options_frame(parent=None):
|
||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||
autoApplyFrame = Frame(romOptionsFrame)
|
||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
|
||||
filler.pack(side=TOP, expand=True, fill=X)
|
||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
55
Main.py
55
Main.py
@@ -8,15 +8,15 @@ import concurrent.futures
|
||||
import pickle
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, Tuple, Optional, Set
|
||||
from typing import Dict, List, Tuple, Optional, Set
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||
from worlds.alttp.Items import item_name_groups
|
||||
from worlds.alttp.Regions import is_main_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||
from worlds import AutoWorld
|
||||
|
||||
ordered_areas = (
|
||||
@@ -80,15 +80,30 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
logger.info("Found World Types:")
|
||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||
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():
|
||||
if not cls.hidden:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
||||
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}}) | "
|
||||
f"{len(cls.location_names):3} "
|
||||
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||
f"{max(cls.location_id_to_name):{numlength}})")
|
||||
if not cls.hidden and len(cls.item_names) > 0:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
||||
f"{len(cls.location_names):{location_count}} "
|
||||
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
||||
f"{max(cls.location_id_to_name):{location_digits}})")
|
||||
|
||||
del item_digits, location_digits, item_count, location_count
|
||||
|
||||
AutoWorld.call_stage(world, "assert_generate")
|
||||
|
||||
@@ -107,7 +122,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].value.add('Triforce Piece')
|
||||
|
||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||
# Not possible to place pendants/crystals outside boss prizes yet.
|
||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||
|
||||
@@ -122,9 +137,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
logger.info('Calculating Access Rules.')
|
||||
if world.players > 1:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
group_locality_rules(world)
|
||||
locality_rules(world)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
@@ -141,8 +154,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in world.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
||||
classifications = collections.defaultdict(int)
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||
]:
|
||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in world.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
@@ -152,7 +167,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del(counters[player])
|
||||
del (counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
@@ -164,14 +179,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del(counters[player][item])
|
||||
del (counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool = []
|
||||
new_itempool: List[Item] = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
|
||||
@@ -13,10 +13,12 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
# skip .* (hidden / disabled) folders
|
||||
if not entry.name.startswith("."):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
|
||||
|
||||
def update_command():
|
||||
@@ -37,11 +39,25 @@ def update(yes=False, force=False):
|
||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||
with open(path) as requirementsfile:
|
||||
for line in requirementsfile:
|
||||
if line.startswith('https://'):
|
||||
# extract name and version from url
|
||||
wheel = line.split('/')[-1]
|
||||
name, version, _ = wheel.split('-', 2)
|
||||
line = f'{name}=={version}'
|
||||
if line.startswith(("https://", "git+https://")):
|
||||
# extract name and version for url
|
||||
rest = line.split('/')[-1]
|
||||
line = ""
|
||||
if "#egg=" in rest:
|
||||
# from egg info
|
||||
rest, egg = rest.split("#egg=", 1)
|
||||
egg = egg.split(";", 1)[0]
|
||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
||||
line = egg
|
||||
else:
|
||||
egg = ""
|
||||
if "@" in rest and not line:
|
||||
raise ValueError("Can't deduce version from requirement")
|
||||
elif not line:
|
||||
# from filename
|
||||
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
|
||||
name, version, _ = rest.split("-", 2)
|
||||
line = f'{egg or name}=={version}'
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
for requirement in requirements:
|
||||
requirement = str(requirement)
|
||||
|
||||
110
MultiServer.py
110
MultiServer.py
@@ -31,7 +31,7 @@ except ImportError:
|
||||
|
||||
import NetUtils
|
||||
import Utils
|
||||
from Utils import version_tuple, restricted_loads, Version
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType
|
||||
|
||||
@@ -273,16 +273,16 @@ class Context:
|
||||
def broadcast_all(self, msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
||||
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
|
||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
||||
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
|
||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
|
||||
async def disconnect(self, endpoint: Client):
|
||||
if endpoint in self.endpoints:
|
||||
@@ -302,18 +302,18 @@ class Context:
|
||||
return
|
||||
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||
if client.version >= print_command_compatability_threshold:
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
|
||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
|
||||
else:
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
||||
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
||||
|
||||
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
||||
if not client.auth:
|
||||
return
|
||||
if client.version >= print_command_compatability_threshold:
|
||||
asyncio.create_task(self.send_msgs(client,
|
||||
async_start(self.send_msgs(client,
|
||||
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
|
||||
else:
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
||||
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
||||
|
||||
# loading
|
||||
|
||||
@@ -627,7 +627,7 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
|
||||
continue
|
||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
||||
for client in clients:
|
||||
asyncio.create_task(ctx.send_msgs(client, client_hints))
|
||||
async_start(ctx.send_msgs(client, client_hints))
|
||||
|
||||
|
||||
def update_aliases(ctx: Context, team: int):
|
||||
@@ -636,7 +636,7 @@ def update_aliases(ctx: Context, team: int):
|
||||
|
||||
for clients in ctx.clients[team].values():
|
||||
for client in clients:
|
||||
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
|
||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
||||
|
||||
|
||||
async def server(websocket, path: str = "/", ctx: Context = None):
|
||||
@@ -814,7 +814,7 @@ def send_new_items(ctx: Context):
|
||||
items = get_received_items(ctx, team, slot, client.remote_items)
|
||||
if len(start_inventory) + len(items) > client.send_index:
|
||||
first_new_item = max(0, client.send_index - len(start_inventory))
|
||||
asyncio.create_task(ctx.send_msgs(client, [{
|
||||
async_start(ctx.send_msgs(client, [{
|
||||
"cmd": "ReceivedItems",
|
||||
"index": client.send_index,
|
||||
"items": start_inventory[client.send_index:] + items[first_new_item:]}]))
|
||||
@@ -998,7 +998,11 @@ class CommandMeta(type):
|
||||
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
|
||||
|
||||
|
||||
def mark_raw(function):
|
||||
_Return = typing.TypeVar("_Return")
|
||||
# TODO: when python 3.10 is lowest supported, typing.ParamSpec
|
||||
|
||||
|
||||
def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]:
|
||||
function.raw_text = True
|
||||
return function
|
||||
|
||||
@@ -1086,7 +1090,7 @@ class CommonCommandProcessor(CommandProcessor):
|
||||
timer = int(seconds, 10)
|
||||
except ValueError:
|
||||
timer = 10
|
||||
asyncio.create_task(countdown(self.ctx, timer))
|
||||
async_start(countdown(self.ctx, timer))
|
||||
return True
|
||||
|
||||
def _cmd_options(self):
|
||||
@@ -1328,6 +1332,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||
points_available = get_client_points(self.ctx, self.client)
|
||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||
|
||||
if not input_text:
|
||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||
self.ctx.hints[self.client.team, self.client.slot]}
|
||||
@@ -1382,7 +1388,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return False
|
||||
|
||||
if hints:
|
||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||
old_hints = set(hints) - new_hints
|
||||
if old_hints:
|
||||
@@ -1432,7 +1437,12 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return True
|
||||
|
||||
else:
|
||||
self.output("Nothing found. Item/Location may not exist.")
|
||||
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
|
||||
@@ -1761,7 +1771,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Shutdown the server"""
|
||||
asyncio.create_task(self.ctx.server.ws_server._close())
|
||||
async_start(self.ctx.server.ws_server._close())
|
||||
if self.ctx.shutdown_task:
|
||||
self.ctx.shutdown_task.cancel()
|
||||
self.ctx.exit_event.set()
|
||||
@@ -1792,14 +1802,33 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
self.output(response)
|
||||
return False
|
||||
|
||||
def resolve_player(self, input_name: str) -> typing.Optional[typing.Tuple[int, int, str]]:
|
||||
""" returns (team, slot, player name) """
|
||||
# TODO: clean up once we disallow multidata < 0.3.6, which has CI unique names
|
||||
# first match case
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name == input_name:
|
||||
return team, slot, name
|
||||
|
||||
# if no case-sensitive match, then match without case only if there's only 1 match
|
||||
input_lower = input_name.lower()
|
||||
match: typing.Optional[typing.Tuple[int, int, str]] = None
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
lowered = name.lower()
|
||||
if lowered == input_lower:
|
||||
if match:
|
||||
return None # ambiguous input_name
|
||||
match = (team, slot, name)
|
||||
return match
|
||||
|
||||
@mark_raw
|
||||
def _cmd_collect(self, player_name: str) -> bool:
|
||||
"""Send out the remaining items to player."""
|
||||
seeked_player = player_name.lower()
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name.lower() == seeked_player:
|
||||
collect_player(self.ctx, team, slot)
|
||||
return True
|
||||
player = self.resolve_player(player_name)
|
||||
if player:
|
||||
team, slot, _ = player
|
||||
collect_player(self.ctx, team, slot)
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to collect")
|
||||
return False
|
||||
@@ -1812,11 +1841,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
@mark_raw
|
||||
def _cmd_forfeit(self, player_name: str) -> bool:
|
||||
"""Send out the remaining items from a player to their intended recipients."""
|
||||
seeked_player = player_name.lower()
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name.lower() == seeked_player:
|
||||
forfeit_player(self.ctx, team, slot)
|
||||
return True
|
||||
player = self.resolve_player(player_name)
|
||||
if player:
|
||||
team, slot, _ = player
|
||||
forfeit_player(self.ctx, team, slot)
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to release")
|
||||
return False
|
||||
@@ -1824,12 +1853,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
@mark_raw
|
||||
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
||||
"""Allow the specified player to use the !release command."""
|
||||
seeked_player = player_name.lower()
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name.lower() == seeked_player:
|
||||
self.ctx.allow_forfeits[(team, slot)] = True
|
||||
self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
|
||||
return True
|
||||
player = self.resolve_player(player_name)
|
||||
if player:
|
||||
team, slot, name = player
|
||||
self.ctx.allow_forfeits[(team, slot)] = True
|
||||
self.output(f"Player {name} is now allowed to use the !release command at any time.")
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to allow the !release command for.")
|
||||
return False
|
||||
@@ -1837,13 +1866,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
@mark_raw
|
||||
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
||||
""""Disallow the specified player from using the !release command."""
|
||||
seeked_player = player_name.lower()
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name.lower() == seeked_player:
|
||||
self.ctx.allow_forfeits[(team, slot)] = False
|
||||
self.output(
|
||||
f"Player {player_name} has to follow the server restrictions on use of the !release command.")
|
||||
return True
|
||||
player = self.resolve_player(player_name)
|
||||
if player:
|
||||
team, slot, name = player
|
||||
self.ctx.allow_forfeits[(team, slot)] = False
|
||||
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.")
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to forbid the !release command for.")
|
||||
return False
|
||||
@@ -2056,7 +2084,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
await asyncio.sleep(ctx.auto_shutdown)
|
||||
while not ctx.exit_event.is_set():
|
||||
if not ctx.client_activity_timers.values():
|
||||
asyncio.create_task(ctx.server.ws_server._close())
|
||||
async_start(ctx.server.ws_server._close())
|
||||
ctx.exit_event.set()
|
||||
if to_cancel:
|
||||
for task in to_cancel:
|
||||
@@ -2067,7 +2095,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
|
||||
seconds = ctx.auto_shutdown - delta.total_seconds()
|
||||
if seconds < 0:
|
||||
asyncio.create_task(ctx.server.ws_server._close())
|
||||
async_start(ctx.server.ws_server._close())
|
||||
ctx.exit_event.set()
|
||||
if to_cancel:
|
||||
for task in to_cancel:
|
||||
|
||||
@@ -100,7 +100,7 @@ _encode = JSONEncoder(
|
||||
).encode
|
||||
|
||||
|
||||
def encode(obj):
|
||||
def encode(obj: typing.Any) -> str:
|
||||
return _encode(_scan_for_TypedTuples(obj))
|
||||
|
||||
|
||||
|
||||
25
OoTClient.py
25
OoTClient.py
@@ -5,9 +5,11 @@ import multiprocessing
|
||||
import subprocess
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from worlds import network_data_package
|
||||
from worlds.oot.Rom import Rom, compress_rom_file
|
||||
from worlds.oot.N64Patch import apply_patch_file
|
||||
@@ -68,7 +70,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
self.ctx.deathlink_client_override = True
|
||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||
|
||||
|
||||
class OoTContext(CommonContext):
|
||||
@@ -132,6 +134,19 @@ def get_payload(ctx: OoTContext):
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
|
||||
# Refuse to do anything if ROM is detected as changed
|
||||
if ctx.auth and payload['playerName'] != ctx.auth:
|
||||
logger.warning("ROM change detected. Disconnecting and reconnecting...")
|
||||
ctx.deathlink_enabled = False
|
||||
ctx.deathlink_client_override = False
|
||||
ctx.finished_game = False
|
||||
ctx.location_table = {}
|
||||
ctx.deathlink_pending = False
|
||||
ctx.deathlink_sent_this_death = False
|
||||
ctx.auth = payload['playerName']
|
||||
await ctx.send_connect()
|
||||
return
|
||||
|
||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||
await ctx.update_death_link(True)
|
||||
@@ -189,7 +204,7 @@ async def n64_sync_task(ctx: OoTContext):
|
||||
if reported_version >= script_version:
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||
async_start(parse_payload(data_decoded, ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = data_decoded['playerName']
|
||||
if ctx.awaiting_rom:
|
||||
@@ -265,7 +280,7 @@ async def patch_and_run_game(apz5_file):
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
asyncio.create_task(run_game(comp_path))
|
||||
async_start(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -281,7 +296,7 @@ if __name__ == '__main__':
|
||||
|
||||
if args.apz5_file:
|
||||
logger.info("APZ5 file supplied, beginning patching process...")
|
||||
asyncio.create_task(patch_and_run_game(args.apz5_file))
|
||||
async_start(patch_and_run_game(args.apz5_file))
|
||||
|
||||
ctx = OoTContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
|
||||
140
Options.py
140
Options.py
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import abc
|
||||
from copy import deepcopy
|
||||
import math
|
||||
import numbers
|
||||
import typing
|
||||
@@ -78,6 +79,9 @@ class AssembleOptions(abc.ABCMeta):
|
||||
|
||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
@abc.abstractclassmethod
|
||||
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
|
||||
|
||||
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
@@ -165,6 +169,7 @@ class FreeText(Option):
|
||||
|
||||
|
||||
class NumericOption(Option[int], numbers.Integral):
|
||||
default = 0
|
||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||
# (even though isinstance(5, numbers.Integral) == True)
|
||||
@@ -426,7 +431,6 @@ class TextChoice(Choice):
|
||||
assert isinstance(value, str) or isinstance(value, int), \
|
||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||
self.value = value
|
||||
super(TextChoice, self).__init__()
|
||||
|
||||
@property
|
||||
def current_key(self) -> str:
|
||||
@@ -466,6 +470,124 @@ class TextChoice(Choice):
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
|
||||
|
||||
class BossMeta(AssembleOptions):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
if name != "PlandoBosses":
|
||||
assert "bosses" in attrs, f"Please define valid bosses for {name}"
|
||||
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
|
||||
assert "locations" in attrs, f"Please define valid locations for {name}"
|
||||
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
|
||||
cls = super().__new__(mcs, name, bases, attrs)
|
||||
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
|
||||
return cls
|
||||
|
||||
|
||||
class PlandoBosses(TextChoice, metaclass=BossMeta):
|
||||
"""Generic boss shuffle option that supports plando. Format expected is
|
||||
'location1-boss1;location2-boss2;shuffle_mode'.
|
||||
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
|
||||
which passes a plando boss and location. Check if the placement is valid for your game here."""
|
||||
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
||||
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
||||
|
||||
duplicate_bosses: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str):
|
||||
# set all of our text to lower case for name checking
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.options.values())))
|
||||
for option_name, value in cls.options.items():
|
||||
if option_name == text:
|
||||
return cls(value)
|
||||
options = text.split(";")
|
||||
|
||||
# since plando exists in the option verify the plando values given are valid
|
||||
cls.validate_plando_bosses(options)
|
||||
return cls.get_shuffle_mode(options)
|
||||
|
||||
@classmethod
|
||||
def get_shuffle_mode(cls, option_list: typing.List[str]):
|
||||
# find out what mode of boss shuffle we should use for placing bosses after plando
|
||||
# and add as a string to look nice in the spoiler
|
||||
if "random" in option_list:
|
||||
shuffle = random.choice(list(cls.options))
|
||||
option_list.remove("random")
|
||||
options = ";".join(option_list) + f";{shuffle}"
|
||||
boss_class = cls(options)
|
||||
else:
|
||||
for option in option_list:
|
||||
if option in cls.options:
|
||||
options = ";".join(option_list)
|
||||
break
|
||||
else:
|
||||
if cls.duplicate_bosses and len(option_list) == 1:
|
||||
if cls.valid_boss_name(option_list[0]):
|
||||
# this doesn't exist in this class but it's a forced option for classes where this is called
|
||||
options = option_list[0] + ";singularity"
|
||||
else:
|
||||
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
|
||||
else:
|
||||
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
|
||||
boss_class = cls(options)
|
||||
return boss_class
|
||||
|
||||
@classmethod
|
||||
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
|
||||
used_locations = []
|
||||
used_bosses = []
|
||||
for option in options:
|
||||
# check if a shuffle mode was provided in the incorrect location
|
||||
if option == "random" or option in cls.options:
|
||||
if option != options[-1]:
|
||||
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
|
||||
elif "-" in option:
|
||||
location, boss = option.split("-")
|
||||
if location in used_locations:
|
||||
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
|
||||
if not cls.duplicate_bosses and boss in used_bosses:
|
||||
raise ValueError(f"Duplicate Boss {boss} not allowed.")
|
||||
used_locations.append(location)
|
||||
used_bosses.append(boss)
|
||||
if not cls.valid_boss_name(boss):
|
||||
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
||||
if not cls.valid_location_name(location):
|
||||
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
||||
if not cls.can_place_boss(boss, location):
|
||||
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
||||
else:
|
||||
if cls.duplicate_bosses:
|
||||
if not cls.valid_boss_name(option):
|
||||
raise ValueError(f"{option} is not a valid boss name.")
|
||||
else:
|
||||
raise ValueError(f"{option.title()} is not formatted correctly.")
|
||||
|
||||
@classmethod
|
||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def valid_boss_name(cls, value: str) -> bool:
|
||||
return value in cls.bosses
|
||||
|
||||
@classmethod
|
||||
def valid_location_name(cls, value: str) -> bool:
|
||||
return value in cls.locations
|
||||
|
||||
def verify(self, world, player_name: str, plando_options) -> None:
|
||||
if isinstance(self.value, int):
|
||||
return
|
||||
from Generate import PlandoSettings
|
||||
if not(PlandoSettings.bosses & plando_options):
|
||||
import logging
|
||||
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||
option = self.value.split(";")[-1]
|
||||
self.value = self.options[option]
|
||||
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
|
||||
f"boss shuffle will be used for player {player_name}.")
|
||||
|
||||
|
||||
class Range(NumericOption):
|
||||
range_start = 0
|
||||
range_end = 1
|
||||
@@ -628,11 +750,11 @@ class VerifyKeys:
|
||||
|
||||
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
default = {}
|
||||
default: typing.Dict[str, typing.Any] = {}
|
||||
supports_weighting = False
|
||||
|
||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||
self.value = value
|
||||
self.value = deepcopy(value)
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||
@@ -659,11 +781,11 @@ class ItemDict(OptionDict):
|
||||
|
||||
|
||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
default = []
|
||||
default: typing.List[typing.Any] = []
|
||||
supports_weighting = False
|
||||
|
||||
def __init__(self, value: typing.List[typing.Any]):
|
||||
self.value = value or []
|
||||
self.value = deepcopy(value)
|
||||
super(OptionList, self).__init__()
|
||||
|
||||
@classmethod
|
||||
@@ -685,11 +807,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
|
||||
|
||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
default = frozenset()
|
||||
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
|
||||
supports_weighting = False
|
||||
|
||||
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
||||
self.value = set(value)
|
||||
def __init__(self, value: typing.Iterable[str]):
|
||||
self.value = set(deepcopy(value))
|
||||
super(OptionSet, self).__init__()
|
||||
|
||||
@classmethod
|
||||
@@ -728,7 +850,7 @@ class Accessibility(Choice):
|
||||
|
||||
class ProgressionBalancing(SpecialRange):
|
||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||
A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||
default = 50
|
||||
range_start = 0
|
||||
range_end = 99
|
||||
|
||||
428
Patch.py
428
Patch.py
@@ -1,266 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import json
|
||||
import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
import lzma
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
import sys
|
||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||
from typing import Tuple, Optional, TypedDict
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
current_patch_version = 5
|
||||
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
||||
|
||||
|
||||
class AutoPatchRegister(type):
|
||||
patch_types: Dict[str, APDeltaPatch] = {}
|
||||
file_endings: Dict[str, APDeltaPatch] = {}
|
||||
|
||||
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
||||
# construct class
|
||||
new_class = super().__new__(cls, name, bases, dct)
|
||||
if "game" in dct:
|
||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||
if not dct["patch_file_ending"]:
|
||||
raise Exception(f"Need an expected file ending for {name}")
|
||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
||||
if file.endswith(file_ending):
|
||||
return handler
|
||||
|
||||
|
||||
class APContainer:
|
||||
"""A zipfile containing at least archipelago.json"""
|
||||
version: int = current_patch_version
|
||||
compression_level: int = 9
|
||||
compression_method: int = zipfile.ZIP_DEFLATED
|
||||
game: Optional[str] = None
|
||||
|
||||
# instance attributes:
|
||||
path: Optional[str]
|
||||
class RomMeta(TypedDict):
|
||||
server: str
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
server: str
|
||||
|
||||
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
||||
player_name: str = "", server: str = ""):
|
||||
self.path = path
|
||||
self.player = player
|
||||
self.player_name = player_name
|
||||
self.server = server
|
||||
|
||||
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
||||
as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.write_contents(zf)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
manifest = self.get_manifest()
|
||||
try:
|
||||
manifest = json.dumps(manifest)
|
||||
except Exception as e:
|
||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||
else:
|
||||
opened_zipfile.writestr("archipelago.json", manifest)
|
||||
|
||||
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||
if not self.path and not file:
|
||||
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
||||
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
||||
if file:
|
||||
self.path = zf.filename
|
||||
self.read_contents(zf)
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
if manifest["compatible_version"] > self.version:
|
||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||
f"for this handler (version: {self.version})")
|
||||
self.player = manifest["player"]
|
||||
self.server = manifest["server"]
|
||||
self.player_name = manifest["player_name"]
|
||||
|
||||
def get_manifest(self) -> dict:
|
||||
return {
|
||||
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player": self.player,
|
||||
"player_name": self.player_name,
|
||||
"game": self.game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 4,
|
||||
"version": current_patch_version,
|
||||
}
|
||||
|
||||
|
||||
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]:
|
||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||
if auto_handler:
|
||||
handler: APDeltaPatch = auto_handler(patch_file)
|
||||
@@ -269,171 +26,10 @@ def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
return {"server": handler.server,
|
||||
"player": handler.player,
|
||||
"player_name": handler.player_name}, target
|
||||
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
|
||||
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = Utils.get_public_ipv4()
|
||||
options = Utils.get_options()['server_options']
|
||||
if options['host']:
|
||||
host = options['host']
|
||||
|
||||
address = f"{host}:{options['port']}"
|
||||
ziplock = threading.Lock()
|
||||
print(f"Host for patches to be created is {address}")
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
for rom in sys.argv:
|
||||
try:
|
||||
if rom.endswith(".sfc"):
|
||||
print(f"Creating patch for {rom}")
|
||||
result = pool.submit(create_patch_file, rom, address)
|
||||
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
|
||||
|
||||
elif rom.endswith(".apbp"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
#romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||
adjusted = False
|
||||
if adjuster_settings:
|
||||
import pprint
|
||||
from worlds.alttp.Rom import get_base_rom_path
|
||||
adjuster_settings.rom = target
|
||||
adjuster_settings.baserom = get_base_rom_path()
|
||||
adjuster_settings.world = None
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||
"reduceflashing", "deathlink"}
|
||||
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
sprite_pool = {}
|
||||
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
||||
if sprite in sprite_pool:
|
||||
sprite_pool[sprite] += 1
|
||||
else:
|
||||
sprite_pool[sprite] = 1
|
||||
if sprite_pool:
|
||||
printed_options["sprite_pool"] = sprite_pool
|
||||
|
||||
adjust_wanted = str('no')
|
||||
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
|
||||
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no, always or never: ")
|
||||
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
|
||||
adjust_wanted = 'no'
|
||||
elif adjuster_settings.auto_apply == 'always':
|
||||
adjust_wanted = 'yes'
|
||||
|
||||
if adjust_wanted and "never" in adjust_wanted:
|
||||
adjuster_settings.auto_apply = 'never'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||
|
||||
elif adjust_wanted and "always" in adjust_wanted:
|
||||
adjuster_settings.auto_apply = 'always'
|
||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
||||
|
||||
if adjust_wanted and adjust_wanted.startswith("y"):
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
from LttPAdjuster import AdjusterWorld
|
||||
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
||||
|
||||
adjusted = True
|
||||
import LttPAdjuster
|
||||
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
||||
|
||||
if hasattr(adjuster_settings, "world"):
|
||||
delattr(adjuster_settings, "world")
|
||||
else:
|
||||
adjusted = False
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(romfile, target)
|
||||
romfile = target
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(f"Created rom {romfile if adjusted else target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apm3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apsmz"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".apdkc3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
|
||||
elif rom.endswith(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp") or \
|
||||
zfinfo.filename.endswith(".apm3") or \
|
||||
zfinfo.filename.endswith(".apdkc3"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
return zfinfo.filename
|
||||
|
||||
|
||||
futures = []
|
||||
with zipfile.ZipFile(rom, "r") as zfr:
|
||||
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
|
||||
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zfw:
|
||||
for zfname in zfr.namelist():
|
||||
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
|
||||
for future in futures:
|
||||
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
|
||||
|
||||
except:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
for file in sys.argv[1:]:
|
||||
meta_data, result_file = create_rom_file(file)
|
||||
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
||||
|
||||
304
PokemonClient.py
Normal file
304
PokemonClient.py
Normal file
@@ -0,0 +1,304 @@
|
||||
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,6 +28,11 @@ Currently, the following games are supported:
|
||||
* Starcraft 2: Wings of Liberty
|
||||
* Donkey Kong Country 3
|
||||
* Dark Souls 3
|
||||
* Super Mario World
|
||||
* Pokémon Red and Blue
|
||||
* Hylics 2
|
||||
* Overcooked! 2
|
||||
* Zillion
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
1175
SNIClient.py
1175
SNIClient.py
File diff suppressed because it is too large
Load Diff
@@ -12,21 +12,9 @@ import typing
|
||||
import queue
|
||||
from pathlib import Path
|
||||
|
||||
import nest_asyncio
|
||||
import sc2
|
||||
from sc2.bot_ai import BotAI
|
||||
from sc2.data import Race
|
||||
from sc2.main import run_game
|
||||
from sc2.player import Bot
|
||||
|
||||
import NetUtils
|
||||
from MultiServer import mark_raw
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from Utils import init_logging, is_windows
|
||||
from worlds.sc2wol import SC2WoLWorld
|
||||
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
|
||||
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
||||
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
||||
from worlds.sc2wol.Regions import MissionInfo
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging("SC2Client", exception_logger="Client")
|
||||
@@ -34,10 +22,21 @@ if __name__ == "__main__":
|
||||
logger = logging.getLogger("Client")
|
||||
sc2_logger = logging.getLogger("Starcraft2")
|
||||
|
||||
import colorama
|
||||
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
|
||||
|
||||
from NetUtils import ClientStatus, RawJSONtoTextParser
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
import colorama
|
||||
from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser
|
||||
from MultiServer import mark_raw
|
||||
|
||||
nest_asyncio.apply()
|
||||
max_bonus: int = 8
|
||||
@@ -115,12 +114,40 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
||||
if path:
|
||||
os.environ["SC2PATH"] = path
|
||||
check_mod_install()
|
||||
is_mod_installed_correctly()
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||
return False
|
||||
|
||||
def _cmd_download_data(self, force: bool = False) -> bool:
|
||||
"""Download the most recent release of the necessary files for playing SC2 with
|
||||
Archipelago. force should be True or False. force=True will overwrite your files."""
|
||||
if "SC2PATH" not in os.environ:
|
||||
check_game_install_path()
|
||||
|
||||
if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"):
|
||||
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f:
|
||||
current_ver = f.read()
|
||||
else:
|
||||
current_ver = None
|
||||
|
||||
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', current_version=current_ver, force_download=force)
|
||||
|
||||
if tempzip != '':
|
||||
try:
|
||||
import zipfile
|
||||
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
|
||||
sc2_logger.info(f"Download complete. Version {version} installed.")
|
||||
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
|
||||
f.write(version)
|
||||
finally:
|
||||
os.remove(tempzip)
|
||||
else:
|
||||
sc2_logger.warning("Download aborted/failed. Read the log for more information.")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class SC2Context(CommonContext):
|
||||
command_processor = StarcraftClientProcessor
|
||||
@@ -128,7 +155,9 @@ class SC2Context(CommonContext):
|
||||
items_handling = 0b111
|
||||
difficulty = -1
|
||||
all_in_choice = 0
|
||||
mission_order = 0
|
||||
mission_req_table: typing.Dict[str, MissionInfo] = {}
|
||||
final_mission: int = 29
|
||||
announcements = queue.Queue()
|
||||
sc2_run_task: typing.Optional[asyncio.Task] = None
|
||||
missions_unlocked: bool = False # allow launching missions ignoring requirements
|
||||
@@ -153,16 +182,25 @@ class SC2Context(CommonContext):
|
||||
self.difficulty = args["slot_data"]["game_difficulty"]
|
||||
self.all_in_choice = args["slot_data"]["all_in_map"]
|
||||
slot_req_table = args["slot_data"]["mission_req"]
|
||||
# Maintaining backwards compatibility with older slot data
|
||||
self.mission_req_table = {
|
||||
mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
|
||||
mission: MissionInfo(
|
||||
**{field: value for field, value in mission_info.items() if field in MissionInfo._fields}
|
||||
)
|
||||
for mission, mission_info in slot_req_table.items()
|
||||
}
|
||||
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()
|
||||
|
||||
# Look for and set SC2PATH.
|
||||
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
|
||||
if "SC2PATH" not in os.environ and check_game_install_path():
|
||||
check_mod_install()
|
||||
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
|
||||
is_mod_installed_correctly()
|
||||
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
|
||||
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
|
||||
current_ver = f.read()
|
||||
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
|
||||
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
# goes to this world
|
||||
@@ -274,7 +312,6 @@ class SC2Context(CommonContext):
|
||||
self.refresh_from_launching = True
|
||||
|
||||
self.mission_panel.clear_widgets()
|
||||
|
||||
if self.ctx.mission_req_table:
|
||||
self.last_checked_locations = self.ctx.checked_locations.copy()
|
||||
self.first_check = False
|
||||
@@ -292,17 +329,20 @@ class SC2Context(CommonContext):
|
||||
|
||||
for category in categories:
|
||||
category_panel = MissionCategory()
|
||||
if category.startswith('_'):
|
||||
category_display_name = ''
|
||||
else:
|
||||
category_display_name = category
|
||||
category_panel.add_widget(
|
||||
Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
||||
Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1))
|
||||
|
||||
for mission in categories[category]:
|
||||
text: str = mission
|
||||
tooltip: str = ""
|
||||
|
||||
mission_id: int = self.ctx.mission_req_table[mission].id
|
||||
# Map has uncollected locations
|
||||
if mission in unfinished_missions:
|
||||
text = f"[color=6495ED]{text}[/color]"
|
||||
|
||||
elif mission in available_missions:
|
||||
text = f"[color=FFFFFF]{text}[/color]"
|
||||
# Map requirements not met
|
||||
@@ -321,6 +361,16 @@ class SC2Context(CommonContext):
|
||||
remaining_location_names: typing.List[str] = [
|
||||
self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
|
||||
if loc in self.ctx.missing_locations]
|
||||
|
||||
if mission_id == self.ctx.final_mission:
|
||||
if mission in available_missions:
|
||||
text = f"[color=FFBC95]{mission}[/color]"
|
||||
else:
|
||||
text = f"[color=D0C0BE]{mission}[/color]"
|
||||
if tooltip:
|
||||
tooltip += "\n"
|
||||
tooltip += "Final Mission"
|
||||
|
||||
if remaining_location_names:
|
||||
if tooltip:
|
||||
tooltip += "\n"
|
||||
@@ -330,7 +380,7 @@ class SC2Context(CommonContext):
|
||||
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
||||
mission_button.tooltip_text = tooltip
|
||||
mission_button.bind(on_press=self.mission_callback)
|
||||
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
|
||||
self.mission_id_to_button[mission_id] = mission_button
|
||||
category_panel.add_widget(mission_button)
|
||||
|
||||
category_panel.add_widget(Label(text=""))
|
||||
@@ -357,8 +407,9 @@ class SC2Context(CommonContext):
|
||||
|
||||
self.ui = SC2Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
|
||||
import pkgutil
|
||||
data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode()
|
||||
Builder.load_string(data)
|
||||
|
||||
async def shutdown(self):
|
||||
await super(SC2Context, self).shutdown()
|
||||
@@ -438,10 +489,13 @@ wol_default_categories = [
|
||||
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
||||
"Char", "Char", "Char", "Char"
|
||||
]
|
||||
wol_default_category_names = [
|
||||
"Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char"
|
||||
]
|
||||
|
||||
|
||||
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
|
||||
network_item: NetUtils.NetworkItem
|
||||
def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]:
|
||||
network_item: NetworkItem
|
||||
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
|
||||
|
||||
for network_item in items:
|
||||
@@ -555,7 +609,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
|
||||
|
||||
if self.can_read_game:
|
||||
if game_state & (1 << 1) and not self.mission_completed:
|
||||
if self.mission_id != 29:
|
||||
if self.mission_id != self.ctx.final_mission:
|
||||
print("Mission Completed")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
@@ -711,13 +765,14 @@ def calc_available_missions(ctx: SC2Context, unlocks=None):
|
||||
return available_missions
|
||||
|
||||
|
||||
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete):
|
||||
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int):
|
||||
"""Returns a bool signifying if the mission has all requirements complete and can be done
|
||||
|
||||
Arguments:
|
||||
ctx -- instance of SC2Context
|
||||
locations_to_check -- the mission string name to check
|
||||
missions_complete -- an int of how many missions have been completed
|
||||
mission_path -- a list of missions that have already been checked
|
||||
"""
|
||||
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
|
||||
# A check for when the requirements are being or'd
|
||||
@@ -735,7 +790,18 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# Grid-specific logic (to avoid long path checks and infinite recursion)
|
||||
if ctx.mission_order in (3, 4):
|
||||
if req_success:
|
||||
return True
|
||||
else:
|
||||
if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
|
||||
return False
|
||||
else:
|
||||
continue
|
||||
|
||||
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
||||
# Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
|
||||
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
|
||||
if not ctx.mission_req_table[mission_name].or_requirements:
|
||||
return False
|
||||
@@ -820,18 +886,53 @@ def check_game_install_path() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def check_mod_install() -> bool:
|
||||
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
|
||||
try:
|
||||
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
|
||||
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
|
||||
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
||||
return True
|
||||
else:
|
||||
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
|
||||
except KeyError:
|
||||
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
|
||||
return False
|
||||
def is_mod_installed_correctly() -> bool:
|
||||
"""Searches for all required files."""
|
||||
if "SC2PATH" not in os.environ:
|
||||
check_game_install_path()
|
||||
|
||||
mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
|
||||
modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
|
||||
wol_required_maps = [
|
||||
"ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
|
||||
"ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
|
||||
"ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
|
||||
"ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map",
|
||||
"ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
|
||||
"ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
|
||||
"ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
|
||||
]
|
||||
needs_files = False
|
||||
|
||||
# Check for maps.
|
||||
missing_maps = []
|
||||
for mapfile in wol_required_maps:
|
||||
if not os.path.isfile(mapdir / mapfile):
|
||||
missing_maps.append(mapfile)
|
||||
if len(missing_maps) >= 19:
|
||||
sc2_logger.warning(f"All map files missing from {mapdir}.")
|
||||
needs_files = True
|
||||
elif len(missing_maps) > 0:
|
||||
for map in missing_maps:
|
||||
sc2_logger.debug(f"Missing {map} from {mapdir}.")
|
||||
sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
|
||||
needs_files = True
|
||||
else: # Must be no maps missing
|
||||
sc2_logger.info(f"All maps found in {mapdir}.")
|
||||
|
||||
# Check for mods.
|
||||
if os.path.isfile(modfile):
|
||||
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
||||
else:
|
||||
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
|
||||
needs_files = True
|
||||
|
||||
# Final verdict.
|
||||
if needs_files:
|
||||
sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class DllDirectory:
|
||||
@@ -870,6 +971,64 @@ class DllDirectory:
|
||||
return False
|
||||
|
||||
|
||||
def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
|
||||
"""Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
|
||||
import requests
|
||||
|
||||
headers = {"Accept": 'application/vnd.github.v3+json'}
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
||||
|
||||
r1 = requests.get(url, headers=headers)
|
||||
if r1.status_code == 200:
|
||||
latest_version = r1.json()["tag_name"]
|
||||
sc2_logger.info(f"Latest version: {latest_version}.")
|
||||
else:
|
||||
sc2_logger.warning(f"Status code: {r1.status_code}")
|
||||
sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
|
||||
sc2_logger.warning(f"text: {r1.text}")
|
||||
return "", current_version
|
||||
|
||||
if (force_download is False) and (current_version == latest_version):
|
||||
sc2_logger.info("Latest version already installed.")
|
||||
return "", current_version
|
||||
|
||||
sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
|
||||
download_url = r1.json()["assets"][0]["browser_download_url"]
|
||||
|
||||
r2 = requests.get(download_url, headers=headers)
|
||||
if r2.status_code == 200:
|
||||
with open(f"{repo}.zip", "wb") as fh:
|
||||
fh.write(r2.content)
|
||||
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
|
||||
return f"{repo}.zip", latest_version
|
||||
else:
|
||||
sc2_logger.warning(f"Status code: {r2.status_code}")
|
||||
sc2_logger.warning("Download failed.")
|
||||
sc2_logger.warning(f"text: {r2.text}")
|
||||
return "", current_version
|
||||
|
||||
|
||||
def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
|
||||
import requests
|
||||
|
||||
headers = {"Accept": 'application/vnd.github.v3+json'}
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
||||
|
||||
r1 = requests.get(url, headers=headers)
|
||||
if r1.status_code == 200:
|
||||
latest_version = r1.json()["tag_name"]
|
||||
if current_version != latest_version:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
else:
|
||||
sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
|
||||
sc2_logger.warning(f"Status code: {r1.status_code}")
|
||||
sc2_logger.warning(f"text: {r1.text}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
|
||||
79
Utils.py
79
Utils.py
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import typing
|
||||
import builtins
|
||||
import os
|
||||
@@ -11,6 +12,8 @@ import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
|
||||
|
||||
from yaml import load, load_all, dump, SafeLoader
|
||||
|
||||
try:
|
||||
@@ -35,7 +38,7 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.3.5"
|
||||
__version__ = "0.3.6"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -139,7 +142,7 @@ def user_path(*path: str) -> str:
|
||||
return os.path.join(user_path.cached_path, *path)
|
||||
|
||||
|
||||
def output_path(*path: str):
|
||||
def output_path(*path: str) -> str:
|
||||
if hasattr(output_path, 'cached_path'):
|
||||
return os.path.join(output_path.cached_path, *path)
|
||||
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
||||
@@ -217,8 +220,11 @@ def get_public_ipv6() -> str:
|
||||
return ip
|
||||
|
||||
|
||||
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_default_options() -> dict:
|
||||
def get_default_options() -> OptionsType:
|
||||
# Refer to host.yaml for comments as to what all these options mean.
|
||||
options = {
|
||||
"general_options": {
|
||||
@@ -226,20 +232,21 @@ def get_default_options() -> dict:
|
||||
},
|
||||
"factorio_options": {
|
||||
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
||||
"filter_item_sends": False,
|
||||
"bridge_chat_out": True,
|
||||
},
|
||||
"sni_options": {
|
||||
"sni": "SNI",
|
||||
"snes_rom_start": True,
|
||||
},
|
||||
"sm_options": {
|
||||
"rom_file": "Super Metroid (JU).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
},
|
||||
"soe_options": {
|
||||
"rom_file": "Secret of Evermore (USA).sfc",
|
||||
},
|
||||
"lttp_options": {
|
||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
|
||||
},
|
||||
"server_options": {
|
||||
"host": None,
|
||||
@@ -282,15 +289,27 @@ def get_default_options() -> dict:
|
||||
},
|
||||
"dkc3_options": {
|
||||
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
},
|
||||
"smw_options": {
|
||||
"rom_file": "Super Mario World (USA).sfc",
|
||||
},
|
||||
"zillion_options": {
|
||||
"rom_file": "Zillion (UE) [!].sms",
|
||||
# RetroArch doesn't make it easy to launch a game from the command line.
|
||||
# You have to know the path to the emulator core library on the user's computer.
|
||||
"rom_start": "retroarch",
|
||||
},
|
||||
"pokemon_rb_options": {
|
||||
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
|
||||
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
|
||||
"rom_start": True
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType:
|
||||
for key, value in src.items():
|
||||
new_keys = keys.copy()
|
||||
new_keys.append(key)
|
||||
@@ -310,9 +329,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_options() -> dict:
|
||||
def get_options() -> OptionsType:
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations = []
|
||||
locations: typing.List[str] = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
locations += filenames # use files from cwd only if it's not the local_path
|
||||
locations += [user_path(filename) for filename in filenames]
|
||||
@@ -353,7 +372,7 @@ def persistent_load() -> typing.Dict[str, dict]:
|
||||
return storage
|
||||
|
||||
|
||||
def get_adjuster_settings(game_name: str):
|
||||
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||
return adjuster_settings
|
||||
|
||||
@@ -392,7 +411,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||
return getattr(self.generic_properties_module, name)
|
||||
if module.endswith("Options"):
|
||||
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
||||
if module.lower().endswith("options"):
|
||||
if module == "Options":
|
||||
mod = self.options_module
|
||||
else:
|
||||
@@ -623,3 +643,32 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
|
||||
else:
|
||||
return element.lower()
|
||||
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
||||
|
||||
|
||||
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||
buffer = bytearray(stream.read())
|
||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||
return buffer[0x200:]
|
||||
return buffer
|
||||
|
||||
|
||||
_faf_tasks: "Set[asyncio.Task[None]]" = set()
|
||||
|
||||
|
||||
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||
to prevent early garbage collection. "fire-and-forget"
|
||||
"""
|
||||
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
||||
# Python docs:
|
||||
# ```
|
||||
# Important: Save a reference to the result of [asyncio.create_task],
|
||||
# to avoid a task disappearing mid-execution.
|
||||
# ```
|
||||
# This implementation follows the pattern given in that documentation.
|
||||
|
||||
task = asyncio.create_task(co, name=name)
|
||||
_faf_tasks.add(task)
|
||||
task.add_done_callback(_faf_tasks.discard)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
import multiprocessing
|
||||
import logging
|
||||
import typing
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import os
|
||||
import uuid
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
|
||||
from pony.flask import Pony
|
||||
from flask import Flask
|
||||
from flask_caching import Cache
|
||||
from flask_compress import Compress
|
||||
from pony.flask import Pony
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from Utils import title_sorted
|
||||
from .models import *
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
@@ -32,8 +31,10 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 2
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
app.config["JOB_TIME"] = 600
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
|
||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
@@ -73,8 +74,10 @@ def register():
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
# has automatic patch integration
|
||||
import Patch
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""API endpoints package."""
|
||||
from uuid import UUID
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Blueprint, abort
|
||||
|
||||
from ..models import Room, Seed
|
||||
from .. import cache
|
||||
from ..models import Room, Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import json
|
||||
import pickle
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from . import api_endpoints
|
||||
from flask import request, session, url_for
|
||||
from pony.orm import commit
|
||||
|
||||
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from WebHostLib import app
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from . import api_endpoints
|
||||
|
||||
|
||||
@api_endpoints.route('/generate', methods=['POST'])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from flask import session, jsonify
|
||||
from pony.orm import select
|
||||
|
||||
from WebHostLib.models import *
|
||||
from WebHostLib.models import Room, Seed
|
||||
from . import api_endpoints, get_players
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import json
|
||||
import multiprocessing
|
||||
import threading
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import zipfile
|
||||
from typing import *
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from flask import request, flash, redirect, url_for, render_template
|
||||
|
||||
from WebHostLib import app
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import websockets
|
||||
from pony.orm import db_session, commit, select
|
||||
|
||||
import Utils
|
||||
from .models import db_session, Room, select, commit, Command, db
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||
from .models import Room, Command, db
|
||||
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
@@ -183,4 +184,12 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
||||
|
||||
from .autolauncher import Locker
|
||||
with Locker(room_id):
|
||||
asyncio.run(main())
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
raise
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import zipfile
|
||||
import json
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from flask import send_file, Response, render_template
|
||||
from pony.orm import select
|
||||
|
||||
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
from worlds.Files import AutoPatchRegister
|
||||
from . import app, cache
|
||||
from .models import Slot, Room, Seed
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
@@ -41,12 +42,7 @@ def download_patch(room_id, patch_id):
|
||||
new_file.seek(0)
|
||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||
else:
|
||||
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)
|
||||
return "Old Patch file, no longer compatible."
|
||||
|
||||
|
||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||
@@ -79,6 +75,8 @@ def download_slot_file(room_id, player_id: int):
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||
elif slot_data.game == "Zillion":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
|
||||
elif slot_data.game == "Super Mario 64":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||
elif slot_data.game == "Dark Souls III":
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import os
|
||||
import tempfile
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import random
|
||||
import tempfile
|
||||
import zipfile
|
||||
import concurrent.futures
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional, Any
|
||||
from Utils import __version__
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import commit, db_session
|
||||
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name, PlandoSettings
|
||||
import pickle
|
||||
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
@@ -98,7 +99,7 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
|
||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||
race = meta.setdefault("race", False)
|
||||
|
||||
try:
|
||||
def task():
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
seed = get_seed()
|
||||
@@ -138,6 +139,23 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
|
||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
thread = thread_pool.submit(task)
|
||||
|
||||
try:
|
||||
return thread.result(app.config["JOB_TIME"])
|
||||
except concurrent.futures.TimeoutError as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
gen = Generation.get(id=sid)
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (
|
||||
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
||||
e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from flask import render_template
|
||||
from pony.orm import count
|
||||
|
||||
from WebHostLib import app, cache
|
||||
from .models import *
|
||||
from datetime import timedelta
|
||||
from .models import Room, Seed
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||
|
||||
@@ -32,7 +32,7 @@ def update_sprites_lttp():
|
||||
|
||||
spriteData = []
|
||||
|
||||
for file in os.listdir(input_dir):
|
||||
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
|
||||
sprite = Sprite(os.path.join(input_dir, file))
|
||||
|
||||
if not sprite.name:
|
||||
|
||||
@@ -3,10 +3,11 @@ import os
|
||||
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from pony.orm import count, commit, db_session
|
||||
|
||||
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
|
||||
|
||||
def get_world_theme(game_name: str):
|
||||
@@ -151,7 +152,7 @@ def favicon():
|
||||
|
||||
@app.route('/discord')
|
||||
def discord():
|
||||
return redirect("https://discord.gg/archipelago")
|
||||
return redirect("https://discord.gg/8Z65BR2")
|
||||
|
||||
|
||||
@app.route('/datapackage')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from pony.orm import *
|
||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||
|
||||
db = Database()
|
||||
|
||||
@@ -29,6 +29,7 @@ class Room(db.Entity):
|
||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||
tracker = Optional(UUID, index=True)
|
||||
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
|
||||
last_port = Optional(int, default=lambda: 0)
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from Utils import __version__, local_path
|
||||
from jinja2 import Template
|
||||
import yaml
|
||||
import json
|
||||
import typing
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
import Options
|
||||
from Utils import __version__, local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations"}
|
||||
@@ -15,26 +16,23 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
|
||||
|
||||
def create():
|
||||
target_folder = local_path("WebHostLib", "static", "generated")
|
||||
os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
|
||||
yaml_folder = os.path.join(target_folder, "configs")
|
||||
os.makedirs(yaml_folder, exist_ok=True)
|
||||
|
||||
for file in os.listdir(yaml_folder):
|
||||
full_path: str = os.path.join(yaml_folder, file)
|
||||
if os.path.isfile(full_path):
|
||||
os.unlink(full_path)
|
||||
|
||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||
data = {}
|
||||
special = getattr(option, "special_range_cutoff", None)
|
||||
if special is not None:
|
||||
data[special] = 0
|
||||
data.update({
|
||||
option.range_start: 0,
|
||||
option.range_end: 0,
|
||||
"random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50
|
||||
})
|
||||
notes = {
|
||||
special: "minimum value without special meaning",
|
||||
option.range_start: "minimum value",
|
||||
option.range_end: "maximum value"
|
||||
}
|
||||
data = {option.default: 50}
|
||||
for sub_option in ["random", "random-low", "random-high"]:
|
||||
if sub_option != option.default:
|
||||
data[sub_option] = 0
|
||||
|
||||
notes = {}
|
||||
for name, number in getattr(option, "special_range_names", {}).items():
|
||||
notes[name] = f"equivalent to {number}"
|
||||
if number in data:
|
||||
data[name] = data[number]
|
||||
del data[number]
|
||||
@@ -43,11 +41,6 @@ def create():
|
||||
|
||||
return data, notes
|
||||
|
||||
def default_converter(default_value):
|
||||
if isinstance(default_value, (set, frozenset)):
|
||||
return list(default_value)
|
||||
return default_value
|
||||
|
||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||
if not option_type.__doc__:
|
||||
return "Please document me!"
|
||||
@@ -64,13 +57,16 @@ def create():
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
all_options = {**Options.per_game_common_options, **world.option_definitions}
|
||||
all_options: typing.Dict[str, Options.AssembleOptions] = {
|
||||
**Options.per_game_common_options,
|
||||
**world.option_definitions
|
||||
}
|
||||
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range, default_converter=default_converter,
|
||||
dictify_range=dictify_range,
|
||||
)
|
||||
|
||||
del file_data
|
||||
@@ -110,11 +106,6 @@ def create():
|
||||
if sub_option_id == option.default:
|
||||
this_option["defaultValue"] = sub_option_name
|
||||
|
||||
this_option["options"].append({
|
||||
"name": "Random",
|
||||
"value": "random",
|
||||
})
|
||||
|
||||
if option.default == "random":
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@ flask>=2.2.2
|
||||
pony>=0.7.16
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.0.1
|
||||
Flask-Compress>=1.12
|
||||
Flask-Limiter>=2.6.2
|
||||
bokeh>=2.4.3
|
||||
Flask-Compress>=1.13
|
||||
Flask-Limiter>=2.7.0
|
||||
bokeh>=3.0.0
|
||||
|
||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -46,7 +46,7 @@ the website is not required to generate them.
|
||||
## How do I get started?
|
||||
|
||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## What are some common terms I should know?
|
||||
|
||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
gameInfo.innerHTML =
|
||||
|
||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -118,6 +118,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
const tdr = document.createElement('td');
|
||||
let element = null;
|
||||
|
||||
const randomButton = document.createElement('button');
|
||||
|
||||
switch(settings[setting].type){
|
||||
case 'select':
|
||||
element = document.createElement('div');
|
||||
@@ -138,8 +140,21 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', (event) => updateGameSetting(event));
|
||||
select.addEventListener('change', (event) => updateGameSetting(event.target));
|
||||
element.appendChild(select);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
select.disabled = true;
|
||||
}
|
||||
|
||||
element.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
@@ -154,15 +169,29 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
range.value = currentSettings[gameName][setting];
|
||||
range.addEventListener('change', (event) => {
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
updateGameSetting(event.target);
|
||||
});
|
||||
element.appendChild(range);
|
||||
|
||||
let rangeVal = document.createElement('span');
|
||||
rangeVal.classList.add('range-value');
|
||||
rangeVal.setAttribute('id', `${setting}-value`);
|
||||
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||
element.appendChild(rangeVal);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
range.disabled = true;
|
||||
}
|
||||
|
||||
element.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
case 'special_range':
|
||||
@@ -201,7 +230,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
let specialRangeVal = document.createElement('span');
|
||||
specialRangeVal.classList.add('range-value');
|
||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||
|
||||
// Configure select event listener
|
||||
specialRangeSelect.addEventListener('change', (event) => {
|
||||
@@ -210,7 +240,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
// Update range slider
|
||||
specialRange.value = event.target.value;
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
updateGameSetting(event.target);
|
||||
});
|
||||
|
||||
// Configure range event handler
|
||||
@@ -220,13 +250,29 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||
parseInt(event.target.value) : 'custom';
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
updateGameSetting(event.target);
|
||||
});
|
||||
|
||||
element.appendChild(specialRangeSelect);
|
||||
specialRangeWrapper.appendChild(specialRange);
|
||||
specialRangeWrapper.appendChild(specialRangeVal);
|
||||
element.appendChild(specialRangeWrapper);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(
|
||||
event, [specialRange, specialRangeSelect])
|
||||
);
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
specialRange.disabled = true;
|
||||
specialRangeSelect.disabled = true;
|
||||
}
|
||||
|
||||
specialRangeWrapper.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -243,6 +289,25 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
return table;
|
||||
};
|
||||
|
||||
const toggleRandomize = (event, inputElements) => {
|
||||
const active = event.target.classList.contains('active');
|
||||
const randomButton = event.target;
|
||||
|
||||
if (active) {
|
||||
randomButton.classList.remove('active');
|
||||
for (const element of inputElements) {
|
||||
element.disabled = undefined;
|
||||
updateGameSetting(element);
|
||||
}
|
||||
} else {
|
||||
randomButton.classList.add('active');
|
||||
for (const element of inputElements) {
|
||||
element.disabled = true;
|
||||
updateGameSetting(randomButton);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateBaseSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
@@ -250,10 +315,17 @@ const updateBaseSetting = (event) => {
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const updateGameSetting = (event) => {
|
||||
const updateGameSetting = (settingElement) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
|
||||
if (settingElement.classList.contains('randomize-button')) {
|
||||
// If the event passed in is the randomize button, then we know what we must do.
|
||||
options[gameName][settingElement.getAttribute('data-key')] = 'random';
|
||||
} else {
|
||||
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
|
||||
settingElement.value : parseInt(settingElement.value, 10);
|
||||
}
|
||||
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
|
||||
@@ -27,25 +27,28 @@ window.addEventListener('load', () => {
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
const title = document.querySelector('h1')
|
||||
if (title) {
|
||||
document.title = title.textContent;
|
||||
}
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -78,13 +78,16 @@ const createDefaultSettings = (settingData) => {
|
||||
break;
|
||||
case 'range':
|
||||
case 'special_range':
|
||||
for (let i = setting.min; i <= setting.max; ++i){
|
||||
newSettings[game][gameSetting][i] =
|
||||
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
|
||||
}
|
||||
newSettings[game][gameSetting][setting.min] = 0;
|
||||
newSettings[game][gameSetting][setting.max] = 0;
|
||||
newSettings[game][gameSetting]['random'] = 0;
|
||||
newSettings[game][gameSetting]['random-low'] = 0;
|
||||
newSettings[game][gameSetting]['random-high'] = 0;
|
||||
if (setting.hasOwnProperty('defaultValue')) {
|
||||
newSettings[game][gameSetting][setting.defaultValue] = 25;
|
||||
} else {
|
||||
newSettings[game][gameSetting][setting.min] = 25;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'items-list':
|
||||
@@ -401,11 +404,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
tr.appendChild(tdDelete);
|
||||
|
||||
rangeTbody.appendChild(tr);
|
||||
|
||||
// Save new option to settings
|
||||
range.dispatchEvent(new Event('change'));
|
||||
});
|
||||
|
||||
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
||||
if (currentSettings[game][settingName][option] > 0) {
|
||||
const tr = document.createElement('tr');
|
||||
// These options are statically generated below, and should always appear even if they are deleted
|
||||
// from localStorage
|
||||
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
const tdLeft = document.createElement('td');
|
||||
tdLeft.classList.add('td-left');
|
||||
tdLeft.innerText = option;
|
||||
@@ -439,14 +448,15 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
deleteButton.innerText = '❌';
|
||||
deleteButton.addEventListener('click', () => {
|
||||
range.value = 0;
|
||||
range.dispatchEvent(new Event('change'));
|
||||
const changeEvent = new Event('change');
|
||||
changeEvent.action = 'rangeDelete';
|
||||
range.dispatchEvent(changeEvent);
|
||||
rangeTbody.removeChild(tr);
|
||||
});
|
||||
tdDelete.appendChild(deleteButton);
|
||||
tr.appendChild(tdDelete);
|
||||
|
||||
rangeTbody.appendChild(tr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -904,8 +914,12 @@ const updateGameSetting = (evt) => {
|
||||
const setting = evt.target.getAttribute('data-setting');
|
||||
const option = evt.target.getAttribute('data-option');
|
||||
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
||||
options[game][setting][option] = isNaN(evt.target.value) ?
|
||||
evt.target.value : parseInt(evt.target.value, 10);
|
||||
console.log(event);
|
||||
if (evt.action && evt.action === 'rangeDelete') {
|
||||
delete options[game][setting][option];
|
||||
} else {
|
||||
options[game][setting][option] = parseInt(evt.target.value, 10);
|
||||
}
|
||||
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||
};
|
||||
|
||||
|
||||
@@ -55,4 +55,6 @@
|
||||
border: 1px solid #2a6c2f;
|
||||
border-radius: 6px;
|
||||
color: #000000;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,10 @@ html{
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#player-settings table select:disabled{
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
#player-settings table .range-container{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -138,12 +142,27 @@ html{
|
||||
#player-settings table .special-range-wrapper{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
#player-settings table .special-range-wrapper input[type=range]{
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#player-settings table .randomize-button {
|
||||
max-height: 24px;
|
||||
line-height: 16px;
|
||||
padding: 2px 8px;
|
||||
margin: 0 0 0 0.25rem;
|
||||
font-size: 12px;
|
||||
border: 1px solid black;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#player-settings table .randomize-button.active {
|
||||
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
|
||||
}
|
||||
|
||||
#player-settings table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
html{
|
||||
padding-top: 110px;
|
||||
scroll-padding-top: 100px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
#base-header{
|
||||
|
||||
@@ -52,6 +52,7 @@ pre{
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
code{
|
||||
|
||||
@@ -52,6 +52,7 @@ pre{
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
code{
|
||||
|
||||
@@ -52,6 +52,7 @@ pre{
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
code{
|
||||
|
||||
@@ -52,6 +52,7 @@ pre{
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
code{
|
||||
|
||||
@@ -52,6 +52,7 @@ pre{
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
code{
|
||||
|
||||
@@ -53,6 +53,7 @@ pre{
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
code{
|
||||
|
||||
@@ -52,6 +52,7 @@ pre{
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
code{
|
||||
|
||||
@@ -50,6 +50,7 @@ pre{
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
code{
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import typing
|
||||
from collections import Counter, defaultdict
|
||||
from colorsys import hsv_to_rgb
|
||||
from datetime import datetime, timedelta, date
|
||||
from math import tau
|
||||
import typing
|
||||
|
||||
from bokeh.colors import RGB
|
||||
from bokeh.embed import components
|
||||
from bokeh.models import HoverTool
|
||||
from bokeh.plotting import figure, ColumnDataSource
|
||||
from bokeh.resources import INLINE
|
||||
from bokeh.colors import RGB
|
||||
from flask import render_template
|
||||
from pony.orm import select
|
||||
|
||||
@@ -18,7 +18,8 @@ from .models import Room
|
||||
PLOT_WIDTH = 600
|
||||
|
||||
|
||||
def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
|
||||
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
|
||||
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
||||
games_played = defaultdict(Counter)
|
||||
total_games = Counter()
|
||||
cutoff = date.today()-timedelta(days=30)
|
||||
@@ -93,7 +94,7 @@ def stats():
|
||||
occurences, legend_label=game, line_width=2, color=game_to_color[game])
|
||||
|
||||
total = sum(total_games.values())
|
||||
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
||||
pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
||||
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
||||
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
||||
pie.axis.visible = False
|
||||
@@ -121,7 +122,8 @@ def stats():
|
||||
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
||||
source=ColumnDataSource(data=data), legend_field="games")
|
||||
|
||||
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games
|
||||
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in
|
||||
sorted(total_games, key=lambda game: total_games[game])
|
||||
if total_games[game] > 1]
|
||||
|
||||
script, charts = components((plot, pie, *per_game_charts))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Mystery Check Result</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Generate Game</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Upload Multidata</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>
|
||||
|
||||
@@ -20,12 +20,16 @@
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
Should you wish to continue later,
|
||||
anyone can simply refresh this page and the server will resume.<br>
|
||||
{% if room.last_port %}
|
||||
{% if room.last_port == -1 %}
|
||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||
{% elif room.last_port %}
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||
{% endif %}
|
||||
{{ macros.list_patches_room(room) }}
|
||||
{% if room.owner == session["_id"] %}
|
||||
<form method=post>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{% elif patch.game | supports_apdeltapatch %}
|
||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||
Download Patch File...</a>
|
||||
{% elif patch.game == "Dark Souls III" %}
|
||||
{% elif patch.game == "Dark Souls III" and patch.data %}
|
||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||
Download JSON File...</a>
|
||||
{% else %}
|
||||
|
||||
@@ -43,6 +43,19 @@
|
||||
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
|
||||
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
||||
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
||||
<td>
|
||||
<div class="counted-item">
|
||||
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
|
||||
<div class="item-count">{{ shard_count }}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
|
||||
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
|
||||
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
|
||||
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
|
||||
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<table id="location-table">
|
||||
|
||||
@@ -1,56 +1,85 @@
|
||||
# What is this file?
|
||||
# This file contains options which allow you to configure your multiworld experience while allowing others
|
||||
# to play how they want as well.
|
||||
|
||||
# How do I use it?
|
||||
# The options in this file are weighted. This means the higher number you assign to a value, the more
|
||||
# chances you have for that option to be chosen. For example, an option like this:
|
||||
# Q. What is this file?
|
||||
# A. This file contains options which allow you to configure your multiworld experience while allowing
|
||||
# others to play how they want as well.
|
||||
#
|
||||
# map_shuffle:
|
||||
# on: 5
|
||||
# off: 15
|
||||
# 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:
|
||||
#
|
||||
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned off
|
||||
# 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
|
||||
|
||||
# I've never seen a file like this before. What characters am I allowed to use?
|
||||
# This is a .yaml file. You are allowed to use most characters.
|
||||
# To test if your yaml is valid or not, you can use this website:
|
||||
# http://www.yamllint.com/
|
||||
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit.
|
||||
# {player} will be replaced with the player's slot number.
|
||||
# {PLAYER} will be replaced with the player's slot number, if that slot number is greater than 1.
|
||||
# {number} will be replaced with the counter value of the name.
|
||||
# {NUMBER} will be replaced with the counter value of the name, if the counter value is greater than 1.
|
||||
name: Player{number}
|
||||
|
||||
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
|
||||
# Used to describe your yaml. Useful if you have multiple files.
|
||||
description: Default {{ game }} Template
|
||||
|
||||
game: {{ game }}
|
||||
requires:
|
||||
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
||||
# Shared Options supported by all games:
|
||||
|
||||
{%- macro range_option(option) %}
|
||||
# you can add additional values between minimum and maximum
|
||||
# You can define additional values between the minimum and maximum values.
|
||||
# Minimum value is {{ option.range_start }}
|
||||
# Maximum value is {{ option.range_end }}
|
||||
{%- set data, notes = dictify_range(option) %}
|
||||
{%- for entry, default in data.items() %}
|
||||
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
|
||||
{%- endfor -%}
|
||||
{% endmacro %}
|
||||
|
||||
{{ game }}:
|
||||
{%- for option_key, option in options.items() %}
|
||||
{{ option_key }}:{% if option.__doc__ %} # {{ option.__doc__ | replace('\n', '\n#') | indent(4, first=False) }}{% endif %}
|
||||
{{ option_key }}:
|
||||
{%- if option.__doc__ %}
|
||||
# {{ option.__doc__
|
||||
| trim
|
||||
| replace('\n\n', '\n \n')
|
||||
| replace('\n ', '\n# ')
|
||||
| indent(4, first=False)
|
||||
}}
|
||||
{%- endif -%}
|
||||
|
||||
{%- if option.__doc__ and option.range_start is defined %}
|
||||
#
|
||||
{%- endif -%}
|
||||
|
||||
{%- if option.range_start is defined and option.range_start is number %}
|
||||
{{- range_option(option) -}}
|
||||
|
||||
{%- elif option.options -%}
|
||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||
{%- endfor -%}
|
||||
{% if option.default == "random" %}
|
||||
random: 50
|
||||
|
||||
{%- if option.name_lookup[option.default] not in option.options %}
|
||||
{{ option.default }}: 50
|
||||
{%- endif -%}
|
||||
|
||||
{%- elif option.default is string %}
|
||||
{{ option.default }}: 50
|
||||
|
||||
{%- elif option.default is iterable and option.default is not mapping %}
|
||||
{{ option.default | list }}
|
||||
|
||||
{%- else %}
|
||||
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
|
||||
{%- endif -%}
|
||||
{{ yaml_dump(option.default) | trim | indent(4, first=false) }}
|
||||
{%- endif -%}
|
||||
{{ "\n" }}
|
||||
{%- endfor %}
|
||||
{% if not options %}{}{% endif %}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Start Playing</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
|
||||
{% if 'FacebookMode' in options %}
|
||||
{% if 'EyeSpy' in options %}
|
||||
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import collections
|
||||
import datetime
|
||||
import typing
|
||||
from typing import Counter, Optional, Dict, Any, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import render_template
|
||||
from werkzeug.exceptions import abort
|
||||
import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from worlds.alttp import Items
|
||||
from WebHostLib import app, cache, Room
|
||||
from Utils import restricted_loads
|
||||
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||
from MultiServer import Context
|
||||
from NetUtils import SlotType
|
||||
from Utils import restricted_loads
|
||||
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||
from worlds.alttp import Items
|
||||
from . import app, cache
|
||||
from .models import Room
|
||||
|
||||
alttp_icons = {
|
||||
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
|
||||
@@ -442,17 +443,23 @@ 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",
|
||||
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
|
||||
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
|
||||
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
|
||||
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
|
||||
"Saddle": "https://i.imgur.com/2QtDyR0.png",
|
||||
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
|
||||
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
|
||||
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
|
||||
}
|
||||
|
||||
minecraft_location_ids = {
|
||||
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
||||
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
||||
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
|
||||
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42014],
|
||||
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
|
||||
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
|
||||
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42035, 42016, 42020,
|
||||
42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42099, 42100],
|
||||
"Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028, 42036,
|
||||
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
|
||||
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100],
|
||||
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112,
|
||||
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
|
||||
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
|
||||
}
|
||||
@@ -481,7 +488,8 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
||||
# Multi-items
|
||||
multi_items = {
|
||||
"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():
|
||||
base_name = item_name.split()[-1].lower()
|
||||
@@ -819,27 +827,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:
|
||||
|
||||
icons = {
|
||||
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ETank.png",
|
||||
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Missile.png",
|
||||
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Super.png",
|
||||
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/PowerBomb.png",
|
||||
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Bomb.png",
|
||||
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Charge.png",
|
||||
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Ice.png",
|
||||
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/HiJump.png",
|
||||
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpeedBooster.png",
|
||||
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Wave.png",
|
||||
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Spazer.png",
|
||||
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpringBall.png",
|
||||
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Varia.png",
|
||||
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Plasma.png",
|
||||
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Grapple.png",
|
||||
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Morph.png",
|
||||
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Reserve.png",
|
||||
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Gravity.png",
|
||||
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/XRayScope.png",
|
||||
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpaceJump.png",
|
||||
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ScrewAttack.png",
|
||||
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png",
|
||||
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png",
|
||||
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png",
|
||||
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png",
|
||||
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png",
|
||||
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png",
|
||||
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png",
|
||||
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png",
|
||||
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png",
|
||||
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png",
|
||||
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png",
|
||||
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png",
|
||||
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png",
|
||||
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png",
|
||||
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png",
|
||||
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png",
|
||||
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png",
|
||||
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png",
|
||||
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png",
|
||||
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png",
|
||||
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png",
|
||||
"Nothing": "",
|
||||
"No Energy": "",
|
||||
"Kraid": "",
|
||||
@@ -1037,4 +1045,4 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
||||
"Timespinner": __renderTimespinnerTracker,
|
||||
"A Link to the Past": __renderAlttpTracker,
|
||||
"Super Metroid": __renderSuperMetroidTracker
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import typing
|
||||
import zipfile
|
||||
import lzma
|
||||
import json
|
||||
import base64
|
||||
import MultiServer
|
||||
import json
|
||||
import typing
|
||||
import uuid
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import flush, select
|
||||
|
||||
from WebHostLib import app, Seed, Room, Slot
|
||||
from Utils import parse_yaml, VersionException, __version__
|
||||
from Patch import preferred_endings, AutoPatchRegister
|
||||
import MultiServer
|
||||
from NetUtils import NetworkSlot, SlotType
|
||||
from Utils import VersionException, __version__
|
||||
from worlds.Files import AutoPatchRegister
|
||||
from . import app
|
||||
from .models import Seed, Room, Slot
|
||||
|
||||
banned_zip_contents = (".sfc",)
|
||||
|
||||
@@ -22,7 +22,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
||||
if not owner:
|
||||
owner = session["_id"]
|
||||
infolist = zfile.infolist()
|
||||
slots = set()
|
||||
slots: typing.Set[Slot] = set()
|
||||
spoiler = ""
|
||||
multidata = None
|
||||
for file in infolist:
|
||||
@@ -38,17 +38,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
||||
player_name=patch.player_name,
|
||||
player_id=patch.player,
|
||||
game=patch.game))
|
||||
elif file.filename.endswith(tuple(preferred_endings.values())):
|
||||
data = zfile.open(file, "r").read()
|
||||
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
|
||||
if yaml_data["version"] < 2:
|
||||
return "Old format cannot be uploaded (outdated .apbp)"
|
||||
metadata = yaml_data["meta"]
|
||||
|
||||
slots.add(Slot(data=data,
|
||||
player_name=metadata["player_name"],
|
||||
player_id=metadata["player_id"],
|
||||
game=yaml_data["game"]))
|
||||
|
||||
elif file.filename.endswith(".apmc"):
|
||||
data = zfile.open(file, "r").read()
|
||||
|
||||
495
ZillionClient.py
Normal file
495
ZillionClient.py
Normal file
@@ -0,0 +1,495 @@
|
||||
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()
|
||||
Binary file not shown.
BIN
data/basepatch.bsdiff4
Normal file
BIN
data/basepatch.bsdiff4
Normal file
Binary file not shown.
@@ -15,6 +15,8 @@
|
||||
<UILog>:
|
||||
viewclass: 'SelectableLabel'
|
||||
scroll_y: 0
|
||||
scroll_type: ["content", "bars"]
|
||||
bar_width: dp(12)
|
||||
effect_cls: "ScrollEffect"
|
||||
SelectableRecycleBoxLayout:
|
||||
default_size: None, dp(20)
|
||||
|
||||
@@ -77,12 +77,13 @@ local scrub_sanity_check = function(scene_offset, bit_to_check)
|
||||
return scene_check(scene_offset, bit_to_check, 0x10)
|
||||
end
|
||||
|
||||
-- Why is there an extra offset of 3 for temp context checks? Who knows.
|
||||
local cow_check = function(scene_offset, bit_to_check)
|
||||
return scene_check(scene_offset, bit_to_check, 0xC)
|
||||
or check_temp_context({scene_offset, 0x00, bit_to_check})
|
||||
or check_temp_context({scene_offset, 0x00, bit_to_check - 0x03})
|
||||
end
|
||||
|
||||
-- Haven't been able to get DMT and DMC fairy to send instantly
|
||||
-- DMT and DMC fairies are weird, their temp context check is special-coded for them
|
||||
local great_fairy_magic_check = function(scene_offset, bit_to_check)
|
||||
return scene_check(scene_offset, bit_to_check, 0x4)
|
||||
or check_temp_context({scene_offset, 0x05, bit_to_check})
|
||||
@@ -100,6 +101,18 @@ local bean_sale_check = function(scene_offset, bit_to_check)
|
||||
or check_temp_context({scene_offset, 0x00, 0x16})
|
||||
end
|
||||
|
||||
-- Medigoron reports 0x00620028 to 0x40002C
|
||||
local medigoron_check = function(scene_offset, bit_to_check)
|
||||
return scene_check(scene_offset, bit_to_check, 0xC)
|
||||
or check_temp_context({scene_offset, 0x00, 0x28})
|
||||
end
|
||||
|
||||
-- Bombchu salesman reports 0x005E0003 to 0x40002C
|
||||
local salesman_check = function(scene_offset, bit_to_check)
|
||||
return scene_check(scene_offset, bit_to_check, 0xC)
|
||||
or check_temp_context({scene_offset, 0x00, 0x03})
|
||||
end
|
||||
|
||||
--Helper method to resolve skulltula lookup location
|
||||
local function skulltula_scene_to_array_index(i)
|
||||
return (i + 3) - 2 * (i % 4)
|
||||
@@ -575,7 +588,7 @@ local read_death_mountain_trail_checks = function()
|
||||
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
|
||||
checks["DMT Chest"] = chest_check(0x60, 0x01)
|
||||
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
|
||||
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18)
|
||||
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18) or check_temp_context({0xFF, 0x05, 0x13})
|
||||
checks["DMT Biggoron"] = big_goron_sword_check()
|
||||
checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18)
|
||||
|
||||
@@ -592,7 +605,7 @@ local read_goron_city_checks = function()
|
||||
checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F)
|
||||
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
|
||||
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
|
||||
checks["GC Medigoron"] = on_the_ground_check(0x62, 0x1)
|
||||
checks["GC Medigoron"] = medigoron_check(0x62, 0x1)
|
||||
checks["GC Maze Left Chest"] = chest_check(0x62, 0x00)
|
||||
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
|
||||
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
|
||||
@@ -614,7 +627,7 @@ local read_death_mountain_crater_checks = function()
|
||||
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
|
||||
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
|
||||
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
|
||||
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10)
|
||||
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10) or check_temp_context({0xFF, 0x05, 0x14})
|
||||
|
||||
checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6)
|
||||
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
|
||||
@@ -961,7 +974,7 @@ end
|
||||
|
||||
local read_haunted_wasteland_checks = function()
|
||||
local checks = {}
|
||||
checks["Wasteland Bombchu Salesman"] = on_the_ground_check(0x5E, 0x01)
|
||||
checks["Wasteland Bombchu Salesman"] = salesman_check(0x5E, 0x01)
|
||||
checks["Wasteland Chest"] = chest_check(0x5E, 0x00)
|
||||
checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
|
||||
return checks
|
||||
|
||||
BIN
data/lua/PKMN_RB/core.dll
Normal file
BIN
data/lua/PKMN_RB/core.dll
Normal file
Binary file not shown.
389
data/lua/PKMN_RB/json.lua
Normal file
389
data/lua/PKMN_RB/json.lua
Normal file
@@ -0,0 +1,389 @@
|
||||
--
|
||||
-- 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
|
||||
226
data/lua/PKMN_RB/pkmn_rb.lua
Normal file
226
data/lua/PKMN_RB/pkmn_rb.lua
Normal file
@@ -0,0 +1,226 @@
|
||||
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()
|
||||
132
data/lua/PKMN_RB/socket.lua
Normal file
132
data/lua/PKMN_RB/socket.lua
Normal file
@@ -0,0 +1,132 @@
|
||||
-----------------------------------------------------------------------------
|
||||
-- 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
|
||||
bsdiff between the original and the randomized ROM.
|
||||
|
||||
To make using APBP easy, they can be generated by inheriting from `Patch.APDeltaPatch`.
|
||||
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`.
|
||||
|
||||
### Mod files
|
||||
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.
|
||||
|
||||
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 `Patch.APContainer`.
|
||||
integration into the Webhost by inheriting from `worlds.Files.APContainer`.
|
||||
|
||||
|
||||
## Archipelago Integration
|
||||
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -21,10 +21,11 @@ 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). |
|
||||
| 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) | |
|
||||
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | almost-header-only |
|
||||
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | header-only |
|
||||
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
|
||||
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
|
||||
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
|
||||
| Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | |
|
||||
|
||||
## 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.
|
||||
@@ -234,6 +235,8 @@ 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. |
|
||||
|
||||
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.
|
||||
|
||||
### SetReply
|
||||
@@ -371,7 +374,7 @@ Used to write data to the server's data storage, that data can then be shared ac
|
||||
| ------ | ----- | ------ |
|
||||
| key | str | The key to manipulate. |
|
||||
| default | any | The default value to use in case the key has no value on the server. |
|
||||
| want_reply | bool | If set, the server will send a [SetReply](#SetReply) response back to the client. |
|
||||
| want_reply | bool | If true, 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. |
|
||||
|
||||
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
|
||||
|
||||
@@ -16,6 +16,14 @@ 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
* 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,
|
||||
use single quotes inside them: `f"Like {dct['key']}"`
|
||||
* Use type annotation where possible.
|
||||
* Use type annotations where possible for function signatures and class members.
|
||||
* 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
|
||||
|
||||
@@ -102,7 +102,7 @@ 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.
|
||||
|
||||
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
|
||||
in a Region and has access rules.
|
||||
in a Region, has access rules and a classification.
|
||||
The name needs to be unique in each game and must not be numeric (has to
|
||||
contain least 1 letter or symbol). The ID needs to be unique across all games
|
||||
and is best in the same range as the item IDs.
|
||||
@@ -110,6 +110,10 @@ 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.
|
||||
|
||||
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 are all things that can "drop" for your game. This may be RPG items like
|
||||
@@ -455,7 +459,7 @@ In addition, the following methods can be implemented and attributes can be set
|
||||
```python
|
||||
def generate_early(self) -> None:
|
||||
# read player settings to world instance
|
||||
self.final_boss_hp = self.world.final_boss_hp[self.player].value
|
||||
self.final_boss_hp = self.multiworld.final_boss_hp[self.player].value
|
||||
```
|
||||
|
||||
#### create_item
|
||||
@@ -490,19 +494,19 @@ def create_items(self) -> None:
|
||||
# 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
|
||||
exclude = [item for item in self.world.precollected_items[self.player]]
|
||||
exclude = [item for item in self.multiworld.precollected_items[self.player]]
|
||||
|
||||
for item in map(self.create_item, mygame_items):
|
||||
if item in exclude:
|
||||
exclude.remove(item) # this is destructive. create unique list above
|
||||
self.world.itempool.append(self.create_item("nothing"))
|
||||
self.multiworld.itempool.append(self.create_item("nothing"))
|
||||
else:
|
||||
self.world.itempool.append(item)
|
||||
self.multiworld.itempool.append(item)
|
||||
|
||||
# itempool and number of locations should match up.
|
||||
# If this is not the case we want to fill the itempool with junk.
|
||||
junk = 0 # calculate this based on player settings
|
||||
self.world.itempool += [self.create_item("nothing") for _ in range(junk)]
|
||||
self.multiworld.itempool += [self.create_item("nothing") for _ in range(junk)]
|
||||
```
|
||||
|
||||
#### create_regions
|
||||
@@ -511,30 +515,30 @@ def create_items(self) -> None:
|
||||
def create_regions(self) -> None:
|
||||
# Add regions to the multiworld. "Menu" is the required starting point.
|
||||
# Arguments to Region() are name, type, human_readable_name, player, world
|
||||
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.world)
|
||||
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.multiworld)
|
||||
# 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
|
||||
# Append region to MultiWorld's regions
|
||||
self.world.regions.append(r) # or use += [r...]
|
||||
self.multiworld.regions.append(r) # or use += [r...]
|
||||
|
||||
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.world)
|
||||
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.multiworld)
|
||||
# Add main area's locations to main area (all but final boss)
|
||||
r.locations = [MyGameLocation(self.player, location.name,
|
||||
self.location_name_to_id[location.name], r)]
|
||||
r.exits = [Entrance(self.player, "Boss Door", r)]
|
||||
self.world.regions.append(r)
|
||||
self.multiworld.regions.append(r)
|
||||
|
||||
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.world)
|
||||
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.multiworld)
|
||||
# add event to Boss Room
|
||||
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
|
||||
self.world.regions.append(r)
|
||||
self.multiworld.regions.append(r)
|
||||
|
||||
# If entrances are not randomized, they should be connected here, otherwise
|
||||
# they can also be connected at a later stage.
|
||||
self.world.get_entrance("New Game", self.player)\
|
||||
.connect(self.world.get_region("Main Area", self.player))
|
||||
self.world.get_entrance("Boss Door", self.player)\
|
||||
.connect(self.world.get_region("Boss Room", self.player))
|
||||
self.multiworld.get_entrance("New Game", self.player)
|
||||
.connect(self.multiworld.get_region("Main Area", self.player))
|
||||
self.multiworld.get_entrance("Boss Door", self.player)
|
||||
.connect(self.multiworld.get_region("Boss Room", self.player))
|
||||
|
||||
# If setting location access rules from data is easier here, set_rules can
|
||||
# possibly omitted.
|
||||
@@ -545,14 +549,14 @@ def create_regions(self) -> None:
|
||||
```python
|
||||
def generate_basic(self) -> None:
|
||||
# place "Victory" at "Final Boss" and set collection as win condition
|
||||
self.world.get_location("Final Boss", self.player)\
|
||||
self.multiworld.get_location("Final Boss", self.player)
|
||||
.place_locked_item(self.create_event("Victory"))
|
||||
self.world.completion_condition[self.player] = \
|
||||
self.multiworld.completion_condition[self.player] =
|
||||
lambda state: state.has("Victory", self.player)
|
||||
|
||||
# place item Herb into location Chest1 for some reason
|
||||
item = self.create_item("Herb")
|
||||
self.world.get_location("Chest1", self.player).place_locked_item(item)
|
||||
self.multiworld.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
|
||||
# filled to avoid accidental duplicates:
|
||||
# manually placed and still in the itempool
|
||||
@@ -564,41 +568,42 @@ def generate_basic(self) -> None:
|
||||
from worlds.generic.Rules import add_rule, set_rule, forbid_item
|
||||
from Items import get_item_type
|
||||
|
||||
|
||||
def set_rules(self) -> None:
|
||||
# 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
|
||||
# location generation or everything is in generate_basic
|
||||
|
||||
# set a simple rule for an region
|
||||
set_rule(self.world.get_entrance("Boss Door", self.player),
|
||||
set_rule(self.multiworld.get_entrance("Boss Door", self.player),
|
||||
lambda state: state.has("Boss Key", self.player))
|
||||
# combine rules to require two items
|
||||
add_rule(self.world.get_location("Chest2", self.player),
|
||||
add_rule(self.multiworld.get_location("Chest2", self.player),
|
||||
lambda state: state.has("Sword", self.player))
|
||||
add_rule(self.world.get_location("Chest2", self.player),
|
||||
add_rule(self.multiworld.get_location("Chest2", self.player),
|
||||
lambda state: state.has("Shield", self.player))
|
||||
# or simply combine yourself
|
||||
set_rule(self.world.get_location("Chest2", self.player),
|
||||
set_rule(self.multiworld.get_location("Chest2", self.player),
|
||||
lambda state: state.has("Sword", self.player) and
|
||||
state.has("Shield", self.player))
|
||||
# require two of an item
|
||||
set_rule(self.world.get_location("Chest3", self.player),
|
||||
set_rule(self.multiworld.get_location("Chest3", self.player),
|
||||
lambda state: state.has("Key", self.player, 2))
|
||||
# require one item from an item group
|
||||
add_rule(self.world.get_location("Chest3", self.player),
|
||||
add_rule(self.multiworld.get_location("Chest3", self.player),
|
||||
lambda state: state.has_group("weapons", self.player))
|
||||
# state also has .item_count() for items, .has_any() and.has_all() for sets
|
||||
# and .count_group() for groups
|
||||
# set_rule is likely to be a bit faster than add_rule
|
||||
|
||||
# disallow placing a specific local item at a specific location
|
||||
forbid_item(self.world.get_location("Chest4", self.player), "Sword")
|
||||
forbid_item(self.multiworld.get_location("Chest4", self.player), "Sword")
|
||||
# disallow placing items with a specific property
|
||||
add_item_rule(self.world.get_location("Chest5", self.player),
|
||||
add_item_rule(self.multiworld.get_location("Chest5", self.player),
|
||||
lambda item: get_item_type(item) == "weapon")
|
||||
# get_item_type needs to take player/world into account
|
||||
# if MyGameItem has a type property, a more direct implementation would be
|
||||
add_item_rule(self.world.get_location("Chest5", self.player),
|
||||
add_item_rule(self.multiworld.get_location("Chest5", self.player),
|
||||
lambda item: item.player != self.player or\
|
||||
item.my_type == "weapon")
|
||||
# location.item_rule = ... is likely to be a bit faster
|
||||
@@ -617,8 +622,10 @@ the name of the implementing world. This is due to sharing a namespace with all
|
||||
other logic mixins.
|
||||
|
||||
Typical uses are defining methods that are used instead of `state.has`
|
||||
in lambdas, e.g.`state._mygame_has(custom, world, player)` or recurring checks
|
||||
like `state._mygame_can_do_something(world, player)` to simplify lambdas.
|
||||
in lambdas, e.g.`state.mygame_has(custom, player)` or recurring checks
|
||||
like `state.mygame_can_do_something(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,
|
||||
override `World.collect(self, state, item)` and `remove(self, state, item)`
|
||||
@@ -633,9 +640,10 @@ Please do this with caution and only when neccessary.
|
||||
from worlds.AutoWorld import LogicMixin
|
||||
|
||||
class MyGameLogic(LogicMixin):
|
||||
def _mygame_has_key(self, world: MultiWorld, player: int):
|
||||
def mygame_has_key(self, player: int):
|
||||
# Arguments above are free to choose
|
||||
# it may make sense to use World as argument instead of MultiWorld
|
||||
# MultiWorld can be accessed through self.world, explicitly passing in
|
||||
# MyGameWorld instance for easy options access is also a valid approach
|
||||
return self.has("key", player) # or whatever
|
||||
```
|
||||
```python
|
||||
@@ -648,7 +656,7 @@ class MyGameWorld(World):
|
||||
# ...
|
||||
def set_rules(self):
|
||||
set_rule(self.world.get_location("A Door", self.player),
|
||||
lamda state: state._mygame_has_key(self.world, self.player))
|
||||
lamda state: state.mygame_has_key(self.player))
|
||||
```
|
||||
|
||||
### Generate Output
|
||||
@@ -656,32 +664,33 @@ class MyGameWorld(World):
|
||||
```python
|
||||
from .Mod import generate_mod
|
||||
|
||||
|
||||
def generate_output(self, output_directory: str):
|
||||
# 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 reads a json file, `json.dump()` can be used to generate that
|
||||
# code below is a dummy
|
||||
data = {
|
||||
"seed": self.world.seed_name, # to verify the server's multiworld
|
||||
"slot": self.world.player_name[self.player], # to connect to server
|
||||
"seed": self.multiworld.seed_name, # to verify the server's multiworld
|
||||
"slot": self.multiworld.player_name[self.player], # to connect to server
|
||||
"items": {location.name: location.item.name
|
||||
if location.item.player == self.player else "Remote"
|
||||
for location in self.world.get_filled_locations(self.player)},
|
||||
for location in self.multiworld.get_filled_locations(self.player)},
|
||||
# store start_inventory from player's .yaml
|
||||
"starter_items": [item.name for item
|
||||
in self.world.precollected_items[self.player]],
|
||||
in self.multiworld.precollected_items[self.player]],
|
||||
"final_boss_hp": self.final_boss_hp,
|
||||
# store option name "easy", "normal" or "hard" for difficuly
|
||||
"difficulty": self.world.difficulty[self.player].current_key,
|
||||
"difficulty": self.multiworld.difficulty[self.player].current_key,
|
||||
# store option value True or False for fixing a glitch
|
||||
"fix_xyz_glitch": self.world.fix_xyz_glitch[self.player].value
|
||||
"fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value
|
||||
}
|
||||
# point to a ROM specified by the installation
|
||||
src = Utils.get_options()["mygame_options"]["rom_file"]
|
||||
# or point to worlds/mygame/data/mod_template
|
||||
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
|
||||
# generate output path
|
||||
mod_name = f"AP-{self.world.seed_name}-P{self.player}-{self.world.player_name[self.player]}"
|
||||
mod_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.multiworld.player_name[self.player]}"
|
||||
out_file = os.path.join(output_directory, mod_name + ".zip")
|
||||
# generate the file
|
||||
generate_mod(src, out_file, data)
|
||||
|
||||
50
host.yaml
50
host.yaml
@@ -82,28 +82,27 @@ generator:
|
||||
# List of options that can be plando'd. Can be combined, for example "bosses, items"
|
||||
# Available options: bosses, items, texts, connections
|
||||
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:
|
||||
# File name of the v1.0 J rom
|
||||
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:
|
||||
# File name of the v1.0 J rom
|
||||
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:
|
||||
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.
|
||||
# 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:
|
||||
forge_directory: "Minecraft Forge server"
|
||||
max_heap_size: "2G"
|
||||
@@ -122,19 +121,26 @@ soe_options:
|
||||
rom_file: "Secret of Evermore (USA).sfc"
|
||||
ffr_options:
|
||||
display_msgs: true
|
||||
smz3_options:
|
||||
# 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
|
||||
dkc3_options:
|
||||
# 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"
|
||||
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)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
rom_start: true
|
||||
# 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"
|
||||
|
||||
204
inno_setup.iss
204
inno_setup.iss
@@ -55,21 +55,30 @@ Name: "core"; Description: "Core Files"; Types: full hosting playing
|
||||
Name: "generator"; Description: "Generator"; Types: full hosting
|
||||
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/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/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/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: "client"; Description: "Clients"; 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/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/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
||||
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||
Name: "client/oot"; Description: "Ocarina of Time"; 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/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
|
||||
|
||||
[Dirs]
|
||||
@@ -79,8 +88,12 @@ 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: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: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: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}\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
|
||||
@@ -94,7 +107,9 @@ Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: i
|
||||
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}\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}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
|
||||
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: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
|
||||
@@ -107,7 +122,9 @@ Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.e
|
||||
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} 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} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
|
||||
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
|
||||
|
||||
@@ -117,7 +134,9 @@ 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} 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} 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} 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} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
|
||||
|
||||
@@ -151,6 +170,16 @@ 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\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: "{#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
|
||||
@@ -171,6 +200,16 @@ 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\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: "{#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
|
||||
@@ -217,12 +256,24 @@ var SMRomFilePage: TInputFileWizardPage;
|
||||
var dkc3rom: string;
|
||||
var DKC3RomFilePage: TInputFileWizardPage;
|
||||
|
||||
var smwrom: string;
|
||||
var SMWRomFilePage: TInputFileWizardPage;
|
||||
|
||||
var soerom: string;
|
||||
var SoERomFilePage: TInputFileWizardPage;
|
||||
|
||||
var ootrom: string;
|
||||
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;
|
||||
var data: AnsiString;
|
||||
begin
|
||||
@@ -236,6 +287,15 @@ begin
|
||||
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;
|
||||
var rom: string;
|
||||
begin
|
||||
@@ -255,6 +315,25 @@ begin
|
||||
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;
|
||||
begin
|
||||
Result :=
|
||||
@@ -270,6 +349,37 @@ begin
|
||||
'.sfc');
|
||||
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();
|
||||
begin
|
||||
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
|
||||
@@ -308,10 +418,14 @@ begin
|
||||
Result := not (SMROMFilePage.Values[0] = '')
|
||||
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
|
||||
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
|
||||
Result := not (SoEROMFilePage.Values[0] = '')
|
||||
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
||||
Result := not (OoTROMFilePage.Values[0] = '')
|
||||
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
|
||||
Result := not (ZlROMFilePage.Values[0] = '')
|
||||
else
|
||||
Result := True;
|
||||
end;
|
||||
@@ -364,6 +478,22 @@ begin
|
||||
Result := '';
|
||||
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;
|
||||
begin
|
||||
if Length(soerom) > 0 then
|
||||
@@ -396,6 +526,54 @@ begin
|
||||
Result := '';
|
||||
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();
|
||||
begin
|
||||
AddOoTRomPage();
|
||||
@@ -412,9 +590,25 @@ begin
|
||||
if Length(dkc3rom) = 0 then
|
||||
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');
|
||||
if Length(soerom) = 0 then
|
||||
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;
|
||||
|
||||
|
||||
@@ -427,8 +621,16 @@ begin
|
||||
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
|
||||
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
|
||||
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
|
||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
|
||||
end;
|
||||
if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then
|
||||
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,6 +28,7 @@ from kivy.factory import Factory
|
||||
from kivy.properties import BooleanProperty, ObjectProperty
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.layout import Layout
|
||||
from kivy.uix.textinput import TextInput
|
||||
from kivy.uix.recycleview import RecycleView
|
||||
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
||||
@@ -48,6 +49,7 @@ fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, dura
|
||||
|
||||
|
||||
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
||||
from Utils import async_start
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import CommonClient
|
||||
@@ -299,6 +301,9 @@ class GameManager(App):
|
||||
base_title: str = "Archipelago Client"
|
||||
last_autofillable_command: str
|
||||
|
||||
main_area_container: GridLayout
|
||||
""" subclasses can add more columns beside the tabs """
|
||||
|
||||
def __init__(self, ctx: context_type):
|
||||
self.title = self.base_title
|
||||
self.ctx = ctx
|
||||
@@ -325,7 +330,7 @@ class GameManager(App):
|
||||
|
||||
super(GameManager, self).__init__()
|
||||
|
||||
def build(self):
|
||||
def build(self) -> Layout:
|
||||
self.container = ContainerLayout()
|
||||
|
||||
self.grid = MainLayout()
|
||||
@@ -334,9 +339,12 @@ class GameManager(App):
|
||||
# top part
|
||||
server_label = ServerLabel()
|
||||
self.connect_layout.add_widget(server_label)
|
||||
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.server_address or "archipelago.gg", size_hint_y=None,
|
||||
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", size_hint_y=None,
|
||||
height=30, multiline=False, write_tab=False)
|
||||
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
|
||||
def connect_bar_validate(sender):
|
||||
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.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)
|
||||
@@ -358,7 +366,10 @@ class GameManager(App):
|
||||
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
self.grid.add_widget(self.tabs)
|
||||
self.main_area_container = GridLayout(size_hint_y=1, rows=1)
|
||||
self.main_area_container.add_widget(self.tabs)
|
||||
|
||||
self.grid.add_widget(self.main_area_container)
|
||||
|
||||
if len(self.logging_pairs) == 1:
|
||||
# Hide Tab selection if only one tab
|
||||
@@ -374,17 +385,19 @@ class GameManager(App):
|
||||
bottom_layout.add_widget(info_button)
|
||||
self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
|
||||
self.textinput.bind(on_text_validate=self.on_message)
|
||||
|
||||
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
|
||||
self.textinput.text_validate_unfocus = False
|
||||
bottom_layout.add_widget(self.textinput)
|
||||
self.grid.add_widget(bottom_layout)
|
||||
self.commandprocessor("/help")
|
||||
Clock.schedule_interval(self.update_texts, 1 / 30)
|
||||
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
|
||||
|
||||
def update_texts(self, dt):
|
||||
@@ -395,10 +408,12 @@ class GameManager(App):
|
||||
f" | Connected to: {self.ctx.server_address} " \
|
||||
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
|
||||
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.value = len(self.ctx.checked_locations)
|
||||
else:
|
||||
self.server_connect_button.text = "Connect"
|
||||
self.server_connect_bar.readonly = False
|
||||
self.title = self.base_title + " " + Utils.__version__
|
||||
self.progressbar.value = 0
|
||||
|
||||
@@ -411,11 +426,10 @@ class GameManager(App):
|
||||
|
||||
def connect_button_action(self, button):
|
||||
if self.ctx.server:
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
asyncio.create_task(self.ctx.disconnect())
|
||||
async_start(self.ctx.disconnect())
|
||||
else:
|
||||
asyncio.create_task(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
|
||||
async_start(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
|
||||
|
||||
def on_stop(self):
|
||||
# "kill" input tasks
|
||||
@@ -436,8 +450,6 @@ class GameManager(App):
|
||||
elif input_text:
|
||||
self.commandprocessor(input_text)
|
||||
|
||||
Clock.schedule_once(textinput.text_focus)
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger("Client").exception(e)
|
||||
|
||||
@@ -446,6 +458,10 @@ class GameManager(App):
|
||||
self.log_panels["Archipelago"].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):
|
||||
if hasattr(self, "server_connect_bar"):
|
||||
self.server_connect_bar.text = text
|
||||
|
||||
@@ -453,7 +453,7 @@ A Link to the Past:
|
||||
death_link:
|
||||
false: 50
|
||||
true: 0
|
||||
|
||||
|
||||
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.
|
||||
false: 50
|
||||
|
||||
3
setup.py
3
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
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
requirement = 'cx-Freeze>=6.11'
|
||||
requirement = 'cx-Freeze>=6.13.1'
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
import cx_Freeze
|
||||
@@ -289,6 +289,7 @@ tmp="${{exe#*/}}"
|
||||
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
||||
exe="{default_exe.parent}/$exe"
|
||||
fi
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
|
||||
$APPDIR/$exe "$@"
|
||||
""")
|
||||
launcher_filename.chmod(0o755)
|
||||
|
||||
@@ -11,18 +11,18 @@ from worlds.alttp.Items import ItemFactory
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
world: MultiWorld
|
||||
multiworld: MultiWorld
|
||||
_state_cache = {}
|
||||
|
||||
def get_state(self, items):
|
||||
if (self.world, tuple(items)) in self._state_cache:
|
||||
return self._state_cache[self.world, tuple(items)]
|
||||
state = CollectionState(self.world)
|
||||
if (self.multiworld, tuple(items)) in self._state_cache:
|
||||
return self._state_cache[self.multiworld, tuple(items)]
|
||||
state = CollectionState(self.multiworld)
|
||||
for item in items:
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item)
|
||||
state.sweep_for_events()
|
||||
self._state_cache[self.world, tuple(items)] = state
|
||||
self._state_cache[self.multiworld, tuple(items)] = state
|
||||
return state
|
||||
|
||||
def get_path(self, state, region):
|
||||
@@ -44,11 +44,11 @@ class TestBase(unittest.TestCase):
|
||||
items = item_pool[0]
|
||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||
state = self._get_items(item_pool, all_except)
|
||||
path = self.get_path(state, self.world.get_location(location, 1).parent_region)
|
||||
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
|
||||
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
|
||||
all_except=all_except, path=path, entry=i):
|
||||
|
||||
self.assertEqual(self.world.get_location(location, 1).can_reach(state), access)
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access)
|
||||
|
||||
# check for partial solution
|
||||
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,
|
||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||
state = self._get_items_partial(item_pool, missing_item)
|
||||
self.assertEqual(self.world.get_location(location, 1).can_reach(state), False)
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False)
|
||||
|
||||
def run_entrance_tests(self, access_pool):
|
||||
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
||||
items = item_pool[0]
|
||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||
state = self._get_items(item_pool, all_except)
|
||||
path = self.get_path(state, self.world.get_entrance(entrance, 1).parent_region)
|
||||
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
|
||||
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
|
||||
all_except=all_except, path=path, entry=i):
|
||||
|
||||
self.assertEqual(self.world.get_entrance(entrance, 1).can_reach(state), access)
|
||||
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
|
||||
|
||||
# check for partial solution
|
||||
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,
|
||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||
state = self._get_items_partial(item_pool, missing_item)
|
||||
self.assertEqual(self.world.get_entrance(entrance, 1).can_reach(state), False)
|
||||
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False)
|
||||
|
||||
def _get_items(self, item_pool, all_except):
|
||||
if all_except and len(all_except) > 0:
|
||||
items = self.world.itempool[:]
|
||||
items = self.multiworld.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.extend(ItemFactory(item_pool[0], 1))
|
||||
|
||||
@@ -14,46 +14,46 @@ from worlds import AutoWorld
|
||||
|
||||
class TestDungeon(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
self.multiworld = MultiWorld(1)
|
||||
args = Namespace()
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
self.multiworld.set_options(args)
|
||||
self.multiworld.set_default_common_options()
|
||||
self.starting_regions = [] # Where to start exploring
|
||||
self.remove_exits = [] # Block dungeon exits
|
||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||
create_regions(self.world, 1)
|
||||
create_dungeons(self.world, 1)
|
||||
create_shops(self.world, 1)
|
||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||
create_regions(self.multiworld, 1)
|
||||
create_dungeons(self.multiworld, 1)
|
||||
create_shops(self.multiworld, 1)
|
||||
for exitname, regionname in mandatory_connections:
|
||||
connect_simple(self.world, exitname, regionname, 1)
|
||||
connect_simple(self.world, 'Big Bomb Shop', 'Big Bomb Shop', 1)
|
||||
self.world.get_region('Menu', 1).exits = []
|
||||
self.world.swamp_patch_required[1] = True
|
||||
self.world.worlds[1].set_rules()
|
||||
self.world.worlds[1].create_items()
|
||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||
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))
|
||||
connect_simple(self.multiworld, exitname, regionname, 1)
|
||||
connect_simple(self.multiworld, 'Big Bomb Shop', 'Big Bomb Shop', 1)
|
||||
self.multiworld.get_region('Menu', 1).exits = []
|
||||
self.multiworld.swamp_patch_required[1] = True
|
||||
self.multiworld.worlds[1].set_rules()
|
||||
self.multiworld.worlds[1].create_items()
|
||||
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
||||
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))
|
||||
|
||||
def run_tests(self, access_pool):
|
||||
for exit in self.remove_exits:
|
||||
self.world.get_entrance(exit, 1).connected_region = self.world.get_region('Menu', 1)
|
||||
self.multiworld.get_entrance(exit, 1).connected_region = self.multiworld.get_region('Menu', 1)
|
||||
|
||||
for location, access, *item_pool in access_pool:
|
||||
items = item_pool[0]
|
||||
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):
|
||||
if all_except and len(all_except) > 0:
|
||||
items = self.world.itempool[:]
|
||||
items = self.multiworld.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.extend(ItemFactory(item_pool[0], 1))
|
||||
else:
|
||||
items = ItemFactory(items, 1)
|
||||
state = CollectionState(self.world)
|
||||
state.reachable_regions[1].add(self.world.get_region('Menu', 1))
|
||||
state = CollectionState(self.multiworld)
|
||||
state.reachable_regions[1].add(self.multiworld.get_region('Menu', 1))
|
||||
for region_name in self.starting_regions:
|
||||
region = self.world.get_region(region_name, 1)
|
||||
region = self.multiworld.get_region(region_name, 1)
|
||||
state.reachable_regions[1].add(region)
|
||||
for exit in region.exits:
|
||||
if exit.connected_region is not None:
|
||||
@@ -63,4 +63,4 @@ class TestDungeon(unittest.TestCase):
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item)
|
||||
|
||||
self.assertEqual(self.world.get_location(location, 1).can_reach(state), access)
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access)
|
||||
@@ -1,10 +1,11 @@
|
||||
from typing import List, Iterable
|
||||
import unittest
|
||||
from worlds.AutoWorld import World
|
||||
from Fill import FillError, balance_multiworld_progression, fill_restrictive, distribute_items_restrictive
|
||||
from Fill import FillError, balance_multiworld_progression, fill_restrictive, \
|
||||
distribute_early_items, distribute_items_restrictive
|
||||
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \
|
||||
ItemClassification
|
||||
from worlds.generic.Rules import CollectionRule, locality_rules, set_rule
|
||||
from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule
|
||||
|
||||
|
||||
def generate_multi_world(players: int = 1) -> MultiWorld:
|
||||
@@ -13,7 +14,7 @@ def generate_multi_world(players: int = 1) -> MultiWorld:
|
||||
for i in range(players):
|
||||
player_id = i+1
|
||||
world = World(multi_world, player_id)
|
||||
multi_world.game[player_id] = world
|
||||
multi_world.game[player_id] = f"Game {player_id}"
|
||||
multi_world.worlds[player_id] = world
|
||||
multi_world.player_name[player_id] = "Test Player " + str(player_id)
|
||||
region = Region("Menu", RegionType.Generic,
|
||||
@@ -27,7 +28,7 @@ def generate_multi_world(players: int = 1) -> MultiWorld:
|
||||
|
||||
|
||||
class PlayerDefinition(object):
|
||||
world: MultiWorld
|
||||
multiworld: MultiWorld
|
||||
id: int
|
||||
menu: Region
|
||||
locations: List[Location]
|
||||
@@ -36,7 +37,7 @@ class PlayerDefinition(object):
|
||||
regions: List[Region]
|
||||
|
||||
def __init__(self, world: MultiWorld, id: int, menu: Region, locations: List[Location] = [], prog_items: List[Item] = [], basic_items: List[Item] = []):
|
||||
self.world = world
|
||||
self.multiworld = world
|
||||
self.id = id
|
||||
self.menu = menu
|
||||
self.locations = locations
|
||||
@@ -48,7 +49,7 @@ class PlayerDefinition(object):
|
||||
region_tag = "_region" + str(len(self.regions))
|
||||
region_name = "player" + str(self.id) + region_tag
|
||||
region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
|
||||
"Region Hint", self.id, self.world)
|
||||
"Region Hint", self.id, self.multiworld)
|
||||
self.locations += generate_locations(size, self.id, None, region, region_tag)
|
||||
|
||||
entrance = Entrance(self.id, region_name + "_entrance", parent)
|
||||
@@ -57,7 +58,7 @@ class PlayerDefinition(object):
|
||||
entrance.access_rule = access_rule
|
||||
|
||||
self.regions.append(region)
|
||||
self.world.regions.append(region)
|
||||
self.multiworld.regions.append(region)
|
||||
|
||||
return region
|
||||
|
||||
@@ -359,6 +360,46 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
fill_restrictive(multi_world, multi_world.state,
|
||||
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):
|
||||
def test_basic_distribute(self):
|
||||
@@ -575,8 +616,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
multi_world.local_items[player1.id].value = set(names(player1.basic_items))
|
||||
multi_world.local_items[player2.id].value = set(names(player2.basic_items))
|
||||
locality_rules(multi_world, player1.id)
|
||||
locality_rules(multi_world, player2.id)
|
||||
locality_rules(multi_world)
|
||||
|
||||
distribute_items_restrictive(multi_world)
|
||||
|
||||
@@ -584,6 +624,55 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
self.assertEqual(item.player, item.location.player)
|
||||
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):
|
||||
def assertRegionContains(self, region: Region, item: Item) -> bool:
|
||||
|
||||
@@ -8,7 +8,7 @@ class TestImplemented(unittest.TestCase):
|
||||
def testCompletionCondition(self):
|
||||
"""Ensure a completion condition is set that has requirements."""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy"}:
|
||||
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy", "Sudoku"}:
|
||||
with self.subTest(gamename):
|
||||
world = setup_default_world(world_type)
|
||||
self.assertFalse(world.completion_condition[1](world.state))
|
||||
|
||||
@@ -19,6 +19,8 @@ class TestBase(unittest.TestCase):
|
||||
exclusion_dict = {
|
||||
"A Link to the Past":
|
||||
{"Pendants", "Crystals"},
|
||||
"Ocarina of Time":
|
||||
{"medallions", "stones", "rewards", "logic_bottles"},
|
||||
"Starcraft 2 Wings of Liberty":
|
||||
{"Missions"},
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestBase(unittest.TestCase):
|
||||
for location in world.get_locations():
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location):
|
||||
self.assertTrue(location.can_reach(state))
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
|
||||
with self.subTest("Completion Condition"):
|
||||
self.assertTrue(world.can_beat_game(state))
|
||||
@@ -28,7 +28,7 @@ class TestBase(unittest.TestCase):
|
||||
def testEmptyStateCanReachSomething(self):
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
# Final Fantasy logic is controlled by finalfantasyrandomizer.com
|
||||
if game_name != "Archipelago" and game_name != "Final Fantasy":
|
||||
if game_name not in {"Archipelago", "Final Fantasy", "Sudoku"}:
|
||||
with self.subTest("Game", game=game_name):
|
||||
world = setup_default_world(world_type)
|
||||
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:
|
||||
world = MultiWorld(1)
|
||||
world.game[1] = world_type.game
|
||||
world.player_name = {1: "Tester"}
|
||||
world.set_seed()
|
||||
multiworld = MultiWorld(1)
|
||||
multiworld.game[1] = world_type.game
|
||||
multiworld.player_name = {1: "Tester"}
|
||||
multiworld.set_seed()
|
||||
args = Namespace()
|
||||
for name, option in world_type.option_definitions.items():
|
||||
setattr(args, name, {1: option.from_any(option.default)})
|
||||
world.set_options(args)
|
||||
world.set_default_common_options()
|
||||
multiworld.set_options(args)
|
||||
multiworld.set_default_common_options()
|
||||
for step in gen_steps:
|
||||
call_all(world, step)
|
||||
return world
|
||||
call_all(multiworld, step)
|
||||
return multiworld
|
||||
|
||||
@@ -14,23 +14,23 @@ from worlds import AutoWorld
|
||||
|
||||
class TestInverted(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
self.multiworld = MultiWorld(1)
|
||||
args = Namespace()
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||
self.world.mode[1] = "inverted"
|
||||
create_inverted_regions(self.world, 1)
|
||||
create_dungeons(self.world, 1)
|
||||
create_shops(self.world, 1)
|
||||
link_inverted_entrances(self.world, 1)
|
||||
self.world.worlds[1].create_items()
|
||||
self.world.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||
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.world.get_location('Agahnim 1', 1).item = None
|
||||
self.world.get_location('Agahnim 2', 1).item = None
|
||||
mark_light_world_regions(self.world, 1)
|
||||
self.world.worlds[1].set_rules()
|
||||
self.multiworld.set_options(args)
|
||||
self.multiworld.set_default_common_options()
|
||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||
self.multiworld.mode[1] = "inverted"
|
||||
create_inverted_regions(self.multiworld, 1)
|
||||
create_dungeons(self.multiworld, 1)
|
||||
create_shops(self.multiworld, 1)
|
||||
link_inverted_entrances(self.multiworld, 1)
|
||||
self.multiworld.worlds[1].create_items()
|
||||
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
||||
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.multiworld.get_location('Agahnim 1', 1).item = None
|
||||
self.multiworld.get_location('Agahnim 2', 1).item = None
|
||||
mark_light_world_regions(self.multiworld, 1)
|
||||
self.multiworld.worlds[1].set_rules()
|
||||
|
||||
@@ -14,16 +14,16 @@ from worlds import AutoWorld
|
||||
class TestInvertedBombRules(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
self.world.mode[1] = "inverted"
|
||||
self.multiworld = MultiWorld(1)
|
||||
self.multiworld.mode[1] = "inverted"
|
||||
args = Namespace
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||
create_inverted_regions(self.world, 1)
|
||||
create_dungeons(self.world, 1)
|
||||
self.multiworld.set_options(args)
|
||||
self.multiworld.set_default_common_options()
|
||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||
create_inverted_regions(self.multiworld, 1)
|
||||
create_dungeons(self.multiworld, 1)
|
||||
|
||||
#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):
|
||||
@@ -31,9 +31,9 @@ class TestInvertedBombRules(unittest.TestCase):
|
||||
must_exits = list(Inverted_LW_Entrances_Must_Exit + Inverted_LW_Dungeon_Entrances_Must_Exit)
|
||||
for entrance_name in (entrances + must_exits):
|
||||
if entrance_name not in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
||||
entrance = self.world.get_entrance(entrance_name, 1)
|
||||
connect_entrance(self.world, entrance_name, 'Inverted Big Bomb Shop', 1)
|
||||
set_inverted_big_bomb_rules(self.world, 1)
|
||||
entrance = self.multiworld.get_entrance(entrance_name, 1)
|
||||
connect_entrance(self.multiworld, entrance_name, 'Inverted Big Bomb Shop', 1)
|
||||
set_inverted_big_bomb_rules(self.multiworld, 1)
|
||||
entrance.connected_region.entrances.remove(entrance)
|
||||
entrance.connected_region = None
|
||||
|
||||
@@ -45,9 +45,9 @@ class TestInvertedBombRules(unittest.TestCase):
|
||||
|
||||
def testInvalidEntrances(self):
|
||||
for entrance_name in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
||||
entrance = self.world.get_entrance(entrance_name, 1)
|
||||
connect_entrance(self.world, entrance_name, 'Inverted Big Bomb Shop', 1)
|
||||
entrance = self.multiworld.get_entrance(entrance_name, 1)
|
||||
connect_entrance(self.multiworld, entrance_name, 'Inverted Big Bomb Shop', 1)
|
||||
with self.assertRaises(Exception):
|
||||
set_inverted_big_bomb_rules(self.world, 1)
|
||||
set_inverted_big_bomb_rules(self.multiworld, 1)
|
||||
entrance.connected_region.entrances.remove(entrance)
|
||||
entrance.connected_region = None
|
||||
|
||||
@@ -15,24 +15,24 @@ from worlds import AutoWorld
|
||||
|
||||
class TestInvertedMinor(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
self.multiworld = MultiWorld(1)
|
||||
args = Namespace()
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
self.world.mode[1] = "inverted"
|
||||
self.world.logic[1] = "minorglitches"
|
||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||
create_inverted_regions(self.world, 1)
|
||||
create_dungeons(self.world, 1)
|
||||
create_shops(self.world, 1)
|
||||
link_inverted_entrances(self.world, 1)
|
||||
self.world.worlds[1].create_items()
|
||||
self.world.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||
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.world.get_location('Agahnim 1', 1).item = None
|
||||
self.world.get_location('Agahnim 2', 1).item = None
|
||||
mark_light_world_regions(self.world, 1)
|
||||
self.world.worlds[1].set_rules()
|
||||
self.multiworld.set_options(args)
|
||||
self.multiworld.set_default_common_options()
|
||||
self.multiworld.mode[1] = "inverted"
|
||||
self.multiworld.logic[1] = "minorglitches"
|
||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||
create_inverted_regions(self.multiworld, 1)
|
||||
create_dungeons(self.multiworld, 1)
|
||||
create_shops(self.multiworld, 1)
|
||||
link_inverted_entrances(self.multiworld, 1)
|
||||
self.multiworld.worlds[1].create_items()
|
||||
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
||||
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.multiworld.get_location('Agahnim 1', 1).item = None
|
||||
self.multiworld.get_location('Agahnim 2', 1).item = None
|
||||
mark_light_world_regions(self.multiworld, 1)
|
||||
self.multiworld.worlds[1].set_rules()
|
||||
|
||||
@@ -16,26 +16,26 @@ from worlds import AutoWorld
|
||||
|
||||
class TestInvertedOWG(TestBase):
|
||||
def setUp(self):
|
||||
self.world = MultiWorld(1)
|
||||
self.multiworld = MultiWorld(1)
|
||||
args = Namespace()
|
||||
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)})
|
||||
self.world.set_options(args)
|
||||
self.world.set_default_common_options()
|
||||
self.world.logic[1] = "owglitches"
|
||||
self.world.mode[1] = "inverted"
|
||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
||||
create_inverted_regions(self.world, 1)
|
||||
create_dungeons(self.world, 1)
|
||||
create_shops(self.world, 1)
|
||||
link_inverted_entrances(self.world, 1)
|
||||
self.world.worlds[1].create_items()
|
||||
self.world.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
||||
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.world.get_location('Agahnim 1', 1).item = None
|
||||
self.world.get_location('Agahnim 2', 1).item = None
|
||||
self.world.precollected_items[1].clear()
|
||||
self.world.itempool.append(ItemFactory('Pegasus Boots', 1))
|
||||
mark_light_world_regions(self.world, 1)
|
||||
self.world.worlds[1].set_rules()
|
||||
self.multiworld.set_options(args)
|
||||
self.multiworld.set_default_common_options()
|
||||
self.multiworld.logic[1] = "owglitches"
|
||||
self.multiworld.mode[1] = "inverted"
|
||||
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||
create_inverted_regions(self.multiworld, 1)
|
||||
create_dungeons(self.multiworld, 1)
|
||||
create_shops(self.multiworld, 1)
|
||||
link_inverted_entrances(self.multiworld, 1)
|
||||
self.multiworld.worlds[1].create_items()
|
||||
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
|
||||
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
||||
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.multiworld.get_location('Agahnim 1', 1).item = None
|
||||
self.multiworld.get_location('Agahnim 2', 1).item = None
|
||||
self.multiworld.precollected_items[1].clear()
|
||||
self.multiworld.itempool.append(ItemFactory('Pegasus Boots', 1))
|
||||
mark_light_world_regions(self.multiworld, 1)
|
||||
self.multiworld.worlds[1].set_rules()
|
||||
|
||||
@@ -312,9 +312,10 @@ class TestAdvancements(TestMinecraft):
|
||||
["Two by Two", False, [], ['Flint and Steel']],
|
||||
["Two by Two", False, [], ['Progressive Tools']],
|
||||
["Two by Two", False, [], ['Progressive Weapons']],
|
||||
["Two by Two", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']],
|
||||
["Two by Two", False, [], ['Bucket']],
|
||||
["Two by Two", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']],
|
||||
["Two by Two", False, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']],
|
||||
["Two by Two", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons']],
|
||||
["Two by Two", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']],
|
||||
])
|
||||
|
||||
def test_42023(self):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user