mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 15:13:52 -08:00
Compare commits
226 Commits
NewSoupVi-
...
core_negat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffecc62155 | ||
|
|
8193fa12b2 | ||
|
|
de0c498470 | ||
|
|
7337309426 | ||
|
|
3205e9b3a0 | ||
|
|
05439012dc | ||
|
|
177c0fef52 | ||
|
|
5c4e81d046 | ||
|
|
a2d585ba5c | ||
|
|
5ea55d77b0 | ||
|
|
ab8caea8be | ||
|
|
a043ed50a6 | ||
|
|
e85a835b47 | ||
|
|
9a9fea0ca2 | ||
|
|
e910a37273 | ||
|
|
f06d4503d8 | ||
|
|
8021b457b6 | ||
|
|
d43dc62485 | ||
|
|
f7ec3d7508 | ||
|
|
99c02a3eb3 | ||
|
|
449782a4d8 | ||
|
|
97ca2ad258 | ||
|
|
2b88be5791 | ||
|
|
204e940f47 | ||
|
|
69d3db21df | ||
|
|
41ddb96b24 | ||
|
|
ba8f03516e | ||
|
|
0095eecf2b | ||
|
|
79942c09c2 | ||
|
|
1b15c6920d | ||
|
|
499d79f089 | ||
|
|
926e08513c | ||
|
|
025c550991 | ||
|
|
fced9050a4 | ||
|
|
2ee8b7535d | ||
|
|
0d35cd4679 | ||
|
|
db5d9fbf70 | ||
|
|
51a6dc150c | ||
|
|
710609fa60 | ||
|
|
da781bb4ac | ||
|
|
69487661dd | ||
|
|
f73c0d9894 | ||
|
|
6fac83b84c | ||
|
|
debb936618 | ||
|
|
8c5b65ff26 | ||
|
|
a7c96436d9 | ||
|
|
4e60f3cc54 | ||
|
|
30a0b337a2 | ||
|
|
4ea1dddd2f | ||
|
|
dc218b7997 | ||
|
|
78c5489189 | ||
|
|
d1a7bc66e6 | ||
|
|
b982e9ebb4 | ||
|
|
8f7e0dc441 | ||
|
|
5aea8d4ab5 | ||
|
|
97be5f1dde | ||
|
|
dae3fe188d | ||
|
|
96542fb2d8 | ||
|
|
ec50b0716a | ||
|
|
f8d3c26e3c | ||
|
|
1c0cec0de2 | ||
|
|
4692e6f08a | ||
|
|
b8d23ec595 | ||
|
|
ce42e42af7 | ||
|
|
ee12dda361 | ||
|
|
84805a4e54 | ||
|
|
5530d181da | ||
|
|
ed948e3e5b | ||
|
|
7621889b8b | ||
|
|
c9f1a21bd2 | ||
|
|
874392756b | ||
|
|
7ff201e32c | ||
|
|
170aedba8f | ||
|
|
09c7f5f909 | ||
|
|
4aab317665 | ||
|
|
e52ce0149a | ||
|
|
5a5162c9d3 | ||
|
|
cf375cbcc4 | ||
|
|
6d6d35d598 | ||
|
|
05b257adf9 | ||
|
|
cabfef669a | ||
|
|
e4a5ed1cc4 | ||
|
|
5021997df0 | ||
|
|
d90cf0db65 | ||
|
|
dad228cd4a | ||
|
|
a652108472 | ||
|
|
5348f693fe | ||
|
|
b8c2e14e8b | ||
|
|
430b71a092 | ||
|
|
a40744e6db | ||
|
|
d802f9652a | ||
|
|
cbdb4d7ce3 | ||
|
|
691ce6a248 | ||
|
|
f9fc6944d3 | ||
|
|
e984583e5e | ||
|
|
7e03a87608 | ||
|
|
456bc481a3 | ||
|
|
b4752cd32d | ||
|
|
ceec51b9e1 | ||
|
|
d3312287a8 | ||
|
|
d65863ffa2 | ||
|
|
b8d7ef24f7 | ||
|
|
b2949dfbe8 | ||
|
|
2aa0653b6d | ||
|
|
d63efa5846 | ||
|
|
765721888a | ||
|
|
73701292b5 | ||
|
|
3ab71daa8d | ||
|
|
6f46397185 | ||
|
|
1a41e1acc8 | ||
|
|
34a3b5f058 | ||
|
|
456b4adaa1 | ||
|
|
fc8462f4e9 | ||
|
|
499dad53b1 | ||
|
|
8a809be67a | ||
|
|
7e0219c214 | ||
|
|
b37bb60891 | ||
|
|
f81335d614 | ||
|
|
8ed466bf24 | ||
|
|
920cffda2d | ||
|
|
b1be597451 | ||
|
|
08dc7e522e | ||
|
|
0f64bd08e1 | ||
|
|
d52827ebd2 | ||
|
|
0e55ddc7cf | ||
|
|
ab5b986716 | ||
|
|
97c313c1c4 | ||
|
|
701a7faa71 | ||
|
|
9a4e84efdc | ||
|
|
906b23088c | ||
|
|
0fb69dce33 | ||
|
|
e99f027b42 | ||
|
|
dddffa1660 | ||
|
|
83367c6946 | ||
|
|
0fcca25870 | ||
|
|
d1a7fd7da1 | ||
|
|
5c5f2ffc94 | ||
|
|
6f617e302d | ||
|
|
35c9061c9c | ||
|
|
e61d521ba8 | ||
|
|
6efa065867 | ||
|
|
56dbba6a31 | ||
|
|
43cb9611fb | ||
|
|
64b654d42e | ||
|
|
74aab81f79 | ||
|
|
f390b33c17 | ||
|
|
31852801c9 | ||
|
|
e35addf5b2 | ||
|
|
3cdcb8c455 | ||
|
|
48c6a6fb4c | ||
|
|
eaa8156061 | ||
|
|
54a7bb5664 | ||
|
|
0e6e359747 | ||
|
|
c4e7b6ca82 | ||
|
|
f253dffc07 | ||
|
|
c010c8c938 | ||
|
|
1e8a8e7482 | ||
|
|
182f7e24e5 | ||
|
|
9277cb39ef | ||
|
|
28a9709516 | ||
|
|
49a5b52774 | ||
|
|
2b1802ccee | ||
|
|
f5218faea7 | ||
|
|
81092247c6 | ||
|
|
ca96e7e294 | ||
|
|
c014c5a54a | ||
|
|
e9c863dffd | ||
|
|
7eda4c47f8 | ||
|
|
474a3181c6 | ||
|
|
4af6927e23 | ||
|
|
06df072095 | ||
|
|
56aabe51b8 | ||
|
|
5e5f24cdd2 | ||
|
|
9fbaa6050f | ||
|
|
0af31c71e0 | ||
|
|
169da1b1e0 | ||
|
|
8e7ea06f39 | ||
|
|
96d48a923a | ||
|
|
dcaa2f7b97 | ||
|
|
50330cf32f | ||
|
|
67520adcea | ||
|
|
a3e54a951f | ||
|
|
ae0abd3821 | ||
|
|
21bbf5fb95 | ||
|
|
09e052c750 | ||
|
|
68a92b0c6f | ||
|
|
8e06ab4f68 | ||
|
|
9dba39b606 | ||
|
|
a6f376b02e | ||
|
|
c66a8605da | ||
|
|
ac7590e621 | ||
|
|
30f97dd7de | ||
|
|
6e41c60672 | ||
|
|
5efb3fd2b0 | ||
|
|
6803c373e5 | ||
|
|
575c338aa3 | ||
|
|
05ce29f7dc | ||
|
|
74697b679e | ||
|
|
cf6661439e | ||
|
|
6297a4efa5 | ||
|
|
8ddb49f071 | ||
|
|
90446ad175 | ||
|
|
98bb8517e1 | ||
|
|
203c8f4d89 | ||
|
|
c0ef02d6fa | ||
|
|
4620493828 | ||
|
|
75b8c7891c | ||
|
|
53bc4ffa52 | ||
|
|
91f7cf16de | ||
|
|
7c8ea34a02 | ||
|
|
a05dbac55f | ||
|
|
83521e99d9 | ||
|
|
1d19da0c76 | ||
|
|
77e3f9fbef | ||
|
|
954d728005 | ||
|
|
80daa092a7 | ||
|
|
fac72dbc20 | ||
|
|
e764da3dc6 | ||
|
|
ab0903679c | ||
|
|
67f329b96f | ||
|
|
b273852512 | ||
|
|
b77805e5ee | ||
|
|
34141f8de0 | ||
|
|
e38f5d0a61 | ||
|
|
35ed0d4e19 | ||
|
|
e5c9b8ad0c |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
worlds/blasphemous/region_data.py linguist-generated=true
|
||||
7
.github/workflows/unittests.yml
vendored
7
.github/workflows/unittests.yml
vendored
@@ -37,12 +37,13 @@ jobs:
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
- {version: '3.12'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.11'} # current
|
||||
- python: {version: '3.12'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.11'} # current
|
||||
- python: {version: '3.12'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
@@ -70,7 +71,7 @@ jobs:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
python:
|
||||
- {version: '3.11'} # current
|
||||
- {version: '3.12'} # current
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
318
BaseClasses.py
318
BaseClasses.py
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import collections
|
||||
import itertools
|
||||
import functools
|
||||
import logging
|
||||
@@ -11,8 +11,10 @@ from argparse import Namespace
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Collection, MutableSequence
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
|
||||
TypedDict, Union, Type, ClassVar
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, Type)
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
import NetUtils
|
||||
import Options
|
||||
@@ -22,16 +24,16 @@ if typing.TYPE_CHECKING:
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
class Group(TypedDict, total=False):
|
||||
class Group(TypedDict):
|
||||
name: str
|
||||
game: str
|
||||
world: "AutoWorld.World"
|
||||
players: Set[int]
|
||||
item_pool: Set[str]
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
local_items: Set[str]
|
||||
non_local_items: Set[str]
|
||||
link_replacement: bool
|
||||
players: AbstractSet[int]
|
||||
item_pool: NotRequired[Set[str]]
|
||||
replacement_items: NotRequired[Dict[int, Optional[str]]]
|
||||
local_items: NotRequired[Set[str]]
|
||||
non_local_items: NotRequired[Set[str]]
|
||||
link_replacement: NotRequired[bool]
|
||||
|
||||
|
||||
class ThreadBarrierProxy:
|
||||
@@ -48,6 +50,11 @@ class ThreadBarrierProxy:
|
||||
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
||||
|
||||
|
||||
class HasNameAndPlayer(Protocol):
|
||||
name: str
|
||||
player: int
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
debug_types = False
|
||||
player_name: Dict[int, str]
|
||||
@@ -63,7 +70,6 @@ class MultiWorld():
|
||||
state: CollectionState
|
||||
|
||||
plando_options: PlandoOptions
|
||||
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]
|
||||
@@ -157,7 +163,7 @@ class MultiWorld():
|
||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||
|
||||
for player in range(1, players + 1):
|
||||
def set_player_attr(attr, val):
|
||||
def set_player_attr(attr: str, val) -> None:
|
||||
self.__dict__.setdefault(attr, {})[player] = val
|
||||
set_player_attr('plando_items', [])
|
||||
set_player_attr('plando_texts', {})
|
||||
@@ -166,13 +172,13 @@ class MultiWorld():
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.worlds = {}
|
||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||
"world's random object instead (usually self.random)")
|
||||
"world's random object instead (usually self.random)")
|
||||
self.plando_options = PlandoOptions.none
|
||||
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
return self.player_ids + tuple(self.groups)
|
||||
|
||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||
def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset()) -> Tuple[int, Group]:
|
||||
"""Create a group with name and return the assigned player ID and group.
|
||||
If a group of this name already exists, the set of players is extended instead of creating a new one."""
|
||||
from worlds import AutoWorld
|
||||
@@ -188,7 +194,9 @@ class MultiWorld():
|
||||
self.player_types[new_id] = NetUtils.SlotType.group
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
||||
self.worlds[new_id] = world_type.create_group(self, new_id, players)
|
||||
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
|
||||
self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id])
|
||||
self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id])
|
||||
self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id])
|
||||
self.player_name[new_id] = name
|
||||
|
||||
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
|
||||
@@ -196,7 +204,7 @@ class MultiWorld():
|
||||
|
||||
return new_id, new_group
|
||||
|
||||
def get_player_groups(self, player) -> Set[int]:
|
||||
def get_player_groups(self, player: int) -> Set[int]:
|
||||
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
|
||||
|
||||
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
|
||||
@@ -259,7 +267,7 @@ class MultiWorld():
|
||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||
}
|
||||
|
||||
for name, item_link in item_links.items():
|
||||
for _name, item_link in item_links.items():
|
||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||
pool = set()
|
||||
local_items = set()
|
||||
@@ -288,6 +296,88 @@ class MultiWorld():
|
||||
group["non_local_items"] = item_link["non_local_items"]
|
||||
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
||||
|
||||
def link_items(self) -> None:
|
||||
"""Called to link together items in the itempool related to the registered item link groups."""
|
||||
from worlds import AutoWorld
|
||||
|
||||
for group_id, group in self.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||
]:
|
||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in self.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del (counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
if count:
|
||||
for player in players:
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del (counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool: List[Item] = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
# mangle together all original classification bits
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", group_id, self, "ItemLink")
|
||||
self.regions.append(region)
|
||||
locations = region.locations
|
||||
# ensure that progression items are linked first, then non-progression
|
||||
self.itempool.sort(key=lambda item: item.advancement)
|
||||
for item in self.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
itemcount = len(self.itempool)
|
||||
self.itempool = new_itempool
|
||||
|
||||
while itemcount > len(self.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["link_replacement"]:
|
||||
item_player = group_id
|
||||
else:
|
||||
item_player = player
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
|
||||
self.random.shuffle(items_to_add)
|
||||
self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
|
||||
|
||||
def secure(self):
|
||||
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
||||
self.is_race = True
|
||||
@@ -309,7 +399,7 @@ class MultiWorld():
|
||||
return tuple(world for player, world in self.worlds.items() if
|
||||
player not in self.groups and self.game[player] == game_name)
|
||||
|
||||
def get_name_string_for_object(self, obj) -> str:
|
||||
def get_name_string_for_object(self, obj: HasNameAndPlayer) -> str:
|
||||
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
|
||||
|
||||
def get_player_name(self, player: int) -> str:
|
||||
@@ -351,7 +441,7 @@ class MultiWorld():
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
ret.sweep_for_events()
|
||||
ret.sweep_for_advancements()
|
||||
|
||||
if use_cache:
|
||||
self._all_state = ret
|
||||
@@ -360,7 +450,7 @@ class MultiWorld():
|
||||
def get_items(self) -> List[Item]:
|
||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||
|
||||
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
def find_item_locations(self, item: str, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
if resolve_group_locations:
|
||||
player_groups = self.get_player_groups(player)
|
||||
return [location for location in self.get_locations() if
|
||||
@@ -369,7 +459,7 @@ class MultiWorld():
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player]
|
||||
|
||||
def find_item(self, item, player: int) -> Location:
|
||||
def find_item(self, item: str, player: int) -> Location:
|
||||
return next(location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player)
|
||||
|
||||
@@ -462,9 +552,9 @@ class MultiWorld():
|
||||
return True
|
||||
state = starting_state.copy()
|
||||
else:
|
||||
if self.has_beaten_game(self.state):
|
||||
return True
|
||||
state = CollectionState(self)
|
||||
if self.has_beaten_game(state):
|
||||
return True
|
||||
prog_locations = {location for location in self.get_locations() if location.item
|
||||
and location.item.advancement and location not in state.locations_checked}
|
||||
|
||||
@@ -523,26 +613,21 @@ class MultiWorld():
|
||||
players: Dict[str, Set[int]] = {
|
||||
"minimal": set(),
|
||||
"items": set(),
|
||||
"locations": set()
|
||||
"full": set()
|
||||
}
|
||||
for player, access in self.accessibility.items():
|
||||
players[access.current_key].add(player)
|
||||
for player, world in self.worlds.items():
|
||||
players[world.options.accessibility.current_key].add(player)
|
||||
|
||||
beatable_fulfilled = False
|
||||
|
||||
def location_condition(location: Location):
|
||||
def location_condition(location: Location) -> bool:
|
||||
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
|
||||
if location.player in players["locations"] or (location.item and location.item.player not in
|
||||
players["minimal"]):
|
||||
return True
|
||||
return False
|
||||
return location.player in players["full"] or \
|
||||
(location.item and location.item.player not in players["minimal"])
|
||||
|
||||
def location_relevant(location: Location):
|
||||
def location_relevant(location: Location) -> bool:
|
||||
"""Determine if this location is relevant to sweep."""
|
||||
if location.progress_type != LocationProgressType.EXCLUDED \
|
||||
and (location.player in players["locations"] or location.advancement):
|
||||
return True
|
||||
return False
|
||||
return location.player in players["full"] or location.advancement
|
||||
|
||||
def all_done() -> bool:
|
||||
"""Check if all access rules are fulfilled"""
|
||||
@@ -587,7 +672,7 @@ class CollectionState():
|
||||
multiworld: MultiWorld
|
||||
reachable_regions: Dict[int, Set[Region]]
|
||||
blocked_connections: Dict[int, Set[Entrance]]
|
||||
events: Set[Location]
|
||||
advancements: Set[Location]
|
||||
path: Dict[Union[Region, Entrance], PathValue]
|
||||
locations_checked: Set[Location]
|
||||
stale: Dict[int, bool]
|
||||
@@ -599,7 +684,7 @@ class CollectionState():
|
||||
self.multiworld = parent
|
||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
|
||||
self.events = set()
|
||||
self.advancements = set()
|
||||
self.path = {}
|
||||
self.locations_checked = set()
|
||||
self.stale = {player: True for player in parent.get_all_ids()}
|
||||
@@ -611,17 +696,25 @@ class CollectionState():
|
||||
|
||||
def update_reachable_regions(self, player: int):
|
||||
self.stale[player] = False
|
||||
world: AutoWorld.World = self.multiworld.worlds[player]
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
queue = deque(self.blocked_connections[player])
|
||||
start = self.multiworld.get_region("Menu", player)
|
||||
start: Region = world.get_region(world.origin_region_name)
|
||||
|
||||
# init on first call - this can't be done on construction since the regions don't exist yet
|
||||
if start not in reachable_regions:
|
||||
reachable_regions.add(start)
|
||||
blocked_connections.update(start.exits)
|
||||
self.blocked_connections[player].update(start.exits)
|
||||
queue.extend(start.exits)
|
||||
|
||||
if world.explicit_indirect_conditions:
|
||||
self._update_reachable_regions_explicit_indirect_conditions(player, queue)
|
||||
else:
|
||||
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
||||
|
||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
# run BFS on all connections, and keep track of those blocked by missing items
|
||||
while queue:
|
||||
connection = queue.popleft()
|
||||
@@ -629,7 +722,7 @@ class CollectionState():
|
||||
if new_region in reachable_regions:
|
||||
blocked_connections.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
blocked_connections.update(new_region.exits)
|
||||
@@ -641,16 +734,39 @@ class CollectionState():
|
||||
if new_entrance in blocked_connections and new_entrance not in queue:
|
||||
queue.append(new_entrance)
|
||||
|
||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
new_connection: bool = True
|
||||
# run BFS on all connections, and keep track of those blocked by missing items
|
||||
while new_connection:
|
||||
new_connection = False
|
||||
while queue:
|
||||
connection = queue.popleft()
|
||||
new_region = connection.connected_region
|
||||
if new_region in reachable_regions:
|
||||
blocked_connections.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
blocked_connections.update(new_region.exits)
|
||||
queue.extend(new_region.exits)
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
new_connection = True
|
||||
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
||||
queue.extend(blocked_connections)
|
||||
|
||||
def copy(self) -> CollectionState:
|
||||
ret = CollectionState(self.multiworld)
|
||||
ret.prog_items = copy.deepcopy(self.prog_items)
|
||||
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
|
||||
self.reachable_regions}
|
||||
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
|
||||
self.blocked_connections}
|
||||
ret.events = copy.copy(self.events)
|
||||
ret.path = copy.copy(self.path)
|
||||
ret.locations_checked = copy.copy(self.locations_checked)
|
||||
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
|
||||
ret.reachable_regions = {player: region_set.copy() for player, region_set in
|
||||
self.reachable_regions.items()}
|
||||
ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
|
||||
self.blocked_connections.items()}
|
||||
ret.advancements = self.advancements.copy()
|
||||
ret.path = self.path.copy()
|
||||
ret.locations_checked = self.locations_checked.copy()
|
||||
for function in self.additional_copy_functions:
|
||||
ret = function(self, ret)
|
||||
return ret
|
||||
@@ -680,20 +796,25 @@ class CollectionState():
|
||||
def can_reach_region(self, spot: str, player: int) -> bool:
|
||||
return self.multiworld.get_region(spot, player).can_reach(self)
|
||||
|
||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
Utils.deprecate("sweep_for_events has been renamed to sweep_for_advancements. The functionality is the same. "
|
||||
"Please switch over to sweep_for_advancements.")
|
||||
return self.sweep_for_advancements(locations)
|
||||
|
||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.multiworld.get_filled_locations()
|
||||
reachable_events = True
|
||||
# since the loop has a good chance to run more than once, only filter the events once
|
||||
locations = {location for location in locations if location.advancement and location not in self.events and
|
||||
not key_only or getattr(location.item, "locked_dungeon_item", False)}
|
||||
while reachable_events:
|
||||
reachable_events = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_events
|
||||
for event in reachable_events:
|
||||
self.events.add(event)
|
||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(event.item, True, event)
|
||||
reachable_advancements = True
|
||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
||||
|
||||
while reachable_advancements:
|
||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_advancements
|
||||
for advancement in reachable_advancements:
|
||||
self.advancements.add(advancement)
|
||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(advancement.item, True, advancement)
|
||||
|
||||
# item name related
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
@@ -727,7 +848,7 @@ class CollectionState():
|
||||
if found >= count:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
|
||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
||||
Ignores duplicates of the same item."""
|
||||
@@ -742,7 +863,7 @@ class CollectionState():
|
||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state."""
|
||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||
|
||||
|
||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
||||
@@ -788,20 +909,16 @@ class CollectionState():
|
||||
)
|
||||
|
||||
# Item related
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
def collect(self, item: Item, prevent_sweep: bool = False, location: Optional[Location] = None) -> bool:
|
||||
if location:
|
||||
self.locations_checked.add(location)
|
||||
|
||||
changed = self.multiworld.worlds[item.player].collect(self, item)
|
||||
|
||||
if not changed and event:
|
||||
self.prog_items[item.player][item.name] += 1
|
||||
changed = True
|
||||
|
||||
self.stale[item.player] = True
|
||||
|
||||
if changed and not event:
|
||||
self.sweep_for_events()
|
||||
if changed and not prevent_sweep:
|
||||
self.sweep_for_advancements()
|
||||
|
||||
return changed
|
||||
|
||||
@@ -825,12 +942,13 @@ class Entrance:
|
||||
addresses = None
|
||||
target = None
|
||||
|
||||
def __init__(self, player: int, name: str = '', parent: Region = None):
|
||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
|
||||
self.name = name
|
||||
self.parent_region = parent
|
||||
self.player = player
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||
if not self.hide_path and not self in state.path:
|
||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||
@@ -845,9 +963,6 @@ class Entrance:
|
||||
region.entrances.append(self)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
@@ -973,7 +1088,7 @@ class Region:
|
||||
self.locations.append(location_type(self.player, location, address, self))
|
||||
|
||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||
"""
|
||||
Connects this Region to another Region, placing the provided rule on the connection.
|
||||
|
||||
@@ -1013,9 +1128,6 @@ class Region:
|
||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
@@ -1034,9 +1146,9 @@ class Location:
|
||||
locked: bool = False
|
||||
show_in_spoiler: bool = True
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow = staticmethod(lambda state, item: False)
|
||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
item_rule = staticmethod(lambda item: True)
|
||||
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
|
||||
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
||||
@@ -1045,16 +1157,20 @@ class Location:
|
||||
self.address = address
|
||||
self.parent_region = parent
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
|
||||
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_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
|
||||
return ((
|
||||
self.always_allow(state, item)
|
||||
and item.name not in state.multiworld.worlds[item.player].options.non_local_items
|
||||
) or (
|
||||
(self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))
|
||||
))
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
# self.access_rule computes faster on average, so placing it first for faster abort
|
||||
assert self.parent_region, "Can't reach location without region"
|
||||
return self.access_rule(state) and self.parent_region.can_reach(state)
|
||||
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
|
||||
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
|
||||
return self.parent_region.can_reach(state) and self.access_rule(state)
|
||||
|
||||
def place_locked_item(self, item: Item):
|
||||
if self.item:
|
||||
@@ -1064,9 +1180,6 @@ class Location:
|
||||
self.locked = True
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
@@ -1088,7 +1201,7 @@ class Location:
|
||||
@property
|
||||
def native_item(self) -> bool:
|
||||
"""Returns True if the item in this location matches game."""
|
||||
return self.item and self.item.game == self.game
|
||||
return self.item is not None and self.item.game == self.game
|
||||
|
||||
@property
|
||||
def hint_text(self) -> str:
|
||||
@@ -1099,7 +1212,7 @@ class ItemClassification(IntFlag):
|
||||
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
||||
progression = 0b0001 # Item that is logically relevant
|
||||
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
||||
trap = 0b0100 # detrimental or entirely useless (nothing) item
|
||||
trap = 0b0100 # detrimental item
|
||||
skip_balancing = 0b1000 # should technically never occur on its own
|
||||
# Item that is logically relevant, but progression balancing should not touch.
|
||||
# Typically currency or other counted items.
|
||||
@@ -1171,9 +1284,6 @@ class Item:
|
||||
return hash((self.name, self.player))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
|
||||
return self.location.parent_region.multiworld.get_name_string_for_object(self)
|
||||
return f"{self.name} (Player {self.player})"
|
||||
@@ -1251,9 +1361,9 @@ class Spoiler:
|
||||
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later = {}
|
||||
restore_later: Dict[Location, Item] = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete = set()
|
||||
to_delete: Set[Location] = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
@@ -1271,7 +1381,7 @@ class Spoiler:
|
||||
sphere -= to_delete
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected = []
|
||||
removed_precollected: List[Item] = []
|
||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
multiworld.precollected_items[item.player].remove(item)
|
||||
@@ -1291,8 +1401,6 @@ class Spoiler:
|
||||
state = CollectionState(multiworld)
|
||||
collection_spheres = []
|
||||
while required_locations:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
sphere = set(filter(state.can_reach, required_locations))
|
||||
|
||||
for location in sphere:
|
||||
@@ -1354,7 +1462,7 @@ class Spoiler:
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
||||
for (_, exit_path) in path):
|
||||
if multiworld.mode[player] != 'inverted':
|
||||
if multiworld.worlds[player].options.mode != 'inverted':
|
||||
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
@@ -1426,9 +1534,9 @@ class Spoiler:
|
||||
|
||||
if self.paths:
|
||||
outfile.write('\n\nPaths:\n\n')
|
||||
path_listings = []
|
||||
path_listings: List[str] = []
|
||||
for location, path in sorted(self.paths.items()):
|
||||
path_lines = []
|
||||
path_lines: List[str] = []
|
||||
for region, exit in path:
|
||||
if exit is not None:
|
||||
path_lines.append("{} -> {}".format(region, exit))
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds._bizhawk.context import launch
|
||||
|
||||
if __name__ == "__main__":
|
||||
launch()
|
||||
launch(*sys.argv[1:])
|
||||
|
||||
@@ -45,10 +45,21 @@ def get_ssl_context():
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
"""
|
||||
The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
|
||||
when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
|
||||
|
||||
The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
|
||||
space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
|
||||
and method("one", "two", "three") without.
|
||||
|
||||
In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
|
||||
"""
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
|
||||
def output(self, text: str):
|
||||
"""Helper function to abstract logging to the CommonClient UI"""
|
||||
logger.info(text)
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
@@ -164,13 +175,14 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def default(self, raw: str):
|
||||
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext:
|
||||
# Should be adjusted as needed in subclasses
|
||||
# The following attributes are used to Connect and should be adjusted as needed in subclasses
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
game: typing.Optional[str] = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
@@ -252,7 +264,7 @@ class CommonContext:
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||
ui = None
|
||||
ui: typing.Optional["kvui.GameManager"] = None
|
||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
@@ -429,7 +441,10 @@ class CommonContext:
|
||||
self.auth = await self.console_input()
|
||||
|
||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||
""" send `Connect` packet to log in to server """
|
||||
"""
|
||||
Send a `Connect` packet to log in to the server,
|
||||
additional keyword args can override any value in the connection packet
|
||||
"""
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
@@ -459,6 +474,7 @@ class CommonContext:
|
||||
return False
|
||||
|
||||
def slot_concerns_self(self, slot) -> bool:
|
||||
"""Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
|
||||
if slot == self.slot:
|
||||
return True
|
||||
if slot in self.slot_info:
|
||||
@@ -466,6 +482,7 @@ class CommonContext:
|
||||
return False
|
||||
|
||||
def is_echoed_chat(self, print_json_packet: dict) -> bool:
|
||||
"""Helper function for filtering out messages sent by self."""
|
||||
return print_json_packet.get("type", "") == "Chat" \
|
||||
and print_json_packet.get("team", None) == self.team \
|
||||
and print_json_packet.get("slot", None) == self.slot
|
||||
@@ -497,13 +514,14 @@ class CommonContext:
|
||||
"""Gets called before sending a Say to the server from the user.
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
|
||||
def on_ui_command(self, text: str) -> None:
|
||||
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
|
||||
The command processor is still called; this is just intended for command echoing."""
|
||||
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
"""Internal method to parse and save server permissions from RoomInfo"""
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
try:
|
||||
flag = Permission(permission_flag)
|
||||
@@ -613,6 +631,7 @@ class CommonContext:
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
"""Helper function to send a deathlink using death_text as the unique death cause string."""
|
||||
if self.server and self.server.socket:
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
@@ -626,6 +645,7 @@ class CommonContext:
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link: bool):
|
||||
"""Helper function to set Death Link connection tag on/off and update the connection if already connected."""
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
@@ -635,7 +655,7 @@ class CommonContext:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
||||
"""Displays an error messagebox"""
|
||||
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework"""
|
||||
if not self.ui:
|
||||
return None
|
||||
title = title or "Error"
|
||||
@@ -662,17 +682,19 @@ class CommonContext:
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
||||
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Text Client"
|
||||
|
||||
self.ui = TextManager(self)
|
||||
return TextManager
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
|
||||
ui_class = self.make_gui()
|
||||
self.ui = ui_class(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def run_cli(self):
|
||||
@@ -985,6 +1007,7 @@ async def console_loop(ctx: CommonContext):
|
||||
|
||||
|
||||
def get_base_parser(description: typing.Optional[str] = None):
|
||||
"""Base argument parser to be reused for components subclassing off of CommonClient"""
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
@@ -994,7 +1017,7 @@ def get_base_parser(description: typing.Optional[str] = None):
|
||||
return parser
|
||||
|
||||
|
||||
def run_as_textclient():
|
||||
def run_as_textclient(*args):
|
||||
class TextContext(CommonContext):
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
tags = CommonContext.tags | {"TextOnly"}
|
||||
@@ -1033,16 +1056,21 @@ def run_as_textclient():
|
||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(args)
|
||||
|
||||
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
if url.scheme == "archipelago":
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
else:
|
||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||
|
||||
# use colorama to display colored text highlighting on windows
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main(args))
|
||||
@@ -1051,4 +1079,4 @@ def run_as_textclient():
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
|
||||
run_as_textclient()
|
||||
run_as_textclient(*sys.argv[1:]) # default value for parse_args
|
||||
|
||||
54
Fill.py
54
Fill.py
@@ -12,7 +12,12 @@ from worlds.generic.Rules import add_item_rule
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
pass
|
||||
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
|
||||
if "multiworld" in kwargs and isinstance(args[0], str):
|
||||
placements = (args[0] + f"\nAll Placements:\n" +
|
||||
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
|
||||
args = (placements, *args[1:])
|
||||
super().__init__(*args)
|
||||
|
||||
|
||||
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
||||
@@ -24,7 +29,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
new_state.sweep_for_events(locations=locations)
|
||||
new_state.sweep_for_advancements(locations=locations)
|
||||
return new_state
|
||||
|
||||
|
||||
@@ -212,7 +217,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
f"Unfilled locations:\n"
|
||||
f"{', '.join(str(location) for location in locations)}\n"
|
||||
f"Already placed {len(placements)}:\n"
|
||||
f"{', '.join(str(place) for place in placements)}")
|
||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||
|
||||
item_pool.extend(unplaced_items)
|
||||
|
||||
@@ -299,7 +304,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
f"Unfilled locations:\n"
|
||||
f"{', '.join(str(location) for location in locations)}\n"
|
||||
f"Already placed {len(placements)}:\n"
|
||||
f"{', '.join(str(place) for place in placements)}")
|
||||
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
@@ -324,8 +329,8 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
|
||||
pool.append(location.item)
|
||||
state.remove(location.item)
|
||||
location.item = None
|
||||
if location in state.events:
|
||||
state.events.remove(location)
|
||||
if location in state.advancements:
|
||||
state.advancements.remove(location)
|
||||
locations.append(location)
|
||||
if pool and locations:
|
||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||
@@ -358,7 +363,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
early_priority_locations: typing.List[Location] = []
|
||||
loc_indexes_to_remove: typing.Set[int] = set()
|
||||
base_state = multiworld.state.copy()
|
||||
base_state.sweep_for_events(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.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:
|
||||
@@ -470,28 +475,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
nonlocal lock_later
|
||||
lock_later.append(location)
|
||||
|
||||
single_player = multiworld.players == 1 and not multiworld.groups
|
||||
|
||||
if prioritylocations:
|
||||
# "priority fill"
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
|
||||
name="Priority")
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
|
||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
if progitempool:
|
||||
# "advancement/progression fill"
|
||||
if panic_method == "swap":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
||||
name="Progression", single_player_placement=single_player)
|
||||
elif panic_method == "raise":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||
name="Progression", single_player_placement=single_player)
|
||||
elif panic_method == "start_inventory":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False, allow_partial=True,
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
||||
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||
if progitempool:
|
||||
for item in progitempool:
|
||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
||||
@@ -506,7 +509,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f"Not enough locations for progression items. "
|
||||
f"There are {len(progitempool)} more progression items than there are available locations."
|
||||
f"There are {len(progitempool)} more progression items than there are available locations.",
|
||||
multiworld=multiworld,
|
||||
)
|
||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||
|
||||
@@ -523,7 +527,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if excludedlocations:
|
||||
raise FillError(
|
||||
f"Not enough filler items for excluded locations. "
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items."
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
|
||||
multiworld=multiworld,
|
||||
)
|
||||
|
||||
restitempool = filleritempool + usefulitempool
|
||||
@@ -551,7 +556,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
progress_done = False
|
||||
|
||||
# sweep once to pick up preplaced items
|
||||
multiworld.state.sweep_for_events()
|
||||
multiworld.state.sweep_for_advancements()
|
||||
|
||||
# fill multiworld from top of itempool while we can
|
||||
while not progress_done:
|
||||
@@ -589,7 +594,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
if candidate_item_to_place is not None:
|
||||
item_to_place = candidate_item_to_place
|
||||
else:
|
||||
raise FillError('No more progress items left to place.')
|
||||
raise FillError('No more progress items left to place.', multiworld=multiworld)
|
||||
|
||||
# find item to replace with progress item
|
||||
location_list = multiworld.get_reachable_locations()
|
||||
@@ -646,7 +651,6 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
|
||||
def get_sphere_locations(sphere_state: CollectionState,
|
||||
locations: typing.Set[Location]) -> typing.Set[Location]:
|
||||
sphere_state.sweep_for_events(key_only=True, locations=locations)
|
||||
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
||||
|
||||
def item_percentage(player: int, num: int) -> float:
|
||||
@@ -740,7 +744,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
), items_to_test):
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||
reducing_state.sweep_for_advancements(locations=locations_to_test)
|
||||
|
||||
if multiworld.has_beaten_game(balancing_state):
|
||||
if not multiworld.has_beaten_game(reducing_state):
|
||||
@@ -823,7 +827,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
||||
warn(warning, force)
|
||||
|
||||
swept_state = multiworld.state.copy()
|
||||
swept_state.sweep_for_events()
|
||||
swept_state.sweep_for_advancements()
|
||||
reachable = frozenset(multiworld.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)
|
||||
|
||||
36
Generate.py
36
Generate.py
@@ -43,10 +43,10 @@ def mystery_argparse():
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default=defaults.plando_options,
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
parser.add_argument("--csv_output", action="store_true",
|
||||
help="Output rolled player options to csv (made for async multiworld).")
|
||||
parser.add_argument("--plando", default=defaults.plando_options,
|
||||
help="List of options that can be set manually. Can be combined, for example \"bosses, items\"")
|
||||
parser.add_argument("--skip_prog_balancing", action="store_true",
|
||||
help="Skip progression balancing step during generation.")
|
||||
parser.add_argument("--skip_output", action="store_true",
|
||||
@@ -155,6 +155,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||
erargs.skip_output = args.skip_output
|
||||
erargs.name = {}
|
||||
erargs.csv_output = args.csv_output
|
||||
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||
@@ -202,7 +204,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
elif player not in erargs.name: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
@@ -215,28 +217,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
important = {}
|
||||
for option, player_settings in vars(erargs).items():
|
||||
if type(player_settings) == dict:
|
||||
if all(type(value) != list for value in player_settings.values()):
|
||||
if len(player_settings.values()) > 1:
|
||||
important[option] = {player: value for player, value in player_settings.items() if
|
||||
player <= args.yaml_output}
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
|
||||
else:
|
||||
if player_settings != "": # is not empty name
|
||||
important[option] = player_settings
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
return erargs, seed
|
||||
|
||||
|
||||
@@ -511,7 +491,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
continue
|
||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||
if ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights)
|
||||
|
||||
|
||||
9
KH1Client.py
Normal file
9
KH1Client.py
Normal file
@@ -0,0 +1,9 @@
|
||||
if __name__ == '__main__':
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
Utils.init_logging("KH1Client", exception_logger="Client")
|
||||
|
||||
from worlds.kh1.Client import launch
|
||||
launch()
|
||||
105
Launcher.py
105
Launcher.py
@@ -16,10 +16,11 @@ import multiprocessing
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Callable, Sequence, Union, Optional
|
||||
from typing import Callable, Optional, Sequence, Tuple, Union
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
@@ -107,7 +108,81 @@ components.extend([
|
||||
])
|
||||
|
||||
|
||||
def identify(path: Union[None, str]):
|
||||
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
||||
url = urllib.parse.urlparse(path)
|
||||
queries = urllib.parse.parse_qs(url.query)
|
||||
launch_args = (path, *launch_args)
|
||||
client_component = None
|
||||
text_client_component = None
|
||||
if "game" in queries:
|
||||
game = queries["game"][0]
|
||||
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
||||
game = "Archipelago"
|
||||
for component in components:
|
||||
if component.supports_uri and component.game_name == game:
|
||||
client_component = component
|
||||
elif component.display_name == "Text Client":
|
||||
text_client_component = component
|
||||
|
||||
from kvui import App, Button, BoxLayout, Label, Clock, Window
|
||||
|
||||
class Popup(App):
|
||||
timer_label: Label
|
||||
remaining_time: Optional[int]
|
||||
|
||||
def __init__(self):
|
||||
self.title = "Connect to Multiworld"
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
def build(self):
|
||||
layout = BoxLayout(orientation="vertical")
|
||||
|
||||
if client_component is None:
|
||||
self.remaining_time = 7
|
||||
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
|
||||
f"Launching Text Client in 7 seconds...")
|
||||
self.timer_label = Label(text=label_text)
|
||||
layout.add_widget(self.timer_label)
|
||||
Clock.schedule_interval(self.update_label, 1)
|
||||
else:
|
||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
||||
|
||||
text_client_button = Button(
|
||||
text=text_client_component.display_name,
|
||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(text_client_button)
|
||||
|
||||
game_client_button = Button(
|
||||
text=client_component.display_name,
|
||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
||||
)
|
||||
button_row.add_widget(game_client_button)
|
||||
|
||||
layout.add_widget(button_row)
|
||||
|
||||
return layout
|
||||
|
||||
def update_label(self, dt):
|
||||
if self.remaining_time > 1:
|
||||
# countdown the timer and string replace the number
|
||||
self.remaining_time -= 1
|
||||
self.timer_label.text = self.timer_label.text.replace(
|
||||
str(self.remaining_time + 1), str(self.remaining_time)
|
||||
)
|
||||
else:
|
||||
# our timer is finished so launch text client and close down
|
||||
run_component(text_client_component, *launch_args)
|
||||
Clock.unschedule(self.update_label)
|
||||
App.get_running_app().stop()
|
||||
Window.close()
|
||||
|
||||
Popup().run()
|
||||
|
||||
|
||||
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||
if path is None:
|
||||
return None, None
|
||||
for component in components:
|
||||
@@ -266,7 +341,7 @@ def run_gui():
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
else:
|
||||
logging.warning(f"unable to identify component for {filename}")
|
||||
logging.warning(f"unable to identify component for {file}")
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
@@ -299,20 +374,24 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
if args.get("Patch|Game|Component", None) is not None:
|
||||
file, component = identify(args["Patch|Game|Component"])
|
||||
path = args.get("Patch|Game|Component|url", None)
|
||||
if path is not None:
|
||||
if path.startswith("archipelago://"):
|
||||
handle_uri(path, args.get("args", ()))
|
||||
return
|
||||
file, component = identify(path)
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
if not component:
|
||||
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
||||
logging.warning(f"Could not identify Component responsible for {path}")
|
||||
|
||||
if args["update_settings"]:
|
||||
update_settings()
|
||||
if 'file' in args:
|
||||
if "file" in args:
|
||||
run_component(args["component"], args["file"], *args["args"])
|
||||
elif 'component' in args:
|
||||
elif "component" in args:
|
||||
run_component(args["component"], *args["args"])
|
||||
elif not args["update_settings"]:
|
||||
run_gui()
|
||||
@@ -322,12 +401,16 @@ if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
Utils.freeze_support()
|
||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Archipelago Launcher',
|
||||
usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
|
||||
)
|
||||
run_group = parser.add_argument_group("Run")
|
||||
run_group.add_argument("--update_settings", action="store_true",
|
||||
help="Update host.yaml and exit.")
|
||||
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game, the component name to run, or a url to "
|
||||
"connect with.")
|
||||
run_group.add_argument("args", nargs="*",
|
||||
help="Arguments to pass to component.")
|
||||
main(parser.parse_args())
|
||||
|
||||
@@ -467,6 +467,8 @@ class LinksAwakeningContext(CommonContext):
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||
self.client = LinksAwakeningClient()
|
||||
self.slot_data = {}
|
||||
|
||||
if magpie:
|
||||
self.magpie_enabled = True
|
||||
self.magpie = MagpieBridge()
|
||||
@@ -564,6 +566,8 @@ class LinksAwakeningContext(CommonContext):
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
self.slot_data = args.get("slot_data", {})
|
||||
|
||||
# TODO - use watcher_event
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], start=args["index"]):
|
||||
@@ -628,6 +632,7 @@ class LinksAwakeningContext(CommonContext):
|
||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||
await self.magpie.send_gps(self.client.gps_tracker)
|
||||
self.magpie.slot_data = self.slot_data
|
||||
except Exception:
|
||||
# Don't let magpie errors take out the client
|
||||
pass
|
||||
|
||||
@@ -14,7 +14,7 @@ import tkinter as tk
|
||||
from argparse import Namespace
|
||||
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||
from glob import glob
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \
|
||||
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||
from tkinter.constants import DISABLED, NORMAL
|
||||
from urllib.parse import urlparse
|
||||
@@ -29,7 +29,8 @@ from Utils import output_path, local_path, user_path, open_file, get_cert_none_s
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
|
||||
WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
@@ -242,16 +243,17 @@ def adjustGUI():
|
||||
from argparse import Namespace
|
||||
from Utils import __version__ as MWVersion
|
||||
adjustWindow = Tk()
|
||||
adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
|
||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||
set_icon(adjustWindow)
|
||||
|
||||
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
|
||||
|
||||
bottomFrame2 = Frame(adjustWindow)
|
||||
bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
|
||||
|
||||
romFrame, romVar = get_rom_frame(adjustWindow)
|
||||
|
||||
romDialogFrame = Frame(adjustWindow)
|
||||
romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
|
||||
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
|
||||
romVar2 = StringVar()
|
||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||
@@ -261,9 +263,9 @@ def adjustGUI():
|
||||
romVar2.set(rom)
|
||||
|
||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||
romDialogFrame.pack(side=TOP, expand=True, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT)
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=X)
|
||||
romDialogFrame.pack(side=TOP, expand=False, fill=X)
|
||||
baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
|
||||
romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romSelectButton2.pack(side=LEFT)
|
||||
|
||||
def adjustRom():
|
||||
@@ -331,12 +333,11 @@ def adjustGUI():
|
||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||
|
||||
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
|
||||
rom_options_frame.pack(side=TOP)
|
||||
rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True)
|
||||
adjustButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
|
||||
saveButton.pack(side=LEFT, padx=(5,5))
|
||||
|
||||
bottomFrame2.pack(side=TOP, pady=(5,5))
|
||||
|
||||
tkinter_center_window(adjustWindow)
|
||||
@@ -576,7 +577,7 @@ class AttachTooltip(object):
|
||||
def get_rom_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
romFrame = Frame(parent)
|
||||
romFrame = Frame(parent, padx=8, pady=8)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
romVar = StringVar(value=adjuster_settings.baserom)
|
||||
romEntry = Entry(romFrame, textvariable=romVar)
|
||||
@@ -596,20 +597,19 @@ def get_rom_frame(parent=None):
|
||||
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||
|
||||
baseRomLabel.pack(side=LEFT)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
|
||||
romSelectButton.pack(side=LEFT)
|
||||
romFrame.pack(side=TOP, expand=True, fill=X)
|
||||
romFrame.pack(side=TOP, fill=X)
|
||||
|
||||
return romFrame, romVar
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
romOptionsFrame.columnconfigure(1, weight=1)
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
|
||||
|
||||
for i in range(5):
|
||||
romOptionsFrame.rowconfigure(i, weight=1)
|
||||
romOptionsFrame.rowconfigure(i, weight=0, pad=4)
|
||||
vars = Namespace()
|
||||
|
||||
vars.MusicVar = IntVar()
|
||||
@@ -660,7 +660,7 @@ def get_rom_options_frame(parent=None):
|
||||
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
||||
|
||||
baseSpriteLabel.pack(side=LEFT)
|
||||
spriteEntry.pack(side=LEFT)
|
||||
spriteEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
spriteSelectButton.pack(side=LEFT)
|
||||
|
||||
oofDialogFrame = Frame(romOptionsFrame)
|
||||
|
||||
102
Main.py
102
Main.py
@@ -11,7 +11,8 @@ from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||
flood_items
|
||||
from Options import StartInventoryPool
|
||||
from Utils import __version__, output_path, version_tuple, get_settings
|
||||
from settings import get_settings
|
||||
@@ -45,6 +46,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld.sprite_pool = args.sprite_pool.copy()
|
||||
|
||||
multiworld.set_options(args)
|
||||
if args.csv_output:
|
||||
from Options import dump_player_options
|
||||
dump_player_options(multiworld)
|
||||
multiworld.set_item_links()
|
||||
multiworld.state = CollectionState(multiworld)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
|
||||
@@ -100,7 +104,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
multiworld.early_items[player][item_name] = max(0, early-count)
|
||||
remaining_count = count-early
|
||||
if remaining_count > 0:
|
||||
local_early = multiworld.early_local_items[player].get(item_name, 0)
|
||||
local_early = multiworld.local_early_items[player].get(item_name, 0)
|
||||
if local_early:
|
||||
multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
|
||||
del local_early
|
||||
@@ -151,6 +155,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
||||
new_items: List[Item] = []
|
||||
old_items: List[Item] = []
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: getattr(multiworld.worlds[player].options,
|
||||
"start_inventory_from_pool",
|
||||
@@ -169,97 +174,26 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
depletion_pool[item.player][item.name] -= 1
|
||||
# quick abort if we have found all items
|
||||
if not target:
|
||||
new_items.extend(multiworld.itempool[i+1:])
|
||||
old_items.extend(multiworld.itempool[i+1:])
|
||||
break
|
||||
else:
|
||||
new_items.append(item)
|
||||
old_items.append(item)
|
||||
|
||||
# leftovers?
|
||||
if target:
|
||||
for player, remaining_items in depletion_pool.items():
|
||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||
if remaining_items:
|
||||
raise Exception(f"{multiworld.get_player_name(player)}"
|
||||
logger.warning(f"{multiworld.get_player_name(player)}"
|
||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||
assert len(multiworld.itempool) == len(new_items), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_items
|
||||
# find all filler we generated for the current player and remove until it matches
|
||||
removables = [item for item in new_items if item.player == player]
|
||||
for _ in range(sum(remaining_items.values())):
|
||||
new_items.remove(removables.pop())
|
||||
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
|
||||
multiworld.itempool[:] = new_items + old_items
|
||||
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in multiworld.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||
]:
|
||||
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in multiworld.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del (counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
if count:
|
||||
for player in players:
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del (counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool: List[Item] = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
# mangle together all original classification bits
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", group_id, multiworld, "ItemLink")
|
||||
multiworld.regions.append(region)
|
||||
locations = region.locations
|
||||
for item in multiworld.itempool:
|
||||
count = common_item_count.get(item.player, {}).get(item.name, 0)
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {multiworld.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
else:
|
||||
new_itempool.append(item)
|
||||
|
||||
itemcount = len(multiworld.itempool)
|
||||
multiworld.itempool = new_itempool
|
||||
|
||||
while itemcount > len(multiworld.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["link_replacement"]:
|
||||
item_player = group_id
|
||||
else:
|
||||
item_player = player
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(multiworld, "create_item", item_player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(multiworld, "create_filler", item_player))
|
||||
multiworld.random.shuffle(items_to_add)
|
||||
multiworld.itempool.extend(items_to_add[:itemcount - len(multiworld.itempool)])
|
||||
multiworld.link_items()
|
||||
|
||||
if any(multiworld.item_links.values()):
|
||||
multiworld._all_state = None
|
||||
@@ -416,7 +350,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
output_file_futures.append(pool.submit(write_multidata))
|
||||
if not check_accessibility_task.result():
|
||||
if not multiworld.can_beat_game():
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
|
||||
@@ -75,13 +75,13 @@ def update(yes: bool = False, force: bool = False) -> None:
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
prev = "" # if a line ends in \ we store here and merge later
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
|
||||
@@ -67,6 +67,21 @@ def update_dict(dictionary, entries):
|
||||
return dictionary
|
||||
|
||||
|
||||
def queue_gc():
|
||||
import gc
|
||||
from threading import Thread
|
||||
|
||||
gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
|
||||
def async_collect():
|
||||
time.sleep(2)
|
||||
setattr(queue_gc, "_thread", None)
|
||||
gc.collect()
|
||||
if not gc_thread:
|
||||
gc_thread = Thread(target=async_collect)
|
||||
setattr(queue_gc, "_thread", gc_thread)
|
||||
gc_thread.start()
|
||||
|
||||
|
||||
# functions callable on storable data on the server by clients
|
||||
modify_functions = {
|
||||
# generic:
|
||||
@@ -551,6 +566,9 @@ class Context:
|
||||
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||
else:
|
||||
self.save_dirty = False
|
||||
if not atexit_save: # if atexit is used, that keeps a reference anyway
|
||||
queue_gc()
|
||||
|
||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||
self.auto_saver_thread.start()
|
||||
|
||||
@@ -991,7 +1009,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
||||
collect_player(ctx, team, group, True)
|
||||
|
||||
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
|
||||
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
||||
|
||||
|
||||
@@ -1203,6 +1221,10 @@ class CommonCommandProcessor(CommandProcessor):
|
||||
timer = int(seconds, 10)
|
||||
except ValueError:
|
||||
timer = 10
|
||||
else:
|
||||
if timer > 60 * 60:
|
||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||
|
||||
async_start(countdown(self.ctx, timer))
|
||||
return True
|
||||
|
||||
@@ -1350,10 +1372,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def _cmd_remaining(self) -> bool:
|
||||
"""List remaining items in your game, but not their location or recipient"""
|
||||
if self.ctx.remaining_mode == "enabled":
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if rest_locations:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||
for slot, item_id in rest_locations))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
return True
|
||||
@@ -1363,10 +1385,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return False
|
||||
else: # is goal
|
||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if remaining_item_ids:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
|
||||
for item_id in remaining_item_ids))
|
||||
rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||
if rest_locations:
|
||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
|
||||
for slot, item_id in rest_locations))
|
||||
else:
|
||||
self.output("No remaining items found.")
|
||||
return True
|
||||
@@ -2039,6 +2061,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
item_name, usable, response = get_intended_text(item_name, names)
|
||||
if usable:
|
||||
amount: int = int(amount)
|
||||
if amount > 100:
|
||||
raise ValueError(f"{amount} is invalid. Maximum is 100.")
|
||||
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
||||
send_items_to(self.ctx, team, slot, *new_items)
|
||||
|
||||
|
||||
12
NetUtils.py
12
NetUtils.py
@@ -79,6 +79,7 @@ class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
location: int
|
||||
player: int
|
||||
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
|
||||
flags: int = 0
|
||||
|
||||
|
||||
@@ -272,7 +273,8 @@ class RawJSONtoTextParser(JSONtoTextParser):
|
||||
|
||||
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
||||
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47,
|
||||
'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
|
||||
|
||||
|
||||
def color_code(*args):
|
||||
@@ -397,12 +399,12 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||
location_id not in checked]
|
||||
|
||||
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[int]:
|
||||
) -> typing.List[typing.Tuple[int, int]]:
|
||||
checked = state[team, slot]
|
||||
player_locations = self[slot]
|
||||
return sorted([player_locations[location_id][0] for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||
|
||||
170
Options.py
170
Options.py
@@ -8,16 +8,17 @@ import numbers
|
||||
import random
|
||||
import typing
|
||||
import enum
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from schema import And, Optional, Or, Schema
|
||||
from typing_extensions import Self
|
||||
|
||||
from Utils import get_fuzzy_results, is_iterable_except_str
|
||||
from Utils import get_fuzzy_results, is_iterable_except_str, output_path
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import PlandoOptions
|
||||
from BaseClasses import MultiWorld, PlandoOptions
|
||||
from worlds.AutoWorld import World
|
||||
import pathlib
|
||||
|
||||
@@ -704,10 +705,26 @@ class Range(NumericOption):
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
|
||||
@classmethod
|
||||
def custom_range(cls, text) -> Range:
|
||||
textsplit = text.split("-")
|
||||
def custom_range(cls, text: str) -> Range:
|
||||
numeric_text: str = text[len("random-range-"):]
|
||||
if numeric_text.startswith(("low", "middle", "high")):
|
||||
numeric_text = numeric_text.split("-", 1)[1]
|
||||
textsplit = numeric_text.split("-")
|
||||
if len(textsplit) > 2: # looks like there may be minus signs, which will now be empty string from the split
|
||||
new_textsplit: typing.List[str] = []
|
||||
next_negative: bool = False
|
||||
for element in textsplit:
|
||||
if not element: # empty string -> next element gets a minus sign in front
|
||||
next_negative = True
|
||||
elif next_negative:
|
||||
new_textsplit.append("-"+element)
|
||||
next_negative = False
|
||||
else:
|
||||
new_textsplit.append(element)
|
||||
textsplit = new_textsplit
|
||||
del next_negative, new_textsplit
|
||||
try:
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
random_range = [int(textsplit[0]), int(textsplit[1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
@@ -786,17 +803,22 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
||||
verify_location_name: bool = False
|
||||
value: typing.Any
|
||||
|
||||
@classmethod
|
||||
def verify_keys(cls, data: typing.Iterable[str]) -> None:
|
||||
if cls.valid_keys:
|
||||
data = set(data)
|
||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||
extra = dataset - cls._valid_keys
|
||||
def verify_keys(self) -> None:
|
||||
if self.valid_keys:
|
||||
data = set(self.value)
|
||||
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
|
||||
extra = dataset - self._valid_keys
|
||||
if extra:
|
||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||
f"Allowed keys: {cls._valid_keys}.")
|
||||
raise OptionError(
|
||||
f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
|
||||
f"Allowed keys: {self._valid_keys}."
|
||||
)
|
||||
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
try:
|
||||
self.verify_keys()
|
||||
except OptionError as validation_error:
|
||||
raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
|
||||
if self.convert_name_groups and self.verify_item_name:
|
||||
new_value = type(self.value)() # empty container of whatever value is
|
||||
for item_name in self.value:
|
||||
@@ -833,7 +855,6 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||
if type(data) == dict:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||
@@ -879,7 +900,6 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if is_iterable_except_str(data):
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
@@ -905,7 +925,6 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if is_iterable_except_str(data):
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
@@ -948,6 +967,19 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
self.value = []
|
||||
logging.warning(f"The plando texts module is turned off, "
|
||||
f"so text for {player_name} will be ignored.")
|
||||
else:
|
||||
super().verify(world, player_name, plando_options)
|
||||
|
||||
def verify_keys(self) -> None:
|
||||
if self.valid_keys:
|
||||
data = set(text.at for text in self)
|
||||
dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
|
||||
extra = dataset - self._valid_keys
|
||||
if extra:
|
||||
raise OptionError(
|
||||
f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
|
||||
f"Allowed placements: {self._valid_keys}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
|
||||
@@ -958,7 +990,19 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
if random.random() < float(text.get("percentage", 100)/100):
|
||||
at = text.get("at", None)
|
||||
if at is not None:
|
||||
if isinstance(at, dict):
|
||||
if at:
|
||||
at = random.choices(list(at.keys()),
|
||||
weights=list(at.values()), k=1)[0]
|
||||
else:
|
||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||
given_text = text.get("text", [])
|
||||
if isinstance(given_text, dict):
|
||||
if not given_text:
|
||||
given_text = []
|
||||
else:
|
||||
given_text = random.choices(list(given_text.keys()),
|
||||
weights=list(given_text.values()), k=1)
|
||||
if isinstance(given_text, str):
|
||||
given_text = [given_text]
|
||||
texts.append(PlandoText(
|
||||
@@ -966,12 +1010,13 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
given_text,
|
||||
text.get("percentage", 100)
|
||||
))
|
||||
else:
|
||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||
elif isinstance(text, PlandoText):
|
||||
if random.random() < float(text.percentage/100):
|
||||
texts.append(text)
|
||||
else:
|
||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
||||
cls.verify_keys([text.at for text in texts])
|
||||
return cls(texts)
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
|
||||
@@ -1144,18 +1189,35 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
||||
|
||||
|
||||
class Accessibility(Choice):
|
||||
"""Set rules for reachability of your items/locations.
|
||||
"""
|
||||
Set rules for reachability of your items/locations.
|
||||
|
||||
**Full:** ensure everything can be reached and acquired.
|
||||
|
||||
- **Locations:** ensure everything can be reached and acquired.
|
||||
- **Items:** ensure all logically relevant items can be acquired.
|
||||
- **Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||
"""
|
||||
display_name = "Accessibility"
|
||||
rich_text_doc = True
|
||||
option_locations = 0
|
||||
option_items = 1
|
||||
option_full = 0
|
||||
option_minimal = 2
|
||||
alias_none = 2
|
||||
alias_locations = 0
|
||||
alias_items = 0
|
||||
default = 0
|
||||
|
||||
|
||||
class ItemsAccessibility(Accessibility):
|
||||
"""
|
||||
Set rules for reachability of your items/locations.
|
||||
|
||||
**Full:** ensure everything can be reached and acquired.
|
||||
|
||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||
|
||||
**Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
|
||||
some locations may be inaccessible.
|
||||
"""
|
||||
option_items = 1
|
||||
default = 1
|
||||
|
||||
|
||||
@@ -1205,6 +1267,7 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
||||
:param option_names: names of the options to return
|
||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
||||
"""
|
||||
assert option_names, "options.as_dict() was used without any option names."
|
||||
option_results = {}
|
||||
for option_name in option_names:
|
||||
if option_name in type(self).type_hints:
|
||||
@@ -1289,7 +1352,7 @@ class PriorityLocations(LocationSet):
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
"""When you die, everyone who enabled death link dies. Of course, the reverse is true too."""
|
||||
display_name = "Death Link"
|
||||
rich_text_doc = True
|
||||
|
||||
@@ -1488,29 +1551,40 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
f.write(res)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def dump_player_options(multiworld: MultiWorld) -> None:
|
||||
from csv import DictWriter
|
||||
|
||||
from worlds.alttp.Options import Logic
|
||||
import argparse
|
||||
game_players = defaultdict(list)
|
||||
for player, game in multiworld.game.items():
|
||||
game_players[game].append(player)
|
||||
game_players = dict(sorted(game_players.items()))
|
||||
|
||||
map_shuffle = Toggle
|
||||
compass_shuffle = Toggle
|
||||
key_shuffle = Toggle
|
||||
big_key_shuffle = Toggle
|
||||
hints = Toggle
|
||||
test = argparse.Namespace()
|
||||
test.logic = Logic.from_text("no_logic")
|
||||
test.map_shuffle = map_shuffle.from_text("ON")
|
||||
test.hints = hints.from_text('OFF')
|
||||
try:
|
||||
test.logic = Logic.from_text("overworld_glitches_typo")
|
||||
except KeyError as e:
|
||||
print(e)
|
||||
try:
|
||||
test.logic_owg = Logic.from_text("owg")
|
||||
except KeyError as e:
|
||||
print(e)
|
||||
if test.map_shuffle:
|
||||
print("map_shuffle is on")
|
||||
print(f"Hints are {bool(test.hints)}")
|
||||
print(test)
|
||||
output = []
|
||||
per_game_option_names = [
|
||||
getattr(option, "display_name", option_key)
|
||||
for option_key, option in PerGameCommonOptions.type_hints.items()
|
||||
]
|
||||
all_option_names = per_game_option_names.copy()
|
||||
for game, players in game_players.items():
|
||||
game_option_names = per_game_option_names.copy()
|
||||
for player in players:
|
||||
world = multiworld.worlds[player]
|
||||
player_output = {
|
||||
"Game": multiworld.game[player],
|
||||
"Name": multiworld.get_player_name(player),
|
||||
}
|
||||
output.append(player_output)
|
||||
for option_key, option in world.options_dataclass.type_hints.items():
|
||||
if issubclass(Removed, option):
|
||||
continue
|
||||
display_name = getattr(option, "display_name", option_key)
|
||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||
if display_name not in game_option_names:
|
||||
all_option_names.append(display_name)
|
||||
game_option_names.append(display_name)
|
||||
|
||||
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
||||
fields = ["Game", "Name", *all_option_names]
|
||||
writer = DictWriter(file, fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(output)
|
||||
|
||||
@@ -72,6 +72,10 @@ Currently, the following games are supported:
|
||||
* Aquaria
|
||||
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
* A Hat in Time
|
||||
* Old School Runescape
|
||||
* Kingdom Hearts 1
|
||||
* Mega Man 2
|
||||
* Yacht Dice
|
||||
|
||||
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
|
||||
|
||||
2
Utils.py
2
Utils.py
@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.5.1"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
|
||||
@@ -267,9 +267,7 @@ class WargrooveContext(CommonContext):
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
panel = TabbedPanelItem(text="Wargroove")
|
||||
panel.content = self.build_tracker()
|
||||
self.tabs.add_widget(panel)
|
||||
self.add_client_tab("Wargroove", self.build_tracker())
|
||||
return container
|
||||
|
||||
def build_tracker(self) -> TrackerLayout:
|
||||
|
||||
10
WebHost.py
10
WebHost.py
@@ -1,3 +1,4 @@
|
||||
import argparse
|
||||
import os
|
||||
import multiprocessing
|
||||
import logging
|
||||
@@ -31,6 +32,15 @@ def get_app() -> "Flask":
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--config_override', default=None,
|
||||
help="Path to yaml config file that overrules config.yaml.")
|
||||
args = parser.parse_known_args()[0]
|
||||
if args.config_override:
|
||||
import yaml
|
||||
app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
|
||||
logging.info(f"Updated config from {args.config_override}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
|
||||
@@ -1,51 +1,15 @@
|
||||
"""API endpoints package."""
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Blueprint, abort, url_for
|
||||
from flask import Blueprint
|
||||
|
||||
import worlds.Files
|
||||
from ..models import Room, Seed
|
||||
from ..models import Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
# unsorted/misc endpoints
|
||||
|
||||
|
||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
||||
|
||||
|
||||
@api_endpoints.route('/room_status/<suuid:room>')
|
||||
def room_info(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
def supports_apdeltapatch(game: str):
|
||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||
downloads = []
|
||||
for slot in sorted(room.seed.slots):
|
||||
if slot.data and not supports_apdeltapatch(slot.game):
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
elif slot.data:
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout,
|
||||
"downloads": downloads,
|
||||
}
|
||||
|
||||
|
||||
from . import generate, user, datapackage # trigger registration
|
||||
from . import datapackage, generate, room, user # trigger registration
|
||||
|
||||
42
WebHostLib/api/room.py
Normal file
42
WebHostLib/api/room.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from typing import Any, Dict
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, url_for
|
||||
|
||||
import worlds.Files
|
||||
from . import api_endpoints, get_players
|
||||
from ..models import Room
|
||||
|
||||
|
||||
@api_endpoints.route('/room_status/<suuid:room_id>')
|
||||
def room_info(room_id: UUID) -> Dict[str, Any]:
|
||||
room = Room.get(id=room_id)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
def supports_apdeltapatch(game: str) -> bool:
|
||||
return game in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
downloads = []
|
||||
for slot in sorted(room.seed.slots):
|
||||
if slot.data and not supports_apdeltapatch(slot.game):
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
elif slot.data:
|
||||
slot_download = {
|
||||
"slot": slot.player_id,
|
||||
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
|
||||
}
|
||||
downloads.append(slot_download)
|
||||
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout,
|
||||
"downloads": downloads,
|
||||
}
|
||||
@@ -72,6 +72,14 @@ class WebHostContext(Context):
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
import psutil
|
||||
from Utils import format_SI_prefix
|
||||
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
||||
except ImportError:
|
||||
self.logger.debug("Context destroyed")
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||
@@ -249,6 +257,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx = WebHostContext(static_server_data, logger)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
assert ctx.server is None
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
@@ -279,6 +288,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
if ctx.saving:
|
||||
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
||||
assert ctx.shutdown_task is None
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
|
||||
@@ -325,7 +335,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
def run(self):
|
||||
while 1:
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
gc.collect(0)
|
||||
gc.collect()
|
||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
self._tasks.append(task)
|
||||
task.add_done_callback(self._done)
|
||||
|
||||
@@ -134,6 +134,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
erargs.skip_prog_balancing = False
|
||||
erargs.skip_output = False
|
||||
erargs.csv_output = False
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
|
||||
@@ -132,26 +132,41 @@ def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
@app.post("/room/<suuid:room>")
|
||||
def host_room_command(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
@app.get("/room/<suuid:room>")
|
||||
def host_room(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
# indicate that the page should reload to get the assigned port
|
||||
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
|
||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||
with db_session:
|
||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||
|
||||
def get_log(max_size: int = 1024000) -> str:
|
||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||
automated = ("update" in request.args
|
||||
or "Discordbot" in request.user_agent.string
|
||||
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
||||
|
||||
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
||||
if max_size == 0:
|
||||
return "…"
|
||||
try:
|
||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||
raw_size = 0
|
||||
|
||||
@@ -231,6 +231,13 @@ def generate_yaml(game: str):
|
||||
|
||||
del options[key]
|
||||
|
||||
# Detect keys which end with -range, indicating a NamedRange with a possible custom value
|
||||
elif key_parts[-1].endswith("-range"):
|
||||
if options[key_parts[-1][:-6]] == "custom":
|
||||
options[key_parts[-1][:-6]] = val
|
||||
|
||||
del options[key]
|
||||
|
||||
# Detect random-* keys and set their options accordingly
|
||||
for key, val in options.copy().items():
|
||||
if key.startswith("random-"):
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
flask>=3.0.3
|
||||
werkzeug>=3.0.3
|
||||
pony>=0.7.17
|
||||
werkzeug>=3.0.4
|
||||
pony>=0.7.19
|
||||
waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.7.0
|
||||
Flask-Limiter>=3.8.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.4.1; python_version >= '3.9'
|
||||
bokeh>=3.4.3; python_version == '3.9'
|
||||
bokeh>=3.5.2; python_version >= '3.10'
|
||||
markupsafe>=2.1.5
|
||||
|
||||
@@ -77,4 +77,4 @@ There, you will find examples of games in the `worlds` folder:
|
||||
You may also find developer documentation in the `docs` folder:
|
||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||
|
||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
||||
|
||||
@@ -58,3 +58,28 @@
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.loader{
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
margin-left: 5px;
|
||||
width: 40px;
|
||||
aspect-ratio: 4;
|
||||
--_g: no-repeat radial-gradient(circle closest-side,#fff 90%,#fff0);
|
||||
background:
|
||||
var(--_g) 0 50%,
|
||||
var(--_g) 50% 50%,
|
||||
var(--_g) 100% 50%;
|
||||
background-size: calc(100%/3) 100%;
|
||||
animation: l7 1s infinite linear;
|
||||
}
|
||||
|
||||
.loader.loading{
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@keyframes l7{
|
||||
33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%}
|
||||
50%{background-size:calc(100%/3) 100%,calc(100%/3) 0 ,calc(100%/3) 100%}
|
||||
66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0 }
|
||||
}
|
||||
|
||||
@@ -99,14 +99,18 @@
|
||||
{% if hint.finding_player == player %}
|
||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||
{% else %}
|
||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if hint.receiving_player == player %}
|
||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
||||
{% else %}
|
||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
|
||||
|
||||
@@ -19,28 +19,30 @@
|
||||
{% block body %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<div id="host-room">
|
||||
{% if room.owner == session["_id"] %}
|
||||
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
Should you wish to continue later,
|
||||
anyone can simply refresh this page and the server will resume.<br>
|
||||
{% if room.last_port == -1 %}
|
||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||
{% elif room.last_port %}
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||
{% endif %}
|
||||
<span id="host-room-info">
|
||||
{% if room.owner == session["_id"] %}
|
||||
Room created from <a href="{{ url_for("view_seed", seed=room.seed.id) }}">Seed #{{ room.seed.id|suuid }}</a>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
Should you wish to continue later,
|
||||
anyone can simply refresh this page and the server will resume.<br>
|
||||
{% if room.last_port == -1 %}
|
||||
There was an error hosting this Room. Another attempt will be made on refreshing this page.
|
||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||
{% elif room.last_port %}
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||
{% endif %}
|
||||
</span>
|
||||
{{ macros.list_patches_room(room) }}
|
||||
{% if room.owner == session["_id"] %}
|
||||
<div style="display: flex; align-items: center;">
|
||||
@@ -49,6 +51,7 @@
|
||||
<label for="cmd"></label>
|
||||
<input class="form-control" type="text" id="cmd" name="cmd"
|
||||
placeholder="Server Command. /help to list them, list gets appended to log.">
|
||||
<span class="loader"></span>
|
||||
</div>
|
||||
</form>
|
||||
<a href="{{ url_for("display_log", room=room.id) }}">
|
||||
@@ -62,6 +65,7 @@
|
||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||
let bytesReceived = {{ log_len }};
|
||||
let updateLogTimeout;
|
||||
let updateLogImmediately = false;
|
||||
let awaitingCommandResponse = false;
|
||||
let logger = document.getElementById("logger");
|
||||
|
||||
@@ -78,29 +82,36 @@
|
||||
|
||||
async function updateLog() {
|
||||
try {
|
||||
let res = await fetch(url, {
|
||||
headers: {
|
||||
'Range': `bytes=${bytesReceived}-`,
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
let text = await res.text();
|
||||
if (text.length > 0) {
|
||||
awaitingCommandResponse = false;
|
||||
if (bytesReceived === 0 || res.status !== 206) {
|
||||
logger.innerHTML = '';
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
bytesReceived = 0;
|
||||
} else {
|
||||
bytesReceived += new Blob([text]).size;
|
||||
}
|
||||
if (logger.innerHTML.endsWith('…')) {
|
||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
||||
}
|
||||
logger.appendChild(document.createTextNode(text));
|
||||
scrollToBottom(logger);
|
||||
if (!document.hidden) {
|
||||
updateLogImmediately = false;
|
||||
let res = await fetch(url, {
|
||||
headers: {
|
||||
'Range': `bytes=${bytesReceived}-`,
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
let text = await res.text();
|
||||
if (text.length > 0) {
|
||||
awaitingCommandResponse = false;
|
||||
if (bytesReceived === 0 || res.status !== 206) {
|
||||
logger.innerHTML = '';
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
bytesReceived = 0;
|
||||
} else {
|
||||
bytesReceived += new Blob([text]).size;
|
||||
}
|
||||
if (logger.innerHTML.endsWith('…')) {
|
||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
||||
}
|
||||
logger.appendChild(document.createTextNode(text));
|
||||
scrollToBottom(logger);
|
||||
let loader = document.getElementById("command-form").getElementsByClassName("loader")[0];
|
||||
loader.classList.remove("loading");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateLogImmediately = true;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
@@ -125,20 +136,62 @@
|
||||
});
|
||||
ev.preventDefault(); // has to happen before first await
|
||||
form.reset();
|
||||
let res = await req;
|
||||
if (res.ok || res.type === 'opaqueredirect') {
|
||||
awaitingCommandResponse = true;
|
||||
window.clearTimeout(updateLogTimeout);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
||||
} else {
|
||||
window.alert(res.statusText);
|
||||
let loader = form.getElementsByClassName("loader")[0];
|
||||
loader.classList.add("loading");
|
||||
try {
|
||||
let res = await req;
|
||||
if (res.ok || res.type === 'opaqueredirect') {
|
||||
awaitingCommandResponse = true;
|
||||
window.clearTimeout(updateLogTimeout);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
||||
} else {
|
||||
loader.classList.remove("loading");
|
||||
window.alert(res.statusText);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.classList.remove("loading");
|
||||
window.alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("command-form").addEventListener("submit", postForm);
|
||||
updateLogTimeout = window.setTimeout(updateLog, 1000);
|
||||
logger.scrollTop = logger.scrollHeight;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && updateLogImmediately) {
|
||||
updateLog();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
function updateInfo() {
|
||||
let url = new URL(window.location.href);
|
||||
url.search = "?update";
|
||||
fetch(url)
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error ${res.status}`);
|
||||
}
|
||||
return res.text()
|
||||
})
|
||||
.then(text => new DOMParser().parseFromString(text, 'text/html'))
|
||||
.then(newDocument => {
|
||||
let el = newDocument.getElementById("host-room-info");
|
||||
document.getElementById("host-room-info").innerHTML = el.innerHTML;
|
||||
});
|
||||
}
|
||||
|
||||
if (document.querySelector("meta[http-equiv='refresh']")) {
|
||||
console.log("Refresh!");
|
||||
window.addEventListener('load', function () {
|
||||
for (let i=0; i<3; i++) {
|
||||
window.setTimeout(updateInfo, Math.pow(2, i) * 2000); // 2, 4, 8s
|
||||
}
|
||||
window.stop(); // cancel meta refresh
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
||||
<tr>
|
||||
<td>{{ patch.player_id }}</td>
|
||||
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
|
||||
<td data-tooltip="Connect via Game Client"><a href="archipelago://{{ patch.player_name | e}}:None@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}?game={{ patch.game }}&room={{ room.id | suuid }}">{{ patch.player_name }}</a></td>
|
||||
<td>{{ patch.game }}</td>
|
||||
<td>
|
||||
{% if patch.data %}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
{% macro NamedRange(option_name, option) %}
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="named-range-container">
|
||||
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% for key, val in option.special_range_names.items() %}
|
||||
{% if option.default == val %}
|
||||
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
||||
@@ -64,17 +64,17 @@
|
||||
{% endfor %}
|
||||
<option value="custom" hidden>Custom</option>
|
||||
</select>
|
||||
<div class="named-range-wrapper">
|
||||
<div class="named-range-wrapper js-required">
|
||||
<input
|
||||
type="range"
|
||||
id="{{ option_name }}"
|
||||
name="{{ option_name }}"
|
||||
name="{{ option_name }}-range"
|
||||
min="{{ option.range_start }}"
|
||||
max="{{ option.range_end }}"
|
||||
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
|
||||
{{ "disabled" if option.default == "random" }}
|
||||
/>
|
||||
<span id="{{ option_name }}-value" class="range-value js-required">
|
||||
<span id="{{ option_name }}-value" class="range-value">
|
||||
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
|
||||
</span>
|
||||
{{ RandomizeButton(option_name, option) }}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<noscript>
|
||||
<style>
|
||||
.js-required{
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
{% extends 'tablepage.html' %}
|
||||
|
||||
{%- macro games(slots) -%}
|
||||
{%- set gameList = [] -%}
|
||||
{%- set maxGamesToShow = 10 -%}
|
||||
|
||||
{%- for slot in (slots|list|sort(attribute="player_id"))[:maxGamesToShow] -%}
|
||||
{% set player = "#" + slot["player_id"]|string + " " + slot["player_name"] + " : " + slot["game"] -%}
|
||||
{% set _ = gameList.append(player) -%}
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if slots|length > maxGamesToShow -%}
|
||||
{% set _ = gameList.append("... and " + (slots|length - maxGamesToShow)|string + " more") -%}
|
||||
{%- endif -%}
|
||||
|
||||
{{ gameList|join('\n') }}
|
||||
{%- endmacro -%}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>User Content</title>
|
||||
@@ -33,10 +49,12 @@
|
||||
<tr>
|
||||
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
|
||||
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
|
||||
<td>{{ room.seed.slots|length }}</td>
|
||||
<td title="{{ games(room.seed.slots) }}">
|
||||
{{ room.seed.slots|length }}
|
||||
</td>
|
||||
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</td>
|
||||
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -60,16 +78,21 @@
|
||||
{% for seed in seeds %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
|
||||
<td>{% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %}
|
||||
<td title="{{ games(seed.slots) }}">
|
||||
{% if seed.multidata %}
|
||||
{{ seed.slots|length }}
|
||||
{% else %}
|
||||
1
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</td>
|
||||
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
You have no generated any seeds yet!
|
||||
You have not generated any seeds yet!
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
id="{{ option_name }}-{{ key }}"
|
||||
name="{{ option_name }}||{{ key }}"
|
||||
value="1"
|
||||
checked="{{ "checked" if key in option.default else "" }}"
|
||||
{{ "checked" if key in option.default }}
|
||||
/>
|
||||
<label for="{{ option_name }}-{{ key }}">
|
||||
{{ key }}
|
||||
|
||||
@@ -79,7 +79,7 @@ class TrackerData:
|
||||
|
||||
# Normal lookup tables as well.
|
||||
self.item_name_to_id[game] = game_package["item_name_to_id"]
|
||||
self.location_name_to_id[game] = game_package["item_name_to_id"]
|
||||
self.location_name_to_id[game] = game_package["location_name_to_id"]
|
||||
|
||||
def get_seed_name(self) -> str:
|
||||
"""Retrieves the seed name."""
|
||||
|
||||
@@ -287,15 +287,15 @@ cdef class LocationStore:
|
||||
entry in self.entries[start:start + count] if
|
||||
entry.location not in checked]
|
||||
|
||||
def get_remaining(self, state: State, team: int, slot: int) -> List[int]:
|
||||
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
|
||||
cdef LocationEntry* entry
|
||||
cdef ap_player_t sender = slot
|
||||
cdef size_t start = self.sender_index[sender].start
|
||||
cdef size_t count = self.sender_index[sender].count
|
||||
cdef set checked = state[team, slot]
|
||||
return sorted([entry.item for
|
||||
entry in self.entries[start:start+count] if
|
||||
entry.location not in checked])
|
||||
return sorted([(entry.receiver, entry.item) for
|
||||
entry in self.entries[start:start+count] if
|
||||
entry.location not in checked])
|
||||
|
||||
|
||||
@cython.auto_pickle(False)
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
/worlds/clique/ @ThePhar
|
||||
|
||||
# Dark Souls III
|
||||
/worlds/dark_souls_3/ @Marechal-L
|
||||
/worlds/dark_souls_3/ @Marechal-L @nex3
|
||||
|
||||
# Donkey Kong Country 3
|
||||
/worlds/dkc3/ @PoryGone
|
||||
@@ -78,6 +78,9 @@
|
||||
# Kirby's Dream Land 3
|
||||
/worlds/kdl3/ @Silvris
|
||||
|
||||
# Kingdom Hearts
|
||||
/worlds/kh1/ @gaithern
|
||||
|
||||
# Kingdom Hearts 2
|
||||
/worlds/kh2/ @JaredWeakStrike
|
||||
|
||||
@@ -103,6 +106,9 @@
|
||||
# Minecraft
|
||||
/worlds/minecraft/ @KonoTyran @espeon65536
|
||||
|
||||
# Mega Man 2
|
||||
/worlds/mm2/ @Silvris
|
||||
|
||||
# MegaMan Battle Network 3
|
||||
/worlds/mmbn3/ @digiholic
|
||||
|
||||
@@ -112,8 +118,8 @@
|
||||
# Noita
|
||||
/worlds/noita/ @ScipioWright @heinermann
|
||||
|
||||
# Ocarina of Time
|
||||
/worlds/oot/ @espeon65536
|
||||
# Old School Runescape
|
||||
/worlds/osrs @digiholic
|
||||
|
||||
# Overcooked! 2
|
||||
/worlds/overcooked2/ @toasterparty
|
||||
@@ -193,6 +199,9 @@
|
||||
# The Witness
|
||||
/worlds/witness/ @NewSoupVi @blastron
|
||||
|
||||
# Yacht Dice
|
||||
/worlds/yachtdice/ @spinerak
|
||||
|
||||
# Yoshi's Island
|
||||
/worlds/yoshisisland/ @PinkSwitch
|
||||
|
||||
@@ -218,6 +227,9 @@
|
||||
# Links Awakening DX
|
||||
# /worlds/ladx/
|
||||
|
||||
# Ocarina of Time
|
||||
# /worlds/oot/
|
||||
|
||||
## Disabled Unmaintained Worlds
|
||||
|
||||
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
|
||||
|
||||
@@ -702,14 +702,18 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|
||||
| checksum | str | A checksum hash of this game's data. |
|
||||
|
||||
### Tags
|
||||
Tags are represented as a list of strings, the common Client tags follow:
|
||||
Tags are represented as a list of strings, the common client tags follow:
|
||||
|
||||
| Name | Notes |
|
||||
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
|
||||
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
| Name | Notes |
|
||||
|-----------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets. |
|
||||
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||
|
||||
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
||||
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
||||
|
||||
### DeathLink
|
||||
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
||||
|
||||
@@ -24,7 +24,7 @@ display as `Value1` on the webhost.
|
||||
files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a
|
||||
Choice, and defining `alias_true = option_full`.
|
||||
- All options with a fixed set of possible values (i.e. those which inherit from `Toggle`, `(Text)Choice` or
|
||||
`(Named/Special)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
|
||||
`(Named)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
|
||||
option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
|
||||
However, you can override `from_text` and handle `text == "random"` to customize its behavior or
|
||||
implement it for additional option types.
|
||||
@@ -129,6 +129,23 @@ class Difficulty(Choice):
|
||||
default = 1
|
||||
```
|
||||
|
||||
### Option Visibility
|
||||
Every option has a Visibility IntFlag, defaulting to `all` (`0b1111`). This lets you choose where the option will be
|
||||
displayed. This only impacts where options are displayed, not how they can be used. Hidden options are still valid
|
||||
options in a yaml. The flags are as follows:
|
||||
* `none` (`0b0000`): This option is not shown anywhere
|
||||
* `template` (`0b0001`): This option shows up in template yamls
|
||||
* `simple_ui` (`0b0010`): This option shows up on the options page
|
||||
* `complex_ui` (`0b0100`): This option shows up on the advanced/weighted options page
|
||||
* `spoiler` (`0b1000`): This option shows up in spoiler logs
|
||||
|
||||
```python
|
||||
from Options import Choice, Visibility
|
||||
|
||||
class HiddenChoiceOption(Choice):
|
||||
visibility = Visibility.none
|
||||
```
|
||||
|
||||
### Option Groups
|
||||
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
|
||||
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
|
||||
|
||||
@@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r
|
||||
|
||||
What you'll need:
|
||||
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||
* **Python 3.12 is currently unsupported**
|
||||
* Python 3.12.x is currently the newest supported version
|
||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||
* Matching C compiler
|
||||
* possibly optional, read operating system specific sections
|
||||
@@ -31,14 +31,14 @@ After this, you should be able to run the programs.
|
||||
|
||||
Recommended steps
|
||||
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
|
||||
* **Python 3.12 is currently unsupported**
|
||||
* [read above](#General) which versions are supported
|
||||
|
||||
* **Optional**: Download and install Visual Studio Build Tools from
|
||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
|
||||
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details.
|
||||
Generally, selecting the box for "Desktop Development with C++" will provide what you need.
|
||||
* Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on
|
||||
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||
|
||||
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
|
||||
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
|
||||
|
||||
@@ -303,6 +303,31 @@ generation (entrance randomization).
|
||||
An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state`
|
||||
(items that have been collected).
|
||||
|
||||
The two possible ways to make a [CollectionRule](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10) are:
|
||||
- `def rule(state: CollectionState) -> bool:`
|
||||
- `lambda state: ... boolean expression ...`
|
||||
|
||||
An access rule can be assigned through `set_rule(location, rule)`.
|
||||
|
||||
Access rules usually check for one of two things.
|
||||
- Items that have been collected (e.g. `state.has("Sword", player)`)
|
||||
- Locations, Regions or Entrances that have been reached (e.g. `state.can_reach_region("Boss Room")`)
|
||||
|
||||
Keep in mind that entrances and locations implicitly check for the accessibility of their parent region, so you do not need to check explicitly for it.
|
||||
|
||||
#### An important note on Entrance access rules:
|
||||
When using `state.can_reach` within an entrance access condition, you must also use `multiworld.register_indirect_condition`.
|
||||
|
||||
For efficiency reasons, every time reachable regions are searched, every entrance is only checked once in a somewhat non-deterministic order.
|
||||
This is fine when checking for items using `state.has`, because items do not change during a region sweep.
|
||||
However, `state.can_reach` checks for the very same thing we are updating: Regions.
|
||||
This can lead to non-deterministic behavior and, in the worst case, even generation failures.
|
||||
Even doing `state.can_reach_location` or `state.can_reach_entrance` is problematic, as these functions call `state.can_reach_region` on the respective parent region.
|
||||
|
||||
**Therefore, it is considered unsafe to perform `state.can_reach` from within an access condition for an entrance**, unless you are checking for something that sits in the source region of the entrance.
|
||||
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
||||
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
||||
|
||||
### Item Rules
|
||||
|
||||
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
|
||||
@@ -630,7 +655,7 @@ def set_rules(self) -> None:
|
||||
|
||||
Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or
|
||||
Entrance should be
|
||||
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L9).
|
||||
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10).
|
||||
Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other
|
||||
functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly.
|
||||
For an example, see [The Messenger](/worlds/messenger/rules.py).
|
||||
|
||||
@@ -26,8 +26,17 @@ Unless these are shared between multiple people, we expect the following from ea
|
||||
### Adding a World
|
||||
|
||||
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
|
||||
nominate someone else (i.e. there are multiple devs). You can define who is allowed to approve changes to your world
|
||||
in the [CODEOWNERS](/docs/CODEOWNERS) document.
|
||||
nominate someone else (i.e. there are multiple devs).
|
||||
|
||||
### Being added as a maintainer to an existing implementation
|
||||
|
||||
At any point, a world maintainer can approve the addition of another maintainer to their world.
|
||||
In order to do this, either an existing maintainer or the new maintainer must open a PR updating the
|
||||
[CODEOWNERS](/docs/CODEOWNERS) file.
|
||||
This change must be approved by all existing maintainers of the affected world, the new maintainer candidate, and
|
||||
one core maintainer.
|
||||
To help the core team review the change, information about the new maintainer and their contributions should be
|
||||
included in the PR description.
|
||||
|
||||
### Getting Voted
|
||||
|
||||
@@ -35,7 +44,7 @@ When a world is unmaintained, the [core maintainers](https://github.com/orgs/Arc
|
||||
can vote for a new maintainer if there is a candidate.
|
||||
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
||||
The time limit is 1 week, but can end early if the majority is reached earlier.
|
||||
Voting shall be conducted on Discord in #archipelago-dev.
|
||||
Voting shall be conducted on Discord in #ap-core-dev.
|
||||
|
||||
## Dropping out
|
||||
|
||||
@@ -51,7 +60,7 @@ for example when they become unreachable.
|
||||
For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
|
||||
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
|
||||
made their case or was pinged and has been unreachable for more than 2 weeks already.
|
||||
Voting shall be conducted on Discord in #archipelago-dev. Commits that are a direct result of the voting shall include
|
||||
Voting shall be conducted on Discord in #ap-core-dev. Commits that are a direct result of the voting shall include
|
||||
date, voting members and final result in the commit message.
|
||||
|
||||
## Handling of Unmaintained Worlds
|
||||
|
||||
@@ -186,6 +186,11 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
|
||||
@@ -223,8 +228,8 @@ Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{a
|
||||
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
|
||||
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1""";
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoLauncher.exe,0";
|
||||
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1""";
|
||||
|
||||
[Code]
|
||||
// See: https://stackoverflow.com/a/51614652/2287576
|
||||
|
||||
15
kvui.py
15
kvui.py
@@ -5,6 +5,8 @@ import typing
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
|
||||
@@ -534,9 +536,8 @@ class GameManager(App):
|
||||
# show Archipelago tab if other logging is present
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
hint_panel = TabbedPanelItem(text="Hints")
|
||||
self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
|
||||
self.tabs.add_widget(hint_panel)
|
||||
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser))
|
||||
self.log_panels["Hints"] = hint_panel.content
|
||||
|
||||
if len(self.logging_pairs) == 1:
|
||||
self.tabs.default_tab_text = "Archipelago"
|
||||
@@ -570,6 +571,14 @@ class GameManager(App):
|
||||
|
||||
return self.container
|
||||
|
||||
def add_client_tab(self, title: str, content: Widget) -> Widget:
|
||||
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
|
||||
Returns the new tab widget, with the provided content being placed on the tab as content."""
|
||||
new_tab = TabbedPanelItem(text=title)
|
||||
new_tab.content = content
|
||||
self.tabs.add_widget(new_tab)
|
||||
return new_tab
|
||||
|
||||
def update_texts(self, dt):
|
||||
if hasattr(self.tabs.content.children[0], "fix_heights"):
|
||||
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
colorama>=0.4.6
|
||||
websockets>=12.0
|
||||
PyYAML>=6.0.1
|
||||
jellyfish>=1.0.3
|
||||
websockets>=13.0.1
|
||||
PyYAML>=6.0.2
|
||||
jellyfish>=1.1.0
|
||||
jinja2>=3.1.4
|
||||
schema>=0.7.7
|
||||
kivy>=2.3.0
|
||||
bsdiff4>=1.2.4
|
||||
platformdirs>=4.2.2
|
||||
certifi>=2024.6.2
|
||||
cython>=3.0.10
|
||||
certifi>=2024.8.30
|
||||
cython>=3.0.11
|
||||
cymem>=2.0.8
|
||||
orjson>=3.10.3
|
||||
typing_extensions>=4.12.1
|
||||
orjson>=3.10.7
|
||||
typing_extensions>=4.12.2
|
||||
|
||||
1
setup.py
1
setup.py
@@ -66,7 +66,6 @@ non_apworlds: set = {
|
||||
"Adventure",
|
||||
"ArchipIDLE",
|
||||
"Archipelago",
|
||||
"ChecksFinder",
|
||||
"Clique",
|
||||
"Final Fantasy",
|
||||
"Lufia II Ancient Cave",
|
||||
|
||||
@@ -23,8 +23,8 @@ class TestBase(unittest.TestCase):
|
||||
state = CollectionState(self.multiworld)
|
||||
for item in items:
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item, event=True)
|
||||
state.sweep_for_events()
|
||||
state.collect(item, prevent_sweep=True)
|
||||
state.sweep_for_advancements()
|
||||
state.update_reachable_regions(1)
|
||||
self._state_cache[self.multiworld, tuple(items)] = state
|
||||
return state
|
||||
@@ -221,8 +221,8 @@ class WorldTestBase(unittest.TestCase):
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
if item.location and item.advancement and item.location in self.multiworld.state.events:
|
||||
self.multiworld.state.events.remove(item.location)
|
||||
if item.location and item.advancement and item.location in self.multiworld.state.advancements:
|
||||
self.multiworld.state.advancements.remove(item.location)
|
||||
self.multiworld.state.remove(item)
|
||||
|
||||
def can_reach_location(self, location: str) -> bool:
|
||||
@@ -293,13 +293,11 @@ class WorldTestBase(unittest.TestCase):
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
||||
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
|
||||
state = self.multiworld.get_all_state(False)
|
||||
for location in self.multiworld.get_locations():
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
reachable = location.can_reach(state)
|
||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
reachable = location.can_reach(state)
|
||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||
with self.subTest("Beatable"):
|
||||
self.multiworld.state = state
|
||||
self.assertBeatable(True)
|
||||
|
||||
@@ -174,8 +174,8 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
player1 = generate_player_data(multiworld, 1, 3, 3)
|
||||
player2 = generate_player_data(multiworld, 2, 3, 3)
|
||||
|
||||
multiworld.accessibility[player1.id].value = multiworld.accessibility[player1.id].option_minimal
|
||||
multiworld.accessibility[player2.id].value = multiworld.accessibility[player2.id].option_locations
|
||||
multiworld.worlds[player1.id].options.accessibility.value = Accessibility.option_minimal
|
||||
multiworld.worlds[player2.id].options.accessibility.value = Accessibility.option_full
|
||||
|
||||
multiworld.completion_condition[player1.id] = lambda state: True
|
||||
multiworld.completion_condition[player2.id] = lambda state: state.has(player2.prog_items[2].name, player2.id)
|
||||
@@ -192,7 +192,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
location_pool = player1.locations[1:] + player2.locations
|
||||
item_pool = player1.prog_items[:-1] + player2.prog_items
|
||||
fill_restrictive(multiworld, multiworld.state, location_pool, item_pool)
|
||||
multiworld.state.sweep_for_events() # collect everything
|
||||
multiworld.state.sweep_for_advancements() # collect everything
|
||||
|
||||
# all of player2's locations and items should be accessible (not all of player1's)
|
||||
for item in player2.prog_items:
|
||||
@@ -443,8 +443,8 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
item = player1.prog_items[0]
|
||||
item.code = None
|
||||
location.place_locked_item(item)
|
||||
multiworld.state.sweep_for_events()
|
||||
multiworld.state.sweep_for_events()
|
||||
multiworld.state.sweep_for_advancements()
|
||||
multiworld.state.sweep_for_advancements()
|
||||
self.assertTrue(multiworld.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed")
|
||||
self.assertEqual(multiworld.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import PlandoOptions
|
||||
from BaseClasses import MultiWorld, PlandoOptions
|
||||
from Options import ItemLinks
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
@@ -47,3 +47,15 @@ class TestOptions(unittest.TestCase):
|
||||
self.assertIn("Bow", link.value[0]["item_pool"])
|
||||
|
||||
# TODO test that the group created using these options has the items
|
||||
|
||||
def test_item_links_resolve(self):
|
||||
"""Test item link option resolves correctly."""
|
||||
item_link_group = [{
|
||||
"name": "ItemLinkTest",
|
||||
"item_pool": ["Everything"],
|
||||
"link_replacement": False,
|
||||
"replacement_item": None,
|
||||
}]
|
||||
item_links = {1: ItemLinks.from_any(item_link_group), 2: ItemLinks.from_any(item_link_group)}
|
||||
for link in item_links.values():
|
||||
self.assertEqual(link.value[0], item_link_group[0])
|
||||
|
||||
@@ -14,6 +14,18 @@ class TestBase(unittest.TestCase):
|
||||
"Desert Northern Cliffs", # on top of mountain, only reachable via OWG
|
||||
"Dark Death Mountain Bunny Descent Area" # OWG Mountain descent
|
||||
},
|
||||
# These Blasphemous regions are not reachable with default options
|
||||
"Blasphemous": {
|
||||
"D01Z04S13[SE]", # difficulty must be hard
|
||||
"D01Z05S25[E]", # difficulty must be hard
|
||||
"D02Z02S05[W]", # difficulty must be hard and purified_hand must be true
|
||||
"D04Z01S06[E]", # purified_hand must be true
|
||||
"D04Z02S02[NE]", # difficulty must be hard and purified_hand must be true
|
||||
"D05Z01S11[SW]", # difficulty must be hard
|
||||
"D06Z01S08[N]", # difficulty must be hard and purified_hand must be true
|
||||
"D20Z02S11[NW]", # difficulty must be hard
|
||||
"D20Z02S11[E]", # difficulty must be hard
|
||||
},
|
||||
"Ocarina of Time": {
|
||||
"Prelude of Light Warp", # Prelude is not progression by default
|
||||
"Serenade of Water Warp", # Serenade is not progression by default
|
||||
@@ -37,12 +49,10 @@ class TestBase(unittest.TestCase):
|
||||
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
excluded = multiworld.worlds[1].options.exclude_locations.value
|
||||
state = multiworld.get_all_state(False)
|
||||
for location in multiworld.get_locations():
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
|
||||
for region in multiworld.get_regions():
|
||||
if region.name in unreachable_regions:
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
all_worlds = list(AutoWorldRegister.world_types.values())
|
||||
self.multiworld = setup_multiworld(all_worlds, ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_locations
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
self.assertSteps(gen_steps)
|
||||
with self.subTest("filling multiworld", seed=self.multiworld.seed):
|
||||
distribute_items_restrictive(self.multiworld)
|
||||
@@ -66,10 +66,10 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
class TestTwoPlayerMulti(MultiworldTestBase):
|
||||
def test_two_player_single_game_fills(self) -> None:
|
||||
"""Tests that a multiworld of two players for each registered game world can generate."""
|
||||
for world in AutoWorldRegister.world_types.values():
|
||||
self.multiworld = setup_multiworld([world, world], ())
|
||||
for world_type in AutoWorldRegister.world_types.values():
|
||||
self.multiworld = setup_multiworld([world_type, world_type], ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_locations
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
self.assertSteps(gen_steps)
|
||||
with self.subTest("filling multiworld", seed=self.multiworld.seed):
|
||||
distribute_items_restrictive(self.multiworld)
|
||||
|
||||
@@ -130,9 +130,9 @@ class Base:
|
||||
|
||||
def test_get_remaining(self) -> None:
|
||||
self.assertEqual(self.store.get_remaining(full_state, 0, 1), [])
|
||||
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [13, 21])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [13, 21, 22])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [99])
|
||||
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)])
|
||||
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)])
|
||||
|
||||
def test_location_set_intersection(self) -> None:
|
||||
locations = {10, 11, 12}
|
||||
|
||||
@@ -131,7 +131,8 @@ class TestHostFakeRoom(TestBase):
|
||||
f.write(text)
|
||||
|
||||
with self.app.app_context(), self.app.test_request_context():
|
||||
response = self.client.get(url_for("host_room", room=self.room_id))
|
||||
response = self.client.get(url_for("host_room", room=self.room_id),
|
||||
headers={"User-Agent": "Mozilla/5.0"})
|
||||
response_text = response.get_data(True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("href=\"/seed/", response_text)
|
||||
|
||||
@@ -292,6 +292,14 @@ class World(metaclass=AutoWorldRegister):
|
||||
web: ClassVar[WebWorld] = WebWorld()
|
||||
"""see WebWorld for options"""
|
||||
|
||||
origin_region_name: str = "Menu"
|
||||
"""Name of the Region from which accessibility is tested."""
|
||||
|
||||
explicit_indirect_conditions: bool = True
|
||||
"""If True, the world implementation is supposed to use MultiWorld.register_indirect_condition() correctly.
|
||||
If False, everything is rechecked at every step, which is slower computationally,
|
||||
but may be desirable in complex/dynamic worlds."""
|
||||
|
||||
multiworld: "MultiWorld"
|
||||
"""autoset on creation. The MultiWorld object for the currently generating multiworld."""
|
||||
player: int
|
||||
@@ -334,7 +342,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
|
||||
# overridable methods that get called by Main.py, sorted by execution order
|
||||
# can also be implemented as a classmethod and called "stage_<original_name>",
|
||||
# in that case the MultiWorld object is passed as an argument, and it gets called once for the entire multiworld.
|
||||
# in that case the MultiWorld object is passed as the first argument, and it gets called once for the entire multiworld.
|
||||
# An example of this can be found in alttp as stage_pre_fill
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -26,10 +26,13 @@ class Component:
|
||||
cli: bool
|
||||
func: Optional[Callable]
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
game_name: Optional[str]
|
||||
supports_uri: Optional[bool]
|
||||
|
||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
|
||||
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None):
|
||||
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None,
|
||||
game_name: Optional[str] = None, supports_uri: Optional[bool] = False):
|
||||
self.display_name = display_name
|
||||
self.script_name = script_name
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
@@ -45,6 +48,8 @@ class Component:
|
||||
Type.ADJUSTER if "Adjuster" in display_name else Type.MISC)
|
||||
self.func = func
|
||||
self.file_identifier = file_identifier
|
||||
self.game_name = game_name
|
||||
self.supports_uri = supports_uri
|
||||
|
||||
def handles_file(self, path: str):
|
||||
return self.file_identifier(path) if self.file_identifier else False
|
||||
@@ -56,10 +61,10 @@ class Component:
|
||||
processes = weakref.WeakSet()
|
||||
|
||||
|
||||
def launch_subprocess(func: Callable, name: str = None):
|
||||
def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] = ()) -> None:
|
||||
global processes
|
||||
import multiprocessing
|
||||
process = multiprocessing.Process(target=func, name=name)
|
||||
process = multiprocessing.Process(target=func, name=name, args=args)
|
||||
process.start()
|
||||
processes.add(process)
|
||||
|
||||
@@ -78,9 +83,9 @@ class SuffixIdentifier:
|
||||
return False
|
||||
|
||||
|
||||
def launch_textclient():
|
||||
def launch_textclient(*args):
|
||||
import CommonClient
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||
|
||||
|
||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
@@ -132,7 +137,8 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
|
||||
break
|
||||
if found_already_loaded:
|
||||
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
|
||||
"so a Launcher restart is required to use the new installation.")
|
||||
"so a Launcher restart is required to use the new installation.\n"
|
||||
"If the Launcher is not open, no action needs to be taken.")
|
||||
world_source = worlds.WorldSource(str(target), is_zip=True)
|
||||
bisect.insort(worlds.world_sources, world_source)
|
||||
world_source.load()
|
||||
|
||||
@@ -73,7 +73,12 @@ class WorldSource:
|
||||
else: # TODO: remove with 3.8 support
|
||||
mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
if mod.__package__ is not None:
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
else:
|
||||
# load_module does not populate package, we'll have to assume mod.__name__ is correct here
|
||||
# probably safe to remove with 3.8 support
|
||||
mod.__package__ = f"worlds.{mod.__name__}"
|
||||
mod.__name__ = f"worlds.{mod.__name__}"
|
||||
sys.modules[mod.__name__] = mod
|
||||
with warnings.catch_warnings():
|
||||
|
||||
@@ -223,8 +223,8 @@ async def set_message_interval(ctx: BizHawkContext, value: float) -> None:
|
||||
raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}")
|
||||
|
||||
|
||||
async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]],
|
||||
guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> typing.Optional[typing.List[bytes]]:
|
||||
async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]],
|
||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> typing.Optional[typing.List[bytes]]:
|
||||
"""Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
|
||||
value.
|
||||
|
||||
@@ -266,7 +266,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[
|
||||
return ret
|
||||
|
||||
|
||||
async def read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
|
||||
async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
|
||||
"""Reads data at 1 or more addresses.
|
||||
|
||||
Items in `read_list` should be organized `(address, size, domain)` where
|
||||
@@ -278,8 +278,8 @@ async def read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int
|
||||
return await guarded_read(ctx, read_list, [])
|
||||
|
||||
|
||||
async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]],
|
||||
guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> bool:
|
||||
async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]],
|
||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> bool:
|
||||
"""Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value.
|
||||
|
||||
Items in `write_list` should be organized `(address, value, domain)` where
|
||||
@@ -316,7 +316,7 @@ async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tupl
|
||||
return True
|
||||
|
||||
|
||||
async def write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> None:
|
||||
async def write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> None:
|
||||
"""Writes data to 1 or more addresses.
|
||||
|
||||
Items in write_list should be organized `(address, value, domain)` where
|
||||
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
def launch_client(*args) -> None:
|
||||
from .context import launch
|
||||
launch_subprocess(launch, name="BizHawkClient")
|
||||
launch_subprocess(launch, name="BizHawkClient", args=args)
|
||||
|
||||
|
||||
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
|
||||
|
||||
@@ -59,14 +59,10 @@ class BizHawkClientContext(CommonContext):
|
||||
self.bizhawk_ctx = BizHawkContext()
|
||||
self.watcher_timeout = 0.5
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class BizHawkManager(GameManager):
|
||||
base_title = "Archipelago BizHawk Client"
|
||||
|
||||
self.ui = BizHawkManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
def make_gui(self):
|
||||
ui = super().make_gui()
|
||||
ui.base_title = "Archipelago BizHawk Client"
|
||||
return ui
|
||||
|
||||
def on_package(self, cmd, args):
|
||||
if cmd == "Connected":
|
||||
@@ -243,11 +239,11 @@ async def _patch_and_run_game(patch_file: str):
|
||||
logger.exception(exc)
|
||||
|
||||
|
||||
def launch() -> None:
|
||||
def launch(*launch_args) -> None:
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(launch_args)
|
||||
|
||||
ctx = BizHawkClientContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
@@ -4,7 +4,7 @@ import websockets
|
||||
import functools
|
||||
from copy import deepcopy
|
||||
from typing import List, Any, Iterable
|
||||
from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem
|
||||
from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem, NetworkPlayer
|
||||
from MultiServer import Endpoint
|
||||
from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
||||
|
||||
@@ -101,12 +101,35 @@ class AHITContext(CommonContext):
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.connected_msg = encode([args])
|
||||
json = args
|
||||
# This data is not needed and causes the game to freeze for long periods of time in large asyncs.
|
||||
if "slot_info" in json.keys():
|
||||
json["slot_info"] = {}
|
||||
if "players" in json.keys():
|
||||
me: NetworkPlayer
|
||||
for n in json["players"]:
|
||||
if n.slot == json["slot"] and n.team == json["team"]:
|
||||
me = n
|
||||
break
|
||||
|
||||
# Only put our player info in there as we actually need it
|
||||
json["players"] = [me]
|
||||
if DEBUG:
|
||||
print(json)
|
||||
self.connected_msg = encode([json])
|
||||
if self.awaiting_info:
|
||||
self.server_msgs.append(self.room_info)
|
||||
self.update_items()
|
||||
self.awaiting_info = False
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
# Same story as above
|
||||
json = args
|
||||
if "players" in json.keys():
|
||||
json["players"] = []
|
||||
|
||||
self.server_msgs.append(encode(json))
|
||||
|
||||
elif cmd == "ReceivedItems":
|
||||
if args["index"] == 0:
|
||||
self.full_inventory.clear()
|
||||
@@ -166,6 +189,17 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
|
||||
await ctx.disconnect_proxy()
|
||||
break
|
||||
|
||||
if ctx.auth:
|
||||
name = msg.get("name", "")
|
||||
if name != "" and name != ctx.auth:
|
||||
logger.info("Aborting proxy connection: player name mismatch from save file")
|
||||
logger.info(f"Expected: {ctx.auth}, got: {name}")
|
||||
text = encode([{"cmd": "PrintJSON",
|
||||
"data": [{"text": "Connection aborted - player name mismatch"}]}])
|
||||
await ctx.send_msgs_proxy(text)
|
||||
await ctx.disconnect_proxy()
|
||||
break
|
||||
|
||||
if ctx.connected_msg and ctx.is_connected():
|
||||
await ctx.send_msgs_proxy(ctx.connected_msg)
|
||||
ctx.update_items()
|
||||
|
||||
@@ -152,10 +152,10 @@ def create_dw_regions(world: "HatInTimeWorld"):
|
||||
for name in annoying_dws:
|
||||
world.excluded_dws.append(name)
|
||||
|
||||
if not world.options.DWEnableBonus or world.options.DWAutoCompleteBonuses:
|
||||
if not world.options.DWEnableBonus and world.options.DWAutoCompleteBonuses:
|
||||
for name in death_wishes:
|
||||
world.excluded_bonuses.append(name)
|
||||
elif world.options.DWExcludeAnnoyingBonuses:
|
||||
if world.options.DWExcludeAnnoyingBonuses and not world.options.DWAutoCompleteBonuses:
|
||||
for name in annoying_bonuses:
|
||||
world.excluded_bonuses.append(name)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def create_itempool(world: "HatInTimeWorld") -> List[Item]:
|
||||
continue
|
||||
else:
|
||||
if name == "Scooter Badge":
|
||||
if world.options.CTRLogic is CTRLogic.option_scooter or get_difficulty(world) >= Difficulty.MODERATE:
|
||||
if world.options.CTRLogic == CTRLogic.option_scooter or get_difficulty(world) >= Difficulty.MODERATE:
|
||||
item_type = ItemClassification.progression
|
||||
elif name == "No Bonk Badge" and world.is_dw():
|
||||
item_type = ItemClassification.progression
|
||||
|
||||
@@ -659,6 +659,10 @@ def is_valid_act_combo(world: "HatInTimeWorld", entrance_act: Region,
|
||||
if exit_act.name not in chapter_finales:
|
||||
return False
|
||||
|
||||
exit_chapter: str = act_chapters.get(exit_act.name)
|
||||
# make sure that certain time rift combinations never happen
|
||||
always_block: bool = exit_chapter != "Mafia Town" and exit_chapter != "Subcon Forest"
|
||||
if not ignore_certain_rules or always_block:
|
||||
if entrance_act.name in rift_access_regions and exit_act.name in rift_access_regions[entrance_act.name]:
|
||||
return False
|
||||
|
||||
@@ -684,9 +688,12 @@ def is_valid_first_act(world: "HatInTimeWorld", act: Region) -> bool:
|
||||
if act.name not in guaranteed_first_acts:
|
||||
return False
|
||||
|
||||
if world.options.ActRandomizer == ActRandomizer.option_light and "Time Rift" in act.name:
|
||||
return False
|
||||
|
||||
# If there's only a single level in the starting chapter, only allow Mafia Town or Subcon Forest levels
|
||||
start_chapter = world.options.StartingChapter
|
||||
if start_chapter is ChapterIndex.ALPINE or start_chapter is ChapterIndex.SUBCON:
|
||||
if start_chapter == ChapterIndex.ALPINE or start_chapter == ChapterIndex.SUBCON:
|
||||
if "Time Rift" in act.name:
|
||||
return False
|
||||
|
||||
@@ -723,7 +730,8 @@ def is_valid_first_act(world: "HatInTimeWorld", act: Region) -> bool:
|
||||
elif act.name == "Contractual Obligations" and world.options.ShuffleSubconPaintings:
|
||||
return False
|
||||
|
||||
if world.options.ShuffleSubconPaintings and act_chapters.get(act.name, "") == "Subcon Forest":
|
||||
if world.options.ShuffleSubconPaintings and "Time Rift" not in act.name \
|
||||
and act_chapters.get(act.name, "") == "Subcon Forest":
|
||||
# Only allow Subcon levels if painting skips are allowed
|
||||
if diff < Difficulty.MODERATE or world.options.NoPaintingSkips:
|
||||
return False
|
||||
@@ -960,40 +968,35 @@ def get_act_by_number(world: "HatInTimeWorld", chapter_name: str, num: int) -> R
|
||||
def create_thug_shops(world: "HatInTimeWorld"):
|
||||
min_items: int = world.options.NyakuzaThugMinShopItems.value
|
||||
max_items: int = world.options.NyakuzaThugMaxShopItems.value
|
||||
count = -1
|
||||
step = 0
|
||||
old_name = ""
|
||||
|
||||
thug_location_counts: Dict[str, int] = {}
|
||||
|
||||
for key, data in shop_locations.items():
|
||||
if data.nyakuza_thug == "":
|
||||
thug_name = data.nyakuza_thug
|
||||
if thug_name == "":
|
||||
# Different shop type.
|
||||
continue
|
||||
|
||||
if old_name != "" and old_name == data.nyakuza_thug:
|
||||
if thug_name not in world.nyakuza_thug_items:
|
||||
shop_item_count = world.random.randint(min_items, max_items)
|
||||
world.nyakuza_thug_items[thug_name] = shop_item_count
|
||||
else:
|
||||
shop_item_count = world.nyakuza_thug_items[thug_name]
|
||||
|
||||
if shop_item_count <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
if world.nyakuza_thug_items[data.nyakuza_thug] <= 0:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
location_count = thug_location_counts.setdefault(thug_name, 0)
|
||||
if location_count >= shop_item_count:
|
||||
# Already created all the locations for this thug.
|
||||
continue
|
||||
|
||||
if count == -1:
|
||||
count = world.random.randint(min_items, max_items)
|
||||
world.nyakuza_thug_items.setdefault(data.nyakuza_thug, count)
|
||||
if count <= 0:
|
||||
continue
|
||||
|
||||
if count >= 1:
|
||||
region = world.multiworld.get_region(data.region, world.player)
|
||||
loc = HatInTimeLocation(world.player, key, data.id, region)
|
||||
region.locations.append(loc)
|
||||
world.shop_locs.append(loc.name)
|
||||
|
||||
step += 1
|
||||
if step >= count:
|
||||
old_name = data.nyakuza_thug
|
||||
step = 0
|
||||
count = -1
|
||||
# Create the shop location.
|
||||
region = world.multiworld.get_region(data.region, world.player)
|
||||
loc = HatInTimeLocation(world.player, key, data.id, region)
|
||||
region.locations.append(loc)
|
||||
world.shop_locs.append(loc.name)
|
||||
thug_location_counts[thug_name] = location_count + 1
|
||||
|
||||
|
||||
def create_events(world: "HatInTimeWorld") -> int:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from worlds.AutoWorld import CollectionState
|
||||
from worlds.generic.Rules import add_rule, set_rule
|
||||
from .Locations import location_table, zipline_unlocks, is_location_valid, contract_locations, \
|
||||
shop_locations, event_locs
|
||||
from .Locations import location_table, zipline_unlocks, is_location_valid, shop_locations, event_locs
|
||||
from .Types import HatType, ChapterIndex, hat_type_to_item, Difficulty, HitType
|
||||
from BaseClasses import Location, Entrance, Region
|
||||
from typing import TYPE_CHECKING, List, Callable, Union, Dict
|
||||
@@ -148,14 +147,14 @@ def set_rules(world: "HatInTimeWorld"):
|
||||
if world.is_dlc1():
|
||||
chapter_list.append(ChapterIndex.CRUISE)
|
||||
|
||||
if world.is_dlc2() and final_chapter is not ChapterIndex.METRO:
|
||||
if world.is_dlc2() and final_chapter != ChapterIndex.METRO:
|
||||
chapter_list.append(ChapterIndex.METRO)
|
||||
|
||||
chapter_list.remove(starting_chapter)
|
||||
world.random.shuffle(chapter_list)
|
||||
|
||||
# Make sure Alpine is unlocked before any DLC chapters are, as the Alpine door needs to be open to access them
|
||||
if starting_chapter is not ChapterIndex.ALPINE and (world.is_dlc1() or world.is_dlc2()):
|
||||
if starting_chapter != ChapterIndex.ALPINE and (world.is_dlc1() or world.is_dlc2()):
|
||||
index1 = 69
|
||||
index2 = 69
|
||||
pos: int
|
||||
@@ -165,7 +164,7 @@ def set_rules(world: "HatInTimeWorld"):
|
||||
if world.is_dlc1():
|
||||
index1 = chapter_list.index(ChapterIndex.CRUISE)
|
||||
|
||||
if world.is_dlc2() and final_chapter is not ChapterIndex.METRO:
|
||||
if world.is_dlc2() and final_chapter != ChapterIndex.METRO:
|
||||
index2 = chapter_list.index(ChapterIndex.METRO)
|
||||
|
||||
lowest_index = min(index1, index2)
|
||||
@@ -242,9 +241,6 @@ def set_rules(world: "HatInTimeWorld"):
|
||||
if not is_location_valid(world, key):
|
||||
continue
|
||||
|
||||
if key in contract_locations.keys():
|
||||
continue
|
||||
|
||||
loc = world.multiworld.get_location(key, world.player)
|
||||
|
||||
for hat in data.required_hats:
|
||||
@@ -256,7 +252,7 @@ def set_rules(world: "HatInTimeWorld"):
|
||||
if data.paintings > 0 and world.options.ShuffleSubconPaintings:
|
||||
add_rule(loc, lambda state, paintings=data.paintings: has_paintings(state, world, paintings))
|
||||
|
||||
if data.hit_type is not HitType.none and world.options.UmbrellaLogic:
|
||||
if data.hit_type != HitType.none and world.options.UmbrellaLogic:
|
||||
if data.hit_type == HitType.umbrella:
|
||||
add_rule(loc, lambda state: state.has("Umbrella", world.player))
|
||||
|
||||
@@ -385,8 +381,8 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
lambda state: can_use_hat(state, world, HatType.ICE), "or")
|
||||
|
||||
# Moderate: Clock Tower Chest + Ruined Tower with nothing
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Top of Ruined Tower", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Top of Ruined Tower", world.player), lambda state: True)
|
||||
|
||||
# Moderate: enter and clear The Subcon Well without Hookshot and without hitting the bell
|
||||
for loc in world.multiworld.get_region("The Subcon Well", world.player).locations:
|
||||
@@ -436,8 +432,8 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
|
||||
if world.is_dlc1():
|
||||
# Moderate: clear Rock the Boat without Ice Hat
|
||||
add_rule(world.multiworld.get_location("Rock the Boat - Post Captain Rescue", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Act Completion (Rock the Boat)", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Rock the Boat - Post Captain Rescue", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rock the Boat)", world.player), lambda state: True)
|
||||
|
||||
# Moderate: clear Deep Sea without Ice Hat
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Deep Sea)", world.player),
|
||||
@@ -518,7 +514,7 @@ def set_hard_rules(world: "HatInTimeWorld"):
|
||||
lambda state: can_use_hat(state, world, HatType.ICE))
|
||||
|
||||
# Hard: clear Rush Hour with Brewing Hat only
|
||||
if world.options.NoTicketSkips is not NoTicketSkips.option_true:
|
||||
if world.options.NoTicketSkips != NoTicketSkips.option_true:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.BREWING))
|
||||
else:
|
||||
@@ -859,6 +855,9 @@ def set_rift_rules(world: "HatInTimeWorld", regions: Dict[str, Region]):
|
||||
|
||||
for entrance in regions["Time Rift - Alpine Skyline"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon"))
|
||||
if entrance.parent_region.name == "Alpine Free Roam":
|
||||
add_rule(entrance,
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world, umbrella_only=True))
|
||||
|
||||
if world.is_dlc1():
|
||||
for entrance in regions["Time Rift - Balcony"].entrances:
|
||||
@@ -937,6 +936,9 @@ def set_default_rift_rules(world: "HatInTimeWorld"):
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Alpine Skyline", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon"))
|
||||
if entrance.parent_region.name == "Alpine Free Roam":
|
||||
add_rule(entrance,
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world, umbrella_only=True))
|
||||
|
||||
if world.is_dlc1():
|
||||
for entrance in world.multiworld.get_region("Time Rift - Balcony", world.player).entrances:
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld
|
||||
from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \
|
||||
calculate_yarn_costs
|
||||
calculate_yarn_costs, alps_hooks
|
||||
from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region
|
||||
from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \
|
||||
get_total_locations
|
||||
from .Rules import set_rules
|
||||
from .Rules import set_rules, has_paintings
|
||||
from .Options import AHITOptions, slot_data_options, adjust_options, RandomizeHatOrder, EndGoal, create_option_groups
|
||||
from .Types import HatType, ChapterIndex, HatInTimeItem, hat_type_to_item
|
||||
from .Types import HatType, ChapterIndex, HatInTimeItem, hat_type_to_item, Difficulty
|
||||
from .DeathWishLocations import create_dw_regions, dw_classes, death_wishes
|
||||
from .DeathWishRules import set_dw_rules, create_enemy_events, hit_list, bosses
|
||||
from worlds.AutoWorld import World, WebWorld, CollectionState
|
||||
from worlds.generic.Rules import add_rule
|
||||
from typing import List, Dict, TextIO
|
||||
from worlds.LauncherComponents import Component, components, icon_paths, launch_subprocess, Type
|
||||
from Utils import local_path
|
||||
@@ -86,19 +87,27 @@ class HatInTimeWorld(World):
|
||||
if self.is_dw_only():
|
||||
return
|
||||
|
||||
# If our starting chapter is 4 and act rando isn't on, force hookshot into inventory
|
||||
# If starting chapter is 3 and painting shuffle is enabled, and act rando isn't, give one free painting unlock
|
||||
start_chapter: ChapterIndex = ChapterIndex(self.options.StartingChapter)
|
||||
# Take care of some extremely restrictive starts in other chapters with act shuffle off
|
||||
if not self.options.ActRandomizer:
|
||||
start_chapter = self.options.StartingChapter
|
||||
if start_chapter == ChapterIndex.ALPINE:
|
||||
self.multiworld.push_precollected(self.create_item("Hookshot Badge"))
|
||||
if self.options.UmbrellaLogic:
|
||||
self.multiworld.push_precollected(self.create_item("Umbrella"))
|
||||
|
||||
if start_chapter == ChapterIndex.ALPINE or start_chapter == ChapterIndex.SUBCON:
|
||||
if not self.options.ActRandomizer:
|
||||
if start_chapter == ChapterIndex.ALPINE:
|
||||
self.multiworld.push_precollected(self.create_item("Hookshot Badge"))
|
||||
if self.options.UmbrellaLogic:
|
||||
self.multiworld.push_precollected(self.create_item("Umbrella"))
|
||||
|
||||
if start_chapter == ChapterIndex.SUBCON and self.options.ShuffleSubconPaintings:
|
||||
if self.options.ShuffleAlpineZiplines:
|
||||
ziplines = list(alps_hooks.keys())
|
||||
ziplines.remove("Zipline Unlock - The Twilight Bell Path") # not enough checks from this one
|
||||
self.multiworld.push_precollected(self.create_item(self.random.choice(ziplines)))
|
||||
elif start_chapter == ChapterIndex.SUBCON:
|
||||
if self.options.ShuffleSubconPaintings:
|
||||
self.multiworld.push_precollected(self.create_item("Progressive Painting Unlock"))
|
||||
elif start_chapter == ChapterIndex.BIRDS:
|
||||
if self.options.UmbrellaLogic:
|
||||
if self.options.LogicDifficulty < Difficulty.EXPERT:
|
||||
self.multiworld.push_precollected(self.create_item("Umbrella"))
|
||||
elif self.options.LogicDifficulty < Difficulty.MODERATE:
|
||||
self.multiworld.push_precollected(self.create_item("Umbrella"))
|
||||
|
||||
def create_regions(self):
|
||||
# noinspection PyClassVar
|
||||
@@ -119,7 +128,10 @@ class HatInTimeWorld(World):
|
||||
# place vanilla contract locations if contract shuffle is off
|
||||
if not self.options.ShuffleActContracts:
|
||||
for name in contract_locations.keys():
|
||||
self.multiworld.get_location(name, self.player).place_locked_item(create_item(self, name))
|
||||
loc = self.get_location(name)
|
||||
loc.place_locked_item(create_item(self, name))
|
||||
if self.options.ShuffleSubconPaintings and loc.name != "Snatcher's Contract - The Subcon Well":
|
||||
add_rule(loc, lambda state: has_paintings(state, self, 1))
|
||||
|
||||
def create_items(self):
|
||||
if self.has_yarn():
|
||||
@@ -241,7 +253,8 @@ class HatInTimeWorld(World):
|
||||
else:
|
||||
item_name = loc.item.name
|
||||
|
||||
shop_item_names.setdefault(str(loc.address), item_name)
|
||||
shop_item_names.setdefault(str(loc.address),
|
||||
f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})")
|
||||
|
||||
slot_data["ShopItemNames"] = shop_item_names
|
||||
|
||||
@@ -317,7 +330,7 @@ class HatInTimeWorld(World):
|
||||
|
||||
def remove(self, state: "CollectionState", item: "Item") -> bool:
|
||||
old_count: int = state.count(item.name, self.player)
|
||||
change = super().collect(state, item)
|
||||
change = super().remove(state, item)
|
||||
if change and old_count == 1:
|
||||
if "Stamp" in item.name:
|
||||
if "2 Stamp" in item.name:
|
||||
|
||||
@@ -248,7 +248,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
pass
|
||||
for item in pre_fill_items:
|
||||
multiworld.worlds[item.player].collect(all_state_base, item)
|
||||
all_state_base.sweep_for_events()
|
||||
all_state_base.sweep_for_advancements()
|
||||
|
||||
# Remove completion condition so that minimal-accessibility worlds place keys properly
|
||||
for player in {item.player for item in in_dungeon_items}:
|
||||
@@ -262,8 +262,8 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))
|
||||
loc = multiworld.get_location(key_loc, player)
|
||||
|
||||
if loc in all_state_base.events:
|
||||
all_state_base.events.remove(loc)
|
||||
if loc in all_state_base.advancements:
|
||||
all_state_base.advancements.remove(loc)
|
||||
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, lock=True, allow_excluded=True,
|
||||
name="LttP Dungeon Items")
|
||||
|
||||
|
||||
@@ -682,7 +682,7 @@ def get_pool_core(world, player: int):
|
||||
if 'triforce_hunt' in goal:
|
||||
|
||||
if world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_extra:
|
||||
treasure_hunt_total = (world.triforce_pieces_available[player].value
|
||||
treasure_hunt_total = (world.triforce_pieces_required[player].value
|
||||
+ world.triforce_pieces_extra[player].value)
|
||||
elif world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_percentage:
|
||||
percentage = float(world.triforce_pieces_percentage[player].value) / 100
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, \
|
||||
StartInventoryPool, PlandoBosses, PlandoConnections, PlandoTexts, FreeText, Removed
|
||||
from Options import Choice, Range, DeathLink, DefaultOnToggle, FreeText, ItemsAccessibility, Option, \
|
||||
PlandoBosses, PlandoConnections, PlandoTexts, Removed, StartInventoryPool, Toggle
|
||||
from .EntranceShuffle import default_connections, default_dungeon_connections, \
|
||||
inverted_default_connections, inverted_default_dungeon_connections
|
||||
from .Text import TextTable
|
||||
@@ -728,7 +728,7 @@ class ALttPPlandoConnections(PlandoConnections):
|
||||
entrances = set([connection[0] for connection in (
|
||||
*default_connections, *default_dungeon_connections, *inverted_default_connections,
|
||||
*inverted_default_dungeon_connections)])
|
||||
exits = set([connection[1] for connection in (
|
||||
exits = set([connection[0] for connection in (
|
||||
*default_connections, *default_dungeon_connections, *inverted_default_connections,
|
||||
*inverted_default_dungeon_connections)])
|
||||
|
||||
@@ -743,6 +743,7 @@ class ALttPPlandoTexts(PlandoTexts):
|
||||
|
||||
|
||||
alttp_options: typing.Dict[str, type(Option)] = {
|
||||
"accessibility": ItemsAccessibility,
|
||||
"plando_connections": ALttPPlandoConnections,
|
||||
"plando_texts": ALttPPlandoTexts,
|
||||
"start_inventory_from_pool": StartInventoryPool,
|
||||
|
||||
@@ -2,6 +2,7 @@ import collections
|
||||
import logging
|
||||
from typing import Iterator, Set
|
||||
|
||||
from Options import ItemsAccessibility
|
||||
from BaseClasses import Entrance, MultiWorld
|
||||
from worlds.generic.Rules import (add_item_rule, add_rule, forbid_item,
|
||||
item_name_in_location_names, location_item_name, set_rule, allow_self_locking_items)
|
||||
@@ -39,7 +40,7 @@ def set_rules(world):
|
||||
else:
|
||||
# Set access rules according to max glitches for multiworld progression.
|
||||
# Set accessibility to none, and shuffle assuming the no logic players can always win
|
||||
world.accessibility[player] = world.accessibility[player].from_text("minimal")
|
||||
world.accessibility[player].value = ItemsAccessibility.option_minimal
|
||||
world.progression_balancing[player].value = 0
|
||||
|
||||
else:
|
||||
@@ -377,7 +378,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
or state.has("Cane of Somaria", player)))
|
||||
set_rule(multiworld.get_location('Tower of Hera - Big Chest', player), lambda state: state.has('Big Key (Tower of Hera)', player))
|
||||
set_rule(multiworld.get_location('Tower of Hera - Big Key Chest', player), lambda state: has_fire_source(state, player))
|
||||
if multiworld.accessibility[player] != 'locations':
|
||||
if multiworld.accessibility[player] != 'full':
|
||||
set_always_allow(multiworld.get_location('Tower of Hera - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Tower of Hera)' and item.player == player)
|
||||
|
||||
set_rule(multiworld.get_entrance('Swamp Palace Moat', player), lambda state: state.has('Flippers', player) and state.has('Open Floodgate', player))
|
||||
@@ -393,7 +394,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
if state.has('Hookshot', player)
|
||||
else state._lttp_has_key('Small Key (Swamp Palace)', player, 4))
|
||||
set_rule(multiworld.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player))
|
||||
if multiworld.accessibility[player] != 'locations':
|
||||
if multiworld.accessibility[player] != 'full':
|
||||
allow_self_locking_items(multiworld.get_location('Swamp Palace - Big Chest', player), 'Big Key (Swamp Palace)')
|
||||
set_rule(multiworld.get_entrance('Swamp Palace (North)', player), lambda state: state.has('Hookshot', player) and state._lttp_has_key('Small Key (Swamp Palace)', player, 5))
|
||||
if not multiworld.small_key_shuffle[player] and multiworld.glitches_required[player] not in ['hybrid_major_glitches', 'no_logic']:
|
||||
@@ -411,7 +412,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
lambda state: ((state._lttp_has_key('Small Key (Thieves Town)', player, 3)) or (location_item_name(state, 'Thieves\' Town - Big Chest', player) == ("Small Key (Thieves Town)", player)) and state._lttp_has_key('Small Key (Thieves Town)', player, 2)) and state.has('Hammer', player))
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Blind\'s Cell', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Thieves Town)', player))
|
||||
if multiworld.accessibility[player] != 'locations' and not multiworld.key_drop_shuffle[player]:
|
||||
if multiworld.accessibility[player] != 'full' and not multiworld.key_drop_shuffle[player]:
|
||||
set_always_allow(multiworld.get_location('Thieves\' Town - Big Chest', player), lambda state, item: item.name == 'Small Key (Thieves Town)' and item.player == player)
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3))
|
||||
set_rule(multiworld.get_location('Thieves\' Town - Spike Switch Pot Key', player),
|
||||
@@ -423,7 +424,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
set_rule(multiworld.get_entrance('Skull Woods First Section West Door', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5))
|
||||
set_rule(multiworld.get_entrance('Skull Woods First Section (Left) Door to Exit', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5))
|
||||
set_rule(multiworld.get_location('Skull Woods - Big Chest', player), lambda state: state.has('Big Key (Skull Woods)', player) and can_use_bombs(state, player))
|
||||
if multiworld.accessibility[player] != 'locations':
|
||||
if multiworld.accessibility[player] != 'full':
|
||||
allow_self_locking_items(multiworld.get_location('Skull Woods - Big Chest', player), 'Big Key (Skull Woods)')
|
||||
set_rule(multiworld.get_entrance('Skull Woods Torch Room', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 4) and state.has('Fire Rod', player) and has_sword(state, player)) # sword required for curtain
|
||||
add_rule(multiworld.get_location('Skull Woods - Prize', player), lambda state: state._lttp_has_key('Small Key (Skull Woods)', player, 5))
|
||||
@@ -522,12 +523,12 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
|
||||
set_rule(multiworld.get_entrance('Palace of Darkness Big Key Chest Staircase', player), lambda state: can_use_bombs(state, player) and (state._lttp_has_key('Small Key (Palace of Darkness)', player, 6) or (
|
||||
location_item_name(state, 'Palace of Darkness - Big Key Chest', player) in [('Small Key (Palace of Darkness)', player)] and state._lttp_has_key('Small Key (Palace of Darkness)', player, 3))))
|
||||
if multiworld.accessibility[player] != 'locations':
|
||||
if multiworld.accessibility[player] != 'full':
|
||||
set_always_allow(multiworld.get_location('Palace of Darkness - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Palace of Darkness)' and item.player == player and state._lttp_has_key('Small Key (Palace of Darkness)', player, 5))
|
||||
|
||||
set_rule(multiworld.get_entrance('Palace of Darkness Spike Statue Room Door', player), lambda state: state._lttp_has_key('Small Key (Palace of Darkness)', player, 6) or (
|
||||
location_item_name(state, 'Palace of Darkness - Harmless Hellway', player) in [('Small Key (Palace of Darkness)', player)] and state._lttp_has_key('Small Key (Palace of Darkness)', player, 4)))
|
||||
if multiworld.accessibility[player] != 'locations':
|
||||
if multiworld.accessibility[player] != 'full':
|
||||
set_always_allow(multiworld.get_location('Palace of Darkness - Harmless Hellway', player), lambda state, item: item.name == 'Small Key (Palace of Darkness)' and item.player == player and state._lttp_has_key('Small Key (Palace of Darkness)', player, 5))
|
||||
|
||||
set_rule(multiworld.get_entrance('Palace of Darkness Maze Door', player), lambda state: state._lttp_has_key('Small Key (Palace of Darkness)', player, 6))
|
||||
@@ -546,7 +547,7 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
location_item_name(state, 'Ganons Tower - Map Chest', player) in [('Big Key (Ganons Tower)', player)] and state._lttp_has_key('Small Key (Ganons Tower)', player, 6)))
|
||||
|
||||
# this seemed to be causing generation failure, disable for now
|
||||
# if world.accessibility[player] != 'locations':
|
||||
# if world.accessibility[player] != 'full':
|
||||
# set_always_allow(world.get_location('Ganons Tower - Map Chest', player), lambda state, item: item.name == 'Small Key (Ganons Tower)' and item.player == player and state._lttp_has_key('Small Key (Ganons Tower)', player, 7) and state.can_reach('Ganons Tower (Hookshot Room)', 'region', player))
|
||||
|
||||
# It is possible to need more than 6 keys to get through this entrance if you spend keys elsewhere. We reflect this in the chest requirements.
|
||||
@@ -1200,7 +1201,7 @@ def set_trock_key_rules(world, player):
|
||||
# Must not go in the Chain Chomps chest - only 2 other chests available and 3+ keys required for all other chests
|
||||
forbid_item(world.get_location('Turtle Rock - Chain Chomps', player), 'Big Key (Turtle Rock)', player)
|
||||
forbid_item(world.get_location('Turtle Rock - Pokey 2 Key Drop', player), 'Big Key (Turtle Rock)', player)
|
||||
if world.accessibility[player] == 'locations':
|
||||
if world.accessibility[player] == 'full':
|
||||
if world.big_key_shuffle[player] and can_reach_big_chest:
|
||||
# Must not go in the dungeon - all 3 available chests (Chomps, Big Chest, Crystaroller) must be keys to access laser bridge, and the big key is required first
|
||||
for location in ['Turtle Rock - Chain Chomps', 'Turtle Rock - Compass Chest',
|
||||
@@ -1214,7 +1215,7 @@ def set_trock_key_rules(world, player):
|
||||
location.place_locked_item(item)
|
||||
toss_junk_item(world, player)
|
||||
|
||||
if world.accessibility[player] != 'locations':
|
||||
if world.accessibility[player] != 'full':
|
||||
set_always_allow(world.get_location('Turtle Rock - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Turtle Rock)' and item.player == player
|
||||
and state.can_reach(state.multiworld.get_region('Turtle Rock (Second Section)', player)))
|
||||
|
||||
|
||||
@@ -76,10 +76,6 @@ class ALttPItem(Item):
|
||||
if self.type in {"SmallKey", "BigKey", "Map", "Compass"}:
|
||||
return self.type
|
||||
|
||||
@property
|
||||
def locked_dungeon_item(self):
|
||||
return self.location.locked and self.dungeon_item
|
||||
|
||||
|
||||
class LTTPRegionType(IntEnum):
|
||||
LightWorld = 1
|
||||
|
||||
@@ -356,6 +356,8 @@ class ALTTPWorld(World):
|
||||
self.dungeon_local_item_names |= self.item_name_groups[option.item_name_group]
|
||||
if option == "original_dungeon":
|
||||
self.dungeon_specific_item_names |= self.item_name_groups[option.item_name_group]
|
||||
else:
|
||||
self.options.local_items.value |= self.dungeon_local_item_names
|
||||
|
||||
self.difficulty_requirements = difficulties[multiworld.item_pool[player].current_key]
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Plando features have to be enabled first, before they can be used (opt-in).
|
||||
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml
|
||||
1. All plando options are enabled by default, except for "items plando" which has to be enabled before it can be used (opt-in).
|
||||
2. To enable it, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml
|
||||
file with a text editor.
|
||||
3. In it, you're looking for the option key `plando_options`. To enable all plando modules you can set the value
|
||||
to `bosses, items, texts, connections`
|
||||
@@ -66,6 +66,7 @@ boss_shuffle:
|
||||
- ignored if only one world is generated
|
||||
- can be a number, to target that slot in the multiworld
|
||||
- can be a name, to target that player's world
|
||||
- can be a list of names, to target those players' worlds
|
||||
- can be true, to target any other player's world
|
||||
- can be false, to target own world and is the default
|
||||
- can be null, to target a random world
|
||||
@@ -132,17 +133,15 @@ plando_items:
|
||||
|
||||
### Texts
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options `text`, `at`, and `percentage`
|
||||
- All of these options support subweights
|
||||
- percentage is the percentage chance for this text to be placed, can be omitted entirely for 100%
|
||||
- text is the text to be placed.
|
||||
- can be weighted.
|
||||
- `\n` is a newline.
|
||||
- `@` is the entered player's name.
|
||||
- Warning: Text Mapper does not support full unicode.
|
||||
- [Alphabet](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L758)
|
||||
- at is the location within the game to attach the text to.
|
||||
- can be weighted.
|
||||
- [List of targets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L1499)
|
||||
|
||||
#### Example
|
||||
@@ -162,7 +161,6 @@ and `uncle_dying_sewer`, then places the text "This is a plando. You've been war
|
||||
|
||||
### Connections
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options `percentage`, `entrance`, `exit` and `direction`.
|
||||
- All options support subweights
|
||||
- percentage is the percentage chance for this to be connected, can be omitted entirely for 100%
|
||||
|
||||
@@ -54,7 +54,7 @@ class TestDungeon(LTTPTestBase):
|
||||
|
||||
for item in items:
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item, event=True) # event=True prevents running sweep_for_events() and picking up
|
||||
state.sweep_for_events() # key drop keys repeatedly
|
||||
state.collect(item, prevent_sweep=True) # prevent_sweep=True prevents running sweep_for_advancements() and picking up
|
||||
state.sweep_for_advancements() # key drop keys repeatedly
|
||||
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access, f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
|
||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||
from worlds.alttp.EntranceShuffle import link_inverted_entrances
|
||||
from worlds.alttp.InvertedRegions import create_inverted_regions
|
||||
from worlds.alttp.ItemPool import difficulties
|
||||
from worlds.alttp.Items import item_factory
|
||||
from worlds.alttp.Regions import mark_light_world_regions
|
||||
from worlds.alttp.Shops import create_shops
|
||||
from test.TestBase import TestBase
|
||||
from test.bases import TestBase
|
||||
|
||||
from worlds.alttp.test import LTTPTestBase
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from worlds.alttp.Items import item_factory
|
||||
from worlds.alttp.Options import GlitchesRequired
|
||||
from worlds.alttp.Regions import mark_light_world_regions
|
||||
from worlds.alttp.Shops import create_shops
|
||||
from test.TestBase import TestBase
|
||||
from test.bases import TestBase
|
||||
|
||||
from worlds.alttp.test import LTTPTestBase
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from worlds.alttp.Items import item_factory
|
||||
from worlds.alttp.Options import GlitchesRequired
|
||||
from worlds.alttp.Regions import mark_light_world_regions
|
||||
from worlds.alttp.Shops import create_shops
|
||||
from test.TestBase import TestBase
|
||||
from test.bases import TestBase
|
||||
|
||||
from worlds.alttp.test import LTTPTestBase
|
||||
|
||||
|
||||
60
worlds/alttp/test/options/test_dungeon_fill.py
Normal file
60
worlds/alttp/test/options/test_dungeon_fill.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from test.general import gen_steps, setup_multiworld
|
||||
from worlds.AutoWorld import call_all
|
||||
from worlds.generic.Rules import locality_rules
|
||||
from ... import ALTTPWorld
|
||||
from ...Options import DungeonItem
|
||||
|
||||
|
||||
class DungeonFillTestBase(TestCase):
|
||||
multiworld: MultiWorld
|
||||
world_1: ALTTPWorld
|
||||
world_2: ALTTPWorld
|
||||
options = (
|
||||
"big_key_shuffle",
|
||||
"small_key_shuffle",
|
||||
"key_drop_shuffle",
|
||||
"compass_shuffle",
|
||||
"map_shuffle",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.multiworld = setup_multiworld([ALTTPWorld, ALTTPWorld], ())
|
||||
self.world_1 = self.multiworld.worlds[1]
|
||||
self.world_2 = self.multiworld.worlds[2]
|
||||
|
||||
def generate_with_options(self, option_value: int):
|
||||
for option in self.options:
|
||||
getattr(self.world_1.options, option).value = getattr(self.world_2.options, option).value = option_value
|
||||
|
||||
for step in gen_steps:
|
||||
call_all(self.multiworld, step)
|
||||
# this is where locality rules are set in normal generation which we need to verify this test
|
||||
if step == "set_rules":
|
||||
locality_rules(self.multiworld)
|
||||
|
||||
def test_original_dungeons(self):
|
||||
self.generate_with_options(DungeonItem.option_original_dungeon)
|
||||
for location in self.multiworld.get_filled_locations():
|
||||
with (self.subTest(location=location)):
|
||||
if location.parent_region.dungeon is None:
|
||||
self.assertIs(location.item.dungeon, None)
|
||||
else:
|
||||
self.assertEqual(location.player, location.item.player,
|
||||
f"{location.item} does not belong to {location}'s player")
|
||||
if location.item.dungeon is None:
|
||||
continue
|
||||
self.assertIs(location.item.dungeon, location.parent_region.dungeon,
|
||||
f"{location.item} was not placed in its original dungeon.")
|
||||
|
||||
def test_own_dungeons(self):
|
||||
self.generate_with_options(DungeonItem.option_own_dungeons)
|
||||
for location in self.multiworld.get_filled_locations():
|
||||
with self.subTest(location=location):
|
||||
if location.parent_region.dungeon is None:
|
||||
self.assertIs(location.item.dungeon, None)
|
||||
else:
|
||||
self.assertEqual(location.player, location.item.player,
|
||||
f"{location.item} does not belong to {location}'s player")
|
||||
@@ -4,7 +4,7 @@ from BaseClasses import Tutorial
|
||||
from ..AutoWorld import WebWorld, World
|
||||
|
||||
class AP_SudokuWebWorld(WebWorld):
|
||||
options_page = "games/Sudoku/info/en"
|
||||
options_page = False
|
||||
theme = 'partyTime'
|
||||
|
||||
setup_en = Tutorial(
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# APSudoku Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [APSudoku](https://github.com/EmilyV99/APSudoku)
|
||||
- Windows (most tested on Win10)
|
||||
- Other platforms might be able to build from source themselves; and may be included in the future.
|
||||
- [APSudoku](https://github.com/APSudoku/APSudoku)
|
||||
|
||||
## General Concept
|
||||
|
||||
@@ -13,25 +11,33 @@ Does not need to be added at the start of a seed, as it does not create any slot
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
Go to the latest release from the [APSudoku Releases page](https://github.com/EmilyV99/APSudoku/releases). Download and extract the `APSudoku.zip` file.
|
||||
Go to the latest release from the [APSudoku Releases page](https://github.com/APSudoku/APSudoku/releases/latest). Download and extract the appropriate file for your platform.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Run APSudoku.exe
|
||||
2. Under the 'Archipelago' tab at the top-right:
|
||||
- Enter the server url & port number
|
||||
1. Run the APSudoku executable.
|
||||
2. Under `Settings` → `Connection` at the top-right:
|
||||
- Enter the server address and port number
|
||||
- Enter the name of the slot you wish to connect to
|
||||
- Enter the room password (optional)
|
||||
- Select DeathLink related settings (optional)
|
||||
- Press connect
|
||||
3. Go back to the 'Sudoku' tab
|
||||
- Click the various '?' buttons for information on how to play / control
|
||||
4. Choose puzzle difficulty
|
||||
5. Try to solve the Sudoku. Click 'Check' when done.
|
||||
- Press `Connect`
|
||||
4. Under the `Sudoku` tab
|
||||
- Choose puzzle difficulty
|
||||
- Click `Start` to generate a puzzle
|
||||
5. Try to solve the Sudoku. Click `Check` when done
|
||||
- A correct solution rewards you with 1 hint for a location in the world you are connected to
|
||||
- An incorrect solution has no penalty, unless DeathLink is enabled (see below)
|
||||
|
||||
Info:
|
||||
- You can set various settings under `Settings` → `Sudoku`, and can change the colors used under `Settings` → `Theme`.
|
||||
- While connected, you can view the `Console` and `Hints` tabs for standard TextClient-like features
|
||||
- You can also use the `Tracking` tab to view either a basic tracker or a valid [GodotAP tracker pack](https://github.com/EmilyV99/GodotAP/blob/main/tracker_packs/GET_PACKS.md)
|
||||
- While connected, the number of "unhinted" locations for your slot is shown in the upper-left of the the `Sudoku` tab. (If this reads 0, no further hints can be earned for this slot, as every locations is already hinted)
|
||||
- Click the various `?` buttons for information on controls/how to play
|
||||
## DeathLink Support
|
||||
|
||||
If 'DeathLink' is enabled when you click 'Connect':
|
||||
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or quit a puzzle without solving it (including disconnecting).
|
||||
- Life count customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
|
||||
If `DeathLink` is enabled when you click `Connect`:
|
||||
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or if you quit a puzzle without solving it (including disconnecting).
|
||||
- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
|
||||
- On receiving a DeathLink from another player, your puzzle resets.
|
||||
|
||||
@@ -99,7 +99,7 @@ item_table = {
|
||||
"Mutant Costume": ItemData(698020, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mutant_costume
|
||||
"Baby Nautilus": ItemData(698021, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_nautilus
|
||||
"Baby Piranha": ItemData(698022, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_piranha
|
||||
"Arnassi Armor": ItemData(698023, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_seahorse_costume
|
||||
"Arnassi Armor": ItemData(698023, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_seahorse_costume
|
||||
"Seed Bag": ItemData(698024, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_seed_bag
|
||||
"King's Skull": ItemData(698025, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_skull
|
||||
"Song Plant Spore": ItemData(698026, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_spore_seed
|
||||
|
||||
@@ -45,7 +45,7 @@ class AquariaLocations:
|
||||
"Home Water, bulb below the grouper fish": 698058,
|
||||
"Home Water, bulb in the path below Nautilus Prime": 698059,
|
||||
"Home Water, bulb in the little room above the grouper fish": 698060,
|
||||
"Home Water, bulb in the end of the left path from the Verse Cave": 698061,
|
||||
"Home Water, bulb in the end of the path close to the Verse Cave": 698061,
|
||||
"Home Water, bulb in the top left path": 698062,
|
||||
"Home Water, bulb in the bottom left room": 698063,
|
||||
"Home Water, bulb close to Naija's Home": 698064,
|
||||
@@ -67,7 +67,7 @@ class AquariaLocations:
|
||||
|
||||
locations_song_cave = {
|
||||
"Song Cave, Erulian spirit": 698206,
|
||||
"Song Cave, bulb in the top left part": 698071,
|
||||
"Song Cave, bulb in the top right part": 698071,
|
||||
"Song Cave, bulb in the big anemone room": 698072,
|
||||
"Song Cave, bulb in the path to the singing statues": 698073,
|
||||
"Song Cave, bulb under the rock in the path to the singing statues": 698074,
|
||||
@@ -152,6 +152,9 @@ class AquariaLocations:
|
||||
|
||||
locations_arnassi_path = {
|
||||
"Arnassi Ruins, Arnassi Statue": 698164,
|
||||
}
|
||||
|
||||
locations_arnassi_cave_transturtle = {
|
||||
"Arnassi Ruins, Transturtle": 698217,
|
||||
}
|
||||
|
||||
@@ -269,9 +272,12 @@ class AquariaLocations:
|
||||
}
|
||||
|
||||
locations_forest_bl = {
|
||||
"Kelp Forest bottom left area, Transturtle": 698212,
|
||||
}
|
||||
|
||||
locations_forest_bl_sc = {
|
||||
"Kelp Forest bottom left area, bulb close to the spirit crystals": 698054,
|
||||
"Kelp Forest bottom left area, Walker Baby": 698186,
|
||||
"Kelp Forest bottom left area, Transturtle": 698212,
|
||||
}
|
||||
|
||||
locations_forest_br = {
|
||||
@@ -370,7 +376,7 @@ class AquariaLocations:
|
||||
|
||||
locations_sun_temple_r = {
|
||||
"Sun Temple, first bulb of the temple": 698091,
|
||||
"Sun Temple, bulb on the left part": 698092,
|
||||
"Sun Temple, bulb on the right part": 698092,
|
||||
"Sun Temple, bulb in the hidden room of the right part": 698093,
|
||||
"Sun Temple, Sun Key": 698182,
|
||||
}
|
||||
@@ -402,6 +408,9 @@ class AquariaLocations:
|
||||
"Abyss right area, bulb in the middle path": 698110,
|
||||
"Abyss right area, bulb behind the rock in the middle path": 698111,
|
||||
"Abyss right area, bulb in the left green room": 698112,
|
||||
}
|
||||
|
||||
locations_abyss_r_transturtle = {
|
||||
"Abyss right area, Transturtle": 698214,
|
||||
}
|
||||
|
||||
@@ -499,6 +508,7 @@ location_table = {
|
||||
**AquariaLocations.locations_skeleton_path_sc,
|
||||
**AquariaLocations.locations_arnassi,
|
||||
**AquariaLocations.locations_arnassi_path,
|
||||
**AquariaLocations.locations_arnassi_cave_transturtle,
|
||||
**AquariaLocations.locations_arnassi_crab_boss,
|
||||
**AquariaLocations.locations_sun_temple_l,
|
||||
**AquariaLocations.locations_sun_temple_r,
|
||||
@@ -509,6 +519,7 @@ location_table = {
|
||||
**AquariaLocations.locations_abyss_l,
|
||||
**AquariaLocations.locations_abyss_lb,
|
||||
**AquariaLocations.locations_abyss_r,
|
||||
**AquariaLocations.locations_abyss_r_transturtle,
|
||||
**AquariaLocations.locations_energy_temple_1,
|
||||
**AquariaLocations.locations_energy_temple_2,
|
||||
**AquariaLocations.locations_energy_temple_3,
|
||||
@@ -530,6 +541,7 @@ location_table = {
|
||||
**AquariaLocations.locations_forest_tr,
|
||||
**AquariaLocations.locations_forest_tr_fp,
|
||||
**AquariaLocations.locations_forest_bl,
|
||||
**AquariaLocations.locations_forest_bl_sc,
|
||||
**AquariaLocations.locations_forest_br,
|
||||
**AquariaLocations.locations_forest_boss,
|
||||
**AquariaLocations.locations_forest_boss_entrance,
|
||||
|
||||
@@ -14,97 +14,112 @@ from worlds.generic.Rules import add_rule, set_rule
|
||||
|
||||
# Every condition to connect regions
|
||||
|
||||
def _has_hot_soup(state:CollectionState, player: int) -> bool:
|
||||
def _has_hot_soup(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the hotsoup item"""
|
||||
return state.has("Hot soup", player)
|
||||
return state.has_any({"Hot soup", "Hot soup x 2"}, player)
|
||||
|
||||
|
||||
def _has_tongue_cleared(state:CollectionState, player: int) -> bool:
|
||||
def _has_tongue_cleared(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the Body tongue cleared item"""
|
||||
return state.has("Body tongue cleared", player)
|
||||
|
||||
|
||||
def _has_sun_crystal(state:CollectionState, player: int) -> bool:
|
||||
def _has_sun_crystal(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the Sun crystal item"""
|
||||
return state.has("Has sun crystal", player) and _has_bind_song(state, player)
|
||||
|
||||
|
||||
def _has_li(state:CollectionState, player: int) -> bool:
|
||||
def _has_li(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has Li in its team"""
|
||||
return state.has("Li and Li song", player)
|
||||
|
||||
|
||||
def _has_damaging_item(state:CollectionState, player: int) -> bool:
|
||||
def _has_damaging_item(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the shield song item"""
|
||||
return state.has_any({"Energy form", "Nature form", "Beast form", "Li and Li song", "Baby Nautilus",
|
||||
"Baby Piranha", "Baby Blaster"}, player)
|
||||
return state.has_any({"Energy form", "Nature form", "Beast form", "Li and Li song", "Baby Nautilus",
|
||||
"Baby Piranha", "Baby Blaster"}, player)
|
||||
|
||||
|
||||
def _has_shield_song(state:CollectionState, player: int) -> bool:
|
||||
def _has_energy_attack_item(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has items that can do a lot of damage (enough to beat bosses)"""
|
||||
return _has_energy_form(state, player) or _has_dual_form(state, player)
|
||||
|
||||
|
||||
def _has_shield_song(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the shield song item"""
|
||||
return state.has("Shield song", player)
|
||||
|
||||
|
||||
def _has_bind_song(state:CollectionState, player: int) -> bool:
|
||||
def _has_bind_song(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the bind song item"""
|
||||
return state.has("Bind song", player)
|
||||
|
||||
|
||||
def _has_energy_form(state:CollectionState, player: int) -> bool:
|
||||
def _has_energy_form(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the energy form item"""
|
||||
return state.has("Energy form", player)
|
||||
|
||||
|
||||
def _has_beast_form(state:CollectionState, player: int) -> bool:
|
||||
def _has_beast_form(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the beast form item"""
|
||||
return state.has("Beast form", player)
|
||||
|
||||
|
||||
def _has_nature_form(state:CollectionState, player: int) -> bool:
|
||||
def _has_beast_and_soup_form(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the beast form item"""
|
||||
return _has_beast_form(state, player) and _has_hot_soup(state, player)
|
||||
|
||||
|
||||
def _has_beast_form_or_arnassi_armor(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the beast form item"""
|
||||
return _has_beast_form(state, player) or state.has("Arnassi Armor", player)
|
||||
|
||||
|
||||
def _has_nature_form(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the nature form item"""
|
||||
return state.has("Nature form", player)
|
||||
|
||||
|
||||
def _has_sun_form(state:CollectionState, player: int) -> bool:
|
||||
def _has_sun_form(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the sun form item"""
|
||||
return state.has("Sun form", player)
|
||||
|
||||
|
||||
def _has_light(state:CollectionState, player: int) -> bool:
|
||||
def _has_light(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the light item"""
|
||||
return state.has("Baby Dumbo", player) or _has_sun_form(state, player)
|
||||
|
||||
|
||||
def _has_dual_form(state:CollectionState, player: int) -> bool:
|
||||
def _has_dual_form(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the dual form item"""
|
||||
return _has_li(state, player) and state.has("Dual form", player)
|
||||
|
||||
|
||||
def _has_fish_form(state:CollectionState, player: int) -> bool:
|
||||
def _has_fish_form(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the fish form item"""
|
||||
return state.has("Fish form", player)
|
||||
|
||||
|
||||
def _has_spirit_form(state:CollectionState, player: int) -> bool:
|
||||
def _has_spirit_form(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the spirit form item"""
|
||||
return state.has("Spirit form", player)
|
||||
|
||||
|
||||
def _has_big_bosses(state:CollectionState, player: int) -> bool:
|
||||
def _has_big_bosses(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has beated every big bosses"""
|
||||
return state.has_all({"Fallen God beated", "Mithalan God beated", "Drunian God beated",
|
||||
"Sun God beated", "The Golem beated"}, player)
|
||||
"Sun God beated", "The Golem beated"}, player)
|
||||
|
||||
|
||||
def _has_mini_bosses(state:CollectionState, player: int) -> bool:
|
||||
def _has_mini_bosses(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has beated every big bosses"""
|
||||
return state.has_all({"Nautilus Prime beated", "Blaster Peg Prime beated", "Mergog beated",
|
||||
"Mithalan priests beated", "Octopus Prime beated", "Crabbius Maximus beated",
|
||||
"Mantis Shrimp Prime beated", "King Jellyfish God Prime beated"}, player)
|
||||
"Mithalan priests beated", "Octopus Prime beated", "Crabbius Maximus beated",
|
||||
"Mantis Shrimp Prime beated", "King Jellyfish God Prime beated"}, player)
|
||||
|
||||
|
||||
def _has_secrets(state:CollectionState, player: int) -> bool:
|
||||
return state.has_all({"First secret obtained", "Second secret obtained", "Third secret obtained"},player)
|
||||
def _has_secrets(state: CollectionState, player: int) -> bool:
|
||||
return state.has_all({"First secret obtained", "Second secret obtained", "Third secret obtained"}, player)
|
||||
|
||||
|
||||
class AquariaRegions:
|
||||
@@ -134,6 +149,7 @@ class AquariaRegions:
|
||||
skeleton_path: Region
|
||||
skeleton_path_sc: Region
|
||||
arnassi: Region
|
||||
arnassi_cave_transturtle: Region
|
||||
arnassi_path: Region
|
||||
arnassi_crab_boss: Region
|
||||
simon: Region
|
||||
@@ -152,6 +168,7 @@ class AquariaRegions:
|
||||
forest_tr: Region
|
||||
forest_tr_fp: Region
|
||||
forest_bl: Region
|
||||
forest_bl_sc: Region
|
||||
forest_br: Region
|
||||
forest_boss: Region
|
||||
forest_boss_entrance: Region
|
||||
@@ -179,6 +196,7 @@ class AquariaRegions:
|
||||
abyss_l: Region
|
||||
abyss_lb: Region
|
||||
abyss_r: Region
|
||||
abyss_r_transturtle: Region
|
||||
ice_cave: Region
|
||||
bubble_cave: Region
|
||||
bubble_cave_boss: Region
|
||||
@@ -213,7 +231,7 @@ class AquariaRegions:
|
||||
"""
|
||||
|
||||
def __add_region(self, hint: str,
|
||||
locations: Optional[Dict[str, Optional[int]]]) -> Region:
|
||||
locations: Optional[Dict[str, int]]) -> Region:
|
||||
"""
|
||||
Create a new Region, add it to the `world` regions and return it.
|
||||
Be aware that this function have a side effect on ``world`.`regions`
|
||||
@@ -236,7 +254,7 @@ class AquariaRegions:
|
||||
self.home_water_nautilus = self.__add_region("Home Water, Nautilus nest",
|
||||
AquariaLocations.locations_home_water_nautilus)
|
||||
self.home_water_transturtle = self.__add_region("Home Water, turtle room",
|
||||
AquariaLocations.locations_home_water_transturtle)
|
||||
AquariaLocations.locations_home_water_transturtle)
|
||||
self.naija_home = self.__add_region("Naija's Home", AquariaLocations.locations_naija_home)
|
||||
self.song_cave = self.__add_region("Song Cave", AquariaLocations.locations_song_cave)
|
||||
|
||||
@@ -280,6 +298,8 @@ class AquariaRegions:
|
||||
self.arnassi = self.__add_region("Arnassi Ruins", AquariaLocations.locations_arnassi)
|
||||
self.arnassi_path = self.__add_region("Arnassi Ruins, back entrance path",
|
||||
AquariaLocations.locations_arnassi_path)
|
||||
self.arnassi_cave_transturtle = self.__add_region("Arnassi Ruins, transturtle area",
|
||||
AquariaLocations.locations_arnassi_cave_transturtle)
|
||||
self.arnassi_crab_boss = self.__add_region("Arnassi Ruins, Crabbius Maximus lair",
|
||||
AquariaLocations.locations_arnassi_crab_boss)
|
||||
|
||||
@@ -302,9 +322,9 @@ class AquariaRegions:
|
||||
AquariaLocations.locations_cathedral_r)
|
||||
self.cathedral_underground = self.__add_region("Mithalas Cathedral underground",
|
||||
AquariaLocations.locations_cathedral_underground)
|
||||
self.cathedral_boss_r = self.__add_region("Mithalas Cathedral, Mithalan God room",
|
||||
self.cathedral_boss_r = self.__add_region("Mithalas Cathedral, Mithalan God room", None)
|
||||
self.cathedral_boss_l = self.__add_region("Mithalas Cathedral, after Mithalan God room",
|
||||
AquariaLocations.locations_cathedral_boss)
|
||||
self.cathedral_boss_l = self.__add_region("Mithalas Cathedral, after Mithalan God room", None)
|
||||
|
||||
def __create_forest(self) -> None:
|
||||
"""
|
||||
@@ -320,6 +340,8 @@ class AquariaRegions:
|
||||
AquariaLocations.locations_forest_tr_fp)
|
||||
self.forest_bl = self.__add_region("Kelp Forest bottom left area",
|
||||
AquariaLocations.locations_forest_bl)
|
||||
self.forest_bl_sc = self.__add_region("Kelp Forest bottom left area, spirit crystals",
|
||||
AquariaLocations.locations_forest_bl_sc)
|
||||
self.forest_br = self.__add_region("Kelp Forest bottom right area",
|
||||
AquariaLocations.locations_forest_br)
|
||||
self.forest_sprite_cave = self.__add_region("Kelp Forest spirit cave",
|
||||
@@ -375,9 +397,9 @@ class AquariaRegions:
|
||||
self.sun_temple_r = self.__add_region("Sun Temple right area",
|
||||
AquariaLocations.locations_sun_temple_r)
|
||||
self.sun_temple_boss_path = self.__add_region("Sun Temple before boss area",
|
||||
AquariaLocations.locations_sun_temple_boss_path)
|
||||
AquariaLocations.locations_sun_temple_boss_path)
|
||||
self.sun_temple_boss = self.__add_region("Sun Temple boss area",
|
||||
AquariaLocations.locations_sun_temple_boss)
|
||||
AquariaLocations.locations_sun_temple_boss)
|
||||
|
||||
def __create_abyss(self) -> None:
|
||||
"""
|
||||
@@ -388,6 +410,8 @@ class AquariaRegions:
|
||||
AquariaLocations.locations_abyss_l)
|
||||
self.abyss_lb = self.__add_region("Abyss left bottom area", AquariaLocations.locations_abyss_lb)
|
||||
self.abyss_r = self.__add_region("Abyss right area", AquariaLocations.locations_abyss_r)
|
||||
self.abyss_r_transturtle = self.__add_region("Abyss right area, transturtle",
|
||||
AquariaLocations.locations_abyss_r_transturtle)
|
||||
self.ice_cave = self.__add_region("Ice Cave", AquariaLocations.locations_ice_cave)
|
||||
self.bubble_cave = self.__add_region("Bubble Cave", AquariaLocations.locations_bubble_cave)
|
||||
self.bubble_cave_boss = self.__add_region("Bubble Cave boss area", AquariaLocations.locations_bubble_cave_boss)
|
||||
@@ -407,7 +431,7 @@ class AquariaRegions:
|
||||
self.sunken_city_r = self.__add_region("Sunken City right area",
|
||||
AquariaLocations.locations_sunken_city_r)
|
||||
self.sunken_city_boss = self.__add_region("Sunken City boss area",
|
||||
AquariaLocations.locations_sunken_city_boss)
|
||||
AquariaLocations.locations_sunken_city_boss)
|
||||
|
||||
def __create_body(self) -> None:
|
||||
"""
|
||||
@@ -427,7 +451,7 @@ class AquariaRegions:
|
||||
self.final_boss_tube = self.__add_region("The Body, final boss area turtle room",
|
||||
AquariaLocations.locations_final_boss_tube)
|
||||
self.final_boss = self.__add_region("The Body, final boss",
|
||||
AquariaLocations.locations_final_boss)
|
||||
AquariaLocations.locations_final_boss)
|
||||
self.final_boss_end = self.__add_region("The Body, final boss area", None)
|
||||
|
||||
def __connect_one_way_regions(self, source_name: str, destination_name: str,
|
||||
@@ -455,8 +479,8 @@ class AquariaRegions:
|
||||
"""
|
||||
Connect entrances of the different regions around `home_water`
|
||||
"""
|
||||
self.__connect_regions("Menu", "Verse Cave right area",
|
||||
self.menu, self.verse_cave_r)
|
||||
self.__connect_one_way_regions("Menu", "Verse Cave right area",
|
||||
self.menu, self.verse_cave_r)
|
||||
self.__connect_regions("Verse Cave left area", "Verse Cave right area",
|
||||
self.verse_cave_l, self.verse_cave_r)
|
||||
self.__connect_regions("Verse Cave", "Home Water", self.verse_cave_l, self.home_water)
|
||||
@@ -464,7 +488,8 @@ class AquariaRegions:
|
||||
self.__connect_regions("Home Water", "Song Cave", self.home_water, self.song_cave)
|
||||
self.__connect_regions("Home Water", "Home Water, nautilus nest",
|
||||
self.home_water, self.home_water_nautilus,
|
||||
lambda state: _has_energy_form(state, self.player) and _has_bind_song(state, self.player))
|
||||
lambda state: _has_energy_attack_item(state, self.player) and
|
||||
_has_bind_song(state, self.player))
|
||||
self.__connect_regions("Home Water", "Home Water transturtle room",
|
||||
self.home_water, self.home_water_transturtle)
|
||||
self.__connect_regions("Home Water", "Energy Temple first area",
|
||||
@@ -472,7 +497,7 @@ class AquariaRegions:
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
self.__connect_regions("Home Water", "Energy Temple_altar",
|
||||
self.home_water, self.energy_temple_altar,
|
||||
lambda state: _has_energy_form(state, self.player) and
|
||||
lambda state: _has_energy_attack_item(state, self.player) and
|
||||
_has_bind_song(state, self.player))
|
||||
self.__connect_regions("Energy Temple first area", "Energy Temple second area",
|
||||
self.energy_temple_1, self.energy_temple_2,
|
||||
@@ -482,28 +507,28 @@ class AquariaRegions:
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
self.__connect_regions("Energy Temple idol room", "Energy Temple boss area",
|
||||
self.energy_temple_idol, self.energy_temple_boss,
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
lambda state: _has_energy_attack_item(state, self.player) and
|
||||
_has_fish_form(state, self.player))
|
||||
self.__connect_one_way_regions("Energy Temple first area", "Energy Temple boss area",
|
||||
self.energy_temple_1, self.energy_temple_boss,
|
||||
lambda state: _has_beast_form(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
_has_energy_attack_item(state, self.player))
|
||||
self.__connect_one_way_regions("Energy Temple boss area", "Energy Temple first area",
|
||||
self.energy_temple_boss, self.energy_temple_1,
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
lambda state: _has_energy_attack_item(state, self.player))
|
||||
self.__connect_regions("Energy Temple second area", "Energy Temple third area",
|
||||
self.energy_temple_2, self.energy_temple_3,
|
||||
lambda state: _has_bind_song(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
self.__connect_regions("Energy Temple boss area", "Energy Temple blaster room",
|
||||
self.energy_temple_boss, self.energy_temple_blaster_room,
|
||||
lambda state: _has_nature_form(state, self.player) and
|
||||
_has_bind_song(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
_has_energy_attack_item(state, self.player))
|
||||
self.__connect_regions("Energy Temple first area", "Energy Temple blaster room",
|
||||
self.energy_temple_1, self.energy_temple_blaster_room,
|
||||
lambda state: _has_nature_form(state, self.player) and
|
||||
_has_bind_song(state, self.player) and
|
||||
_has_energy_form(state, self.player) and
|
||||
_has_energy_attack_item(state, self.player) and
|
||||
_has_beast_form(state, self.player))
|
||||
self.__connect_regions("Home Water", "Open Water top left area",
|
||||
self.home_water, self.openwater_tl)
|
||||
@@ -520,7 +545,7 @@ class AquariaRegions:
|
||||
self.openwater_tl, self.forest_br)
|
||||
self.__connect_regions("Open Water top right area", "Open Water top right area, turtle room",
|
||||
self.openwater_tr, self.openwater_tr_turtle,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
|
||||
self.__connect_regions("Open Water top right area", "Open Water bottom right area",
|
||||
self.openwater_tr, self.openwater_br)
|
||||
self.__connect_regions("Open Water top right area", "Mithalas City",
|
||||
@@ -529,10 +554,9 @@ class AquariaRegions:
|
||||
self.openwater_tr, self.veil_bl)
|
||||
self.__connect_one_way_regions("Open Water top right area", "Veil bottom right",
|
||||
self.openwater_tr, self.veil_br,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
|
||||
self.__connect_one_way_regions("Veil bottom right", "Open Water top right area",
|
||||
self.veil_br, self.openwater_tr,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
self.veil_br, self.openwater_tr)
|
||||
self.__connect_regions("Open Water bottom left area", "Open Water bottom right area",
|
||||
self.openwater_bl, self.openwater_br)
|
||||
self.__connect_regions("Open Water bottom left area", "Skeleton path",
|
||||
@@ -551,10 +575,14 @@ class AquariaRegions:
|
||||
self.arnassi, self.openwater_br)
|
||||
self.__connect_regions("Arnassi", "Arnassi path",
|
||||
self.arnassi, self.arnassi_path)
|
||||
self.__connect_regions("Arnassi ruins, transturtle area", "Arnassi path",
|
||||
self.arnassi_cave_transturtle, self.arnassi_path,
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
self.__connect_one_way_regions("Arnassi path", "Arnassi crab boss area",
|
||||
self.arnassi_path, self.arnassi_crab_boss,
|
||||
lambda state: _has_beast_form(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
lambda state: _has_beast_form_or_arnassi_armor(state, self.player) and
|
||||
(_has_energy_attack_item(state, self.player) or
|
||||
_has_nature_form(state, self.player)))
|
||||
self.__connect_one_way_regions("Arnassi crab boss area", "Arnassi path",
|
||||
self.arnassi_crab_boss, self.arnassi_path)
|
||||
|
||||
@@ -564,61 +592,62 @@ class AquariaRegions:
|
||||
"""
|
||||
self.__connect_one_way_regions("Mithalas City", "Mithalas City top path",
|
||||
self.mithalas_city, self.mithalas_city_top_path,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas City_top_path", "Mithalas City",
|
||||
self.mithalas_city_top_path, self.mithalas_city)
|
||||
self.__connect_regions("Mithalas City", "Mithalas City home with fishpass",
|
||||
self.mithalas_city, self.mithalas_city_fishpass,
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
self.__connect_regions("Mithalas City", "Mithalas castle",
|
||||
self.mithalas_city, self.cathedral_l,
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
self.mithalas_city, self.cathedral_l)
|
||||
self.__connect_one_way_regions("Mithalas City top path", "Mithalas castle, flower tube",
|
||||
self.mithalas_city_top_path,
|
||||
self.cathedral_l_tube,
|
||||
lambda state: _has_nature_form(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
_has_energy_attack_item(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas castle, flower tube area", "Mithalas City top path",
|
||||
self.cathedral_l_tube,
|
||||
self.mithalas_city_top_path,
|
||||
lambda state: _has_beast_form(state, self.player) and
|
||||
_has_nature_form(state, self.player))
|
||||
lambda state: _has_nature_form(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas castle flower tube area", "Mithalas castle, spirit crystals",
|
||||
self.cathedral_l_tube, self.cathedral_l_sc,
|
||||
lambda state: _has_spirit_form(state, self.player))
|
||||
self.cathedral_l_tube, self.cathedral_l_sc,
|
||||
lambda state: _has_spirit_form(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas castle_flower tube area", "Mithalas castle",
|
||||
self.cathedral_l_tube, self.cathedral_l,
|
||||
lambda state: _has_spirit_form(state, self.player))
|
||||
self.cathedral_l_tube, self.cathedral_l,
|
||||
lambda state: _has_spirit_form(state, self.player))
|
||||
self.__connect_regions("Mithalas castle", "Mithalas castle, spirit crystals",
|
||||
self.cathedral_l, self.cathedral_l_sc,
|
||||
lambda state: _has_spirit_form(state, self.player))
|
||||
self.__connect_regions("Mithalas castle", "Cathedral boss left area",
|
||||
self.cathedral_l, self.cathedral_boss_l,
|
||||
lambda state: _has_beast_form(state, self.player) and
|
||||
_has_energy_form(state, self.player) and
|
||||
_has_bind_song(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas castle", "Cathedral boss right area",
|
||||
self.cathedral_l, self.cathedral_boss_r,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
self.__connect_one_way_regions("Cathedral boss left area", "Mithalas castle",
|
||||
self.cathedral_boss_l, self.cathedral_l,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
self.__connect_regions("Mithalas castle", "Mithalas Cathedral underground",
|
||||
self.cathedral_l, self.cathedral_underground,
|
||||
lambda state: _has_beast_form(state, self.player) and
|
||||
_has_bind_song(state, self.player))
|
||||
self.__connect_regions("Mithalas castle", "Mithalas Cathedral",
|
||||
self.cathedral_l, self.cathedral_r,
|
||||
lambda state: _has_bind_song(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
self.__connect_regions("Mithalas Cathedral", "Mithalas Cathedral underground",
|
||||
self.cathedral_r, self.cathedral_underground,
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas Cathedral underground", "Cathedral boss left area",
|
||||
self.cathedral_underground, self.cathedral_boss_r,
|
||||
lambda state: _has_energy_form(state, self.player) and
|
||||
_has_bind_song(state, self.player))
|
||||
self.__connect_one_way_regions("Cathedral boss left area", "Mithalas Cathedral underground",
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas castle", "Mithalas Cathedral",
|
||||
self.cathedral_l, self.cathedral_r,
|
||||
lambda state: _has_bind_song(state, self.player) and
|
||||
_has_energy_attack_item(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas Cathedral", "Mithalas Cathedral underground",
|
||||
self.cathedral_r, self.cathedral_underground)
|
||||
self.__connect_one_way_regions("Mithalas Cathedral underground", "Mithalas Cathedral",
|
||||
self.cathedral_underground, self.cathedral_r,
|
||||
lambda state: _has_beast_form(state, self.player) and
|
||||
_has_energy_attack_item(state, self.player))
|
||||
self.__connect_one_way_regions("Mithalas Cathedral underground", "Cathedral boss right area",
|
||||
self.cathedral_underground, self.cathedral_boss_r)
|
||||
self.__connect_one_way_regions("Cathedral boss right area", "Mithalas Cathedral underground",
|
||||
self.cathedral_boss_r, self.cathedral_underground,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
self.__connect_regions("Cathedral boss right area", "Cathedral boss left area",
|
||||
self.__connect_one_way_regions("Cathedral boss right area", "Cathedral boss left area",
|
||||
self.cathedral_boss_r, self.cathedral_boss_l,
|
||||
lambda state: _has_bind_song(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
_has_energy_attack_item(state, self.player))
|
||||
self.__connect_one_way_regions("Cathedral boss left area", "Cathedral boss right area",
|
||||
self.cathedral_boss_l, self.cathedral_boss_r)
|
||||
|
||||
def __connect_forest_regions(self) -> None:
|
||||
"""
|
||||
@@ -628,6 +657,12 @@ class AquariaRegions:
|
||||
self.forest_br, self.veil_bl)
|
||||
self.__connect_regions("Forest bottom right", "Forest bottom left area",
|
||||
self.forest_br, self.forest_bl)
|
||||
self.__connect_one_way_regions("Forest bottom left area", "Forest bottom left area, spirit crystals",
|
||||
self.forest_bl, self.forest_bl_sc,
|
||||
lambda state: _has_energy_attack_item(state, self.player) or
|
||||
_has_fish_form(state, self.player))
|
||||
self.__connect_one_way_regions("Forest bottom left area, spirit crystals", "Forest bottom left area",
|
||||
self.forest_bl_sc, self.forest_bl)
|
||||
self.__connect_regions("Forest bottom right", "Forest top right area",
|
||||
self.forest_br, self.forest_tr)
|
||||
self.__connect_regions("Forest bottom left area", "Forest fish cave",
|
||||
@@ -641,7 +676,7 @@ class AquariaRegions:
|
||||
self.forest_tl, self.forest_tl_fp,
|
||||
lambda state: _has_nature_form(state, self.player) and
|
||||
_has_bind_song(state, self.player) and
|
||||
_has_energy_form(state, self.player) and
|
||||
_has_energy_attack_item(state, self.player) and
|
||||
_has_fish_form(state, self.player))
|
||||
self.__connect_regions("Forest top left area", "Forest top right area",
|
||||
self.forest_tl, self.forest_tr)
|
||||
@@ -649,7 +684,7 @@ class AquariaRegions:
|
||||
self.forest_tl, self.forest_boss_entrance)
|
||||
self.__connect_regions("Forest boss area", "Forest boss entrance",
|
||||
self.forest_boss, self.forest_boss_entrance,
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
lambda state: _has_energy_attack_item(state, self.player))
|
||||
self.__connect_regions("Forest top right area", "Forest top right area fish pass",
|
||||
self.forest_tr, self.forest_tr_fp,
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
@@ -663,7 +698,7 @@ class AquariaRegions:
|
||||
self.__connect_regions("Fermog cave", "Fermog boss",
|
||||
self.mermog_cave, self.mermog_boss,
|
||||
lambda state: _has_beast_form(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
_has_energy_attack_item(state, self.player))
|
||||
|
||||
def __connect_veil_regions(self) -> None:
|
||||
"""
|
||||
@@ -681,8 +716,7 @@ class AquariaRegions:
|
||||
self.veil_b_sc, self.veil_br,
|
||||
lambda state: _has_spirit_form(state, self.player))
|
||||
self.__connect_regions("Veil bottom right", "Veil top left area",
|
||||
self.veil_br, self.veil_tl,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
self.veil_br, self.veil_tl)
|
||||
self.__connect_regions("Veil top left area", "Veil_top left area, fish pass",
|
||||
self.veil_tl, self.veil_tl_fp,
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
@@ -691,20 +725,25 @@ class AquariaRegions:
|
||||
self.__connect_regions("Veil top left area", "Turtle cave",
|
||||
self.veil_tl, self.turtle_cave)
|
||||
self.__connect_regions("Turtle cave", "Turtle cave Bubble Cliff",
|
||||
self.turtle_cave, self.turtle_cave_bubble,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
self.turtle_cave, self.turtle_cave_bubble)
|
||||
self.__connect_regions("Veil right of sun temple", "Sun Temple right area",
|
||||
self.veil_tr_r, self.sun_temple_r)
|
||||
self.__connect_regions("Sun Temple right area", "Sun Temple left area",
|
||||
self.sun_temple_r, self.sun_temple_l,
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
self.__connect_one_way_regions("Sun Temple right area", "Sun Temple left area",
|
||||
self.sun_temple_r, self.sun_temple_l,
|
||||
lambda state: _has_bind_song(state, self.player) or
|
||||
_has_light(state, self.player))
|
||||
self.__connect_one_way_regions("Sun Temple left area", "Sun Temple right area",
|
||||
self.sun_temple_l, self.sun_temple_r,
|
||||
lambda state: _has_light(state, self.player))
|
||||
self.__connect_regions("Sun Temple left area", "Veil left of sun temple",
|
||||
self.sun_temple_l, self.veil_tr_l)
|
||||
self.__connect_regions("Sun Temple left area", "Sun Temple before boss area",
|
||||
self.sun_temple_l, self.sun_temple_boss_path)
|
||||
self.sun_temple_l, self.sun_temple_boss_path,
|
||||
lambda state: _has_light(state, self.player) or
|
||||
_has_sun_crystal(state, self.player))
|
||||
self.__connect_regions("Sun Temple before boss area", "Sun Temple boss area",
|
||||
self.sun_temple_boss_path, self.sun_temple_boss,
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
lambda state: _has_energy_attack_item(state, self.player))
|
||||
self.__connect_one_way_regions("Sun Temple boss area", "Veil left of sun temple",
|
||||
self.sun_temple_boss, self.veil_tr_l)
|
||||
self.__connect_regions("Veil left of sun temple", "Octo cave top path",
|
||||
@@ -712,7 +751,7 @@ class AquariaRegions:
|
||||
lambda state: _has_fish_form(state, self.player) and
|
||||
_has_sun_form(state, self.player) and
|
||||
_has_beast_form(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
_has_energy_attack_item(state, self.player))
|
||||
self.__connect_regions("Veil left of sun temple", "Octo cave bottom path",
|
||||
self.veil_tr_l, self.octo_cave_b,
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
@@ -728,16 +767,22 @@ class AquariaRegions:
|
||||
self.abyss_lb, self.sunken_city_r,
|
||||
lambda state: _has_li(state, self.player))
|
||||
self.__connect_one_way_regions("Abyss left bottom area", "Body center area",
|
||||
self.abyss_lb, self.body_c,
|
||||
lambda state: _has_tongue_cleared(state, self.player))
|
||||
self.abyss_lb, self.body_c,
|
||||
lambda state: _has_tongue_cleared(state, self.player))
|
||||
self.__connect_one_way_regions("Body center area", "Abyss left bottom area",
|
||||
self.body_c, self.abyss_lb)
|
||||
self.body_c, self.abyss_lb)
|
||||
self.__connect_regions("Abyss left area", "King jellyfish cave",
|
||||
self.abyss_l, self.king_jellyfish_cave,
|
||||
lambda state: _has_energy_form(state, self.player) and
|
||||
_has_beast_form(state, self.player))
|
||||
lambda state: (_has_energy_form(state, self.player) and
|
||||
_has_beast_form(state, self.player)) or
|
||||
_has_dual_form(state, self.player))
|
||||
self.__connect_regions("Abyss left area", "Abyss right area",
|
||||
self.abyss_l, self.abyss_r)
|
||||
self.__connect_one_way_regions("Abyss right area", "Abyss right area, transturtle",
|
||||
self.abyss_r, self.abyss_r_transturtle)
|
||||
self.__connect_one_way_regions("Abyss right area, transturtle", "Abyss right area",
|
||||
self.abyss_r_transturtle, self.abyss_r,
|
||||
lambda state: _has_light(state, self.player))
|
||||
self.__connect_regions("Abyss right area", "Inside the whale",
|
||||
self.abyss_r, self.whale,
|
||||
lambda state: _has_spirit_form(state, self.player) and
|
||||
@@ -747,13 +792,14 @@ class AquariaRegions:
|
||||
lambda state: _has_spirit_form(state, self.player) and
|
||||
_has_sun_form(state, self.player) and
|
||||
_has_bind_song(state, self.player) and
|
||||
_has_energy_form(state, self.player))
|
||||
_has_energy_attack_item(state, self.player))
|
||||
self.__connect_regions("Abyss right area", "Ice Cave",
|
||||
self.abyss_r, self.ice_cave,
|
||||
lambda state: _has_spirit_form(state, self.player))
|
||||
self.__connect_regions("Abyss right area", "Bubble Cave",
|
||||
self.__connect_regions("Ice cave", "Bubble Cave",
|
||||
self.ice_cave, self.bubble_cave,
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
lambda state: _has_beast_form(state, self.player) or
|
||||
_has_hot_soup(state, self.player))
|
||||
self.__connect_regions("Bubble Cave boss area", "Bubble Cave",
|
||||
self.bubble_cave, self.bubble_cave_boss,
|
||||
lambda state: _has_nature_form(state, self.player) and _has_bind_song(state, self.player)
|
||||
@@ -772,7 +818,7 @@ class AquariaRegions:
|
||||
self.sunken_city_l, self.sunken_city_boss,
|
||||
lambda state: _has_beast_form(state, self.player) and
|
||||
_has_sun_form(state, self.player) and
|
||||
_has_energy_form(state, self.player) and
|
||||
_has_energy_attack_item(state, self.player) and
|
||||
_has_bind_song(state, self.player))
|
||||
|
||||
def __connect_body_regions(self) -> None:
|
||||
@@ -780,11 +826,13 @@ class AquariaRegions:
|
||||
Connect entrances of the different regions around The Body
|
||||
"""
|
||||
self.__connect_regions("Body center area", "Body left area",
|
||||
self.body_c, self.body_l)
|
||||
self.body_c, self.body_l,
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
self.__connect_regions("Body center area", "Body right area top path",
|
||||
self.body_c, self.body_rt)
|
||||
self.__connect_regions("Body center area", "Body right area bottom path",
|
||||
self.body_c, self.body_rb)
|
||||
self.body_c, self.body_rb,
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
self.__connect_regions("Body center area", "Body bottom area",
|
||||
self.body_c, self.body_b,
|
||||
lambda state: _has_dual_form(state, self.player))
|
||||
@@ -803,22 +851,12 @@ class AquariaRegions:
|
||||
self.__connect_one_way_regions("final boss third form area", "final boss end",
|
||||
self.final_boss, self.final_boss_end)
|
||||
|
||||
def __connect_transturtle(self, item_source: str, item_target: str, region_source: Region, region_target: Region,
|
||||
rule=None) -> None:
|
||||
def __connect_transturtle(self, item_source: str, item_target: str, region_source: Region,
|
||||
region_target: Region) -> None:
|
||||
"""Connect a single transturtle to another one"""
|
||||
if item_source != item_target:
|
||||
if rule is None:
|
||||
self.__connect_one_way_regions(item_source, item_target, region_source, region_target,
|
||||
lambda state: state.has(item_target, self.player))
|
||||
else:
|
||||
self.__connect_one_way_regions(item_source, item_target, region_source, region_target, rule)
|
||||
|
||||
def __connect_arnassi_path_transturtle(self, item_source: str, item_target: str, region_source: Region,
|
||||
region_target: Region) -> None:
|
||||
"""Connect the Arnassi Ruins transturtle to another one"""
|
||||
self.__connect_one_way_regions(item_source, item_target, region_source, region_target,
|
||||
lambda state: state.has(item_target, self.player) and
|
||||
_has_fish_form(state, self.player))
|
||||
self.__connect_one_way_regions(item_source, item_target, region_source, region_target,
|
||||
lambda state: state.has(item_target, self.player))
|
||||
|
||||
def _connect_transturtle_to_other(self, item: str, region: Region) -> None:
|
||||
"""Connect a single transturtle to all others"""
|
||||
@@ -827,24 +865,10 @@ class AquariaRegions:
|
||||
self.__connect_transturtle(item, "Transturtle Open Water top right", region, self.openwater_tr_turtle)
|
||||
self.__connect_transturtle(item, "Transturtle Forest bottom left", region, self.forest_bl)
|
||||
self.__connect_transturtle(item, "Transturtle Home Water", region, self.home_water_transturtle)
|
||||
self.__connect_transturtle(item, "Transturtle Abyss right", region, self.abyss_r)
|
||||
self.__connect_transturtle(item, "Transturtle Abyss right", region, self.abyss_r_transturtle)
|
||||
self.__connect_transturtle(item, "Transturtle Final Boss", region, self.final_boss_tube)
|
||||
self.__connect_transturtle(item, "Transturtle Simon Says", region, self.simon)
|
||||
self.__connect_transturtle(item, "Transturtle Arnassi Ruins", region, self.arnassi_path,
|
||||
lambda state: state.has("Transturtle Arnassi Ruins", self.player) and
|
||||
_has_fish_form(state, self.player))
|
||||
|
||||
def _connect_arnassi_path_transturtle_to_other(self, item: str, region: Region) -> None:
|
||||
"""Connect the Arnassi Ruins transturtle to all others"""
|
||||
self.__connect_arnassi_path_transturtle(item, "Transturtle Veil top left", region, self.veil_tl)
|
||||
self.__connect_arnassi_path_transturtle(item, "Transturtle Veil top right", region, self.veil_tr_l)
|
||||
self.__connect_arnassi_path_transturtle(item, "Transturtle Open Water top right", region,
|
||||
self.openwater_tr_turtle)
|
||||
self.__connect_arnassi_path_transturtle(item, "Transturtle Forest bottom left", region, self.forest_bl)
|
||||
self.__connect_arnassi_path_transturtle(item, "Transturtle Home Water", region, self.home_water_transturtle)
|
||||
self.__connect_arnassi_path_transturtle(item, "Transturtle Abyss right", region, self.abyss_r)
|
||||
self.__connect_arnassi_path_transturtle(item, "Transturtle Final Boss", region, self.final_boss_tube)
|
||||
self.__connect_arnassi_path_transturtle(item, "Transturtle Simon Says", region, self.simon)
|
||||
self.__connect_transturtle(item, "Transturtle Arnassi Ruins", region, self.arnassi_cave_transturtle)
|
||||
|
||||
def __connect_transturtles(self) -> None:
|
||||
"""Connect every transturtle with others"""
|
||||
@@ -853,10 +877,10 @@ class AquariaRegions:
|
||||
self._connect_transturtle_to_other("Transturtle Open Water top right", self.openwater_tr_turtle)
|
||||
self._connect_transturtle_to_other("Transturtle Forest bottom left", self.forest_bl)
|
||||
self._connect_transturtle_to_other("Transturtle Home Water", self.home_water_transturtle)
|
||||
self._connect_transturtle_to_other("Transturtle Abyss right", self.abyss_r)
|
||||
self._connect_transturtle_to_other("Transturtle Abyss right", self.abyss_r_transturtle)
|
||||
self._connect_transturtle_to_other("Transturtle Final Boss", self.final_boss_tube)
|
||||
self._connect_transturtle_to_other("Transturtle Simon Says", self.simon)
|
||||
self._connect_arnassi_path_transturtle_to_other("Transturtle Arnassi Ruins", self.arnassi_path)
|
||||
self._connect_transturtle_to_other("Transturtle Arnassi Ruins", self.arnassi_cave_transturtle)
|
||||
|
||||
def connect_regions(self) -> None:
|
||||
"""
|
||||
@@ -893,7 +917,7 @@ class AquariaRegions:
|
||||
self.__add_event_location(self.energy_temple_boss,
|
||||
"Beating Fallen God",
|
||||
"Fallen God beated")
|
||||
self.__add_event_location(self.cathedral_boss_r,
|
||||
self.__add_event_location(self.cathedral_boss_l,
|
||||
"Beating Mithalan God",
|
||||
"Mithalan God beated")
|
||||
self.__add_event_location(self.forest_boss,
|
||||
@@ -970,8 +994,9 @@ class AquariaRegions:
|
||||
"""Since Urns need to be broken, add a damaging item to rules"""
|
||||
add_rule(self.multiworld.get_location("Open Water top right area, first urn in the Mithalas exit", self.player),
|
||||
lambda state: _has_damaging_item(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Open Water top right area, second urn in the Mithalas exit", self.player),
|
||||
lambda state: _has_damaging_item(state, self.player))
|
||||
add_rule(
|
||||
self.multiworld.get_location("Open Water top right area, second urn in the Mithalas exit", self.player),
|
||||
lambda state: _has_damaging_item(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Open Water top right area, third urn in the Mithalas exit", self.player),
|
||||
lambda state: _has_damaging_item(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Mithalas City, first urn in one of the homes", self.player),
|
||||
@@ -1019,66 +1044,46 @@ class AquariaRegions:
|
||||
Modify rules for location that need soup
|
||||
"""
|
||||
add_rule(self.multiworld.get_location("Turtle cave, Urchin Costume", self.player),
|
||||
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Sun Worm path, first cliff bulb", self.player),
|
||||
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Sun Worm path, second cliff bulb", self.player),
|
||||
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
|
||||
lambda state: _has_hot_soup(state, self.player))
|
||||
add_rule(self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall", self.player),
|
||||
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
|
||||
lambda state: _has_beast_and_soup_form(state, self.player))
|
||||
|
||||
def __adjusting_under_rock_location(self) -> None:
|
||||
"""
|
||||
Modify rules implying bind song needed for bulb under rocks
|
||||
"""
|
||||
add_rule(self.multiworld.get_location("Home Water, bulb under the rock in the left path from the Verse Cave",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Verse Cave left area, bulb under the rock at the end of the path",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Naija's Home, bulb under the rock at the right of the main path",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Song Cave, bulb under the rock in the path to the singing statues",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Song Cave, bulb under the rock close to the song door",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Energy Temple second area, bulb under the rock",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Open Water top left area, bulb under the rock in the right path",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Open Water top left area, bulb under the rock in the left path",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Kelp Forest top right area, bulb under the rock in the right path",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("The Veil top left area, bulb under the rock in the top right path",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Abyss right area, bulb behind the rock in the whale room",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Abyss right area, bulb in the middle path",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("The Veil top left area, bulb under the rock in the top right path",
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
|
||||
def __adjusting_light_in_dark_place_rules(self) -> None:
|
||||
add_rule(self.multiworld.get_location("Kelp Forest top right area, Black Pearl", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Kelp Forest bottom right area, Odd Container", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Transturtle Veil top left to Transturtle Abyss right", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Transturtle Open Water top right to Transturtle Abyss right", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Transturtle Veil top right to Transturtle Abyss right", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Transturtle Forest bottom left to Transturtle Abyss right", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Transturtle Home Water to Transturtle Abyss right", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Transturtle Final Boss to Transturtle Abyss right", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Transturtle Simon Says to Transturtle Abyss right", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Transturtle Arnassi Ruins to Transturtle Abyss right", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Body center area to Abyss left bottom area", self.player),
|
||||
lambda state: _has_light(state, self.player))
|
||||
add_rule(self.multiworld.get_entrance("Veil left of sun temple to Octo cave top path", self.player),
|
||||
@@ -1097,12 +1102,14 @@ class AquariaRegions:
|
||||
def __adjusting_manual_rules(self) -> None:
|
||||
add_rule(self.multiworld.get_location("Mithalas Cathedral, Mithalan Dress", self.player),
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Open Water bottom left area, bulb inside the lowest fish pass", self.player),
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
add_rule(
|
||||
self.multiworld.get_location("Open Water bottom left area, bulb inside the lowest fish pass", self.player),
|
||||
lambda state: _has_fish_form(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Kelp Forest bottom left area, Walker Baby", self.player),
|
||||
lambda state: _has_spirit_form(state, self.player))
|
||||
add_rule(self.multiworld.get_location("The Veil top left area, bulb hidden behind the blocking rock", self.player),
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(
|
||||
self.multiworld.get_location("The Veil top left area, bulb hidden behind the blocking rock", self.player),
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Turtle cave, Turtle Egg", self.player),
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Abyss left area, bulb in the bottom fish pass", self.player),
|
||||
@@ -1114,103 +1121,119 @@ class AquariaRegions:
|
||||
add_rule(self.multiworld.get_location("Verse Cave right area, Big Seed", self.player),
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Arnassi Ruins, Song Plant Spore", self.player),
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Energy Temple first area, bulb in the bottom room blocked by a rock",
|
||||
self.player), lambda state: _has_energy_form(state, self.player))
|
||||
self.player), lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Home Water, bulb in the bottom left room", self.player),
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Home Water, bulb in the path below Nautilus Prime", self.player),
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Naija's Home, bulb after the energy door", self.player),
|
||||
lambda state: _has_energy_form(state, self.player))
|
||||
lambda state: _has_energy_attack_item(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Abyss right area, bulb behind the rock in the whale room", self.player),
|
||||
lambda state: _has_spirit_form(state, self.player) and
|
||||
_has_sun_form(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Arnassi Ruins, Arnassi Armor", self.player),
|
||||
lambda state: _has_fish_form(state, self.player) and
|
||||
_has_spirit_form(state, self.player))
|
||||
lambda state: _has_fish_form(state, self.player) or
|
||||
_has_beast_and_soup_form(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Mithalas City, urn inside a home fish pass", self.player),
|
||||
lambda state: _has_damaging_item(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Mithalas City, urn in the Castle flower tube entrance", self.player),
|
||||
lambda state: _has_damaging_item(state, self.player))
|
||||
add_rule(self.multiworld.get_location(
|
||||
"The Veil top right area, bulb in the middle of the wall jump cliff", self.player
|
||||
), lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Kelp Forest top left area, Jelly Egg", self.player),
|
||||
lambda state: _has_beast_form(state, self.player))
|
||||
add_rule(self.multiworld.get_location("Sun Worm path, first cliff bulb", self.player),
|
||||
lambda state: state.has("Sun God beated", self.player))
|
||||
add_rule(self.multiworld.get_location("Sun Worm path, second cliff bulb", self.player),
|
||||
lambda state: state.has("Sun God beated", self.player))
|
||||
add_rule(self.multiworld.get_location("The Body center area, breaking Li's cage", self.player),
|
||||
lambda state: _has_tongue_cleared(state, self.player))
|
||||
|
||||
def __no_progression_hard_or_hidden_location(self) -> None:
|
||||
self.multiworld.get_location("Energy Temple boss area, Fallen God Tooth",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Mithalas boss area, beating Mithalan God",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Kelp Forest boss area, beating Drunian God",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Sun Temple boss area, beating Sun God",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Sunken City, bulb on top of the boss area",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Home Water, Nautilus Egg",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Energy Temple blaster room, Blaster Egg",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Mithalas City Castle, beating the Priests",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Mermog cave, Piranha Egg",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Octopus Cave, Dumbo Egg",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("King Jellyfish Cave, bulb in the right path from King Jelly",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("King Jellyfish Cave, Jellyfish Costume",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Final Boss area, bulb in the boss third form room",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Sun Worm path, first cliff bulb",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Sun Worm path, second cliff bulb",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Bubble Cave, bulb in the left cave wall",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Bubble Cave, Verse Egg",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Kelp Forest bottom left area, bulb close to the spirit crystals",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Kelp Forest bottom left area, Walker Baby",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Sun Temple, Sun Key",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("The Body bottom area, Mutant Costume",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Sun Temple, bulb in the hidden room of the right part",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
self.multiworld.get_location("Arnassi Ruins, Arnassi Armor",
|
||||
self.player).item_rule =\
|
||||
self.player).item_rule = \
|
||||
lambda item: item.classification != ItemClassification.progression
|
||||
|
||||
def adjusting_rules(self, options: AquariaOptions) -> None:
|
||||
"""
|
||||
Modify rules for single location or optional rules
|
||||
"""
|
||||
self.multiworld.get_entrance("Before Final Boss to Final Boss", self.player)
|
||||
self.__adjusting_urns_rules()
|
||||
self.__adjusting_crates_rules()
|
||||
self.__adjusting_soup_rules()
|
||||
@@ -1234,7 +1257,7 @@ class AquariaRegions:
|
||||
lambda state: _has_bind_song(state, self.player))
|
||||
if options.unconfine_home_water.value in [0, 2]:
|
||||
add_rule(self.multiworld.get_entrance("Home Water to Open Water top left area", self.player),
|
||||
lambda state: _has_bind_song(state, self.player) and _has_energy_form(state, self.player))
|
||||
lambda state: _has_bind_song(state, self.player) and _has_energy_attack_item(state, self.player))
|
||||
if options.early_energy_form:
|
||||
self.multiworld.early_items[self.player]["Energy form"] = 1
|
||||
|
||||
@@ -1274,6 +1297,7 @@ class AquariaRegions:
|
||||
self.multiworld.regions.append(self.arnassi)
|
||||
self.multiworld.regions.append(self.arnassi_path)
|
||||
self.multiworld.regions.append(self.arnassi_crab_boss)
|
||||
self.multiworld.regions.append(self.arnassi_cave_transturtle)
|
||||
self.multiworld.regions.append(self.simon)
|
||||
|
||||
def __add_mithalas_regions_to_world(self) -> None:
|
||||
@@ -1300,6 +1324,7 @@ class AquariaRegions:
|
||||
self.multiworld.regions.append(self.forest_tr)
|
||||
self.multiworld.regions.append(self.forest_tr_fp)
|
||||
self.multiworld.regions.append(self.forest_bl)
|
||||
self.multiworld.regions.append(self.forest_bl_sc)
|
||||
self.multiworld.regions.append(self.forest_br)
|
||||
self.multiworld.regions.append(self.forest_boss)
|
||||
self.multiworld.regions.append(self.forest_boss_entrance)
|
||||
@@ -1337,6 +1362,7 @@ class AquariaRegions:
|
||||
self.multiworld.regions.append(self.abyss_l)
|
||||
self.multiworld.regions.append(self.abyss_lb)
|
||||
self.multiworld.regions.append(self.abyss_r)
|
||||
self.multiworld.regions.append(self.abyss_r_transturtle)
|
||||
self.multiworld.regions.append(self.ice_cave)
|
||||
self.multiworld.regions.append(self.bubble_cave)
|
||||
self.multiworld.regions.append(self.bubble_cave_boss)
|
||||
|
||||
@@ -141,7 +141,7 @@ after_home_water_locations = [
|
||||
"Sun Temple, bulb at the top of the high dark room",
|
||||
"Sun Temple, Golden Gear",
|
||||
"Sun Temple, first bulb of the temple",
|
||||
"Sun Temple, bulb on the left part",
|
||||
"Sun Temple, bulb on the right part",
|
||||
"Sun Temple, bulb in the hidden room of the right part",
|
||||
"Sun Temple, Sun Key",
|
||||
"Sun Worm path, first path bulb",
|
||||
|
||||
@@ -13,36 +13,16 @@ class BeastFormAccessTest(AquariaTestBase):
|
||||
def test_beast_form_location(self) -> None:
|
||||
"""Test locations that require beast form"""
|
||||
locations = [
|
||||
"Mithalas City Castle, beating the Priests",
|
||||
"Arnassi Ruins, Crab Armor",
|
||||
"Arnassi Ruins, Song Plant Spore",
|
||||
"Mithalas City, first bulb at the end of the top path",
|
||||
"Mithalas City, second bulb at the end of the top path",
|
||||
"Mithalas City, bulb in the top path",
|
||||
"Mithalas City, Mithalas Pot",
|
||||
"Mithalas City, urn in the Castle flower tube entrance",
|
||||
"Mermog cave, Piranha Egg",
|
||||
"Kelp Forest top left area, Jelly Egg",
|
||||
"Mithalas Cathedral, Mithalan Dress",
|
||||
"Turtle cave, bulb in Bubble Cliff",
|
||||
"Turtle cave, Urchin Costume",
|
||||
"Sun Worm path, first cliff bulb",
|
||||
"Sun Worm path, second cliff bulb",
|
||||
"The Veil top right area, bulb at the top of the waterfall",
|
||||
"Bubble Cave, bulb in the left cave wall",
|
||||
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
|
||||
"Bubble Cave, Verse Egg",
|
||||
"Sunken City, bulb on top of the boss area",
|
||||
"Octopus Cave, Dumbo Egg",
|
||||
"Beating the Golem",
|
||||
"Beating Mergog",
|
||||
"Beating Crabbius Maximus",
|
||||
"Beating Octopus Prime",
|
||||
"Beating Mantis Shrimp Prime",
|
||||
"King Jellyfish Cave, Jellyfish Costume",
|
||||
"King Jellyfish Cave, bulb in the right path from King Jelly",
|
||||
"Beating King Jellyfish God Prime",
|
||||
"Beating Mithalan priests",
|
||||
"Sunken City cleared"
|
||||
"Sunken City cleared",
|
||||
]
|
||||
items = [["Beast form"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Author: Louis M
|
||||
Date: Thu, 18 Apr 2024 18:45:56 +0000
|
||||
Description: Unit test used to test accessibility of locations with and without the beast form or arnassi armor
|
||||
"""
|
||||
|
||||
from . import AquariaTestBase
|
||||
|
||||
|
||||
class BeastForArnassiArmormAccessTest(AquariaTestBase):
|
||||
"""Unit test used to test accessibility of locations with and without the beast form or arnassi armor"""
|
||||
|
||||
def test_beast_form_arnassi_armor_location(self) -> None:
|
||||
"""Test locations that require beast form or arnassi armor"""
|
||||
locations = [
|
||||
"Mithalas City Castle, beating the Priests",
|
||||
"Arnassi Ruins, Crab Armor",
|
||||
"Arnassi Ruins, Song Plant Spore",
|
||||
"Mithalas City, first bulb at the end of the top path",
|
||||
"Mithalas City, second bulb at the end of the top path",
|
||||
"Mithalas City, bulb in the top path",
|
||||
"Mithalas City, Mithalas Pot",
|
||||
"Mithalas City, urn in the Castle flower tube entrance",
|
||||
"Mermog cave, Piranha Egg",
|
||||
"Mithalas Cathedral, Mithalan Dress",
|
||||
"Kelp Forest top left area, Jelly Egg",
|
||||
"The Veil top right area, bulb in the middle of the wall jump cliff",
|
||||
"The Veil top right area, bulb at the top of the waterfall",
|
||||
"Sunken City, bulb on top of the boss area",
|
||||
"Octopus Cave, Dumbo Egg",
|
||||
"Beating the Golem",
|
||||
"Beating Mergog",
|
||||
"Beating Crabbius Maximus",
|
||||
"Beating Octopus Prime",
|
||||
"Beating Mithalan priests",
|
||||
"Sunken City cleared"
|
||||
]
|
||||
items = [["Beast form", "Arnassi Armor"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
@@ -17,55 +17,16 @@ class EnergyFormAccessTest(AquariaTestBase):
|
||||
def test_energy_form_location(self) -> None:
|
||||
"""Test locations that require Energy form"""
|
||||
locations = [
|
||||
"Home Water, Nautilus Egg",
|
||||
"Naija's Home, bulb after the energy door",
|
||||
"Energy Temple first area, bulb in the bottom room blocked by a rock",
|
||||
"Energy Temple second area, bulb under the rock",
|
||||
"Energy Temple bottom entrance, Krotite Armor",
|
||||
"Energy Temple third area, bulb in the bottom path",
|
||||
"Energy Temple boss area, Fallen God Tooth",
|
||||
"Energy Temple blaster room, Blaster Egg",
|
||||
"Mithalas City Castle, beating the Priests",
|
||||
"Mithalas Cathedral, first urn in the top right room",
|
||||
"Mithalas Cathedral, second urn in the top right room",
|
||||
"Mithalas Cathedral, third urn in the top right room",
|
||||
"Mithalas Cathedral, urn in the flesh room with fleas",
|
||||
"Mithalas Cathedral, first urn in the bottom right path",
|
||||
"Mithalas Cathedral, second urn in the bottom right path",
|
||||
"Mithalas Cathedral, urn behind the flesh vein",
|
||||
"Mithalas Cathedral, urn in the top left eyes boss room",
|
||||
"Mithalas Cathedral, first urn in the path behind the flesh vein",
|
||||
"Mithalas Cathedral, second urn in the path behind the flesh vein",
|
||||
"Mithalas Cathedral, third urn in the path behind the flesh vein",
|
||||
"Mithalas Cathedral, fourth urn in the top right room",
|
||||
"Mithalas Cathedral, Mithalan Dress",
|
||||
"Mithalas Cathedral, urn below the left entrance",
|
||||
"Mithalas boss area, beating Mithalan God",
|
||||
"Kelp Forest top left area, bulb close to the Verse Egg",
|
||||
"Kelp Forest top left area, Verse Egg",
|
||||
"Kelp Forest boss area, beating Drunian God",
|
||||
"Mermog cave, Piranha Egg",
|
||||
"Octopus Cave, Dumbo Egg",
|
||||
"Sun Temple boss area, beating Sun God",
|
||||
"Arnassi Ruins, Crab Armor",
|
||||
"King Jellyfish Cave, bulb in the right path from King Jelly",
|
||||
"King Jellyfish Cave, Jellyfish Costume",
|
||||
"Sunken City, bulb on top of the boss area",
|
||||
"The Body left area, first bulb in the top face room",
|
||||
"The Body left area, second bulb in the top face room",
|
||||
"The Body left area, bulb below the water stream",
|
||||
"The Body left area, bulb in the top path to the top face room",
|
||||
"The Body left area, bulb in the bottom face room",
|
||||
"The Body right area, bulb in the top path to the bottom face room",
|
||||
"The Body right area, bulb in the bottom face room",
|
||||
"Final Boss area, bulb in the boss third form room",
|
||||
"Beating Fallen God",
|
||||
"Beating Mithalan God",
|
||||
"Beating Drunian God",
|
||||
"Beating Sun God",
|
||||
"Beating the Golem",
|
||||
"Beating Nautilus Prime",
|
||||
"Beating Blaster Peg Prime",
|
||||
"Beating Mergog",
|
||||
"Beating Mithalan priests",
|
||||
"Beating Octopus Prime",
|
||||
"Beating Crabbius Maximus",
|
||||
"Beating King Jellyfish God Prime",
|
||||
"First secret",
|
||||
"Sunken City cleared",
|
||||
"Objective complete",
|
||||
]
|
||||
items = [["Energy form"]]
|
||||
|
||||
92
worlds/aquaria/test/test_energy_form_or_dual_form_access.py
Normal file
92
worlds/aquaria/test/test_energy_form_or_dual_form_access.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Author: Louis M
|
||||
Date: Thu, 18 Apr 2024 18:45:56 +0000
|
||||
Description: Unit test used to test accessibility of locations with and without the energy form and dual form (and Li)
|
||||
"""
|
||||
|
||||
from . import AquariaTestBase
|
||||
|
||||
|
||||
class EnergyFormDualFormAccessTest(AquariaTestBase):
|
||||
"""Unit test used to test accessibility of locations with and without the energy form and dual form (and Li)"""
|
||||
options = {
|
||||
"early_energy_form": False,
|
||||
}
|
||||
|
||||
def test_energy_form_or_dual_form_location(self) -> None:
|
||||
"""Test locations that require Energy form or dual form"""
|
||||
locations = [
|
||||
"Naija's Home, bulb after the energy door",
|
||||
"Home Water, Nautilus Egg",
|
||||
"Energy Temple second area, bulb under the rock",
|
||||
"Energy Temple bottom entrance, Krotite Armor",
|
||||
"Energy Temple third area, bulb in the bottom path",
|
||||
"Energy Temple blaster room, Blaster Egg",
|
||||
"Energy Temple boss area, Fallen God Tooth",
|
||||
"Mithalas City Castle, beating the Priests",
|
||||
"Mithalas boss area, beating Mithalan God",
|
||||
"Mithalas Cathedral, first urn in the top right room",
|
||||
"Mithalas Cathedral, second urn in the top right room",
|
||||
"Mithalas Cathedral, third urn in the top right room",
|
||||
"Mithalas Cathedral, urn in the flesh room with fleas",
|
||||
"Mithalas Cathedral, first urn in the bottom right path",
|
||||
"Mithalas Cathedral, second urn in the bottom right path",
|
||||
"Mithalas Cathedral, urn behind the flesh vein",
|
||||
"Mithalas Cathedral, urn in the top left eyes boss room",
|
||||
"Mithalas Cathedral, first urn in the path behind the flesh vein",
|
||||
"Mithalas Cathedral, second urn in the path behind the flesh vein",
|
||||
"Mithalas Cathedral, third urn in the path behind the flesh vein",
|
||||
"Mithalas Cathedral, fourth urn in the top right room",
|
||||
"Mithalas Cathedral, Mithalan Dress",
|
||||
"Mithalas Cathedral, urn below the left entrance",
|
||||
"Kelp Forest top left area, bulb close to the Verse Egg",
|
||||
"Kelp Forest top left area, Verse Egg",
|
||||
"Kelp Forest boss area, beating Drunian God",
|
||||
"Mermog cave, Piranha Egg",
|
||||
"Octopus Cave, Dumbo Egg",
|
||||
"Sun Temple boss area, beating Sun God",
|
||||
"King Jellyfish Cave, bulb in the right path from King Jelly",
|
||||
"King Jellyfish Cave, Jellyfish Costume",
|
||||
"Sunken City right area, crate close to the save crystal",
|
||||
"Sunken City right area, crate in the left bottom room",
|
||||
"Sunken City left area, crate in the little pipe room",
|
||||
"Sunken City left area, crate close to the save crystal",
|
||||
"Sunken City left area, crate before the bedroom",
|
||||
"Sunken City left area, Girl Costume",
|
||||
"Sunken City, bulb on top of the boss area",
|
||||
"The Body center area, breaking Li's cage",
|
||||
"The Body center area, bulb on the main path blocking tube",
|
||||
"The Body left area, first bulb in the top face room",
|
||||
"The Body left area, second bulb in the top face room",
|
||||
"The Body left area, bulb below the water stream",
|
||||
"The Body left area, bulb in the top path to the top face room",
|
||||
"The Body left area, bulb in the bottom face room",
|
||||
"The Body right area, bulb in the top face room",
|
||||
"The Body right area, bulb in the top path to the bottom face room",
|
||||
"The Body right area, bulb in the bottom face room",
|
||||
"The Body bottom area, bulb in the Jelly Zap room",
|
||||
"The Body bottom area, bulb in the nautilus room",
|
||||
"The Body bottom area, Mutant Costume",
|
||||
"Final Boss area, bulb in the boss third form room",
|
||||
"Final Boss area, first bulb in the turtle room",
|
||||
"Final Boss area, second bulb in the turtle room",
|
||||
"Final Boss area, third bulb in the turtle room",
|
||||
"Final Boss area, Transturtle",
|
||||
"Beating Fallen God",
|
||||
"Beating Blaster Peg Prime",
|
||||
"Beating Mithalan God",
|
||||
"Beating Drunian God",
|
||||
"Beating Sun God",
|
||||
"Beating the Golem",
|
||||
"Beating Nautilus Prime",
|
||||
"Beating Mergog",
|
||||
"Beating Mithalan priests",
|
||||
"Beating Octopus Prime",
|
||||
"Beating King Jellyfish God Prime",
|
||||
"Beating the Golem",
|
||||
"Sunken City cleared",
|
||||
"First secret",
|
||||
"Objective complete"
|
||||
]
|
||||
items = [["Energy form", "Dual form", "Li and Li song", "Body tongue cleared"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
@@ -17,6 +17,7 @@ class FishFormAccessTest(AquariaTestBase):
|
||||
"""Test locations that require fish form"""
|
||||
locations = [
|
||||
"The Veil top left area, bulb inside the fish pass",
|
||||
"Energy Temple first area, Energy Idol",
|
||||
"Mithalas City, Doll",
|
||||
"Mithalas City, urn inside a home fish pass",
|
||||
"Kelp Forest top right area, bulb in the top fish pass",
|
||||
@@ -30,8 +31,7 @@ class FishFormAccessTest(AquariaTestBase):
|
||||
"Octopus Cave, Dumbo Egg",
|
||||
"Octopus Cave, bulb in the path below the Octopus Cave path",
|
||||
"Beating Octopus Prime",
|
||||
"Abyss left area, bulb in the bottom fish pass",
|
||||
"Arnassi Ruins, Arnassi Armor"
|
||||
"Abyss left area, bulb in the bottom fish pass"
|
||||
]
|
||||
items = [["Fish form"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
|
||||
@@ -39,7 +39,6 @@ class LightAccessTest(AquariaTestBase):
|
||||
"Abyss right area, bulb in the middle path",
|
||||
"Abyss right area, bulb behind the rock in the middle path",
|
||||
"Abyss right area, bulb in the left green room",
|
||||
"Abyss right area, Transturtle",
|
||||
"Ice Cave, bulb in the room to the right",
|
||||
"Ice Cave, first bulb in the top exit room",
|
||||
"Ice Cave, second bulb in the top exit room",
|
||||
|
||||
@@ -30,7 +30,6 @@ class SpiritFormAccessTest(AquariaTestBase):
|
||||
"Sunken City left area, Girl Costume",
|
||||
"Beating Mantis Shrimp Prime",
|
||||
"First secret",
|
||||
"Arnassi Ruins, Arnassi Armor",
|
||||
]
|
||||
items = [["Spirit form"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
|
||||
19
worlds/blasphemous/ExtractorConfig.json
Normal file
19
worlds/blasphemous/ExtractorConfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"type": "WorldDefinition",
|
||||
"configuration": "./output/StringWorldDefinition.json",
|
||||
"emptyRegionsToKeep": [
|
||||
"D17Z01S01",
|
||||
"D01Z02S01",
|
||||
"D02Z03S09",
|
||||
"D03Z03S11",
|
||||
"D04Z03S01",
|
||||
"D06Z01S09",
|
||||
"D20Z02S09",
|
||||
"D09Z01S09[Cell24]",
|
||||
"D09Z01S08[Cell7]",
|
||||
"D09Z01S08[Cell18]",
|
||||
"D09BZ01S01[Cell24]",
|
||||
"D09BZ01S01[Cell17]",
|
||||
"D09BZ01S01[Cell19]"
|
||||
]
|
||||
}
|
||||
@@ -637,52 +637,35 @@ item_table: List[ItemDict] = [
|
||||
'classification': ItemClassification.filler}
|
||||
]
|
||||
|
||||
event_table: Dict[str, str] = {
|
||||
"OpenedDCGateW": "D01Z05S24",
|
||||
"OpenedDCGateE": "D01Z05S12",
|
||||
"OpenedDCLadder": "D01Z05S20",
|
||||
"OpenedWOTWCave": "D02Z01S06",
|
||||
"RodeGOTPElevator": "D02Z02S11",
|
||||
"OpenedConventLadder": "D02Z03S11",
|
||||
"BrokeJondoBellW": "D03Z02S09",
|
||||
"BrokeJondoBellE": "D03Z02S05",
|
||||
"OpenedMOMLadder": "D04Z02S06",
|
||||
"OpenedTSCGate": "D05Z02S11",
|
||||
"OpenedARLadder": "D06Z01S23",
|
||||
"BrokeBOTTCStatue": "D08Z01S02",
|
||||
"OpenedWOTHPGate": "D09Z01S05",
|
||||
"OpenedBOTSSLadder": "D17Z01S04"
|
||||
}
|
||||
|
||||
group_table: Dict[str, Set[str]] = {
|
||||
"wounds" : ["Holy Wound of Attrition",
|
||||
"wounds" : {"Holy Wound of Attrition",
|
||||
"Holy Wound of Contrition",
|
||||
"Holy Wound of Compunction"],
|
||||
"Holy Wound of Compunction"},
|
||||
|
||||
"masks" : ["Deformed Mask of Orestes",
|
||||
"masks" : {"Deformed Mask of Orestes",
|
||||
"Mirrored Mask of Dolphos",
|
||||
"Embossed Mask of Crescente"],
|
||||
"Embossed Mask of Crescente"},
|
||||
|
||||
"marks" : ["Mark of the First Refuge",
|
||||
"marks" : {"Mark of the First Refuge",
|
||||
"Mark of the Second Refuge",
|
||||
"Mark of the Third Refuge"],
|
||||
"Mark of the Third Refuge"},
|
||||
|
||||
"tirso" : ["Bouquet of Rosemary",
|
||||
"tirso" : {"Bouquet of Rosemary",
|
||||
"Incense Garlic",
|
||||
"Olive Seeds",
|
||||
"Dried Clove",
|
||||
"Sooty Garlic",
|
||||
"Bouquet of Thyme"],
|
||||
"Bouquet of Thyme"},
|
||||
|
||||
"tentudia": ["Tentudia's Carnal Remains",
|
||||
"tentudia": {"Tentudia's Carnal Remains",
|
||||
"Remains of Tentudia's Hair",
|
||||
"Tentudia's Skeletal Remains"],
|
||||
"Tentudia's Skeletal Remains"},
|
||||
|
||||
"egg" : ["Melted Golden Coins",
|
||||
"egg" : {"Melted Golden Coins",
|
||||
"Torn Bridal Ribbon",
|
||||
"Black Grieving Veil"],
|
||||
"Black Grieving Veil"},
|
||||
|
||||
"bones" : ["Parietal bone of Lasser, the Inquisitor",
|
||||
"bones" : {"Parietal bone of Lasser, the Inquisitor",
|
||||
"Jaw of Ashgan, the Inquisitor",
|
||||
"Cervical vertebra of Zicher, the Brewmaster",
|
||||
"Clavicle of Dalhuisen, the Schoolchild",
|
||||
@@ -725,14 +708,14 @@ group_table: Dict[str, Set[str]] = {
|
||||
"Scaphoid of Fierce, the Leper",
|
||||
"Anklebone of Weston, the Pilgrim",
|
||||
"Calcaneum of Persian, the Bandit",
|
||||
"Navicular of Kahnnyhoo, the Murderer"],
|
||||
"Navicular of Kahnnyhoo, the Murderer"},
|
||||
|
||||
"power" : ["Life Upgrade",
|
||||
"power" : {"Life Upgrade",
|
||||
"Fervour Upgrade",
|
||||
"Empty Bile Vessel",
|
||||
"Quicksilver"],
|
||||
"Quicksilver"},
|
||||
|
||||
"prayer" : ["Seguiriya to your Eyes like Stars",
|
||||
"prayer" : {"Seguiriya to your Eyes like Stars",
|
||||
"Debla of the Lights",
|
||||
"Saeta Dolorosa",
|
||||
"Campanillero to the Sons of the Aurora",
|
||||
@@ -746,10 +729,17 @@ group_table: Dict[str, Set[str]] = {
|
||||
"Romance to the Crimson Mist",
|
||||
"Zambra to the Resplendent Crown",
|
||||
"Cantina of the Blue Rose",
|
||||
"Mirabras of the Return to Port"]
|
||||
"Mirabras of the Return to Port"},
|
||||
|
||||
"toe" : {"Little Toe made of Limestone",
|
||||
"Big Toe made of Limestone",
|
||||
"Fourth Toe made of Limestone"},
|
||||
|
||||
"eye" : {"Severed Right Eye of the Traitor",
|
||||
"Broken Left Eye of the Traitor"}
|
||||
}
|
||||
|
||||
tears_set: Set[str] = [
|
||||
tears_list: List[str] = [
|
||||
"Tears of Atonement (500)",
|
||||
"Tears of Atonement (625)",
|
||||
"Tears of Atonement (750)",
|
||||
@@ -772,16 +762,16 @@ tears_set: Set[str] = [
|
||||
"Tears of Atonement (30000)"
|
||||
]
|
||||
|
||||
reliquary_set: Set[str] = [
|
||||
reliquary_set: Set[str] = {
|
||||
"Reliquary of the Fervent Heart",
|
||||
"Reliquary of the Suffering Heart",
|
||||
"Reliquary of the Sorrowful Heart"
|
||||
]
|
||||
}
|
||||
|
||||
skill_set: Set[str] = [
|
||||
skill_set: Set[str] = {
|
||||
"Combo Skill",
|
||||
"Charged Skill",
|
||||
"Ranged Skill",
|
||||
"Dive Skill",
|
||||
"Lunge Skill"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, PerGameCommonOptions, OptionGroup
|
||||
import random
|
||||
|
||||
|
||||
@@ -20,23 +21,30 @@ class ChoiceIsRandom(Choice):
|
||||
|
||||
|
||||
class PrieDieuWarp(DefaultOnToggle):
|
||||
"""Automatically unlocks the ability to warp between Prie Dieu shrines."""
|
||||
"""
|
||||
Automatically unlocks the ability to warp between Prie Dieu shrines.
|
||||
"""
|
||||
display_name = "Unlock Fast Travel"
|
||||
|
||||
|
||||
class SkipCutscenes(DefaultOnToggle):
|
||||
"""Automatically skips most cutscenes."""
|
||||
"""
|
||||
Automatically skips most cutscenes.
|
||||
"""
|
||||
display_name = "Auto Skip Cutscenes"
|
||||
|
||||
|
||||
class CorpseHints(DefaultOnToggle):
|
||||
"""Changes the 34 corpses in game to give various hints about item locations."""
|
||||
"""
|
||||
Changes the 34 corpses in game to give various hints about item locations.
|
||||
"""
|
||||
display_name = "Corpse Hints"
|
||||
|
||||
|
||||
class Difficulty(Choice):
|
||||
"""Adjusts the overall difficulty of the randomizer, including upgrades required to defeat bosses
|
||||
and advanced movement tricks or glitches."""
|
||||
"""
|
||||
Adjusts the overall difficulty of the randomizer, including upgrades required to defeat bosses and advanced movement tricks or glitches.
|
||||
"""
|
||||
display_name = "Difficulty"
|
||||
option_easy = 0
|
||||
option_normal = 1
|
||||
@@ -45,15 +53,18 @@ class Difficulty(Choice):
|
||||
|
||||
|
||||
class Penitence(Toggle):
|
||||
"""Allows one of the three Penitences to be chosen at the beginning of the game."""
|
||||
"""
|
||||
Allows one of the three Penitences to be chosen at the beginning of the game.
|
||||
"""
|
||||
display_name = "Penitence"
|
||||
|
||||
|
||||
class StartingLocation(ChoiceIsRandom):
|
||||
"""Choose where to start the randomizer. Note that some starting locations cannot be chosen with certain
|
||||
other options.
|
||||
Specifically, Brotherhood and Mourning And Havoc cannot be chosen if Shuffle Dash is enabled, and Grievance Ascends
|
||||
cannot be chosen if Shuffle Wall Climb is enabled."""
|
||||
"""
|
||||
Choose where to start the randomizer. Note that some starting locations cannot be chosen with certain other options.
|
||||
|
||||
Specifically, Brotherhood and Mourning And Havoc cannot be chosen if Shuffle Dash is enabled, and Grievance Ascends cannot be chosen if Shuffle Wall Climb is enabled.
|
||||
"""
|
||||
display_name = "Starting Location"
|
||||
option_brotherhood = 0
|
||||
option_albero = 1
|
||||
@@ -66,10 +77,15 @@ class StartingLocation(ChoiceIsRandom):
|
||||
|
||||
|
||||
class Ending(Choice):
|
||||
"""Choose which ending is required to complete the game.
|
||||
"""
|
||||
Choose which ending is required to complete the game.
|
||||
|
||||
Talking to Tirso in Albero will tell you the selected ending for the current game.
|
||||
|
||||
Ending A: Collect all thorn upgrades.
|
||||
Ending C: Collect all thorn upgrades and the Holy Wound of Abnegation."""
|
||||
|
||||
Ending C: Collect all thorn upgrades and the Holy Wound of Abnegation.
|
||||
"""
|
||||
display_name = "Ending"
|
||||
option_any_ending = 0
|
||||
option_ending_a = 1
|
||||
@@ -78,14 +94,18 @@ class Ending(Choice):
|
||||
|
||||
|
||||
class SkipLongQuests(Toggle):
|
||||
"""Ensures that the rewards for long quests will be filler items.
|
||||
Affected locations: \"Albero: Donate 50000 Tears\", \"Ossuary: 11th reward\", \"AtTotS: Miriam's gift\",
|
||||
\"TSC: Jocinero's final reward\""""
|
||||
"""
|
||||
Ensures that the rewards for long quests will be filler items.
|
||||
|
||||
Affected locations: "Albero: Donate 50000 Tears", "Ossuary: 11th reward", "AtTotS: Miriam's gift", "TSC: Jocinero's final reward"
|
||||
"""
|
||||
display_name = "Skip Long Quests"
|
||||
|
||||
|
||||
class ThornShuffle(Choice):
|
||||
"""Shuffles the Thorn given by Deogracias and all Thorn upgrades into the item pool."""
|
||||
"""
|
||||
Shuffles the Thorn given by Deogracias and all Thorn upgrades into the item pool.
|
||||
"""
|
||||
display_name = "Shuffle Thorn"
|
||||
option_anywhere = 0
|
||||
option_local_only = 1
|
||||
@@ -94,50 +114,68 @@ class ThornShuffle(Choice):
|
||||
|
||||
|
||||
class DashShuffle(Toggle):
|
||||
"""Turns the ability to dash into an item that must be found in the multiworld."""
|
||||
"""
|
||||
Turns the ability to dash into an item that must be found in the multiworld.
|
||||
"""
|
||||
display_name = "Shuffle Dash"
|
||||
|
||||
|
||||
class WallClimbShuffle(Toggle):
|
||||
"""Turns the ability to climb walls with your sword into an item that must be found in the multiworld."""
|
||||
"""
|
||||
Turns the ability to climb walls with your sword into an item that must be found in the multiworld.
|
||||
"""
|
||||
display_name = "Shuffle Wall Climb"
|
||||
|
||||
|
||||
class ReliquaryShuffle(DefaultOnToggle):
|
||||
"""Adds the True Torment exclusive Reliquary rosary beads into the item pool."""
|
||||
"""
|
||||
Adds the True Torment exclusive Reliquary rosary beads into the item pool.
|
||||
"""
|
||||
display_name = "Shuffle Penitence Rewards"
|
||||
|
||||
|
||||
class CustomItem1(Toggle):
|
||||
"""Adds the custom relic Boots of Pleading into the item pool, which grants the ability to fall onto spikes
|
||||
and survive.
|
||||
Must have the \"Blasphemous-Boots-of-Pleading\" mod installed to connect to a multiworld."""
|
||||
"""
|
||||
Adds the custom relic Boots of Pleading into the item pool, which grants the ability to fall onto spikes and survive.
|
||||
|
||||
Must have the "Boots of Pleading" mod installed to connect to a multiworld.
|
||||
"""
|
||||
display_name = "Boots of Pleading"
|
||||
|
||||
|
||||
class CustomItem2(Toggle):
|
||||
"""Adds the custom relic Purified Hand of the Nun into the item pool, which grants the ability to jump
|
||||
a second time in mid-air.
|
||||
Must have the \"Blasphemous-Double-Jump\" mod installed to connect to a multiworld."""
|
||||
"""
|
||||
Adds the custom relic Purified Hand of the Nun into the item pool, which grants the ability to jump a second time in mid-air.
|
||||
|
||||
Must have the "Double Jump" mod installed to connect to a multiworld.
|
||||
"""
|
||||
display_name = "Purified Hand of the Nun"
|
||||
|
||||
|
||||
class StartWheel(Toggle):
|
||||
"""Changes the beginning gift to The Young Mason's Wheel."""
|
||||
"""
|
||||
Changes the beginning gift to The Young Mason's Wheel.
|
||||
"""
|
||||
display_name = "Start with Wheel"
|
||||
|
||||
|
||||
class SkillRando(Toggle):
|
||||
"""Randomizes the abilities from the skill tree into the item pool."""
|
||||
"""
|
||||
Randomizes the abilities from the skill tree into the item pool.
|
||||
"""
|
||||
display_name = "Skill Randomizer"
|
||||
|
||||
|
||||
class EnemyRando(Choice):
|
||||
"""Randomizes the enemies that appear in each room.
|
||||
Shuffled: Enemies will be shuffled amongst each other, but can only appear as many times as they do in
|
||||
a standard game.
|
||||
"""
|
||||
Randomizes the enemies that appear in each room.
|
||||
|
||||
Shuffled: Enemies will be shuffled amongst each other, but can only appear as many times as they do in a standard game.
|
||||
|
||||
Randomized: Every enemy is completely random, and can appear any number of times.
|
||||
Some enemies will never be randomized."""
|
||||
|
||||
Some enemies will never be randomized.
|
||||
"""
|
||||
display_name = "Enemy Randomizer"
|
||||
option_disabled = 0
|
||||
option_shuffled = 1
|
||||
@@ -146,43 +184,75 @@ class EnemyRando(Choice):
|
||||
|
||||
|
||||
class EnemyGroups(DefaultOnToggle):
|
||||
"""Randomized enemies will chosen from sets of specific groups.
|
||||
"""
|
||||
Randomized enemies will be chosen from sets of specific groups.
|
||||
|
||||
(Weak, normal, large, flying)
|
||||
Has no effect if Enemy Randomizer is disabled."""
|
||||
|
||||
Has no effect if Enemy Randomizer is disabled.
|
||||
"""
|
||||
display_name = "Enemy Groups"
|
||||
|
||||
|
||||
class EnemyScaling(DefaultOnToggle):
|
||||
"""Randomized enemies will have their stats increased or decreased depending on the area they appear in.
|
||||
Has no effect if Enemy Randomizer is disabled."""
|
||||
"""
|
||||
Randomized enemies will have their stats increased or decreased depending on the area they appear in.
|
||||
|
||||
Has no effect if Enemy Randomizer is disabled.
|
||||
"""
|
||||
display_name = "Enemy Scaling"
|
||||
|
||||
|
||||
class BlasphemousDeathLink(DeathLink):
|
||||
"""When you die, everyone dies. The reverse is also true.
|
||||
Note that Guilt Fragments will not appear when killed by Death Link."""
|
||||
"""
|
||||
When you die, everyone dies. The reverse is also true.
|
||||
|
||||
Note that Guilt Fragments will not appear when killed by Death Link.
|
||||
"""
|
||||
|
||||
|
||||
blasphemous_options = {
|
||||
"prie_dieu_warp": PrieDieuWarp,
|
||||
"skip_cutscenes": SkipCutscenes,
|
||||
"corpse_hints": CorpseHints,
|
||||
"difficulty": Difficulty,
|
||||
"penitence": Penitence,
|
||||
"starting_location": StartingLocation,
|
||||
"ending": Ending,
|
||||
"skip_long_quests": SkipLongQuests,
|
||||
"thorn_shuffle" : ThornShuffle,
|
||||
"dash_shuffle": DashShuffle,
|
||||
"wall_climb_shuffle": WallClimbShuffle,
|
||||
"reliquary_shuffle": ReliquaryShuffle,
|
||||
"boots_of_pleading": CustomItem1,
|
||||
"purified_hand": CustomItem2,
|
||||
"start_wheel": StartWheel,
|
||||
"skill_randomizer": SkillRando,
|
||||
"enemy_randomizer": EnemyRando,
|
||||
"enemy_groups": EnemyGroups,
|
||||
"enemy_scaling": EnemyScaling,
|
||||
"death_link": BlasphemousDeathLink,
|
||||
"start_inventory": StartInventoryPool
|
||||
}
|
||||
@dataclass
|
||||
class BlasphemousOptions(PerGameCommonOptions):
|
||||
prie_dieu_warp: PrieDieuWarp
|
||||
skip_cutscenes: SkipCutscenes
|
||||
corpse_hints: CorpseHints
|
||||
difficulty: Difficulty
|
||||
penitence: Penitence
|
||||
starting_location: StartingLocation
|
||||
ending: Ending
|
||||
skip_long_quests: SkipLongQuests
|
||||
thorn_shuffle: ThornShuffle
|
||||
dash_shuffle: DashShuffle
|
||||
wall_climb_shuffle: WallClimbShuffle
|
||||
reliquary_shuffle: ReliquaryShuffle
|
||||
boots_of_pleading: CustomItem1
|
||||
purified_hand: CustomItem2
|
||||
start_wheel: StartWheel
|
||||
skill_randomizer: SkillRando
|
||||
enemy_randomizer: EnemyRando
|
||||
enemy_groups: EnemyGroups
|
||||
enemy_scaling: EnemyScaling
|
||||
death_link: BlasphemousDeathLink
|
||||
|
||||
|
||||
blas_option_groups = [
|
||||
OptionGroup("Quality of Life", [
|
||||
PrieDieuWarp,
|
||||
SkipCutscenes,
|
||||
CorpseHints,
|
||||
SkipLongQuests,
|
||||
StartWheel
|
||||
]),
|
||||
OptionGroup("Moveset", [
|
||||
DashShuffle,
|
||||
WallClimbShuffle,
|
||||
SkillRando,
|
||||
CustomItem1,
|
||||
CustomItem2
|
||||
]),
|
||||
OptionGroup("Enemy Randomizer", [
|
||||
EnemyRando,
|
||||
EnemyGroups,
|
||||
EnemyScaling
|
||||
])
|
||||
]
|
||||
|
||||
582
worlds/blasphemous/Preprocessor.py
Normal file
582
worlds/blasphemous/Preprocessor.py
Normal file
@@ -0,0 +1,582 @@
|
||||
# Preprocessor to convert Blasphemous Randomizer logic into a StringWorldDefinition for use with APHKLogicExtractor
|
||||
# https://github.com/BrandenEK/Blasphemous.Randomizer
|
||||
# https://github.com/ArchipelagoMW-HollowKnight/APHKLogicExtractor
|
||||
|
||||
|
||||
import json, requests, argparse
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
def load_resource_local(file: str) -> List[Dict[str, Any]]:
|
||||
print(f"Reading from {file}")
|
||||
loaded = []
|
||||
with open(file, encoding="utf-8") as f:
|
||||
loaded = read_json(f.readlines())
|
||||
f.close()
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def load_resource_from_web(url: str) -> List[Dict[str, Any]]:
|
||||
req = requests.get(url, timeout=1)
|
||||
print(f"Reading from {url}")
|
||||
req.encoding = "utf-8"
|
||||
lines: List[str] = []
|
||||
for line in req.text.splitlines():
|
||||
while "\t" in line:
|
||||
line = line[1::]
|
||||
if line != "":
|
||||
lines.append(line)
|
||||
return read_json(lines)
|
||||
|
||||
|
||||
def read_json(lines: List[str]) -> List[Dict[str, Any]]:
|
||||
loaded = []
|
||||
creating_object: bool = False
|
||||
obj: str = ""
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if "{" in stripped:
|
||||
creating_object = True
|
||||
obj += stripped
|
||||
continue
|
||||
elif "}," in stripped or "}" in stripped and "]" in lines[lines.index(line)+1]:
|
||||
creating_object = False
|
||||
obj += "}"
|
||||
#print(f"obj = {obj}")
|
||||
loaded.append(json.loads(obj))
|
||||
obj = ""
|
||||
continue
|
||||
|
||||
if not creating_object:
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
if "}," in lines[lines.index(line)+1] and stripped[-1] == ",":
|
||||
obj += stripped[:-1]
|
||||
else:
|
||||
obj += stripped
|
||||
except IndexError:
|
||||
obj += stripped
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def get_room_from_door(door: str) -> str:
|
||||
return door[:door.find("[")]
|
||||
|
||||
|
||||
def preprocess_logic(is_door: bool, id: str, logic: str) -> str:
|
||||
if id in logic and not is_door:
|
||||
index: int = logic.find(id)
|
||||
logic = logic[:index] + logic[index+len(id)+4:]
|
||||
|
||||
while ">=" in logic:
|
||||
index: int = logic.find(">=")
|
||||
logic = logic[:index-1] + logic[index+3:]
|
||||
|
||||
while ">" in logic:
|
||||
index: int = logic.find(">")
|
||||
count = int(logic[index+2])
|
||||
count += 1
|
||||
logic = logic[:index-1] + str(count) + logic[index+3:]
|
||||
|
||||
while "<=" in logic:
|
||||
index: int = logic.find("<=")
|
||||
logic = logic[:index-1] + logic[index+3:]
|
||||
|
||||
while "<" in logic:
|
||||
index: int = logic.find("<")
|
||||
count = int(logic[index+2])
|
||||
count += 1
|
||||
logic = logic[:index-1] + str(count) + logic[index+3:]
|
||||
|
||||
#print(logic)
|
||||
return logic
|
||||
|
||||
|
||||
def build_logic_conditions(logic: str) -> List[List[str]]:
|
||||
all_conditions: List[List[str]] = []
|
||||
|
||||
parts = logic.split()
|
||||
sub_part: str = ""
|
||||
current_index: int = 0
|
||||
parens: int = -1
|
||||
current_condition: List[str] = []
|
||||
parens_conditions: List[List[List[str]]] = []
|
||||
|
||||
for index, part in enumerate(parts):
|
||||
#print(current_index, index, parens, part)
|
||||
|
||||
# skip parts that have already been handled
|
||||
if index < current_index:
|
||||
continue
|
||||
|
||||
# break loop if reached final part
|
||||
try:
|
||||
parts[index+1]
|
||||
except IndexError:
|
||||
#print("INDEXERROR", part)
|
||||
if parens < 0:
|
||||
current_condition.append(part)
|
||||
if len(parens_conditions) > 0:
|
||||
for i in parens_conditions:
|
||||
for j in i:
|
||||
all_conditions.append(j + current_condition)
|
||||
else:
|
||||
all_conditions.append(current_condition)
|
||||
break
|
||||
|
||||
#print(current_condition, parens, sub_part)
|
||||
|
||||
# prepare for subcondition
|
||||
if "(" in part:
|
||||
# keep track of nested parentheses
|
||||
if parens == -1:
|
||||
parens = 0
|
||||
for char in part:
|
||||
if char == "(":
|
||||
parens += 1
|
||||
|
||||
# add to sub part
|
||||
if sub_part == "":
|
||||
sub_part = part
|
||||
else:
|
||||
sub_part += f" {part}"
|
||||
#if not ")" in part:
|
||||
continue
|
||||
|
||||
# end of subcondition
|
||||
if ")" in part:
|
||||
# read every character in case of multiple closing parentheses
|
||||
for char in part:
|
||||
if char == ")":
|
||||
parens -= 1
|
||||
|
||||
sub_part += f" {part}"
|
||||
|
||||
# if reached end of parentheses, handle subcondition
|
||||
if parens == 0:
|
||||
#print(current_condition, sub_part)
|
||||
parens = -1
|
||||
|
||||
try:
|
||||
parts[index+1]
|
||||
except IndexError:
|
||||
#print("END OF LOGIC")
|
||||
if len(parens_conditions) > 0:
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
|
||||
temp_conditions: List[List[str]] = []
|
||||
|
||||
for i in parens_conditions[0]:
|
||||
for j in parens_conditions[1]:
|
||||
temp_conditions.append(i + j)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
parens_conditions.pop(0)
|
||||
|
||||
while len(parens_conditions) > 0:
|
||||
temp_conditions2 = temp_conditions
|
||||
temp_conditions = []
|
||||
for k in temp_conditions2:
|
||||
for l in parens_conditions[0]:
|
||||
temp_conditions.append(k + l)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
|
||||
#print("TEMP:", remove_duplicates(temp_conditions))
|
||||
all_conditions += temp_conditions
|
||||
else:
|
||||
all_conditions += build_logic_subconditions(current_condition, sub_part)
|
||||
else:
|
||||
#print("NEXT PARTS:", parts[index+1], parts[index+2])
|
||||
if parts[index+1] == "&&":
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
else:
|
||||
if len(parens_conditions) > 0:
|
||||
parens_conditions.append(build_logic_subconditions(current_condition, sub_part))
|
||||
#print("PARENS:", parens_conditions)
|
||||
|
||||
temp_conditions: List[List[str]] = []
|
||||
|
||||
for i in parens_conditions[0]:
|
||||
for j in parens_conditions[1]:
|
||||
temp_conditions.append(i + j)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
parens_conditions.pop(0)
|
||||
|
||||
while len(parens_conditions) > 0:
|
||||
temp_conditions2 = temp_conditions
|
||||
temp_conditions = []
|
||||
for k in temp_conditions2:
|
||||
for l in parens_conditions[0]:
|
||||
temp_conditions.append(k + l)
|
||||
|
||||
parens_conditions.pop(0)
|
||||
|
||||
#print("TEMP:", remove_duplicates(temp_conditions))
|
||||
all_conditions += temp_conditions
|
||||
else:
|
||||
all_conditions += build_logic_subconditions(current_condition, sub_part)
|
||||
|
||||
current_index = index+2
|
||||
|
||||
current_condition = []
|
||||
sub_part = ""
|
||||
|
||||
continue
|
||||
|
||||
# collect all parts until reaching end of parentheses
|
||||
if parens > 0:
|
||||
sub_part += f" {part}"
|
||||
continue
|
||||
|
||||
current_condition.append(part)
|
||||
|
||||
# continue with current condition
|
||||
if parts[index+1] == "&&":
|
||||
current_index = index+2
|
||||
continue
|
||||
|
||||
# add condition to list and start new one
|
||||
elif parts[index+1] == "||":
|
||||
if len(parens_conditions) > 0:
|
||||
for i in parens_conditions:
|
||||
for j in i:
|
||||
all_conditions.append(j + current_condition)
|
||||
parens_conditions = []
|
||||
else:
|
||||
all_conditions.append(current_condition)
|
||||
current_condition = []
|
||||
current_index = index+2
|
||||
continue
|
||||
|
||||
return remove_duplicates(all_conditions)
|
||||
|
||||
|
||||
def build_logic_subconditions(current_condition: List[str], subcondition: str) -> List[List[str]]:
|
||||
#print("STARTED SUBCONDITION", current_condition, subcondition)
|
||||
subconditions = build_logic_conditions(subcondition[1:-1])
|
||||
final_conditions = []
|
||||
|
||||
for condition in subconditions:
|
||||
final_condition = current_condition + condition
|
||||
final_conditions.append(final_condition)
|
||||
|
||||
#print("ENDED SUBCONDITION")
|
||||
#print(final_conditions)
|
||||
return final_conditions
|
||||
|
||||
|
||||
def remove_duplicates(conditions: List[List[str]]) -> List[List[str]]:
|
||||
final_conditions: List[List[str]] = []
|
||||
for condition in conditions:
|
||||
final_conditions.append(list(dict.fromkeys(condition)))
|
||||
|
||||
return final_conditions
|
||||
|
||||
|
||||
def handle_door_visibility(door: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if door.get("visibilityFlags") == None:
|
||||
return door
|
||||
else:
|
||||
flags: List[str] = str(door.get("visibilityFlags")).split(", ")
|
||||
#print(flags)
|
||||
temp_flags: List[str] = []
|
||||
this_door: bool = False
|
||||
#required_doors: str = ""
|
||||
|
||||
if "ThisDoor" in flags:
|
||||
this_door = True
|
||||
|
||||
#if "requiredDoors" in flags:
|
||||
# required_doors: str = " || ".join(door.get("requiredDoors"))
|
||||
|
||||
if "DoubleJump" in flags:
|
||||
temp_flags.append("DoubleJump")
|
||||
|
||||
if "NormalLogic" in flags:
|
||||
temp_flags.append("NormalLogic")
|
||||
|
||||
if "NormalLogicAndDoubleJump" in flags:
|
||||
temp_flags.append("NormalLogicAndDoubleJump")
|
||||
|
||||
if "HardLogic" in flags:
|
||||
temp_flags.append("HardLogic")
|
||||
|
||||
if "HardLogicAndDoubleJump" in flags:
|
||||
temp_flags.append("HardLogicAndDoubleJump")
|
||||
|
||||
if "EnemySkips" in flags:
|
||||
temp_flags.append("EnemySkips")
|
||||
|
||||
if "EnemySkipsAndDoubleJump" in flags:
|
||||
temp_flags.append("EnemySkipsAndDoubleJump")
|
||||
|
||||
# remove duplicates
|
||||
temp_flags = list(dict.fromkeys(temp_flags))
|
||||
|
||||
original_logic: str = door.get("logic")
|
||||
temp_logic: str = ""
|
||||
|
||||
if this_door:
|
||||
temp_logic = door.get("id")
|
||||
|
||||
if temp_flags != []:
|
||||
if temp_logic != "":
|
||||
temp_logic += " || "
|
||||
temp_logic += ' && '.join(temp_flags)
|
||||
|
||||
if temp_logic != "" and original_logic != None:
|
||||
if len(original_logic.split()) == 1:
|
||||
if len(temp_logic.split()) == 1:
|
||||
door["logic"] = f"{temp_logic} && {original_logic}"
|
||||
else:
|
||||
door["logic"] = f"({temp_logic}) && {original_logic}"
|
||||
else:
|
||||
if len(temp_logic.split()) == 1:
|
||||
door["logic"] = f"{temp_logic} && ({original_logic})"
|
||||
else:
|
||||
door["logic"] = f"({temp_logic}) && ({original_logic})"
|
||||
elif temp_logic != "" and original_logic == None:
|
||||
door["logic"] = temp_logic
|
||||
|
||||
return door
|
||||
|
||||
|
||||
def get_state_provider_for_condition(condition: List[str]) -> str:
|
||||
for item in condition:
|
||||
if (item[0] == "D" and item[3] == "Z" and item[6] == "S")\
|
||||
or (item[0] == "D" and item[3] == "B" and item[4] == "Z" and item[7] == "S"):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('-l', '--local', action="store_true", help="Use local files in the same directory instead of reading resource files from the BrandenEK/Blasphemous-Randomizer repository.")
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def main(args: argparse.Namespace):
|
||||
doors = []
|
||||
locations = []
|
||||
|
||||
if (args.local):
|
||||
doors = load_resource_local("doors.json")
|
||||
locations = load_resource_local("locations_items.json")
|
||||
|
||||
else:
|
||||
doors = load_resource_from_web("https://raw.githubusercontent.com/BrandenEK/Blasphemous-Randomizer/main/resources/data/Randomizer/doors.json")
|
||||
locations = load_resource_from_web("https://raw.githubusercontent.com/BrandenEK/Blasphemous-Randomizer/main/resources/data/Randomizer/locations_items.json")
|
||||
|
||||
original_connections: Dict[str, str] = {}
|
||||
rooms: Dict[str, List[str]] = {}
|
||||
output: Dict[str, Any] = {}
|
||||
logic_objects: List[Dict[str, Any]] = []
|
||||
|
||||
for door in doors:
|
||||
if door.get("originalDoor") != None:
|
||||
if not door.get("id") in original_connections:
|
||||
original_connections[door.get("id")] = door.get("originalDoor")
|
||||
original_connections[door.get("originalDoor")] = door.get("id")
|
||||
|
||||
room: str = get_room_from_door(door.get("originalDoor"))
|
||||
if not room in rooms.keys():
|
||||
rooms[room] = [door.get("id")]
|
||||
else:
|
||||
rooms[room].append(door.get("id"))
|
||||
|
||||
def flip_doors_in_condition(condition: List[str]) -> List[str]:
|
||||
new_condition = []
|
||||
for item in condition:
|
||||
if item in original_connections:
|
||||
new_condition.append(original_connections[item])
|
||||
else:
|
||||
new_condition.append(item)
|
||||
|
||||
return new_condition
|
||||
|
||||
for room in rooms.keys():
|
||||
obj = {
|
||||
"Name": room,
|
||||
"Logic": [],
|
||||
"Handling": "Default"
|
||||
}
|
||||
|
||||
for door in rooms[room]:
|
||||
logic = {
|
||||
"StateProvider": door,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
for door in doors:
|
||||
if door.get("direction") == 5:
|
||||
continue
|
||||
|
||||
handling: str = "Transition"
|
||||
if "Cell" in door.get("id"):
|
||||
handling = "Default"
|
||||
obj = {
|
||||
"Name": door.get("id"),
|
||||
"Logic": [],
|
||||
"Handling": handling
|
||||
}
|
||||
|
||||
visibility_flags: List[str] = []
|
||||
if door.get("visibilityFlags") != None:
|
||||
visibility_flags = str(door.get("visibilityFlags")).split(", ")
|
||||
if "1" in visibility_flags:
|
||||
visibility_flags.remove("1")
|
||||
visibility_flags.append("ThisDoor")
|
||||
|
||||
required_doors: List[str] = []
|
||||
if door.get("requiredDoors"):
|
||||
required_doors = door.get("requiredDoors")
|
||||
|
||||
if len(visibility_flags) > 0:
|
||||
for flag in visibility_flags:
|
||||
if flag == "RequiredDoors":
|
||||
continue
|
||||
|
||||
if flag == "ThisDoor":
|
||||
flag = original_connections[door.get("id")]
|
||||
|
||||
if door.get("logic") != None:
|
||||
logic: str = door.get("logic")
|
||||
logic = f"{flag} && ({logic})"
|
||||
logic = preprocess_logic(True, door.get("id"), logic)
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
state_provider: str = get_room_from_door(door.get("id"))
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
state_provider = get_state_provider_for_condition(condition)
|
||||
condition.remove(state_provider)
|
||||
|
||||
logic = {
|
||||
"StateProvider": state_provider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": get_room_from_door(door.get("id")),
|
||||
"Conditions": [flag],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
if "RequiredDoors" in visibility_flags:
|
||||
for d in required_doors:
|
||||
flipped = original_connections[d]
|
||||
if door.get("logic") != None:
|
||||
logic: str = preprocess_logic(True, door.get("id"), door.get("logic"))
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
state_provider: str = flipped
|
||||
|
||||
if flipped in condition:
|
||||
condition.remove(flipped)
|
||||
|
||||
logic = {
|
||||
"StateProvider": state_provider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": flipped,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
else:
|
||||
if door.get("logic") != None:
|
||||
logic: str = preprocess_logic(True, door.get("id"), door.get("logic"))
|
||||
conditions = build_logic_conditions(logic)
|
||||
for condition in conditions:
|
||||
condition = flip_doors_in_condition(condition)
|
||||
stateProvider: str = get_room_from_door(door.get("id"))
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
stateProvider = get_state_provider_for_condition(condition)
|
||||
condition.remove(stateProvider)
|
||||
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
logic = {
|
||||
"StateProvider": get_room_from_door(door.get("id")),
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
for location in locations:
|
||||
obj = {
|
||||
"Name": location.get("id"),
|
||||
"Logic": [],
|
||||
"Handling": "Location"
|
||||
}
|
||||
|
||||
if location.get("logic") != None:
|
||||
for condition in build_logic_conditions(preprocess_logic(False, location.get("id"), location.get("logic"))):
|
||||
condition = flip_doors_in_condition(condition)
|
||||
stateProvider: str = location.get("room")
|
||||
|
||||
if get_state_provider_for_condition(condition) != None:
|
||||
stateProvider = get_state_provider_for_condition(condition)
|
||||
condition.remove(stateProvider)
|
||||
|
||||
if stateProvider == "Initial":
|
||||
stateProvider = None
|
||||
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": condition,
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
else:
|
||||
stateProvider: str = location.get("room")
|
||||
if stateProvider == "Initial":
|
||||
stateProvider = None
|
||||
logic = {
|
||||
"StateProvider": stateProvider,
|
||||
"Conditions": [],
|
||||
"StateModifiers": []
|
||||
}
|
||||
obj["Logic"].append(logic)
|
||||
|
||||
logic_objects.append(obj)
|
||||
|
||||
output["LogicObjects"] = logic_objects
|
||||
|
||||
with open("StringWorldDefinition.json", "w") as file:
|
||||
print("Writing to StringWorldDefinition.json")
|
||||
file.write(json.dumps(output, indent=4))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(parse_args())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,12 @@ unrandomized_dict: Dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
junk_locations: Set[str] = [
|
||||
junk_locations: Set[str] = {
|
||||
"Albero: Donate 50000 Tears",
|
||||
"Ossuary: 11th reward",
|
||||
"AtTotS: Miriam's gift",
|
||||
"TSC: Jocinero's final reward"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
thorn_set: Set[str] = {
|
||||
@@ -44,4 +44,4 @@ skill_dict: Dict[str, str] = {
|
||||
"Skill 5, Tier 1": "Lunge Skill",
|
||||
"Skill 5, Tier 2": "Lunge Skill",
|
||||
"Skill 5, Tier 3": "Lunge Skill",
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user