mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-25 03:23:24 -07:00
Compare commits
188 Commits
| 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 | ||
|
|
813ee5ee3b | ||
|
|
be1158ad78 | ||
|
|
6d5ddf3cad | ||
|
|
809bda02d1 | ||
|
|
2d5ec6ce22 | ||
|
|
a95d0ce9ef | ||
|
|
267d9234e5 | ||
|
|
4686881566 | ||
|
|
101dab0ea4 | ||
|
|
c2d69cb05e | ||
|
|
58f66e0f42 | ||
|
|
0215e1fa28 | ||
|
|
1c0a93acad | ||
|
|
4fcde135e5 | ||
|
|
332dde154f | ||
|
|
8d51205e8f | ||
|
|
ff05e9d7d5 | ||
|
|
516a52c041 | ||
|
|
9daa64741b | ||
|
|
af11fa5150 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip setuptools
|
python -m pip install --upgrade pip setuptools
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python setup.py build --yes
|
python setup.py build_exe --yes
|
||||||
$NAME="$(ls build)".Split('.',2)[1]
|
$NAME="$(ls build)".Split('.',2)[1]
|
||||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python setup.py build --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 build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -13,6 +13,10 @@
|
|||||||
*.z64
|
*.z64
|
||||||
*.n64
|
*.n64
|
||||||
*.nes
|
*.nes
|
||||||
|
*.sms
|
||||||
|
*.gb
|
||||||
|
*.gbc
|
||||||
|
*.gba
|
||||||
*.wixobj
|
*.wixobj
|
||||||
*.lck
|
*.lck
|
||||||
*.db3
|
*.db3
|
||||||
@@ -125,7 +129,7 @@ ipython_config.py
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv*
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
|
|||||||
328
BaseClasses.py
328
BaseClasses.py
@@ -1,4 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
from enum import unique, IntEnum, IntFlag
|
from enum import unique, IntEnum, IntFlag
|
||||||
@@ -40,16 +41,23 @@ class MultiWorld():
|
|||||||
plando_connections: List
|
plando_connections: List
|
||||||
worlds: Dict[int, auto_world]
|
worlds: Dict[int, auto_world]
|
||||||
groups: Dict[int, Group]
|
groups: Dict[int, Group]
|
||||||
|
regions: List[Region]
|
||||||
itempool: List[Item]
|
itempool: List[Item]
|
||||||
is_race: bool = False
|
is_race: bool = False
|
||||||
precollected_items: Dict[int, List[Item]]
|
precollected_items: Dict[int, List[Item]]
|
||||||
state: CollectionState
|
state: CollectionState
|
||||||
|
|
||||||
accessibility: Dict[int, Options.Accessibility]
|
accessibility: Dict[int, Options.Accessibility]
|
||||||
|
early_items: Dict[int, Dict[str, int]]
|
||||||
|
local_early_items: Dict[int, Dict[str, int]]
|
||||||
local_items: Dict[int, Options.LocalItems]
|
local_items: Dict[int, Options.LocalItems]
|
||||||
non_local_items: Dict[int, Options.NonLocalItems]
|
non_local_items: Dict[int, Options.NonLocalItems]
|
||||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||||
|
indirect_connections: Dict[Region, Set[Entrance]]
|
||||||
|
exclude_locations: Dict[int, Options.ExcludeLocations]
|
||||||
|
|
||||||
|
game: Dict[int, str]
|
||||||
|
|
||||||
class AttributeProxy():
|
class AttributeProxy():
|
||||||
def __init__(self, rule):
|
def __init__(self, rule):
|
||||||
@@ -87,6 +95,9 @@ class MultiWorld():
|
|||||||
self.customitemarray = []
|
self.customitemarray = []
|
||||||
self.shuffle_ganon = True
|
self.shuffle_ganon = True
|
||||||
self.spoiler = Spoiler(self)
|
self.spoiler = Spoiler(self)
|
||||||
|
self.early_items = {player: {} for player in self.player_ids}
|
||||||
|
self.local_early_items = {player: {} for player in self.player_ids}
|
||||||
|
self.indirect_connections = {}
|
||||||
self.fix_trock_doors = self.AttributeProxy(
|
self.fix_trock_doors = self.AttributeProxy(
|
||||||
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
||||||
self.fix_skullwoods_exit = self.AttributeProxy(
|
self.fix_skullwoods_exit = self.AttributeProxy(
|
||||||
@@ -195,7 +206,7 @@ class MultiWorld():
|
|||||||
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
|
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
|
||||||
range(1, self.players + 1)}
|
range(1, self.players + 1)}
|
||||||
|
|
||||||
def set_options(self, args):
|
def set_options(self, args: Namespace) -> None:
|
||||||
for option_key in Options.common_options:
|
for option_key in Options.common_options:
|
||||||
setattr(self, option_key, getattr(args, option_key, {}))
|
setattr(self, option_key, getattr(args, option_key, {}))
|
||||||
for option_key in Options.per_game_common_options:
|
for option_key in Options.per_game_common_options:
|
||||||
@@ -295,9 +306,16 @@ class MultiWorld():
|
|||||||
def get_file_safe_player_name(self, player: int) -> str:
|
def get_file_safe_player_name(self, player: int) -> str:
|
||||||
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
||||||
|
|
||||||
|
def get_out_file_name_base(self, player: int) -> str:
|
||||||
|
""" the base name (without file extension) for each player's output file for a seed """
|
||||||
|
return f"AP_{self.seed_name}_P{player}" \
|
||||||
|
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
||||||
|
if (self.player_name[player] != f"Player{player}")
|
||||||
|
else '')
|
||||||
|
|
||||||
def initialize_regions(self, regions=None):
|
def initialize_regions(self, regions=None):
|
||||||
for region in regions if regions else self.regions:
|
for region in regions if regions else self.regions:
|
||||||
region.world = self
|
region.multiworld = self
|
||||||
self._region_cache[region.player][region.name] = region
|
self._region_cache[region.player][region.name] = region
|
||||||
|
|
||||||
@functools.cached_property
|
@functools.cached_property
|
||||||
@@ -404,6 +422,11 @@ class MultiWorld():
|
|||||||
def clear_entrance_cache(self):
|
def clear_entrance_cache(self):
|
||||||
self._cached_entrances = None
|
self._cached_entrances = None
|
||||||
|
|
||||||
|
def register_indirect_condition(self, region: Region, entrance: Entrance):
|
||||||
|
"""Report that access to this Region can result in unlocking this Entrance,
|
||||||
|
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
|
||||||
|
self.indirect_connections.setdefault(region, set()).add(entrance)
|
||||||
|
|
||||||
def get_locations(self) -> List[Location]:
|
def get_locations(self) -> List[Location]:
|
||||||
if self._cached_locations is None:
|
if self._cached_locations is None:
|
||||||
self._cached_locations = [location for region in self.regions for location in region.locations]
|
self._cached_locations = [location for region in self.regions for location in region.locations]
|
||||||
@@ -521,15 +544,17 @@ class MultiWorld():
|
|||||||
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
||||||
if not state:
|
if not state:
|
||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
players = {"minimal": set(),
|
players: Dict[str, Set[int]] = {
|
||||||
"items": set(),
|
"minimal": set(),
|
||||||
"locations": set()}
|
"items": set(),
|
||||||
|
"locations": set()
|
||||||
|
}
|
||||||
for player, access in self.accessibility.items():
|
for player, access in self.accessibility.items():
|
||||||
players[access.current_key].add(player)
|
players[access.current_key].add(player)
|
||||||
|
|
||||||
beatable_fulfilled = False
|
beatable_fulfilled = False
|
||||||
|
|
||||||
def location_conditition(location: Location):
|
def location_condition(location: Location):
|
||||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||||
if location.player in players["minimal"]:
|
if location.player in players["minimal"]:
|
||||||
return False
|
return False
|
||||||
@@ -543,20 +568,21 @@ class MultiWorld():
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def all_done():
|
def all_done() -> bool:
|
||||||
"""Check if all access rules are fulfilled"""
|
"""Check if all access rules are fulfilled"""
|
||||||
if beatable_fulfilled:
|
if not beatable_fulfilled:
|
||||||
if any(location_conditition(location) for location in locations):
|
return False
|
||||||
return False # still locations required to be collected
|
if any(location_condition(location) for location in locations):
|
||||||
return True
|
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:
|
while locations:
|
||||||
sphere = set()
|
sphere: List[Location] = []
|
||||||
for location in locations:
|
for n in range(len(locations) - 1, -1, -1):
|
||||||
if location.can_reach(state):
|
if locations[n].can_reach(state):
|
||||||
sphere.add(location)
|
sphere.append(locations.pop(n))
|
||||||
|
|
||||||
if not sphere:
|
if not sphere:
|
||||||
# ran out of places and did not finish yet, quit
|
# ran out of places and did not finish yet, quit
|
||||||
@@ -565,8 +591,8 @@ class MultiWorld():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
locations.remove(location)
|
if location.item:
|
||||||
state.collect(location.item, True, location)
|
state.collect(location.item, True, location)
|
||||||
|
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
beatable_fulfilled = True
|
beatable_fulfilled = True
|
||||||
@@ -582,7 +608,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
|
|||||||
|
|
||||||
class CollectionState():
|
class CollectionState():
|
||||||
prog_items: typing.Counter[Tuple[str, int]]
|
prog_items: typing.Counter[Tuple[str, int]]
|
||||||
world: MultiWorld
|
multiworld: MultiWorld
|
||||||
reachable_regions: Dict[int, Set[Region]]
|
reachable_regions: Dict[int, Set[Region]]
|
||||||
blocked_connections: Dict[int, Set[Entrance]]
|
blocked_connections: Dict[int, Set[Entrance]]
|
||||||
events: Set[Location]
|
events: Set[Location]
|
||||||
@@ -594,7 +620,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def __init__(self, parent: MultiWorld):
|
def __init__(self, parent: MultiWorld):
|
||||||
self.prog_items = Counter()
|
self.prog_items = Counter()
|
||||||
self.world = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||||
self.events = set()
|
self.events = set()
|
||||||
@@ -608,15 +634,14 @@ class CollectionState():
|
|||||||
self.collect(item, True)
|
self.collect(item, True)
|
||||||
|
|
||||||
def update_reachable_regions(self, player: int):
|
def update_reachable_regions(self, player: int):
|
||||||
from worlds.alttp.EntranceShuffle import indirect_connections
|
|
||||||
self.stale[player] = False
|
self.stale[player] = False
|
||||||
rrp = self.reachable_regions[player]
|
rrp = self.reachable_regions[player]
|
||||||
bc = self.blocked_connections[player]
|
bc = self.blocked_connections[player]
|
||||||
queue = deque(self.blocked_connections[player])
|
queue = deque(self.blocked_connections[player])
|
||||||
start = self.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
|
# 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)
|
rrp.add(start)
|
||||||
bc.update(start.exits)
|
bc.update(start.exits)
|
||||||
queue.extend(start.exits)
|
queue.extend(start.exits)
|
||||||
@@ -628,7 +653,7 @@ class CollectionState():
|
|||||||
if new_region in rrp:
|
if new_region in rrp:
|
||||||
bc.remove(connection)
|
bc.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
assert new_region, "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)
|
rrp.add(new_region)
|
||||||
bc.remove(connection)
|
bc.remove(connection)
|
||||||
bc.update(new_region.exits)
|
bc.update(new_region.exits)
|
||||||
@@ -636,13 +661,12 @@ class CollectionState():
|
|||||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||||
|
|
||||||
# Retry connections if the new region can unblock them
|
# Retry connections if the new region can unblock them
|
||||||
if new_region.name in indirect_connections:
|
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
|
||||||
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
|
|
||||||
if new_entrance in bc and new_entrance not in queue:
|
if new_entrance in bc and new_entrance not in queue:
|
||||||
queue.append(new_entrance)
|
queue.append(new_entrance)
|
||||||
|
|
||||||
def copy(self) -> CollectionState:
|
def copy(self) -> CollectionState:
|
||||||
ret = CollectionState(self.world)
|
ret = CollectionState(self.multiworld)
|
||||||
ret.prog_items = self.prog_items.copy()
|
ret.prog_items = self.prog_items.copy()
|
||||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||||
self.reachable_regions}
|
self.reachable_regions}
|
||||||
@@ -663,25 +687,25 @@ class CollectionState():
|
|||||||
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
||||||
# try to resolve a name
|
# try to resolve a name
|
||||||
if resolution_hint == 'Location':
|
if resolution_hint == 'Location':
|
||||||
spot = self.world.get_location(spot, player)
|
spot = self.multiworld.get_location(spot, player)
|
||||||
elif resolution_hint == 'Entrance':
|
elif resolution_hint == 'Entrance':
|
||||||
spot = self.world.get_entrance(spot, player)
|
spot = self.multiworld.get_entrance(spot, player)
|
||||||
else:
|
else:
|
||||||
# default to Region
|
# default to Region
|
||||||
spot = self.world.get_region(spot, player)
|
spot = self.multiworld.get_region(spot, player)
|
||||||
return spot.can_reach(self)
|
return spot.can_reach(self)
|
||||||
|
|
||||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||||
if locations is None:
|
if locations is None:
|
||||||
locations = self.world.get_filled_locations()
|
locations = self.multiworld.get_filled_locations()
|
||||||
new_locations = True
|
reachable_events = True
|
||||||
# since the loop has a good chance to run more than once, only filter the events once
|
# since the loop has a good chance to run more than once, only filter the events once
|
||||||
locations = {location for location in locations if location.event and
|
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)}
|
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)}
|
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||||
new_locations = reachable_events - self.events
|
locations -= reachable_events
|
||||||
for event in new_locations:
|
for event in reachable_events:
|
||||||
self.events.add(event)
|
self.events.add(event)
|
||||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||||
self.collect(event.item, True, event)
|
self.collect(event.item, True, event)
|
||||||
@@ -700,7 +724,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||||
found: int = 0
|
found: int = 0
|
||||||
for item_name in self.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]
|
found += self.prog_items[item_name, player]
|
||||||
if found >= count:
|
if found >= count:
|
||||||
return True
|
return True
|
||||||
@@ -708,17 +732,17 @@ class CollectionState():
|
|||||||
|
|
||||||
def count_group(self, item_name_group: str, player: int) -> int:
|
def count_group(self, item_name_group: str, player: int) -> int:
|
||||||
found: int = 0
|
found: int = 0
|
||||||
for item_name in self.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]
|
found += self.prog_items[item_name, player]
|
||||||
return found
|
return found
|
||||||
|
|
||||||
def can_buy_unlimited(self, item: str, player: int) -> bool:
|
def can_buy_unlimited(self, item: str, player: int) -> bool:
|
||||||
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
|
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
|
||||||
shop in self.world.shops)
|
shop in self.multiworld.shops)
|
||||||
|
|
||||||
def can_buy(self, item: str, player: int) -> bool:
|
def can_buy(self, item: str, player: int) -> bool:
|
||||||
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
|
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
|
||||||
shop in self.world.shops)
|
shop in self.multiworld.shops)
|
||||||
|
|
||||||
def item_count(self, item: str, player: int) -> int:
|
def item_count(self, item: str, player: int) -> int:
|
||||||
return self.prog_items[item, player]
|
return self.prog_items[item, player]
|
||||||
@@ -738,7 +762,7 @@ class CollectionState():
|
|||||||
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
|
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
|
||||||
|
|
||||||
def bottle_count(self, player: int) -> int:
|
def bottle_count(self, player: int) -> int:
|
||||||
return min(self.world.difficulty_requirements[player].progressive_bottle_limit,
|
return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit,
|
||||||
self.count_group("Bottles", player))
|
self.count_group("Bottles", player))
|
||||||
|
|
||||||
def has_hearts(self, player: int, count: int) -> int:
|
def has_hearts(self, player: int, count: int) -> int:
|
||||||
@@ -747,7 +771,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def heart_count(self, player: int) -> int:
|
def heart_count(self, player: int) -> int:
|
||||||
# Warning: This only considers items that are marked as advancement items
|
# Warning: This only considers items that are marked as advancement items
|
||||||
diff = self.world.difficulty_requirements[player]
|
diff = self.multiworld.difficulty_requirements[player]
|
||||||
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
||||||
+ self.item_count('Sanctuary Heart Container', player) \
|
+ self.item_count('Sanctuary Heart Container', player) \
|
||||||
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
||||||
@@ -764,9 +788,9 @@ class CollectionState():
|
|||||||
elif self.has('Magic Upgrade (1/2)', player):
|
elif self.has('Magic Upgrade (1/2)', player):
|
||||||
basemagic = 16
|
basemagic = 16
|
||||||
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
|
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
|
||||||
if self.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))
|
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))
|
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
|
||||||
else:
|
else:
|
||||||
basemagic = basemagic + basemagic * self.bottle_count(player)
|
basemagic = basemagic + basemagic * self.bottle_count(player)
|
||||||
@@ -781,12 +805,12 @@ class CollectionState():
|
|||||||
or (self.has('Bombs (10)', player) and enemies < 6))
|
or (self.has('Bombs (10)', player) and enemies < 6))
|
||||||
|
|
||||||
def can_shoot_arrows(self, player: int) -> bool:
|
def can_shoot_arrows(self, player: int) -> bool:
|
||||||
if self.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)) and self.can_buy('Single Arrow', player)
|
||||||
return self.has('Bow', player) or self.has('Silver Bow', player)
|
return self.has('Bow', player) or self.has('Silver Bow', player)
|
||||||
|
|
||||||
def can_get_good_bee(self, player: int) -> bool:
|
def can_get_good_bee(self, player: int) -> bool:
|
||||||
cave = self.world.get_region('Good Bee Cave', player)
|
cave = self.multiworld.get_region('Good Bee Cave', player)
|
||||||
return (
|
return (
|
||||||
self.has_group("Bottles", player) and
|
self.has_group("Bottles", player) and
|
||||||
self.has('Bug Catching Net', player) and
|
self.has('Bug Catching Net', player) and
|
||||||
@@ -797,7 +821,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def can_retrieve_tablet(self, player: int) -> bool:
|
def can_retrieve_tablet(self, player: int) -> bool:
|
||||||
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
|
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
|
||||||
(self.world.swordless[player] and
|
(self.multiworld.swordless[player] and
|
||||||
self.has("Hammer", player)))
|
self.has("Hammer", player)))
|
||||||
|
|
||||||
def has_sword(self, player: int) -> bool:
|
def has_sword(self, player: int) -> bool:
|
||||||
@@ -819,7 +843,7 @@ class CollectionState():
|
|||||||
def can_melt_things(self, player: int) -> bool:
|
def can_melt_things(self, player: int) -> bool:
|
||||||
return self.has('Fire Rod', player) or \
|
return self.has('Fire Rod', player) or \
|
||||||
(self.has('Bombos', player) and
|
(self.has('Bombos', player) and
|
||||||
(self.world.swordless[player] or
|
(self.multiworld.swordless[player] or
|
||||||
self.has_sword(player)))
|
self.has_sword(player)))
|
||||||
|
|
||||||
def can_avoid_lasers(self, player: int) -> bool:
|
def can_avoid_lasers(self, player: int) -> bool:
|
||||||
@@ -829,7 +853,7 @@ class CollectionState():
|
|||||||
if self.has('Moon Pearl', player):
|
if self.has('Moon Pearl', player):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return region.is_light_world if self.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:
|
def can_reach_light_world(self, player: int) -> bool:
|
||||||
if True in [i.is_light_world for i in self.reachable_regions[player]]:
|
if True in [i.is_light_world for i in self.reachable_regions[player]]:
|
||||||
@@ -842,24 +866,24 @@ class CollectionState():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def has_misery_mire_medallion(self, player: int) -> bool:
|
def has_misery_mire_medallion(self, player: int) -> bool:
|
||||||
return self.has(self.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:
|
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:
|
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) and self.has('Moon Pearl', player)
|
||||||
return self.has('Pegasus Boots', player)
|
return self.has('Pegasus Boots', player)
|
||||||
|
|
||||||
def can_boots_clip_dw(self, player: int) -> bool:
|
def can_boots_clip_dw(self, player: int) -> bool:
|
||||||
if self.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) and self.has('Moon Pearl', player)
|
||||||
return self.has('Pegasus Boots', player)
|
return self.has('Pegasus Boots', player)
|
||||||
|
|
||||||
def can_get_glitched_speed_lw(self, player: int) -> bool:
|
def can_get_glitched_speed_lw(self, player: int) -> bool:
|
||||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||||
if self.world.mode[player] == 'inverted':
|
if self.multiworld.mode[player] == 'inverted':
|
||||||
rules.append(self.has('Moon Pearl', player))
|
rules.append(self.has('Moon Pearl', player))
|
||||||
return all(rules)
|
return all(rules)
|
||||||
|
|
||||||
@@ -868,7 +892,7 @@ class CollectionState():
|
|||||||
|
|
||||||
def can_get_glitched_speed_dw(self, player: int) -> bool:
|
def can_get_glitched_speed_dw(self, player: int) -> bool:
|
||||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||||
if self.world.mode[player] != 'inverted':
|
if self.multiworld.mode[player] != 'inverted':
|
||||||
rules.append(self.has('Moon Pearl', player))
|
rules.append(self.has('Moon Pearl', player))
|
||||||
return all(rules)
|
return all(rules)
|
||||||
|
|
||||||
@@ -879,7 +903,7 @@ class CollectionState():
|
|||||||
if location:
|
if location:
|
||||||
self.locations_checked.add(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:
|
if not changed and event:
|
||||||
self.prog_items[item.name, item.player] += 1
|
self.prog_items[item.name, item.player] += 1
|
||||||
@@ -893,7 +917,7 @@ class CollectionState():
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
def remove(self, item: Item):
|
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:
|
if changed:
|
||||||
# invalidate caches, nothing can be trusted anymore now
|
# invalidate caches, nothing can be trusted anymore now
|
||||||
self.reachable_regions[item.player] = set()
|
self.reachable_regions[item.player] = set()
|
||||||
@@ -920,7 +944,7 @@ class Region:
|
|||||||
type: RegionType
|
type: RegionType
|
||||||
hint_text: str
|
hint_text: str
|
||||||
player: int
|
player: int
|
||||||
world: Optional[MultiWorld]
|
multiworld: Optional[MultiWorld]
|
||||||
entrances: List[Entrance]
|
entrances: List[Entrance]
|
||||||
exits: List[Entrance]
|
exits: List[Entrance]
|
||||||
locations: List[Location]
|
locations: List[Location]
|
||||||
@@ -938,7 +962,7 @@ class Region:
|
|||||||
self.entrances = []
|
self.entrances = []
|
||||||
self.exits = []
|
self.exits = []
|
||||||
self.locations = []
|
self.locations = []
|
||||||
self.world = world
|
self.multiworld = world
|
||||||
self.hint_text = hint
|
self.hint_text = hint
|
||||||
self.player = player
|
self.player = player
|
||||||
|
|
||||||
@@ -955,11 +979,18 @@ class Region:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance:
|
||||||
|
for entrance in self.entrances:
|
||||||
|
if is_main_entrance(entrance):
|
||||||
|
return entrance
|
||||||
|
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
||||||
|
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.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:
|
class Entrance:
|
||||||
@@ -986,7 +1017,7 @@ class Entrance:
|
|||||||
|
|
||||||
return False
|
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.connected_region = region
|
||||||
self.target = target
|
self.target = target
|
||||||
self.addresses = addresses
|
self.addresses = addresses
|
||||||
@@ -996,7 +1027,7 @@ class Entrance:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
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})'
|
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
|
|
||||||
@@ -1010,7 +1041,7 @@ class Dungeon(object):
|
|||||||
self.dungeon_items = dungeon_items
|
self.dungeon_items = dungeon_items
|
||||||
self.bosses = dict()
|
self.bosses = dict()
|
||||||
self.player = player
|
self.player = player
|
||||||
self.world = None
|
self.multiworld = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def boss(self) -> Optional[Boss]:
|
def boss(self) -> Optional[Boss]:
|
||||||
@@ -1040,7 +1071,7 @@ class Dungeon(object):
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
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():
|
class Boss():
|
||||||
@@ -1074,7 +1105,7 @@ class Location:
|
|||||||
show_in_spoiler: bool = True
|
show_in_spoiler: bool = True
|
||||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||||
always_allow = staticmethod(lambda item, state: False)
|
always_allow = staticmethod(lambda item, state: False)
|
||||||
access_rule = staticmethod(lambda state: True)
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
item_rule = staticmethod(lambda item: True)
|
item_rule = staticmethod(lambda item: True)
|
||||||
item: Optional[Item] = None
|
item: Optional[Item] = None
|
||||||
|
|
||||||
@@ -1085,13 +1116,15 @@ class Location:
|
|||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
|
|
||||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||||
return self.always_allow(state, item) 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:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
# self.access_rule computes faster on average, so placing it first for faster abort
|
# self.access_rule computes faster on average, so placing it first for faster abort
|
||||||
if self.access_rule(state) and self.parent_region.can_reach(state):
|
assert self.parent_region, "Can't reach location without region"
|
||||||
return True
|
return self.access_rule(state) and self.parent_region.can_reach(state)
|
||||||
return False
|
|
||||||
|
|
||||||
def place_locked_item(self, item: Item):
|
def place_locked_item(self, item: Item):
|
||||||
if self.item:
|
if self.item:
|
||||||
@@ -1105,7 +1138,7 @@ class Location:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
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})'
|
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
@@ -1202,17 +1235,17 @@ class Item:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
if self.location and self.location.parent_region and self.location.parent_region.world:
|
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
||||||
return self.location.parent_region.world.get_name_string_for_object(self)
|
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
||||||
return f"{self.name} (Player {self.player})"
|
return f"{self.name} (Player {self.player})"
|
||||||
|
|
||||||
|
|
||||||
class Spoiler():
|
class Spoiler():
|
||||||
world: MultiWorld
|
multiworld: MultiWorld
|
||||||
unreachables: Set[Location]
|
unreachables: Set[Location]
|
||||||
|
|
||||||
def __init__(self, world):
|
def __init__(self, world):
|
||||||
self.world = world
|
self.multiworld = world
|
||||||
self.hashes = {}
|
self.hashes = {}
|
||||||
self.entrances = OrderedDict()
|
self.entrances = OrderedDict()
|
||||||
self.medallions = {}
|
self.medallions = {}
|
||||||
@@ -1224,7 +1257,7 @@ class Spoiler():
|
|||||||
self.bosses = OrderedDict()
|
self.bosses = OrderedDict()
|
||||||
|
|
||||||
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
||||||
if self.world.players == 1:
|
if self.multiworld.players == 1:
|
||||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||||
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||||
else:
|
else:
|
||||||
@@ -1233,45 +1266,45 @@ class Spoiler():
|
|||||||
|
|
||||||
def parse_data(self):
|
def parse_data(self):
|
||||||
self.medallions = OrderedDict()
|
self.medallions = OrderedDict()
|
||||||
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.medallions[f'Misery Mire ({self.world.get_player_name(player)})'] = \
|
self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \
|
||||||
self.world.required_medallions[player][0]
|
self.multiworld.required_medallions[player][0]
|
||||||
self.medallions[f'Turtle Rock ({self.world.get_player_name(player)})'] = \
|
self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \
|
||||||
self.world.required_medallions[player][1]
|
self.multiworld.required_medallions[player][1]
|
||||||
|
|
||||||
self.locations = OrderedDict()
|
self.locations = OrderedDict()
|
||||||
listed_locations = set()
|
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]
|
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
|
||||||
self.locations['Light World'] = OrderedDict(
|
self.locations['Light World'] = OrderedDict(
|
||||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||||
lw_locations])
|
lw_locations])
|
||||||
listed_locations.update(lw_locations)
|
listed_locations.update(lw_locations)
|
||||||
|
|
||||||
dw_locations = [loc for loc in self.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]
|
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
|
||||||
self.locations['Dark World'] = OrderedDict(
|
self.locations['Dark World'] = OrderedDict(
|
||||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||||
dw_locations])
|
dw_locations])
|
||||||
listed_locations.update(dw_locations)
|
listed_locations.update(dw_locations)
|
||||||
|
|
||||||
cave_locations = [loc for loc in self.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]
|
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
|
||||||
self.locations['Caves'] = OrderedDict(
|
self.locations['Caves'] = OrderedDict(
|
||||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||||
cave_locations])
|
cave_locations])
|
||||||
listed_locations.update(cave_locations)
|
listed_locations.update(cave_locations)
|
||||||
|
|
||||||
for dungeon in self.world.dungeons.values():
|
for dungeon in self.multiworld.dungeons.values():
|
||||||
dungeon_locations = [loc for loc in self.world.get_locations() if
|
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]
|
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
|
||||||
self.locations[str(dungeon)] = OrderedDict(
|
self.locations[str(dungeon)] = OrderedDict(
|
||||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||||
dungeon_locations])
|
dungeon_locations])
|
||||||
listed_locations.update(dungeon_locations)
|
listed_locations.update(dungeon_locations)
|
||||||
|
|
||||||
other_locations = [loc for loc in self.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]
|
loc not in listed_locations and loc.show_in_spoiler]
|
||||||
if other_locations:
|
if other_locations:
|
||||||
self.locations['Other Locations'] = OrderedDict(
|
self.locations['Other Locations'] = OrderedDict(
|
||||||
@@ -1281,7 +1314,7 @@ class Spoiler():
|
|||||||
|
|
||||||
self.shops = []
|
self.shops = []
|
||||||
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
|
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
|
||||||
for shop in self.world.shops:
|
for shop in self.multiworld.shops:
|
||||||
if not shop.custom:
|
if not shop.custom:
|
||||||
continue
|
continue
|
||||||
shopdata = {
|
shopdata = {
|
||||||
@@ -1310,34 +1343,34 @@ class Spoiler():
|
|||||||
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
|
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
|
||||||
self.shops.append(shopdata)
|
self.shops.append(shopdata)
|
||||||
|
|
||||||
for player in self.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)] = OrderedDict()
|
||||||
self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", 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.world.get_dungeon("Desert 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.world.get_dungeon("Tower of Hera", 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)]["Hyrule Castle"] = "Agahnim"
|
||||||
self.bosses[str(player)]["Palace Of Darkness"] = self.world.get_dungeon("Palace of Darkness",
|
self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness",
|
||||||
player).boss.name
|
player).boss.name
|
||||||
self.bosses[str(player)]["Swamp Palace"] = self.world.get_dungeon("Swamp Palace", 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.world.get_dungeon("Skull Woods", 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.world.get_dungeon("Thieves Town", 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.world.get_dungeon("Ice Palace", 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.world.get_dungeon("Misery Mire", 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.world.get_dungeon("Turtle Rock", player).boss.name
|
self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name
|
||||||
if self.world.mode[player] != 'inverted':
|
if self.multiworld.mode[player] != 'inverted':
|
||||||
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
||||||
self.world.get_dungeon('Ganons Tower', player).bosses['bottom'].name
|
self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name
|
||||||
self.bosses[str(player)]["Ganons Tower Middle"] = self.world.get_dungeon('Ganons Tower', player).bosses[
|
self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
|
||||||
'middle'].name
|
'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
|
'top'].name
|
||||||
else:
|
else:
|
||||||
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
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.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.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)]["Ganons Tower"] = "Agahnim 2"
|
||||||
self.bosses[str(player)]["Ganon"] = "Ganon"
|
self.bosses[str(player)]["Ganon"] = "Ganon"
|
||||||
@@ -1367,7 +1400,7 @@ class Spoiler():
|
|||||||
return 'Yes' if variable else 'No'
|
return 'Yes' if variable else 'No'
|
||||||
|
|
||||||
def write_option(option_key: str, option_obj: type(Options.Option)):
|
def write_option(option_key: str, option_obj: type(Options.Option)):
|
||||||
res = getattr(self.world, option_key)[player]
|
res = getattr(self.multiworld, option_key)[player]
|
||||||
display_name = getattr(option_obj, "display_name", option_key)
|
display_name = getattr(option_obj, "display_name", option_key)
|
||||||
try:
|
try:
|
||||||
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
|
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
|
||||||
@@ -1377,60 +1410,59 @@ class Spoiler():
|
|||||||
with open(filename, 'w', encoding="utf-8-sig") as outfile:
|
with open(filename, 'w', encoding="utf-8-sig") as outfile:
|
||||||
outfile.write(
|
outfile.write(
|
||||||
'Archipelago Version %s - Seed: %s\n\n' % (
|
'Archipelago Version %s - Seed: %s\n\n' % (
|
||||||
Utils.__version__, self.world.seed))
|
Utils.__version__, self.multiworld.seed))
|
||||||
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
|
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
||||||
outfile.write('Players: %d\n' % self.world.players)
|
outfile.write('Players: %d\n' % self.multiworld.players)
|
||||||
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
|
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
||||||
|
|
||||||
for player in range(1, self.world.players + 1):
|
for player in range(1, self.multiworld.players + 1):
|
||||||
if self.world.players > 1:
|
if self.multiworld.players > 1:
|
||||||
outfile.write('\nPlayer %d: %s\n' % (player, self.world.get_player_name(player)))
|
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
||||||
outfile.write('Game: %s\n' % self.world.game[player])
|
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
||||||
for f_option, option in Options.per_game_common_options.items():
|
for f_option, option in Options.per_game_common_options.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
options = self.world.worlds[player].option_definitions
|
options = self.multiworld.worlds[player].option_definitions
|
||||||
if options:
|
if options:
|
||||||
for f_option, option in options.items():
|
for f_option, option in options.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
AutoWorld.call_single(self.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('%s%s\n' % ('Hash: ', self.hashes[player]))
|
||||||
|
|
||||||
outfile.write('Logic: %s\n' % self.world.logic[player])
|
outfile.write('Logic: %s\n' % self.multiworld.logic[player])
|
||||||
outfile.write('Dark Room Logic: %s\n' % self.world.dark_room_logic[player])
|
outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player])
|
||||||
outfile.write('Mode: %s\n' % self.world.mode[player])
|
outfile.write('Mode: %s\n' % self.multiworld.mode[player])
|
||||||
outfile.write('Goal: %s\n' % self.world.goal[player])
|
outfile.write('Goal: %s\n' % self.multiworld.goal[player])
|
||||||
if "triforce" in self.world.goal[player]: # triforce hunt
|
if "triforce" in self.multiworld.goal[player]: # triforce hunt
|
||||||
outfile.write("Pieces available for Triforce: %s\n" %
|
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" %
|
outfile.write("Pieces required for Triforce: %s\n" %
|
||||||
self.world.triforce_pieces_required[player])
|
self.multiworld.triforce_pieces_required[player])
|
||||||
outfile.write('Difficulty: %s\n' % self.world.difficulty[player])
|
outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player])
|
||||||
outfile.write('Item Functionality: %s\n' % self.world.item_functionality[player])
|
outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player])
|
||||||
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
|
outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player])
|
||||||
if self.world.shuffle[player] != "vanilla":
|
if self.multiworld.shuffle[player] != "vanilla":
|
||||||
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
|
outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed)
|
||||||
outfile.write('Shop inventory shuffle: %s\n' %
|
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' %
|
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' %
|
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' %
|
outfile.write('New Shop inventory: %s\n' %
|
||||||
bool_to_text("g" in self.world.shop_shuffle[player] or
|
bool_to_text("g" in self.multiworld.shop_shuffle[player] or
|
||||||
"f" in self.world.shop_shuffle[player]))
|
"f" in self.multiworld.shop_shuffle[player]))
|
||||||
outfile.write('Custom Potion Shop: %s\n' %
|
outfile.write('Custom Potion Shop: %s\n' %
|
||||||
bool_to_text("w" in self.world.shop_shuffle[player]))
|
bool_to_text("w" in self.multiworld.shop_shuffle[player]))
|
||||||
outfile.write('Boss shuffle: %s\n' % self.world.boss_shuffle[player])
|
outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player])
|
||||||
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
|
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
|
||||||
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
|
|
||||||
outfile.write('Prize shuffle %s\n' %
|
outfile.write('Prize shuffle %s\n' %
|
||||||
self.world.shuffle_prizes[player])
|
self.multiworld.shuffle_prizes[player])
|
||||||
if self.entrances:
|
if self.entrances:
|
||||||
outfile.write('\n\nEntrances:\n\n')
|
outfile.write('\n\nEntrances:\n\n')
|
||||||
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.world.get_player_name(entry["player"])}: '
|
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: '
|
||||||
if self.world.players > 1 else '', entry['entrance'],
|
if self.multiworld.players > 1 else '', entry['entrance'],
|
||||||
'<=>' if entry['direction'] == 'both' else
|
'<=>' if entry['direction'] == 'both' else
|
||||||
'<=' if entry['direction'] == 'exit' else '=>',
|
'<=' if entry['direction'] == 'exit' else '=>',
|
||||||
entry['exit']) for entry in self.entrances.values()]))
|
entry['exit']) for entry in self.entrances.values()]))
|
||||||
@@ -1440,7 +1472,7 @@ class Spoiler():
|
|||||||
for dungeon, medallion in self.medallions.items():
|
for dungeon, medallion in self.medallions.items():
|
||||||
outfile.write(f'\n{dungeon}: {medallion}')
|
outfile.write(f'\n{dungeon}: {medallion}')
|
||||||
|
|
||||||
AutoWorld.call_all(self.world, "write_spoiler", outfile)
|
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
|
||||||
|
|
||||||
outfile.write('\n\nLocations:\n\n')
|
outfile.write('\n\nLocations:\n\n')
|
||||||
outfile.write('\n'.join(
|
outfile.write('\n'.join(
|
||||||
@@ -1453,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 item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
|
||||||
item)) for shop in self.shops))
|
item)) for shop in self.shops))
|
||||||
|
|
||||||
for player in self.world.get_game_players("A Link to the Past"):
|
for player in self.multiworld.get_game_players("A Link to the Past"):
|
||||||
if self.world.boss_shuffle[player] != 'none':
|
if self.multiworld.boss_shuffle[player] != 'none':
|
||||||
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
|
bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses
|
||||||
outfile.write(
|
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 '.join([f'{x}: {y}' for x, y in bossmap.items()]))
|
||||||
outfile.write('\n\nPlaythrough:\n\n')
|
outfile.write('\n\nPlaythrough:\n\n')
|
||||||
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
|
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
|
||||||
@@ -1481,7 +1513,7 @@ class Spoiler():
|
|||||||
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
|
||||||
|
|
||||||
outfile.write('\n'.join(path_listings))
|
outfile.write('\n'.join(path_listings))
|
||||||
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
|
AutoWorld.call_all(self.multiworld, "write_spoiler_end", outfile)
|
||||||
|
|
||||||
|
|
||||||
class Tutorial(NamedTuple):
|
class Tutorial(NamedTuple):
|
||||||
|
|||||||
161
CommonClient.py
161
CommonClient.py
@@ -20,10 +20,13 @@ if __name__ == "__main__":
|
|||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor
|
||||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||||
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||||
from Utils import Version, stream_input
|
from Utils import Version, stream_input, async_start
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
import kvui
|
||||||
|
|
||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
|
|
||||||
# without terminal, we have to use gui mode
|
# without terminal, we have to use gui mode
|
||||||
@@ -44,16 +47,18 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_connect(self, address: str = "") -> bool:
|
def _cmd_connect(self, address: str = "") -> bool:
|
||||||
"""Connect to a MultiWorld Server"""
|
"""Connect to a MultiWorld Server"""
|
||||||
self.ctx.server_address = None
|
if address:
|
||||||
self.ctx.username = None
|
self.ctx.server_address = None
|
||||||
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
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
|
return True
|
||||||
|
|
||||||
def _cmd_disconnect(self) -> bool:
|
def _cmd_disconnect(self) -> bool:
|
||||||
"""Disconnect from a MultiWorld Server"""
|
"""Disconnect from a MultiWorld Server"""
|
||||||
self.ctx.server_address = None
|
async_start(self.ctx.disconnect(), name="disconnecting")
|
||||||
self.ctx.username = None
|
|
||||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_received(self) -> bool:
|
def _cmd_received(self) -> bool:
|
||||||
@@ -91,12 +96,18 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_items(self):
|
def _cmd_items(self):
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output("No game set, cannot determine existing items.")
|
||||||
|
return False
|
||||||
self.output(f"Item Names for {self.ctx.game}")
|
self.output(f"Item Names for {self.ctx.game}")
|
||||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||||
self.output(item_name)
|
self.output(item_name)
|
||||||
|
|
||||||
def _cmd_locations(self):
|
def _cmd_locations(self):
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output("No game set, cannot determine existing locations.")
|
||||||
|
return False
|
||||||
self.output(f"Location Names for {self.ctx.game}")
|
self.output(f"Location Names for {self.ctx.game}")
|
||||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||||
self.output(location_name)
|
self.output(location_name)
|
||||||
@@ -110,12 +121,12 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
else:
|
else:
|
||||||
state = ClientStatus.CLIENT_CONNECTED
|
state = ClientStatus.CLIENT_CONNECTED
|
||||||
self.output("Unreadied.")
|
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):
|
def default(self, raw: str):
|
||||||
raw = self.ctx.on_user_say(raw)
|
raw = self.ctx.on_user_say(raw)
|
||||||
if 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:
|
class CommonContext:
|
||||||
@@ -132,28 +143,36 @@ class CommonContext:
|
|||||||
# defaults
|
# defaults
|
||||||
starting_reconnect_delay: int = 5
|
starting_reconnect_delay: int = 5
|
||||||
current_reconnect_delay: int = starting_reconnect_delay
|
current_reconnect_delay: int = starting_reconnect_delay
|
||||||
command_processor: type(CommandProcessor) = ClientCommandProcessor
|
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||||
ui = None
|
ui = None
|
||||||
ui_task: typing.Optional[asyncio.Task] = None
|
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
input_task: typing.Optional[asyncio.Task] = None
|
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
keep_alive_task: typing.Optional[asyncio.Task] = None
|
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
server_task: typing.Optional[asyncio.Task] = 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: typing.Optional[Endpoint] = None
|
||||||
server_version: Version = Version(0, 0, 0)
|
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
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
server_address: str
|
server_address: typing.Optional[str]
|
||||||
password: typing.Optional[str]
|
password: typing.Optional[str]
|
||||||
hint_cost: typing.Optional[int]
|
hint_cost: typing.Optional[int]
|
||||||
player_names: typing.Dict[int, str]
|
player_names: typing.Dict[int, str]
|
||||||
|
|
||||||
|
finished_game: bool
|
||||||
|
ready: bool
|
||||||
|
auth: typing.Optional[str]
|
||||||
|
seed_name: typing.Optional[str]
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: typing.Set[int] # local state
|
locations_checked: typing.Set[int] # local state
|
||||||
locations_scouted: typing.Set[int]
|
locations_scouted: typing.Set[int]
|
||||||
|
items_received: typing.List[NetworkItem]
|
||||||
missing_locations: typing.Set[int] # server state
|
missing_locations: typing.Set[int] # server state
|
||||||
checked_locations: typing.Set[int] # server state
|
checked_locations: typing.Set[int] # server state
|
||||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||||
@@ -161,9 +180,11 @@ class CommonContext:
|
|||||||
|
|
||||||
# internals
|
# internals
|
||||||
# current message box through kvui
|
# 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
|
# server state
|
||||||
self.server_address = server_address
|
self.server_address = server_address
|
||||||
self.username = None
|
self.username = None
|
||||||
@@ -206,6 +227,12 @@ class CommonContext:
|
|||||||
# execution
|
# execution
|
||||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def suggested_address(self) -> str:
|
||||||
|
if self.server_address:
|
||||||
|
return self.server_address
|
||||||
|
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
|
||||||
|
|
||||||
@functools.cached_property
|
@functools.cached_property
|
||||||
def raw_text_parser(self) -> RawJSONtoTextParser:
|
def raw_text_parser(self) -> RawJSONtoTextParser:
|
||||||
return RawJSONtoTextParser(self)
|
return RawJSONtoTextParser(self)
|
||||||
@@ -217,9 +244,9 @@ class CommonContext:
|
|||||||
return len(self.checked_locations | self.missing_locations)
|
return len(self.checked_locations | self.missing_locations)
|
||||||
|
|
||||||
async def connection_closed(self):
|
async def connection_closed(self):
|
||||||
self.reset_server_state()
|
|
||||||
if self.server and self.server.socket is not None:
|
if self.server and self.server.socket is not None:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
|
self.reset_server_state()
|
||||||
|
|
||||||
def reset_server_state(self):
|
def reset_server_state(self):
|
||||||
self.auth = None
|
self.auth = None
|
||||||
@@ -237,13 +264,18 @@ class CommonContext:
|
|||||||
"remaining": "disabled",
|
"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:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
|
|
||||||
async def send_msgs(self, msgs):
|
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:
|
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||||
return
|
return
|
||||||
await self.server.socket.send(encode(msgs))
|
await self.server.socket.send(encode(msgs))
|
||||||
@@ -271,7 +303,8 @@ class CommonContext:
|
|||||||
logger.info('Enter slot name:')
|
logger.info('Enter slot name:')
|
||||||
self.auth = await self.console_input()
|
self.auth = await self.console_input()
|
||||||
|
|
||||||
async def send_connect(self, **kwargs):
|
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||||
|
""" send `Connect` packet to log in to server """
|
||||||
payload = {
|
payload = {
|
||||||
'cmd': 'Connect',
|
'cmd': 'Connect',
|
||||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||||
@@ -282,14 +315,24 @@ class CommonContext:
|
|||||||
payload.update(kwargs)
|
payload.update(kwargs)
|
||||||
await self.send_msgs([payload])
|
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
|
self.input_requests += 1
|
||||||
return await self.input_queue.get()
|
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()
|
await self.disconnect()
|
||||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||||
|
|
||||||
|
def cancel_autoreconnect(self) -> bool:
|
||||||
|
if self.autoreconnect_task:
|
||||||
|
self.autoreconnect_task.cancel()
|
||||||
|
self.autoreconnect_task = None
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def slot_concerns_self(self, slot) -> bool:
|
def slot_concerns_self(self, slot) -> bool:
|
||||||
if slot == self.slot:
|
if slot == self.slot:
|
||||||
return True
|
return True
|
||||||
@@ -297,6 +340,12 @@ class CommonContext:
|
|||||||
return self.slot in self.slot_info[slot].group_members
|
return self.slot in self.slot_info[slot].group_members
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
|
||||||
|
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
|
||||||
|
return print_json_packet.get("type", "") == "ItemSend" \
|
||||||
|
and not self.slot_concerns_self(print_json_packet["receiving"]) \
|
||||||
|
and not self.slot_concerns_self(print_json_packet["item"].player)
|
||||||
|
|
||||||
def on_print(self, args: dict):
|
def on_print(self, args: dict):
|
||||||
logger.info(args["text"])
|
logger.info(args["text"])
|
||||||
|
|
||||||
@@ -328,6 +377,7 @@ class CommonContext:
|
|||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
self.server_address = ""
|
self.server_address = ""
|
||||||
self.username = None
|
self.username = None
|
||||||
|
self.cancel_autoreconnect()
|
||||||
if self.server and not self.server.socket.closed:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task:
|
if self.server_task:
|
||||||
@@ -390,7 +440,7 @@ class CommonContext:
|
|||||||
|
|
||||||
# DeathLink hooks
|
# 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."""
|
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||||
self.last_death_link = max(data["time"], self.last_death_link)
|
self.last_death_link = max(data["time"], self.last_death_link)
|
||||||
text = data.get("cause", "")
|
text = data.get("cause", "")
|
||||||
@@ -421,10 +471,10 @@ class CommonContext:
|
|||||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||||
|
|
||||||
def gui_error(self, title: str, text: typing.Union[Exception, str]):
|
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
||||||
"""Displays an error messagebox"""
|
"""Displays an error messagebox"""
|
||||||
if not self.ui:
|
if not self.ui:
|
||||||
return
|
return None
|
||||||
title = title or "Error"
|
title = title or "Error"
|
||||||
from kvui import MessageBox
|
from kvui import MessageBox
|
||||||
if self._messagebox:
|
if self._messagebox:
|
||||||
@@ -441,6 +491,13 @@ class CommonContext:
|
|||||||
# display error
|
# display error
|
||||||
self._messagebox = MessageBox(title, text, error=True)
|
self._messagebox = MessageBox(title, text, error=True)
|
||||||
self._messagebox.open()
|
self._messagebox.open()
|
||||||
|
return self._messagebox
|
||||||
|
|
||||||
|
def _handle_connection_loss(self, msg: str) -> None:
|
||||||
|
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
|
||||||
|
exc_info = sys.exc_info()
|
||||||
|
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||||
|
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
"""Import kivy UI system and start running it as self.ui_task."""
|
||||||
@@ -477,7 +534,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
|||||||
seconds_elapsed = 0
|
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:
|
if ctx.server and ctx.server.socket:
|
||||||
logger.error('Already connected')
|
logger.error('Already connected')
|
||||||
return
|
return
|
||||||
@@ -490,6 +547,11 @@ async def server_loop(ctx: CommonContext, address=None):
|
|||||||
logger.info('Please connect to an Archipelago server.')
|
logger.info('Please connect to an Archipelago server.')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
ctx.cancel_autoreconnect()
|
||||||
|
if ctx._messagebox_connection_loss:
|
||||||
|
ctx._messagebox_connection_loss.dismiss()
|
||||||
|
ctx._messagebox_connection_loss = None
|
||||||
|
|
||||||
address = f"ws://{address}" if "://" not in address \
|
address = f"ws://{address}" if "://" not in address \
|
||||||
else address.replace("archipelago://", "ws://")
|
else address.replace("archipelago://", "ws://")
|
||||||
|
|
||||||
@@ -500,6 +562,9 @@ async def server_loop(ctx: CommonContext, address=None):
|
|||||||
ctx.password = server_url.password
|
ctx.password = server_url.password
|
||||||
port = server_url.port or 38281
|
port = server_url.port or 38281
|
||||||
|
|
||||||
|
def reconnect_hint() -> str:
|
||||||
|
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||||
|
|
||||||
logger.info(f'Connecting to Archipelago server at {address}')
|
logger.info(f'Connecting to Archipelago server at {address}')
|
||||||
try:
|
try:
|
||||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||||
@@ -509,31 +574,25 @@ async def server_loop(ctx: CommonContext, address=None):
|
|||||||
logger.info('Connected')
|
logger.info('Connected')
|
||||||
ctx.server_address = address
|
ctx.server_address = address
|
||||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||||
|
ctx.disconnected_intentionally = False
|
||||||
async for data in ctx.server.socket:
|
async for data in ctx.server.socket:
|
||||||
for msg in decode(data):
|
for msg in decode(data):
|
||||||
await process_server_cmd(ctx, msg)
|
await process_server_cmd(ctx, msg)
|
||||||
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
|
||||||
except ConnectionRefusedError as e:
|
except ConnectionRefusedError:
|
||||||
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
|
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.")
|
||||||
logger.exception(msg, extra={'compact_gui': True})
|
except websockets.InvalidURI:
|
||||||
ctx.gui_error(msg, e)
|
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||||
except websockets.InvalidURI as e:
|
except OSError:
|
||||||
msg = 'Failed to connect to the multiworld server (invalid URI)'
|
ctx._handle_connection_loss("Failed to connect to the multiworld server")
|
||||||
logger.exception(msg, extra={'compact_gui': True})
|
except Exception:
|
||||||
ctx.gui_error(msg, e)
|
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
||||||
except OSError as e:
|
|
||||||
msg = 'Failed to connect to the multiworld server'
|
|
||||||
logger.exception(msg, extra={'compact_gui': True})
|
|
||||||
ctx.gui_error(msg, e)
|
|
||||||
except Exception as e:
|
|
||||||
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
|
|
||||||
logger.exception(msg, extra={'compact_gui': True})
|
|
||||||
ctx.gui_error(msg, e)
|
|
||||||
finally:
|
finally:
|
||||||
await ctx.connection_closed()
|
await ctx.connection_closed()
|
||||||
if ctx.server_address:
|
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
|
||||||
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
|
||||||
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
assert ctx.autoreconnect_task is None
|
||||||
|
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||||
ctx.current_reconnect_delay *= 2
|
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.checked_locations = set(args["checked_locations"])
|
||||||
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
||||||
|
|
||||||
|
server_url = urllib.parse.urlparse(ctx.server_address)
|
||||||
|
Utils.persistent_store("client", "last_server_address", server_url.netloc)
|
||||||
|
|
||||||
elif cmd == 'ReceivedItems':
|
elif cmd == 'ReceivedItems':
|
||||||
start_index = args["index"]
|
start_index = args["index"]
|
||||||
|
|
||||||
@@ -722,7 +784,7 @@ async def console_loop(ctx: CommonContext):
|
|||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
|
||||||
|
|
||||||
def get_base_parser(description=None):
|
def get_base_parser(description: typing.Optional[str] = None):
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description=description)
|
parser = argparse.ArgumentParser(description=description)
|
||||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||||
@@ -754,7 +816,6 @@ if __name__ == '__main__':
|
|||||||
async def main(args):
|
async def main(args):
|
||||||
ctx = TextContext(args.connect, args.password)
|
ctx = TextContext(args.connect, args.password)
|
||||||
ctx.auth = args.name
|
ctx.auth = args.name
|
||||||
ctx.server_address = args.connect
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
|
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
from Utils import async_start
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
get_base_parser
|
get_base_parser
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class FF1Context(CommonContext):
|
|||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
async_start(parse_locations(self.locations_array, self, True))
|
||||||
elif cmd == 'Print':
|
elif cmd == 'Print':
|
||||||
msg = args['text']
|
msg = args['text']
|
||||||
if ': !' not in msg:
|
if ': !' not in msg:
|
||||||
@@ -180,7 +181,7 @@ async def nes_sync_task(ctx: FF1Context):
|
|||||||
# print(data_decoded)
|
# print(data_decoded)
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
||||||
if not ctx.auth:
|
if not ctx.auth:
|
||||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||||
if ctx.auth == '':
|
if ctx.auth == '':
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import logging
|
|||||||
import json
|
import json
|
||||||
import string
|
import string
|
||||||
import copy
|
import copy
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
import typing
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -17,12 +20,18 @@ import asyncio
|
|||||||
from queue import Queue
|
from queue import Queue
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
|
def check_stdin() -> None:
|
||||||
|
if Utils.is_windows and sys.stdin:
|
||||||
|
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||||
|
check_stdin()
|
||||||
|
|
||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||||
|
from Utils import async_start
|
||||||
|
|
||||||
from worlds.factorio import Factorio
|
from worlds.factorio import Factorio
|
||||||
|
|
||||||
@@ -30,6 +39,10 @@ from worlds.factorio import Factorio
|
|||||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||||
ctx: FactorioContext
|
ctx: FactorioContext
|
||||||
|
|
||||||
|
def _cmd_energy_link(self):
|
||||||
|
"""Print the status of the energy link."""
|
||||||
|
self.output(f"Energy Link: {self.ctx.energy_link_status}")
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_factorio(self, text: str) -> bool:
|
def _cmd_factorio(self, text: str) -> bool:
|
||||||
"""Send the following command to the bound Factorio Server."""
|
"""Send the following command to the bound Factorio Server."""
|
||||||
@@ -46,6 +59,13 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
|||||||
"""Manually trigger a resync."""
|
"""Manually trigger a resync."""
|
||||||
self.ctx.awaiting_bridge = True
|
self.ctx.awaiting_bridge = True
|
||||||
|
|
||||||
|
def _cmd_toggle_send_filter(self):
|
||||||
|
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
|
||||||
|
self.ctx.toggle_filter_item_sends()
|
||||||
|
|
||||||
|
def _cmd_toggle_chat(self):
|
||||||
|
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
|
||||||
|
self.ctx.toggle_bridge_chat_out()
|
||||||
|
|
||||||
class FactorioContext(CommonContext):
|
class FactorioContext(CommonContext):
|
||||||
command_processor = FactorioCommandProcessor
|
command_processor = FactorioCommandProcessor
|
||||||
@@ -65,6 +85,9 @@ class FactorioContext(CommonContext):
|
|||||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||||
self.energy_link_increment = 0
|
self.energy_link_increment = 0
|
||||||
self.last_deplete = 0
|
self.last_deplete = 0
|
||||||
|
self.filter_item_sends: bool = False
|
||||||
|
self.multiplayer: bool = False # whether multiple different players have connected
|
||||||
|
self.bridge_chat_out: bool = True
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -81,12 +104,15 @@ class FactorioContext(CommonContext):
|
|||||||
def on_print(self, args: dict):
|
def on_print(self, args: dict):
|
||||||
super(FactorioContext, self).on_print(args)
|
super(FactorioContext, self).on_print(args)
|
||||||
if self.rcon_client:
|
if self.rcon_client:
|
||||||
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):
|
def on_print_json(self, args: dict):
|
||||||
if self.rcon_client:
|
if self.rcon_client:
|
||||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
if not self.filter_item_sends or not self.is_uninteresting_item_send(args):
|
||||||
self.print_to_game(text)
|
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)
|
super(FactorioContext, self).on_print_json(args)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -97,6 +123,15 @@ class FactorioContext(CommonContext):
|
|||||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||||
f"{text}")
|
f"{text}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def energy_link_status(self) -> str:
|
||||||
|
if not self.energy_link_increment:
|
||||||
|
return "Disabled"
|
||||||
|
elif self.current_energy_link_value is None:
|
||||||
|
return "Standby"
|
||||||
|
else:
|
||||||
|
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
|
||||||
|
|
||||||
def on_deathlink(self, data: dict):
|
def on_deathlink(self, data: dict):
|
||||||
if self.rcon_client:
|
if self.rcon_client:
|
||||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||||
@@ -109,7 +144,7 @@ class FactorioContext(CommonContext):
|
|||||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||||
item_name in args["checked_locations"]})
|
item_name in args["checked_locations"]})
|
||||||
if cmd == "Connected" and self.energy_link_increment:
|
if cmd == "Connected" and self.energy_link_increment:
|
||||||
asyncio.create_task(self.send_msgs([{
|
async_start(self.send_msgs([{
|
||||||
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
||||||
}]))
|
}]))
|
||||||
elif cmd == "SetReply":
|
elif cmd == "SetReply":
|
||||||
@@ -123,6 +158,45 @@ class FactorioContext(CommonContext):
|
|||||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||||
|
|
||||||
|
def on_user_say(self, text: str) -> typing.Optional[str]:
|
||||||
|
# Mirror chat sent from the UI to the Factorio server.
|
||||||
|
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
async def chat_from_factorio(self, user: str, message: str) -> None:
|
||||||
|
if not self.bridge_chat_out:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pass through commands
|
||||||
|
if message.startswith("!"):
|
||||||
|
await self.send_msgs([{"cmd": "Say", "text": message}])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Omit messages that contain local coordinates
|
||||||
|
if "[gps=" in message:
|
||||||
|
return
|
||||||
|
|
||||||
|
prefix = f"({user}) " if self.multiplayer else ""
|
||||||
|
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
|
||||||
|
|
||||||
|
def toggle_filter_item_sends(self) -> None:
|
||||||
|
self.filter_item_sends = not self.filter_item_sends
|
||||||
|
if self.filter_item_sends:
|
||||||
|
announcement = "Item sends are now filtered."
|
||||||
|
else:
|
||||||
|
announcement = "Item sends are no longer filtered."
|
||||||
|
logger.info(announcement)
|
||||||
|
self.print_to_game(announcement)
|
||||||
|
|
||||||
|
def toggle_bridge_chat_out(self) -> None:
|
||||||
|
self.bridge_chat_out = not self.bridge_chat_out
|
||||||
|
if self.bridge_chat_out:
|
||||||
|
announcement = "Chat is now bridged to Archipelago."
|
||||||
|
else:
|
||||||
|
announcement = "Chat is no longer bridged to Archipelago."
|
||||||
|
logger.info(announcement)
|
||||||
|
self.print_to_game(announcement)
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
|
|
||||||
@@ -140,7 +214,6 @@ class FactorioContext(CommonContext):
|
|||||||
|
|
||||||
async def game_watcher(ctx: FactorioContext):
|
async def game_watcher(ctx: FactorioContext):
|
||||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||||
from worlds.factorio.Technologies import lookup_id_to_name
|
|
||||||
next_bridge = time.perf_counter() + 1
|
next_bridge = time.perf_counter() + 1
|
||||||
try:
|
try:
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
@@ -162,6 +235,7 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||||
victory = data["victory"]
|
victory = data["victory"]
|
||||||
await ctx.update_death_link(data["death_link"])
|
await ctx.update_death_link(data["death_link"])
|
||||||
|
ctx.multiplayer = data.get("multiplayer", False)
|
||||||
|
|
||||||
if not ctx.finished_game and victory:
|
if not ctx.finished_game and victory:
|
||||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||||
@@ -170,14 +244,14 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
if ctx.locations_checked != research_data:
|
if ctx.locations_checked != research_data:
|
||||||
bridge_logger.debug(
|
bridge_logger.debug(
|
||||||
f"New researches done: "
|
f"New researches done: "
|
||||||
f"{[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
|
ctx.locations_checked = research_data
|
||||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||||
death_link_tick = data.get("death_link_tick", 0)
|
death_link_tick = data.get("death_link_tick", 0)
|
||||||
if death_link_tick != ctx.death_link_tick:
|
if death_link_tick != ctx.death_link_tick:
|
||||||
ctx.death_link_tick = death_link_tick
|
ctx.death_link_tick = death_link_tick
|
||||||
if "DeathLink" in ctx.tags:
|
if "DeathLink" in ctx.tags:
|
||||||
asyncio.create_task(ctx.send_death())
|
async_start(ctx.send_death())
|
||||||
if ctx.energy_link_increment:
|
if ctx.energy_link_increment:
|
||||||
in_world_bridges = data["energy_bridges"]
|
in_world_bridges = data["energy_bridges"]
|
||||||
if in_world_bridges:
|
if in_world_bridges:
|
||||||
@@ -185,7 +259,7 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
||||||
# attempt to refill
|
# attempt to refill
|
||||||
ctx.last_deplete = time.time()
|
ctx.last_deplete = time.time()
|
||||||
asyncio.create_task(ctx.send_msgs([{
|
async_start(ctx.send_msgs([{
|
||||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||||
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
||||||
{"operation": "max", "value": 0}],
|
{"operation": "max", "value": 0}],
|
||||||
@@ -195,7 +269,7 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
||||||
ctx.energy_link_increment*in_world_bridges:
|
ctx.energy_link_increment*in_world_bridges:
|
||||||
value = ctx.energy_link_increment * in_world_bridges
|
value = ctx.energy_link_increment * in_world_bridges
|
||||||
asyncio.create_task(ctx.send_msgs([{
|
async_start(ctx.send_msgs([{
|
||||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||||
[{"operation": "add", "value": value}]
|
[{"operation": "add", "value": value}]
|
||||||
}]))
|
}]))
|
||||||
@@ -211,6 +285,8 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
|
|
||||||
|
|
||||||
def stream_factorio_output(pipe, queue, process):
|
def stream_factorio_output(pipe, queue, process):
|
||||||
|
pipe.reconfigure(errors="replace")
|
||||||
|
|
||||||
def queuer():
|
def queuer():
|
||||||
while process.poll() is None:
|
while process.poll() is None:
|
||||||
text = pipe.readline().strip()
|
text = pipe.readline().strip()
|
||||||
@@ -243,7 +319,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
|||||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||||
try:
|
try:
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
if factorio_process.poll():
|
if factorio_process.poll() is not None:
|
||||||
factorio_server_logger.info("Factorio server has exited.")
|
factorio_server_logger.info("Factorio server has exited.")
|
||||||
ctx.exit_event.set()
|
ctx.exit_event.set()
|
||||||
|
|
||||||
@@ -256,12 +332,25 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
|||||||
if not ctx.server:
|
if not ctx.server:
|
||||||
logger.info("Established bridge to Factorio Server. "
|
logger.info("Established bridge to Factorio Server. "
|
||||||
"Ready to connect to Archipelago via /connect")
|
"Ready to connect to Archipelago via /connect")
|
||||||
|
check_stdin()
|
||||||
|
|
||||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||||
ctx.awaiting_bridge = True
|
ctx.awaiting_bridge = True
|
||||||
factorio_server_logger.debug(msg)
|
factorio_server_logger.debug(msg)
|
||||||
|
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
|
||||||
|
factorio_server_logger.debug(msg)
|
||||||
|
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
|
||||||
|
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
|
||||||
|
factorio_server_logger.debug(msg)
|
||||||
|
ctx.toggle_filter_item_sends()
|
||||||
|
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
|
||||||
|
factorio_server_logger.debug(msg)
|
||||||
|
ctx.toggle_bridge_chat_out()
|
||||||
else:
|
else:
|
||||||
factorio_server_logger.info(msg)
|
factorio_server_logger.info(msg)
|
||||||
|
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
|
||||||
|
if match:
|
||||||
|
await ctx.chat_from_factorio(match.group(1), match.group(2))
|
||||||
if ctx.rcon_client:
|
if ctx.rcon_client:
|
||||||
commands = {}
|
commands = {}
|
||||||
while ctx.send_index < len(ctx.items_received):
|
while ctx.send_index < len(ctx.items_received):
|
||||||
@@ -282,12 +371,34 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
logging.error("Aborted Factorio Server Bridge")
|
logging.error("Aborted Factorio Server Bridge")
|
||||||
ctx.rcon_client = None
|
|
||||||
ctx.exit_event.set()
|
ctx.exit_event.set()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
factorio_process.terminate()
|
if factorio_process.poll() is not None:
|
||||||
factorio_process.wait(5)
|
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):
|
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):
|
async def main(args):
|
||||||
ctx = FactorioContext(args.connect, args.password)
|
ctx = FactorioContext(args.connect, args.password)
|
||||||
|
ctx.filter_item_sends = initial_filter_item_sends
|
||||||
|
ctx.bridge_chat_out = initial_bridge_chat_out
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||||
|
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
@@ -413,6 +526,12 @@ if __name__ == '__main__':
|
|||||||
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||||
if server_settings:
|
if server_settings:
|
||||||
server_settings = os.path.abspath(server_settings)
|
server_settings = os.path.abspath(server_settings)
|
||||||
|
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
|
||||||
|
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
|
||||||
|
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
|
||||||
|
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
|
||||||
|
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
|
||||||
|
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
|
||||||
|
|
||||||
if not os.path.exists(os.path.dirname(executable)):
|
if not os.path.exists(os.path.dirname(executable)):
|
||||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||||
|
|||||||
466
Fill.py
466
Fill.py
@@ -4,9 +4,10 @@ import collections
|
|||||||
import itertools
|
import itertools
|
||||||
from collections import Counter, deque
|
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.AutoWorld import call_all
|
||||||
|
from worlds.generic.Rules import add_item_rule
|
||||||
|
|
||||||
|
|
||||||
class FillError(RuntimeError):
|
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],
|
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] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
|
|
||||||
@@ -69,60 +71,66 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# we filled all reachable spots.
|
# we filled all reachable spots.
|
||||||
# try swapping this item with previously placed items
|
if swap:
|
||||||
for (i, location) in enumerate(placements):
|
# try swapping this item with previously placed items
|
||||||
placed_item = location.item
|
for (i, location) in enumerate(placements):
|
||||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
placed_item = location.item
|
||||||
# number of times we will swap an individual item to prevent this
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
swap_count = swapped_items[placed_item.player,
|
# number of times we will swap an individual item to prevent this
|
||||||
placed_item.name]
|
swap_count = swapped_items[placed_item.player,
|
||||||
if swap_count > 1:
|
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
|
continue
|
||||||
|
else:
|
||||||
location.item = None
|
|
||||||
placed_item.location = None
|
|
||||||
swap_state = sweep_from_pool(base_state)
|
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
|
||||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
|
||||||
|
|
||||||
# Verify that placing this item won't reduce available locations
|
|
||||||
prev_state = swap_state.copy()
|
|
||||||
prev_state.collect(placed_item)
|
|
||||||
prev_loc_count = len(
|
|
||||||
world.get_reachable_locations(prev_state))
|
|
||||||
|
|
||||||
swap_state.collect(item_to_place, True)
|
|
||||||
new_loc_count = len(
|
|
||||||
world.get_reachable_locations(swap_state))
|
|
||||||
|
|
||||||
if new_loc_count >= prev_loc_count:
|
|
||||||
# Add this item to the existing placement, and
|
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swap_count += 1
|
|
||||||
swapped_items[placed_item.player,
|
|
||||||
placed_item.name] = swap_count
|
|
||||||
|
|
||||||
reachable_items[placed_item.player].appendleft(
|
|
||||||
placed_item)
|
|
||||||
itempool.append(placed_item)
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
|
||||||
location.item = placed_item
|
|
||||||
placed_item.location = location
|
|
||||||
|
|
||||||
if spot_to_fill is None:
|
|
||||||
# Can't place this item, move on to the next
|
|
||||||
unplaced_items.append(item_to_place)
|
unplaced_items.append(item_to_place)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
world.push_item(spot_to_fill, item_to_place, False)
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
spot_to_fill.locked = lock
|
spot_to_fill.locked = lock
|
||||||
placements.append(spot_to_fill)
|
placements.append(spot_to_fill)
|
||||||
spot_to_fill.event = item_to_place.advancement
|
spot_to_fill.event = item_to_place.advancement
|
||||||
|
if on_place:
|
||||||
|
on_place(spot_to_fill)
|
||||||
|
|
||||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||||
# There are leftover unplaceable items and locations that won't accept them
|
# There are leftover unplaceable items and locations that won't accept them
|
||||||
@@ -136,33 +144,207 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
itempool.extend(unplaced_items)
|
itempool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
|
def remaining_fill(world: MultiWorld,
|
||||||
|
locations: typing.List[Location],
|
||||||
|
itempool: typing.List[Item]) -> None:
|
||||||
|
unplaced_items: typing.List[Item] = []
|
||||||
|
placements: typing.List[Location] = []
|
||||||
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
|
while locations and itempool:
|
||||||
|
item_to_place = itempool.pop()
|
||||||
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
|
|
||||||
|
for i, location in enumerate(locations):
|
||||||
|
if location.item_rule(item_to_place):
|
||||||
|
# popping by index is faster than removing by content,
|
||||||
|
spot_to_fill = locations.pop(i)
|
||||||
|
# skipping a scan for the element
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
# we filled all reachable spots.
|
||||||
|
# try swapping this item with previously placed items
|
||||||
|
|
||||||
|
for (i, location) in enumerate(placements):
|
||||||
|
placed_item = location.item
|
||||||
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
|
# number of times we will swap an individual item to prevent this
|
||||||
|
|
||||||
|
if swapped_items[placed_item.player,
|
||||||
|
placed_item.name] > 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
location.item = None
|
||||||
|
placed_item.location = None
|
||||||
|
if location.item_rule(item_to_place):
|
||||||
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
|
swapped_items[placed_item.player,
|
||||||
|
placed_item.name] += 1
|
||||||
|
|
||||||
|
itempool.append(placed_item)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
# Item can't be placed here, restore original item
|
||||||
|
location.item = placed_item
|
||||||
|
placed_item.location = location
|
||||||
|
|
||||||
|
if spot_to_fill is None:
|
||||||
|
# Can't place this item, move on to the next
|
||||||
|
unplaced_items.append(item_to_place)
|
||||||
|
continue
|
||||||
|
|
||||||
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
|
placements.append(spot_to_fill)
|
||||||
|
|
||||||
|
if unplaced_items and locations:
|
||||||
|
# There are leftover unplaceable items and locations that won't accept them
|
||||||
|
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||||
|
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||||
|
|
||||||
|
itempool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
|
def fast_fill(world: MultiWorld,
|
||||||
|
item_pool: typing.List[Item],
|
||||||
|
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
||||||
|
placing = min(len(item_pool), len(fill_locations))
|
||||||
|
for item, location in zip(item_pool, fill_locations):
|
||||||
|
world.push_item(location, item, False)
|
||||||
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
|
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||||
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
|
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
|
||||||
|
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
|
||||||
|
not location.can_reach(maximum_exploration_state)]
|
||||||
|
for location in unreachable_locations:
|
||||||
|
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||||
|
location.locked and location.item.player not in minimal_players):
|
||||||
|
pool.append(location.item)
|
||||||
|
state.remove(location.item)
|
||||||
|
location.item = None
|
||||||
|
location.event = False
|
||||||
|
if location in state.events:
|
||||||
|
state.events.remove(location)
|
||||||
|
locations.append(location)
|
||||||
|
if pool and locations:
|
||||||
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
|
fill_restrictive(world, state, locations, pool)
|
||||||
|
|
||||||
|
|
||||||
|
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
|
||||||
|
maximum_exploration_state = sweep_from_pool(state)
|
||||||
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||||
|
if unreachable_locations:
|
||||||
|
def forbid_important_item_rule(item: Item):
|
||||||
|
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
|
||||||
|
|
||||||
|
for location in unreachable_locations:
|
||||||
|
add_item_rule(location, forbid_important_item_rule)
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_early_items(world: MultiWorld,
|
||||||
|
fill_locations: typing.List[Location],
|
||||||
|
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
||||||
|
""" returns new fill_locations and itempool """
|
||||||
|
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
|
||||||
|
for player in world.player_ids:
|
||||||
|
items = itertools.chain(world.early_items[player], world.local_early_items[player])
|
||||||
|
for item in items:
|
||||||
|
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
|
||||||
|
if early_items_count:
|
||||||
|
early_locations: typing.List[Location] = []
|
||||||
|
early_priority_locations: typing.List[Location] = []
|
||||||
|
loc_indexes_to_remove: typing.Set[int] = set()
|
||||||
|
base_state = world.state.copy()
|
||||||
|
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
|
||||||
|
for i, loc in enumerate(fill_locations):
|
||||||
|
if loc.can_reach(base_state):
|
||||||
|
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||||
|
early_priority_locations.append(loc)
|
||||||
|
else:
|
||||||
|
early_locations.append(loc)
|
||||||
|
loc_indexes_to_remove.add(i)
|
||||||
|
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
|
||||||
|
|
||||||
|
early_prog_items: typing.List[Item] = []
|
||||||
|
early_rest_items: typing.List[Item] = []
|
||||||
|
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
||||||
|
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
||||||
|
item_indexes_to_remove: typing.Set[int] = set()
|
||||||
|
for i, item in enumerate(itempool):
|
||||||
|
if (item.name, item.player) in early_items_count:
|
||||||
|
if item.advancement:
|
||||||
|
if early_items_count[(item.name, item.player)][1]:
|
||||||
|
early_local_prog_items[item.player].append(item)
|
||||||
|
early_items_count[(item.name, item.player)][1] -= 1
|
||||||
|
else:
|
||||||
|
early_prog_items.append(item)
|
||||||
|
early_items_count[(item.name, item.player)][0] -= 1
|
||||||
|
else:
|
||||||
|
if early_items_count[(item.name, item.player)][1]:
|
||||||
|
early_local_rest_items[item.player].append(item)
|
||||||
|
early_items_count[(item.name, item.player)][1] -= 1
|
||||||
|
else:
|
||||||
|
early_rest_items.append(item)
|
||||||
|
early_items_count[(item.name, item.player)][0] -= 1
|
||||||
|
item_indexes_to_remove.add(i)
|
||||||
|
if early_items_count[(item.name, item.player)] == [0, 0]:
|
||||||
|
del early_items_count[(item.name, item.player)]
|
||||||
|
if len(early_items_count) == 0:
|
||||||
|
break
|
||||||
|
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
||||||
|
for player in world.player_ids:
|
||||||
|
fill_restrictive(world, base_state,
|
||||||
|
[loc for loc in early_locations if loc.player == player],
|
||||||
|
early_local_rest_items[player], lock=True)
|
||||||
|
early_locations = [loc for loc in early_locations if not loc.item]
|
||||||
|
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True)
|
||||||
|
early_locations += early_priority_locations
|
||||||
|
for player in world.player_ids:
|
||||||
|
fill_restrictive(world, base_state,
|
||||||
|
[loc for loc in early_locations if loc.player == player],
|
||||||
|
early_local_prog_items[player], lock=True)
|
||||||
|
early_locations = [loc for loc in early_locations if not loc.item]
|
||||||
|
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True)
|
||||||
|
unplaced_early_items = early_rest_items + early_prog_items
|
||||||
|
if unplaced_early_items:
|
||||||
|
logging.warning("Ran out of early locations for early items. Failed to place "
|
||||||
|
f"{len(unplaced_early_items)} items early.")
|
||||||
|
itempool += unplaced_early_items
|
||||||
|
|
||||||
|
fill_locations.extend(early_locations)
|
||||||
|
world.random.shuffle(fill_locations)
|
||||||
|
return fill_locations, itempool
|
||||||
|
|
||||||
|
|
||||||
def distribute_items_restrictive(world: MultiWorld) -> None:
|
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||||
fill_locations = sorted(world.get_unfilled_locations())
|
fill_locations = sorted(world.get_unfilled_locations())
|
||||||
world.random.shuffle(fill_locations)
|
world.random.shuffle(fill_locations)
|
||||||
|
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
itempool = sorted(world.itempool)
|
itempool = sorted(world.itempool)
|
||||||
world.random.shuffle(itempool)
|
world.random.shuffle(itempool)
|
||||||
|
|
||||||
|
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
|
||||||
|
|
||||||
progitempool: typing.List[Item] = []
|
progitempool: typing.List[Item] = []
|
||||||
nonexcludeditempool: typing.List[Item] = []
|
usefulitempool: typing.List[Item] = []
|
||||||
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
|
filleritempool: typing.List[Item] = []
|
||||||
nonlocalrestitempool: typing.List[Item] = []
|
|
||||||
restitempool: typing.List[Item] = []
|
|
||||||
|
|
||||||
for item in itempool:
|
for item in itempool:
|
||||||
if item.advancement:
|
if item.advancement:
|
||||||
progitempool.append(item)
|
progitempool.append(item)
|
||||||
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
|
elif item.useful:
|
||||||
nonexcludeditempool.append(item)
|
usefulitempool.append(item)
|
||||||
elif item.name in world.local_items[item.player].value:
|
|
||||||
localrestitempool[item.player].append(item)
|
|
||||||
elif item.name in world.non_local_items[item.player].value:
|
|
||||||
nonlocalrestitempool.append(item)
|
|
||||||
else:
|
else:
|
||||||
restitempool.append(item)
|
filleritempool.append(item)
|
||||||
|
|
||||||
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
|
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
||||||
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
|
||||||
|
|
||||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||||
loc_type: [] for loc_type in LocationProgressType}
|
loc_type: [] for loc_type in LocationProgressType}
|
||||||
@@ -174,60 +356,44 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
|||||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
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:
|
if prioritylocations:
|
||||||
|
# "priority fill"
|
||||||
|
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
|
||||||
|
accessibility_corrections(world, world.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
|
# "progression fill"
|
||||||
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||||
|
accessibility_corrections(world, world.state, defaultlocations)
|
||||||
|
|
||||||
if nonexcludeditempool:
|
for location in lock_later:
|
||||||
world.random.shuffle(defaultlocations)
|
if location.item:
|
||||||
# needs logical fill to not conflict with local items
|
location.locked = True
|
||||||
fill_restrictive(
|
del mark_for_locking, lock_later
|
||||||
world, world.state, defaultlocations, nonexcludeditempool)
|
|
||||||
if nonexcludeditempool:
|
|
||||||
raise FillError(
|
|
||||||
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
|
|
||||||
|
|
||||||
defaultlocations = defaultlocations + excludedlocations
|
inaccessible_location_rules(world, world.state, defaultlocations)
|
||||||
world.random.shuffle(defaultlocations)
|
|
||||||
|
|
||||||
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
remaining_fill(world, excludedlocations, filleritempool)
|
||||||
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
|
if excludedlocations:
|
||||||
for location in defaultlocations:
|
raise FillError(
|
||||||
local_locations[location.player].append(location)
|
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
|
||||||
for player_locations in local_locations.values():
|
|
||||||
world.random.shuffle(player_locations)
|
|
||||||
|
|
||||||
for player, items in localrestitempool.items(): # items already shuffled
|
restitempool = usefulitempool + filleritempool
|
||||||
player_local_locations = local_locations[player]
|
|
||||||
for item_to_place in items:
|
|
||||||
if not player_local_locations:
|
|
||||||
logging.warning(f"Ran out of local locations for player {player}, "
|
|
||||||
f"cannot place {item_to_place}.")
|
|
||||||
break
|
|
||||||
spot_to_fill = player_local_locations.pop()
|
|
||||||
world.push_item(spot_to_fill, item_to_place, False)
|
|
||||||
defaultlocations.remove(spot_to_fill)
|
|
||||||
|
|
||||||
for item_to_place in nonlocalrestitempool:
|
remaining_fill(world, defaultlocations, restitempool)
|
||||||
for i, location in enumerate(defaultlocations):
|
|
||||||
if location.player != item_to_place.player:
|
|
||||||
world.push_item(defaultlocations.pop(i), item_to_place, False)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. "
|
|
||||||
f"Too many non-local items for too few remaining locations.")
|
|
||||||
|
|
||||||
world.random.shuffle(defaultlocations)
|
unplaced = restitempool
|
||||||
|
|
||||||
restitempool, defaultlocations = fast_fill(
|
|
||||||
world, restitempool, defaultlocations)
|
|
||||||
unplaced = progitempool + restitempool
|
|
||||||
unfilled = defaultlocations
|
unfilled = defaultlocations
|
||||||
|
|
||||||
if unplaced or unfilled:
|
if unplaced or unfilled:
|
||||||
@@ -241,15 +407,6 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
|||||||
logging.info(f'Per-Player counts: {print_data})')
|
logging.info(f'Per-Player counts: {print_data})')
|
||||||
|
|
||||||
|
|
||||||
def fast_fill(world: MultiWorld,
|
|
||||||
item_pool: typing.List[Item],
|
|
||||||
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
|
||||||
placing = min(len(item_pool), len(fill_locations))
|
|
||||||
for item, location in zip(item_pool, fill_locations):
|
|
||||||
world.push_item(location, item, False)
|
|
||||||
return item_pool[placing:], fill_locations[placing:]
|
|
||||||
|
|
||||||
|
|
||||||
def flood_items(world: MultiWorld) -> None:
|
def flood_items(world: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
world.random.shuffle(world.itempool)
|
world.random.shuffle(world.itempool)
|
||||||
@@ -526,6 +683,17 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
|
swept_state = world.state.copy()
|
||||||
|
swept_state.sweep_for_events()
|
||||||
|
reachable = frozenset(world.get_reachable_locations(swept_state))
|
||||||
|
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
for loc in world.get_unfilled_locations():
|
||||||
|
if loc in reachable:
|
||||||
|
early_locations[loc.player].append(loc.name)
|
||||||
|
else: # not reachable with swept state
|
||||||
|
non_early_locations[loc.player].append(loc.name)
|
||||||
|
|
||||||
# TODO: remove. Preferably by implementing key drop
|
# TODO: remove. Preferably by implementing key drop
|
||||||
from worlds.alttp.Regions import key_drop_data
|
from worlds.alttp.Regions import key_drop_data
|
||||||
world_name_lookup = world.world_name_lookup
|
world_name_lookup = world.world_name_lookup
|
||||||
@@ -541,7 +709,39 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
if 'from_pool' not in block:
|
if 'from_pool' not in block:
|
||||||
block['from_pool'] = True
|
block['from_pool'] = True
|
||||||
if 'world' not in block:
|
if 'world' not in block:
|
||||||
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 = []
|
items: block_value = []
|
||||||
if "items" in block:
|
if "items" in block:
|
||||||
items = block["items"]
|
items = block["items"]
|
||||||
@@ -578,6 +778,17 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
for key, value in locations.items():
|
for key, value in locations.items():
|
||||||
location_list += [key] * value
|
location_list += [key] * value
|
||||||
locations = location_list
|
locations = location_list
|
||||||
|
|
||||||
|
if "early_locations" in locations:
|
||||||
|
locations.remove("early_locations")
|
||||||
|
for player in worlds:
|
||||||
|
locations += early_locations[player]
|
||||||
|
if "non_early_locations" in locations:
|
||||||
|
locations.remove("non_early_locations")
|
||||||
|
for player in worlds:
|
||||||
|
locations += non_early_locations[player]
|
||||||
|
|
||||||
|
|
||||||
block['locations'] = locations
|
block['locations'] = locations
|
||||||
|
|
||||||
if not block['count']:
|
if not block['count']:
|
||||||
@@ -613,38 +824,11 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement['player']
|
player = placement['player']
|
||||||
try:
|
try:
|
||||||
target_world = placement['world']
|
worlds = placement['world']
|
||||||
locations = placement['locations']
|
locations = placement['locations']
|
||||||
items = placement['items']
|
items = placement['items']
|
||||||
maxcount = placement['count']['target']
|
maxcount = placement['count']['target']
|
||||||
from_pool = placement['from_pool']
|
from_pool = placement['from_pool']
|
||||||
if target_world is False or world.players == 1: # target own world
|
|
||||||
worlds: typing.Set[int] = {player}
|
|
||||||
elif target_world is True: # target any worlds besides own
|
|
||||||
worlds = set(world.player_ids) - {player}
|
|
||||||
elif target_world is None: # target all worlds
|
|
||||||
worlds = set(world.player_ids)
|
|
||||||
elif type(target_world) == list: # list of target worlds
|
|
||||||
worlds = set()
|
|
||||||
for listed_world in target_world:
|
|
||||||
if listed_world not in world_name_lookup:
|
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
|
||||||
placement['force'])
|
|
||||||
continue
|
|
||||||
worlds.add(world_name_lookup[listed_world])
|
|
||||||
elif type(target_world) == int: # target world by slot number
|
|
||||||
if target_world not in range(1, world.players + 1):
|
|
||||||
failed(
|
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
|
||||||
placement['force'])
|
|
||||||
continue
|
|
||||||
worlds = {target_world}
|
|
||||||
else: # target world by slot name
|
|
||||||
if target_world not in world_name_lookup:
|
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
|
||||||
placement['force'])
|
|
||||||
continue
|
|
||||||
worlds = {world_name_lookup[target_world]}
|
|
||||||
|
|
||||||
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
|
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
|
||||||
worlds))
|
worlds))
|
||||||
|
|||||||
87
Generate.py
87
Generate.py
@@ -23,7 +23,6 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
|
|||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed
|
||||||
import Options
|
import Options
|
||||||
from worlds.alttp import Bosses
|
|
||||||
from worlds.alttp.Text import TextTable
|
from worlds.alttp.Text import TextTable
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
import copy
|
import copy
|
||||||
@@ -155,11 +154,12 @@ def main(args=None, callback=ERmain):
|
|||||||
# sort dict for consistent results across platforms:
|
# sort dict for consistent results across platforms:
|
||||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||||
for filename, yaml_data in weights_cache.items():
|
for filename, yaml_data in weights_cache.items():
|
||||||
for yaml in yaml_data:
|
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||||
print(f"P{player_id} Weights: {filename} >> "
|
for yaml in yaml_data:
|
||||||
f"{get_choice('description', yaml, 'No description specified')}")
|
print(f"P{player_id} Weights: {filename} >> "
|
||||||
player_files[player_id] = filename
|
f"{get_choice('description', yaml, 'No description specified')}")
|
||||||
player_id += 1
|
player_files[player_id] = filename
|
||||||
|
player_id += 1
|
||||||
|
|
||||||
args.multi = max(player_id - 1, args.multi)
|
args.multi = max(player_id - 1, args.multi)
|
||||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||||
@@ -233,8 +233,8 @@ def main(args=None, callback=ERmain):
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
|
|
||||||
if len(set(erargs.name.values())) != len(erargs.name):
|
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||||
|
|
||||||
if args.yaml_output:
|
if args.yaml_output:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -317,11 +317,11 @@ class SafeDict(dict):
|
|||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name] += 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 = "%".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],
|
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
||||||
NUMBER=(name_counter[name] if name_counter[
|
NUMBER=(number if number > 1 else ''),
|
||||||
name] > 1 else ''),
|
|
||||||
player=player,
|
player=player,
|
||||||
PLAYER=(player if player > 1 else '')))
|
PLAYER=(player if player > 1 else '')))
|
||||||
new_name = new_name.strip()[:16]
|
new_name = new_name.strip()[:16]
|
||||||
@@ -337,19 +337,6 @@ def prefer_int(input_data: str) -> Union[str, int]:
|
|||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
|
|
||||||
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
|
||||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
|
||||||
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
|
||||||
Bosses.boss_location_table}
|
|
||||||
|
|
||||||
boss_shuffle_options = {None: 'none',
|
|
||||||
'none': 'none',
|
|
||||||
'basic': 'basic',
|
|
||||||
'full': 'full',
|
|
||||||
'chaos': 'chaos',
|
|
||||||
'singularity': 'singularity'
|
|
||||||
}
|
|
||||||
|
|
||||||
goals = {
|
goals = {
|
||||||
'ganon': 'ganon',
|
'ganon': 'ganon',
|
||||||
'crystals': 'crystals',
|
'crystals': 'crystals',
|
||||||
@@ -391,7 +378,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
|||||||
if option_key in options:
|
if option_key in options:
|
||||||
if options[option_key].supports_weighting:
|
if options[option_key].supports_weighting:
|
||||||
return get_choice(option_key, category_dict)
|
return get_choice(option_key, category_dict)
|
||||||
return options[option_key]
|
return category_dict[option_key]
|
||||||
if game == "A Link to the Past": # TODO wow i hate this
|
if game == "A Link to the Past": # TODO wow i hate this
|
||||||
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||||
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||||
@@ -456,42 +443,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
|
||||||
if boss_shuffle in boss_shuffle_options:
|
|
||||||
return boss_shuffle_options[boss_shuffle]
|
|
||||||
elif PlandoSettings.bosses in plando_options:
|
|
||||||
options = boss_shuffle.lower().split(";")
|
|
||||||
remainder_shuffle = "none" # vanilla
|
|
||||||
bosses = []
|
|
||||||
for boss in options:
|
|
||||||
if boss in boss_shuffle_options:
|
|
||||||
remainder_shuffle = boss_shuffle_options[boss]
|
|
||||||
elif "-" in boss:
|
|
||||||
loc, boss_name = boss.split("-")
|
|
||||||
if boss_name not in available_boss_names:
|
|
||||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
|
||||||
if loc not in available_boss_locations:
|
|
||||||
raise ValueError(f"Unknown Boss Location {loc}")
|
|
||||||
level = ''
|
|
||||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
|
||||||
# split off level
|
|
||||||
loc = loc.split(" ")
|
|
||||||
level = f" {loc[-1]}"
|
|
||||||
loc = " ".join(loc[:-1])
|
|
||||||
loc = loc.title().replace("Of", "of")
|
|
||||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
|
||||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
|
||||||
bosses.append(boss)
|
|
||||||
elif boss not in available_boss_names:
|
|
||||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
|
||||||
else:
|
|
||||||
bosses.append(boss)
|
|
||||||
return ";".join(bosses + [remainder_shuffle])
|
|
||||||
else:
|
|
||||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
|
||||||
|
|
||||||
|
|
||||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
|
||||||
if option_key in game_weights:
|
if option_key in game_weights:
|
||||||
try:
|
try:
|
||||||
if not option.supports_weighting:
|
if not option.supports_weighting:
|
||||||
@@ -502,10 +454,9 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
if hasattr(player_option, "verify"):
|
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||||
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
|
||||||
else:
|
else:
|
||||||
setattr(ret, option_key, option(option.default))
|
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
||||||
@@ -549,11 +500,11 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
|||||||
|
|
||||||
if ret.game in AutoWorldRegister.world_types:
|
if ret.game in AutoWorldRegister.world_types:
|
||||||
for option_key, option in world_type.option_definitions.items():
|
for option_key, option in world_type.option_definitions.items():
|
||||||
handle_option(ret, game_weights, option_key, option)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
for option_key, option in Options.per_game_common_options.items():
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
# skip setting this option if already set from common_options, defaulting to root option
|
# skip setting this option if already set from common_options, defaulting to root option
|
||||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||||
handle_option(ret, game_weights, option_key, option)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
if PlandoSettings.items in plando_options:
|
if PlandoSettings.items in plando_options:
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||||
@@ -636,8 +587,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
|
|
||||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||||
|
|
||||||
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
|
||||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
|
||||||
|
|
||||||
ret.enemy_damage = {None: 'default',
|
ret.enemy_damage = {None: 'default',
|
||||||
'default': 'default',
|
'default': 'default',
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ components: Iterable[Component] = (
|
|||||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||||
# SNI
|
# SNI
|
||||||
Component('SNI Client', 'SNIClient',
|
Component('SNI Client', 'SNIClient',
|
||||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
|
||||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||||
# Factorio
|
# Factorio
|
||||||
Component('Factorio Client', 'FactorioClient'),
|
Component('Factorio Client', 'FactorioClient'),
|
||||||
@@ -145,10 +145,15 @@ components: Iterable[Component] = (
|
|||||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||||
# FF1
|
# FF1
|
||||||
Component('FF1 Client', 'FF1Client'),
|
Component('FF1 Client', 'FF1Client'),
|
||||||
|
# Pokémon
|
||||||
|
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
|
||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||||
# Starcraft 2
|
# Starcraft 2
|
||||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||||
|
# Zillion
|
||||||
|
Component('Zillion Client', 'ZillionClient',
|
||||||
|
file_identifier=SuffixIdentifier('.apzl')),
|
||||||
# Functions
|
# Functions
|
||||||
Component('Open host.yaml', func=open_host_yaml),
|
Component('Open host.yaml', func=open_host_yaml),
|
||||||
Component('Open Patch', func=open_patch),
|
Component('Open Patch', func=open_patch),
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ ModuleUpdate.update()
|
|||||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||||
get_adjuster_settings, tkinter_center_window, init_logging
|
get_adjuster_settings, tkinter_center_window, init_logging
|
||||||
from Patch import GAME_ALTTP
|
|
||||||
|
|
||||||
|
GAME_ALTTP = "A Link to the Past"
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
@@ -139,7 +141,7 @@ def adjust(args):
|
|||||||
vanillaRom = args.baserom
|
vanillaRom = args.baserom
|
||||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||||
vanillaRom = local_path(vanillaRom)
|
vanillaRom = local_path(vanillaRom)
|
||||||
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
|
||||||
import Patch
|
import Patch
|
||||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||||
|
|
||||||
@@ -195,7 +197,7 @@ def adjustGUI():
|
|||||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||||
|
|
||||||
def RomSelect2():
|
def RomSelect2():
|
||||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
|
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")])
|
||||||
romVar2.set(rom)
|
romVar2.set(rom)
|
||||||
|
|
||||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
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)
|
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||||
autoApplyFrame = Frame(romOptionsFrame)
|
autoApplyFrame = Frame(romOptionsFrame)
|
||||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
|
||||||
filler.pack(side=TOP, expand=True, fill=X)
|
filler.pack(side=TOP, expand=True, fill=X)
|
||||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||||
|
|||||||
108
Main.py
108
Main.py
@@ -8,15 +8,15 @@ import concurrent.futures
|
|||||||
import pickle
|
import pickle
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from typing import Dict, 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.Items import item_name_groups
|
||||||
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
from worlds.alttp.Regions import is_main_entrance
|
||||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||||
from Utils import output_path, get_options, __version__, version_tuple
|
from Utils import output_path, get_options, __version__, version_tuple
|
||||||
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
ordered_areas = (
|
ordered_areas = (
|
||||||
@@ -80,15 +80,30 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
logger.info("Found World Types:")
|
logger.info("Found World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
numlength = 8
|
|
||||||
|
max_item = 0
|
||||||
|
max_location = 0
|
||||||
|
for cls in AutoWorld.AutoWorldRegister.world_types.values():
|
||||||
|
if cls.item_id_to_name:
|
||||||
|
max_item = max(max_item, max(cls.item_id_to_name))
|
||||||
|
max_location = max(max_location, max(cls.location_id_to_name))
|
||||||
|
|
||||||
|
item_digits = len(str(max_item))
|
||||||
|
location_digits = len(str(max_location))
|
||||||
|
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
|
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
|
del max_item, max_location
|
||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden:
|
if not cls.hidden and len(cls.item_names) > 0:
|
||||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
||||||
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
||||||
f"{max(cls.item_id_to_name):{numlength}}) | "
|
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
||||||
f"{len(cls.location_names):3} "
|
f"{len(cls.location_names):{location_count}} "
|
||||||
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
|
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
||||||
f"{max(cls.location_id_to_name):{numlength}})")
|
f"{max(cls.location_id_to_name):{location_digits}})")
|
||||||
|
|
||||||
|
del item_digits, location_digits, item_count, location_count
|
||||||
|
|
||||||
AutoWorld.call_stage(world, "assert_generate")
|
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"]:
|
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||||
world.local_items[player].value.add('Triforce Piece')
|
world.local_items[player].value.add('Triforce Piece')
|
||||||
|
|
||||||
# Not possible to place pendants/crystals 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['Pendants']
|
||||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
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.')
|
logger.info('Calculating Access Rules.')
|
||||||
if world.players > 1:
|
if world.players > 1:
|
||||||
for player in world.player_ids:
|
locality_rules(world)
|
||||||
locality_rules(world, player)
|
|
||||||
group_locality_rules(world)
|
|
||||||
else:
|
else:
|
||||||
world.non_local_items[1].value = set()
|
world.non_local_items[1].value = set()
|
||||||
world.local_items[1].value = set()
|
world.local_items[1].value = set()
|
||||||
@@ -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
|
# temporary home for item links, should be moved out of Main
|
||||||
for group_id, group in world.groups.items():
|
for group_id, group in world.groups.items():
|
||||||
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||||
classifications = collections.defaultdict(int)
|
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}
|
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||||
for item in world.itempool:
|
for item in world.itempool:
|
||||||
if item.player in counters and item.name in shared_pool:
|
if item.player in counters and item.name in shared_pool:
|
||||||
@@ -152,7 +167,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in players.copy():
|
for player in players.copy():
|
||||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||||
players.remove(player)
|
players.remove(player)
|
||||||
del(counters[player])
|
del (counters[player])
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -164,14 +179,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
counters[player][item] = count
|
counters[player][item] = count
|
||||||
else:
|
else:
|
||||||
for player in players:
|
for player in players:
|
||||||
del(counters[player][item])
|
del (counters[player][item])
|
||||||
return counters, classifications
|
return counters, classifications
|
||||||
|
|
||||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||||
if not common_item_count:
|
if not common_item_count:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
new_itempool = []
|
new_itempool: List[Item] = []
|
||||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||||
for _ in range(item_count):
|
for _ in range(item_count):
|
||||||
new_item = group["world"].create_item(item_name)
|
new_item = group["world"].create_item(item_name)
|
||||||
@@ -249,24 +264,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
output_file_futures.append(
|
output_file_futures.append(
|
||||||
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
def get_entrance_to_region(region: Region):
|
|
||||||
for entrance in region.entrances:
|
|
||||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
|
||||||
return entrance
|
|
||||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
|
||||||
return get_entrance_to_region(entrance.parent_region)
|
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||||
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
|
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
for region in world.regions:
|
|
||||||
if region.player in er_hint_data and region.locations:
|
|
||||||
main_entrance = get_entrance_to_region(region)
|
|
||||||
for location in region.locations:
|
|
||||||
if type(location.address) == int: # skips events and crystals
|
|
||||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
|
||||||
er_hint_data[region.player][location.address] = main_entrance.name
|
|
||||||
|
|
||||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||||
for player in range(1, world.players + 1)}
|
for player in range(1, world.players + 1)}
|
||||||
@@ -276,22 +276,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
for location in world.get_filled_locations():
|
for location in world.get_filled_locations():
|
||||||
if type(location.address) is int:
|
if type(location.address) is int:
|
||||||
main_entrance = get_entrance_to_region(location.parent_region)
|
|
||||||
if location.game != "A Link to the Past":
|
if location.game != "A Link to the Past":
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
elif location.parent_region.dungeon:
|
else:
|
||||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
if location.parent_region.dungeon:
|
||||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||||
checks_in_area[location.player][dungeonname].append(location.address)
|
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||||
elif location.parent_region.type == RegionType.LightWorld:
|
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player][dungeonname].append(location.address)
|
||||||
elif location.parent_region.type == RegionType.DarkWorld:
|
elif location.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
elif location.parent_region.type == RegionType.DarkWorld:
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||||
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
checks_in_area[location.player]["Total"] += 1
|
checks_in_area[location.player]["Total"] += 1
|
||||||
|
|
||||||
oldmancaves = []
|
oldmancaves = []
|
||||||
@@ -305,7 +306,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
player = region.player
|
player = region.player
|
||||||
location_id = SHOP_ID_START + total_shop_slots + index
|
location_id = SHOP_ID_START + total_shop_slots + index
|
||||||
|
|
||||||
main_entrance = get_entrance_to_region(region)
|
main_entrance = region.get_connecting_entrance(is_main_entrance)
|
||||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[player]["Light World"].append(location_id)
|
checks_in_area[player]["Light World"].append(location_id)
|
||||||
else:
|
else:
|
||||||
@@ -340,7 +341,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player, world_precollected in world.precollected_items.items()}
|
for player, world_precollected in world.precollected_items.items()}
|
||||||
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||||
|
|
||||||
|
|
||||||
for slot in world.player_ids:
|
for slot in world.player_ids:
|
||||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
|
|||||||
|
|
||||||
if not update_ran:
|
if not update_ran:
|
||||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||||
if entry.is_dir():
|
# skip .* (hidden / disabled) folders
|
||||||
req_file = os.path.join(entry.path, "requirements.txt")
|
if not entry.name.startswith("."):
|
||||||
if os.path.exists(req_file):
|
if entry.is_dir():
|
||||||
requirements_files.add(req_file)
|
req_file = os.path.join(entry.path, "requirements.txt")
|
||||||
|
if os.path.exists(req_file):
|
||||||
|
requirements_files.add(req_file)
|
||||||
|
|
||||||
|
|
||||||
def update_command():
|
def update_command():
|
||||||
@@ -37,11 +39,25 @@ def update(yes=False, force=False):
|
|||||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||||
with open(path) as requirementsfile:
|
with open(path) as requirementsfile:
|
||||||
for line in requirementsfile:
|
for line in requirementsfile:
|
||||||
if line.startswith('https://'):
|
if line.startswith(("https://", "git+https://")):
|
||||||
# extract name and version from url
|
# extract name and version for url
|
||||||
wheel = line.split('/')[-1]
|
rest = line.split('/')[-1]
|
||||||
name, version, _ = wheel.split('-', 2)
|
line = ""
|
||||||
line = f'{name}=={version}'
|
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)
|
requirements = pkg_resources.parse_requirements(line)
|
||||||
for requirement in requirements:
|
for requirement in requirements:
|
||||||
requirement = str(requirement)
|
requirement = str(requirement)
|
||||||
|
|||||||
309
MultiServer.py
309
MultiServer.py
@@ -31,7 +31,7 @@ except ImportError:
|
|||||||
|
|
||||||
import NetUtils
|
import NetUtils
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import version_tuple, restricted_loads, Version
|
from Utils import version_tuple, restricted_loads, Version, async_start
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType
|
SlotType
|
||||||
|
|
||||||
@@ -126,6 +126,7 @@ class Context:
|
|||||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
forced_auto_forfeits: typing.Dict[str, bool]
|
forced_auto_forfeits: typing.Dict[str, bool]
|
||||||
|
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
|
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
|
||||||
@@ -196,7 +197,7 @@ class Context:
|
|||||||
self.item_name_groups = {}
|
self.item_name_groups = {}
|
||||||
self.all_item_and_group_names = {}
|
self.all_item_and_group_names = {}
|
||||||
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
|
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
|
||||||
self.non_hintable_names = {}
|
self.non_hintable_names = collections.defaultdict(frozenset)
|
||||||
|
|
||||||
self._load_game_data()
|
self._load_game_data()
|
||||||
self._init_game_data()
|
self._init_game_data()
|
||||||
@@ -221,11 +222,11 @@ class Context:
|
|||||||
self.all_item_and_group_names[game_name] = \
|
self.all_item_and_group_names[game_name] = \
|
||||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||||
|
|
||||||
def item_names_for_game(self, game: str) -> typing.Dict[str, int]:
|
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||||
return self.gamespackage[game]["item_name_to_id"]
|
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
||||||
|
|
||||||
def location_names_for_game(self, game: str) -> typing.Dict[str, int]:
|
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||||
return self.gamespackage[game]["location_name_to_id"]
|
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
|
||||||
|
|
||||||
# General networking
|
# General networking
|
||||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
||||||
@@ -272,16 +273,16 @@ class Context:
|
|||||||
def broadcast_all(self, msgs: typing.List[dict]):
|
def broadcast_all(self, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
||||||
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]):
|
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
||||||
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]):
|
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
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):
|
async def disconnect(self, endpoint: Client):
|
||||||
if endpoint in self.endpoints:
|
if endpoint in self.endpoints:
|
||||||
@@ -301,18 +302,18 @@ class Context:
|
|||||||
return
|
return
|
||||||
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
if client.version >= print_command_compatability_threshold:
|
if client.version >= print_command_compatability_threshold:
|
||||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
|
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
|
||||||
else:
|
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]):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
||||||
if not client.auth:
|
if not client.auth:
|
||||||
return
|
return
|
||||||
if client.version >= print_command_compatability_threshold:
|
if client.version >= print_command_compatability_threshold:
|
||||||
asyncio.create_task(self.send_msgs(client,
|
async_start(self.send_msgs(client,
|
||||||
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
|
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
|
||||||
else:
|
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
|
# loading
|
||||||
|
|
||||||
@@ -626,7 +627,7 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
|
|||||||
continue
|
continue
|
||||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
||||||
for client in clients:
|
for client in clients:
|
||||||
asyncio.create_task(ctx.send_msgs(client, client_hints))
|
async_start(ctx.send_msgs(client, client_hints))
|
||||||
|
|
||||||
|
|
||||||
def update_aliases(ctx: Context, team: int):
|
def update_aliases(ctx: Context, team: int):
|
||||||
@@ -635,7 +636,7 @@ def update_aliases(ctx: Context, team: int):
|
|||||||
|
|
||||||
for clients in ctx.clients[team].values():
|
for clients in ctx.clients[team].values():
|
||||||
for client in clients:
|
for client in clients:
|
||||||
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):
|
async def server(websocket, path: str = "/", ctx: Context = None):
|
||||||
@@ -813,7 +814,7 @@ def send_new_items(ctx: Context):
|
|||||||
items = get_received_items(ctx, team, slot, client.remote_items)
|
items = get_received_items(ctx, team, slot, client.remote_items)
|
||||||
if len(start_inventory) + len(items) > client.send_index:
|
if len(start_inventory) + len(items) > client.send_index:
|
||||||
first_new_item = max(0, client.send_index - len(start_inventory))
|
first_new_item = max(0, client.send_index - len(start_inventory))
|
||||||
asyncio.create_task(ctx.send_msgs(client, [{
|
async_start(ctx.send_msgs(client, [{
|
||||||
"cmd": "ReceivedItems",
|
"cmd": "ReceivedItems",
|
||||||
"index": client.send_index,
|
"index": client.send_index,
|
||||||
"items": start_inventory[client.send_index:] + items[first_new_item:]}]))
|
"items": start_inventory[client.send_index:] + items[first_new_item:]}]))
|
||||||
@@ -900,14 +901,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item_name: str) -> typing.List[NetUtils.Hint]:
|
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
|
||||||
hints = []
|
hints = []
|
||||||
slots: typing.Set[int] = {slot}
|
slots: typing.Set[int] = {slot}
|
||||||
for group_id, group in ctx.groups.items():
|
for group_id, group in ctx.groups.items():
|
||||||
if slot in group:
|
if slot in group:
|
||||||
slots.add(group_id)
|
slots.add(group_id)
|
||||||
|
|
||||||
seeked_item_id = ctx.item_names_for_game(ctx.games[slot])[item_name]
|
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||||
for finding_player, check_data in ctx.locations.items():
|
for finding_player, check_data in ctx.locations.items():
|
||||||
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
||||||
if receiving_player in slots and item_id == seeked_item_id:
|
if receiving_player in slots and item_id == seeked_item_id:
|
||||||
@@ -997,7 +998,11 @@ class CommandMeta(type):
|
|||||||
return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
|
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
|
function.raw_text = True
|
||||||
return function
|
return function
|
||||||
|
|
||||||
@@ -1085,7 +1090,7 @@ class CommonCommandProcessor(CommandProcessor):
|
|||||||
timer = int(seconds, 10)
|
timer = int(seconds, 10)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
timer = 10
|
timer = 10
|
||||||
asyncio.create_task(countdown(self.ctx, timer))
|
async_start(countdown(self.ctx, timer))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_options(self):
|
def _cmd_options(self):
|
||||||
@@ -1327,6 +1332,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
|
|
||||||
if not input_text:
|
if not input_text:
|
||||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||||
self.ctx.hints[self.client.team, self.client.slot]}
|
self.ctx.hints[self.client.team, self.client.slot]}
|
||||||
@@ -1335,13 +1342,33 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
||||||
f"You have {points_available} points.")
|
f"You have {points_available} points.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
elif input_text.isnumeric():
|
||||||
|
game = self.ctx.games[self.client.slot]
|
||||||
|
hint_id = int(input_text)
|
||||||
|
hint_name = self.ctx.item_names[hint_id] \
|
||||||
|
if not for_location and hint_id in self.ctx.item_names \
|
||||||
|
else self.ctx.location_names[hint_id] \
|
||||||
|
if for_location and hint_id in self.ctx.location_names \
|
||||||
|
else None
|
||||||
|
if hint_name in self.ctx.non_hintable_names[game]:
|
||||||
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
|
hints = []
|
||||||
|
elif not for_location:
|
||||||
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||||
|
else:
|
||||||
|
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
|
if game not in self.ctx.all_item_and_group_names:
|
||||||
|
self.output("Can't look up item/location for unknown game. Hint for ID instead.")
|
||||||
|
return False
|
||||||
names = self.ctx.location_names_for_game(game) \
|
names = self.ctx.location_names_for_game(game) \
|
||||||
if for_location else \
|
if for_location else \
|
||||||
self.ctx.all_item_and_group_names[game]
|
self.ctx.all_item_and_group_names[game]
|
||||||
hint_name, usable, response = get_intended_text(input_text,
|
hint_name, usable, response = get_intended_text(input_text, names)
|
||||||
names)
|
|
||||||
if usable:
|
if usable:
|
||||||
if hint_name in self.ctx.non_hintable_names[game]:
|
if hint_name in self.ctx.non_hintable_names[game]:
|
||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
@@ -1355,63 +1382,69 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
|
||||||
if hints:
|
|
||||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
|
||||||
old_hints = set(hints) - new_hints
|
|
||||||
if old_hints:
|
|
||||||
notify_hints(self.ctx, self.client.team, list(old_hints))
|
|
||||||
if not new_hints:
|
|
||||||
self.output("Hint was previously used, no points deducted.")
|
|
||||||
if new_hints:
|
|
||||||
found_hints = [hint for hint in new_hints if hint.found]
|
|
||||||
not_found_hints = [hint for hint in new_hints if not hint.found]
|
|
||||||
|
|
||||||
if not not_found_hints: # everything's been found, no need to pay
|
|
||||||
can_pay = 1000
|
|
||||||
elif cost:
|
|
||||||
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
|
|
||||||
else:
|
|
||||||
can_pay = 1000
|
|
||||||
|
|
||||||
self.ctx.random.shuffle(not_found_hints)
|
|
||||||
# By popular vote, make hints prefer non-local placements
|
|
||||||
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
|
||||||
|
|
||||||
hints = found_hints
|
|
||||||
while can_pay > 0:
|
|
||||||
if not not_found_hints:
|
|
||||||
break
|
|
||||||
hint = not_found_hints.pop()
|
|
||||||
hints.append(hint)
|
|
||||||
can_pay -= 1
|
|
||||||
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
|
||||||
|
|
||||||
if not_found_hints:
|
|
||||||
if hints and cost and int((points_available // cost) == 0):
|
|
||||||
self.output(
|
|
||||||
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
|
||||||
f" You have {points_available} and need at least "
|
|
||||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
|
||||||
elif hints:
|
|
||||||
self.output(
|
|
||||||
"There may be more hintables, you can rerun the command to find more.")
|
|
||||||
else:
|
|
||||||
self.output(f"You can't afford the hint. "
|
|
||||||
f"You have {points_available} points and need at least "
|
|
||||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
|
||||||
notify_hints(self.ctx, self.client.team, hints)
|
|
||||||
self.ctx.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
else:
|
|
||||||
self.output("Nothing found. Item/Location may not exist.")
|
|
||||||
return False
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if hints:
|
||||||
|
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||||
|
old_hints = set(hints) - new_hints
|
||||||
|
if old_hints:
|
||||||
|
notify_hints(self.ctx, self.client.team, list(old_hints))
|
||||||
|
if not new_hints:
|
||||||
|
self.output("Hint was previously used, no points deducted.")
|
||||||
|
if new_hints:
|
||||||
|
found_hints = [hint for hint in new_hints if hint.found]
|
||||||
|
not_found_hints = [hint for hint in new_hints if not hint.found]
|
||||||
|
|
||||||
|
if not not_found_hints: # everything's been found, no need to pay
|
||||||
|
can_pay = 1000
|
||||||
|
elif cost:
|
||||||
|
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
|
||||||
|
else:
|
||||||
|
can_pay = 1000
|
||||||
|
|
||||||
|
self.ctx.random.shuffle(not_found_hints)
|
||||||
|
# By popular vote, make hints prefer non-local placements
|
||||||
|
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
||||||
|
|
||||||
|
hints = found_hints
|
||||||
|
while can_pay > 0:
|
||||||
|
if not not_found_hints:
|
||||||
|
break
|
||||||
|
hint = not_found_hints.pop()
|
||||||
|
hints.append(hint)
|
||||||
|
can_pay -= 1
|
||||||
|
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
||||||
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
|
|
||||||
|
if not_found_hints:
|
||||||
|
if hints and cost and int((points_available // cost) == 0):
|
||||||
|
self.output(
|
||||||
|
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
||||||
|
f" You have {points_available} and need at least "
|
||||||
|
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
||||||
|
elif hints:
|
||||||
|
self.output(
|
||||||
|
"There may be more hintables, you can rerun the command to find more.")
|
||||||
|
else:
|
||||||
|
self.output(f"You can't afford the hint. "
|
||||||
|
f"You have {points_available} points and need at least "
|
||||||
|
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
||||||
|
notify_hints(self.ctx, self.client.team, hints)
|
||||||
|
self.ctx.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
else:
|
||||||
|
if points_available >= cost:
|
||||||
|
self.output("Nothing found. Item/Location may not exist.")
|
||||||
|
else:
|
||||||
|
self.output(f"You can't afford the hint. "
|
||||||
|
f"You have {points_available} points and need at least "
|
||||||
|
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
||||||
|
return False
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_hint(self, item_name: str = "") -> bool:
|
def _cmd_hint(self, item_name: str = "") -> bool:
|
||||||
"""Use !hint {item_name},
|
"""Use !hint {item_name},
|
||||||
@@ -1738,7 +1771,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
def _cmd_exit(self) -> bool:
|
def _cmd_exit(self) -> bool:
|
||||||
"""Shutdown the server"""
|
"""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:
|
if self.ctx.shutdown_task:
|
||||||
self.ctx.shutdown_task.cancel()
|
self.ctx.shutdown_task.cancel()
|
||||||
self.ctx.exit_event.set()
|
self.ctx.exit_event.set()
|
||||||
@@ -1769,14 +1802,33 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def resolve_player(self, input_name: str) -> typing.Optional[typing.Tuple[int, int, str]]:
|
||||||
|
""" returns (team, slot, player name) """
|
||||||
|
# TODO: clean up once we disallow multidata < 0.3.6, which has CI unique names
|
||||||
|
# first match case
|
||||||
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
|
if name == input_name:
|
||||||
|
return team, slot, name
|
||||||
|
|
||||||
|
# if no case-sensitive match, then match without case only if there's only 1 match
|
||||||
|
input_lower = input_name.lower()
|
||||||
|
match: typing.Optional[typing.Tuple[int, int, str]] = None
|
||||||
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
|
lowered = name.lower()
|
||||||
|
if lowered == input_lower:
|
||||||
|
if match:
|
||||||
|
return None # ambiguous input_name
|
||||||
|
match = (team, slot, name)
|
||||||
|
return match
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_collect(self, player_name: str) -> bool:
|
def _cmd_collect(self, player_name: str) -> bool:
|
||||||
"""Send out the remaining items to player."""
|
"""Send out the remaining items to player."""
|
||||||
seeked_player = player_name.lower()
|
player = self.resolve_player(player_name)
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
if player:
|
||||||
if name.lower() == seeked_player:
|
team, slot, _ = player
|
||||||
collect_player(self.ctx, team, slot)
|
collect_player(self.ctx, team, slot)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to collect")
|
self.output(f"Could not find player {player_name} to collect")
|
||||||
return False
|
return False
|
||||||
@@ -1789,11 +1841,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_forfeit(self, player_name: str) -> bool:
|
def _cmd_forfeit(self, player_name: str) -> bool:
|
||||||
"""Send out the remaining items from a player to their intended recipients."""
|
"""Send out the remaining items from a player to their intended recipients."""
|
||||||
seeked_player = player_name.lower()
|
player = self.resolve_player(player_name)
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
if player:
|
||||||
if name.lower() == seeked_player:
|
team, slot, _ = player
|
||||||
forfeit_player(self.ctx, team, slot)
|
forfeit_player(self.ctx, team, slot)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to release")
|
self.output(f"Could not find player {player_name} to release")
|
||||||
return False
|
return False
|
||||||
@@ -1801,12 +1853,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
||||||
"""Allow the specified player to use the !release command."""
|
"""Allow the specified player to use the !release command."""
|
||||||
seeked_player = player_name.lower()
|
player = self.resolve_player(player_name)
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
if player:
|
||||||
if name.lower() == seeked_player:
|
team, slot, name = player
|
||||||
self.ctx.allow_forfeits[(team, slot)] = True
|
self.ctx.allow_forfeits[(team, slot)] = True
|
||||||
self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
|
self.output(f"Player {name} is now allowed to use the !release command at any time.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to allow the !release command for.")
|
self.output(f"Could not find player {player_name} to allow the !release command for.")
|
||||||
return False
|
return False
|
||||||
@@ -1814,13 +1866,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
||||||
""""Disallow the specified player from using the !release command."""
|
""""Disallow the specified player from using the !release command."""
|
||||||
seeked_player = player_name.lower()
|
player = self.resolve_player(player_name)
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
if player:
|
||||||
if name.lower() == seeked_player:
|
team, slot, name = player
|
||||||
self.ctx.allow_forfeits[(team, slot)] = False
|
self.ctx.allow_forfeits[(team, slot)] = False
|
||||||
self.output(
|
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.")
|
||||||
f"Player {player_name} has to follow the server restrictions on use of the !release command.")
|
return True
|
||||||
return True
|
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to forbid the !release command for.")
|
self.output(f"Could not find player {player_name} to forbid the !release command for.")
|
||||||
return False
|
return False
|
||||||
@@ -1859,17 +1910,25 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
item_name = " ".join(item_name)
|
|
||||||
game = self.ctx.games[slot]
|
game = self.ctx.games[slot]
|
||||||
item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game])
|
full_name = " ".join(item_name)
|
||||||
|
|
||||||
|
if full_name.isnumeric():
|
||||||
|
item, usable, response = int(full_name), True, None
|
||||||
|
elif game in self.ctx.all_item_and_group_names:
|
||||||
|
item, usable, response = get_intended_text(full_name, self.ctx.all_item_and_group_names[game])
|
||||||
|
else:
|
||||||
|
self.output("Can't look up item for unknown game. Hint for ID instead.")
|
||||||
|
return False
|
||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if item_name in self.ctx.item_name_groups[game]:
|
if game in self.ctx.item_name_groups and item in self.ctx.item_name_groups[game]:
|
||||||
hints = []
|
hints = []
|
||||||
for item_name_from_group in self.ctx.item_name_groups[game][item_name]:
|
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||||
else: # item name
|
else: # item name or id
|
||||||
hints = collect_hints(self.ctx, team, slot, item_name)
|
hints = collect_hints(self.ctx, team, slot, item)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
notify_hints(self.ctx, team, hints)
|
notify_hints(self.ctx, team, hints)
|
||||||
@@ -1890,11 +1949,22 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
location_name = " ".join(location_name)
|
game = self.ctx.games[slot]
|
||||||
location_name, usable, response = get_intended_text(location_name,
|
full_name = " ".join(location_name)
|
||||||
self.ctx.location_names_for_game(self.ctx.games[slot]))
|
|
||||||
|
if full_name.isnumeric():
|
||||||
|
location, usable, response = int(full_name), True, None
|
||||||
|
elif self.ctx.location_names_for_game(game) is not None:
|
||||||
|
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
|
||||||
|
else:
|
||||||
|
self.output("Can't look up location for unknown game. Hint for ID instead.")
|
||||||
|
return False
|
||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, location_name)
|
if isinstance(location, int):
|
||||||
|
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
||||||
|
else:
|
||||||
|
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||||
if hints:
|
if hints:
|
||||||
notify_hints(self.ctx, team, hints)
|
notify_hints(self.ctx, team, hints)
|
||||||
else:
|
else:
|
||||||
@@ -2014,7 +2084,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
|||||||
await asyncio.sleep(ctx.auto_shutdown)
|
await asyncio.sleep(ctx.auto_shutdown)
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
if not ctx.client_activity_timers.values():
|
if not ctx.client_activity_timers.values():
|
||||||
asyncio.create_task(ctx.server.ws_server._close())
|
async_start(ctx.server.ws_server._close())
|
||||||
ctx.exit_event.set()
|
ctx.exit_event.set()
|
||||||
if to_cancel:
|
if to_cancel:
|
||||||
for task in to_cancel:
|
for task in to_cancel:
|
||||||
@@ -2025,7 +2095,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
|||||||
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
|
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
|
||||||
seconds = ctx.auto_shutdown - delta.total_seconds()
|
seconds = ctx.auto_shutdown - delta.total_seconds()
|
||||||
if seconds < 0:
|
if seconds < 0:
|
||||||
asyncio.create_task(ctx.server.ws_server._close())
|
async_start(ctx.server.ws_server._close())
|
||||||
ctx.exit_event.set()
|
ctx.exit_event.set()
|
||||||
if to_cancel:
|
if to_cancel:
|
||||||
for task in to_cancel:
|
for task in to_cancel:
|
||||||
@@ -2044,15 +2114,28 @@ async def main(args: argparse.Namespace):
|
|||||||
args.auto_shutdown, args.compatibility, args.log_network)
|
args.auto_shutdown, args.compatibility, args.log_network)
|
||||||
data_filename = args.multidata
|
data_filename = args.multidata
|
||||||
|
|
||||||
try:
|
if not data_filename:
|
||||||
if not data_filename:
|
try:
|
||||||
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
|
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
|
||||||
data_filename = Utils.open_filename("Select multiworld data", filetypes)
|
data_filename = Utils.open_filename("Select multiworld data", filetypes)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, ImportError) or (e.__class__.__name__ == "TclError" and "no display" in str(e)):
|
||||||
|
if not isinstance(e, ImportError):
|
||||||
|
logging.error(f"Failed to load tkinter ({e})")
|
||||||
|
logging.info("Pass a multidata filename on command line to run headless.")
|
||||||
|
exit(1)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not data_filename:
|
||||||
|
logging.info("No file selected. Exiting.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
ctx.load(data_filename, args.use_embedded_options)
|
ctx.load(data_filename, args.use_embedded_options)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception('Failed to read multiworld data (%s)' % e)
|
logging.exception(f"Failed to read multiworld data ({e})")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
ctx.init_save(not args.disable_save)
|
ctx.init_save(not args.disable_save)
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ _encode = JSONEncoder(
|
|||||||
).encode
|
).encode
|
||||||
|
|
||||||
|
|
||||||
def encode(obj):
|
def encode(obj: typing.Any) -> str:
|
||||||
return _encode(_scan_for_TypedTuples(obj))
|
return _encode(_scan_for_TypedTuples(obj))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
25
OoTClient.py
25
OoTClient.py
@@ -5,9 +5,11 @@ import multiprocessing
|
|||||||
import subprocess
|
import subprocess
|
||||||
from asyncio import StreamReader, StreamWriter
|
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
|
ClientCommandProcessor, logger, get_base_parser
|
||||||
import Utils
|
import Utils
|
||||||
|
from Utils import async_start
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
@@ -68,7 +70,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
|
|||||||
if isinstance(self.ctx, OoTContext):
|
if isinstance(self.ctx, OoTContext):
|
||||||
self.ctx.deathlink_client_override = True
|
self.ctx.deathlink_client_override = True
|
||||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||||
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):
|
class OoTContext(CommonContext):
|
||||||
@@ -132,6 +134,19 @@ def get_payload(ctx: OoTContext):
|
|||||||
|
|
||||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||||
|
|
||||||
|
# Refuse to do anything if ROM is detected as changed
|
||||||
|
if ctx.auth and payload['playerName'] != ctx.auth:
|
||||||
|
logger.warning("ROM change detected. Disconnecting and reconnecting...")
|
||||||
|
ctx.deathlink_enabled = False
|
||||||
|
ctx.deathlink_client_override = False
|
||||||
|
ctx.finished_game = False
|
||||||
|
ctx.location_table = {}
|
||||||
|
ctx.deathlink_pending = False
|
||||||
|
ctx.deathlink_sent_this_death = False
|
||||||
|
ctx.auth = payload['playerName']
|
||||||
|
await ctx.send_connect()
|
||||||
|
return
|
||||||
|
|
||||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||||
await ctx.update_death_link(True)
|
await ctx.update_death_link(True)
|
||||||
@@ -189,7 +204,7 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
if reported_version >= script_version:
|
if reported_version >= script_version:
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
async_start(parse_payload(data_decoded, ctx, False))
|
||||||
if not ctx.auth:
|
if not ctx.auth:
|
||||||
ctx.auth = data_decoded['playerName']
|
ctx.auth = data_decoded['playerName']
|
||||||
if ctx.awaiting_rom:
|
if ctx.awaiting_rom:
|
||||||
@@ -265,7 +280,7 @@ async def patch_and_run_game(apz5_file):
|
|||||||
os.chdir(data_path("Compress"))
|
os.chdir(data_path("Compress"))
|
||||||
compress_rom_file(decomp_path, comp_path)
|
compress_rom_file(decomp_path, comp_path)
|
||||||
os.remove(decomp_path)
|
os.remove(decomp_path)
|
||||||
asyncio.create_task(run_game(comp_path))
|
async_start(run_game(comp_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -281,7 +296,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
if args.apz5_file:
|
if args.apz5_file:
|
||||||
logger.info("APZ5 file supplied, beginning patching process...")
|
logger.info("APZ5 file supplied, beginning patching process...")
|
||||||
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 = OoTContext(args.connect, args.password)
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||||
|
|||||||
253
Options.py
253
Options.py
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import abc
|
import abc
|
||||||
|
from copy import deepcopy
|
||||||
import math
|
import math
|
||||||
import numbers
|
import numbers
|
||||||
import typing
|
import typing
|
||||||
@@ -26,15 +27,31 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
|
|
||||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||||
options.update(new_options)
|
options.update(new_options)
|
||||||
|
|
||||||
# apply aliases, without name_lookup
|
# apply aliases, without name_lookup
|
||||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||||
name.startswith("alias_")}
|
name.startswith("alias_")}
|
||||||
|
|
||||||
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
||||||
|
|
||||||
|
# auto-alias Off and On being parsed as True and False
|
||||||
|
if "off" in options:
|
||||||
|
options["false"] = options["off"]
|
||||||
|
if "on" in options:
|
||||||
|
options["true"] = options["on"]
|
||||||
|
|
||||||
options.update(aliases)
|
options.update(aliases)
|
||||||
|
|
||||||
|
if "verify" not in attrs:
|
||||||
|
# not overridden by class -> look up bases
|
||||||
|
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
|
||||||
|
if len(verifiers) > 1: # verify multiple bases/mixins
|
||||||
|
def verify(self, *args, **kwargs) -> None:
|
||||||
|
for f in verifiers:
|
||||||
|
f(self, *args, **kwargs)
|
||||||
|
attrs["verify"] = verify
|
||||||
|
else:
|
||||||
|
assert verifiers, "class Option is supposed to implement def verify"
|
||||||
|
|
||||||
# auto-validate schema on __init__
|
# auto-validate schema on __init__
|
||||||
if "schema" in attrs.keys():
|
if "schema" in attrs.keys():
|
||||||
|
|
||||||
@@ -62,6 +79,9 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
|
|
||||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
|
@abc.abstractclassmethod
|
||||||
|
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
|
||||||
|
|
||||||
|
|
||||||
T = typing.TypeVar('T')
|
T = typing.TypeVar('T')
|
||||||
|
|
||||||
@@ -112,8 +132,44 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from Generate import PlandoSettings
|
||||||
|
from worlds.AutoWorld import World
|
||||||
|
|
||||||
|
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
def verify(self, *args, **kwargs) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FreeText(Option):
|
||||||
|
"""Text option that allows users to enter strings.
|
||||||
|
Needs to be validated by the world or option definition."""
|
||||||
|
|
||||||
|
def __init__(self, value: str):
|
||||||
|
assert isinstance(value, str), "value of FreeText must be a string"
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self) -> str:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str) -> FreeText:
|
||||||
|
return cls(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_any(cls, data: typing.Any) -> FreeText:
|
||||||
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: T) -> str:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class NumericOption(Option[int], numbers.Integral):
|
class NumericOption(Option[int], numbers.Integral):
|
||||||
|
default = 0
|
||||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||||
# (even though isinstance(5, numbers.Integral) == True)
|
# (even though isinstance(5, numbers.Integral) == True)
|
||||||
@@ -368,6 +424,170 @@ class Choice(NumericOption):
|
|||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
|
class TextChoice(Choice):
|
||||||
|
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
||||||
|
|
||||||
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
|
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self) -> str:
|
||||||
|
if isinstance(self.value, str):
|
||||||
|
return self.value
|
||||||
|
else:
|
||||||
|
return self.name_lookup[self.value]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str) -> TextChoice:
|
||||||
|
if text.lower() == "random": # chooses a random defined option but won't use any free text options
|
||||||
|
return cls(random.choice(list(cls.name_lookup)))
|
||||||
|
for option_name, value in cls.options.items():
|
||||||
|
if option_name.lower() == text.lower():
|
||||||
|
return cls(value)
|
||||||
|
return cls(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: T) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return cls.name_lookup[value]
|
||||||
|
|
||||||
|
def __eq__(self, other: typing.Any):
|
||||||
|
if isinstance(other, self.__class__):
|
||||||
|
return other.value == self.value
|
||||||
|
elif isinstance(other, str):
|
||||||
|
if other in self.options:
|
||||||
|
return other == self.current_key
|
||||||
|
return other == self.value
|
||||||
|
elif isinstance(other, int):
|
||||||
|
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
||||||
|
return other == self.value
|
||||||
|
elif isinstance(other, bool):
|
||||||
|
return other == bool(self.value)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
class BossMeta(AssembleOptions):
|
||||||
|
def __new__(mcs, name, bases, attrs):
|
||||||
|
if name != "PlandoBosses":
|
||||||
|
assert "bosses" in attrs, f"Please define valid bosses for {name}"
|
||||||
|
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
|
||||||
|
assert "locations" in attrs, f"Please define valid locations for {name}"
|
||||||
|
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
|
||||||
|
cls = super().__new__(mcs, name, bases, attrs)
|
||||||
|
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
class PlandoBosses(TextChoice, metaclass=BossMeta):
|
||||||
|
"""Generic boss shuffle option that supports plando. Format expected is
|
||||||
|
'location1-boss1;location2-boss2;shuffle_mode'.
|
||||||
|
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
|
||||||
|
which passes a plando boss and location. Check if the placement is valid for your game here."""
|
||||||
|
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
||||||
|
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
||||||
|
|
||||||
|
duplicate_bosses: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str):
|
||||||
|
# set all of our text to lower case for name checking
|
||||||
|
text = text.lower()
|
||||||
|
if text == "random":
|
||||||
|
return cls(random.choice(list(cls.options.values())))
|
||||||
|
for option_name, value in cls.options.items():
|
||||||
|
if option_name == text:
|
||||||
|
return cls(value)
|
||||||
|
options = text.split(";")
|
||||||
|
|
||||||
|
# since plando exists in the option verify the plando values given are valid
|
||||||
|
cls.validate_plando_bosses(options)
|
||||||
|
return cls.get_shuffle_mode(options)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_shuffle_mode(cls, option_list: typing.List[str]):
|
||||||
|
# find out what mode of boss shuffle we should use for placing bosses after plando
|
||||||
|
# and add as a string to look nice in the spoiler
|
||||||
|
if "random" in option_list:
|
||||||
|
shuffle = random.choice(list(cls.options))
|
||||||
|
option_list.remove("random")
|
||||||
|
options = ";".join(option_list) + f";{shuffle}"
|
||||||
|
boss_class = cls(options)
|
||||||
|
else:
|
||||||
|
for option in option_list:
|
||||||
|
if option in cls.options:
|
||||||
|
options = ";".join(option_list)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if cls.duplicate_bosses and len(option_list) == 1:
|
||||||
|
if cls.valid_boss_name(option_list[0]):
|
||||||
|
# this doesn't exist in this class but it's a forced option for classes where this is called
|
||||||
|
options = option_list[0] + ";singularity"
|
||||||
|
else:
|
||||||
|
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
|
||||||
|
else:
|
||||||
|
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
|
||||||
|
boss_class = cls(options)
|
||||||
|
return boss_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
|
||||||
|
used_locations = []
|
||||||
|
used_bosses = []
|
||||||
|
for option in options:
|
||||||
|
# check if a shuffle mode was provided in the incorrect location
|
||||||
|
if option == "random" or option in cls.options:
|
||||||
|
if option != options[-1]:
|
||||||
|
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
|
||||||
|
elif "-" in option:
|
||||||
|
location, boss = option.split("-")
|
||||||
|
if location in used_locations:
|
||||||
|
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
|
||||||
|
if not cls.duplicate_bosses and boss in used_bosses:
|
||||||
|
raise ValueError(f"Duplicate Boss {boss} not allowed.")
|
||||||
|
used_locations.append(location)
|
||||||
|
used_bosses.append(boss)
|
||||||
|
if not cls.valid_boss_name(boss):
|
||||||
|
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
||||||
|
if not cls.valid_location_name(location):
|
||||||
|
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
||||||
|
if not cls.can_place_boss(boss, location):
|
||||||
|
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
||||||
|
else:
|
||||||
|
if cls.duplicate_bosses:
|
||||||
|
if not cls.valid_boss_name(option):
|
||||||
|
raise ValueError(f"{option} is not a valid boss name.")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{option.title()} is not formatted correctly.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def valid_boss_name(cls, value: str) -> bool:
|
||||||
|
return value in cls.bosses
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def valid_location_name(cls, value: str) -> bool:
|
||||||
|
return value in cls.locations
|
||||||
|
|
||||||
|
def verify(self, world, player_name: str, plando_options) -> None:
|
||||||
|
if isinstance(self.value, int):
|
||||||
|
return
|
||||||
|
from Generate import PlandoSettings
|
||||||
|
if not(PlandoSettings.bosses & plando_options):
|
||||||
|
import logging
|
||||||
|
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||||
|
option = self.value.split(";")[-1]
|
||||||
|
self.value = self.options[option]
|
||||||
|
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
|
||||||
|
f"boss shuffle will be used for player {player_name}.")
|
||||||
|
|
||||||
|
|
||||||
class Range(NumericOption):
|
class Range(NumericOption):
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 1
|
range_end = 1
|
||||||
@@ -385,7 +605,7 @@ class Range(NumericOption):
|
|||||||
if text.startswith("random"):
|
if text.startswith("random"):
|
||||||
return cls.weighted_range(text)
|
return cls.weighted_range(text)
|
||||||
elif text == "default" and hasattr(cls, "default"):
|
elif text == "default" and hasattr(cls, "default"):
|
||||||
return cls(cls.default)
|
return cls.from_any(cls.default)
|
||||||
elif text == "high":
|
elif text == "high":
|
||||||
return cls(cls.range_end)
|
return cls(cls.range_end)
|
||||||
elif text == "low":
|
elif text == "low":
|
||||||
@@ -396,7 +616,7 @@ class Range(NumericOption):
|
|||||||
and text in ("true", "false"):
|
and text in ("true", "false"):
|
||||||
# these are the conditions where "true" and "false" make sense
|
# these are the conditions where "true" and "false" make sense
|
||||||
if text == "true":
|
if text == "true":
|
||||||
return cls(cls.default)
|
return cls.from_any(cls.default)
|
||||||
else: # "false"
|
else: # "false"
|
||||||
return cls(0)
|
return cls(0)
|
||||||
return cls(int(text))
|
return cls(int(text))
|
||||||
@@ -507,7 +727,7 @@ class VerifyKeys:
|
|||||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||||
f"Allowed keys: {cls.valid_keys}.")
|
f"Allowed keys: {cls.valid_keys}.")
|
||||||
|
|
||||||
def verify(self, world):
|
def verify(self, world, player_name: str, plando_options) -> None:
|
||||||
if self.convert_name_groups and self.verify_item_name:
|
if self.convert_name_groups and self.verify_item_name:
|
||||||
new_value = type(self.value)() # empty container of whatever value is
|
new_value = type(self.value)() # empty container of whatever value is
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
@@ -530,11 +750,11 @@ class VerifyKeys:
|
|||||||
|
|
||||||
|
|
||||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||||
default = {}
|
default: typing.Dict[str, typing.Any] = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||||
self.value = value
|
self.value = deepcopy(value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||||
@@ -561,11 +781,11 @@ class ItemDict(OptionDict):
|
|||||||
|
|
||||||
|
|
||||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||||
default = []
|
default: typing.List[typing.Any] = []
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.List[typing.Any]):
|
def __init__(self, value: typing.List[typing.Any]):
|
||||||
self.value = value or []
|
self.value = deepcopy(value)
|
||||||
super(OptionList, self).__init__()
|
super(OptionList, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -587,11 +807,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
|
|
||||||
|
|
||||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||||
default = frozenset()
|
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
def __init__(self, value: typing.Iterable[str]):
|
||||||
self.value = set(value)
|
self.value = set(deepcopy(value))
|
||||||
super(OptionSet, self).__init__()
|
super(OptionSet, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -600,10 +820,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if type(data) == list:
|
if isinstance(data, (list, set, frozenset)):
|
||||||
cls.verify_keys(data)
|
|
||||||
return cls(data)
|
|
||||||
elif type(data) == set:
|
|
||||||
cls.verify_keys(data)
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
@@ -633,7 +850,7 @@ class Accessibility(Choice):
|
|||||||
|
|
||||||
class ProgressionBalancing(SpecialRange):
|
class ProgressionBalancing(SpecialRange):
|
||||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||||
[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
|
default = 50
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 99
|
range_end = 99
|
||||||
@@ -732,8 +949,8 @@ class ItemLinks(OptionList):
|
|||||||
pool |= {item_name}
|
pool |= {item_name}
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
def verify(self, world):
|
def verify(self, world, player_name: str, plando_options) -> None:
|
||||||
super(ItemLinks, self).verify(world)
|
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
|
|||||||
428
Patch.py
428
Patch.py
@@ -1,266 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
import bsdiff4
|
|
||||||
import yaml
|
|
||||||
import os
|
import os
|
||||||
import lzma
|
|
||||||
import threading
|
|
||||||
import concurrent.futures
|
|
||||||
import zipfile
|
|
||||||
import sys
|
import sys
|
||||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
from typing import Tuple, Optional, TypedDict
|
||||||
|
|
||||||
import ModuleUpdate
|
if __name__ == "__main__":
|
||||||
ModuleUpdate.update()
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import Utils
|
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
||||||
|
|
||||||
current_patch_version = 5
|
|
||||||
|
|
||||||
|
|
||||||
class AutoPatchRegister(type):
|
class RomMeta(TypedDict):
|
||||||
patch_types: Dict[str, APDeltaPatch] = {}
|
server: str
|
||||||
file_endings: Dict[str, APDeltaPatch] = {}
|
|
||||||
|
|
||||||
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
|
||||||
# construct class
|
|
||||||
new_class = super().__new__(cls, name, bases, dct)
|
|
||||||
if "game" in dct:
|
|
||||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
|
||||||
if not dct["patch_file_ending"]:
|
|
||||||
raise Exception(f"Need an expected file ending for {name}")
|
|
||||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
|
||||||
return new_class
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
|
||||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
|
||||||
if file.endswith(file_ending):
|
|
||||||
return handler
|
|
||||||
|
|
||||||
|
|
||||||
class APContainer:
|
|
||||||
"""A zipfile containing at least archipelago.json"""
|
|
||||||
version: int = current_patch_version
|
|
||||||
compression_level: int = 9
|
|
||||||
compression_method: int = zipfile.ZIP_DEFLATED
|
|
||||||
game: Optional[str] = None
|
|
||||||
|
|
||||||
# instance attributes:
|
|
||||||
path: Optional[str]
|
|
||||||
player: Optional[int]
|
player: Optional[int]
|
||||||
player_name: str
|
player_name: str
|
||||||
server: str
|
|
||||||
|
|
||||||
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
|
||||||
player_name: str = "", server: str = ""):
|
|
||||||
self.path = path
|
|
||||||
self.player = player
|
|
||||||
self.player_name = player_name
|
|
||||||
self.server = server
|
|
||||||
|
|
||||||
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
|
||||||
if not self.path and not file:
|
|
||||||
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
|
||||||
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
|
||||||
as zf:
|
|
||||||
if file:
|
|
||||||
self.path = zf.filename
|
|
||||||
self.write_contents(zf)
|
|
||||||
|
|
||||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
|
||||||
manifest = self.get_manifest()
|
|
||||||
try:
|
|
||||||
manifest = json.dumps(manifest)
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
|
||||||
else:
|
|
||||||
opened_zipfile.writestr("archipelago.json", manifest)
|
|
||||||
|
|
||||||
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
|
||||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
|
||||||
if not self.path and not file:
|
|
||||||
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
|
||||||
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
|
||||||
if file:
|
|
||||||
self.path = zf.filename
|
|
||||||
self.read_contents(zf)
|
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
|
||||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
|
||||||
manifest = json.load(f)
|
|
||||||
if manifest["compatible_version"] > self.version:
|
|
||||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
|
||||||
f"for this handler (version: {self.version})")
|
|
||||||
self.player = manifest["player"]
|
|
||||||
self.server = manifest["server"]
|
|
||||||
self.player_name = manifest["player_name"]
|
|
||||||
|
|
||||||
def get_manifest(self) -> dict:
|
|
||||||
return {
|
|
||||||
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
|
||||||
"player": self.player,
|
|
||||||
"player_name": self.player_name,
|
|
||||||
"game": self.game,
|
|
||||||
# minimum version of patch system expected for patching to be successful
|
|
||||||
"compatible_version": 4,
|
|
||||||
"version": current_patch_version,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||||
"""An APContainer that additionally has delta.bsdiff4
|
|
||||||
containing a delta patch to get the desired file, often a rom."""
|
|
||||||
|
|
||||||
hash = Optional[str] # base checksum of source file
|
|
||||||
patch_file_ending: str = ""
|
|
||||||
delta: Optional[bytes] = None
|
|
||||||
result_file_ending: str = ".sfc"
|
|
||||||
source_data: bytes
|
|
||||||
|
|
||||||
def __init__(self, *args, patched_path: str = "", **kwargs):
|
|
||||||
self.patched_path = patched_path
|
|
||||||
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_manifest(self) -> dict:
|
|
||||||
manifest = super(APDeltaPatch, self).get_manifest()
|
|
||||||
manifest["base_checksum"] = self.hash
|
|
||||||
manifest["result_file_ending"] = self.result_file_ending
|
|
||||||
manifest["patch_file_ending"] = self.patch_file_ending
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_source_data(cls) -> bytes:
|
|
||||||
"""Get Base data"""
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_source_data_with_cache(cls) -> bytes:
|
|
||||||
if not hasattr(cls, "source_data"):
|
|
||||||
cls.source_data = cls.get_source_data()
|
|
||||||
return cls.source_data
|
|
||||||
|
|
||||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
|
||||||
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
|
||||||
# write Delta
|
|
||||||
opened_zipfile.writestr("delta.bsdiff4",
|
|
||||||
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
|
||||||
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
|
||||||
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
|
||||||
self.delta = opened_zipfile.read("delta.bsdiff4")
|
|
||||||
|
|
||||||
def patch(self, target: str):
|
|
||||||
"""Base + Delta -> Patched"""
|
|
||||||
if not self.delta:
|
|
||||||
self.read()
|
|
||||||
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
|
||||||
with open(target, "wb") as f:
|
|
||||||
f.write(result)
|
|
||||||
|
|
||||||
|
|
||||||
# legacy patch handling follows:
|
|
||||||
GAME_ALTTP = "A Link to the Past"
|
|
||||||
GAME_SM = "Super Metroid"
|
|
||||||
GAME_SOE = "Secret of Evermore"
|
|
||||||
GAME_SMZ3 = "SMZ3"
|
|
||||||
GAME_DKC3 = "Donkey Kong Country 3"
|
|
||||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
|
||||||
|
|
||||||
preferred_endings = {
|
|
||||||
GAME_ALTTP: "apbp",
|
|
||||||
GAME_SM: "apm3",
|
|
||||||
GAME_SOE: "apsoe",
|
|
||||||
GAME_SMZ3: "apsmz",
|
|
||||||
GAME_DKC3: "apdkc3"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
|
||||||
if game == GAME_ALTTP:
|
|
||||||
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
|
||||||
elif game == GAME_SM:
|
|
||||||
from worlds.sm.Rom import SMJUHASH as HASH
|
|
||||||
elif game == GAME_SOE:
|
|
||||||
from worlds.soe.Patch import USHASH as HASH
|
|
||||||
elif game == GAME_SMZ3:
|
|
||||||
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
|
||||||
from worlds.sm.Rom import SMJUHASH as SMHASH
|
|
||||||
HASH = ALTTPHASH + SMHASH
|
|
||||||
elif game == GAME_DKC3:
|
|
||||||
from worlds.dkc3.Rom import USHASH as HASH
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
|
||||||
|
|
||||||
patch = yaml.dump({"meta": metadata,
|
|
||||||
"patch": patch,
|
|
||||||
"game": game,
|
|
||||||
# minimum version of patch system expected for patching to be successful
|
|
||||||
"compatible_version": 3,
|
|
||||||
"version": current_patch_version,
|
|
||||||
"base_checksum": HASH})
|
|
||||||
return patch.encode(encoding="utf-8-sig")
|
|
||||||
|
|
||||||
|
|
||||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
|
||||||
if metadata is None:
|
|
||||||
metadata = {}
|
|
||||||
patch = bsdiff4.diff(get_base_rom_data(game), rom)
|
|
||||||
return generate_yaml(patch, metadata, game)
|
|
||||||
|
|
||||||
|
|
||||||
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
|
|
||||||
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
|
|
||||||
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
|
||||||
"player_id": player,
|
|
||||||
"player_name": player_name}
|
|
||||||
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
|
||||||
meta,
|
|
||||||
game)
|
|
||||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
|
||||||
".apbp" if game == GAME_ALTTP
|
|
||||||
else ".apsmz" if game == GAME_SMZ3
|
|
||||||
else ".apdkc3" if game == GAME_DKC3
|
|
||||||
else ".apm3")
|
|
||||||
write_lzma(bytes, target)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
|
||||||
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
|
||||||
game_name = data["game"]
|
|
||||||
if not ignore_version and data["compatible_version"] > current_patch_version:
|
|
||||||
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
|
|
||||||
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
|
|
||||||
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
|
|
||||||
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
|
|
||||||
target = os.path.splitext(patch_file)[0] + ".sfc"
|
|
||||||
return data["meta"], target, patched_data
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_rom_data(game: str):
|
|
||||||
if game == GAME_ALTTP:
|
|
||||||
from worlds.alttp.Rom import get_base_rom_bytes
|
|
||||||
elif game == "alttp": # old version for A Link to the Past
|
|
||||||
from worlds.alttp.Rom import get_base_rom_bytes
|
|
||||||
elif game == GAME_SM:
|
|
||||||
from worlds.sm.Rom import get_base_rom_bytes
|
|
||||||
elif game == GAME_SOE:
|
|
||||||
from worlds.soe.Patch import get_base_rom_path
|
|
||||||
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
|
||||||
elif game == GAME_SMZ3:
|
|
||||||
from worlds.smz3.Rom import get_base_rom_bytes
|
|
||||||
elif game == GAME_DKC3:
|
|
||||||
from worlds.dkc3.Rom import get_base_rom_bytes
|
|
||||||
else:
|
|
||||||
raise RuntimeError("Selected game for base rom not found.")
|
|
||||||
return get_base_rom_bytes()
|
|
||||||
|
|
||||||
|
|
||||||
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
|
||||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||||
if auto_handler:
|
if auto_handler:
|
||||||
handler: APDeltaPatch = auto_handler(patch_file)
|
handler: APDeltaPatch = auto_handler(patch_file)
|
||||||
@@ -269,171 +26,10 @@ def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
|||||||
return {"server": handler.server,
|
return {"server": handler.server,
|
||||||
"player": handler.player,
|
"player": handler.player,
|
||||||
"player_name": handler.player_name}, target
|
"player_name": handler.player_name}, target
|
||||||
else:
|
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
||||||
data, target, patched_data = create_rom_bytes(patch_file)
|
|
||||||
with open(target, "wb") as f:
|
|
||||||
f.write(patched_data)
|
|
||||||
return data, target
|
|
||||||
|
|
||||||
|
|
||||||
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
|
|
||||||
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
|
|
||||||
data["meta"]["server"] = server
|
|
||||||
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
|
|
||||||
return lzma.compress(bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def load_bytes(path: str) -> bytes:
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
def write_lzma(data: bytes, path: str):
|
|
||||||
with lzma.LZMAFile(path, 'wb') as f:
|
|
||||||
f.write(data)
|
|
||||||
|
|
||||||
|
|
||||||
def read_rom(stream, strip_header=True) -> bytearray:
|
|
||||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
|
||||||
buffer = bytearray(stream.read())
|
|
||||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
|
||||||
return buffer[0x200:]
|
|
||||||
return buffer
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
host = Utils.get_public_ipv4()
|
for file in sys.argv[1:]:
|
||||||
options = Utils.get_options()['server_options']
|
meta_data, result_file = create_rom_file(file)
|
||||||
if options['host']:
|
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
||||||
host = options['host']
|
|
||||||
|
|
||||||
address = f"{host}:{options['port']}"
|
|
||||||
ziplock = threading.Lock()
|
|
||||||
print(f"Host for patches to be created is {address}")
|
|
||||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
||||||
for rom in sys.argv:
|
|
||||||
try:
|
|
||||||
if rom.endswith(".sfc"):
|
|
||||||
print(f"Creating patch for {rom}")
|
|
||||||
result = pool.submit(create_patch_file, rom, address)
|
|
||||||
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
|
|
||||||
|
|
||||||
elif rom.endswith(".apbp"):
|
|
||||||
print(f"Applying patch {rom}")
|
|
||||||
data, target = create_rom_file(rom)
|
|
||||||
#romfile, adjusted = Utils.get_adjuster_settings(target)
|
|
||||||
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
|
|
||||||
adjusted = False
|
|
||||||
if adjuster_settings:
|
|
||||||
import pprint
|
|
||||||
from worlds.alttp.Rom import get_base_rom_path
|
|
||||||
adjuster_settings.rom = target
|
|
||||||
adjuster_settings.baserom = get_base_rom_path()
|
|
||||||
adjuster_settings.world = None
|
|
||||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
|
||||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
|
||||||
"reduceflashing", "deathlink"}
|
|
||||||
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
|
||||||
if hasattr(adjuster_settings, "sprite_pool"):
|
|
||||||
sprite_pool = {}
|
|
||||||
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
|
||||||
if sprite in sprite_pool:
|
|
||||||
sprite_pool[sprite] += 1
|
|
||||||
else:
|
|
||||||
sprite_pool[sprite] = 1
|
|
||||||
if sprite_pool:
|
|
||||||
printed_options["sprite_pool"] = sprite_pool
|
|
||||||
|
|
||||||
adjust_wanted = str('no')
|
|
||||||
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
|
|
||||||
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
|
||||||
f"{pprint.pformat(printed_options)}\n"
|
|
||||||
f"Enter yes, no, always or never: ")
|
|
||||||
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
|
|
||||||
adjust_wanted = 'no'
|
|
||||||
elif adjuster_settings.auto_apply == 'always':
|
|
||||||
adjust_wanted = 'yes'
|
|
||||||
|
|
||||||
if adjust_wanted and "never" in adjust_wanted:
|
|
||||||
adjuster_settings.auto_apply = 'never'
|
|
||||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
|
||||||
|
|
||||||
elif adjust_wanted and "always" in adjust_wanted:
|
|
||||||
adjuster_settings.auto_apply = 'always'
|
|
||||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
|
||||||
|
|
||||||
if adjust_wanted and adjust_wanted.startswith("y"):
|
|
||||||
if hasattr(adjuster_settings, "sprite_pool"):
|
|
||||||
from LttPAdjuster import AdjusterWorld
|
|
||||||
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
|
||||||
|
|
||||||
adjusted = True
|
|
||||||
import LttPAdjuster
|
|
||||||
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
|
||||||
|
|
||||||
if hasattr(adjuster_settings, "world"):
|
|
||||||
delattr(adjuster_settings, "world")
|
|
||||||
else:
|
|
||||||
adjusted = False
|
|
||||||
if adjusted:
|
|
||||||
try:
|
|
||||||
shutil.move(romfile, target)
|
|
||||||
romfile = target
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print(f"Created rom {romfile if adjusted else target}.")
|
|
||||||
if 'server' in data:
|
|
||||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
|
||||||
print(f"Host is {data['server']}")
|
|
||||||
elif rom.endswith(".apm3"):
|
|
||||||
print(f"Applying patch {rom}")
|
|
||||||
data, target = create_rom_file(rom)
|
|
||||||
print(f"Created rom {target}.")
|
|
||||||
if 'server' in data:
|
|
||||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
|
||||||
print(f"Host is {data['server']}")
|
|
||||||
elif rom.endswith(".apsmz"):
|
|
||||||
print(f"Applying patch {rom}")
|
|
||||||
data, target = create_rom_file(rom)
|
|
||||||
print(f"Created rom {target}.")
|
|
||||||
if 'server' in data:
|
|
||||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
|
||||||
print(f"Host is {data['server']}")
|
|
||||||
elif rom.endswith(".apdkc3"):
|
|
||||||
print(f"Applying patch {rom}")
|
|
||||||
data, target = create_rom_file(rom)
|
|
||||||
print(f"Created rom {target}.")
|
|
||||||
if 'server' in data:
|
|
||||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
|
||||||
print(f"Host is {data['server']}")
|
|
||||||
|
|
||||||
elif rom.endswith(".zip"):
|
|
||||||
print(f"Updating host in patch files contained in {rom}")
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
|
||||||
data = zfr.read(zfinfo)
|
|
||||||
if zfinfo.filename.endswith(".apbp") or \
|
|
||||||
zfinfo.filename.endswith(".apm3") or \
|
|
||||||
zfinfo.filename.endswith(".apdkc3"):
|
|
||||||
data = update_patch_data(data, server)
|
|
||||||
with ziplock:
|
|
||||||
zfw.writestr(zfinfo, data)
|
|
||||||
return zfinfo.filename
|
|
||||||
|
|
||||||
|
|
||||||
futures = []
|
|
||||||
with zipfile.ZipFile(rom, "r") as zfr:
|
|
||||||
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
|
|
||||||
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
|
|
||||||
compresslevel=9) as zfw:
|
|
||||||
for zfname in zfr.namelist():
|
|
||||||
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
|
|
||||||
for future in futures:
|
|
||||||
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
|
|
||||||
|
|
||||||
except:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
input("Press enter to close.")
|
|
||||||
|
|||||||
304
PokemonClient.py
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
|
* Starcraft 2: Wings of Liberty
|
||||||
* Donkey Kong Country 3
|
* Donkey Kong Country 3
|
||||||
* Dark Souls 3
|
* Dark Souls 3
|
||||||
|
* Super Mario World
|
||||||
|
* Pokémon Red and Blue
|
||||||
|
* Hylics 2
|
||||||
|
* Overcooked! 2
|
||||||
|
* Zillion
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
1175
SNIClient.py
1175
SNIClient.py
File diff suppressed because it is too large
Load Diff
@@ -12,21 +12,9 @@ import typing
|
|||||||
import queue
|
import queue
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import nest_asyncio
|
# CommonClient import first to trigger ModuleUpdater
|
||||||
import sc2
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
from sc2.bot_ai import BotAI
|
|
||||||
from sc2.data import Race
|
|
||||||
from sc2.main import run_game
|
|
||||||
from sc2.player import Bot
|
|
||||||
|
|
||||||
import NetUtils
|
|
||||||
from MultiServer import mark_raw
|
|
||||||
from Utils import init_logging, is_windows
|
from Utils import init_logging, is_windows
|
||||||
from worlds.sc2wol import SC2WoLWorld
|
|
||||||
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
|
|
||||||
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
|
||||||
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
|
||||||
from worlds.sc2wol.Regions import MissionInfo
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_logging("SC2Client", exception_logger="Client")
|
init_logging("SC2Client", exception_logger="Client")
|
||||||
@@ -34,10 +22,21 @@ if __name__ == "__main__":
|
|||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
sc2_logger = logging.getLogger("Starcraft2")
|
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
|
import colorama
|
||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser
|
||||||
|
from MultiServer import mark_raw
|
||||||
|
|
||||||
nest_asyncio.apply()
|
nest_asyncio.apply()
|
||||||
max_bonus: int = 8
|
max_bonus: int = 8
|
||||||
@@ -115,12 +114,40 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
|||||||
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
||||||
if path:
|
if path:
|
||||||
os.environ["SC2PATH"] = path
|
os.environ["SC2PATH"] = path
|
||||||
check_mod_install()
|
is_mod_installed_correctly()
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _cmd_download_data(self, force: bool = False) -> bool:
|
||||||
|
"""Download the most recent release of the necessary files for playing SC2 with
|
||||||
|
Archipelago. force should be True or False. force=True will overwrite your files."""
|
||||||
|
if "SC2PATH" not in os.environ:
|
||||||
|
check_game_install_path()
|
||||||
|
|
||||||
|
if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"):
|
||||||
|
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f:
|
||||||
|
current_ver = f.read()
|
||||||
|
else:
|
||||||
|
current_ver = None
|
||||||
|
|
||||||
|
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', current_version=current_ver, force_download=force)
|
||||||
|
|
||||||
|
if tempzip != '':
|
||||||
|
try:
|
||||||
|
import zipfile
|
||||||
|
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
|
||||||
|
sc2_logger.info(f"Download complete. Version {version} installed.")
|
||||||
|
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
|
||||||
|
f.write(version)
|
||||||
|
finally:
|
||||||
|
os.remove(tempzip)
|
||||||
|
else:
|
||||||
|
sc2_logger.warning("Download aborted/failed. Read the log for more information.")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class SC2Context(CommonContext):
|
class SC2Context(CommonContext):
|
||||||
command_processor = StarcraftClientProcessor
|
command_processor = StarcraftClientProcessor
|
||||||
@@ -128,7 +155,9 @@ class SC2Context(CommonContext):
|
|||||||
items_handling = 0b111
|
items_handling = 0b111
|
||||||
difficulty = -1
|
difficulty = -1
|
||||||
all_in_choice = 0
|
all_in_choice = 0
|
||||||
|
mission_order = 0
|
||||||
mission_req_table: typing.Dict[str, MissionInfo] = {}
|
mission_req_table: typing.Dict[str, MissionInfo] = {}
|
||||||
|
final_mission: int = 29
|
||||||
announcements = queue.Queue()
|
announcements = queue.Queue()
|
||||||
sc2_run_task: typing.Optional[asyncio.Task] = None
|
sc2_run_task: typing.Optional[asyncio.Task] = None
|
||||||
missions_unlocked: bool = False # allow launching missions ignoring requirements
|
missions_unlocked: bool = False # allow launching missions ignoring requirements
|
||||||
@@ -153,16 +182,25 @@ class SC2Context(CommonContext):
|
|||||||
self.difficulty = args["slot_data"]["game_difficulty"]
|
self.difficulty = args["slot_data"]["game_difficulty"]
|
||||||
self.all_in_choice = args["slot_data"]["all_in_map"]
|
self.all_in_choice = args["slot_data"]["all_in_map"]
|
||||||
slot_req_table = args["slot_data"]["mission_req"]
|
slot_req_table = args["slot_data"]["mission_req"]
|
||||||
|
# Maintaining backwards compatibility with older slot data
|
||||||
self.mission_req_table = {
|
self.mission_req_table = {
|
||||||
mission: MissionInfo(**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()
|
self.build_location_to_mission_mapping()
|
||||||
|
|
||||||
# Look for and set SC2PATH.
|
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
|
||||||
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
|
is_mod_installed_correctly()
|
||||||
if "SC2PATH" not in os.environ and check_game_install_path():
|
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
|
||||||
check_mod_install()
|
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):
|
def on_print_json(self, args: dict):
|
||||||
# goes to this world
|
# goes to this world
|
||||||
@@ -274,7 +312,6 @@ class SC2Context(CommonContext):
|
|||||||
self.refresh_from_launching = True
|
self.refresh_from_launching = True
|
||||||
|
|
||||||
self.mission_panel.clear_widgets()
|
self.mission_panel.clear_widgets()
|
||||||
|
|
||||||
if self.ctx.mission_req_table:
|
if self.ctx.mission_req_table:
|
||||||
self.last_checked_locations = self.ctx.checked_locations.copy()
|
self.last_checked_locations = self.ctx.checked_locations.copy()
|
||||||
self.first_check = False
|
self.first_check = False
|
||||||
@@ -292,42 +329,58 @@ class SC2Context(CommonContext):
|
|||||||
|
|
||||||
for category in categories:
|
for category in categories:
|
||||||
category_panel = MissionCategory()
|
category_panel = MissionCategory()
|
||||||
|
if category.startswith('_'):
|
||||||
|
category_display_name = ''
|
||||||
|
else:
|
||||||
|
category_display_name = category
|
||||||
category_panel.add_widget(
|
category_panel.add_widget(
|
||||||
Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1))
|
||||||
|
|
||||||
# Map is completed
|
|
||||||
for mission in categories[category]:
|
for mission in categories[category]:
|
||||||
text = mission
|
text: str = mission
|
||||||
tooltip = ""
|
tooltip: str = ""
|
||||||
|
mission_id: int = self.ctx.mission_req_table[mission].id
|
||||||
# Map has uncollected locations
|
# Map has uncollected locations
|
||||||
if mission in unfinished_missions:
|
if mission in unfinished_missions:
|
||||||
text = f"[color=6495ED]{text}[/color]"
|
text = f"[color=6495ED]{text}[/color]"
|
||||||
|
|
||||||
tooltip = f"Uncollected locations:\n"
|
|
||||||
tooltip += "\n".join([self.ctx.location_names[loc] for loc in
|
|
||||||
self.ctx.locations_for_mission(mission)
|
|
||||||
if loc in self.ctx.missing_locations])
|
|
||||||
elif mission in available_missions:
|
elif mission in available_missions:
|
||||||
text = f"[color=FFFFFF]{text}[/color]"
|
text = f"[color=FFFFFF]{text}[/color]"
|
||||||
# Map requirements not met
|
# Map requirements not met
|
||||||
else:
|
else:
|
||||||
text = f"[color=a9a9a9]{text}[/color]"
|
text = f"[color=a9a9a9]{text}[/color]"
|
||||||
tooltip = f"Requires: "
|
tooltip = f"Requires: "
|
||||||
if len(self.ctx.mission_req_table[mission].required_world) > 0:
|
if self.ctx.mission_req_table[mission].required_world:
|
||||||
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
|
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
|
||||||
req_mission in
|
req_mission in
|
||||||
self.ctx.mission_req_table[mission].required_world)
|
self.ctx.mission_req_table[mission].required_world)
|
||||||
|
|
||||||
if self.ctx.mission_req_table[mission].number > 0:
|
if self.ctx.mission_req_table[mission].number:
|
||||||
tooltip += " and "
|
tooltip += " and "
|
||||||
if self.ctx.mission_req_table[mission].number > 0:
|
if self.ctx.mission_req_table[mission].number:
|
||||||
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
|
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
|
||||||
|
remaining_location_names: typing.List[str] = [
|
||||||
|
self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
|
||||||
|
if loc in self.ctx.missing_locations]
|
||||||
|
|
||||||
|
if mission_id == self.ctx.final_mission:
|
||||||
|
if mission in available_missions:
|
||||||
|
text = f"[color=FFBC95]{mission}[/color]"
|
||||||
|
else:
|
||||||
|
text = f"[color=D0C0BE]{mission}[/color]"
|
||||||
|
if tooltip:
|
||||||
|
tooltip += "\n"
|
||||||
|
tooltip += "Final Mission"
|
||||||
|
|
||||||
|
if remaining_location_names:
|
||||||
|
if tooltip:
|
||||||
|
tooltip += "\n"
|
||||||
|
tooltip += f"Uncollected locations:\n"
|
||||||
|
tooltip += "\n".join(remaining_location_names)
|
||||||
|
|
||||||
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
||||||
mission_button.tooltip_text = tooltip
|
mission_button.tooltip_text = tooltip
|
||||||
mission_button.bind(on_press=self.mission_callback)
|
mission_button.bind(on_press=self.mission_callback)
|
||||||
self.mission_id_to_button[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(mission_button)
|
||||||
|
|
||||||
category_panel.add_widget(Label(text=""))
|
category_panel.add_widget(Label(text=""))
|
||||||
@@ -354,8 +407,9 @@ class SC2Context(CommonContext):
|
|||||||
|
|
||||||
self.ui = SC2Manager(self)
|
self.ui = SC2Manager(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
import pkgutil
|
||||||
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
|
data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode()
|
||||||
|
Builder.load_string(data)
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
await super(SC2Context, self).shutdown()
|
await super(SC2Context, self).shutdown()
|
||||||
@@ -435,10 +489,13 @@ wol_default_categories = [
|
|||||||
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
||||||
"Char", "Char", "Char", "Char"
|
"Char", "Char", "Char", "Char"
|
||||||
]
|
]
|
||||||
|
wol_default_category_names = [
|
||||||
|
"Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
|
def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]:
|
||||||
network_item: NetUtils.NetworkItem
|
network_item: NetworkItem
|
||||||
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
|
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
|
||||||
|
|
||||||
for network_item in items:
|
for network_item in items:
|
||||||
@@ -552,7 +609,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
|
|||||||
|
|
||||||
if self.can_read_game:
|
if self.can_read_game:
|
||||||
if game_state & (1 << 1) and not self.mission_completed:
|
if game_state & (1 << 1) and not self.mission_completed:
|
||||||
if self.mission_id != 29:
|
if self.mission_id != self.ctx.final_mission:
|
||||||
print("Mission Completed")
|
print("Mission Completed")
|
||||||
await self.ctx.send_msgs(
|
await self.ctx.send_msgs(
|
||||||
[{"cmd": 'LocationChecks',
|
[{"cmd": 'LocationChecks',
|
||||||
@@ -708,13 +765,14 @@ def calc_available_missions(ctx: SC2Context, unlocks=None):
|
|||||||
return available_missions
|
return available_missions
|
||||||
|
|
||||||
|
|
||||||
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete):
|
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
|
"""Returns a bool signifying if the mission has all requirements complete and can be done
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
ctx -- instance of SC2Context
|
ctx -- instance of SC2Context
|
||||||
locations_to_check -- the mission string name to check
|
locations_to_check -- the mission string name to check
|
||||||
missions_complete -- an int of how many missions have been completed
|
missions_complete -- an int of how many missions have been completed
|
||||||
|
mission_path -- a list of missions that have already been checked
|
||||||
"""
|
"""
|
||||||
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
|
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
|
||||||
# A check for when the requirements are being or'd
|
# A check for when the requirements are being or'd
|
||||||
@@ -732,7 +790,18 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete
|
|||||||
else:
|
else:
|
||||||
req_success = False
|
req_success = False
|
||||||
|
|
||||||
|
# Grid-specific logic (to avoid long path checks and infinite recursion)
|
||||||
|
if ctx.mission_order in (3, 4):
|
||||||
|
if req_success:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
||||||
|
# Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
|
||||||
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
|
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
|
||||||
if not ctx.mission_req_table[mission_name].or_requirements:
|
if not ctx.mission_req_table[mission_name].or_requirements:
|
||||||
return False
|
return False
|
||||||
@@ -790,7 +859,12 @@ def check_game_install_path() -> bool:
|
|||||||
with open(einfo) as f:
|
with open(einfo) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if content:
|
if content:
|
||||||
base = re.search(r" = (.*)Versions", content).group(1)
|
try:
|
||||||
|
base = re.search(r" = (.*)Versions", content).group(1)
|
||||||
|
except AttributeError:
|
||||||
|
sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then "
|
||||||
|
f"try again.")
|
||||||
|
return False
|
||||||
if os.path.exists(base):
|
if os.path.exists(base):
|
||||||
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
|
executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions")
|
||||||
|
|
||||||
@@ -807,22 +881,58 @@ def check_game_install_path() -> bool:
|
|||||||
else:
|
else:
|
||||||
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
|
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
|
||||||
else:
|
else:
|
||||||
sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.")
|
sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. "
|
||||||
|
f"If that fails, please run /set_path with your SC2 install directory.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_mod_install() -> bool:
|
def is_mod_installed_correctly() -> bool:
|
||||||
# Pull up the SC2PATH if set. If not, encourage the user to manually run /set_path.
|
"""Searches for all required files."""
|
||||||
try:
|
if "SC2PATH" not in os.environ:
|
||||||
# Check inside the Mods folder for Archipelago.SC2Mod. If found, tell user. If not, tell user.
|
check_game_install_path()
|
||||||
if os.path.isfile(modfile := (os.environ["SC2PATH"] / Path("Mods") / Path("Archipelago.SC2Mod"))):
|
|
||||||
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
|
||||||
return True
|
modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
|
||||||
else:
|
wol_required_maps = [
|
||||||
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}. Please install the mod file there.")
|
"ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
|
||||||
except KeyError:
|
"ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
|
||||||
sc2_logger.warning(f"SC2PATH isn't set. Please run /set_path with the path to your SC2 install.")
|
"ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
|
||||||
return False
|
"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:
|
class DllDirectory:
|
||||||
@@ -861,6 +971,64 @@ class DllDirectory:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
|
||||||
|
"""Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {"Accept": 'application/vnd.github.v3+json'}
|
||||||
|
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
||||||
|
|
||||||
|
r1 = requests.get(url, headers=headers)
|
||||||
|
if r1.status_code == 200:
|
||||||
|
latest_version = r1.json()["tag_name"]
|
||||||
|
sc2_logger.info(f"Latest version: {latest_version}.")
|
||||||
|
else:
|
||||||
|
sc2_logger.warning(f"Status code: {r1.status_code}")
|
||||||
|
sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
|
||||||
|
sc2_logger.warning(f"text: {r1.text}")
|
||||||
|
return "", current_version
|
||||||
|
|
||||||
|
if (force_download is False) and (current_version == latest_version):
|
||||||
|
sc2_logger.info("Latest version already installed.")
|
||||||
|
return "", current_version
|
||||||
|
|
||||||
|
sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
|
||||||
|
download_url = r1.json()["assets"][0]["browser_download_url"]
|
||||||
|
|
||||||
|
r2 = requests.get(download_url, headers=headers)
|
||||||
|
if r2.status_code == 200:
|
||||||
|
with open(f"{repo}.zip", "wb") as fh:
|
||||||
|
fh.write(r2.content)
|
||||||
|
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
|
||||||
|
return f"{repo}.zip", latest_version
|
||||||
|
else:
|
||||||
|
sc2_logger.warning(f"Status code: {r2.status_code}")
|
||||||
|
sc2_logger.warning("Download failed.")
|
||||||
|
sc2_logger.warning(f"text: {r2.text}")
|
||||||
|
return "", current_version
|
||||||
|
|
||||||
|
|
||||||
|
def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {"Accept": 'application/vnd.github.v3+json'}
|
||||||
|
url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
||||||
|
|
||||||
|
r1 = requests.get(url, headers=headers)
|
||||||
|
if r1.status_code == 200:
|
||||||
|
latest_version = r1.json()["tag_name"]
|
||||||
|
if current_version != latest_version:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
else:
|
||||||
|
sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
|
||||||
|
sc2_logger.warning(f"Status code: {r1.status_code}")
|
||||||
|
sc2_logger.warning(f"text: {r1.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.init()
|
colorama.init()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
79
Utils.py
79
Utils.py
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
import os
|
import os
|
||||||
@@ -11,6 +12,8 @@ import io
|
|||||||
import collections
|
import collections
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
|
||||||
|
|
||||||
from yaml import load, load_all, dump, SafeLoader
|
from yaml import load, load_all, dump, SafeLoader
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -35,7 +38,7 @@ class Version(typing.NamedTuple):
|
|||||||
build: int
|
build: int
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.3.5"
|
__version__ = "0.3.6"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
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)
|
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'):
|
if hasattr(output_path, 'cached_path'):
|
||||||
return os.path.join(output_path.cached_path, *path)
|
return os.path.join(output_path.cached_path, *path)
|
||||||
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
||||||
@@ -217,8 +220,11 @@ def get_public_ipv6() -> str:
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
|
||||||
|
|
||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_default_options() -> dict:
|
def get_default_options() -> OptionsType:
|
||||||
# Refer to host.yaml for comments as to what all these options mean.
|
# Refer to host.yaml for comments as to what all these options mean.
|
||||||
options = {
|
options = {
|
||||||
"general_options": {
|
"general_options": {
|
||||||
@@ -226,20 +232,21 @@ def get_default_options() -> dict:
|
|||||||
},
|
},
|
||||||
"factorio_options": {
|
"factorio_options": {
|
||||||
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
||||||
|
"filter_item_sends": False,
|
||||||
|
"bridge_chat_out": True,
|
||||||
|
},
|
||||||
|
"sni_options": {
|
||||||
|
"sni": "SNI",
|
||||||
|
"snes_rom_start": True,
|
||||||
},
|
},
|
||||||
"sm_options": {
|
"sm_options": {
|
||||||
"rom_file": "Super Metroid (JU).sfc",
|
"rom_file": "Super Metroid (JU).sfc",
|
||||||
"sni": "SNI",
|
|
||||||
"rom_start": True,
|
|
||||||
},
|
},
|
||||||
"soe_options": {
|
"soe_options": {
|
||||||
"rom_file": "Secret of Evermore (USA).sfc",
|
"rom_file": "Secret of Evermore (USA).sfc",
|
||||||
},
|
},
|
||||||
"lttp_options": {
|
"lttp_options": {
|
||||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||||
"sni": "SNI",
|
|
||||||
"rom_start": True,
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"server_options": {
|
"server_options": {
|
||||||
"host": None,
|
"host": None,
|
||||||
@@ -282,15 +289,27 @@ def get_default_options() -> dict:
|
|||||||
},
|
},
|
||||||
"dkc3_options": {
|
"dkc3_options": {
|
||||||
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||||
"sni": "SNI",
|
|
||||||
"rom_start": True,
|
|
||||||
},
|
},
|
||||||
|
"smw_options": {
|
||||||
|
"rom_file": "Super Mario World (USA).sfc",
|
||||||
|
},
|
||||||
|
"zillion_options": {
|
||||||
|
"rom_file": "Zillion (UE) [!].sms",
|
||||||
|
# RetroArch doesn't make it easy to launch a game from the command line.
|
||||||
|
# You have to know the path to the emulator core library on the user's computer.
|
||||||
|
"rom_start": "retroarch",
|
||||||
|
},
|
||||||
|
"pokemon_rb_options": {
|
||||||
|
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
|
||||||
|
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
|
||||||
|
"rom_start": True
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType:
|
||||||
for key, value in src.items():
|
for key, value in src.items():
|
||||||
new_keys = keys.copy()
|
new_keys = keys.copy()
|
||||||
new_keys.append(key)
|
new_keys.append(key)
|
||||||
@@ -310,9 +329,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_options() -> dict:
|
def get_options() -> OptionsType:
|
||||||
filenames = ("options.yaml", "host.yaml")
|
filenames = ("options.yaml", "host.yaml")
|
||||||
locations = []
|
locations: typing.List[str] = []
|
||||||
if os.path.join(os.getcwd()) != local_path():
|
if os.path.join(os.getcwd()) != local_path():
|
||||||
locations += filenames # use files from cwd only if it's not the local_path
|
locations += filenames # use files from cwd only if it's not the local_path
|
||||||
locations += [user_path(filename) for filename in filenames]
|
locations += [user_path(filename) for filename in filenames]
|
||||||
@@ -353,7 +372,7 @@ def persistent_load() -> typing.Dict[str, dict]:
|
|||||||
return storage
|
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, {})
|
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||||
return adjuster_settings
|
return adjuster_settings
|
||||||
|
|
||||||
@@ -392,7 +411,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
# Options and Plando are unpickled by WebHost -> Generate
|
# Options and Plando are unpickled by WebHost -> Generate
|
||||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||||
return getattr(self.generic_properties_module, name)
|
return getattr(self.generic_properties_module, name)
|
||||||
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":
|
if module == "Options":
|
||||||
mod = self.options_module
|
mod = self.options_module
|
||||||
else:
|
else:
|
||||||
@@ -623,3 +643,32 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
|
|||||||
else:
|
else:
|
||||||
return element.lower()
|
return element.lower()
|
||||||
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
||||||
|
|
||||||
|
|
||||||
|
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
||||||
|
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||||
|
buffer = bytearray(stream.read())
|
||||||
|
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||||
|
return buffer[0x200:]
|
||||||
|
return buffer
|
||||||
|
|
||||||
|
|
||||||
|
_faf_tasks: "Set[asyncio.Task[None]]" = set()
|
||||||
|
|
||||||
|
|
||||||
|
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||||
|
to prevent early garbage collection. "fire-and-forget"
|
||||||
|
"""
|
||||||
|
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
||||||
|
# Python docs:
|
||||||
|
# ```
|
||||||
|
# Important: Save a reference to the result of [asyncio.create_task],
|
||||||
|
# to avoid a task disappearing mid-execution.
|
||||||
|
# ```
|
||||||
|
# This implementation follows the pattern given in that documentation.
|
||||||
|
|
||||||
|
task = asyncio.create_task(co, name=name)
|
||||||
|
_faf_tasks.add(task)
|
||||||
|
task.add_done_callback(_faf_tasks.discard)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import os
|
|
||||||
import uuid
|
|
||||||
import base64
|
import base64
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import uuid
|
||||||
|
|
||||||
from pony.flask import Pony
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
|
from pony.flask import Pony
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from Utils import title_sorted
|
from Utils import title_sorted
|
||||||
from .models import *
|
|
||||||
|
|
||||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||||
LOGS_FOLDER = os.path.relpath('logs')
|
LOGS_FOLDER = os.path.relpath('logs')
|
||||||
@@ -32,8 +31,10 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
|||||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||||
# if you want to deploy, make sure you have a non-guessable secret key
|
# if you want to deploy, make sure you have a non-guessable secret key
|
||||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||||
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
|
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||||
app.config["JOB_THRESHOLD"] = 2
|
app.config["JOB_THRESHOLD"] = 2
|
||||||
|
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||||
|
app.config["JOB_TIME"] = 600
|
||||||
app.config['SESSION_PERMANENT'] = True
|
app.config['SESSION_PERMANENT'] = True
|
||||||
|
|
||||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||||
@@ -73,8 +74,10 @@ def register():
|
|||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
import Patch
|
import worlds.AutoWorld
|
||||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
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
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
# to trigger app routing picking up on it
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""API endpoints package."""
|
"""API endpoints package."""
|
||||||
from uuid import UUID
|
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import Blueprint, abort
|
from flask import Blueprint, abort
|
||||||
|
|
||||||
from ..models import Room, Seed
|
|
||||||
from .. import cache
|
from .. import cache
|
||||||
|
from ..models import Room, Seed
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from . import api_endpoints
|
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
from pony.orm import commit
|
from pony.orm import commit
|
||||||
|
|
||||||
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
from WebHostLib import app
|
||||||
from WebHostLib.check import get_yaml_data, roll_options
|
from WebHostLib.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
from WebHostLib.generate import get_meta
|
||||||
|
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||||
|
from . import api_endpoints
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/generate', methods=['POST'])
|
@api_endpoints.route('/generate', methods=['POST'])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import session, jsonify
|
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
|
from . import api_endpoints, get_players
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import multiprocessing
|
|
||||||
import threading
|
|
||||||
from datetime import timedelta, datetime
|
|
||||||
|
|
||||||
import sys
|
import json
|
||||||
import typing
|
import logging
|
||||||
import time
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import typing
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import zipfile
|
import zipfile
|
||||||
from typing import *
|
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
|
from WebHostLib import app
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
|
||||||
import websockets
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import pickle
|
||||||
|
import random
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import random
|
|
||||||
import pickle
|
import websockets
|
||||||
import logging
|
from pony.orm import db_session, commit, select
|
||||||
import datetime
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from .models import db_session, Room, select, commit, Command, db
|
|
||||||
|
|
||||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||||
|
from .models import Room, Command, db
|
||||||
|
|
||||||
|
|
||||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||||
@@ -49,6 +51,8 @@ class DBCommandProcessor(ServerCommandProcessor):
|
|||||||
|
|
||||||
|
|
||||||
class WebHostContext(Context):
|
class WebHostContext(Context):
|
||||||
|
room_id: int
|
||||||
|
|
||||||
def __init__(self, static_server_data: dict):
|
def __init__(self, static_server_data: dict):
|
||||||
# static server data is used during _load_game_data to load required data,
|
# static server data is used during _load_game_data to load required data,
|
||||||
# without needing to import worlds system, which takes quite a bit of memory
|
# without needing to import worlds system, which takes quite a bit of memory
|
||||||
@@ -62,6 +66,8 @@ class WebHostContext(Context):
|
|||||||
def _load_game_data(self):
|
def _load_game_data(self):
|
||||||
for key, value in self.static_server_data.items():
|
for key, value in self.static_server_data.items():
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
|
||||||
|
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||||
|
|
||||||
def listen_to_db_commands(self):
|
def listen_to_db_commands(self):
|
||||||
cmdprocessor = DBCommandProcessor(self)
|
cmdprocessor = DBCommandProcessor(self)
|
||||||
@@ -178,4 +184,12 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
|||||||
|
|
||||||
from .autolauncher import Locker
|
from .autolauncher import Locker
|
||||||
with Locker(room_id):
|
with Locker(room_id):
|
||||||
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 json
|
||||||
|
import zipfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from flask import send_file, Response, render_template
|
from flask import send_file, Response, render_template
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
from worlds.Files import AutoPatchRegister
|
||||||
from WebHostLib import app, Slot, Room, Seed, cache
|
from . import app, cache
|
||||||
|
from .models import Slot, Room, Seed
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||||
@@ -41,12 +42,7 @@ def download_patch(room_id, patch_id):
|
|||||||
new_file.seek(0)
|
new_file.seek(0)
|
||||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||||
else:
|
else:
|
||||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
return "Old Patch file, no longer compatible."
|
||||||
patch_data = BytesIO(patch_data)
|
|
||||||
|
|
||||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
|
||||||
f"{preferred_endings[patch.game]}"
|
|
||||||
return send_file(patch_data, as_attachment=True, download_name=fname)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||||
@@ -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"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||||
elif slot_data.game == "VVVVVV":
|
elif slot_data.game == "VVVVVV":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||||
|
elif slot_data.game == "Zillion":
|
||||||
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
|
||||||
elif slot_data.game == "Super Mario 64":
|
elif slot_data.game == "Super Mario 64":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||||
elif slot_data.game == "Dark Souls III":
|
elif slot_data.game == "Dark Souls III":
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import random
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import random
|
||||||
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import concurrent.futures
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, Optional, Any
|
||||||
from Utils import __version__
|
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, session, render_template
|
from flask import request, flash, redirect, url_for, session, render_template
|
||||||
|
from pony.orm import commit, db_session
|
||||||
|
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
|
||||||
from Main import main as ERmain
|
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed
|
||||||
from Generate import handle_name, PlandoSettings
|
from Generate import handle_name, PlandoSettings
|
||||||
import pickle
|
from Main import main as ERmain
|
||||||
|
from Utils import __version__
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
race = meta.setdefault("race", False)
|
race = meta.setdefault("race", False)
|
||||||
|
|
||||||
try:
|
def task():
|
||||||
target = tempfile.TemporaryDirectory()
|
target = tempfile.TemporaryDirectory()
|
||||||
playercount = len(gen_options)
|
playercount = len(gen_options)
|
||||||
seed = get_seed()
|
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"])
|
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
|
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||||
|
thread = thread_pool.submit(task)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return thread.result(app.config["JOB_TIME"])
|
||||||
|
except concurrent.futures.TimeoutError as e:
|
||||||
|
if sid:
|
||||||
|
with db_session:
|
||||||
|
gen = Generation.get(id=sid)
|
||||||
|
if gen is not None:
|
||||||
|
gen.state = STATE_ERROR
|
||||||
|
meta = json.loads(gen.meta)
|
||||||
|
meta["error"] = (
|
||||||
|
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
||||||
|
e.__class__.__name__ + ": " + str(e))
|
||||||
|
gen.meta = json.dumps(meta)
|
||||||
|
commit()
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
|
from pony.orm import count
|
||||||
|
|
||||||
from WebHostLib import app, cache
|
from WebHostLib import app, cache
|
||||||
from .models import *
|
from .models import Room, Seed
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def update_sprites_lttp():
|
|||||||
|
|
||||||
spriteData = []
|
spriteData = []
|
||||||
|
|
||||||
for file in 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))
|
sprite = Sprite(os.path.join(input_dir, file))
|
||||||
|
|
||||||
if not sprite.name:
|
if not sprite.name:
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import os
|
|||||||
|
|
||||||
import jinja2.exceptions
|
import jinja2.exceptions
|
||||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||||
|
from pony.orm import count, commit, db_session
|
||||||
|
|
||||||
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
|
from .models import Seed, Room, Command, UUID, uuid4
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
def get_world_theme(game_name: str):
|
||||||
@@ -151,7 +152,7 @@ def favicon():
|
|||||||
|
|
||||||
@app.route('/discord')
|
@app.route('/discord')
|
||||||
def discord():
|
def discord():
|
||||||
return redirect("https://discord.gg/archipelago")
|
return redirect("https://discord.gg/8Z65BR2")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/datapackage')
|
@app.route('/datapackage')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
from pony.orm import *
|
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||||
|
|
||||||
db = Database()
|
db = Database()
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ class Room(db.Entity):
|
|||||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||||
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||||
tracker = Optional(UUID, index=True)
|
tracker = Optional(UUID, index=True)
|
||||||
|
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
|
||||||
last_port = Optional(int, default=lambda: 0)
|
last_port = Optional(int, default=lambda: 0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from Utils import __version__, local_path
|
|
||||||
from jinja2 import Template
|
|
||||||
import yaml
|
|
||||||
import json
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
import yaml
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
import Options
|
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",
|
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||||
"exclude_locations"}
|
"exclude_locations"}
|
||||||
@@ -15,26 +16,23 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
|
|||||||
|
|
||||||
def create():
|
def create():
|
||||||
target_folder = local_path("WebHostLib", "static", "generated")
|
target_folder = local_path("WebHostLib", "static", "generated")
|
||||||
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]):
|
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||||
data = {}
|
data = {option.default: 50}
|
||||||
special = getattr(option, "special_range_cutoff", None)
|
for sub_option in ["random", "random-low", "random-high"]:
|
||||||
if special is not None:
|
if sub_option != option.default:
|
||||||
data[special] = 0
|
data[sub_option] = 0
|
||||||
data.update({
|
|
||||||
option.range_start: 0,
|
|
||||||
option.range_end: 0,
|
|
||||||
"random": 0, "random-low": 0, "random-high": 0,
|
|
||||||
option.default: 50
|
|
||||||
})
|
|
||||||
notes = {
|
|
||||||
special: "minimum value without special meaning",
|
|
||||||
option.range_start: "minimum value",
|
|
||||||
option.range_end: "maximum value"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
notes = {}
|
||||||
for name, number in getattr(option, "special_range_names", {}).items():
|
for name, number in getattr(option, "special_range_names", {}).items():
|
||||||
|
notes[name] = f"equivalent to {number}"
|
||||||
if number in data:
|
if number in data:
|
||||||
data[name] = data[number]
|
data[name] = data[number]
|
||||||
del data[number]
|
del data[number]
|
||||||
@@ -43,11 +41,6 @@ def create():
|
|||||||
|
|
||||||
return data, notes
|
return data, notes
|
||||||
|
|
||||||
def default_converter(default_value):
|
|
||||||
if isinstance(default_value, (set, frozenset)):
|
|
||||||
return list(default_value)
|
|
||||||
return default_value
|
|
||||||
|
|
||||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||||
if not option_type.__doc__:
|
if not option_type.__doc__:
|
||||||
return "Please document me!"
|
return "Please document me!"
|
||||||
@@ -64,13 +57,16 @@ def create():
|
|||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
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:
|
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
||||||
file_data = f.read()
|
file_data = f.read()
|
||||||
res = Template(file_data).render(
|
res = Template(file_data).render(
|
||||||
options=all_options,
|
options=all_options,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||||
dictify_range=dictify_range, default_converter=default_converter,
|
dictify_range=dictify_range,
|
||||||
)
|
)
|
||||||
|
|
||||||
del file_data
|
del file_data
|
||||||
@@ -110,11 +106,6 @@ def create():
|
|||||||
if sub_option_id == option.default:
|
if sub_option_id == option.default:
|
||||||
this_option["defaultValue"] = sub_option_name
|
this_option["defaultValue"] = sub_option_name
|
||||||
|
|
||||||
this_option["options"].append({
|
|
||||||
"name": "Random",
|
|
||||||
"value": "random",
|
|
||||||
})
|
|
||||||
|
|
||||||
if option.default == "random":
|
if option.default == "random":
|
||||||
this_option["defaultValue"] = "random"
|
this_option["defaultValue"] = "random"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ flask>=2.2.2
|
|||||||
pony>=0.7.16
|
pony>=0.7.16
|
||||||
waitress>=2.1.2
|
waitress>=2.1.2
|
||||||
Flask-Caching>=2.0.1
|
Flask-Caching>=2.0.1
|
||||||
Flask-Compress>=1.12
|
Flask-Compress>=1.13
|
||||||
Flask-Limiter>=2.6.2
|
Flask-Limiter>=2.7.0
|
||||||
bokeh>=2.4.3
|
bokeh>=3.0.0
|
||||||
|
|||||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||||
for (let i=0; i < headers.length; i++){
|
header.setAttribute('id', headerId);
|
||||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
header.addEventListener('click', () => {
|
||||||
headers[i].setAttribute('id', headerId);
|
window.location.hash = `#${headerId}`;
|
||||||
headers[i].addEventListener('click', () =>
|
header.scrollIntoView();
|
||||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
if (scrollTargetIndex > -1) {
|
document.fonts.ready.finally(() => {
|
||||||
try{
|
if (window.location.hash) {
|
||||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
scrollTarget?.scrollIntoView();
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ the website is not required to generate them.
|
|||||||
## How do I get started?
|
## How do I get started?
|
||||||
|
|
||||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||||
our discord server at the [Archipelago Discord](https://discord.gg/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.
|
any questions you might have.
|
||||||
|
|
||||||
## What are some common terms I should know?
|
## What are some common terms I should know?
|
||||||
|
|||||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||||
for (let i=0; i < headers.length; i++){
|
header.setAttribute('id', headerId);
|
||||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
header.addEventListener('click', () => {
|
||||||
headers[i].setAttribute('id', headerId);
|
window.location.hash = `#${headerId}`;
|
||||||
headers[i].addEventListener('click', () =>
|
header.scrollIntoView();
|
||||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
if (scrollTargetIndex > -1) {
|
document.fonts.ready.finally(() => {
|
||||||
try{
|
if (window.location.hash) {
|
||||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
scrollTarget?.scrollIntoView();
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
gameInfo.innerHTML =
|
gameInfo.innerHTML =
|
||||||
|
|||||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||||
for (let i=0; i < headers.length; i++){
|
header.setAttribute('id', headerId);
|
||||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
header.addEventListener('click', () => {
|
||||||
headers[i].setAttribute('id', headerId);
|
window.location.hash = `#${headerId}`;
|
||||||
headers[i].addEventListener('click', () =>
|
header.scrollIntoView();
|
||||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
if (scrollTargetIndex > -1) {
|
document.fonts.ready.finally(() => {
|
||||||
try{
|
if (window.location.hash) {
|
||||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
scrollTarget?.scrollIntoView();
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
const tdr = document.createElement('td');
|
const tdr = document.createElement('td');
|
||||||
let element = null;
|
let element = null;
|
||||||
|
|
||||||
|
const randomButton = document.createElement('button');
|
||||||
|
|
||||||
switch(settings[setting].type){
|
switch(settings[setting].type){
|
||||||
case 'select':
|
case 'select':
|
||||||
element = document.createElement('div');
|
element = document.createElement('div');
|
||||||
@@ -138,8 +140,21 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
}
|
}
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
select.addEventListener('change', (event) => updateGameSetting(event));
|
select.addEventListener('change', (event) => updateGameSetting(event.target));
|
||||||
element.appendChild(select);
|
element.appendChild(select);
|
||||||
|
|
||||||
|
// Randomize button
|
||||||
|
randomButton.innerText = '🎲';
|
||||||
|
randomButton.classList.add('randomize-button');
|
||||||
|
randomButton.setAttribute('data-key', setting);
|
||||||
|
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||||
|
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
|
||||||
|
if (currentSettings[gameName][setting] === 'random') {
|
||||||
|
randomButton.classList.add('active');
|
||||||
|
select.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.appendChild(randomButton);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'range':
|
case 'range':
|
||||||
@@ -154,15 +169,29 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
range.value = currentSettings[gameName][setting];
|
range.value = currentSettings[gameName][setting];
|
||||||
range.addEventListener('change', (event) => {
|
range.addEventListener('change', (event) => {
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event);
|
updateGameSetting(event.target);
|
||||||
});
|
});
|
||||||
element.appendChild(range);
|
element.appendChild(range);
|
||||||
|
|
||||||
let rangeVal = document.createElement('span');
|
let rangeVal = document.createElement('span');
|
||||||
rangeVal.classList.add('range-value');
|
rangeVal.classList.add('range-value');
|
||||||
rangeVal.setAttribute('id', `${setting}-value`);
|
rangeVal.setAttribute('id', `${setting}-value`);
|
||||||
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||||
|
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||||
element.appendChild(rangeVal);
|
element.appendChild(rangeVal);
|
||||||
|
|
||||||
|
// Randomize button
|
||||||
|
randomButton.innerText = '🎲';
|
||||||
|
randomButton.classList.add('randomize-button');
|
||||||
|
randomButton.setAttribute('data-key', setting);
|
||||||
|
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||||
|
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
|
||||||
|
if (currentSettings[gameName][setting] === 'random') {
|
||||||
|
randomButton.classList.add('active');
|
||||||
|
range.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.appendChild(randomButton);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'special_range':
|
case 'special_range':
|
||||||
@@ -201,7 +230,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
let specialRangeVal = document.createElement('span');
|
let specialRangeVal = document.createElement('span');
|
||||||
specialRangeVal.classList.add('range-value');
|
specialRangeVal.classList.add('range-value');
|
||||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||||
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||||
|
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||||
|
|
||||||
// Configure select event listener
|
// Configure select event listener
|
||||||
specialRangeSelect.addEventListener('change', (event) => {
|
specialRangeSelect.addEventListener('change', (event) => {
|
||||||
@@ -210,7 +240,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
// Update range slider
|
// Update range slider
|
||||||
specialRange.value = event.target.value;
|
specialRange.value = event.target.value;
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event);
|
updateGameSetting(event.target);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure range event handler
|
// Configure range event handler
|
||||||
@@ -220,13 +250,29 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||||
parseInt(event.target.value) : 'custom';
|
parseInt(event.target.value) : 'custom';
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event);
|
updateGameSetting(event.target);
|
||||||
});
|
});
|
||||||
|
|
||||||
element.appendChild(specialRangeSelect);
|
element.appendChild(specialRangeSelect);
|
||||||
specialRangeWrapper.appendChild(specialRange);
|
specialRangeWrapper.appendChild(specialRange);
|
||||||
specialRangeWrapper.appendChild(specialRangeVal);
|
specialRangeWrapper.appendChild(specialRangeVal);
|
||||||
element.appendChild(specialRangeWrapper);
|
element.appendChild(specialRangeWrapper);
|
||||||
|
|
||||||
|
// Randomize button
|
||||||
|
randomButton.innerText = '🎲';
|
||||||
|
randomButton.classList.add('randomize-button');
|
||||||
|
randomButton.setAttribute('data-key', setting);
|
||||||
|
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||||
|
randomButton.addEventListener('click', (event) => toggleRandomize(
|
||||||
|
event, [specialRange, specialRangeSelect])
|
||||||
|
);
|
||||||
|
if (currentSettings[gameName][setting] === 'random') {
|
||||||
|
randomButton.classList.add('active');
|
||||||
|
specialRange.disabled = true;
|
||||||
|
specialRangeSelect.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
specialRangeWrapper.appendChild(randomButton);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -243,6 +289,25 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
return table;
|
return table;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleRandomize = (event, inputElements) => {
|
||||||
|
const active = event.target.classList.contains('active');
|
||||||
|
const randomButton = event.target;
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
randomButton.classList.remove('active');
|
||||||
|
for (const element of inputElements) {
|
||||||
|
element.disabled = undefined;
|
||||||
|
updateGameSetting(element);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
randomButton.classList.add('active');
|
||||||
|
for (const element of inputElements) {
|
||||||
|
element.disabled = true;
|
||||||
|
updateGameSetting(randomButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateBaseSetting = (event) => {
|
const updateBaseSetting = (event) => {
|
||||||
const options = JSON.parse(localStorage.getItem(gameName));
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
@@ -250,10 +315,17 @@ const updateBaseSetting = (event) => {
|
|||||||
localStorage.setItem(gameName, JSON.stringify(options));
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateGameSetting = (event) => {
|
const updateGameSetting = (settingElement) => {
|
||||||
const options = JSON.parse(localStorage.getItem(gameName));
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
|
||||||
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));
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,25 +27,28 @@ window.addEventListener('load', () => {
|
|||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
|
const title = document.querySelector('h1')
|
||||||
|
if (title) {
|
||||||
|
document.title = title.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||||
for (let i=0; i < headers.length; i++){
|
header.setAttribute('id', headerId);
|
||||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
header.addEventListener('click', () => {
|
||||||
headers[i].setAttribute('id', headerId);
|
window.location.hash = `#${headerId}`;
|
||||||
headers[i].addEventListener('click', () =>
|
header.scrollIntoView();
|
||||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
if (scrollTargetIndex > -1) {
|
document.fonts.ready.finally(() => {
|
||||||
try{
|
if (window.location.hash) {
|
||||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
scrollTarget?.scrollIntoView();
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -78,13 +78,16 @@ const createDefaultSettings = (settingData) => {
|
|||||||
break;
|
break;
|
||||||
case 'range':
|
case 'range':
|
||||||
case 'special_range':
|
case 'special_range':
|
||||||
for (let i = setting.min; i <= setting.max; ++i){
|
newSettings[game][gameSetting][setting.min] = 0;
|
||||||
newSettings[game][gameSetting][i] =
|
newSettings[game][gameSetting][setting.max] = 0;
|
||||||
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
|
|
||||||
}
|
|
||||||
newSettings[game][gameSetting]['random'] = 0;
|
newSettings[game][gameSetting]['random'] = 0;
|
||||||
newSettings[game][gameSetting]['random-low'] = 0;
|
newSettings[game][gameSetting]['random-low'] = 0;
|
||||||
newSettings[game][gameSetting]['random-high'] = 0;
|
newSettings[game][gameSetting]['random-high'] = 0;
|
||||||
|
if (setting.hasOwnProperty('defaultValue')) {
|
||||||
|
newSettings[game][gameSetting][setting.defaultValue] = 25;
|
||||||
|
} else {
|
||||||
|
newSettings[game][gameSetting][setting.min] = 25;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'items-list':
|
case 'items-list':
|
||||||
@@ -401,11 +404,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
tr.appendChild(tdDelete);
|
tr.appendChild(tdDelete);
|
||||||
|
|
||||||
rangeTbody.appendChild(tr);
|
rangeTbody.appendChild(tr);
|
||||||
|
|
||||||
|
// Save new option to settings
|
||||||
|
range.dispatchEvent(new Event('change'));
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
||||||
if (currentSettings[game][settingName][option] > 0) {
|
// These options are statically generated below, and should always appear even if they are deleted
|
||||||
const tr = document.createElement('tr');
|
// from localStorage
|
||||||
|
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
const tdLeft = document.createElement('td');
|
const tdLeft = document.createElement('td');
|
||||||
tdLeft.classList.add('td-left');
|
tdLeft.classList.add('td-left');
|
||||||
tdLeft.innerText = option;
|
tdLeft.innerText = option;
|
||||||
@@ -439,14 +448,15 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
deleteButton.innerText = '❌';
|
deleteButton.innerText = '❌';
|
||||||
deleteButton.addEventListener('click', () => {
|
deleteButton.addEventListener('click', () => {
|
||||||
range.value = 0;
|
range.value = 0;
|
||||||
range.dispatchEvent(new Event('change'));
|
const changeEvent = new Event('change');
|
||||||
|
changeEvent.action = 'rangeDelete';
|
||||||
|
range.dispatchEvent(changeEvent);
|
||||||
rangeTbody.removeChild(tr);
|
rangeTbody.removeChild(tr);
|
||||||
});
|
});
|
||||||
tdDelete.appendChild(deleteButton);
|
tdDelete.appendChild(deleteButton);
|
||||||
tr.appendChild(tdDelete);
|
tr.appendChild(tdDelete);
|
||||||
|
|
||||||
rangeTbody.appendChild(tr);
|
rangeTbody.appendChild(tr);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,8 +914,12 @@ const updateGameSetting = (evt) => {
|
|||||||
const setting = evt.target.getAttribute('data-setting');
|
const setting = evt.target.getAttribute('data-setting');
|
||||||
const option = evt.target.getAttribute('data-option');
|
const option = evt.target.getAttribute('data-option');
|
||||||
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
||||||
options[game][setting][option] = isNaN(evt.target.value) ?
|
console.log(event);
|
||||||
evt.target.value : parseInt(evt.target.value, 10);
|
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));
|
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,4 +55,6 @@
|
|||||||
border: 1px solid #2a6c2f;
|
border: 1px solid #2a6c2f;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 400px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ html{
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#player-settings table select:disabled{
|
||||||
|
background-color: lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
#player-settings table .range-container{
|
#player-settings table .range-container{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -138,12 +142,27 @@ html{
|
|||||||
#player-settings table .special-range-wrapper{
|
#player-settings table .special-range-wrapper{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-settings table .special-range-wrapper input[type=range]{
|
#player-settings table .special-range-wrapper input[type=range]{
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#player-settings table .randomize-button {
|
||||||
|
max-height: 24px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin: 0 0 0 0.25rem;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .randomize-button.active {
|
||||||
|
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
|
||||||
|
}
|
||||||
|
|
||||||
#player-settings table label{
|
#player-settings table label{
|
||||||
display: block;
|
display: block;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
html{
|
html{
|
||||||
padding-top: 110px;
|
padding-top: 110px;
|
||||||
|
scroll-padding-top: 100px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
#base-header{
|
#base-header{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
|
import typing
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from colorsys import hsv_to_rgb
|
from colorsys import hsv_to_rgb
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from math import tau
|
from math import tau
|
||||||
import typing
|
|
||||||
|
|
||||||
|
from bokeh.colors import RGB
|
||||||
from bokeh.embed import components
|
from bokeh.embed import components
|
||||||
from bokeh.models import HoverTool
|
from bokeh.models import HoverTool
|
||||||
from bokeh.plotting import figure, ColumnDataSource
|
from bokeh.plotting import figure, ColumnDataSource
|
||||||
from bokeh.resources import INLINE
|
from bokeh.resources import INLINE
|
||||||
from bokeh.colors import RGB
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
@@ -18,7 +18,8 @@ from .models import Room
|
|||||||
PLOT_WIDTH = 600
|
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)
|
games_played = defaultdict(Counter)
|
||||||
total_games = Counter()
|
total_games = Counter()
|
||||||
cutoff = date.today()-timedelta(days=30)
|
cutoff = date.today()-timedelta(days=30)
|
||||||
@@ -93,7 +94,7 @@ def stats():
|
|||||||
occurences, legend_label=game, line_width=2, color=game_to_color[game])
|
occurences, legend_label=game, line_width=2, color=game_to_color[game])
|
||||||
|
|
||||||
total = sum(total_games.values())
|
total = sum(total_games.values())
|
||||||
pie = figure(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")],
|
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
||||||
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
||||||
pie.axis.visible = False
|
pie.axis.visible = False
|
||||||
@@ -121,7 +122,8 @@ def stats():
|
|||||||
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
||||||
source=ColumnDataSource(data=data), legend_field="games")
|
source=ColumnDataSource(data=data), legend_field="games")
|
||||||
|
|
||||||
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in 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]
|
if total_games[game] > 1]
|
||||||
|
|
||||||
script, charts = components((plot, pie, *per_game_charts))
|
script, charts = components((plot, pie, *per_game_charts))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
|
||||||
<title>Mystery Check Result</title>
|
<title>Mystery Check Result</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
|
||||||
<title>Generate Game</title>
|
<title>Generate Game</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
|
||||||
<title>Upload Multidata</title>
|
<title>Upload Multidata</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>
|
||||||
|
|||||||
@@ -20,12 +20,16 @@
|
|||||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||||
Should you wish to continue later,
|
Should you wish to continue later,
|
||||||
anyone can simply refresh this page and the server will resume.<br>
|
anyone can simply refresh this page and the server will resume.<br>
|
||||||
{% if room.last_port %}
|
{% 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"
|
You can connect to this room by using <span class="interactive"
|
||||||
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
||||||
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
|
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
|
||||||
</span>
|
</span>
|
||||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
|
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||||
|
{% endif %}
|
||||||
{{ macros.list_patches_room(room) }}
|
{{ macros.list_patches_room(room) }}
|
||||||
{% if room.owner == session["_id"] %}
|
{% if room.owner == session["_id"] %}
|
||||||
<form method=post>
|
<form method=post>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
{% elif patch.game | supports_apdeltapatch %}
|
{% elif patch.game | supports_apdeltapatch %}
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
{% elif patch.game == "Dark Souls III" %}
|
{% 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>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download JSON File...</a>
|
Download JSON File...</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -43,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['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
|
||||||
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
||||||
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
|
||||||
|
<div class="item-count">{{ shard_count }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
|
||||||
|
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
|
||||||
|
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
|
||||||
|
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
|
||||||
|
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table id="location-table">
|
<table id="location-table">
|
||||||
|
|||||||
@@ -1,56 +1,85 @@
|
|||||||
# What is this file?
|
# Q. What is this file?
|
||||||
# This file contains options which allow you to configure your multiworld experience while allowing others
|
# A. This file contains options which allow you to configure your multiworld experience while allowing
|
||||||
# to play how they want as well.
|
# 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:
|
|
||||||
#
|
#
|
||||||
# map_shuffle:
|
# Q. How do I use it?
|
||||||
# on: 5
|
# A. The options in this file are weighted. This means the higher number you assign to a value, the
|
||||||
# off: 15
|
# 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?
|
# Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit.
|
||||||
# This is a .yaml file. You are allowed to use most characters.
|
# {player} will be replaced with the player's slot number.
|
||||||
# To test if your yaml is valid or not, you can use this website:
|
# {PLAYER} will be replaced with the player's slot number, if that slot number is greater than 1.
|
||||||
# http://www.yamllint.com/
|
# {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
|
# 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
|
description: Default {{ game }} Template
|
||||||
name: YourName{number}
|
|
||||||
#{player} will be replaced with the player's slot number.
|
game: {{ game }}
|
||||||
#{PLAYER} will be replaced with the player's slot number if that slot number is greater than 1.
|
|
||||||
#{number} will be replaced with the counter value of the name.
|
|
||||||
#{NUMBER} will be replaced with the counter value of the name if the counter value is greater than 1.
|
|
||||||
game:
|
|
||||||
{{ game }}: 1
|
|
||||||
requires:
|
requires:
|
||||||
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
||||||
# Shared Options supported by all games:
|
|
||||||
|
|
||||||
{%- macro range_option(option) %}
|
{%- macro range_option(option) %}
|
||||||
# you can 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) %}
|
{%- set data, notes = dictify_range(option) %}
|
||||||
{%- for entry, default in data.items() %}
|
{%- for entry, default in data.items() %}
|
||||||
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
|
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{{ game }}:
|
{{ game }}:
|
||||||
{%- for option_key, option in options.items() %}
|
{%- for option_key, option in options.items() %}
|
||||||
{{ option_key }}:{% 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 %}
|
{%- if option.range_start is defined and option.range_start is number %}
|
||||||
{{- range_option(option) -}}
|
{{- range_option(option) -}}
|
||||||
|
|
||||||
{%- elif option.options -%}
|
{%- elif option.options -%}
|
||||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||||
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
{% if option.default == "random" %}
|
|
||||||
random: 50
|
{%- if option.name_lookup[option.default] not in option.options %}
|
||||||
|
{{ option.default }}: 50
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- elif option.default is string %}
|
||||||
|
{{ option.default }}: 50
|
||||||
|
|
||||||
|
{%- elif option.default is iterable and option.default is not mapping %}
|
||||||
|
{{ option.default | list }}
|
||||||
|
|
||||||
{%- else %}
|
{%- else %}
|
||||||
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
|
{{ yaml_dump(option.default) | trim | indent(4, first=false) }}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
{{ "\n" }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{% if not options %}{}{% endif %}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
|
||||||
<title>Start Playing</title>
|
<title>Start Playing</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<td></td>
|
<td></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
|
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
|
||||||
{% if '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>
|
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td></td>
|
<td></td>
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import collections
|
import collections
|
||||||
|
import datetime
|
||||||
import typing
|
import typing
|
||||||
from typing import Counter, Optional, Dict, Any, Tuple
|
from typing import Counter, Optional, Dict, Any, Tuple
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from werkzeug.exceptions import abort
|
from werkzeug.exceptions import abort
|
||||||
import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from 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 MultiServer import Context
|
||||||
from NetUtils import SlotType
|
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 = {
|
alttp_icons = {
|
||||||
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
|
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
|
||||||
@@ -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",
|
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
|
||||||
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
|
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
|
||||||
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
|
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
|
||||||
|
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
|
||||||
|
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
|
||||||
|
"Saddle": "https://i.imgur.com/2QtDyR0.png",
|
||||||
|
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
|
||||||
|
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
|
||||||
|
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
minecraft_location_ids = {
|
minecraft_location_ids = {
|
||||||
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
||||||
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
||||||
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
|
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
|
||||||
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 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],
|
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
|
||||||
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42035, 42016, 42020,
|
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
|
||||||
42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42099, 42100],
|
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100],
|
||||||
"Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028, 42036,
|
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112,
|
||||||
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
|
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
|
||||||
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
|
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
|
||||||
}
|
}
|
||||||
@@ -481,7 +488,8 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
|||||||
# Multi-items
|
# Multi-items
|
||||||
multi_items = {
|
multi_items = {
|
||||||
"3 Ender Pearls": 45029,
|
"3 Ender Pearls": 45029,
|
||||||
"8 Netherite Scrap": 45015
|
"8 Netherite Scrap": 45015,
|
||||||
|
"Dragon Egg Shard": 45043
|
||||||
}
|
}
|
||||||
for item_name, item_id in multi_items.items():
|
for item_name, item_id in multi_items.items():
|
||||||
base_name = item_name.split()[-1].lower()
|
base_name = item_name.split()[-1].lower()
|
||||||
@@ -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:
|
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||||
|
|
||||||
icons = {
|
icons = {
|
||||||
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ETank.png",
|
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png",
|
||||||
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Missile.png",
|
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png",
|
||||||
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Super.png",
|
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png",
|
||||||
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/PowerBomb.png",
|
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png",
|
||||||
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Bomb.png",
|
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png",
|
||||||
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Charge.png",
|
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png",
|
||||||
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Ice.png",
|
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png",
|
||||||
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/HiJump.png",
|
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png",
|
||||||
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpeedBooster.png",
|
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png",
|
||||||
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Wave.png",
|
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png",
|
||||||
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Spazer.png",
|
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png",
|
||||||
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpringBall.png",
|
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png",
|
||||||
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Varia.png",
|
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png",
|
||||||
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Plasma.png",
|
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png",
|
||||||
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Grapple.png",
|
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png",
|
||||||
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Morph.png",
|
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png",
|
||||||
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Reserve.png",
|
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png",
|
||||||
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Gravity.png",
|
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png",
|
||||||
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/XRayScope.png",
|
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png",
|
||||||
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpaceJump.png",
|
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png",
|
||||||
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ScrewAttack.png",
|
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png",
|
||||||
"Nothing": "",
|
"Nothing": "",
|
||||||
"No Energy": "",
|
"No Energy": "",
|
||||||
"Kraid": "",
|
"Kraid": "",
|
||||||
@@ -1037,4 +1045,4 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
|||||||
"Timespinner": __renderTimespinnerTracker,
|
"Timespinner": __renderTimespinnerTracker,
|
||||||
"A Link to the Past": __renderAlttpTracker,
|
"A Link to the Past": __renderAlttpTracker,
|
||||||
"Super Metroid": __renderSuperMetroidTracker
|
"Super Metroid": __renderSuperMetroidTracker
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import typing
|
|
||||||
import zipfile
|
|
||||||
import lzma
|
|
||||||
import json
|
|
||||||
import base64
|
import base64
|
||||||
import MultiServer
|
import json
|
||||||
|
import typing
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, session, render_template
|
from flask import request, flash, redirect, url_for, session, render_template
|
||||||
from pony.orm import flush, select
|
from pony.orm import flush, select
|
||||||
|
|
||||||
from WebHostLib import app, Seed, Room, Slot
|
import MultiServer
|
||||||
from Utils import parse_yaml, VersionException, __version__
|
|
||||||
from Patch import preferred_endings, AutoPatchRegister
|
|
||||||
from NetUtils import NetworkSlot, SlotType
|
from NetUtils import NetworkSlot, SlotType
|
||||||
|
from Utils import VersionException, __version__
|
||||||
|
from worlds.Files import AutoPatchRegister
|
||||||
|
from . import app
|
||||||
|
from .models import Seed, Room, Slot
|
||||||
|
|
||||||
banned_zip_contents = (".sfc",)
|
banned_zip_contents = (".sfc",)
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
|||||||
if not owner:
|
if not owner:
|
||||||
owner = session["_id"]
|
owner = session["_id"]
|
||||||
infolist = zfile.infolist()
|
infolist = zfile.infolist()
|
||||||
slots = set()
|
slots: typing.Set[Slot] = set()
|
||||||
spoiler = ""
|
spoiler = ""
|
||||||
multidata = None
|
multidata = None
|
||||||
for file in infolist:
|
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_name=patch.player_name,
|
||||||
player_id=patch.player,
|
player_id=patch.player,
|
||||||
game=patch.game))
|
game=patch.game))
|
||||||
elif file.filename.endswith(tuple(preferred_endings.values())):
|
|
||||||
data = zfile.open(file, "r").read()
|
|
||||||
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
|
|
||||||
if yaml_data["version"] < 2:
|
|
||||||
return "Old format cannot be uploaded (outdated .apbp)"
|
|
||||||
metadata = yaml_data["meta"]
|
|
||||||
|
|
||||||
slots.add(Slot(data=data,
|
|
||||||
player_name=metadata["player_name"],
|
|
||||||
player_id=metadata["player_id"],
|
|
||||||
game=yaml_data["game"]))
|
|
||||||
|
|
||||||
elif file.filename.endswith(".apmc"):
|
elif file.filename.endswith(".apmc"):
|
||||||
data = zfile.open(file, "r").read()
|
data = zfile.open(file, "r").read()
|
||||||
|
|||||||
495
ZillionClient.py
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>:
|
<UILog>:
|
||||||
viewclass: 'SelectableLabel'
|
viewclass: 'SelectableLabel'
|
||||||
scroll_y: 0
|
scroll_y: 0
|
||||||
|
scroll_type: ["content", "bars"]
|
||||||
|
bar_width: dp(12)
|
||||||
effect_cls: "ScrollEffect"
|
effect_cls: "ScrollEffect"
|
||||||
SelectableRecycleBoxLayout:
|
SelectableRecycleBoxLayout:
|
||||||
default_size: None, dp(20)
|
default_size: None, dp(20)
|
||||||
|
|||||||
@@ -77,12 +77,13 @@ local scrub_sanity_check = function(scene_offset, bit_to_check)
|
|||||||
return scene_check(scene_offset, bit_to_check, 0x10)
|
return scene_check(scene_offset, bit_to_check, 0x10)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Why is there an extra offset of 3 for temp context checks? Who knows.
|
||||||
local cow_check = function(scene_offset, bit_to_check)
|
local cow_check = function(scene_offset, bit_to_check)
|
||||||
return scene_check(scene_offset, bit_to_check, 0xC)
|
return scene_check(scene_offset, bit_to_check, 0xC)
|
||||||
or check_temp_context({scene_offset, 0x00, bit_to_check})
|
or check_temp_context({scene_offset, 0x00, bit_to_check - 0x03})
|
||||||
end
|
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)
|
local great_fairy_magic_check = function(scene_offset, bit_to_check)
|
||||||
return scene_check(scene_offset, bit_to_check, 0x4)
|
return scene_check(scene_offset, bit_to_check, 0x4)
|
||||||
or check_temp_context({scene_offset, 0x05, bit_to_check})
|
or check_temp_context({scene_offset, 0x05, bit_to_check})
|
||||||
@@ -100,6 +101,18 @@ local bean_sale_check = function(scene_offset, bit_to_check)
|
|||||||
or check_temp_context({scene_offset, 0x00, 0x16})
|
or check_temp_context({scene_offset, 0x00, 0x16})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Medigoron reports 0x00620028 to 0x40002C
|
||||||
|
local medigoron_check = function(scene_offset, bit_to_check)
|
||||||
|
return scene_check(scene_offset, bit_to_check, 0xC)
|
||||||
|
or check_temp_context({scene_offset, 0x00, 0x28})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Bombchu salesman reports 0x005E0003 to 0x40002C
|
||||||
|
local salesman_check = function(scene_offset, bit_to_check)
|
||||||
|
return scene_check(scene_offset, bit_to_check, 0xC)
|
||||||
|
or check_temp_context({scene_offset, 0x00, 0x03})
|
||||||
|
end
|
||||||
|
|
||||||
--Helper method to resolve skulltula lookup location
|
--Helper method to resolve skulltula lookup location
|
||||||
local function skulltula_scene_to_array_index(i)
|
local function skulltula_scene_to_array_index(i)
|
||||||
return (i + 3) - 2 * (i % 4)
|
return (i + 3) - 2 * (i % 4)
|
||||||
@@ -575,7 +588,7 @@ local read_death_mountain_trail_checks = function()
|
|||||||
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
|
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
|
||||||
checks["DMT Chest"] = chest_check(0x60, 0x01)
|
checks["DMT Chest"] = chest_check(0x60, 0x01)
|
||||||
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
|
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
|
||||||
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18)
|
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 Biggoron"] = big_goron_sword_check()
|
||||||
checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18)
|
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 Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F)
|
||||||
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
|
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
|
||||||
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
|
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
|
||||||
checks["GC Medigoron"] = 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 Left Chest"] = chest_check(0x62, 0x00)
|
||||||
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
|
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
|
||||||
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
|
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
|
||||||
@@ -614,7 +627,7 @@ local read_death_mountain_crater_checks = function()
|
|||||||
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
|
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
|
||||||
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
|
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
|
||||||
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
|
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
|
||||||
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10)
|
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"] = scrub_sanity_check(0x61, 0x6)
|
||||||
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
|
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
|
||||||
@@ -961,7 +974,7 @@ end
|
|||||||
|
|
||||||
local read_haunted_wasteland_checks = function()
|
local read_haunted_wasteland_checks = function()
|
||||||
local checks = {}
|
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 Chest"] = chest_check(0x5E, 0x00)
|
||||||
checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
|
checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
|
||||||
return checks
|
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
|
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
|
||||||
bsdiff between the original and the randomized ROM.
|
bsdiff between the original and the randomized ROM.
|
||||||
|
|
||||||
To make using APBP easy, they can be generated by inheriting from `Patch.APDeltaPatch`.
|
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`.
|
||||||
|
|
||||||
### Mod files
|
### Mod files
|
||||||
Games which support modding will usually just let you drag and drop the mod’s files into a folder somewhere.
|
Games which support modding will usually just let you drag and drop the mod’s files into a folder somewhere.
|
||||||
@@ -230,7 +230,7 @@ They can either be generic and modify the game using a seed or `slot_data` from
|
|||||||
generated per seed.
|
generated per seed.
|
||||||
|
|
||||||
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
|
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
|
||||||
integration into the Webhost by inheriting from `Patch.APContainer`.
|
integration into the Webhost by inheriting from `worlds.Files.APContainer`.
|
||||||
|
|
||||||
|
|
||||||
## Archipelago Integration
|
## Archipelago Integration
|
||||||
|
|||||||
@@ -23,3 +23,10 @@ No metadata is specified yet.
|
|||||||
## Extra Data
|
## Extra Data
|
||||||
|
|
||||||
The zip can contain arbitrary files in addition what was specified above.
|
The zip can contain arbitrary files in addition what was specified above.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
Imports from other files inside the apworld have to use relative imports.
|
||||||
|
|
||||||
|
Imports from AP base have to use absolute imports, e.g. Options.py and worlds/AutoWorld.py.
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ Contributions are welcome. We have a few requests of any new contributors.
|
|||||||
Otherwise, we tend to judge code on a case to case basis.
|
Otherwise, we tend to judge code on a case to case basis.
|
||||||
|
|
||||||
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
|
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
|
||||||
[the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
|
[the docs folder](/docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
|
||||||
channel in our [Discord](https://archipelago.gg/discord).
|
channel in our [Discord](https://archipelago.gg/discord).
|
||||||
|
|||||||
@@ -21,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). |
|
| | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). |
|
||||||
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
|
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
|
||||||
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
|
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
|
||||||
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | almost-header-only |
|
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | header-only |
|
||||||
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
|
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
|
||||||
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
|
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
|
||||||
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
|
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
|
||||||
|
| Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | |
|
||||||
|
|
||||||
## Synchronizing Items
|
## Synchronizing Items
|
||||||
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
|
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
|
||||||
@@ -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. |
|
| keys | dict\[str\, any] | A key-value collection containing all the values for the keys requested in the [Get](#Get) package. |
|
||||||
|
|
||||||
|
If a requested key was not present in the server's data, the associated value will be `null`.
|
||||||
|
|
||||||
Additional arguments added to the [Get](#Get) package that triggered this [Retrieved](#Retrieved) will also be passed along.
|
Additional arguments added to the [Get](#Get) package that triggered this [Retrieved](#Retrieved) will also be passed along.
|
||||||
|
|
||||||
### SetReply
|
### SetReply
|
||||||
@@ -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. |
|
| key | str | The key to manipulate. |
|
||||||
| default | any | The default value to use in case the key has no value on the server. |
|
| default | any | The default value to use in case the key has no value on the server. |
|
||||||
| want_reply | bool | If 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. |
|
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
|
||||||
|
|
||||||
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
|
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
|
||||||
|
|||||||
@@ -16,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.
|
required modules and after pressing enter proceed to install everything automatically.
|
||||||
After this, you should be able to run the programs.
|
After this, you should be able to run the programs.
|
||||||
|
|
||||||
|
* With yaml(s) in the `Players` folder, `Generate.py` will generate the multiworld archive.
|
||||||
|
* `MultiServer.py`, with the filename of the generated archive as a command line parameter, will host the multiworld locally.
|
||||||
|
* `--log_network` is a command line parameter useful for debugging.
|
||||||
|
* `WebHost.py` will host the website on your computer.
|
||||||
|
* You can copy `docs/webhost configuration sample.yaml` to `config.yaml`
|
||||||
|
to change WebHost options (like the web hosting port number).
|
||||||
|
* As a side effect, `WebHost.py` creates the template yamls for all the games in `WebHostLib/static/generated`.
|
||||||
|
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
* Strings in worlds should use double quotes as well, but imported code may differ.
|
* Strings in worlds should use double quotes as well, but imported code may differ.
|
||||||
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
||||||
use single quotes inside them: `f"Like {dct['key']}"`
|
use single quotes inside them: `f"Like {dct['key']}"`
|
||||||
* Use type 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
|
## Markdown
|
||||||
|
|||||||
@@ -102,13 +102,18 @@ Locations are places where items can be located in your game. This may be chests
|
|||||||
or boss drops for RPG-like games but could also be progress in a research tree.
|
or boss drops for RPG-like games but could also be progress in a research tree.
|
||||||
|
|
||||||
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
|
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
|
||||||
in a Region and has access rules.
|
in a Region, has access rules and a classification.
|
||||||
The name needs to be unique in each game, the ID needs to be unique across all
|
The name needs to be unique in each game and must not be numeric (has to
|
||||||
games and is best in the same range as the item IDs.
|
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.
|
||||||
World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
|
World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
|
||||||
|
|
||||||
Special locations with ID `None` can hold events.
|
Special locations with ID `None` can hold events.
|
||||||
|
|
||||||
|
Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`.
|
||||||
|
The Fill algorithm will fill priority first, giving higher chance of it being
|
||||||
|
required, and not place progression or useful items in excluded locations.
|
||||||
|
|
||||||
### Items
|
### Items
|
||||||
|
|
||||||
Items are all things that can "drop" for your game. This may be RPG items like
|
Items are all things that can "drop" for your game. This may be RPG items like
|
||||||
@@ -121,6 +126,9 @@ their world. Progression items will be assigned to locations with higher
|
|||||||
priority and moved around to meet defined rules and accomplish progression
|
priority and moved around to meet defined rules and accomplish progression
|
||||||
balancing.
|
balancing.
|
||||||
|
|
||||||
|
The name needs to be unique in each game, meaning a duplicate item has the
|
||||||
|
same ID. Name must not be numeric (has to contain at least 1 letter or symbol).
|
||||||
|
|
||||||
Special items with ID `None` can mark events (read below).
|
Special items with ID `None` can mark events (read below).
|
||||||
|
|
||||||
Other classifications include
|
Other classifications include
|
||||||
@@ -188,15 +196,17 @@ the `/worlds` directory. The starting point for the package is `__init.py__`.
|
|||||||
Conventionally, your world class is placed in that file.
|
Conventionally, your world class is placed in that file.
|
||||||
|
|
||||||
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
|
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
|
||||||
which can be imported as `..AutoWorld.World` from your package.
|
which can be imported as `worlds.AutoWorld.World` from your package.
|
||||||
|
|
||||||
AP will pick up your world automatically due to the `AutoWorld` implementation.
|
AP will pick up your world automatically due to the `AutoWorld` implementation.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
If your world needs specific python packages, they can be listed in
|
If your world needs specific python packages, they can be listed in
|
||||||
`world/[world_name]/requirements.txt`.
|
`world/[world_name]/requirements.txt`. ModuleUpdate.py will automatically
|
||||||
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format)
|
pick up and install them.
|
||||||
|
|
||||||
|
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format).
|
||||||
|
|
||||||
### Relative Imports
|
### Relative Imports
|
||||||
|
|
||||||
@@ -209,6 +219,10 @@ e.g. `from .Options import mygame_options` from your `__init__.py` will load
|
|||||||
When imported names pile up it may be easier to use `from . import Options`
|
When imported names pile up it may be easier to use `from . import Options`
|
||||||
and access the variable as `Options.mygame_options`.
|
and access the variable as `Options.mygame_options`.
|
||||||
|
|
||||||
|
Imports from directories outside your world should use absolute imports.
|
||||||
|
Correct use of relative / absolute imports is required for zipped worlds to
|
||||||
|
function, see [apworld specification.md](apworld%20specification.md).
|
||||||
|
|
||||||
### Your Item Type
|
### Your Item Type
|
||||||
|
|
||||||
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
|
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
|
||||||
@@ -274,14 +288,12 @@ Define a property `option_<name> = <number>` per selectable value and
|
|||||||
`default = <number>` to set the default selection. Aliases can be set by
|
`default = <number>` to set the default selection. Aliases can be set by
|
||||||
defining a property `alias_<name> = <same number>`.
|
defining a property `alias_<name> = <same number>`.
|
||||||
|
|
||||||
One special case where aliases are required is when option name is `yes`, `no`,
|
|
||||||
`on` or `off` because they parse to `True` or `False`:
|
|
||||||
```python
|
```python
|
||||||
option_off = 0
|
option_off = 0
|
||||||
option_on = 1
|
option_on = 1
|
||||||
option_some = 2
|
option_some = 2
|
||||||
alias_false = 0
|
alias_disabled = 0
|
||||||
alias_true = 1
|
alias_enabled = 1
|
||||||
default = 0
|
default = 0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -323,7 +335,7 @@ mygame_options: typing.Dict[str, type(Option)] = {
|
|||||||
```python
|
```python
|
||||||
# __init__.py
|
# __init__.py
|
||||||
|
|
||||||
from ..AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
from .Options import mygame_options # import the options dict
|
from .Options import mygame_options # import the options dict
|
||||||
|
|
||||||
class MyGameWorld(World):
|
class MyGameWorld(World):
|
||||||
@@ -352,7 +364,7 @@ more natural. These games typically have been edited to 'bake in' the items.
|
|||||||
from .Options import mygame_options # the options we defined earlier
|
from .Options import mygame_options # the options we defined earlier
|
||||||
from .Items import mygame_items # data used below to add items to the World
|
from .Items import mygame_items # data used below to add items to the World
|
||||||
from .Locations import mygame_locations # same as above
|
from .Locations import mygame_locations # same as above
|
||||||
from ..AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
|
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
|
||||||
from Utils import get_options, output_path
|
from Utils import get_options, output_path
|
||||||
|
|
||||||
@@ -447,7 +459,7 @@ In addition, the following methods can be implemented and attributes can be set
|
|||||||
```python
|
```python
|
||||||
def generate_early(self) -> None:
|
def generate_early(self) -> None:
|
||||||
# read player settings to world instance
|
# read player settings to world instance
|
||||||
self.final_boss_hp = self.world.final_boss_hp[self.player].value
|
self.final_boss_hp = self.multiworld.final_boss_hp[self.player].value
|
||||||
```
|
```
|
||||||
|
|
||||||
#### create_item
|
#### create_item
|
||||||
@@ -482,19 +494,19 @@ def create_items(self) -> None:
|
|||||||
# If an item can't have duplicates it has to be excluded manually.
|
# If an item can't have duplicates it has to be excluded manually.
|
||||||
|
|
||||||
# List of items to exclude, as a copy since it will be destroyed below
|
# List of items to exclude, as a copy since it will be destroyed below
|
||||||
exclude = [item for item in self.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):
|
for item in map(self.create_item, mygame_items):
|
||||||
if item in exclude:
|
if item in exclude:
|
||||||
exclude.remove(item) # this is destructive. create unique list above
|
exclude.remove(item) # this is destructive. create unique list above
|
||||||
self.world.itempool.append(self.create_item("nothing"))
|
self.multiworld.itempool.append(self.create_item("nothing"))
|
||||||
else:
|
else:
|
||||||
self.world.itempool.append(item)
|
self.multiworld.itempool.append(item)
|
||||||
|
|
||||||
# itempool and number of locations should match up.
|
# itempool and number of locations should match up.
|
||||||
# If this is not the case we want to fill the itempool with junk.
|
# If this is not the case we want to fill the itempool with junk.
|
||||||
junk = 0 # calculate this based on player settings
|
junk = 0 # calculate this based on player settings
|
||||||
self.world.itempool += [self.create_item("nothing") for _ in range(junk)]
|
self.multiworld.itempool += [self.create_item("nothing") for _ in range(junk)]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### create_regions
|
#### create_regions
|
||||||
@@ -503,30 +515,30 @@ def create_items(self) -> None:
|
|||||||
def create_regions(self) -> None:
|
def create_regions(self) -> None:
|
||||||
# Add regions to the multiworld. "Menu" is the required starting point.
|
# Add regions to the multiworld. "Menu" is the required starting point.
|
||||||
# Arguments to Region() are name, type, human_readable_name, player, world
|
# Arguments to Region() are name, type, human_readable_name, player, world
|
||||||
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.world)
|
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.multiworld)
|
||||||
# Set Region.exits to a list of entrances that are reachable from region
|
# Set Region.exits to a list of entrances that are reachable from region
|
||||||
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
|
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
|
||||||
# Append region to MultiWorld's regions
|
# Append region to MultiWorld's regions
|
||||||
self.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)
|
# Add main area's locations to main area (all but final boss)
|
||||||
r.locations = [MyGameLocation(self.player, location.name,
|
r.locations = [MyGameLocation(self.player, location.name,
|
||||||
self.location_name_to_id[location.name], r)]
|
self.location_name_to_id[location.name], r)]
|
||||||
r.exits = [Entrance(self.player, "Boss Door", r)]
|
r.exits = [Entrance(self.player, "Boss Door", r)]
|
||||||
self.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
|
# add event to Boss Room
|
||||||
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
|
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
|
# If entrances are not randomized, they should be connected here, otherwise
|
||||||
# they can also be connected at a later stage.
|
# they can also be connected at a later stage.
|
||||||
self.world.get_entrance("New Game", self.player)\
|
self.multiworld.get_entrance("New Game", self.player)
|
||||||
.connect(self.world.get_region("Main Area", self.player))
|
.connect(self.multiworld.get_region("Main Area", self.player))
|
||||||
self.world.get_entrance("Boss Door", self.player)\
|
self.multiworld.get_entrance("Boss Door", self.player)
|
||||||
.connect(self.world.get_region("Boss Room", self.player))
|
.connect(self.multiworld.get_region("Boss Room", self.player))
|
||||||
|
|
||||||
# If setting location access rules from data is easier here, set_rules can
|
# If setting location access rules from data is easier here, set_rules can
|
||||||
# possibly omitted.
|
# possibly omitted.
|
||||||
@@ -537,14 +549,14 @@ def create_regions(self) -> None:
|
|||||||
```python
|
```python
|
||||||
def generate_basic(self) -> None:
|
def generate_basic(self) -> None:
|
||||||
# place "Victory" at "Final Boss" and set collection as win condition
|
# place "Victory" at "Final Boss" and set collection as win condition
|
||||||
self.world.get_location("Final Boss", self.player)\
|
self.multiworld.get_location("Final Boss", self.player)
|
||||||
.place_locked_item(self.create_event("Victory"))
|
.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)
|
lambda state: state.has("Victory", self.player)
|
||||||
|
|
||||||
# place item Herb into location Chest1 for some reason
|
# place item Herb into location Chest1 for some reason
|
||||||
item = self.create_item("Herb")
|
item = self.create_item("Herb")
|
||||||
self.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
|
# in most cases it's better to do this at the same time the itempool is
|
||||||
# filled to avoid accidental duplicates:
|
# filled to avoid accidental duplicates:
|
||||||
# manually placed and still in the itempool
|
# manually placed and still in the itempool
|
||||||
@@ -553,44 +565,45 @@ def generate_basic(self) -> None:
|
|||||||
### Setting Rules
|
### Setting Rules
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from ..generic.Rules import add_rule, set_rule, forbid_item
|
from worlds.generic.Rules import add_rule, set_rule, forbid_item
|
||||||
from Items import get_item_type
|
from Items import get_item_type
|
||||||
|
|
||||||
|
|
||||||
def set_rules(self) -> None:
|
def set_rules(self) -> None:
|
||||||
# For some worlds this step can be omitted if either a Logic mixin
|
# For some worlds this step can be omitted if either a Logic mixin
|
||||||
# (see below) is used, it's easier to apply the rules from data during
|
# (see below) is used, it's easier to apply the rules from data during
|
||||||
# location generation or everything is in generate_basic
|
# location generation or everything is in generate_basic
|
||||||
|
|
||||||
# set a simple rule for an region
|
# set a simple rule for an region
|
||||||
set_rule(self.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))
|
lambda state: state.has("Boss Key", self.player))
|
||||||
# combine rules to require two items
|
# 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))
|
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))
|
lambda state: state.has("Shield", self.player))
|
||||||
# or simply combine yourself
|
# 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
|
lambda state: state.has("Sword", self.player) and
|
||||||
state.has("Shield", self.player))
|
state.has("Shield", self.player))
|
||||||
# require two of an item
|
# require two of an item
|
||||||
set_rule(self.world.get_location("Chest3", self.player),
|
set_rule(self.multiworld.get_location("Chest3", self.player),
|
||||||
lambda state: state.has("Key", self.player, 2))
|
lambda state: state.has("Key", self.player, 2))
|
||||||
# require one item from an item group
|
# require one item from an item group
|
||||||
add_rule(self.world.get_location("Chest3", self.player),
|
add_rule(self.multiworld.get_location("Chest3", self.player),
|
||||||
lambda state: state.has_group("weapons", self.player))
|
lambda state: state.has_group("weapons", self.player))
|
||||||
# state also has .item_count() for items, .has_any() and.has_all() for sets
|
# state also has .item_count() for items, .has_any() and.has_all() for sets
|
||||||
# and .count_group() for groups
|
# and .count_group() for groups
|
||||||
# set_rule is likely to be a bit faster than add_rule
|
# set_rule is likely to be a bit faster than add_rule
|
||||||
|
|
||||||
# disallow placing a specific local item at a specific location
|
# disallow placing a specific local item at a specific location
|
||||||
forbid_item(self.world.get_location("Chest4", self.player), "Sword")
|
forbid_item(self.multiworld.get_location("Chest4", self.player), "Sword")
|
||||||
# disallow placing items with a specific property
|
# 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")
|
lambda item: get_item_type(item) == "weapon")
|
||||||
# get_item_type needs to take player/world into account
|
# get_item_type needs to take player/world into account
|
||||||
# if MyGameItem has a type property, a more direct implementation would be
|
# if MyGameItem has a type property, a more direct implementation would be
|
||||||
add_item_rule(self.world.get_location("Chest5", self.player),
|
add_item_rule(self.multiworld.get_location("Chest5", self.player),
|
||||||
lambda item: item.player != self.player or\
|
lambda item: item.player != self.player or\
|
||||||
item.my_type == "weapon")
|
item.my_type == "weapon")
|
||||||
# location.item_rule = ... is likely to be a bit faster
|
# location.item_rule = ... is likely to be a bit faster
|
||||||
@@ -603,14 +616,16 @@ implement more complex logic in logic mixins, even if there is no need to add
|
|||||||
properties to the `BaseClasses.CollectionState` state object.
|
properties to the `BaseClasses.CollectionState` state object.
|
||||||
|
|
||||||
When importing a file that defines a class that inherits from
|
When importing a file that defines a class that inherits from
|
||||||
`..AutoWorld.LogicMixin` the state object's class is automatically extended by
|
`worlds.AutoWorld.LogicMixin` the state object's class is automatically extended by
|
||||||
the mixin's members. These members should be prefixed with underscore following
|
the mixin's members. These members should be prefixed with underscore following
|
||||||
the name of the implementing world. This is due to sharing a namespace with all
|
the name of the implementing world. This is due to sharing a namespace with all
|
||||||
other logic mixins.
|
other logic mixins.
|
||||||
|
|
||||||
Typical uses are defining methods that are used instead of `state.has`
|
Typical uses are defining methods that are used instead of `state.has`
|
||||||
in lambdas, e.g.`state._mygame_has(custom, world, player)` or recurring checks
|
in lambdas, e.g.`state.mygame_has(custom, player)` or recurring checks
|
||||||
like `state._mygame_can_do_something(world, player)` to simplify lambdas.
|
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,
|
More advanced uses could be to add additional variables to the state object,
|
||||||
override `World.collect(self, state, item)` and `remove(self, state, item)`
|
override `World.collect(self, state, item)` and `remove(self, state, item)`
|
||||||
@@ -622,25 +637,26 @@ Please do this with caution and only when neccessary.
|
|||||||
```python
|
```python
|
||||||
# Logic.py
|
# Logic.py
|
||||||
|
|
||||||
from ..AutoWorld import LogicMixin
|
from worlds.AutoWorld import LogicMixin
|
||||||
|
|
||||||
class MyGameLogic(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
|
# 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
|
return self.has("key", player) # or whatever
|
||||||
```
|
```
|
||||||
```python
|
```python
|
||||||
# __init__.py
|
# __init__.py
|
||||||
|
|
||||||
from ..generic.Rules import set_rule
|
from worlds.generic.Rules import set_rule
|
||||||
import .Logic # apply the mixin by importing its file
|
import .Logic # apply the mixin by importing its file
|
||||||
|
|
||||||
class MyGameWorld(World):
|
class MyGameWorld(World):
|
||||||
# ...
|
# ...
|
||||||
def set_rules(self):
|
def set_rules(self):
|
||||||
set_rule(self.world.get_location("A Door", self.player),
|
set_rule(self.world.get_location("A Door", self.player),
|
||||||
lamda state: state._mygame_has_key(self.world, self.player))
|
lamda state: state.mygame_has_key(self.player))
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generate Output
|
### Generate Output
|
||||||
@@ -648,32 +664,33 @@ class MyGameWorld(World):
|
|||||||
```python
|
```python
|
||||||
from .Mod import generate_mod
|
from .Mod import generate_mod
|
||||||
|
|
||||||
|
|
||||||
def generate_output(self, output_directory: str):
|
def generate_output(self, output_directory: str):
|
||||||
# How to generate the mod or ROM highly depends on the game
|
# How to generate the mod or ROM highly depends on the game
|
||||||
# if the mod is written in Lua, Jinja can be used to fill a template
|
# if the mod is written in Lua, Jinja can be used to fill a template
|
||||||
# if the mod reads a json file, `json.dump()` can be used to generate that
|
# if the mod reads a json file, `json.dump()` can be used to generate that
|
||||||
# code below is a dummy
|
# code below is a dummy
|
||||||
data = {
|
data = {
|
||||||
"seed": self.world.seed_name, # to verify the server's multiworld
|
"seed": self.multiworld.seed_name, # to verify the server's multiworld
|
||||||
"slot": self.world.player_name[self.player], # to connect to server
|
"slot": self.multiworld.player_name[self.player], # to connect to server
|
||||||
"items": {location.name: location.item.name
|
"items": {location.name: location.item.name
|
||||||
if location.item.player == self.player else "Remote"
|
if location.item.player == self.player else "Remote"
|
||||||
for location in self.world.get_filled_locations(self.player)},
|
for location in self.multiworld.get_filled_locations(self.player)},
|
||||||
# store start_inventory from player's .yaml
|
# store start_inventory from player's .yaml
|
||||||
"starter_items": [item.name for item
|
"starter_items": [item.name for item
|
||||||
in self.world.precollected_items[self.player]],
|
in self.multiworld.precollected_items[self.player]],
|
||||||
"final_boss_hp": self.final_boss_hp,
|
"final_boss_hp": self.final_boss_hp,
|
||||||
# store option name "easy", "normal" or "hard" for difficuly
|
# store option name "easy", "normal" or "hard" for difficuly
|
||||||
"difficulty": self.world.difficulty[self.player].current_key,
|
"difficulty": self.multiworld.difficulty[self.player].current_key,
|
||||||
# store option value True or False for fixing a glitch
|
# store option value True or False for fixing a glitch
|
||||||
"fix_xyz_glitch": self.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
|
# point to a ROM specified by the installation
|
||||||
src = Utils.get_options()["mygame_options"]["rom_file"]
|
src = Utils.get_options()["mygame_options"]["rom_file"]
|
||||||
# or point to worlds/mygame/data/mod_template
|
# or point to worlds/mygame/data/mod_template
|
||||||
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
|
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
|
||||||
# generate output path
|
# generate output path
|
||||||
mod_name = f"AP-{self.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")
|
out_file = os.path.join(output_directory, mod_name + ".zip")
|
||||||
# generate the file
|
# generate the file
|
||||||
generate_mod(src, out_file, data)
|
generate_mod(src, out_file, data)
|
||||||
|
|||||||
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"
|
# List of options that can be plando'd. Can be combined, for example "bosses, items"
|
||||||
# Available options: bosses, items, texts, connections
|
# Available options: bosses, items, texts, connections
|
||||||
plando_options: "bosses"
|
plando_options: "bosses"
|
||||||
|
sni_options:
|
||||||
|
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||||
|
sni_path: "SNI"
|
||||||
|
# Set this to false to never autostart a rom (such as after patching)
|
||||||
|
# True for operating system default program
|
||||||
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
|
snes_rom_start: true
|
||||||
lttp_options:
|
lttp_options:
|
||||||
# File name of the v1.0 J rom
|
# File name of the v1.0 J rom
|
||||||
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||||
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
|
||||||
sni: "SNI"
|
|
||||||
# Set this to false to never autostart a rom (such as after patching)
|
|
||||||
# True for operating system default program
|
|
||||||
# Alternatively, a path to a program to open the .sfc file with
|
|
||||||
rom_start: true
|
|
||||||
sm_options:
|
sm_options:
|
||||||
# File name of the v1.0 J rom
|
# File name of the v1.0 J rom
|
||||||
rom_file: "Super Metroid (JU).sfc"
|
rom_file: "Super Metroid (JU).sfc"
|
||||||
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
|
||||||
sni: "SNI"
|
|
||||||
# Set this to false to never autostart a rom (such as after patching)
|
|
||||||
# True for operating system default program
|
|
||||||
# Alternatively, a path to a program to open the .sfc file with
|
|
||||||
rom_start: true
|
|
||||||
factorio_options:
|
factorio_options:
|
||||||
executable: "factorio/bin/x64/factorio"
|
executable: "factorio/bin/x64/factorio"
|
||||||
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
|
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
|
||||||
# server_settings: "factorio\\data\\server-settings.json"
|
# server_settings: "factorio\\data\\server-settings.json"
|
||||||
|
# Whether to filter item send messages displayed in-game to only those that involve you.
|
||||||
|
filter_item_sends: false
|
||||||
|
# Whether to send chat messages from players on the Factorio server to Archipelago.
|
||||||
|
bridge_chat_out: true
|
||||||
minecraft_options:
|
minecraft_options:
|
||||||
forge_directory: "Minecraft Forge server"
|
forge_directory: "Minecraft Forge server"
|
||||||
max_heap_size: "2G"
|
max_heap_size: "2G"
|
||||||
@@ -122,19 +121,26 @@ soe_options:
|
|||||||
rom_file: "Secret of Evermore (USA).sfc"
|
rom_file: "Secret of Evermore (USA).sfc"
|
||||||
ffr_options:
|
ffr_options:
|
||||||
display_msgs: true
|
display_msgs: true
|
||||||
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:
|
dkc3_options:
|
||||||
# File name of the DKC3 US rom
|
# File name of the DKC3 US rom
|
||||||
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
||||||
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
smw_options:
|
||||||
sni: "SNI"
|
# File name of the SMW US rom
|
||||||
|
rom_file: "Super Mario World (USA).sfc"
|
||||||
|
pokemon_rb_options:
|
||||||
|
# File names of the Pokemon Red and Blue roms
|
||||||
|
red_rom_file: "Pokemon Red (UE) [S][!].gb"
|
||||||
|
blue_rom_file: "Pokemon Blue (UE) [S][!].gb"
|
||||||
|
# Set this to false to never autostart a rom (such as after patching)
|
||||||
|
# True for operating system default program
|
||||||
|
# Alternatively, a path to a program to open the .gb file with
|
||||||
|
rom_start: true
|
||||||
|
zillion_options:
|
||||||
|
# File name of the Zillion US rom
|
||||||
|
rom_file: "Zillion (UE) [!].sms"
|
||||||
# Set this to false to never autostart a rom (such as after patching)
|
# Set this to false to never autostart a rom (such as after patching)
|
||||||
# True for operating system default program
|
# True for operating system default program
|
||||||
# Alternatively, a path to a program to open the .sfc file with
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
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"
|
||||||
|
|||||||
206
inno_setup.iss
206
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"; Description: "Generator"; Types: full hosting
|
||||||
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
|
Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
||||||
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
||||||
|
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
|
||||||
|
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
|
||||||
|
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
|
||||||
Name: "server"; Description: "Server"; Types: full hosting
|
Name: "server"; Description: "Server"; Types: full hosting
|
||||||
Name: "client"; Description: "Clients"; Types: full playing
|
Name: "client"; Description: "Clients"; Types: full playing
|
||||||
Name: "client/sni"; Description: "SNI Client"; Types: full playing
|
Name: "client/sni"; Description: "SNI Client"; Types: full playing
|
||||||
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
|
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
||||||
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||||
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
||||||
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
|
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
|
||||||
|
Name: "client/pkmn"; Description: "Pokemon Client"
|
||||||
|
Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
|
||||||
|
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
|
||||||
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
|
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
|
||||||
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
|
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
|
||||||
|
Name: "client/zl"; Description: "Zillion"; Types: full playing
|
||||||
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
|
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
|
||||||
|
|
||||||
[Dirs]
|
[Dirs]
|
||||||
@@ -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:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
|
||||||
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
|
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
|
||||||
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
|
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
|
||||||
|
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
|
||||||
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
||||||
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
||||||
|
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
|
||||||
|
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
|
||||||
|
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
|
||||||
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
|
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
|
||||||
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
|
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
|
||||||
@@ -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}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
|
||||||
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
||||||
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
||||||
|
Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl
|
||||||
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
|
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
|
||||||
|
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
|
||||||
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
|
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
|
||||||
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
|
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
|
||||||
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
|
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
|
||||||
@@ -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} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
|
||||||
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
|
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
|
||||||
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
|
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
|
||||||
|
Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl
|
||||||
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
|
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
|
||||||
|
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
|
||||||
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
|
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
|
||||||
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
|
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
|
||||||
|
|
||||||
@@ -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} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
|
||||||
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
|
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
|
||||||
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
|
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
|
||||||
|
Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl
|
||||||
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
|
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
|
||||||
|
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
|
||||||
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
|
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
|
||||||
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
|
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
|
||||||
|
|
||||||
@@ -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\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
|
||||||
|
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
|
||||||
|
Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
@@ -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\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
|
||||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
|
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
|
||||||
|
|
||||||
|
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||||
|
|
||||||
|
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
|
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
|
||||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
|
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
|
||||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
|
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
|
||||||
@@ -196,7 +235,7 @@ begin
|
|||||||
begin
|
begin
|
||||||
// Is the installed version at least the packaged one ?
|
// Is the installed version at least the packaged one ?
|
||||||
Log('VC Redist x64 Version : found ' + strVersion);
|
Log('VC Redist x64 Version : found ' + strVersion);
|
||||||
Result := (CompareStr(strVersion, 'v14.29.30037') < 0);
|
Result := (CompareStr(strVersion, 'v14.32.31332') < 0);
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
begin
|
begin
|
||||||
@@ -217,12 +256,24 @@ var SMRomFilePage: TInputFileWizardPage;
|
|||||||
var dkc3rom: string;
|
var dkc3rom: string;
|
||||||
var DKC3RomFilePage: TInputFileWizardPage;
|
var DKC3RomFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
|
var smwrom: string;
|
||||||
|
var SMWRomFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
var soerom: string;
|
var soerom: string;
|
||||||
var SoERomFilePage: TInputFileWizardPage;
|
var SoERomFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
var ootrom: string;
|
var ootrom: string;
|
||||||
var OoTROMFilePage: TInputFileWizardPage;
|
var OoTROMFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
|
var zlrom: string;
|
||||||
|
var ZlROMFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
|
var redrom: string;
|
||||||
|
var RedROMFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
|
var bluerom: string;
|
||||||
|
var BlueROMFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
function GetSNESMD5OfFile(const rom: string): string;
|
function GetSNESMD5OfFile(const rom: string): string;
|
||||||
var data: AnsiString;
|
var data: AnsiString;
|
||||||
begin
|
begin
|
||||||
@@ -236,6 +287,15 @@ begin
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
function GetSMSMD5OfFile(const rom: string): string;
|
||||||
|
var data: AnsiString;
|
||||||
|
begin
|
||||||
|
if LoadStringFromFile(rom, data) then
|
||||||
|
begin
|
||||||
|
Result := GetMD5OfString(data);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
function CheckRom(name: string; hash: string): string;
|
function CheckRom(name: string; hash: string): string;
|
||||||
var rom: string;
|
var rom: string;
|
||||||
begin
|
begin
|
||||||
@@ -255,6 +315,25 @@ begin
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
function CheckSMSRom(name: string; hash: string): string;
|
||||||
|
var rom: string;
|
||||||
|
begin
|
||||||
|
log('Handling ' + name)
|
||||||
|
rom := FileSearch(name, WizardDirValue());
|
||||||
|
if Length(rom) > 0 then
|
||||||
|
begin
|
||||||
|
log('existing ROM found');
|
||||||
|
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
|
||||||
|
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
|
||||||
|
begin
|
||||||
|
log('existing ROM verified');
|
||||||
|
Result := rom;
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
log('existing ROM failed verification');
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
function AddRomPage(name: string): TInputFileWizardPage;
|
function AddRomPage(name: string): TInputFileWizardPage;
|
||||||
begin
|
begin
|
||||||
Result :=
|
Result :=
|
||||||
@@ -270,6 +349,37 @@ begin
|
|||||||
'.sfc');
|
'.sfc');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|
||||||
|
function AddGBRomPage(name: string): TInputFileWizardPage;
|
||||||
|
begin
|
||||||
|
Result :=
|
||||||
|
CreateInputFilePage(
|
||||||
|
wpSelectComponents,
|
||||||
|
'Select ROM File',
|
||||||
|
'Where is your ' + name + ' located?',
|
||||||
|
'Select the file, then click Next.');
|
||||||
|
|
||||||
|
Result.Add(
|
||||||
|
'Location of ROM file:',
|
||||||
|
'GB ROM files|*.gb;*.gbc|All files|*.*',
|
||||||
|
'.gb');
|
||||||
|
end;
|
||||||
|
|
||||||
|
function AddSMSRomPage(name: string): TInputFileWizardPage;
|
||||||
|
begin
|
||||||
|
Result :=
|
||||||
|
CreateInputFilePage(
|
||||||
|
wpSelectComponents,
|
||||||
|
'Select ROM File',
|
||||||
|
'Where is your ' + name + ' located?',
|
||||||
|
'Select the file, then click Next.');
|
||||||
|
|
||||||
|
Result.Add(
|
||||||
|
'Location of ROM file:',
|
||||||
|
'SMS ROM files|*.sms|All files|*.*',
|
||||||
|
'.sms');
|
||||||
|
end;
|
||||||
|
|
||||||
procedure AddOoTRomPage();
|
procedure AddOoTRomPage();
|
||||||
begin
|
begin
|
||||||
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
|
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
|
||||||
@@ -308,10 +418,14 @@ begin
|
|||||||
Result := not (SMROMFilePage.Values[0] = '')
|
Result := not (SMROMFilePage.Values[0] = '')
|
||||||
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
|
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
|
||||||
Result := not (DKC3ROMFilePage.Values[0] = '')
|
Result := not (DKC3ROMFilePage.Values[0] = '')
|
||||||
|
else if (assigned(SMWROMFilePage)) and (CurPageID = SMWROMFilePage.ID) then
|
||||||
|
Result := not (SMWROMFilePage.Values[0] = '')
|
||||||
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
||||||
Result := not (SoEROMFilePage.Values[0] = '')
|
Result := not (SoEROMFilePage.Values[0] = '')
|
||||||
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
||||||
Result := not (OoTROMFilePage.Values[0] = '')
|
Result := not (OoTROMFilePage.Values[0] = '')
|
||||||
|
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
|
||||||
|
Result := not (ZlROMFilePage.Values[0] = '')
|
||||||
else
|
else
|
||||||
Result := True;
|
Result := True;
|
||||||
end;
|
end;
|
||||||
@@ -364,6 +478,22 @@ begin
|
|||||||
Result := '';
|
Result := '';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
function GetSMWROMPath(Param: string): string;
|
||||||
|
begin
|
||||||
|
if Length(smwrom) > 0 then
|
||||||
|
Result := smwrom
|
||||||
|
else if Assigned(SMWRomFilePage) then
|
||||||
|
begin
|
||||||
|
R := CompareStr(GetSNESMD5OfFile(SMWROMFilePage.Values[0]), 'cdd3c8c37322978ca8669b34bc89c804')
|
||||||
|
if R <> 0 then
|
||||||
|
MsgBox('Super Mario World ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||||
|
|
||||||
|
Result := SMWROMFilePage.Values[0]
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Result := '';
|
||||||
|
end;
|
||||||
|
|
||||||
function GetSoEROMPath(Param: string): string;
|
function GetSoEROMPath(Param: string): string;
|
||||||
begin
|
begin
|
||||||
if Length(soerom) > 0 then
|
if Length(soerom) > 0 then
|
||||||
@@ -396,6 +526,54 @@ begin
|
|||||||
Result := '';
|
Result := '';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
function GetZlROMPath(Param: string): string;
|
||||||
|
begin
|
||||||
|
if Length(zlrom) > 0 then
|
||||||
|
Result := zlrom
|
||||||
|
else if Assigned(ZlROMFilePage) then
|
||||||
|
begin
|
||||||
|
R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270');
|
||||||
|
if R <> 0 then
|
||||||
|
MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||||
|
|
||||||
|
Result := ZlROMFilePage.Values[0]
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Result := '';
|
||||||
|
end;
|
||||||
|
|
||||||
|
function GetRedROMPath(Param: string): string;
|
||||||
|
begin
|
||||||
|
if Length(redrom) > 0 then
|
||||||
|
Result := redrom
|
||||||
|
else if Assigned(RedRomFilePage) then
|
||||||
|
begin
|
||||||
|
R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc')
|
||||||
|
if R <> 0 then
|
||||||
|
MsgBox('Pokemon Red ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||||
|
|
||||||
|
Result := RedROMFilePage.Values[0]
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Result := '';
|
||||||
|
end;
|
||||||
|
|
||||||
|
function GetBlueROMPath(Param: string): string;
|
||||||
|
begin
|
||||||
|
if Length(bluerom) > 0 then
|
||||||
|
Result := bluerom
|
||||||
|
else if Assigned(BlueRomFilePage) then
|
||||||
|
begin
|
||||||
|
R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b')
|
||||||
|
if R <> 0 then
|
||||||
|
MsgBox('Pokemon Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||||
|
|
||||||
|
Result := BlueROMFilePage.Values[0]
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Result := '';
|
||||||
|
end;
|
||||||
|
|
||||||
procedure InitializeWizard();
|
procedure InitializeWizard();
|
||||||
begin
|
begin
|
||||||
AddOoTRomPage();
|
AddOoTRomPage();
|
||||||
@@ -412,9 +590,25 @@ begin
|
|||||||
if Length(dkc3rom) = 0 then
|
if Length(dkc3rom) = 0 then
|
||||||
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
|
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
|
||||||
|
|
||||||
|
smwrom := CheckRom('Super Mario World (USA).sfc', 'cdd3c8c37322978ca8669b34bc89c804');
|
||||||
|
if Length(smwrom) = 0 then
|
||||||
|
SMWRomFilePage:= AddRomPage('Super Mario World (USA).sfc');
|
||||||
|
|
||||||
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
|
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
|
||||||
if Length(soerom) = 0 then
|
if Length(soerom) = 0 then
|
||||||
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
|
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
|
||||||
|
|
||||||
|
zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270');
|
||||||
|
if Length(zlrom) = 0 then
|
||||||
|
ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms');
|
||||||
|
|
||||||
|
redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc');
|
||||||
|
if Length(redrom) = 0 then
|
||||||
|
RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb');
|
||||||
|
|
||||||
|
bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b');
|
||||||
|
if Length(bluerom) = 0 then
|
||||||
|
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|
||||||
@@ -427,8 +621,16 @@ begin
|
|||||||
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
|
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
|
||||||
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
|
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
|
||||||
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
|
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
|
||||||
|
if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then
|
||||||
|
Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw'));
|
||||||
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
||||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||||
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
|
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
|
||||||
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.properties import BooleanProperty, ObjectProperty
|
||||||
from kivy.uix.button import Button
|
from kivy.uix.button import Button
|
||||||
from kivy.uix.gridlayout import GridLayout
|
from kivy.uix.gridlayout import GridLayout
|
||||||
|
from kivy.uix.layout import Layout
|
||||||
from kivy.uix.textinput import TextInput
|
from kivy.uix.textinput import TextInput
|
||||||
from kivy.uix.recycleview import RecycleView
|
from kivy.uix.recycleview import RecycleView
|
||||||
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
||||||
@@ -48,6 +49,7 @@ fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, dura
|
|||||||
|
|
||||||
|
|
||||||
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
||||||
|
from Utils import async_start
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import CommonClient
|
import CommonClient
|
||||||
@@ -299,6 +301,9 @@ class GameManager(App):
|
|||||||
base_title: str = "Archipelago Client"
|
base_title: str = "Archipelago Client"
|
||||||
last_autofillable_command: str
|
last_autofillable_command: str
|
||||||
|
|
||||||
|
main_area_container: GridLayout
|
||||||
|
""" subclasses can add more columns beside the tabs """
|
||||||
|
|
||||||
def __init__(self, ctx: context_type):
|
def __init__(self, ctx: context_type):
|
||||||
self.title = self.base_title
|
self.title = self.base_title
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
@@ -325,7 +330,7 @@ class GameManager(App):
|
|||||||
|
|
||||||
super(GameManager, self).__init__()
|
super(GameManager, self).__init__()
|
||||||
|
|
||||||
def build(self):
|
def build(self) -> Layout:
|
||||||
self.container = ContainerLayout()
|
self.container = ContainerLayout()
|
||||||
|
|
||||||
self.grid = MainLayout()
|
self.grid = MainLayout()
|
||||||
@@ -334,9 +339,12 @@ class GameManager(App):
|
|||||||
# top part
|
# top part
|
||||||
server_label = ServerLabel()
|
server_label = ServerLabel()
|
||||||
self.connect_layout.add_widget(server_label)
|
self.connect_layout.add_widget(server_label)
|
||||||
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.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)
|
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.connect_layout.add_widget(self.server_connect_bar)
|
||||||
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
|
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
|
||||||
self.server_connect_button.bind(on_press=self.connect_button_action)
|
self.server_connect_button.bind(on_press=self.connect_button_action)
|
||||||
@@ -358,7 +366,10 @@ class GameManager(App):
|
|||||||
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
|
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
|
||||||
self.tabs.add_widget(panel)
|
self.tabs.add_widget(panel)
|
||||||
|
|
||||||
self.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:
|
if len(self.logging_pairs) == 1:
|
||||||
# Hide Tab selection if only one tab
|
# Hide Tab selection if only one tab
|
||||||
@@ -374,17 +385,19 @@ class GameManager(App):
|
|||||||
bottom_layout.add_widget(info_button)
|
bottom_layout.add_widget(info_button)
|
||||||
self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
|
self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
|
||||||
self.textinput.bind(on_text_validate=self.on_message)
|
self.textinput.bind(on_text_validate=self.on_message)
|
||||||
|
self.textinput.text_validate_unfocus = False
|
||||||
def text_focus(event):
|
|
||||||
"""Needs to be set via delay, as unfocusing happens after on_message"""
|
|
||||||
self.textinput.focus = True
|
|
||||||
|
|
||||||
self.textinput.text_focus = text_focus
|
|
||||||
bottom_layout.add_widget(self.textinput)
|
bottom_layout.add_widget(self.textinput)
|
||||||
self.grid.add_widget(bottom_layout)
|
self.grid.add_widget(bottom_layout)
|
||||||
self.commandprocessor("/help")
|
self.commandprocessor("/help")
|
||||||
Clock.schedule_interval(self.update_texts, 1 / 30)
|
Clock.schedule_interval(self.update_texts, 1 / 30)
|
||||||
self.container.add_widget(self.grid)
|
self.container.add_widget(self.grid)
|
||||||
|
|
||||||
|
self.server_connect_bar.focus = True
|
||||||
|
self.server_connect_bar.select_text(
|
||||||
|
self.server_connect_bar.text.find(":") + 1,
|
||||||
|
len(self.server_connect_bar.text)
|
||||||
|
)
|
||||||
|
|
||||||
return self.container
|
return self.container
|
||||||
|
|
||||||
def update_texts(self, dt):
|
def update_texts(self, dt):
|
||||||
@@ -395,10 +408,12 @@ class GameManager(App):
|
|||||||
f" | Connected to: {self.ctx.server_address} " \
|
f" | Connected to: {self.ctx.server_address} " \
|
||||||
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
|
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
|
||||||
self.server_connect_button.text = "Disconnect"
|
self.server_connect_button.text = "Disconnect"
|
||||||
|
self.server_connect_bar.readonly = True
|
||||||
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
|
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
|
||||||
self.progressbar.value = len(self.ctx.checked_locations)
|
self.progressbar.value = len(self.ctx.checked_locations)
|
||||||
else:
|
else:
|
||||||
self.server_connect_button.text = "Connect"
|
self.server_connect_button.text = "Connect"
|
||||||
|
self.server_connect_bar.readonly = False
|
||||||
self.title = self.base_title + " " + Utils.__version__
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
self.progressbar.value = 0
|
self.progressbar.value = 0
|
||||||
|
|
||||||
@@ -411,11 +426,10 @@ class GameManager(App):
|
|||||||
|
|
||||||
def connect_button_action(self, button):
|
def connect_button_action(self, button):
|
||||||
if self.ctx.server:
|
if self.ctx.server:
|
||||||
self.ctx.server_address = None
|
|
||||||
self.ctx.username = None
|
self.ctx.username = None
|
||||||
asyncio.create_task(self.ctx.disconnect())
|
async_start(self.ctx.disconnect())
|
||||||
else:
|
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):
|
def on_stop(self):
|
||||||
# "kill" input tasks
|
# "kill" input tasks
|
||||||
@@ -436,8 +450,6 @@ class GameManager(App):
|
|||||||
elif input_text:
|
elif input_text:
|
||||||
self.commandprocessor(input_text)
|
self.commandprocessor(input_text)
|
||||||
|
|
||||||
Clock.schedule_once(textinput.text_focus)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger("Client").exception(e)
|
logging.getLogger("Client").exception(e)
|
||||||
|
|
||||||
@@ -446,6 +458,10 @@ class GameManager(App):
|
|||||||
self.log_panels["Archipelago"].on_message_markup(text)
|
self.log_panels["Archipelago"].on_message_markup(text)
|
||||||
self.log_panels["All"].on_message_markup(text)
|
self.log_panels["All"].on_message_markup(text)
|
||||||
|
|
||||||
|
def focus_textinput(self):
|
||||||
|
if hasattr(self, "textinput"):
|
||||||
|
self.textinput.focus = True
|
||||||
|
|
||||||
def update_address_bar(self, text: str):
|
def update_address_bar(self, text: str):
|
||||||
if hasattr(self, "server_connect_bar"):
|
if hasattr(self, "server_connect_bar"):
|
||||||
self.server_connect_bar.text = text
|
self.server_connect_bar.text = text
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ A Link to the Past:
|
|||||||
death_link:
|
death_link:
|
||||||
false: 50
|
false: 50
|
||||||
true: 0
|
true: 0
|
||||||
|
|
||||||
allow_collect: # Allows for !collect / co-op to auto-open chests containing items for other players.
|
allow_collect: # Allows for !collect / co-op to auto-open chests containing items for other players.
|
||||||
# Off by default, because it currently crashes on real hardware.
|
# Off by default, because it currently crashes on real hardware.
|
||||||
false: 50
|
false: 50
|
||||||
|
|||||||
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
|
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||||
import subprocess
|
import subprocess
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
requirement = 'cx-Freeze>=6.11'
|
requirement = 'cx-Freeze>=6.13.1'
|
||||||
try:
|
try:
|
||||||
pkg_resources.require(requirement)
|
pkg_resources.require(requirement)
|
||||||
import cx_Freeze
|
import cx_Freeze
|
||||||
@@ -289,6 +289,7 @@ tmp="${{exe#*/}}"
|
|||||||
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
||||||
exe="{default_exe.parent}/$exe"
|
exe="{default_exe.parent}/$exe"
|
||||||
fi
|
fi
|
||||||
|
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
|
||||||
$APPDIR/$exe "$@"
|
$APPDIR/$exe "$@"
|
||||||
""")
|
""")
|
||||||
launcher_filename.chmod(0o755)
|
launcher_filename.chmod(0o755)
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ from worlds.alttp.Items import ItemFactory
|
|||||||
|
|
||||||
|
|
||||||
class TestBase(unittest.TestCase):
|
class TestBase(unittest.TestCase):
|
||||||
world: MultiWorld
|
multiworld: MultiWorld
|
||||||
_state_cache = {}
|
_state_cache = {}
|
||||||
|
|
||||||
def get_state(self, items):
|
def get_state(self, items):
|
||||||
if (self.world, tuple(items)) in self._state_cache:
|
if (self.multiworld, tuple(items)) in self._state_cache:
|
||||||
return self._state_cache[self.world, tuple(items)]
|
return self._state_cache[self.multiworld, tuple(items)]
|
||||||
state = CollectionState(self.world)
|
state = CollectionState(self.multiworld)
|
||||||
for item in items:
|
for item in items:
|
||||||
item.classification = ItemClassification.progression
|
item.classification = ItemClassification.progression
|
||||||
state.collect(item)
|
state.collect(item)
|
||||||
state.sweep_for_events()
|
state.sweep_for_events()
|
||||||
self._state_cache[self.world, tuple(items)] = state
|
self._state_cache[self.multiworld, tuple(items)] = state
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def get_path(self, state, region):
|
def get_path(self, state, region):
|
||||||
@@ -44,11 +44,11 @@ class TestBase(unittest.TestCase):
|
|||||||
items = item_pool[0]
|
items = item_pool[0]
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
state = self._get_items(item_pool, all_except)
|
state = self._get_items(item_pool, all_except)
|
||||||
path = self.get_path(state, self.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,
|
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
|
||||||
all_except=all_except, path=path, entry=i):
|
all_except=all_except, path=path, entry=i):
|
||||||
|
|
||||||
self.assertEqual(self.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
|
# check for partial solution
|
||||||
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
||||||
@@ -56,18 +56,18 @@ class TestBase(unittest.TestCase):
|
|||||||
with self.subTest(msg="Location reachable without required item", location=location,
|
with self.subTest(msg="Location reachable without required item", location=location,
|
||||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||||
state = self._get_items_partial(item_pool, missing_item)
|
state = self._get_items_partial(item_pool, missing_item)
|
||||||
self.assertEqual(self.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):
|
def run_entrance_tests(self, access_pool):
|
||||||
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
||||||
items = item_pool[0]
|
items = item_pool[0]
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
state = self._get_items(item_pool, all_except)
|
state = self._get_items(item_pool, all_except)
|
||||||
path = self.get_path(state, self.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,
|
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
|
||||||
all_except=all_except, path=path, entry=i):
|
all_except=all_except, path=path, entry=i):
|
||||||
|
|
||||||
self.assertEqual(self.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
|
# check for partial solution
|
||||||
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
||||||
@@ -75,11 +75,11 @@ class TestBase(unittest.TestCase):
|
|||||||
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
|
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
|
||||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||||
state = self._get_items_partial(item_pool, missing_item)
|
state = self._get_items_partial(item_pool, missing_item)
|
||||||
self.assertEqual(self.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):
|
def _get_items(self, item_pool, all_except):
|
||||||
if all_except and len(all_except) > 0:
|
if all_except and len(all_except) > 0:
|
||||||
items = self.world.itempool[:]
|
items = self.multiworld.itempool[:]
|
||||||
items = [item for item in items if
|
items = [item for item in items if
|
||||||
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
||||||
items.extend(ItemFactory(item_pool[0], 1))
|
items.extend(ItemFactory(item_pool[0], 1))
|
||||||
|
|||||||
@@ -14,46 +14,46 @@ from worlds import AutoWorld
|
|||||||
|
|
||||||
class TestDungeon(unittest.TestCase):
|
class TestDungeon(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.world = MultiWorld(1)
|
self.multiworld = MultiWorld(1)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
self.world.set_options(args)
|
self.multiworld.set_options(args)
|
||||||
self.world.set_default_common_options()
|
self.multiworld.set_default_common_options()
|
||||||
self.starting_regions = [] # Where to start exploring
|
self.starting_regions = [] # Where to start exploring
|
||||||
self.remove_exits = [] # Block dungeon exits
|
self.remove_exits = [] # Block dungeon exits
|
||||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||||
create_regions(self.world, 1)
|
create_regions(self.multiworld, 1)
|
||||||
create_dungeons(self.world, 1)
|
create_dungeons(self.multiworld, 1)
|
||||||
create_shops(self.world, 1)
|
create_shops(self.multiworld, 1)
|
||||||
for exitname, regionname in mandatory_connections:
|
for exitname, regionname in mandatory_connections:
|
||||||
connect_simple(self.world, exitname, regionname, 1)
|
connect_simple(self.multiworld, exitname, regionname, 1)
|
||||||
connect_simple(self.world, 'Big Bomb Shop', 'Big Bomb Shop', 1)
|
connect_simple(self.multiworld, 'Big Bomb Shop', 'Big Bomb Shop', 1)
|
||||||
self.world.get_region('Menu', 1).exits = []
|
self.multiworld.get_region('Menu', 1).exits = []
|
||||||
self.world.swamp_patch_required[1] = True
|
self.multiworld.swamp_patch_required[1] = True
|
||||||
self.world.worlds[1].set_rules()
|
self.multiworld.worlds[1].set_rules()
|
||||||
self.world.worlds[1].create_items()
|
self.multiworld.worlds[1].create_items()
|
||||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
||||||
self.world.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
self.multiworld.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
||||||
|
|
||||||
def run_tests(self, access_pool):
|
def run_tests(self, access_pool):
|
||||||
for exit in self.remove_exits:
|
for exit in self.remove_exits:
|
||||||
self.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:
|
for location, access, *item_pool in access_pool:
|
||||||
items = item_pool[0]
|
items = item_pool[0]
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
with self.subTest(location=location, access=access, items=items, all_except=all_except):
|
with self.subTest(location=location, access=access, items=items, all_except=all_except):
|
||||||
if all_except and len(all_except) > 0:
|
if all_except and len(all_except) > 0:
|
||||||
items = self.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 = [item for item in items if item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
||||||
items.extend(ItemFactory(item_pool[0], 1))
|
items.extend(ItemFactory(item_pool[0], 1))
|
||||||
else:
|
else:
|
||||||
items = ItemFactory(items, 1)
|
items = ItemFactory(items, 1)
|
||||||
state = CollectionState(self.world)
|
state = CollectionState(self.multiworld)
|
||||||
state.reachable_regions[1].add(self.world.get_region('Menu', 1))
|
state.reachable_regions[1].add(self.multiworld.get_region('Menu', 1))
|
||||||
for region_name in self.starting_regions:
|
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)
|
state.reachable_regions[1].add(region)
|
||||||
for exit in region.exits:
|
for exit in region.exits:
|
||||||
if exit.connected_region is not None:
|
if exit.connected_region is not None:
|
||||||
@@ -63,4 +63,4 @@ class TestDungeon(unittest.TestCase):
|
|||||||
item.classification = ItemClassification.progression
|
item.classification = ItemClassification.progression
|
||||||
state.collect(item)
|
state.collect(item)
|
||||||
|
|
||||||
self.assertEqual(self.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
|
from typing import List, Iterable
|
||||||
import unittest
|
import unittest
|
||||||
from worlds.AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
from Fill import FillError, balance_multiworld_progression, fill_restrictive, 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, \
|
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \
|
||||||
ItemClassification
|
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:
|
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):
|
for i in range(players):
|
||||||
player_id = i+1
|
player_id = i+1
|
||||||
world = World(multi_world, player_id)
|
world = World(multi_world, player_id)
|
||||||
multi_world.game[player_id] = world
|
multi_world.game[player_id] = f"Game {player_id}"
|
||||||
multi_world.worlds[player_id] = world
|
multi_world.worlds[player_id] = world
|
||||||
multi_world.player_name[player_id] = "Test Player " + str(player_id)
|
multi_world.player_name[player_id] = "Test Player " + str(player_id)
|
||||||
region = Region("Menu", RegionType.Generic,
|
region = Region("Menu", RegionType.Generic,
|
||||||
@@ -27,7 +28,7 @@ def generate_multi_world(players: int = 1) -> MultiWorld:
|
|||||||
|
|
||||||
|
|
||||||
class PlayerDefinition(object):
|
class PlayerDefinition(object):
|
||||||
world: MultiWorld
|
multiworld: MultiWorld
|
||||||
id: int
|
id: int
|
||||||
menu: Region
|
menu: Region
|
||||||
locations: List[Location]
|
locations: List[Location]
|
||||||
@@ -36,7 +37,7 @@ class PlayerDefinition(object):
|
|||||||
regions: List[Region]
|
regions: List[Region]
|
||||||
|
|
||||||
def __init__(self, world: MultiWorld, id: int, menu: Region, locations: List[Location] = [], prog_items: List[Item] = [], basic_items: List[Item] = []):
|
def __init__(self, world: MultiWorld, id: int, menu: Region, locations: List[Location] = [], prog_items: List[Item] = [], basic_items: List[Item] = []):
|
||||||
self.world = world
|
self.multiworld = world
|
||||||
self.id = id
|
self.id = id
|
||||||
self.menu = menu
|
self.menu = menu
|
||||||
self.locations = locations
|
self.locations = locations
|
||||||
@@ -48,7 +49,7 @@ class PlayerDefinition(object):
|
|||||||
region_tag = "_region" + str(len(self.regions))
|
region_tag = "_region" + str(len(self.regions))
|
||||||
region_name = "player" + str(self.id) + region_tag
|
region_name = "player" + str(self.id) + region_tag
|
||||||
region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
|
region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
|
||||||
"Region Hint", self.id, self.world)
|
"Region Hint", self.id, self.multiworld)
|
||||||
self.locations += generate_locations(size, self.id, None, region, region_tag)
|
self.locations += generate_locations(size, self.id, None, region, region_tag)
|
||||||
|
|
||||||
entrance = Entrance(self.id, region_name + "_entrance", parent)
|
entrance = Entrance(self.id, region_name + "_entrance", parent)
|
||||||
@@ -57,7 +58,7 @@ class PlayerDefinition(object):
|
|||||||
entrance.access_rule = access_rule
|
entrance.access_rule = access_rule
|
||||||
|
|
||||||
self.regions.append(region)
|
self.regions.append(region)
|
||||||
self.world.regions.append(region)
|
self.multiworld.regions.append(region)
|
||||||
|
|
||||||
return region
|
return region
|
||||||
|
|
||||||
@@ -359,6 +360,46 @@ class TestFillRestrictive(unittest.TestCase):
|
|||||||
fill_restrictive(multi_world, multi_world.state,
|
fill_restrictive(multi_world, multi_world.state,
|
||||||
locations, player1.prog_items)
|
locations, player1.prog_items)
|
||||||
|
|
||||||
|
def test_swap_to_earlier_location_with_item_rule(self):
|
||||||
|
# test for PR#1109
|
||||||
|
multi_world = generate_multi_world(1)
|
||||||
|
player1 = generate_player_data(multi_world, 1, 4, 4)
|
||||||
|
locations = player1.locations[:] # copy required
|
||||||
|
items = player1.prog_items[:] # copy required
|
||||||
|
# for the test to work, item and location order is relevant: Sphere 1 last, allowed_item not last
|
||||||
|
for location in locations[:-1]: # Sphere 2
|
||||||
|
# any one provides access to Sphere 2
|
||||||
|
set_rule(location, lambda state: any(state.has(item.name, player1.id) for item in items))
|
||||||
|
# forbid all but 1 item in Sphere 1
|
||||||
|
sphere1_loc = locations[-1]
|
||||||
|
allowed_item = items[1]
|
||||||
|
add_item_rule(sphere1_loc, lambda item_to_place: item_to_place == allowed_item)
|
||||||
|
# test our rules
|
||||||
|
self.assertTrue(location.can_fill(None, allowed_item, False), "Test is flawed")
|
||||||
|
self.assertTrue(location.can_fill(None, items[2], False), "Test is flawed")
|
||||||
|
self.assertTrue(sphere1_loc.can_fill(None, allowed_item, False), "Test is flawed")
|
||||||
|
self.assertFalse(sphere1_loc.can_fill(None, items[2], False), "Test is flawed")
|
||||||
|
# fill has to place items[1] in locations[0] which will result in a swap because of placement order
|
||||||
|
fill_restrictive(multi_world, multi_world.state, player1.locations, player1.prog_items)
|
||||||
|
# assert swap happened
|
||||||
|
self.assertTrue(sphere1_loc.item, "Did not swap required item into Sphere 1")
|
||||||
|
self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1")
|
||||||
|
|
||||||
|
def test_double_sweep(self):
|
||||||
|
# test for PR1114
|
||||||
|
multi_world = generate_multi_world(1)
|
||||||
|
player1 = generate_player_data(multi_world, 1, 1, 1)
|
||||||
|
location = player1.locations[0]
|
||||||
|
location.address = None
|
||||||
|
location.event = True
|
||||||
|
item = player1.prog_items[0]
|
||||||
|
item.code = None
|
||||||
|
location.place_locked_item(item)
|
||||||
|
multi_world.state.sweep_for_events()
|
||||||
|
multi_world.state.sweep_for_events()
|
||||||
|
self.assertTrue(multi_world.state.prog_items[item.name, item.player], "Sweep did not collect - Test flawed")
|
||||||
|
self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times")
|
||||||
|
|
||||||
|
|
||||||
class TestDistributeItemsRestrictive(unittest.TestCase):
|
class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||||
def test_basic_distribute(self):
|
def test_basic_distribute(self):
|
||||||
@@ -371,13 +412,13 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
|
|
||||||
distribute_items_restrictive(multi_world)
|
distribute_items_restrictive(multi_world)
|
||||||
|
|
||||||
self.assertEqual(locations[0].item, basic_items[0])
|
self.assertEqual(locations[0].item, basic_items[1])
|
||||||
self.assertFalse(locations[0].event)
|
self.assertFalse(locations[0].event)
|
||||||
self.assertEqual(locations[1].item, prog_items[0])
|
self.assertEqual(locations[1].item, prog_items[0])
|
||||||
self.assertTrue(locations[1].event)
|
self.assertTrue(locations[1].event)
|
||||||
self.assertEqual(locations[2].item, prog_items[1])
|
self.assertEqual(locations[2].item, prog_items[1])
|
||||||
self.assertTrue(locations[2].event)
|
self.assertTrue(locations[2].event)
|
||||||
self.assertEqual(locations[3].item, basic_items[1])
|
self.assertEqual(locations[3].item, basic_items[0])
|
||||||
self.assertFalse(locations[3].event)
|
self.assertFalse(locations[3].event)
|
||||||
|
|
||||||
def test_excluded_distribute(self):
|
def test_excluded_distribute(self):
|
||||||
@@ -500,8 +541,8 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
removed_item: list[Item] = []
|
removed_item: list[Item] = []
|
||||||
removed_location: list[Location] = []
|
removed_location: list[Location] = []
|
||||||
|
|
||||||
def fill_hook(progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, restitempool, fill_locations):
|
def fill_hook(progitempool, usefulitempool, filleritempool, fill_locations):
|
||||||
removed_item.append(restitempool.pop(0))
|
removed_item.append(filleritempool.pop(0))
|
||||||
removed_location.append(fill_locations.pop(0))
|
removed_location.append(fill_locations.pop(0))
|
||||||
|
|
||||||
multi_world.worlds[player1.id].fill_hook = fill_hook
|
multi_world.worlds[player1.id].fill_hook = fill_hook
|
||||||
@@ -575,8 +616,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
|
|
||||||
multi_world.local_items[player1.id].value = set(names(player1.basic_items))
|
multi_world.local_items[player1.id].value = set(names(player1.basic_items))
|
||||||
multi_world.local_items[player2.id].value = set(names(player2.basic_items))
|
multi_world.local_items[player2.id].value = set(names(player2.basic_items))
|
||||||
locality_rules(multi_world, player1.id)
|
locality_rules(multi_world)
|
||||||
locality_rules(multi_world, player2.id)
|
|
||||||
|
|
||||||
distribute_items_restrictive(multi_world)
|
distribute_items_restrictive(multi_world)
|
||||||
|
|
||||||
@@ -584,6 +624,55 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
self.assertEqual(item.player, item.location.player)
|
self.assertEqual(item.player, item.location.player)
|
||||||
self.assertFalse(item.location.event, False)
|
self.assertFalse(item.location.event, False)
|
||||||
|
|
||||||
|
def test_early_items(self) -> None:
|
||||||
|
mw = generate_multi_world(2)
|
||||||
|
player1 = generate_player_data(mw, 1, location_count=5, basic_item_count=5)
|
||||||
|
player2 = generate_player_data(mw, 2, location_count=5, basic_item_count=5)
|
||||||
|
mw.early_items[1][player1.basic_items[0].name] = 1
|
||||||
|
mw.early_items[2][player2.basic_items[2].name] = 1
|
||||||
|
mw.early_items[2][player2.basic_items[3].name] = 1
|
||||||
|
|
||||||
|
early_items = [
|
||||||
|
player1.basic_items[0],
|
||||||
|
player2.basic_items[2],
|
||||||
|
player2.basic_items[3],
|
||||||
|
]
|
||||||
|
|
||||||
|
# copied this code from the beginning of `distribute_items_restrictive`
|
||||||
|
# before `distribute_early_items` is called
|
||||||
|
fill_locations = sorted(mw.get_unfilled_locations())
|
||||||
|
mw.random.shuffle(fill_locations)
|
||||||
|
itempool = sorted(mw.itempool)
|
||||||
|
mw.random.shuffle(itempool)
|
||||||
|
|
||||||
|
fill_locations, itempool = distribute_early_items(mw, fill_locations, itempool)
|
||||||
|
|
||||||
|
remaining_p1 = [item for item in itempool if item.player == 1]
|
||||||
|
remaining_p2 = [item for item in itempool if item.player == 2]
|
||||||
|
|
||||||
|
assert len(itempool) == 7, f"number of items remaining after early_items: {len(itempool)}"
|
||||||
|
assert len(remaining_p1) == 4, f"number of p1 items after early_items: {len(remaining_p1)}"
|
||||||
|
assert len(remaining_p2) == 3, f"number of p2 items after early_items: {len(remaining_p1)}"
|
||||||
|
for i in range(5):
|
||||||
|
if i != 0:
|
||||||
|
assert player1.basic_items[i] in itempool, "non-early item to remain in itempool"
|
||||||
|
if i not in {2, 3}:
|
||||||
|
assert player2.basic_items[i] in itempool, "non-early item to remain in itempool"
|
||||||
|
for item in early_items:
|
||||||
|
assert item not in itempool, "early item to be taken out of itempool"
|
||||||
|
|
||||||
|
assert len(fill_locations) == len(mw.get_locations()) - len(early_items), \
|
||||||
|
f"early location count from {mw.get_locations()} to {len(fill_locations)} " \
|
||||||
|
f"after {len(early_items)} early items"
|
||||||
|
|
||||||
|
items_in_locations = {loc.item for loc in mw.get_locations() if loc.item}
|
||||||
|
|
||||||
|
assert len(items_in_locations) == len(early_items), \
|
||||||
|
f"{len(early_items)} early items in {len(items_in_locations)} locations"
|
||||||
|
|
||||||
|
for item in early_items:
|
||||||
|
assert item in items_in_locations, "early item to be placed in location"
|
||||||
|
|
||||||
|
|
||||||
class TestBalanceMultiworldProgression(unittest.TestCase):
|
class TestBalanceMultiworldProgression(unittest.TestCase):
|
||||||
def assertRegionContains(self, region: Region, item: Item) -> bool:
|
def assertRegionContains(self, region: Region, item: Item) -> bool:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class TestImplemented(unittest.TestCase):
|
|||||||
def testCompletionCondition(self):
|
def testCompletionCondition(self):
|
||||||
"""Ensure a completion condition is set that has requirements."""
|
"""Ensure a completion condition is set that has requirements."""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy"}:
|
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy", "Sudoku"}:
|
||||||
with self.subTest(gamename):
|
with self.subTest(gamename):
|
||||||
world = setup_default_world(world_type)
|
world = setup_default_world(world_type)
|
||||||
self.assertFalse(world.completion_condition[1](world.state))
|
self.assertFalse(world.completion_condition[1](world.state))
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class TestBase(unittest.TestCase):
|
|||||||
exclusion_dict = {
|
exclusion_dict = {
|
||||||
"A Link to the Past":
|
"A Link to the Past":
|
||||||
{"Pendants", "Crystals"},
|
{"Pendants", "Crystals"},
|
||||||
|
"Ocarina of Time":
|
||||||
|
{"medallions", "stones", "rewards", "logic_bottles"},
|
||||||
"Starcraft 2 Wings of Liberty":
|
"Starcraft 2 Wings of Liberty":
|
||||||
{"Missions"},
|
{"Missions"},
|
||||||
}
|
}
|
||||||
|
|||||||
20
test/general/TestNames.py
Normal file
20
test/general/TestNames.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import unittest
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
|
|
||||||
|
class TestNames(unittest.TestCase):
|
||||||
|
def testItemNamesFormat(self):
|
||||||
|
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
|
||||||
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
with self.subTest(game=gamename):
|
||||||
|
for item_name in world_type.item_name_to_id:
|
||||||
|
self.assertFalse(item_name.isnumeric(),
|
||||||
|
f"Item name \"{item_name}\" is invalid. It must not be numeric.")
|
||||||
|
|
||||||
|
def testLocationNameFormat(self):
|
||||||
|
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
|
||||||
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
with self.subTest(game=gamename):
|
||||||
|
for location_name in world_type.location_name_to_id:
|
||||||
|
self.assertFalse(location_name.isnumeric(),
|
||||||
|
f"Location name \"{location_name}\" is invalid. It must not be numeric.")
|
||||||
@@ -20,7 +20,7 @@ class TestBase(unittest.TestCase):
|
|||||||
for location in world.get_locations():
|
for location in world.get_locations():
|
||||||
if location.name not in excluded:
|
if location.name not in excluded:
|
||||||
with self.subTest("Location should be reached", location=location):
|
with self.subTest("Location should be reached", location=location):
|
||||||
self.assertTrue(location.can_reach(state))
|
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||||
|
|
||||||
with self.subTest("Completion Condition"):
|
with self.subTest("Completion Condition"):
|
||||||
self.assertTrue(world.can_beat_game(state))
|
self.assertTrue(world.can_beat_game(state))
|
||||||
@@ -28,7 +28,7 @@ class TestBase(unittest.TestCase):
|
|||||||
def testEmptyStateCanReachSomething(self):
|
def testEmptyStateCanReachSomething(self):
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
# Final Fantasy logic is controlled by finalfantasyrandomizer.com
|
# Final Fantasy logic is controlled by finalfantasyrandomizer.com
|
||||||
if game_name != "Archipelago" and game_name != "Final Fantasy":
|
if game_name not in {"Archipelago", "Final Fantasy", "Sudoku"}:
|
||||||
with self.subTest("Game", game=game_name):
|
with self.subTest("Game", game=game_name):
|
||||||
world = setup_default_world(world_type)
|
world = setup_default_world(world_type)
|
||||||
state = CollectionState(world)
|
state = CollectionState(world)
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "g
|
|||||||
|
|
||||||
|
|
||||||
def setup_default_world(world_type) -> MultiWorld:
|
def setup_default_world(world_type) -> MultiWorld:
|
||||||
world = MultiWorld(1)
|
multiworld = MultiWorld(1)
|
||||||
world.game[1] = world_type.game
|
multiworld.game[1] = world_type.game
|
||||||
world.player_name = {1: "Tester"}
|
multiworld.player_name = {1: "Tester"}
|
||||||
world.set_seed()
|
multiworld.set_seed()
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for name, option in world_type.option_definitions.items():
|
for name, option in world_type.option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
world.set_options(args)
|
multiworld.set_options(args)
|
||||||
world.set_default_common_options()
|
multiworld.set_default_common_options()
|
||||||
for step in gen_steps:
|
for step in gen_steps:
|
||||||
call_all(world, step)
|
call_all(multiworld, step)
|
||||||
return world
|
return multiworld
|
||||||
|
|||||||
@@ -14,23 +14,23 @@ from worlds import AutoWorld
|
|||||||
|
|
||||||
class TestInverted(TestBase):
|
class TestInverted(TestBase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.world = MultiWorld(1)
|
self.multiworld = MultiWorld(1)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
self.world.set_options(args)
|
self.multiworld.set_options(args)
|
||||||
self.world.set_default_common_options()
|
self.multiworld.set_default_common_options()
|
||||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||||
self.world.mode[1] = "inverted"
|
self.multiworld.mode[1] = "inverted"
|
||||||
create_inverted_regions(self.world, 1)
|
create_inverted_regions(self.multiworld, 1)
|
||||||
create_dungeons(self.world, 1)
|
create_dungeons(self.multiworld, 1)
|
||||||
create_shops(self.world, 1)
|
create_shops(self.multiworld, 1)
|
||||||
link_inverted_entrances(self.world, 1)
|
link_inverted_entrances(self.multiworld, 1)
|
||||||
self.world.worlds[1].create_items()
|
self.multiworld.worlds[1].create_items()
|
||||||
self.world.required_medallions[1] = ['Ether', 'Quake']
|
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
|
||||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
||||||
self.world.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
self.multiworld.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.multiworld.get_location('Agahnim 1', 1).item = None
|
||||||
self.world.get_location('Agahnim 2', 1).item = None
|
self.multiworld.get_location('Agahnim 2', 1).item = None
|
||||||
mark_light_world_regions(self.world, 1)
|
mark_light_world_regions(self.multiworld, 1)
|
||||||
self.world.worlds[1].set_rules()
|
self.multiworld.worlds[1].set_rules()
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ from worlds import AutoWorld
|
|||||||
class TestInvertedBombRules(unittest.TestCase):
|
class TestInvertedBombRules(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.world = MultiWorld(1)
|
self.multiworld = MultiWorld(1)
|
||||||
self.world.mode[1] = "inverted"
|
self.multiworld.mode[1] = "inverted"
|
||||||
args = Namespace
|
args = Namespace
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
self.world.set_options(args)
|
self.multiworld.set_options(args)
|
||||||
self.world.set_default_common_options()
|
self.multiworld.set_default_common_options()
|
||||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||||
create_inverted_regions(self.world, 1)
|
create_inverted_regions(self.multiworld, 1)
|
||||||
create_dungeons(self.world, 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.
|
#TODO: Just making sure I haven't missed an entrance. It would be good to test the rules make sense as well.
|
||||||
def testInvertedBombRulesAreComplete(self):
|
def testInvertedBombRulesAreComplete(self):
|
||||||
@@ -31,9 +31,9 @@ class TestInvertedBombRules(unittest.TestCase):
|
|||||||
must_exits = list(Inverted_LW_Entrances_Must_Exit + Inverted_LW_Dungeon_Entrances_Must_Exit)
|
must_exits = list(Inverted_LW_Entrances_Must_Exit + Inverted_LW_Dungeon_Entrances_Must_Exit)
|
||||||
for entrance_name in (entrances + must_exits):
|
for entrance_name in (entrances + must_exits):
|
||||||
if entrance_name not in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
if entrance_name not in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
||||||
entrance = self.world.get_entrance(entrance_name, 1)
|
entrance = self.multiworld.get_entrance(entrance_name, 1)
|
||||||
connect_entrance(self.world, entrance_name, 'Inverted Big Bomb Shop', 1)
|
connect_entrance(self.multiworld, entrance_name, 'Inverted Big Bomb Shop', 1)
|
||||||
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.entrances.remove(entrance)
|
||||||
entrance.connected_region = None
|
entrance.connected_region = None
|
||||||
|
|
||||||
@@ -45,9 +45,9 @@ class TestInvertedBombRules(unittest.TestCase):
|
|||||||
|
|
||||||
def testInvalidEntrances(self):
|
def testInvalidEntrances(self):
|
||||||
for entrance_name in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
for entrance_name in ['Desert Palace Entrance (East)', 'Spectacle Rock Cave', 'Spectacle Rock Cave (Bottom)']:
|
||||||
entrance = self.world.get_entrance(entrance_name, 1)
|
entrance = self.multiworld.get_entrance(entrance_name, 1)
|
||||||
connect_entrance(self.world, entrance_name, 'Inverted Big Bomb Shop', 1)
|
connect_entrance(self.multiworld, entrance_name, 'Inverted Big Bomb Shop', 1)
|
||||||
with self.assertRaises(Exception):
|
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.entrances.remove(entrance)
|
||||||
entrance.connected_region = None
|
entrance.connected_region = None
|
||||||
|
|||||||
@@ -15,24 +15,24 @@ from worlds import AutoWorld
|
|||||||
|
|
||||||
class TestInvertedMinor(TestBase):
|
class TestInvertedMinor(TestBase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.world = MultiWorld(1)
|
self.multiworld = MultiWorld(1)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
|
||||||
setattr(args, name, {1: option.from_any(option.default)})
|
setattr(args, name, {1: option.from_any(option.default)})
|
||||||
self.world.set_options(args)
|
self.multiworld.set_options(args)
|
||||||
self.world.set_default_common_options()
|
self.multiworld.set_default_common_options()
|
||||||
self.world.mode[1] = "inverted"
|
self.multiworld.mode[1] = "inverted"
|
||||||
self.world.logic[1] = "minorglitches"
|
self.multiworld.logic[1] = "minorglitches"
|
||||||
self.world.difficulty_requirements[1] = difficulties['normal']
|
self.multiworld.difficulty_requirements[1] = difficulties['normal']
|
||||||
create_inverted_regions(self.world, 1)
|
create_inverted_regions(self.multiworld, 1)
|
||||||
create_dungeons(self.world, 1)
|
create_dungeons(self.multiworld, 1)
|
||||||
create_shops(self.world, 1)
|
create_shops(self.multiworld, 1)
|
||||||
link_inverted_entrances(self.world, 1)
|
link_inverted_entrances(self.multiworld, 1)
|
||||||
self.world.worlds[1].create_items()
|
self.multiworld.worlds[1].create_items()
|
||||||
self.world.required_medallions[1] = ['Ether', 'Quake']
|
self.multiworld.required_medallions[1] = ['Ether', 'Quake']
|
||||||
self.world.itempool.extend(get_dungeon_item_pool(self.world))
|
self.multiworld.itempool.extend(get_dungeon_item_pool(self.multiworld))
|
||||||
self.world.itempool.extend(ItemFactory(['Green Pendant', 'Red Pendant', 'Blue Pendant', 'Beat Agahnim 1', 'Beat Agahnim 2', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 5', 'Crystal 6', 'Crystal 7'], 1))
|
self.multiworld.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.multiworld.get_location('Agahnim 1', 1).item = None
|
||||||
self.world.get_location('Agahnim 2', 1).item = None
|
self.multiworld.get_location('Agahnim 2', 1).item = None
|
||||||
mark_light_world_regions(self.world, 1)
|
mark_light_world_regions(self.multiworld, 1)
|
||||||
self.world.worlds[1].set_rules()
|
self.multiworld.worlds[1].set_rules()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user