mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-09 09:03:46 -07:00
Compare commits
219 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2404e18c03 | ||
|
|
6dc461609b | ||
|
|
58d460678e | ||
|
|
0f7fd48cdd | ||
|
|
18de035b4d | ||
|
|
11fa43f0a4 | ||
|
|
91a8fc91d6 | ||
|
|
15bde56551 | ||
|
|
d744e086ef | ||
|
|
378fa5d5c4 | ||
|
|
8349774c5c | ||
|
|
34795b598a | ||
|
|
efd5004330 | ||
|
|
c799531105 | ||
|
|
5c1ded1fe9 | ||
|
|
b2162bb8e6 | ||
|
|
f1769a8d00 | ||
|
|
f520c1d9f2 | ||
|
|
910369a7f8 | ||
|
|
dbf6b6f935 | ||
|
|
e9c463c897 | ||
|
|
f4e43ca9e0 | ||
|
|
a298be9c41 | ||
|
|
18bcaa85a2 | ||
|
|
359f45d50f | ||
|
|
f5c574c37a | ||
|
|
f75a1ae117 | ||
|
|
768ccffe72 | ||
|
|
f6668997e6 | ||
|
|
db11c620a7 | ||
|
|
da48af60dc | ||
|
|
19faaa4104 | ||
|
|
628252896e | ||
|
|
f28aff6f9a | ||
|
|
894732be47 | ||
|
|
051518e72a | ||
|
|
b7b78dead3 | ||
|
|
d1167027f4 | ||
|
|
445c9b22d6 | ||
|
|
67e8877143 | ||
|
|
1fe8024b43 | ||
|
|
8e14e463e4 | ||
|
|
b8666b2562 | ||
|
|
57afdfda6f | ||
|
|
738c21c625 | ||
|
|
41898ed640 | ||
|
|
1ebc9e2ec0 | ||
|
|
9466d5274e | ||
|
|
a53bcb4697 | ||
|
|
8c5592e406 | ||
|
|
41055cd963 | ||
|
|
43874b1d28 | ||
|
|
b570aa2ec6 | ||
|
|
c43233120a | ||
|
|
57a571cc11 | ||
|
|
8622cb6204 | ||
|
|
90417e0022 | ||
|
|
96b941ed35 | ||
|
|
1832bac1a3 | ||
|
|
86641223c1 | ||
|
|
cc770418f2 | ||
|
|
513e361764 | ||
|
|
ddf7fdccc7 | ||
|
|
3df2dbe051 | ||
|
|
3d1d6908c8 | ||
|
|
7474c27372 | ||
|
|
bb0948154d | ||
|
|
fa2816822b | ||
|
|
5a42c70675 | ||
|
|
949527f9cb | ||
|
|
1a1b7e9cf4 | ||
|
|
edacb17171 | ||
|
|
33fd9de281 | ||
|
|
a126dee068 | ||
|
|
e2b942139a | ||
|
|
823b17c386 | ||
|
|
05d1b2129a | ||
|
|
436c0a4104 | ||
|
|
96f469c737 | ||
|
|
4f77abac4f | ||
|
|
d5cd95c7fb | ||
|
|
a2fbf856ff | ||
|
|
4fa8c43266 | ||
|
|
992841a951 | ||
|
|
eb3c3d6bf2 | ||
|
|
39847c5502 | ||
|
|
130232b457 | ||
|
|
ca8ffe583d | ||
|
|
563794ab83 | ||
|
|
9443861849 | ||
|
|
cbf4bbbca8 | ||
|
|
9e353ebb8e | ||
|
|
9183e8f9c9 | ||
|
|
0bb657d2c8 | ||
|
|
992f192529 | ||
|
|
1c9409cac9 | ||
|
|
005a143e3e | ||
|
|
8732974857 | ||
|
|
1ac8349bd4 | ||
|
|
2b9fa89050 | ||
|
|
23ea3c0efc | ||
|
|
698d27aada | ||
|
|
3a46c9fd3e | ||
|
|
9507300939 | ||
|
|
0d6db291de | ||
|
|
d218dec826 | ||
|
|
3d5c277c31 | ||
|
|
a9435dc6bb | ||
|
|
8f307c226b | ||
|
|
4b8f990960 | ||
|
|
3a5a4b89ee | ||
|
|
1485882642 | ||
|
|
2e4f5a64b3 | ||
|
|
90f80ce1c1 | ||
|
|
78904151b0 | ||
|
|
9d4bd6eebd | ||
|
|
5c56dc0357 | ||
|
|
c7810823e8 | ||
|
|
902d03d447 | ||
|
|
b7621a0923 | ||
|
|
b7baaed391 | ||
|
|
9dac7d9cc3 | ||
|
|
1eefe23f11 | ||
|
|
207a76d1b5 | ||
|
|
01df35f215 | ||
|
|
bedf746f1d | ||
|
|
b91a7ac6fb | ||
|
|
79e6beeec3 | ||
|
|
dae9d4c575 | ||
|
|
04928bd83d | ||
|
|
0f3818e711 | ||
|
|
0f1dc6e19c | ||
|
|
ffd0c8b341 | ||
|
|
6220963195 | ||
|
|
20119e3162 | ||
|
|
4cb8fa3cdd | ||
|
|
93e8613da7 | ||
|
|
f9cc19e150 | ||
|
|
0f1c119c76 | ||
|
|
4c734b467f | ||
|
|
1f966ee705 | ||
|
|
172ad4e57d | ||
|
|
3f935aac13 | ||
|
|
9928639ce2 | ||
|
|
0fc722cb28 | ||
|
|
4edca0ce54 | ||
|
|
70942eda8c | ||
|
|
adcb2f59ca | ||
|
|
29b34ca9fd | ||
|
|
d97ee5d209 | ||
|
|
c2bd9df0f7 | ||
|
|
112bfe0933 | ||
|
|
96b500679d | ||
|
|
258ea10c52 | ||
|
|
043ba418ec | ||
|
|
894a8571ee | ||
|
|
874197d940 | ||
|
|
d3ed40cd4d | ||
|
|
a29ba4a6c4 | ||
|
|
fe06fe075e | ||
|
|
de58cb03da | ||
|
|
3204680662 | ||
|
|
07e896508c | ||
|
|
2d3faea713 | ||
|
|
7c89a83d19 | ||
|
|
16f8b41cb9 | ||
|
|
7d506990f5 | ||
|
|
aadcb4c903 | ||
|
|
daf94fcdb2 | ||
|
|
1cef659b78 | ||
|
|
25381ef2c2 | ||
|
|
5927926314 | ||
|
|
2a11d9fec3 | ||
|
|
82c44aaa22 | ||
|
|
a7b483e4b7 | ||
|
|
917335ec54 | ||
|
|
6e59ee2926 | ||
|
|
3c9270d802 | ||
|
|
c4bbcf9890 | ||
|
|
8dbecf3d57 | ||
|
|
0de1369ec5 | ||
|
|
fa95ae4b24 | ||
|
|
2065246186 | ||
|
|
ca1b3df45b | ||
|
|
3bcc86f539 | ||
|
|
218f28912e | ||
|
|
b9642a482f | ||
|
|
33ae68c756 | ||
|
|
62942704bd | ||
|
|
fe81053521 | ||
|
|
222c8aa0ae | ||
|
|
845000d10f | ||
|
|
b05f81b4b4 | ||
|
|
6c1dc5f645 | ||
|
|
5578ccd578 | ||
|
|
78637c96a7 | ||
|
|
f3ec82962e | ||
|
|
4f590cdf7b | ||
|
|
46613adceb | ||
|
|
e1a1cd1067 | ||
|
|
7c8d102c17 | ||
|
|
35d30442f7 | ||
|
|
4f71073d17 | ||
|
|
e142283e64 | ||
|
|
de3707af4a | ||
|
|
2e0769c90e | ||
|
|
1ded7b2fd4 | ||
|
|
cacab68b77 | ||
|
|
728d249202 | ||
|
|
d1823a21ea | ||
|
|
6282efb13c | ||
|
|
0fdc14bc42 | ||
|
|
0370e669e5 | ||
|
|
ccea6bcf51 | ||
|
|
8d9454ea3b | ||
|
|
1ca8d3e4a8 | ||
|
|
9815306875 | ||
|
|
d7736950cd | ||
|
|
f5e3677ef1 |
16
.github/pyright-config.json
vendored
16
.github/pyright-config.json
vendored
@@ -1,8 +1,20 @@
|
||||
{
|
||||
"include": [
|
||||
"type_check.py",
|
||||
"../BizHawkClient.py",
|
||||
"../Patch.py",
|
||||
"../test/general/test_groups.py",
|
||||
"../test/general/test_helpers.py",
|
||||
"../test/general/test_memory.py",
|
||||
"../test/general/test_names.py",
|
||||
"../test/multiworld/__init__.py",
|
||||
"../test/multiworld/test_multiworlds.py",
|
||||
"../test/netutils/__init__.py",
|
||||
"../test/programs/__init__.py",
|
||||
"../test/programs/test_multi_server.py",
|
||||
"../test/utils/__init__.py",
|
||||
"../test/webhost/test_descriptions.py",
|
||||
"../worlds/AutoSNIClient.py",
|
||||
"../Patch.py"
|
||||
"type_check.py"
|
||||
],
|
||||
|
||||
"exclude": [
|
||||
|
||||
4
.github/workflows/ctest.yml
vendored
4
.github/workflows/ctest.yml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '**/CMakeLists.txt'
|
||||
- '.github/workflows/ctest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -21,7 +21,7 @@ on:
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '**/CMakeLists.txt'
|
||||
- '.github/workflows/ctest.yml'
|
||||
|
||||
jobs:
|
||||
|
||||
2
.github/workflows/strict-type-check.yml
vendored
2
.github/workflows/strict-type-check.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: |
|
||||
python -m pip install --upgrade pip pyright==1.1.358
|
||||
python -m pip install --upgrade pip pyright==1.1.392.post0
|
||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||
|
||||
- name: "pyright: strict check on specific files"
|
||||
|
||||
133
BaseClasses.py
133
BaseClasses.py
@@ -19,6 +19,7 @@ import Options
|
||||
import Utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from entrance_rando import ERPlacementState
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
@@ -426,12 +427,12 @@ class MultiWorld():
|
||||
def get_location(self, location_name: str, player: int) -> Location:
|
||||
return self.regions.location_cache[player][location_name]
|
||||
|
||||
def get_all_state(self, use_cache: bool) -> CollectionState:
|
||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
|
||||
cached = getattr(self, "_all_state", None)
|
||||
if use_cache and cached:
|
||||
return cached.copy()
|
||||
|
||||
ret = CollectionState(self)
|
||||
ret = CollectionState(self, allow_partial_entrances)
|
||||
|
||||
for item in self.itempool:
|
||||
self.worlds[item.player].collect(ret, item)
|
||||
@@ -717,10 +718,11 @@ class CollectionState():
|
||||
path: Dict[Union[Region, Entrance], PathValue]
|
||||
locations_checked: Set[Location]
|
||||
stale: Dict[int, bool]
|
||||
allow_partial_entrances: bool
|
||||
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||
|
||||
def __init__(self, parent: MultiWorld):
|
||||
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
||||
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
||||
self.multiworld = parent
|
||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||
@@ -729,6 +731,7 @@ class CollectionState():
|
||||
self.path = {}
|
||||
self.locations_checked = set()
|
||||
self.stale = {player: True for player in parent.get_all_ids()}
|
||||
self.allow_partial_entrances = allow_partial_entrances
|
||||
for function in self.additional_init_functions:
|
||||
function(self, parent)
|
||||
for items in parent.precollected_items.values():
|
||||
@@ -763,6 +766,8 @@ class CollectionState():
|
||||
if new_region in reachable_regions:
|
||||
blocked_connections.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
if self.allow_partial_entrances and not new_region:
|
||||
continue
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
@@ -788,7 +793,9 @@ 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"
|
||||
if self.allow_partial_entrances and not new_region:
|
||||
continue
|
||||
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)
|
||||
@@ -808,6 +815,7 @@ class CollectionState():
|
||||
ret.advancements = self.advancements.copy()
|
||||
ret.path = self.path.copy()
|
||||
ret.locations_checked = self.locations_checked.copy()
|
||||
ret.allow_partial_entrances = self.allow_partial_entrances
|
||||
for function in self.additional_copy_functions:
|
||||
ret = function(self, ret)
|
||||
return ret
|
||||
@@ -861,21 +869,40 @@ class CollectionState():
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
return self.prog_items[player][item] >= count
|
||||
|
||||
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
|
||||
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
|
||||
# argument to all() would be a new generator instance, for example.
|
||||
def has_all(self, items: Iterable[str], player: int) -> bool:
|
||||
"""Returns True if each item name of items is in state at least once."""
|
||||
return all(self.prog_items[player][item] for item in items)
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item in items:
|
||||
if not player_prog_items[item]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_any(self, items: Iterable[str], player: int) -> bool:
|
||||
"""Returns True if at least one item name of items is in state at least once."""
|
||||
return any(self.prog_items[player][item] for item in items)
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item in items:
|
||||
if player_prog_items[item]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||
"""Returns True if each item name is in the state at least as many times as specified."""
|
||||
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item, count in item_counts.items():
|
||||
if player_prog_items[item] < count:
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
||||
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item, count in item_counts.items():
|
||||
if player_prog_items[item] >= count:
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, item: str, player: int) -> int:
|
||||
return self.prog_items[player][item]
|
||||
@@ -903,11 +930,20 @@ 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)
|
||||
player_prog_items = self.prog_items[player]
|
||||
total = 0
|
||||
for item_name in items:
|
||||
total += player_prog_items[item_name]
|
||||
return total
|
||||
|
||||
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)
|
||||
player_prog_items = self.prog_items[player]
|
||||
total = 0
|
||||
for item_name in items:
|
||||
if player_prog_items[item_name] > 0:
|
||||
total += 1
|
||||
return total
|
||||
|
||||
# item name group related
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||
@@ -972,6 +1008,11 @@ class CollectionState():
|
||||
self.stale[item.player] = True
|
||||
|
||||
|
||||
class EntranceType(IntEnum):
|
||||
ONE_WAY = 1
|
||||
TWO_WAY = 2
|
||||
|
||||
|
||||
class Entrance:
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
hide_path: bool = False
|
||||
@@ -979,19 +1020,24 @@ class Entrance:
|
||||
name: str
|
||||
parent_region: Optional[Region]
|
||||
connected_region: Optional[Region] = None
|
||||
randomization_group: int
|
||||
randomization_type: EntranceType
|
||||
# LttP specific, TODO: should make a LttPEntrance
|
||||
addresses = None
|
||||
target = None
|
||||
|
||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
|
||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
||||
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
||||
self.name = name
|
||||
self.parent_region = parent
|
||||
self.player = player
|
||||
self.randomization_group = randomization_group
|
||||
self.randomization_type = randomization_type
|
||||
|
||||
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:
|
||||
if not self.hide_path and self not in state.path:
|
||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||
return True
|
||||
|
||||
@@ -1003,6 +1049,32 @@ class Entrance:
|
||||
self.addresses = addresses
|
||||
region.entrances.append(self)
|
||||
|
||||
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
||||
"""
|
||||
Determines whether this is a valid source transition, that is, whether the entrance
|
||||
randomizer is allowed to pair it to place any other regions. By default, this is the
|
||||
same as a reachability check, but can be modified by Entrance implementations to add
|
||||
other restrictions based on the placement state.
|
||||
|
||||
:param er_state: The current (partial) state of the ongoing entrance randomization
|
||||
"""
|
||||
return self.can_reach(er_state.collection_state)
|
||||
|
||||
def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
|
||||
"""
|
||||
Determines whether a given Entrance is a valid target transition, that is, whether
|
||||
the entrance randomizer is allowed to pair this Entrance to that Entrance. By default,
|
||||
only allows connection between entrances of the same type (one ways only go to one ways,
|
||||
two ways always go to two ways) and prevents connecting an exit to itself in coupled mode.
|
||||
|
||||
:param other: The proposed Entrance to connect to
|
||||
:param dead_end: Whether the other entrance considered a dead end by Entrance randomization
|
||||
:param er_state: The current (partial) state of the ongoing entrance randomization
|
||||
"""
|
||||
# the implementation of coupled causes issues for self-loops since the reverse entrance will be the
|
||||
# same as the forward entrance. In uncoupled they are ok.
|
||||
return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name)
|
||||
|
||||
def __repr__(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})'
|
||||
@@ -1152,6 +1224,16 @@ class Region:
|
||||
self.exits.append(exit_)
|
||||
return exit_
|
||||
|
||||
def create_er_target(self, name: str) -> Entrance:
|
||||
"""
|
||||
Creates and returns an Entrance object as an entrance to this region
|
||||
|
||||
:param name: name of the Entrance being created
|
||||
"""
|
||||
entrance = self.entrance_type(self.player, name)
|
||||
entrance.connect(self)
|
||||
return entrance
|
||||
|
||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
||||
"""
|
||||
@@ -1254,13 +1336,26 @@ class Location:
|
||||
|
||||
|
||||
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 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.
|
||||
filler = 0b0000
|
||||
""" aka trash, as in filler items like ammo, currency etc """
|
||||
|
||||
progression = 0b0001
|
||||
""" Item that is logically relevant.
|
||||
Protects this item from being placed on excluded or unreachable locations. """
|
||||
|
||||
useful = 0b0010
|
||||
""" Item that is especially useful.
|
||||
Protects this item from being placed on excluded or unreachable locations.
|
||||
When combined with another flag like "progression", it means "an especially useful progression item". """
|
||||
|
||||
trap = 0b0100
|
||||
""" Item that is detrimental in some way. """
|
||||
|
||||
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. """
|
||||
|
||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
||||
|
||||
def as_flag(self) -> int:
|
||||
|
||||
@@ -31,6 +31,7 @@ import ssl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import kvui
|
||||
import argparse
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
@@ -459,6 +460,13 @@ class CommonContext:
|
||||
await self.send_msgs([payload])
|
||||
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
||||
|
||||
async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
|
||||
"""Send new location checks to the server. Returns the set of actually new locations that were sent."""
|
||||
locations = set(locations) & self.missing_locations
|
||||
if locations:
|
||||
await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
|
||||
return locations
|
||||
|
||||
async def console_input(self) -> str:
|
||||
if self.ui:
|
||||
self.ui.focus_textinput()
|
||||
@@ -701,8 +709,16 @@ 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 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"""
|
||||
def make_gui(self) -> "type[kvui.GameManager]":
|
||||
"""
|
||||
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
|
||||
|
||||
Common changes are changing `base_title` to update the window title of the client and
|
||||
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
|
||||
|
||||
ex. `logging_pairs.append(("Foo", "Bar"))`
|
||||
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
|
||||
"""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
@@ -891,6 +907,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.disconnected_intentionally = True
|
||||
ctx.event_invalid_game()
|
||||
elif 'IncompatibleVersion' in errors:
|
||||
ctx.disconnected_intentionally = True
|
||||
raise Exception('Server reported your client version as incompatible. '
|
||||
'This probably means you have to update.')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
@@ -1041,6 +1058,32 @@ def get_base_parser(description: typing.Optional[str] = None):
|
||||
return parser
|
||||
|
||||
|
||||
def handle_url_arg(args: "argparse.Namespace",
|
||||
parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
|
||||
"""
|
||||
Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
|
||||
If alternate data is required the urlparse response is saved back to args.url if valid
|
||||
"""
|
||||
if not args.url:
|
||||
return args
|
||||
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
if url.scheme != "archipelago":
|
||||
if not parser:
|
||||
parser = get_base_parser()
|
||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||
return args
|
||||
|
||||
args.url = 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)
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def run_as_textclient(*args):
|
||||
class TextContext(CommonContext):
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
@@ -1053,7 +1096,7 @@ def run_as_textclient(*args):
|
||||
if password_requested and not self.password:
|
||||
await super(TextContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
await self.send_connect(game="")
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
@@ -1082,17 +1125,7 @@ def run_as_textclient(*args):
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
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)
|
||||
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")
|
||||
args = handle_url_arg(args, parser=parser)
|
||||
|
||||
# use colorama to display colored text highlighting on windows
|
||||
colorama.init()
|
||||
|
||||
51
Fill.py
51
Fill.py
@@ -235,18 +235,30 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
locations: typing.List[Location],
|
||||
itempool: typing.List[Item],
|
||||
name: str = "Remaining",
|
||||
move_unplaceable_to_start_inventory: bool = False) -> None:
|
||||
move_unplaceable_to_start_inventory: bool = False,
|
||||
check_location_can_fill: bool = False) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
total = min(len(itempool), len(locations))
|
||||
placed = 0
|
||||
|
||||
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
||||
if check_location_can_fill:
|
||||
state = CollectionState(multiworld)
|
||||
|
||||
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
|
||||
return location_to_fill.can_fill(state, item_to_fill, check_access=False)
|
||||
else:
|
||||
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
|
||||
return location_to_fill.item_rule(item_to_fill)
|
||||
|
||||
while locations and itempool:
|
||||
item_to_place = itempool.pop()
|
||||
spot_to_fill: typing.Optional[Location] = None
|
||||
|
||||
for i, location in enumerate(locations):
|
||||
if location.item_rule(item_to_place):
|
||||
if location_can_fill_item(location, item_to_place):
|
||||
# popping by index is faster than removing by content,
|
||||
spot_to_fill = locations.pop(i)
|
||||
# skipping a scan for the element
|
||||
@@ -267,7 +279,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
if location.item_rule(item_to_place):
|
||||
if location_can_fill_item(location, item_to_place):
|
||||
# Add this item to the existing placement, and
|
||||
# add the old item to the back of the queue
|
||||
spot_to_fill = placements.pop(i)
|
||||
@@ -490,7 +502,13 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
# "priority fill"
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||
name="Priority", one_item_per_player=False)
|
||||
name="Priority", one_item_per_player=True, allow_partial=True)
|
||||
|
||||
if prioritylocations:
|
||||
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||
name="Priority Retry", one_item_per_player=False)
|
||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
@@ -519,7 +537,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.\n"
|
||||
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
|
||||
multiworld=multiworld,
|
||||
)
|
||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||
@@ -537,7 +556,7 @@ 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 excludable items.",
|
||||
multiworld=multiworld,
|
||||
)
|
||||
|
||||
@@ -558,6 +577,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
print_data = {"items": items_counter, "locations": locations_counter}
|
||||
logging.info(f"Per-Player counts: {print_data})")
|
||||
|
||||
more_locations = locations_counter - items_counter
|
||||
more_items = items_counter - locations_counter
|
||||
for player in multiworld.player_ids:
|
||||
if more_locations[player]:
|
||||
logging.error(
|
||||
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
|
||||
elif more_items[player]:
|
||||
logging.warning(
|
||||
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
|
||||
if unfilled:
|
||||
raise FillError(
|
||||
f"Unable to fill all locations.\n" +
|
||||
f"Unfilled locations({len(unfilled)}): {unfilled}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Unable to place all items.\n" +
|
||||
f"Unplaced items({len(unplaced)}): {unplaced}"
|
||||
)
|
||||
|
||||
|
||||
def flood_items(multiworld: MultiWorld) -> None:
|
||||
# get items to distribute
|
||||
|
||||
24
Generate.py
24
Generate.py
@@ -42,7 +42,9 @@ def mystery_argparse():
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
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('--log_level', default=defaults.loglevel, help='Sets log level')
|
||||
parser.add_argument('--log_time', help="Add timestamps to STDOUT",
|
||||
default=defaults.logtime, action='store_true')
|
||||
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,
|
||||
@@ -75,7 +77,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
|
||||
@@ -438,7 +440,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
valid_keys = set()
|
||||
valid_keys = {"triggers"}
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
||||
|
||||
@@ -497,15 +499,23 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
valid_keys.add(option_key)
|
||||
for option_key in game_weights:
|
||||
if option_key in {"triggers", *valid_keys}:
|
||||
continue
|
||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
||||
|
||||
# TODO remove plando_items after moving it to the options system
|
||||
valid_keys.add("plando_items")
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||
if ret.game == "A Link to the Past":
|
||||
# TODO there are still more LTTP options not on the options system
|
||||
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
||||
roll_alttp_settings(ret, game_weights)
|
||||
|
||||
# log a warning for options within a game section that aren't determined as valid
|
||||
for option_key in game_weights:
|
||||
if option_key in valid_keys:
|
||||
continue
|
||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
|
||||
f"for player {ret.name}.")
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,7 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 LLCoolDave
|
||||
Copyright (c) 2022 Berserker66
|
||||
Copyright (c) 2025 Berserker66
|
||||
Copyright (c) 2022 CaitSith2
|
||||
Copyright (c) 2021 LegendaryLinux
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ class RAGameboy():
|
||||
|
||||
def check_command_response(self, command: str, response: bytes):
|
||||
if command == "VERSION":
|
||||
ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
||||
ok = re.match(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
||||
else:
|
||||
ok = response.startswith(command.encode())
|
||||
if not ok:
|
||||
@@ -560,6 +560,10 @@ class LinksAwakeningContext(CommonContext):
|
||||
|
||||
while self.client.auth == None:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Just return if we're closing
|
||||
if self.exit_event.is_set():
|
||||
return
|
||||
self.auth = self.client.auth
|
||||
await self.send_connect()
|
||||
|
||||
|
||||
@@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
class AdjusterWorld(object):
|
||||
class AdjusterSubWorld(object):
|
||||
def __init__(self, random):
|
||||
self.random = random
|
||||
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.per_slot_randoms = {1: random}
|
||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||
|
||||
|
||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
|
||||
3
Main.py
3
Main.py
@@ -148,7 +148,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
else:
|
||||
multiworld.worlds[1].options.non_local_items.value = set()
|
||||
multiworld.worlds[1].options.local_items.value = set()
|
||||
|
||||
|
||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||
AutoWorld.call_all(multiworld, "generate_basic")
|
||||
|
||||
# remove starting inventory from pool items.
|
||||
|
||||
104
MultiServer.py
104
MultiServer.py
@@ -28,9 +28,11 @@ ModuleUpdate.update()
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import ssl
|
||||
from NetUtils import ServerConnection
|
||||
|
||||
import websockets
|
||||
import colorama
|
||||
import websockets
|
||||
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||
try:
|
||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||
from pony.orm.dbapiprovider import OperationalError
|
||||
@@ -119,13 +121,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
||||
|
||||
class Client(Endpoint):
|
||||
version = Version(0, 0, 0)
|
||||
tags: typing.List[str] = []
|
||||
tags: typing.List[str]
|
||||
remote_items: bool
|
||||
remote_start_inventory: bool
|
||||
no_items: bool
|
||||
no_locations: bool
|
||||
no_text: bool
|
||||
|
||||
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
|
||||
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||
super().__init__(socket)
|
||||
self.auth = False
|
||||
self.team = None
|
||||
@@ -175,6 +178,7 @@ class Context:
|
||||
"compatibility": int}
|
||||
# team -> slot id -> list of clients authenticated to slot.
|
||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||
endpoints: list[Client]
|
||||
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||
@@ -364,18 +368,28 @@ class Context:
|
||||
return True
|
||||
|
||||
def broadcast_all(self, msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||
data = self.dumper(msgs)
|
||||
endpoints = (
|
||||
endpoint
|
||||
for endpoint in self.endpoints
|
||||
if endpoint.auth and not (msg_is_text and endpoint.no_text)
|
||||
)
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||
|
||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||
self.logger.info("Notice (all): %s" % text)
|
||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||
|
||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||
data = self.dumper(msgs)
|
||||
endpoints = (
|
||||
endpoint
|
||||
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
|
||||
if not (msg_is_text and endpoint.no_text)
|
||||
)
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||
|
||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
@@ -389,13 +403,13 @@ class Context:
|
||||
await on_client_disconnected(self, endpoint)
|
||||
|
||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||
if not client.auth:
|
||||
if not client.auth or client.no_text:
|
||||
return
|
||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||
|
||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||
if not client.auth:
|
||||
if not client.auth or client.no_text:
|
||||
return
|
||||
async_start(self.send_msgs(client,
|
||||
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
||||
@@ -444,7 +458,7 @@ class Context:
|
||||
|
||||
self.slot_info = decoded_obj["slot_info"]
|
||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
|
||||
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
|
||||
if slot_info.type == SlotType.group}
|
||||
|
||||
self.clients = {0: {}}
|
||||
@@ -743,23 +757,24 @@ class Context:
|
||||
concerns[player].append(data)
|
||||
if not hint.local and data not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(data)
|
||||
# remember hints in all cases
|
||||
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in self.hints[team, hint.finding_player]:
|
||||
self.hints[team, hint.finding_player].add(hint)
|
||||
new_hint_events.add(hint.finding_player)
|
||||
for player in self.slot_set(hint.receiving_player):
|
||||
self.hints[team, player].add(hint)
|
||||
new_hint_events.add(player)
|
||||
# only remember hints that were not already found at the time of creation
|
||||
if not hint.found:
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in self.hints[team, hint.finding_player]:
|
||||
self.hints[team, hint.finding_player].add(hint)
|
||||
new_hint_events.add(hint.finding_player)
|
||||
for player in self.slot_set(hint.receiving_player):
|
||||
self.hints[team, player].add(hint)
|
||||
new_hint_events.add(player)
|
||||
|
||||
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||
for slot in new_hint_events:
|
||||
self.on_new_hint(team, slot)
|
||||
for slot, hint_data in concerns.items():
|
||||
if recipients is None or slot in recipients:
|
||||
clients = self.clients[team].get(slot)
|
||||
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
|
||||
if not clients:
|
||||
continue
|
||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
||||
@@ -768,7 +783,7 @@ class Context:
|
||||
|
||||
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
|
||||
for hint in self.hints[team, finding_player]:
|
||||
if hint.location == seeked_location:
|
||||
if hint.location == seeked_location and hint.finding_player == finding_player:
|
||||
return hint
|
||||
return None
|
||||
|
||||
@@ -818,7 +833,7 @@ def update_aliases(ctx: Context, team: int):
|
||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
||||
|
||||
|
||||
async def server(websocket, path: str = "/", ctx: Context = None):
|
||||
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
|
||||
client = Client(websocket, ctx)
|
||||
ctx.endpoints.append(client)
|
||||
|
||||
@@ -909,6 +924,10 @@ async def on_client_joined(ctx: Context, client: Client):
|
||||
"If your client supports it, "
|
||||
"you may have additional local commands you can list with /help.",
|
||||
{"type": "Tutorial"})
|
||||
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
|
||||
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
|
||||
"It may stop working in the future. If you are a player, please report this to the "
|
||||
"client's developer.")
|
||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
@@ -1059,21 +1078,37 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
|
||||
|
||||
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
||||
count_activity: bool = True):
|
||||
slot_locations = ctx.locations[slot]
|
||||
new_locations = set(locations) - ctx.location_checks[team, slot]
|
||||
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
|
||||
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata
|
||||
if new_locations:
|
||||
if count_activity:
|
||||
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
sortable: list[tuple[int, int, int, int]] = []
|
||||
for location in new_locations:
|
||||
item_id, target_player, flags = ctx.locations[slot][location]
|
||||
# extract all fields to avoid runtime overhead in LocationStore
|
||||
item_id, target_player, flags = slot_locations[location]
|
||||
# sort/group by receiver and item
|
||||
sortable.append((target_player, item_id, location, flags))
|
||||
|
||||
info_texts: list[dict[str, typing.Any]] = []
|
||||
for target_player, item_id, location, flags in sorted(sortable):
|
||||
new_item = NetworkItem(item_id, location, slot, flags)
|
||||
send_items_to(ctx, team, target_player, new_item)
|
||||
|
||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
||||
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
||||
info_text = json_format_send_event(new_item, target_player)
|
||||
ctx.broadcast_team(team, [info_text])
|
||||
if len(info_texts) >= 140:
|
||||
# split into chunks that are close to compression window of 64K but not too big on the wire
|
||||
# (roughly 1300-2600 bytes after compression depending on repetitiveness)
|
||||
ctx.broadcast_team(team, info_texts)
|
||||
info_texts.clear()
|
||||
info_texts.append(json_format_send_event(new_item, target_player))
|
||||
ctx.broadcast_team(team, info_texts)
|
||||
del info_texts
|
||||
del sortable
|
||||
|
||||
ctx.location_checks[team, slot] |= new_locations
|
||||
send_new_items(ctx)
|
||||
@@ -1100,7 +1135,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||
in ctx.locations.find_item(slots, seeked_item_id):
|
||||
prev_hint = ctx.get_hint(team, slot, location_id)
|
||||
prev_hint = ctx.get_hint(team, finding_player, location_id)
|
||||
if prev_hint:
|
||||
hints.append(prev_hint)
|
||||
else:
|
||||
@@ -1786,7 +1821,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
ctx.clients[team][slot].append(client)
|
||||
client.version = args['version']
|
||||
client.tags = args['tags']
|
||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
||||
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||
connected_packet = {
|
||||
"cmd": "Connected",
|
||||
"team": client.team, "slot": client.slot,
|
||||
@@ -1859,6 +1896,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
client.tags = args["tags"]
|
||||
if set(old_tags) != set(client.tags):
|
||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||
client.no_text = "NoText" in client.tags or (
|
||||
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||
)
|
||||
ctx.broadcast_text_all(
|
||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
||||
f"from {old_tags} to {client.tags}.",
|
||||
@@ -1887,7 +1927,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
for location in args["locations"]:
|
||||
if type(location) is not int:
|
||||
await ctx.send_msgs(client,
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'Locations has to be a list of integers',
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
|
||||
@@ -1990,6 +2031,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
args["cmd"] = "SetReply"
|
||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||
args["original_value"] = copy.copy(value)
|
||||
args["slot"] = client.slot
|
||||
for operation in args["operations"]:
|
||||
func = modify_functions[operation["operation"]]
|
||||
value = func(value, operation["value"])
|
||||
|
||||
38
NetUtils.py
38
NetUtils.py
@@ -5,11 +5,20 @@ import enum
|
||||
import warnings
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
|
||||
import websockets
|
||||
if typing.TYPE_CHECKING:
|
||||
from websockets import WebSocketServerProtocol as ServerConnection
|
||||
|
||||
from Utils import ByValue, Version
|
||||
|
||||
|
||||
class HintStatus(ByValue, enum.IntEnum):
|
||||
HINT_UNSPECIFIED = 0
|
||||
HINT_NO_PRIORITY = 10
|
||||
HINT_AVOID = 20
|
||||
HINT_PRIORITY = 30
|
||||
HINT_FOUND = 40
|
||||
|
||||
|
||||
class JSONMessagePart(typing.TypedDict, total=False):
|
||||
text: str
|
||||
# optional
|
||||
@@ -19,6 +28,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
||||
player: int
|
||||
# if type == item indicates item flags
|
||||
flags: int
|
||||
# if type == hint_status
|
||||
hint_status: HintStatus
|
||||
|
||||
|
||||
class ClientStatus(ByValue, enum.IntEnum):
|
||||
@@ -29,14 +40,6 @@ class ClientStatus(ByValue, enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class HintStatus(enum.IntEnum):
|
||||
HINT_FOUND = 0
|
||||
HINT_UNSPECIFIED = 1
|
||||
HINT_NO_PRIORITY = 10
|
||||
HINT_AVOID = 20
|
||||
HINT_PRIORITY = 30
|
||||
|
||||
|
||||
class SlotType(ByValue, enum.IntFlag):
|
||||
spectator = 0b00
|
||||
player = 0b01
|
||||
@@ -149,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
socket: websockets.WebSocketServerProtocol
|
||||
socket: "ServerConnection"
|
||||
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
@@ -192,6 +195,7 @@ class JSONTypes(str, enum.Enum):
|
||||
location_name = "location_name"
|
||||
location_id = "location_id"
|
||||
entrance_name = "entrance_name"
|
||||
hint_status = "hint_status"
|
||||
|
||||
|
||||
class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
@@ -273,6 +277,10 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
node["color"] = 'blue'
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_hint_status(self, node: JSONMessagePart):
|
||||
node["color"] = status_colors.get(node["hint_status"], "red")
|
||||
return self._handle_color(node)
|
||||
|
||||
|
||||
class RawJSONtoTextParser(JSONtoTextParser):
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
@@ -319,6 +327,13 @@ status_colors: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_AVOID: "salmon",
|
||||
HintStatus.HINT_PRIORITY: "plum",
|
||||
}
|
||||
|
||||
|
||||
def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs):
|
||||
parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"),
|
||||
"hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs})
|
||||
|
||||
|
||||
class Hint(typing.NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
@@ -363,8 +378,7 @@ class Hint(typing.NamedTuple):
|
||||
else:
|
||||
add_json_text(parts, "'s World")
|
||||
add_json_text(parts, ". ")
|
||||
add_json_text(parts, status_names.get(self.status, "(unknown)"), type="color",
|
||||
color=status_colors.get(self.status, "red"))
|
||||
add_json_hint_status(parts, self.status)
|
||||
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||
"receiving": self.receiving_player,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import tkinter as tk
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
@@ -197,7 +196,6 @@ def set_icon(window):
|
||||
def adjust(args):
|
||||
# Create a fake multiworld and OOTWorld to use as a base
|
||||
multiworld = MultiWorld(1)
|
||||
multiworld.per_slot_randoms = {1: random}
|
||||
ootworld = OOTWorld(multiworld, 1)
|
||||
# Set options in the fake OOTWorld
|
||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||
|
||||
54
Options.py
54
Options.py
@@ -137,7 +137,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
If this is False, the docstring is instead interpreted as plain text, and
|
||||
displayed as-is on the WebHost with whitespace preserved.
|
||||
|
||||
If this is None, it inherits the value of `World.rich_text_options_doc`. For
|
||||
If this is None, it inherits the value of `WebWorld.rich_text_options_doc`. For
|
||||
backwards compatibility, this defaults to False, but worlds are encouraged to
|
||||
set it to True and use reStructuredText for their Option documentation.
|
||||
|
||||
@@ -496,7 +496,7 @@ class TextChoice(Choice):
|
||||
|
||||
def __init__(self, value: typing.Union[str, int]):
|
||||
assert isinstance(value, str) or isinstance(value, int), \
|
||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
|
||||
self.value = value
|
||||
|
||||
@property
|
||||
@@ -617,17 +617,17 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
|
||||
used_locations.append(location)
|
||||
used_bosses.append(boss)
|
||||
if not cls.valid_boss_name(boss):
|
||||
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
||||
raise ValueError(f"'{boss.title()}' is not a valid boss name.")
|
||||
if not cls.valid_location_name(location):
|
||||
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
||||
raise ValueError(f"'{location.title()}' is not a valid boss location name.")
|
||||
if not cls.can_place_boss(boss, location):
|
||||
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
||||
raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.")
|
||||
else:
|
||||
if cls.duplicate_bosses:
|
||||
if not cls.valid_boss_name(option):
|
||||
raise ValueError(f"{option} is not a valid boss name.")
|
||||
raise ValueError(f"'{option}' is not a valid boss name.")
|
||||
else:
|
||||
raise ValueError(f"{option.title()} is not formatted correctly.")
|
||||
raise ValueError(f"'{option.title()}' is not formatted correctly.")
|
||||
|
||||
@classmethod
|
||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||
@@ -689,9 +689,9 @@ class Range(NumericOption):
|
||||
@classmethod
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
@@ -717,11 +717,11 @@ class Range(NumericOption):
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
|
||||
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
||||
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
|
||||
else:
|
||||
return cls(random.randint(random_range[0], random_range[1]))
|
||||
|
||||
@@ -739,8 +739,16 @@ class Range(NumericOption):
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
||||
return int(round(random.triangular(lower, end, tri), 0))
|
||||
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||
"""
|
||||
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
||||
|
||||
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
||||
"""
|
||||
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
||||
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
||||
# when a != b, so ensure the result is never more than `end`.
|
||||
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
||||
|
||||
|
||||
class NamedRange(Range):
|
||||
@@ -817,15 +825,15 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
||||
for item_name in self.value:
|
||||
if item_name not in world.item_names:
|
||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||
raise Exception(f"Item {item_name} from option {self} "
|
||||
f"is not a valid item name from {world.game}. "
|
||||
raise Exception(f"Item '{item_name}' from option '{self}' "
|
||||
f"is not a valid item name from '{world.game}'. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
elif self.verify_location_name:
|
||||
for location_name in self.value:
|
||||
if location_name not in world.location_names:
|
||||
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
||||
raise Exception(f"Location {location_name} from option {self} "
|
||||
f"is not a valid location name from {world.game}. "
|
||||
raise Exception(f"Location '{location_name}' from option '{self}' "
|
||||
f"is not a valid location name from '{world.game}'. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
|
||||
def __iter__(self) -> typing.Iterator[typing.Any]:
|
||||
@@ -1111,11 +1119,11 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
||||
used_entrances.append(entrance)
|
||||
used_exits.append(exit)
|
||||
if not cls.validate_entrance_name(entrance):
|
||||
raise ValueError(f"{entrance.title()} is not a valid entrance.")
|
||||
raise ValueError(f"'{entrance.title()}' is not a valid entrance.")
|
||||
if not cls.validate_exit_name(exit):
|
||||
raise ValueError(f"{exit.title()} is not a valid exit.")
|
||||
raise ValueError(f"'{exit.title()}' is not a valid exit.")
|
||||
if not cls.can_connect(entrance, exit):
|
||||
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
|
||||
raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.")
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: PlandoConFromAnyType) -> Self:
|
||||
@@ -1379,8 +1387,8 @@ class ItemLinks(OptionList):
|
||||
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
|
||||
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
|
||||
|
||||
raise Exception(f"Item {item_name} from item link {item_link} "
|
||||
f"is not a valid item from {world.game} for {pool_name}. "
|
||||
raise Exception(f"Item '{item_name}' from item link '{item_link}' "
|
||||
f"is not a valid item from '{world.game}' for '{pool_name}'. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
|
||||
if allow_item_groups:
|
||||
pool |= world.item_name_groups.get(item_name, {item_name})
|
||||
@@ -1574,7 +1582,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
||||
}
|
||||
output.append(player_output)
|
||||
for option_key, option in world.options_dataclass.type_hints.items():
|
||||
if issubclass(Removed, option):
|
||||
if option.visibility == Visibility.none:
|
||||
continue
|
||||
display_name = getattr(option, "display_name", option_key)
|
||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||
|
||||
@@ -79,6 +79,7 @@ Currently, the following games are supported:
|
||||
* Faxanadu
|
||||
* Saving Princess
|
||||
* Castlevania: Circle of the Moon
|
||||
* Inscryption
|
||||
|
||||
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
|
||||
|
||||
@@ -243,6 +243,9 @@ class SNIContext(CommonContext):
|
||||
# Once the games handled by SNIClient gets made to be remote items,
|
||||
# this will no longer be needed.
|
||||
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||
|
||||
if self.client_handler is not None:
|
||||
self.client_handler.on_package(self, cmd, args)
|
||||
|
||||
def run_gui(self) -> None:
|
||||
from kvui import GameManager
|
||||
|
||||
30
Utils.py
30
Utils.py
@@ -152,8 +152,15 @@ def home_path(*path: str) -> str:
|
||||
if hasattr(home_path, 'cached_path'):
|
||||
pass
|
||||
elif sys.platform.startswith('linux'):
|
||||
home_path.cached_path = os.path.expanduser('~/Archipelago')
|
||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||
xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
||||
home_path.cached_path = xdg_data_home + '/Archipelago'
|
||||
if not os.path.isdir(home_path.cached_path):
|
||||
legacy_home_path = os.path.expanduser('~/Archipelago')
|
||||
if os.path.isdir(legacy_home_path):
|
||||
os.renames(legacy_home_path, home_path.cached_path)
|
||||
os.symlink(home_path.cached_path, legacy_home_path)
|
||||
else:
|
||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||
else:
|
||||
# not implemented
|
||||
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
||||
@@ -514,8 +521,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return self.condition(record)
|
||||
|
||||
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
||||
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.msg))
|
||||
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
||||
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
|
||||
root_logger.addHandler(file_handler)
|
||||
if sys.stdout:
|
||||
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||
@@ -534,7 +541,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
return
|
||||
logging.getLogger(exception_logger).exception("Uncaught exception",
|
||||
exc_info=(exc_type, exc_value, exc_traceback))
|
||||
exc_info=(exc_type, exc_value, exc_traceback),
|
||||
extra={"NoStream": exception_logger is None})
|
||||
return orig_hook(exc_type, exc_value, exc_traceback)
|
||||
|
||||
handle_exception._wrapped = True
|
||||
@@ -932,7 +940,7 @@ def freeze_support() -> None:
|
||||
|
||||
def visualize_regions(root_region: Region, file_name: str, *,
|
||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||
linetype_ortho: bool = True) -> None:
|
||||
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||
"""Visualize the layout of a world as a PlantUML diagram.
|
||||
|
||||
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
||||
@@ -948,16 +956,22 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
Items without ID will be shown in italics.
|
||||
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
||||
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
||||
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
||||
|
||||
Example usage in World code:
|
||||
from Utils import visualize_regions
|
||||
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
|
||||
state = self.multiworld.get_all_state(False)
|
||||
state.update_reachable_regions(self.player)
|
||||
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
|
||||
regions_to_highlight=state.reachable_regions[self.player])
|
||||
|
||||
Example usage in Main code:
|
||||
from Utils import visualize_regions
|
||||
for player in multiworld.player_ids:
|
||||
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
||||
"""
|
||||
if regions_to_highlight is None:
|
||||
regions_to_highlight = set()
|
||||
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
||||
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from collections import deque
|
||||
@@ -1010,7 +1024,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
|
||||
|
||||
def visualize_region(region: Region) -> None:
|
||||
uml.append(f"class \"{fmt(region)}\"")
|
||||
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}")
|
||||
if show_locations:
|
||||
visualize_locations(region)
|
||||
visualize_exits(region)
|
||||
|
||||
@@ -3,13 +3,13 @@ from typing import List, Tuple
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from ..models import Seed
|
||||
from ..models import Seed, Slot
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||
|
||||
|
||||
from . import datapackage, generate, room, user # trigger registration
|
||||
|
||||
@@ -30,4 +30,4 @@ def get_seeds():
|
||||
"creation_time": seed.creation_time,
|
||||
"players": get_players(seed.slots),
|
||||
})
|
||||
return jsonify(response)
|
||||
return jsonify(response)
|
||||
|
||||
@@ -117,6 +117,7 @@ class WebHostContext(Context):
|
||||
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
||||
missing_checksum = False
|
||||
|
||||
for game in list(multidata.get("datapackage", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
@@ -132,11 +133,13 @@ class WebHostContext(Context):
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||
else:
|
||||
missing_checksum = True # Game rolled on old AP and will load data package from multidata
|
||||
self.gamespackage[game] = static_gamespackage.get(game, {})
|
||||
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||
|
||||
if not game_data_packages:
|
||||
if not game_data_packages and not missing_checksum:
|
||||
# all static -> use the static dicts directly
|
||||
self.gamespackage = static_gamespackage
|
||||
self.item_name_groups = static_item_name_groups
|
||||
|
||||
@@ -31,11 +31,11 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s
|
||||
|
||||
server_options = {
|
||||
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
|
||||
"release_mode": options_source.get("release_mode", ServerOptions.release_mode),
|
||||
"remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
|
||||
"collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode),
|
||||
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
||||
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
"server_password": str(options_source.get("server_password", None)),
|
||||
}
|
||||
generator_options = {
|
||||
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
||||
|
||||
@@ -22,7 +22,7 @@ players to rely upon each other to complete their game.
|
||||
|
||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
|
||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% block footer %}
|
||||
<footer id="island-footer">
|
||||
<div id="copyright-notice">Copyright 2024 Archipelago</div>
|
||||
<div id="copyright-notice">Copyright 2025 Archipelago</div>
|
||||
<div id="links">
|
||||
<a href="/sitemap">Site Map</a>
|
||||
-
|
||||
|
||||
@@ -147,3 +147,8 @@
|
||||
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
||||
<ServerToolTip>:
|
||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||
<AutocompleteHintInput>
|
||||
size_hint_y: None
|
||||
height: dp(30)
|
||||
multiline: False
|
||||
write_tab: False
|
||||
|
||||
@@ -121,6 +121,14 @@ Response:
|
||||
|
||||
Expected Response Type: `HASH_RESPONSE`
|
||||
|
||||
- `MEMORY_SIZE`
|
||||
Returns the size in bytes of the specified memory domain.
|
||||
|
||||
Expected Response Type: `MEMORY_SIZE_RESPONSE`
|
||||
|
||||
Additional Fields:
|
||||
- `domain` (`string`): The name of the memory domain to check
|
||||
|
||||
- `GUARD`
|
||||
Checks a section of memory against `expected_data`. If the bytes starting
|
||||
at `address` do not match `expected_data`, the response will have `value`
|
||||
@@ -216,6 +224,12 @@ Response:
|
||||
Additional Fields:
|
||||
- `value` (`string`): The returned hash
|
||||
|
||||
- `MEMORY_SIZE_RESPONSE`
|
||||
Contains the size in bytes of the specified memory domain.
|
||||
|
||||
Additional Fields:
|
||||
- `value` (`number`): The size of the domain in bytes
|
||||
|
||||
- `GUARD_RESPONSE`
|
||||
The result of an attempted `GUARD` request.
|
||||
|
||||
@@ -376,6 +390,15 @@ request_handlers = {
|
||||
return res
|
||||
end,
|
||||
|
||||
["MEMORY_SIZE"] = function (req)
|
||||
local res = {}
|
||||
|
||||
res["type"] = "MEMORY_SIZE_RESPONSE"
|
||||
res["value"] = memory.getmemorydomainsize(req["domain"])
|
||||
|
||||
return res
|
||||
end,
|
||||
|
||||
["GUARD"] = function (req)
|
||||
local res = {}
|
||||
local expected_data = base64.decode(req["expected_data"])
|
||||
@@ -613,9 +636,11 @@ end)
|
||||
|
||||
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
|
||||
print("Must use BizHawk 2.7.0 or newer")
|
||||
elseif bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) then
|
||||
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.9.")
|
||||
else
|
||||
if bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 10) then
|
||||
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.10.")
|
||||
end
|
||||
|
||||
if emu.getsystemid() == "NULL" then
|
||||
print("No ROM is loaded. Please load a ROM.")
|
||||
while emu.getsystemid() == "NULL" do
|
||||
|
||||
@@ -1816,7 +1816,7 @@ end
|
||||
|
||||
-- Main control handling: main loop and socket receive
|
||||
|
||||
function receive()
|
||||
function APreceive()
|
||||
l, e = ootSocket:receive()
|
||||
-- Handle incoming message
|
||||
if e == 'closed' then
|
||||
@@ -1874,7 +1874,7 @@ function main()
|
||||
end
|
||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
||||
if (frame % 30 == 0) then
|
||||
receive()
|
||||
APreceive()
|
||||
end
|
||||
elseif (curstate == STATE_UNINITIALIZED) then
|
||||
if (frame % 60 == 0) then
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
# Hylics 2
|
||||
/worlds/hylics2/ @TRPG0
|
||||
|
||||
# Inscryption
|
||||
/worlds/inscryption/ @DrBibop @Glowbuzz
|
||||
|
||||
# Kirby's Dream Land 3
|
||||
/worlds/kdl3/ @Silvris
|
||||
|
||||
@@ -96,6 +99,9 @@
|
||||
# Lingo
|
||||
/worlds/lingo/ @hatkirby
|
||||
|
||||
# Links Awakening DX
|
||||
/worlds/ladx/ @threeandthreee
|
||||
|
||||
# Lufia II Ancient Cave
|
||||
/worlds/lufia2ac/ @el-u
|
||||
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
||||
@@ -149,7 +155,7 @@
|
||||
/worlds/saving_princess/ @LeonarthCG
|
||||
|
||||
# Shivers
|
||||
/worlds/shivers/ @GodlFire
|
||||
/worlds/shivers/ @GodlFire @korydondzila
|
||||
|
||||
# A Short Hike
|
||||
/worlds/shorthike/ @chandler05 @BrandenEK
|
||||
@@ -233,9 +239,6 @@
|
||||
# Final Fantasy (1)
|
||||
# /worlds/ff1/
|
||||
|
||||
# Links Awakening DX
|
||||
# /worlds/ladx/
|
||||
|
||||
# Ocarina of Time
|
||||
# /worlds/oot/
|
||||
|
||||
|
||||
@@ -43,3 +43,26 @@ A faster alternative to the `for` loop would be to use a [list comprehension](ht
|
||||
```py
|
||||
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
|
||||
|
||||
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and **when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is quite complicated.
|
||||
|
||||
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph. It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to the queue until there is nothing more to check.
|
||||
|
||||
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region access, then the following may happen:
|
||||
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been reached yet during the graph search.
|
||||
2. Then, the region in its access_rule is determined to be reachable.
|
||||
|
||||
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
|
||||
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new regions are reached.
|
||||
|
||||
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep if a specific region is reached during it.
|
||||
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness), using them is significantly faster than just "rechecking each entrance until nothing new is found".
|
||||
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they call `region.can_reach` on their respective parent/source region.
|
||||
|
||||
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules.
|
||||
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost.
|
||||
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance → region dependencies, making indirect conditions preferred because they are much faster.
|
||||
|
||||
424
docs/entrance randomization.md
Normal file
424
docs/entrance randomization.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# Entrance Randomization
|
||||
|
||||
This document discusses the API and underlying implementation of the generic entrance randomization algorithm
|
||||
exposed in [entrance_rando.py](/entrance_rando.py). Throughout the doc, entrance randomization is frequently abbreviated
|
||||
as "ER."
|
||||
|
||||
This doc assumes familiarity with Archipelago's graph logic model. If you don't have a solid understanding of how
|
||||
regions work, you should start there.
|
||||
|
||||
## Entrance randomization concepts
|
||||
|
||||
### Terminology
|
||||
|
||||
Some important terminology to understand when reading this doc and working with ER is listed below.
|
||||
|
||||
* Entrance rando - sometimes called "room rando," "transition rando," "door rando," or similar,
|
||||
this is a game mode in which the game map itself is randomized.
|
||||
In Archipelago, these things are often represented as `Entrance`s in the region graph, so we call it Entrance rando.
|
||||
* Entrances and exits - entrances are ways into your region, exits are ways out of the region. In code, they are both
|
||||
represented as `Entrance` objects. In this doc, the terms "entrances" and "exits" will be used in this sense; the
|
||||
`Entrance` class will always be referenced in a code block with an uppercase E.
|
||||
* Dead end - a connected group of regions which can never help ER progress. This means that it:
|
||||
* Is not in any indirect conditions/access rules.
|
||||
* Has no plando'd or otherwise preplaced progression items, including events.
|
||||
* Has no randomized exits.
|
||||
* One way transition - a transition that, in the game, is not safe to reverse through (for example, in Hollow Knight,
|
||||
some transitions are inaccessible backwards in vanilla and would put you out of bounds). One way transitions are
|
||||
paired together during randomization to prevent such unsafe game states. Most transitions are not one way.
|
||||
|
||||
### Basic randomization strategy
|
||||
|
||||
The Generic ER algorithm works by using the logic structures you are already familiar with. To give a basic example,
|
||||
let's assume a toy world is defined with the vanilla region graph modeled below. In this diagram, the smaller boxes
|
||||
represent regions while the larger boxes represent scenes. Scenes are not an Archipelago concept, the grouping is
|
||||
purely illustrative.
|
||||
|
||||
```mermaid
|
||||
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||
graph LR
|
||||
subgraph startingRoom [Starting Room]
|
||||
S[Starting Room Right Door]
|
||||
end
|
||||
subgraph sceneB [Scene B]
|
||||
BR1[Scene B Right Door]
|
||||
end
|
||||
subgraph sceneA [Scene A]
|
||||
AL1[Scene A Lower Left Door] <--> AR1[Scene A Right Door]
|
||||
AL2[Scene A Upper Left Door] <--> AR1
|
||||
end
|
||||
subgraph sceneC [Scene C]
|
||||
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
|
||||
CL1 <--> CR2[Scene C Lower Right Door]
|
||||
end
|
||||
subgraph sceneD [Scene D]
|
||||
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
|
||||
end
|
||||
subgraph endingRoom [Ending Room]
|
||||
EL1[Ending Room Upper Left Door] <--> Victory
|
||||
EL2[Ending Room Lower Left Door] <--> Victory
|
||||
end
|
||||
Menu --> S
|
||||
S <--> AL2
|
||||
BR1 <--> AL1
|
||||
AR1 <--> CL1
|
||||
CR1 <--> DL1
|
||||
DR1 <--> EL1
|
||||
CR2 <--> EL2
|
||||
|
||||
classDef hidden display:none;
|
||||
```
|
||||
|
||||
First, the world begins by splitting the `Entrance`s which should be randomized. This is essentially all that has to be
|
||||
done on the world side; calling the `randomize_entrances` function will do the rest, using your region definitions and
|
||||
logic to generate a valid world layout by connecting the partially connected edges you've defined. After you have done
|
||||
that, your region graph might look something like the following diagram. Note how each randomizable entrance/exit pair
|
||||
(represented as a bidirectional arrow) is disconnected on one end.
|
||||
|
||||
> [!NOTE]
|
||||
> It is required to use explicit indirect conditions when using Generic ER. Without this restriction,
|
||||
> Generic ER would have no way to correctly determine that a region may be required in logic,
|
||||
> leading to significantly higher failure rates due to mis-categorized regions.
|
||||
|
||||
```mermaid
|
||||
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||
graph LR
|
||||
subgraph startingRoom [Starting Room]
|
||||
S[Starting Room Right Door]
|
||||
end
|
||||
subgraph sceneA [Scene A]
|
||||
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
|
||||
AL2[Scene A Lower Left Door] <--> AR1
|
||||
end
|
||||
subgraph sceneB [Scene B]
|
||||
BR1[Scene B Right Door]
|
||||
end
|
||||
subgraph sceneC [Scene C]
|
||||
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
|
||||
CL1 <--> CR2[Scene C Lower Right Door]
|
||||
end
|
||||
subgraph sceneD [Scene D]
|
||||
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
|
||||
end
|
||||
subgraph endingRoom [Ending Room]
|
||||
EL1[Ending Room Upper Left Door] <--> Victory
|
||||
EL2[Ending Room Lower Left Door] <--> Victory
|
||||
end
|
||||
Menu --> S
|
||||
S <--> T1:::hidden
|
||||
T2:::hidden <--> AL1
|
||||
T3:::hidden <--> AL2
|
||||
AR1 <--> T5:::hidden
|
||||
BR1 <--> T4:::hidden
|
||||
T6:::hidden <--> CL1
|
||||
CR1 <--> T7:::hidden
|
||||
CR2 <--> T11:::hidden
|
||||
T8:::hidden <--> DL1
|
||||
DR1 <--> T9:::hidden
|
||||
T10:::hidden <--> EL1
|
||||
T12:::hidden <--> EL2
|
||||
|
||||
classDef hidden display:none;
|
||||
```
|
||||
|
||||
From here, you can call the `randomize_entrances` function and Archipelago takes over. Starting from the Menu region,
|
||||
the algorithm will sweep out to find eligible region exits to randomize. It will then select an eligible target entrance
|
||||
and connect them, prioritizing giving access to unvisited regions first until all regions are placed. Once the exit has
|
||||
been connected to the new region, placeholder entrances are deleted. This process is visualized in the diagram below
|
||||
with the newly connected edge highlighted in red.
|
||||
|
||||
```mermaid
|
||||
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||
graph LR
|
||||
subgraph startingRoom [Starting Room]
|
||||
S[Starting Room Right Door]
|
||||
end
|
||||
subgraph sceneA [Scene A]
|
||||
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
|
||||
AL2[Scene A Lower Left Door] <--> AR1
|
||||
end
|
||||
subgraph sceneB [Scene B]
|
||||
BR1[Scene B Right Door]
|
||||
end
|
||||
subgraph sceneC [Scene C]
|
||||
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
|
||||
CL1 <--> CR2[Scene C Lower Right Door]
|
||||
end
|
||||
subgraph sceneD [Scene D]
|
||||
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
|
||||
end
|
||||
subgraph endingRoom [Ending Room]
|
||||
EL1[Ending Room Upper Left Door] <--> Victory
|
||||
EL2[Ending Room Lower Left Door] <--> Victory
|
||||
end
|
||||
Menu --> S
|
||||
S <--> CL1
|
||||
T2:::hidden <--> AL1
|
||||
T3:::hidden <--> AL2
|
||||
AR1 <--> T5:::hidden
|
||||
BR1 <--> T4:::hidden
|
||||
CR1 <--> T7:::hidden
|
||||
CR2 <--> T11:::hidden
|
||||
T8:::hidden <--> DL1
|
||||
DR1 <--> T9:::hidden
|
||||
T10:::hidden <--> EL1
|
||||
T12:::hidden <--> EL2
|
||||
|
||||
classDef hidden display:none;
|
||||
linkStyle 8 stroke:red,stroke-width:5px;
|
||||
```
|
||||
|
||||
This process is then repeated until all disconnected `Entrance`s have been connected or deleted, eventually resulting
|
||||
in a randomized region layout.
|
||||
|
||||
```mermaid
|
||||
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||
graph LR
|
||||
subgraph startingRoom [Starting Room]
|
||||
S[Starting Room Right Door]
|
||||
end
|
||||
subgraph sceneA [Scene A]
|
||||
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
|
||||
AL2[Scene A Lower Left Door] <--> AR1
|
||||
end
|
||||
subgraph sceneB [Scene B]
|
||||
BR1[Scene B Right Door]
|
||||
end
|
||||
subgraph sceneC [Scene C]
|
||||
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
|
||||
CL1 <--> CR2[Scene C Lower Right Door]
|
||||
end
|
||||
subgraph sceneD [Scene D]
|
||||
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
|
||||
end
|
||||
subgraph endingRoom [Ending Room]
|
||||
EL1[Ending Room Upper Left Door] <--> Victory
|
||||
EL2[Ending Room Lower Left Door] <--> Victory
|
||||
end
|
||||
Menu --> S
|
||||
S <--> CL1
|
||||
AR1 <--> DL1
|
||||
BR1 <--> EL2
|
||||
CR1 <--> EL1
|
||||
CR2 <--> AL1
|
||||
DR1 <--> AL2
|
||||
|
||||
classDef hidden display:none;
|
||||
```
|
||||
|
||||
#### ER and minimal accessibility
|
||||
|
||||
In general, even on minimal accessibility, ER will prefer to provide access to as many regions as possible. This is for
|
||||
2 reasons:
|
||||
1. Generally, having items spread across the world is going to be a more fun/engaging experience for players than
|
||||
severely restricting their map. Imagine an ER arrangement with just the start region, the goal region, and exactly
|
||||
enough locations in between them to get the goal - this may be the intuitive behavior of minimal, or even the desired
|
||||
behavior in some cases, but it is not a particularly interesting randomizer.
|
||||
2. Giving access to more of the world will give item fill a higher chance to succeed.
|
||||
|
||||
However, ER will cull unreachable regions and exits if necessary to save the generation of a beaten minimal.
|
||||
|
||||
## Usage
|
||||
|
||||
### Defining entrances to be randomized
|
||||
|
||||
The first step to using generic ER is defining entrances to be randomized. In order to do this, you will need to
|
||||
leave partially disconnected exits without a `target_region` and partially disconnected entrances without a
|
||||
`parent_region`. You can do this either by hand using `region.create_exit` and `region.create_er_target`, or you can
|
||||
create your vanilla region graph and then use `disconnect_entrance_for_randomization` to split the desired edges.
|
||||
If you're not sure which to use, prefer the latter approach as it will automatically satisfy the requirements for
|
||||
coupled randomization (discussed in more depth later).
|
||||
|
||||
> [!TIP]
|
||||
> It's recommended to give your `Entrance`s non-default names when creating them. The default naming scheme is
|
||||
> `f"{parent_region} -> {target_region}"` which is generally not helpful in an entrance rando context - after all,
|
||||
> the target region will not be the same as vanilla and regions are often not user-facing anyway. Instead consider names
|
||||
> that describe the location of the exit, such as "Starting Room Right Door."
|
||||
|
||||
When creating your `Entrance`s you should also set the randomization type and group. One-way `Entrance`s represent
|
||||
transitions which are impossible to traverse in reverse. All other transitions are two-ways. To ensure that all
|
||||
transitions can be accessed in the game, one-ways are only randomized with other one-ways and two-ways are only
|
||||
randomized with other two-ways. You can set whether an `Entrance` is one-way or two-way using the `randomization_type`
|
||||
attribute.
|
||||
|
||||
`Entrance`s can also set the `randomization_group` attribute to allow for grouping during randomization. This can be
|
||||
any integer you define and may be based on player options. Some possible use cases for grouping include:
|
||||
* Directional matching - only match leftward-facing transitions to rightward-facing ones
|
||||
* Terrain matching - only match water transitions to water transitions and land transitions to land transitions
|
||||
* Dungeon shuffle - only shuffle entrances within a dungeon/area with each other
|
||||
* Combinations of the above
|
||||
|
||||
By default, all `Entrance`s are placed in the group 0. An entrance can only be a member of one group, but a given group
|
||||
may connect to many other groups.
|
||||
|
||||
### Calling generic ER
|
||||
|
||||
Once you have defined all your entrances and exits and connected the Menu region to your region graph, you can call
|
||||
`randomize_entrances` to perform randomization.
|
||||
|
||||
#### Coupled and uncoupled modes
|
||||
|
||||
In coupled randomization, an entrance placed from A to B guarantees that the reverse placement B to A also exists
|
||||
(assuming that A and B are both two-way doors). Uncoupled randomization does not make this guarantee.
|
||||
|
||||
When using coupled mode, there are some requirements for how placeholder ER targets for two-ways are named.
|
||||
`disconnect_entrance_for_randomization` will handle this for you. However, if you opt to create your ER targets and
|
||||
exits by hand, you will need to ensure that ER targets into a region are named the same as the exit they correspond to.
|
||||
This allows the randomizer to find and connect the reverse pairing after the first pairing is completed. See the diagram
|
||||
below for an example of incorrect and correct naming.
|
||||
|
||||
Incorrect target naming:
|
||||
|
||||
```mermaid
|
||||
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||
graph LR
|
||||
subgraph a [" "]
|
||||
direction TB
|
||||
target1
|
||||
target2
|
||||
end
|
||||
subgraph b [" "]
|
||||
direction TB
|
||||
Region
|
||||
end
|
||||
Region["Room1"] -->|Room1 Right Door| target1:::hidden
|
||||
Region --- target2:::hidden -->|Room2 Left Door| Region
|
||||
|
||||
linkStyle 1 stroke:none;
|
||||
classDef hidden display:none;
|
||||
style a display:none;
|
||||
style b display:none;
|
||||
```
|
||||
|
||||
Correct target naming:
|
||||
|
||||
```mermaid
|
||||
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
|
||||
graph LR
|
||||
subgraph a [" "]
|
||||
direction TB
|
||||
target1
|
||||
target2
|
||||
end
|
||||
subgraph b [" "]
|
||||
direction TB
|
||||
Region
|
||||
end
|
||||
Region["Room1"] -->|Room1 Right Door| target1:::hidden
|
||||
Region --- target2:::hidden -->|Room1 Right Door| Region
|
||||
|
||||
linkStyle 1 stroke:none;
|
||||
classDef hidden display:none;
|
||||
style a display:none;
|
||||
style b display:none;
|
||||
```
|
||||
|
||||
#### Implementing grouping
|
||||
|
||||
When you created your entrances, you defined the group each entrance belongs to. Now you will have to define how groups
|
||||
should connect with each other. This is done with the `target_group_lookup` and `preserve_group_order` parameters.
|
||||
There is also a convenience function `bake_target_group_lookup` which can help to prepare group lookups when more
|
||||
complex group mapping logic is needed. Some recipes for `target_group_lookup` are presented here.
|
||||
|
||||
For the recipes below, assume the following groups (if the syntax used here is unfamiliar to you, "bit masking" and
|
||||
"bitwise operators" would be the terms to search for):
|
||||
```python
|
||||
class Groups(IntEnum):
|
||||
# Directions
|
||||
LEFT = 1
|
||||
RIGHT = 2
|
||||
TOP = 3
|
||||
BOTTOM = 4
|
||||
DOOR = 5
|
||||
# Areas
|
||||
FIELD = 1 << 3
|
||||
CAVE = 2 << 3
|
||||
MOUNTAIN = 3 << 3
|
||||
# Bitmasks
|
||||
DIRECTION_MASK = FIELD - 1
|
||||
AREA_MASK = ~0 << 3
|
||||
```
|
||||
|
||||
Directional matching:
|
||||
```python
|
||||
direction_matching_group_lookup = {
|
||||
# with preserve_group_order = False, pair a left transition to either a right transition or door randomly
|
||||
# with preserve_group_order = True, pair a left transition to a right transition, or else a door if no
|
||||
# viable right transitions remain
|
||||
Groups.LEFT: [Groups.RIGHT, Groups.DOOR],
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
Terrain matching or dungeon shuffle:
|
||||
```python
|
||||
def randomize_within_same_group(group: int) -> List[int]:
|
||||
return [group]
|
||||
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
|
||||
```
|
||||
|
||||
Directional + area shuffle:
|
||||
```python
|
||||
def get_target_groups(group: int) -> List[int]:
|
||||
# example group: LEFT | CAVE
|
||||
# example result: [RIGHT | CAVE, DOOR | CAVE]
|
||||
direction = group & Groups.DIRECTION_MASK
|
||||
area = group & Groups.AREA_MASK
|
||||
return [pair_direction | area for pair_direction in direction_matching_group_lookup[direction]]
|
||||
target_group_lookup = bake_target_group_lookup(world, get_target_groups)
|
||||
```
|
||||
|
||||
#### When to call `randomize_entrances`
|
||||
|
||||
The correct step for this is `World.connect_entrances`.
|
||||
|
||||
Currently, you could theoretically do it as early as `World.create_regions` or as late as `pre_fill`.
|
||||
However, there are upcoming changes to Item Plando and Generic Entrance Randomizer to make the two features work better
|
||||
together.
|
||||
These changes necessitate that entrance randomization is done exactly in `World.connect_entrances`.
|
||||
It is fine for your Entrances to be connected differently or not at all before this step.
|
||||
|
||||
#### Informing your client about randomized entrances
|
||||
|
||||
`randomize_entrances` returns the completed `ERPlacementState`. The `pairings` attribute contains a list of the
|
||||
created placements by name which can be used to populate slot data.
|
||||
|
||||
### Imposing custom constraints on randomization
|
||||
|
||||
Generic ER is, as the name implies, generic! That means that your world may have some use case which is not covered by
|
||||
the ER implementation. To solve this, you can create a custom `Entrance` class which provides custom implementations
|
||||
for `is_valid_source_transition` and `can_connect_to`. These allow arbitrary constraints to be implemented on
|
||||
randomization, for instance helping to prevent restrictive sphere 1s or ensuring a maximum distance from a "hub" region.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> When implementing these functions, make sure to use `super().is_valid_source_transition` and `super().can_connect_to`
|
||||
> as part of your implementation. Otherwise ER may behave unexpectedly.
|
||||
|
||||
## Implementation details
|
||||
|
||||
This section is a medium-level explainer of the implementation of ER for those who don't want to decipher the code.
|
||||
However, a basic understanding of the mechanics of `fill_restrictive` will be helpful as many of the underlying
|
||||
algorithms are shared
|
||||
|
||||
ER uses a forward fill approach to create the region layout. First, ER collects `all_state` and performs a region sweep
|
||||
from Menu, similar to fill. ER then proceeds in stages to complete the randomization:
|
||||
1. Attempt to connect all non-dead-end regions, prioritizing access to unseen regions so there will always be new exits
|
||||
to pair off.
|
||||
2. Attempt to connect all dead-end regions, so that all regions will be placed
|
||||
3. Connect all remaining dangling edges now that all regions are placed.
|
||||
1. Connect any other dead end entrances (e.g. second entrances to the same dead end regions).
|
||||
2. Connect all remaining non-dead-ends amongst each other.
|
||||
|
||||
The process for each connection will do the following:
|
||||
1. Select a randomizable exit of a reachable region which is a valid source transition.
|
||||
2. Get its group and check `target_group_lookup` to determine which groups are valid targets.
|
||||
3. Look up ER targets from those groups and find one which is valid according to `can_connect_to`
|
||||
4. Connect the source exit to the target's target_region and delete the target.
|
||||
* In stage 1, before placing the last valid source transition, an additional speculative sweep is performed to ensure
|
||||
that there will be an available exit after the placement so randomization can continue.
|
||||
5. If it's coupled mode, find the reverse exit and target by name and connect them as well.
|
||||
6. Sweep to update reachable regions.
|
||||
7. Call the `on_connect` callback.
|
||||
|
||||
This process repeats until the stage is complete, no valid source transition is found, or no valid target transition is
|
||||
found for any source transition. Unlike fill, there is no attempt made to save a failed randomization.
|
||||
@@ -47,6 +47,9 @@ Packets are simple JSON lists in which any number of ordered network commands ca
|
||||
|
||||
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
|
||||
|
||||
Websocket connections should support per-message compression. Uncompressed connections are deprecated and may stop
|
||||
working in the future.
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
|
||||
@@ -261,6 +264,7 @@ Sent to clients in response to a [Set](#Set) package if want_reply was set to tr
|
||||
| key | str | The key that was updated. |
|
||||
| value | any | The new value for the key. |
|
||||
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
|
||||
| slot | int | The slot that originally sent the Set package causing this change. |
|
||||
|
||||
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
|
||||
|
||||
@@ -359,11 +363,11 @@ An enumeration containing the possible hint states.
|
||||
```python
|
||||
import enum
|
||||
class HintStatus(enum.IntEnum):
|
||||
HINT_FOUND = 0 # The location has been collected. Status cannot be changed once found.
|
||||
HINT_UNSPECIFIED = 1 # The receiving player has not specified any status
|
||||
HINT_UNSPECIFIED = 0 # The receiving player has not specified any status
|
||||
HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded
|
||||
HINT_AVOID = 20 # The receiving player has specified that the item is detrimental
|
||||
HINT_PRIORITY = 30 # The receiving player has specified that the item is needed
|
||||
HINT_FOUND = 40 # The location has been collected. Status cannot be changed once found.
|
||||
```
|
||||
- Hints for items with `ItemClassification.trap` default to `HINT_AVOID`.
|
||||
- Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`.
|
||||
@@ -529,9 +533,9 @@ In JSON this may look like:
|
||||
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
||||
]
|
||||
```
|
||||
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
|
||||
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||
|
||||
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
||||
|
||||
@@ -540,7 +544,7 @@ In JSON this may look like:
|
||||
| ----- | ----- |
|
||||
| 0 | Nothing special about this item |
|
||||
| 0b001 | If set, indicates the item can unlock logical advancement |
|
||||
| 0b010 | If set, indicates the item is important but not in a way that unlocks advancement |
|
||||
| 0b010 | If set, indicates the item is especially useful |
|
||||
| 0b100 | If set, indicates the item is a trap |
|
||||
|
||||
### JSONMessagePart
|
||||
@@ -554,6 +558,7 @@ class JSONMessagePart(TypedDict):
|
||||
color: Optional[str] # only available if type is a color
|
||||
flags: Optional[int] # only available if type is an item_id or item_name
|
||||
player: Optional[int] # only available if type is either item or location
|
||||
hint_status: Optional[HintStatus] # only available if type is hint_status
|
||||
```
|
||||
|
||||
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
|
||||
@@ -569,6 +574,7 @@ Possible values for `type` include:
|
||||
| location_id | Location ID, should be resolved to Location Name |
|
||||
| location_name | Location Name, not currently used over network, but supported by reference Clients. |
|
||||
| entrance_name | Entrance Name. No ID mapping exists. |
|
||||
| hint_status | The [HintStatus](#HintStatus) of the hint. Both `text` and `hint_status` are given. |
|
||||
| color | Regular text that should be colored. Only `type` that will contain `color` data. |
|
||||
|
||||
|
||||
@@ -742,6 +748,7 @@ Tags are represented as a list of strings, the common client tags follow:
|
||||
| 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.² |
|
||||
| NoText | Indicates the client does not want to receive text messages, improving performance if not needed. |
|
||||
|
||||
¹: 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.
|
||||
|
||||
@@ -95,7 +95,7 @@ user hovers over the yellow "(?)" icon, and included in the YAML templates gener
|
||||
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
|
||||
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
|
||||
default for backwards compatibility, world authors are encouraged to write their Option documentation as
|
||||
reStructuredText and enable rich text rendering by setting `World.rich_text_options_doc = True`.
|
||||
reStructuredText and enable rich text rendering by setting `WebWorld.rich_text_options_doc = True`.
|
||||
|
||||
[reStructuredText]: https://docutils.sourceforge.io/rst.html
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ Recommended steps
|
||||
[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
|
||||
* In PyCharm: right-click Generate.py and select `Run 'Generate'`
|
||||
* Without PyCharm: open a command prompt in the source folder and type `py Generate.py`
|
||||
* Run ModuleUpdate.py which will prompt installation of missing modules, press enter to confirm
|
||||
* In PyCharm: right-click ModuleUpdate.py and select `Run 'ModuleUpdate'`
|
||||
* Without PyCharm: open a command prompt in the source folder and type `py ModuleUpdate.py`
|
||||
|
||||
|
||||
## macOS
|
||||
|
||||
@@ -222,8 +222,8 @@ could also be progress in a research tree, or even something more abstract like
|
||||
|
||||
Each location has a `name` and an `address` (hereafter referred to as an `id`), is placed in a Region, has access rules,
|
||||
and has a classification. The name needs to be unique within each game and must not be numeric (must contain least 1
|
||||
letter or symbol). The ID needs to be unique across all games, and is best kept in the same range as the item IDs.
|
||||
Locations and items can share IDs, so typically a game's locations and items start at the same ID.
|
||||
letter or symbol). The ID needs to be unique across all locations within the game.
|
||||
Locations and items can share IDs, and locations can share IDs with other games' locations.
|
||||
|
||||
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
|
||||
|
||||
@@ -243,12 +243,15 @@ progression. Progression items will be assigned to locations with higher priorit
|
||||
and satisfy progression balancing.
|
||||
|
||||
The name needs to be unique within each game, meaning if you need to create multiple items with the same name, they
|
||||
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
|
||||
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
|
||||
The ID thus also needs to be unique across all items with different names within the game.
|
||||
Items and locations can share IDs, and items can share IDs with other games' items.
|
||||
|
||||
Other classifications include:
|
||||
|
||||
* `filler`: a regular item or trash item
|
||||
* `useful`: generally quite useful, but not required for anything logical. Cannot be placed on excluded locations
|
||||
* `useful`: item that is especially useful. Cannot be placed on excluded or unreachable locations. When combined with
|
||||
another flag like "progression", it means "an especially useful progression item".
|
||||
* `trap`: negative impact on the player
|
||||
* `skip_balancing`: denotes that an item should not be moved to an earlier sphere for the purpose of balancing (to be
|
||||
combined with `progression`; see below)
|
||||
@@ -489,6 +492,9 @@ In addition, the following methods can be implemented and are called in this ord
|
||||
after this step. Locations cannot be moved to different regions after this step.
|
||||
* `set_rules(self)`
|
||||
called to set access and item rules on locations and entrances.
|
||||
* `connect_entrances(self)`
|
||||
by the end of this step, all entrances must exist and be connected to their source and target regions.
|
||||
Entrance randomization should be done here.
|
||||
* `generate_basic(self)`
|
||||
player-specific randomization that does not affect logic can be done here.
|
||||
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)`
|
||||
@@ -556,17 +562,13 @@ from .items import is_progression # this is just a dummy
|
||||
|
||||
def create_item(self, item: str) -> MyGameItem:
|
||||
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code
|
||||
classification = ItemClassification.progression if is_progression(item) else
|
||||
ItemClassification.filler
|
||||
|
||||
|
||||
return MyGameItem(item, classification, self.item_name_to_id[item],
|
||||
self.player)
|
||||
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
|
||||
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
|
||||
|
||||
|
||||
def create_event(self, event: str) -> MyGameItem:
|
||||
# while we are at it, we can also add a helper to create events
|
||||
return MyGameItem(event, True, None, self.player)
|
||||
return MyGameItem(event, ItemClassification.progression, None, self.player)
|
||||
```
|
||||
|
||||
#### create_items
|
||||
@@ -699,9 +701,92 @@ When importing a file that defines a class that inherits from `worlds.AutoWorld.
|
||||
is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing
|
||||
world since the namespace is shared with all other logic mixins.
|
||||
|
||||
Some uses could be to add additional variables to the state object, or to have a custom state machine that gets modified
|
||||
with the state.
|
||||
Please do this with caution and only when necessary.
|
||||
LogicMixin is handy when your logic is more complex than one-to-one location-item relationships.
|
||||
A game in which "The red key opens the red door" can just express this relationship through a one-line access rule.
|
||||
But now, consider a game with a heavy focus on combat, where the main logical consideration is which enemies you can
|
||||
defeat with your current items.
|
||||
There could be dozens of weapons, armor pieces, or consumables that each improve your ability to defeat
|
||||
specific enemies to varying degrees. It would be useful to be able to keep track of "defeatable enemies" as a state variable,
|
||||
and have this variable be recalculated as necessary based on newly collected/removed items.
|
||||
This is the capability of LogicMixin: Adding custom variables to state that get recalculated as necessary.
|
||||
|
||||
In general, a LogicMixin class should have at least one mutable variable that is tracking some custom state per player,
|
||||
as well as `init_mixin` and `copy_mixin` functions so that this variable gets initialized and copied correctly when
|
||||
`CollectionState()` and `CollectionState.copy()` are called respectively.
|
||||
|
||||
```python
|
||||
from BaseClasses import CollectionState, MultiWorld
|
||||
from worlds.AutoWorld import LogicMixin
|
||||
|
||||
class MyGameState(LogicMixin):
|
||||
mygame_defeatable_enemies: Dict[int, Set[str]] # per player
|
||||
|
||||
def init_mixin(self, multiworld: MultiWorld) -> None:
|
||||
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
|
||||
# You can also use something like Collections.defaultdict
|
||||
self.mygame_defeatable_enemies = {
|
||||
player: set() for player in multiworld.get_game_players("My Game")
|
||||
}
|
||||
|
||||
def copy_mixin(self, new_state: CollectionState) -> CollectionState:
|
||||
# Be careful to make a "deep enough" copy here!
|
||||
new_state.mygame_defeatable_enemies = {
|
||||
player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items()
|
||||
}
|
||||
```
|
||||
|
||||
After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules.
|
||||
|
||||
Usually, doing this coincides with an override of `World.collect` and `World.remove`, where the custom state variable
|
||||
gets recalculated when a relevant item is collected or removed.
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
|
||||
def collect(self, state: CollectionState, item: Item) -> bool:
|
||||
change = super().collect(state, item)
|
||||
if change and item in COMBAT_ITEMS:
|
||||
state.mygame_defeatable_enemies[self.player] |= get_newly_unlocked_enemies(state)
|
||||
return change
|
||||
|
||||
def remove(self, state: CollectionState, item: Item) -> bool:
|
||||
change = super().remove(state, item)
|
||||
if change and item in COMBAT_ITEMS:
|
||||
state.mygame_defeatable_enemies[self.player] -= get_newly_locked_enemies(state)
|
||||
return change
|
||||
```
|
||||
|
||||
Using LogicMixin can greatly slow down your code if you don't use it intelligently. This is because `collect`
|
||||
and `remove` are called very frequently during fill. If your `collect` & `remove` cause a heavy calculation
|
||||
every time, your code might end up being *slower* than just doing calculations in your access rules.
|
||||
|
||||
One way to optimise recalculations is to make use of the fact that `collect` should only unlock things,
|
||||
and `remove` should only lock things.
|
||||
In our example, we have two different functions: `get_newly_unlocked_enemies` and `get_newly_locked_enemies`.
|
||||
`get_newly_unlocked_enemies` should only consider enemies that are *not already in the set*
|
||||
and check whether they were **unlocked**.
|
||||
`get_newly_locked_enemies` should only consider enemies that are *already in the set*
|
||||
and check whether they **became locked**.
|
||||
|
||||
Another impactful way to optimise LogicMixin is to use caching.
|
||||
Your custom state variables don't actually need to be recalculated on every `collect` / `remove`, because there are
|
||||
often multiple calls to `collect` / `remove` between access rule calls. Thus, it would be much more efficient to hold
|
||||
off on recaculating until the an actual access rule call happens.
|
||||
A common way to realize this is to define a `mygame_state_is_stale` variable that is set to True in `collect`, `remove`,
|
||||
and `init_mixin`. The calls to the actual recalculating functions are then moved to the start of the relevant
|
||||
access rules like this:
|
||||
|
||||
```python
|
||||
def can_defeat_enemy(state: CollectionState, player: int, enemy: str) -> bool:
|
||||
if state.mygame_state_is_stale[player]:
|
||||
state.mygame_defeatable_enemies[player] = recalculate_defeatable_enemies(state)
|
||||
state.mygame_state_is_stale[player] = False
|
||||
|
||||
return enemy in state.mygame_defeatable_enemies[player]
|
||||
```
|
||||
|
||||
Only use LogicMixin if necessary. There are often other ways to achieve what it does, like making clever use of
|
||||
`state.prog_items`, using event items, pseudo-regions, etc.
|
||||
|
||||
#### pre_fill
|
||||
|
||||
@@ -751,14 +836,16 @@ def generate_output(self, output_directory: str) -> None:
|
||||
|
||||
### Slot Data
|
||||
|
||||
If the game client needs to know information about the generated seed, a preferred method of transferring the data
|
||||
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning
|
||||
a `dict` with `str` keys that can be serialized with json.
|
||||
But, to not waste resources, it should be limited to data that is absolutely necessary. Slot data is sent to your client
|
||||
once it has successfully [connected](network%20protocol.md#connected).
|
||||
If a client or tracker needs to know information about the generated seed, a preferred method of transferring the data
|
||||
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning a `dict` with
|
||||
`str` keys that can be serialized with json. However, to not waste resources, it should be limited to data that is
|
||||
absolutely necessary. Slot data is sent to your client once it has successfully
|
||||
[connected](network%20protocol.md#connected).
|
||||
|
||||
If you need to know information about locations in your world, instead of propagating the slot data, it is preferable
|
||||
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. The most
|
||||
common usage of slot data is sending option results that the client needs to be aware of.
|
||||
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. Adding
|
||||
item/location pairs is unnecessary since the AP server already retains and freely gives that information to clients
|
||||
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
|
||||
|
||||
```python
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
|
||||
448
entrance_rando.py
Normal file
448
entrance_rando.py
Normal file
@@ -0,0 +1,448 @@
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from BaseClasses import CollectionState, Entrance, Region, EntranceType
|
||||
from Options import Accessibility
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
|
||||
class EntranceRandomizationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class EntranceLookup:
|
||||
class GroupLookup:
|
||||
_lookup: dict[int, list[Entrance]]
|
||||
|
||||
def __init__(self):
|
||||
self._lookup = {}
|
||||
|
||||
def __len__(self):
|
||||
return sum(map(len, self._lookup.values()))
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self._lookup)
|
||||
|
||||
def __getitem__(self, item: int) -> list[Entrance]:
|
||||
return self._lookup.get(item, [])
|
||||
|
||||
def __iter__(self):
|
||||
return itertools.chain.from_iterable(self._lookup.values())
|
||||
|
||||
def __repr__(self):
|
||||
return str(self._lookup)
|
||||
|
||||
def add(self, entrance: Entrance) -> None:
|
||||
self._lookup.setdefault(entrance.randomization_group, []).append(entrance)
|
||||
|
||||
def remove(self, entrance: Entrance) -> None:
|
||||
group = self._lookup[entrance.randomization_group]
|
||||
group.remove(entrance)
|
||||
if not group:
|
||||
del self._lookup[entrance.randomization_group]
|
||||
|
||||
dead_ends: GroupLookup
|
||||
others: GroupLookup
|
||||
_random: random.Random
|
||||
_expands_graph_cache: dict[Entrance, bool]
|
||||
_coupled: bool
|
||||
|
||||
def __init__(self, rng: random.Random, coupled: bool):
|
||||
self.dead_ends = EntranceLookup.GroupLookup()
|
||||
self.others = EntranceLookup.GroupLookup()
|
||||
self._random = rng
|
||||
self._expands_graph_cache = {}
|
||||
self._coupled = coupled
|
||||
|
||||
def _can_expand_graph(self, entrance: Entrance) -> bool:
|
||||
"""
|
||||
Checks whether an entrance is able to expand the region graph, either by
|
||||
providing access to randomizable exits or by granting access to items or
|
||||
regions used in logic conditions.
|
||||
|
||||
:param entrance: A randomizable (no parent) region entrance
|
||||
"""
|
||||
# we've seen this, return cached result
|
||||
if entrance in self._expands_graph_cache:
|
||||
return self._expands_graph_cache[entrance]
|
||||
|
||||
visited = set()
|
||||
q: deque[Region] = deque()
|
||||
q.append(entrance.connected_region)
|
||||
|
||||
while q:
|
||||
region = q.popleft()
|
||||
visited.add(region)
|
||||
|
||||
# check if the region itself is progression
|
||||
if region in region.multiworld.indirect_connections:
|
||||
self._expands_graph_cache[entrance] = True
|
||||
return True
|
||||
|
||||
# check if any placed locations are progression
|
||||
for loc in region.locations:
|
||||
if loc.advancement:
|
||||
self._expands_graph_cache[entrance] = True
|
||||
return True
|
||||
|
||||
# check if there is a randomized exit out (expands the graph directly) or else search any connected
|
||||
# regions to see if they are/have progression
|
||||
for exit_ in region.exits:
|
||||
# randomizable exits which are not reverse of the incoming entrance.
|
||||
# uncoupled mode is an exception because in this case going back in the door you just came in could
|
||||
# actually lead somewhere new
|
||||
if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name):
|
||||
self._expands_graph_cache[entrance] = True
|
||||
return True
|
||||
elif exit_.connected_region and exit_.connected_region not in visited:
|
||||
q.append(exit_.connected_region)
|
||||
|
||||
self._expands_graph_cache[entrance] = False
|
||||
return False
|
||||
|
||||
def add(self, entrance: Entrance) -> None:
|
||||
lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends
|
||||
lookup.add(entrance)
|
||||
|
||||
def remove(self, entrance: Entrance) -> None:
|
||||
lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends
|
||||
lookup.remove(entrance)
|
||||
|
||||
def get_targets(
|
||||
self,
|
||||
groups: Iterable[int],
|
||||
dead_end: bool,
|
||||
preserve_group_order: bool
|
||||
) -> Iterable[Entrance]:
|
||||
|
||||
lookup = self.dead_ends if dead_end else self.others
|
||||
if preserve_group_order:
|
||||
for group in groups:
|
||||
self._random.shuffle(lookup[group])
|
||||
ret = [entrance for group in groups for entrance in lookup[group]]
|
||||
else:
|
||||
ret = [entrance for group in groups for entrance in lookup[group]]
|
||||
self._random.shuffle(ret)
|
||||
return ret
|
||||
|
||||
def __len__(self):
|
||||
return len(self.dead_ends) + len(self.others)
|
||||
|
||||
|
||||
class ERPlacementState:
|
||||
"""The state of an ongoing or completed entrance randomization"""
|
||||
placements: list[Entrance]
|
||||
"""The list of randomized Entrance objects which have been connected successfully"""
|
||||
pairings: list[tuple[str, str]]
|
||||
"""A list of pairings of connected entrance names, of the form (source_exit, target_entrance)"""
|
||||
world: World
|
||||
"""The world which is having its entrances randomized"""
|
||||
collection_state: CollectionState
|
||||
"""The CollectionState backing the entrance randomization logic"""
|
||||
coupled: bool
|
||||
"""Whether entrance randomization is operating in coupled mode"""
|
||||
|
||||
def __init__(self, world: World, coupled: bool):
|
||||
self.placements = []
|
||||
self.pairings = []
|
||||
self.world = world
|
||||
self.coupled = coupled
|
||||
self.collection_state = world.multiworld.get_all_state(False, True)
|
||||
|
||||
@property
|
||||
def placed_regions(self) -> set[Region]:
|
||||
return self.collection_state.reachable_regions[self.world.player]
|
||||
|
||||
def find_placeable_exits(self, check_validity: bool) -> list[Entrance]:
|
||||
if check_validity:
|
||||
blocked_connections = self.collection_state.blocked_connections[self.world.player]
|
||||
blocked_connections = sorted(blocked_connections, key=lambda x: x.name)
|
||||
placeable_randomized_exits = [connection for connection in blocked_connections
|
||||
if not connection.connected_region
|
||||
and connection.is_valid_source_transition(self)]
|
||||
else:
|
||||
# this is on a beaten minimal attempt, so any exit anywhere is fair game
|
||||
placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player)
|
||||
for ex in region.exits if not ex.connected_region]
|
||||
self.world.random.shuffle(placeable_randomized_exits)
|
||||
return placeable_randomized_exits
|
||||
|
||||
def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None:
|
||||
target_region = target_entrance.connected_region
|
||||
|
||||
target_region.entrances.remove(target_entrance)
|
||||
source_exit.connect(target_region)
|
||||
|
||||
self.collection_state.stale[self.world.player] = True
|
||||
self.placements.append(source_exit)
|
||||
self.pairings.append((source_exit.name, target_entrance.name))
|
||||
|
||||
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool:
|
||||
copied_state = self.collection_state.copy()
|
||||
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
|
||||
# propagate back to the real multiworld.
|
||||
copied_state.reachable_regions[self.world.player].add(target_entrance.connected_region)
|
||||
copied_state.blocked_connections[self.world.player].remove(source_exit)
|
||||
copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits)
|
||||
copied_state.update_reachable_regions(self.world.player)
|
||||
copied_state.sweep_for_advancements()
|
||||
# test that at there are newly reachable randomized exits that are ACTUALLY reachable
|
||||
available_randomized_exits = copied_state.blocked_connections[self.world.player]
|
||||
for _exit in available_randomized_exits:
|
||||
if _exit.connected_region:
|
||||
continue
|
||||
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
|
||||
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
|
||||
continue
|
||||
# technically this should be is_valid_source_transition, but that may rely on side effects from
|
||||
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
|
||||
# not want them to persist). can_reach is a close enough approximation most of the time.
|
||||
if _exit.can_reach(copied_state):
|
||||
return True
|
||||
return False
|
||||
|
||||
def connect(
|
||||
self,
|
||||
source_exit: Entrance,
|
||||
target_entrance: Entrance
|
||||
) -> tuple[list[Entrance], list[Entrance]]:
|
||||
"""
|
||||
Connects a source exit to a target entrance in the graph, accounting for coupling
|
||||
|
||||
:returns: The newly placed exits and the dummy entrance(s) which were removed from the graph
|
||||
"""
|
||||
source_region = source_exit.parent_region
|
||||
target_region = target_entrance.connected_region
|
||||
|
||||
self._connect_one_way(source_exit, target_entrance)
|
||||
# if we're doing coupled randomization place the reverse transition as well.
|
||||
if self.coupled and source_exit.randomization_type == EntranceType.TWO_WAY:
|
||||
for reverse_entrance in source_region.entrances:
|
||||
if reverse_entrance.name == source_exit.name:
|
||||
if reverse_entrance.parent_region:
|
||||
raise EntranceRandomizationError(
|
||||
f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} "
|
||||
f"because the reverse entrance is already parented to "
|
||||
f"{reverse_entrance.parent_region.name}.")
|
||||
break
|
||||
else:
|
||||
raise EntranceRandomizationError(f"Two way exit {source_exit.name} had no corresponding entrance in "
|
||||
f"{source_exit.parent_region.name}")
|
||||
for reverse_exit in target_region.exits:
|
||||
if reverse_exit.name == target_entrance.name:
|
||||
if reverse_exit.connected_region:
|
||||
raise EntranceRandomizationError(
|
||||
f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} "
|
||||
f"because the reverse exit is already connected to "
|
||||
f"{reverse_exit.connected_region.name}.")
|
||||
break
|
||||
else:
|
||||
raise EntranceRandomizationError(f"Two way entrance {target_entrance.name} had no corresponding exit "
|
||||
f"in {target_region.name}.")
|
||||
self._connect_one_way(reverse_exit, reverse_entrance)
|
||||
return [source_exit, reverse_exit], [target_entrance, reverse_entrance]
|
||||
return [source_exit], [target_entrance]
|
||||
|
||||
|
||||
def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], list[int]]) \
|
||||
-> dict[int, list[int]]:
|
||||
"""
|
||||
Applies a transformation to all known entrance groups on randomizable exists to build a group lookup table.
|
||||
|
||||
:param world: Your World instance
|
||||
:param get_target_groups: Function to call that returns the groups that a specific group type is allowed to
|
||||
connect to
|
||||
"""
|
||||
unique_groups = { entrance.randomization_group for entrance in world.multiworld.get_entrances(world.player)
|
||||
if entrance.parent_region and not entrance.connected_region }
|
||||
return { group: get_target_groups(group) for group in unique_groups }
|
||||
|
||||
|
||||
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None:
|
||||
"""
|
||||
Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization
|
||||
in randomize_entrances. This should be done after setting the type and group of the entrance.
|
||||
|
||||
:param entrance: The entrance which will be disconnected in preparation for randomization.
|
||||
:param target_group: The group to assign to the created ER target. If not specified, the group from
|
||||
the original entrance will be copied.
|
||||
"""
|
||||
child_region = entrance.connected_region
|
||||
parent_region = entrance.parent_region
|
||||
|
||||
# disconnect the edge
|
||||
child_region.entrances.remove(entrance)
|
||||
entrance.connected_region = None
|
||||
|
||||
# create the needed ER target
|
||||
if entrance.randomization_type == EntranceType.TWO_WAY:
|
||||
# for 2-ways, create a target in the parent region with a matching name to support coupling.
|
||||
# targets in the child region will be created when the other direction edge is disconnected
|
||||
target = parent_region.create_er_target(entrance.name)
|
||||
else:
|
||||
# for 1-ways, the child region needs a target and coupling/naming is not a concern
|
||||
target = child_region.create_er_target(child_region.name)
|
||||
target.randomization_type = entrance.randomization_type
|
||||
target.randomization_group = target_group or entrance.randomization_group
|
||||
|
||||
|
||||
def randomize_entrances(
|
||||
world: World,
|
||||
coupled: bool,
|
||||
target_group_lookup: dict[int, list[int]],
|
||||
preserve_group_order: bool = False,
|
||||
er_targets: list[Entrance] | None = None,
|
||||
exits: list[Entrance] | None = None,
|
||||
on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None
|
||||
) -> ERPlacementState:
|
||||
"""
|
||||
Randomizes Entrances for a single world in the multiworld.
|
||||
|
||||
:param world: Your World instance
|
||||
:param coupled: Whether connected entrances should be coupled to go in both directions
|
||||
:param target_group_lookup: Map from each group to a list of the groups that it can be connect to. Every group
|
||||
used on an exit must be provided and must map to at least one other group. The default
|
||||
group is 0.
|
||||
:param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups
|
||||
:param er_targets: The list of ER targets (Entrance objects with no parent region) to use for randomization.
|
||||
Remember to be deterministic! If not provided, automatically discovers all valid targets
|
||||
in your world.
|
||||
:param exits: The list of exits (Entrance objects with no target region) to use for randomization.
|
||||
Remember to be deterministic! If not provided, automatically discovers all valid exits in your world.
|
||||
:param on_connect: A callback function which allows specifying side effects after a placement is completed
|
||||
successfully and the underlying collection state has been updated.
|
||||
"""
|
||||
if not world.explicit_indirect_conditions:
|
||||
raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order "
|
||||
+ "to correctly analyze whether dead end regions can be required in logic.")
|
||||
|
||||
start_time = time.perf_counter()
|
||||
er_state = ERPlacementState(world, coupled)
|
||||
entrance_lookup = EntranceLookup(world.random, coupled)
|
||||
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
||||
perform_validity_check = True
|
||||
|
||||
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
||||
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
||||
# remove the placed targets from consideration
|
||||
for entrance in removed_entrances:
|
||||
entrance_lookup.remove(entrance)
|
||||
# propagate new connections
|
||||
er_state.collection_state.update_reachable_regions(world.player)
|
||||
er_state.collection_state.sweep_for_advancements()
|
||||
if on_connect:
|
||||
on_connect(er_state, placed_exits)
|
||||
|
||||
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
|
||||
nonlocal perform_validity_check
|
||||
placeable_exits = er_state.find_placeable_exits(perform_validity_check)
|
||||
for source_exit in placeable_exits:
|
||||
target_groups = target_group_lookup[source_exit.randomization_group]
|
||||
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
||||
# when requiring new exits, ideally we would like to make it so that every placement increases
|
||||
# (or keeps the same number of) reachable exits. The goal is to continue to expand the search space
|
||||
# so that we do not crash. In the interest of performance and bias reduction, generally, just checking
|
||||
# that we are going to a new region is a good approximation. however, we should take extra care on the
|
||||
# very last exit and check whatever exits we open up are functionally accessible.
|
||||
# this requirement can be ignored on a beaten minimal, islands are no issue there.
|
||||
exit_requirement_satisfied = (not perform_validity_check or not require_new_exits
|
||||
or target_entrance.connected_region not in er_state.placed_regions)
|
||||
needs_speculative_sweep = (not dead_end and require_new_exits and perform_validity_check
|
||||
and len(placeable_exits) == 1)
|
||||
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
|
||||
if (needs_speculative_sweep
|
||||
and not er_state.test_speculative_connection(source_exit, target_entrance)):
|
||||
continue
|
||||
do_placement(source_exit, target_entrance)
|
||||
return True
|
||||
else:
|
||||
# no source exits had any valid target so this stage is deadlocked. retries may be implemented if early
|
||||
# deadlocking is a frequent issue.
|
||||
lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others
|
||||
|
||||
# if we're in a stage where we're trying to get to new regions, we could also enter this
|
||||
# branch in a success state (when all regions of the preferred type have been placed, but there are still
|
||||
# additional unplaced entrances into those regions)
|
||||
if require_new_exits:
|
||||
if all(e.connected_region in er_state.placed_regions for e in lookup):
|
||||
return False
|
||||
|
||||
# if we're on minimal accessibility and can guarantee the game is beatable,
|
||||
# we can prevent a failure by bypassing future validity checks. this check may be
|
||||
# expensive; fortunately we only have to do it once
|
||||
if perform_validity_check and world.options.accessibility == Accessibility.option_minimal \
|
||||
and world.multiworld.has_beaten_game(er_state.collection_state, world.player):
|
||||
# ensure that we have enough locations to place our progression
|
||||
accessible_location_count = 0
|
||||
prog_item_count = len([item for item in world.multiworld.itempool if item.advancement and item.player == world.player])
|
||||
# short-circuit location checking in this case
|
||||
if prog_item_count == 0:
|
||||
return True
|
||||
for region in er_state.placed_regions:
|
||||
for loc in region.locations:
|
||||
if not loc.item and loc.can_reach(er_state.collection_state):
|
||||
# don't count locations with preplaced items
|
||||
accessible_location_count += 1
|
||||
if accessible_location_count >= prog_item_count:
|
||||
perform_validity_check = False
|
||||
# pretend that this was successful to retry the current stage
|
||||
return True
|
||||
|
||||
unplaced_entrances = [entrance for region in world.multiworld.get_regions(world.player)
|
||||
for entrance in region.entrances if not entrance.parent_region]
|
||||
unplaced_exits = [exit_ for region in world.multiworld.get_regions(world.player)
|
||||
for exit_ in region.exits if not exit_.connected_region]
|
||||
entrance_kind = "dead ends" if dead_end else "non-dead ends"
|
||||
region_access_requirement = "requires" if require_new_exits else "does not require"
|
||||
raise EntranceRandomizationError(
|
||||
f"None of the available entrances are valid targets for the available exits.\n"
|
||||
f"Randomization stage is placing {entrance_kind} and {region_access_requirement} "
|
||||
f"new region/exit access by default\n"
|
||||
f"Placeable entrances: {lookup}\n"
|
||||
f"Placeable exits: {placeable_exits}\n"
|
||||
f"All unplaced entrances: {unplaced_entrances}\n"
|
||||
f"All unplaced exits: {unplaced_exits}")
|
||||
|
||||
if not er_targets:
|
||||
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
|
||||
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
|
||||
if not exits:
|
||||
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
|
||||
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
|
||||
if len(er_targets) != len(exits):
|
||||
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
|
||||
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
|
||||
for entrance in er_targets:
|
||||
entrance_lookup.add(entrance)
|
||||
|
||||
# place the menu region and connected start region(s)
|
||||
er_state.collection_state.update_reachable_regions(world.player)
|
||||
|
||||
# stage 1 - try to place all the non-dead-end entrances
|
||||
while entrance_lookup.others:
|
||||
if not find_pairing(dead_end=False, require_new_exits=True):
|
||||
break
|
||||
# stage 2 - try to place all the dead-end entrances
|
||||
while entrance_lookup.dead_ends:
|
||||
if not find_pairing(dead_end=True, require_new_exits=True):
|
||||
break
|
||||
# stage 3 - all the regions should be placed at this point. We now need to connect dangling edges
|
||||
# stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions)
|
||||
# doing this before the non-dead-ends is important to ensure there are enough connections to
|
||||
# go around
|
||||
while entrance_lookup.dead_ends:
|
||||
find_pairing(dead_end=True, require_new_exits=False)
|
||||
# stage 3b - tie all the other loose ends connecting visited regions to each other
|
||||
while entrance_lookup.others:
|
||||
find_pairing(dead_end=False, require_new_exits=False)
|
||||
|
||||
running_time = time.perf_counter() - start_time
|
||||
if running_time > 1.0:
|
||||
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player},"
|
||||
f"named {world.multiworld.player_name[world.player]}")
|
||||
|
||||
return er_state
|
||||
82
kvui.py
82
kvui.py
@@ -26,6 +26,10 @@ import Utils
|
||||
if Utils.is_frozen():
|
||||
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
|
||||
|
||||
import platformdirs
|
||||
os.environ["KIVY_HOME"] = os.path.join(platformdirs.user_config_dir("Archipelago", False), "kivy")
|
||||
os.makedirs(os.environ["KIVY_HOME"], exist_ok=True)
|
||||
|
||||
from kivy.config import Config
|
||||
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
@@ -40,7 +44,7 @@ from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData
|
||||
from kivy.base import ExceptionHandler, ExceptionManager
|
||||
from kivy.clock import Clock
|
||||
from kivy.factory import Factory
|
||||
from kivy.properties import BooleanProperty, ObjectProperty
|
||||
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty
|
||||
from kivy.metrics import dp
|
||||
from kivy.effects.scroll import ScrollEffect
|
||||
from kivy.uix.widget import Widget
|
||||
@@ -64,6 +68,7 @@ from kivy.uix.recycleboxlayout import RecycleBoxLayout
|
||||
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
|
||||
from kivy.animation import Animation
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.dropdown import DropDown
|
||||
from kivy.uix.image import AsyncImage
|
||||
|
||||
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
|
||||
@@ -305,6 +310,50 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
|
||||
""" Respond to the selection of items in the view. """
|
||||
self.selected = is_selected
|
||||
|
||||
|
||||
class AutocompleteHintInput(TextInput):
|
||||
min_chars = NumericProperty(3)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.dropdown = DropDown()
|
||||
self.dropdown.bind(on_select=lambda instance, x: setattr(self, 'text', x))
|
||||
self.bind(on_text_validate=self.on_message)
|
||||
|
||||
def on_message(self, instance):
|
||||
App.get_running_app().commandprocessor("!hint "+instance.text)
|
||||
|
||||
def on_text(self, instance, value):
|
||||
if len(value) >= self.min_chars:
|
||||
self.dropdown.clear_widgets()
|
||||
ctx: context_type = App.get_running_app().ctx
|
||||
if not ctx.game:
|
||||
return
|
||||
item_names = ctx.item_names._game_store[ctx.game].values()
|
||||
|
||||
def on_press(button: Button):
|
||||
split_text = MarkupLabel(text=button.text).markup
|
||||
return self.dropdown.select("".join(text_frag for text_frag in split_text
|
||||
if not text_frag.startswith("[")))
|
||||
lowered = value.lower()
|
||||
for item_name in item_names:
|
||||
try:
|
||||
index = item_name.lower().index(lowered)
|
||||
except ValueError:
|
||||
pass # substring not found
|
||||
else:
|
||||
text = escape_markup(item_name)
|
||||
text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
|
||||
btn = Button(text=text, size_hint_y=None, height=dp(30), markup=True)
|
||||
btn.bind(on_release=on_press)
|
||||
self.dropdown.add_widget(btn)
|
||||
if not self.dropdown.attach_to:
|
||||
self.dropdown.open(self)
|
||||
else:
|
||||
self.dropdown.dismiss()
|
||||
|
||||
|
||||
class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
selected = BooleanProperty(False)
|
||||
striped = BooleanProperty(False)
|
||||
@@ -395,8 +444,11 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
if child.collide_point(*touch.pos):
|
||||
key = child.sort_key
|
||||
if key == "status":
|
||||
parent.hint_sorter = lambda element: element["status"]["hint"]["status"]
|
||||
else: parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower()
|
||||
parent.hint_sorter = lambda element: status_sort_weights[element["status"]["hint"]["status"]]
|
||||
else:
|
||||
parent.hint_sorter = lambda element: (
|
||||
remove_between_brackets.sub("", element[key]["text"]).lower()
|
||||
)
|
||||
if key == parent.sort_key:
|
||||
# second click reverses order
|
||||
parent.reversed = not parent.reversed
|
||||
@@ -570,8 +622,10 @@ class GameManager(App):
|
||||
# show Archipelago tab if other logging is present
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser))
|
||||
hint_panel = self.add_client_tab("Hints", HintLayout())
|
||||
self.hint_log = HintLog(self.json_to_kivy_parser)
|
||||
self.log_panels["Hints"] = hint_panel.content
|
||||
hint_panel.content.add_widget(self.hint_log)
|
||||
|
||||
if len(self.logging_pairs) == 1:
|
||||
self.tabs.default_tab_text = "Archipelago"
|
||||
@@ -698,7 +752,7 @@ class GameManager(App):
|
||||
|
||||
def update_hints(self):
|
||||
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
|
||||
self.log_panels["Hints"].refresh_hints(hints)
|
||||
self.hint_log.refresh_hints(hints)
|
||||
|
||||
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
|
||||
def open_settings(self, *largs):
|
||||
@@ -753,6 +807,17 @@ class UILog(RecycleView):
|
||||
element.height = element.texture_size[1]
|
||||
|
||||
|
||||
class HintLayout(BoxLayout):
|
||||
orientation = "vertical"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
boxlayout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
|
||||
boxlayout.add_widget(Label(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(30)))
|
||||
boxlayout.add_widget(AutocompleteHintInput())
|
||||
self.add_widget(boxlayout)
|
||||
|
||||
|
||||
status_names: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_FOUND: "Found",
|
||||
HintStatus.HINT_UNSPECIFIED: "Unspecified",
|
||||
@@ -767,6 +832,13 @@ status_colors: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_AVOID: "salmon",
|
||||
HintStatus.HINT_PRIORITY: "plum",
|
||||
}
|
||||
status_sort_weights: dict[HintStatus, int] = {
|
||||
HintStatus.HINT_FOUND: 0,
|
||||
HintStatus.HINT_UNSPECIFIED: 1,
|
||||
HintStatus.HINT_NO_PRIORITY: 2,
|
||||
HintStatus.HINT_AVOID: 3,
|
||||
HintStatus.HINT_PRIORITY: 4,
|
||||
}
|
||||
|
||||
|
||||
class HintLog(RecycleView):
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
|
||||
python_classes = Test
|
||||
python_functions = test
|
||||
testpaths =
|
||||
test
|
||||
worlds
|
||||
|
||||
@@ -7,7 +7,7 @@ schema>=0.7.7
|
||||
kivy>=2.3.0
|
||||
bsdiff4>=1.2.4
|
||||
platformdirs>=4.2.2
|
||||
certifi>=2024.8.30
|
||||
certifi>=2024.12.14
|
||||
cython>=3.0.11
|
||||
cymem>=2.0.8
|
||||
orjson>=3.10.7
|
||||
|
||||
25
settings.py
25
settings.py
@@ -109,7 +109,7 @@ class Group:
|
||||
def get_type_hints(cls) -> Dict[str, Any]:
|
||||
"""Returns resolved type hints for the class"""
|
||||
if cls._type_cache is None:
|
||||
if not isinstance(next(iter(cls.__annotations__.values())), str):
|
||||
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
|
||||
# non-str: assume already resolved
|
||||
cls._type_cache = cls.__annotations__
|
||||
else:
|
||||
@@ -270,15 +270,20 @@ class Group:
|
||||
# fetch class to avoid going through getattr
|
||||
cls = self.__class__
|
||||
type_hints = cls.get_type_hints()
|
||||
entries = [e for e in self]
|
||||
if not entries:
|
||||
# write empty dict for empty Group with no instance values
|
||||
cls._dump_value({}, f, indent=" " * level)
|
||||
# validate group
|
||||
for name in cls.__annotations__.keys():
|
||||
assert hasattr(cls, name), f"{cls}.{name} is missing a default value"
|
||||
# dump ordered members
|
||||
for name in self:
|
||||
for name in entries:
|
||||
attr = cast(object, getattr(self, name))
|
||||
attr_cls = type_hints[name] if name in type_hints else attr.__class__
|
||||
attr_cls_origin = typing.get_origin(attr_cls)
|
||||
while attr_cls_origin is Union: # resolve to first type for doc string
|
||||
# resolve to first type for doc string
|
||||
while attr_cls_origin is Union or attr_cls_origin is types.UnionType:
|
||||
attr_cls = typing.get_args(attr_cls)[0]
|
||||
attr_cls_origin = typing.get_origin(attr_cls)
|
||||
if attr_cls.__doc__ and attr_cls.__module__ != "builtins":
|
||||
@@ -678,6 +683,8 @@ class GeneratorOptions(Group):
|
||||
race: Race = Race(0)
|
||||
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
|
||||
panic_method: PanicMethod = PanicMethod("swap")
|
||||
loglevel: str = "info"
|
||||
logtime: bool = False
|
||||
|
||||
|
||||
class SNIOptions(Group):
|
||||
@@ -785,7 +792,17 @@ class Settings(Group):
|
||||
if location:
|
||||
from Utils import parse_yaml
|
||||
with open(location, encoding="utf-8-sig") as f:
|
||||
options = parse_yaml(f.read())
|
||||
from yaml.error import MarkedYAMLError
|
||||
try:
|
||||
options = parse_yaml(f.read())
|
||||
except MarkedYAMLError as ex:
|
||||
if ex.problem_mark:
|
||||
f.seek(0)
|
||||
lines = f.readlines()
|
||||
problem_line = lines[ex.problem_mark.line]
|
||||
error_line = " " * ex.problem_mark.column + "^"
|
||||
raise Exception(f"{ex.context} {ex.problem}\n{problem_line}{error_line}")
|
||||
raise ex
|
||||
# TODO: detect if upgrade is required
|
||||
# TODO: once we have a cache for _world_settings_name_cache, detect if any game section is missing
|
||||
self.update(options or {})
|
||||
|
||||
@@ -18,7 +18,15 @@ def run_locations_benchmark():
|
||||
|
||||
class BenchmarkRunner:
|
||||
gen_steps: typing.Tuple[str, ...] = (
|
||||
"generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
||||
"generate_early",
|
||||
"create_regions",
|
||||
"create_items",
|
||||
"set_rules",
|
||||
"connect_entrances",
|
||||
"generate_basic",
|
||||
"pre_fill",
|
||||
)
|
||||
|
||||
rule_iterations: int = 100_000
|
||||
|
||||
if sys.version_info >= (3, 9):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(ap-cpp-tests)
|
||||
|
||||
enable_testing()
|
||||
@@ -7,8 +7,8 @@ find_package(GTest REQUIRED)
|
||||
|
||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
||||
add_definitions("/source-charset:utf-8")
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
|
||||
# set(CMAKE_CXX_FLAGS_DEBUG "/MDd") # this is the default
|
||||
# set(CMAKE_CXX_FLAGS_RELEASE "/MD") # this is the default
|
||||
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||
# enable static analysis for gcc
|
||||
add_compile_options(-fanalyzer -Werror)
|
||||
|
||||
@@ -5,7 +5,15 @@ from BaseClasses import CollectionState, Item, ItemClassification, Location, Mul
|
||||
from worlds import network_data_package
|
||||
from worlds.AutoWorld import World, call_all
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
||||
gen_steps = (
|
||||
"generate_early",
|
||||
"create_regions",
|
||||
"create_items",
|
||||
"set_rules",
|
||||
"connect_entrances",
|
||||
"generate_basic",
|
||||
"pre_fill",
|
||||
)
|
||||
|
||||
|
||||
def setup_solo_multiworld(
|
||||
|
||||
418
test/general/test_entrance_rando.py
Normal file
418
test/general/test_entrance_rando.py
Normal file
@@ -0,0 +1,418 @@
|
||||
import unittest
|
||||
from enum import IntEnum
|
||||
|
||||
from BaseClasses import Region, EntranceType, MultiWorld, Entrance
|
||||
from entrance_rando import disconnect_entrance_for_randomization, randomize_entrances, EntranceRandomizationError, \
|
||||
ERPlacementState, EntranceLookup, bake_target_group_lookup
|
||||
from Options import Accessibility
|
||||
from test.general import generate_test_multiworld, generate_locations, generate_items
|
||||
from worlds.generic.Rules import set_rule
|
||||
|
||||
|
||||
class ERTestGroups(IntEnum):
|
||||
LEFT = 1
|
||||
RIGHT = 2
|
||||
TOP = 3
|
||||
BOTTOM = 4
|
||||
|
||||
|
||||
directionally_matched_group_lookup = {
|
||||
ERTestGroups.LEFT: [ERTestGroups.RIGHT],
|
||||
ERTestGroups.RIGHT: [ERTestGroups.LEFT],
|
||||
ERTestGroups.TOP: [ERTestGroups.BOTTOM],
|
||||
ERTestGroups.BOTTOM: [ERTestGroups.TOP]
|
||||
}
|
||||
|
||||
|
||||
def generate_entrance_pair(region: Region, name_suffix: str, group: int):
|
||||
lx = region.create_exit(region.name + name_suffix)
|
||||
lx.randomization_group = group
|
||||
lx.randomization_type = EntranceType.TWO_WAY
|
||||
le = region.create_er_target(region.name + name_suffix)
|
||||
le.randomization_group = group
|
||||
le.randomization_type = EntranceType.TWO_WAY
|
||||
|
||||
|
||||
def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0,
|
||||
region_type: type[Region] = Region):
|
||||
"""
|
||||
Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each
|
||||
region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the
|
||||
bottom right
|
||||
"""
|
||||
for row in range(grid_side_length):
|
||||
for col in range(grid_side_length):
|
||||
index = row * grid_side_length + col
|
||||
name = f"region{index}"
|
||||
region = region_type(name, 1, multiworld)
|
||||
multiworld.regions.append(region)
|
||||
generate_locations(region_size, 1, region=region, tag=f"_{name}")
|
||||
|
||||
if row == 0 and col == 0:
|
||||
multiworld.get_region("Menu", 1).connect(region)
|
||||
if col != 0:
|
||||
generate_entrance_pair(region, "_left", ERTestGroups.LEFT)
|
||||
if col != grid_side_length - 1:
|
||||
generate_entrance_pair(region, "_right", ERTestGroups.RIGHT)
|
||||
if row != 0:
|
||||
generate_entrance_pair(region, "_top", ERTestGroups.TOP)
|
||||
if row != grid_side_length - 1:
|
||||
generate_entrance_pair(region, "_bottom", ERTestGroups.BOTTOM)
|
||||
|
||||
|
||||
class TestEntranceLookup(unittest.TestCase):
|
||||
def test_shuffled_targets(self):
|
||||
"""tests that get_targets shuffles targets between groups when requested"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
|
||||
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
|
||||
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||
for entrance in region.entrances if not entrance.parent_region]
|
||||
for entrance in er_targets:
|
||||
lookup.add(entrance)
|
||||
|
||||
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
|
||||
False, False)
|
||||
prev = None
|
||||
group_order = [prev := group.randomization_group for group in retrieved_targets
|
||||
if prev != group.randomization_group]
|
||||
# technically possible that group order may not be shuffled, by some small chance, on some seeds. but generally
|
||||
# a shuffled list should alternate more frequently which is the desired behavior here
|
||||
self.assertGreater(len(group_order), 2)
|
||||
|
||||
|
||||
def test_ordered_targets(self):
|
||||
"""tests that get_targets does not shuffle targets between groups when requested"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
|
||||
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
|
||||
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||
for entrance in region.entrances if not entrance.parent_region]
|
||||
for entrance in er_targets:
|
||||
lookup.add(entrance)
|
||||
|
||||
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
|
||||
False, True)
|
||||
prev = None
|
||||
group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group]
|
||||
self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order)
|
||||
|
||||
|
||||
class TestBakeTargetGroupLookup(unittest.TestCase):
|
||||
def test_lookup_generation(self):
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
world = multiworld.worlds[1]
|
||||
expected = {
|
||||
ERTestGroups.LEFT: [-ERTestGroups.LEFT],
|
||||
ERTestGroups.RIGHT: [-ERTestGroups.RIGHT],
|
||||
ERTestGroups.TOP: [-ERTestGroups.TOP],
|
||||
ERTestGroups.BOTTOM: [-ERTestGroups.BOTTOM]
|
||||
}
|
||||
actual = bake_target_group_lookup(world, lambda g: [-g])
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
|
||||
class TestDisconnectForRandomization(unittest.TestCase):
|
||||
def test_disconnect_default_2way(self):
|
||||
multiworld = generate_test_multiworld()
|
||||
r1 = Region("r1", 1, multiworld)
|
||||
r2 = Region("r2", 1, multiworld)
|
||||
e = r1.create_exit("e")
|
||||
e.randomization_type = EntranceType.TWO_WAY
|
||||
e.randomization_group = 1
|
||||
e.connect(r2)
|
||||
|
||||
disconnect_entrance_for_randomization(e)
|
||||
|
||||
self.assertIsNone(e.connected_region)
|
||||
self.assertEqual([], r2.entrances)
|
||||
|
||||
self.assertEqual(1, len(r1.exits))
|
||||
self.assertEqual(e, r1.exits[0])
|
||||
|
||||
self.assertEqual(1, len(r1.entrances))
|
||||
self.assertIsNone(r1.entrances[0].parent_region)
|
||||
self.assertEqual("e", r1.entrances[0].name)
|
||||
self.assertEqual(EntranceType.TWO_WAY, r1.entrances[0].randomization_type)
|
||||
self.assertEqual(1, r1.entrances[0].randomization_group)
|
||||
|
||||
def test_disconnect_default_1way(self):
|
||||
multiworld = generate_test_multiworld()
|
||||
r1 = Region("r1", 1, multiworld)
|
||||
r2 = Region("r2", 1, multiworld)
|
||||
e = r1.create_exit("e")
|
||||
e.randomization_type = EntranceType.ONE_WAY
|
||||
e.randomization_group = 1
|
||||
e.connect(r2)
|
||||
|
||||
disconnect_entrance_for_randomization(e)
|
||||
|
||||
self.assertIsNone(e.connected_region)
|
||||
self.assertEqual([], r1.entrances)
|
||||
|
||||
self.assertEqual(1, len(r1.exits))
|
||||
self.assertEqual(e, r1.exits[0])
|
||||
|
||||
self.assertEqual(1, len(r2.entrances))
|
||||
self.assertIsNone(r2.entrances[0].parent_region)
|
||||
self.assertEqual("r2", r2.entrances[0].name)
|
||||
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
|
||||
self.assertEqual(1, r2.entrances[0].randomization_group)
|
||||
|
||||
def test_disconnect_uses_alternate_group(self):
|
||||
multiworld = generate_test_multiworld()
|
||||
r1 = Region("r1", 1, multiworld)
|
||||
r2 = Region("r2", 1, multiworld)
|
||||
e = r1.create_exit("e")
|
||||
e.randomization_type = EntranceType.ONE_WAY
|
||||
e.randomization_group = 1
|
||||
e.connect(r2)
|
||||
|
||||
disconnect_entrance_for_randomization(e, 2)
|
||||
|
||||
self.assertIsNone(e.connected_region)
|
||||
self.assertEqual([], r1.entrances)
|
||||
|
||||
self.assertEqual(1, len(r1.exits))
|
||||
self.assertEqual(e, r1.exits[0])
|
||||
|
||||
self.assertEqual(1, len(r2.entrances))
|
||||
self.assertIsNone(r2.entrances[0].parent_region)
|
||||
self.assertEqual("r2", r2.entrances[0].name)
|
||||
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
|
||||
self.assertEqual(2, r2.entrances[0].randomization_group)
|
||||
|
||||
|
||||
class TestRandomizeEntrances(unittest.TestCase):
|
||||
def test_determinism(self):
|
||||
"""tests that the same output is produced for the same input"""
|
||||
multiworld1 = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld1, 5)
|
||||
multiworld2 = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld2, 5)
|
||||
|
||||
result1 = randomize_entrances(multiworld1.worlds[1], False, directionally_matched_group_lookup)
|
||||
result2 = randomize_entrances(multiworld2.worlds[1], False, directionally_matched_group_lookup)
|
||||
self.assertEqual(result1.pairings, result2.pairings)
|
||||
for e1, e2 in zip(result1.placements, result2.placements):
|
||||
self.assertEqual(e1.name, e2.name)
|
||||
self.assertEqual(e1.parent_region.name, e1.parent_region.name)
|
||||
self.assertEqual(e1.connected_region.name, e2.connected_region.name)
|
||||
|
||||
def test_all_entrances_placed(self):
|
||||
"""tests that all entrances and exits were placed, all regions are connected, and no dangling edges exist"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
|
||||
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
|
||||
|
||||
self.assertEqual([], [entrance for region in multiworld.get_regions()
|
||||
for entrance in region.entrances if not entrance.parent_region])
|
||||
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
||||
for exit_ in region.exits if not exit_.connected_region])
|
||||
# 5x5 grid + menu
|
||||
self.assertEqual(26, len(result.placed_regions))
|
||||
self.assertEqual(80, len(result.pairings))
|
||||
self.assertEqual(80, len(result.placements))
|
||||
|
||||
def test_coupling(self):
|
||||
"""tests that in coupled mode, all 2 way transitions have an inverse"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
seen_placement_count = 0
|
||||
|
||||
def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]):
|
||||
nonlocal seen_placement_count
|
||||
seen_placement_count += len(placed_entrances)
|
||||
self.assertEqual(2, len(placed_entrances))
|
||||
self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region)
|
||||
self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region)
|
||||
|
||||
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup,
|
||||
on_connect=verify_coupled)
|
||||
# if we didn't visit every placement the verification on_connect doesn't really mean much
|
||||
self.assertEqual(len(result.placements), seen_placement_count)
|
||||
|
||||
def test_uncoupled(self):
|
||||
"""tests that in uncoupled mode, no transitions have an (intentional) inverse"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
seen_placement_count = 0
|
||||
|
||||
def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]):
|
||||
nonlocal seen_placement_count
|
||||
seen_placement_count += len(placed_entrances)
|
||||
self.assertEqual(1, len(placed_entrances))
|
||||
|
||||
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup,
|
||||
on_connect=verify_uncoupled)
|
||||
# if we didn't visit every placement the verification on_connect doesn't really mean much
|
||||
self.assertEqual(len(result.placements), seen_placement_count)
|
||||
|
||||
def test_oneway_twoway_pairing(self):
|
||||
"""tests that 1 ways are only paired to 1 ways and 2 ways are only paired to 2 ways"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
region26 = Region("region26", 1, multiworld)
|
||||
multiworld.regions.append(region26)
|
||||
for index, region in enumerate(["region4", "region20", "region24"]):
|
||||
x = multiworld.get_region(region, 1).create_exit(f"{region}_bottom_1way")
|
||||
x.randomization_type = EntranceType.ONE_WAY
|
||||
x.randomization_group = ERTestGroups.BOTTOM
|
||||
e = region26.create_er_target(f"region26_top_1way{index}")
|
||||
e.randomization_type = EntranceType.ONE_WAY
|
||||
e.randomization_group = ERTestGroups.TOP
|
||||
|
||||
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
|
||||
for exit_name, entrance_name in result.pairings:
|
||||
# we have labeled our entrances in such a way that all the 1 way entrances have 1way in the name,
|
||||
# so test for that since the ER target will have been discarded
|
||||
if "1way" in exit_name:
|
||||
self.assertIn("1way", entrance_name)
|
||||
|
||||
def test_group_constraints_satisfied(self):
|
||||
"""tests that all grouping constraints are satisfied"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
|
||||
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
|
||||
for exit_name, entrance_name in result.pairings:
|
||||
# we have labeled our entrances in such a way that all the entrances contain their group in the name
|
||||
# so test for that since the ER target will have been discarded
|
||||
if "top" in exit_name:
|
||||
self.assertIn("bottom", entrance_name)
|
||||
if "bottom" in exit_name:
|
||||
self.assertIn("top", entrance_name)
|
||||
if "left" in exit_name:
|
||||
self.assertIn("right", entrance_name)
|
||||
if "right" in exit_name:
|
||||
self.assertIn("left", entrance_name)
|
||||
|
||||
def test_minimal_entrance_rando(self):
|
||||
"""tests that entrance randomization can complete with minimal accessibility and unreachable exits"""
|
||||
multiworld = generate_test_multiworld()
|
||||
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
|
||||
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
|
||||
generate_disconnected_region_grid(multiworld, 5, 1)
|
||||
prog_items = generate_items(10, 1, True)
|
||||
multiworld.itempool += prog_items
|
||||
filler_items = generate_items(15, 1, False)
|
||||
multiworld.itempool += filler_items
|
||||
e = multiworld.get_entrance("region1_right", 1)
|
||||
set_rule(e, lambda state: False)
|
||||
|
||||
randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
|
||||
|
||||
self.assertEqual([], [entrance for region in multiworld.get_regions()
|
||||
for entrance in region.entrances if not entrance.parent_region])
|
||||
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
||||
for exit_ in region.exits if not exit_.connected_region])
|
||||
|
||||
def test_minimal_entrance_rando_with_collect_override(self):
|
||||
"""
|
||||
tests that entrance randomization can complete with minimal accessibility and unreachable exits
|
||||
when the world defines a collect override that add extra values to prog_items
|
||||
"""
|
||||
multiworld = generate_test_multiworld()
|
||||
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
|
||||
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
|
||||
generate_disconnected_region_grid(multiworld, 5, 1)
|
||||
prog_items = generate_items(10, 1, True)
|
||||
multiworld.itempool += prog_items
|
||||
filler_items = generate_items(15, 1, False)
|
||||
multiworld.itempool += filler_items
|
||||
e = multiworld.get_entrance("region1_right", 1)
|
||||
set_rule(e, lambda state: False)
|
||||
|
||||
old_collect = multiworld.worlds[1].collect
|
||||
|
||||
def new_collect(state, item):
|
||||
old_collect(state, item)
|
||||
state.prog_items[item.player]["counter"] += 300
|
||||
|
||||
multiworld.worlds[1].collect = new_collect
|
||||
|
||||
randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
|
||||
|
||||
self.assertEqual([], [entrance for region in multiworld.get_regions()
|
||||
for entrance in region.entrances if not entrance.parent_region])
|
||||
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
||||
for exit_ in region.exits if not exit_.connected_region])
|
||||
|
||||
def test_restrictive_region_requirement_does_not_fail(self):
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 2, 1)
|
||||
|
||||
region = Region("region4", 1, multiworld)
|
||||
multiworld.regions.append(region)
|
||||
generate_entrance_pair(multiworld.get_region("region0", 1), "_right2", ERTestGroups.RIGHT)
|
||||
generate_entrance_pair(region, "_left", ERTestGroups.LEFT)
|
||||
|
||||
blocked_exits = ["region1_left", "region1_bottom",
|
||||
"region2_top", "region2_right",
|
||||
"region3_left", "region3_top"]
|
||||
for exit_name in blocked_exits:
|
||||
blocked_exit = multiworld.get_entrance(exit_name, 1)
|
||||
blocked_exit.access_rule = lambda state: state.can_reach_region("region4", 1)
|
||||
multiworld.register_indirect_condition(region, blocked_exit)
|
||||
|
||||
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup)
|
||||
# verifying that we did in fact place region3 adjacent to region0 to unblock all the other connections
|
||||
# (and implicitly, that ER didn't fail)
|
||||
self.assertTrue(("region0_right", "region4_left") in result.pairings
|
||||
or ("region0_right2", "region4_left") in result.pairings)
|
||||
|
||||
def test_fails_when_mismatched_entrance_and_exit_count(self):
|
||||
"""tests that entrance randomization fast-fails if the input exit and entrance count do not match"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
multiworld.get_region("region1", 1).create_exit("extra")
|
||||
|
||||
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
|
||||
directionally_matched_group_lookup)
|
||||
|
||||
def test_fails_when_some_unreachable_exit(self):
|
||||
"""tests that entrance randomization fails if an exit is never reachable (non-minimal accessibility)"""
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5)
|
||||
e = multiworld.get_entrance("region1_right", 1)
|
||||
set_rule(e, lambda state: False)
|
||||
|
||||
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
|
||||
directionally_matched_group_lookup)
|
||||
|
||||
def test_fails_when_some_unconnectable_exit(self):
|
||||
"""tests that entrance randomization fails if an exit can't be made into a valid placement (non-minimal)"""
|
||||
class CustomEntrance(Entrance):
|
||||
def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
|
||||
if other.name == "region1_right":
|
||||
return False
|
||||
|
||||
class CustomRegion(Region):
|
||||
entrance_type = CustomEntrance
|
||||
|
||||
multiworld = generate_test_multiworld()
|
||||
generate_disconnected_region_grid(multiworld, 5, region_type=CustomRegion)
|
||||
|
||||
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
|
||||
directionally_matched_group_lookup)
|
||||
|
||||
def test_minimal_er_fails_when_not_enough_locations_to_fit_progression(self):
|
||||
"""
|
||||
tests that entrance randomization fails in minimal accessibility if there are not enough locations
|
||||
available to place all progression items locally
|
||||
"""
|
||||
multiworld = generate_test_multiworld()
|
||||
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
|
||||
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
|
||||
generate_disconnected_region_grid(multiworld, 5, 1)
|
||||
prog_items = generate_items(30, 1, True)
|
||||
multiworld.itempool += prog_items
|
||||
e = multiworld.get_entrance("region1_right", 1)
|
||||
set_rule(e, lambda state: False)
|
||||
|
||||
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
|
||||
directionally_matched_group_lookup)
|
||||
63
test/general/test_entrances.py
Normal file
63
test/general/test_entrances.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import unittest
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all, World
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
def test_entrance_connection_steps(self):
|
||||
"""Tests that Entrances are connected and not changed after connect_entrances."""
|
||||
def get_entrance_name_to_source_and_target_dict(world: World):
|
||||
return [
|
||||
(entrance.name, entrance.parent_region, entrance.connected_region)
|
||||
for entrance in world.get_entrances()
|
||||
]
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
|
||||
additional_steps = ("generate_basic", "pre_fill")
|
||||
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||
|
||||
original_entrances = get_entrance_name_to_source_and_target_dict(multiworld.worlds[1])
|
||||
|
||||
self.assertTrue(
|
||||
all(entrance[1] is not None and entrance[2] is not None for entrance in original_entrances),
|
||||
f"{game_name} had unconnected entrances after connect_entrances"
|
||||
)
|
||||
|
||||
for step in additional_steps:
|
||||
with self.subTest("Step", step=step):
|
||||
call_all(multiworld, step)
|
||||
step_entrances = get_entrance_name_to_source_and_target_dict(multiworld.worlds[1])
|
||||
|
||||
self.assertEqual(
|
||||
original_entrances, step_entrances, f"{game_name} modified entrances during {step}"
|
||||
)
|
||||
|
||||
def test_all_state_before_connect_entrances(self):
|
||||
"""Before connect_entrances, Entrance objects may be unconnected.
|
||||
Thus, we test that get_all_state is performed with allow_partial_entrances if used before or during
|
||||
connect_entrances."""
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
|
||||
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, ())
|
||||
|
||||
original_get_all_state = multiworld.get_all_state
|
||||
|
||||
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
|
||||
self.assertTrue(allow_partial_entrances, (
|
||||
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
|
||||
"As such, any call to get_all_state must use allow_partial_entrances = True."
|
||||
))
|
||||
|
||||
return original_get_all_state(use_cache, allow_partial_entrances)
|
||||
|
||||
multiworld.get_all_state = patched_get_all_state
|
||||
|
||||
for step in gen_steps:
|
||||
with self.subTest("Step", step=step):
|
||||
call_all(multiworld, step)
|
||||
@@ -1,6 +1,8 @@
|
||||
import unittest
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from BaseClasses import CollectionState, MultiWorld, Region
|
||||
|
||||
|
||||
@@ -8,6 +10,7 @@ class TestHelpers(unittest.TestCase):
|
||||
multiworld: MultiWorld
|
||||
player: int = 1
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
self.multiworld = MultiWorld(self.player)
|
||||
self.multiworld.game[self.player] = "helper_test_game"
|
||||
@@ -38,15 +41,15 @@ class TestHelpers(unittest.TestCase):
|
||||
"TestRegion1": {"TestRegion2": "connection"},
|
||||
"TestRegion2": {"TestRegion1": None},
|
||||
}
|
||||
|
||||
|
||||
reg_exit_set: Dict[str, set[str]] = {
|
||||
"TestRegion1": {"TestRegion3"}
|
||||
}
|
||||
|
||||
|
||||
exit_rules: Dict[str, Callable[[CollectionState], bool]] = {
|
||||
"TestRegion1": lambda state: state.has("test_item", self.player)
|
||||
}
|
||||
|
||||
|
||||
self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions]
|
||||
|
||||
with self.subTest("Test Location Creation Helper"):
|
||||
@@ -73,7 +76,7 @@ class TestHelpers(unittest.TestCase):
|
||||
entrance_name = exit_name if exit_name else f"{parent} -> {exit_reg}"
|
||||
self.assertEqual(exit_rules[exit_reg],
|
||||
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
|
||||
|
||||
|
||||
for region in reg_exit_set:
|
||||
current_region = self.multiworld.get_region(region, self.player)
|
||||
current_region.add_exits(reg_exit_set[region])
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestImplemented(unittest.TestCase):
|
||||
"""Tests that if a world creates slot data, it's json serializable."""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
# has an await for generate_output which isn't being called
|
||||
if game_name in {"Ocarina of Time", "Zillion"}:
|
||||
if game_name in {"Ocarina of Time"}:
|
||||
continue
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
with self.subTest(game=game_name, seed=multiworld.seed):
|
||||
@@ -52,3 +52,77 @@ class TestImplemented(unittest.TestCase):
|
||||
def test_no_failed_world_loads(self):
|
||||
if failed_world_loads:
|
||||
self.fail(f"The following worlds failed to load: {failed_world_loads}")
|
||||
|
||||
def test_explicit_indirect_conditions_spheres(self):
|
||||
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
|
||||
indirect conditions"""
|
||||
# Because the iteration order of blocked_connections in CollectionState.update_reachable_regions() is
|
||||
# nondeterministic, this test may sometimes pass with the same seed even when there are missing indirect
|
||||
# conditions.
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
world = multiworld.get_game_worlds(game_name)[0]
|
||||
if not world.explicit_indirect_conditions:
|
||||
# The world does not use explicit indirect conditions, so it can be skipped.
|
||||
continue
|
||||
# The world may override explicit_indirect_conditions as a property that cannot be set, so try modifying it.
|
||||
try:
|
||||
world.explicit_indirect_conditions = False
|
||||
world.explicit_indirect_conditions = True
|
||||
except Exception:
|
||||
# Could not modify the attribute, so skip this world.
|
||||
with self.subTest(game=game_name, skipped="world.explicit_indirect_conditions could not be set"):
|
||||
continue
|
||||
with self.subTest(game=game_name, seed=multiworld.seed):
|
||||
distribute_items_restrictive(multiworld)
|
||||
call_all(multiworld, "post_fill")
|
||||
|
||||
# Note: `multiworld.get_spheres()` iterates a set of locations, so the order that locations are checked
|
||||
# is nondeterministic and may vary between runs with the same seed.
|
||||
explicit_spheres = list(multiworld.get_spheres())
|
||||
# Disable explicit indirect conditions and produce a second list of spheres.
|
||||
world.explicit_indirect_conditions = False
|
||||
implicit_spheres = list(multiworld.get_spheres())
|
||||
|
||||
# Both lists should be identical.
|
||||
if explicit_spheres == implicit_spheres:
|
||||
# Test passed.
|
||||
continue
|
||||
|
||||
# Find the first sphere that was different and provide a useful failure message.
|
||||
zipped = zip(explicit_spheres, implicit_spheres)
|
||||
for sphere_num, (sphere_explicit, sphere_implicit) in enumerate(zipped, start=1):
|
||||
# Each sphere created with explicit indirect conditions should be identical to the sphere created
|
||||
# with implicit indirect conditions.
|
||||
if sphere_explicit != sphere_implicit:
|
||||
reachable_only_with_implicit = sorted(sphere_implicit - sphere_explicit)
|
||||
if reachable_only_with_implicit:
|
||||
locations_and_parents = [(loc, loc.parent_region) for loc in reachable_only_with_implicit]
|
||||
self.fail(f"Sphere {sphere_num} created with explicit indirect conditions did not contain"
|
||||
f" the same locations as sphere {sphere_num} created with implicit indirect"
|
||||
f" conditions. There may be missing indirect conditions for connections to the"
|
||||
f" locations' parent regions or connections from other regions which connect to"
|
||||
f" these regions."
|
||||
f"\nLocations that should have been reachable in sphere {sphere_num} and their"
|
||||
f" parent regions:"
|
||||
f"\n{locations_and_parents}")
|
||||
else:
|
||||
# Some locations were only present in the sphere created with explicit indirect conditions.
|
||||
# This should not happen because missing indirect conditions should only reduce
|
||||
# accessibility, not increase accessibility.
|
||||
reachable_only_with_explicit = sorted(sphere_explicit - sphere_implicit)
|
||||
self.fail(f"Sphere {sphere_num} created with explicit indirect conditions contained more"
|
||||
f" locations than sphere {sphere_num} created with implicit indirect conditions."
|
||||
f" This should not happen."
|
||||
f"\nUnexpectedly reachable locations in sphere {sphere_num}:"
|
||||
f"\n{reachable_only_with_explicit}")
|
||||
self.fail("Unreachable")
|
||||
|
||||
def test_no_items_or_locations_or_regions_submitted_in_init(self):
|
||||
"""Test that worlds don't submit items/locations/regions to the multiworld in __init__"""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, ())
|
||||
self.assertEqual(len(multiworld.itempool), 0)
|
||||
self.assertEqual(len(multiworld.get_locations()), 0)
|
||||
self.assertEqual(len(multiworld.get_regions()), 0)
|
||||
|
||||
@@ -67,7 +67,7 @@ class TestBase(unittest.TestCase):
|
||||
def test_itempool_not_modified(self):
|
||||
"""Test that worlds don't modify the itempool after `create_items`"""
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
additional_steps = ("set_rules", "generate_basic", "pre_fill")
|
||||
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||
excluded_games = ("Links Awakening DX", "Ocarina of Time", "SMZ3")
|
||||
worlds_to_test = {game: world
|
||||
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
||||
@@ -84,7 +84,7 @@ class TestBase(unittest.TestCase):
|
||||
def test_locality_not_modified(self):
|
||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
additional_steps = ("set_rules", "generate_basic", "pre_fill")
|
||||
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
||||
for game_name, world_type in worlds_to_test.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
|
||||
@@ -45,6 +45,12 @@ class TestBase(unittest.TestCase):
|
||||
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||
f"{game_name} modified locations count during rule creation")
|
||||
|
||||
call_all(multiworld, "connect_entrances")
|
||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||
f"{game_name} modified region count during rule creation")
|
||||
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||
f"{game_name} modified locations count during rule creation")
|
||||
|
||||
call_all(multiworld, "generate_basic")
|
||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||
f"{game_name} modified region count during generate_basic")
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestWorldMemory(unittest.TestCase):
|
||||
def test_leak(self):
|
||||
def test_leak(self) -> None:
|
||||
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
|
||||
import gc
|
||||
import weakref
|
||||
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
with self.subTest("Game creation", game_name=game_name):
|
||||
weak = weakref.ref(setup_solo_multiworld(world_type))
|
||||
gc.collect()
|
||||
refs[game_name] = weak
|
||||
gc.collect()
|
||||
for game_name, weak in refs.items():
|
||||
with self.subTest("Game cleanup", game_name=game_name):
|
||||
self.assertFalse(weak(), "World leaked a reference")
|
||||
|
||||
@@ -3,7 +3,7 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestNames(unittest.TestCase):
|
||||
def test_item_names_format(self):
|
||||
def test_item_names_format(self) -> None:
|
||||
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
@@ -11,7 +11,7 @@ class TestNames(unittest.TestCase):
|
||||
self.assertFalse(item_name.isnumeric(),
|
||||
f"Item name \"{item_name}\" is invalid. It must not be numeric.")
|
||||
|
||||
def test_location_name_format(self):
|
||||
def test_location_name_format(self) -> None:
|
||||
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
|
||||
@@ -2,11 +2,11 @@ import unittest
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import setup_solo_multiworld
|
||||
from . import setup_solo_multiworld, gen_steps
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
|
||||
gen_steps = gen_steps
|
||||
|
||||
default_settings_unreachable_regions = {
|
||||
"A Link to the Past": {
|
||||
|
||||
29
test/general/test_state.py
Normal file
29
test/general/test_state.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import unittest
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
gen_steps = (
|
||||
"generate_early",
|
||||
"create_regions",
|
||||
)
|
||||
|
||||
test_steps = (
|
||||
"create_items",
|
||||
"set_rules",
|
||||
"connect_entrances",
|
||||
"generate_basic",
|
||||
"pre_fill",
|
||||
)
|
||||
|
||||
def test_all_state_is_available(self):
|
||||
"""Ensure all_state can be created at certain steps."""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, self.gen_steps)
|
||||
for step in self.test_steps:
|
||||
with self.subTest("Step", step=step):
|
||||
call_all(multiworld, step)
|
||||
self.assertTrue(multiworld.get_all_state(False, True))
|
||||
@@ -86,3 +86,7 @@ class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
|
||||
async def deathlink_kill_player(self, ctx: SNIContext) -> None:
|
||||
""" override this with implementation to kill player """
|
||||
pass
|
||||
|
||||
def on_package(self, ctx: SNIContext, cmd: str, args: Dict[str, Any]) -> None:
|
||||
""" override this with code to handle packages from the server """
|
||||
pass
|
||||
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
import time
|
||||
from random import Random
|
||||
from dataclasses import make_dataclass
|
||||
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping, Optional, Set, TextIO, Tuple,
|
||||
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
|
||||
TYPE_CHECKING, Type, Union)
|
||||
|
||||
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
||||
@@ -378,6 +378,10 @@ class World(metaclass=AutoWorldRegister):
|
||||
"""Method for setting the rules on the World's regions and locations."""
|
||||
pass
|
||||
|
||||
def connect_entrances(self) -> None:
|
||||
"""Method to finalize the source and target regions of the World's entrances"""
|
||||
pass
|
||||
|
||||
def generate_basic(self) -> None:
|
||||
"""
|
||||
Useful for randomizing things that don't affect logic but are better to be determined before the output stage.
|
||||
@@ -534,12 +538,24 @@ class World(metaclass=AutoWorldRegister):
|
||||
def get_location(self, location_name: str) -> "Location":
|
||||
return self.multiworld.get_location(location_name, self.player)
|
||||
|
||||
def get_locations(self) -> "Iterable[Location]":
|
||||
return self.multiworld.get_locations(self.player)
|
||||
|
||||
def get_entrance(self, entrance_name: str) -> "Entrance":
|
||||
return self.multiworld.get_entrance(entrance_name, self.player)
|
||||
|
||||
def get_entrances(self) -> "Iterable[Entrance]":
|
||||
return self.multiworld.get_entrances(self.player)
|
||||
|
||||
def get_region(self, region_name: str) -> "Region":
|
||||
return self.multiworld.get_region(region_name, self.player)
|
||||
|
||||
def get_regions(self) -> "Iterable[Region]":
|
||||
return self.multiworld.get_regions(self.player)
|
||||
|
||||
def push_precollected(self, item: Item) -> None:
|
||||
self.multiworld.push_precollected(item)
|
||||
|
||||
@property
|
||||
def player_name(self) -> str:
|
||||
return self.multiworld.get_player_name(self.player)
|
||||
|
||||
@@ -18,16 +18,42 @@ class Type(Enum):
|
||||
|
||||
|
||||
class Component:
|
||||
"""
|
||||
A Component represents a process launchable by Archipelago Launcher, either by a User action in the GUI,
|
||||
by resolving an archipelago://user:pass@host:port link from the WebHost, by resolving a patch file's metadata,
|
||||
or by using a component name arg while running the Launcher in CLI i.e. `ArchipelagoLauncher.exe "Text Client"`
|
||||
|
||||
Expected to be appended to LauncherComponents.component list to be used.
|
||||
"""
|
||||
display_name: str
|
||||
"""Used as the GUI button label and the component name in the CLI args"""
|
||||
type: Type
|
||||
"""
|
||||
Enum "Type" classification of component intent, for filtering in the Launcher GUI
|
||||
If not set in the constructor, it will be inferred by display_name
|
||||
"""
|
||||
script_name: Optional[str]
|
||||
"""Recommended to use func instead; Name of file to run when the component is called"""
|
||||
frozen_name: Optional[str]
|
||||
"""Recommended to use func instead; Name of the frozen executable file for this component"""
|
||||
icon: str # just the name, no suffix
|
||||
"""Lookup ID for the icon path in LauncherComponents.icon_paths"""
|
||||
cli: bool
|
||||
"""Bool to control if the component gets launched in an appropriate Terminal for the OS"""
|
||||
func: Optional[Callable]
|
||||
"""
|
||||
Function that gets called when the component gets launched
|
||||
Any arg besides the component name arg is passed into the func as well, so handling *args is suggested
|
||||
"""
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
"""
|
||||
Function that is run against patch file arg to identify which component is appropriate to launch
|
||||
If the function is an Instance of SuffixIdentifier the suffixes will also be valid for the Open Patch component
|
||||
"""
|
||||
game_name: Optional[str]
|
||||
"""Game name to identify component when handling launch links from WebHost"""
|
||||
supports_uri: Optional[bool]
|
||||
"""Bool to identify if a component supports being launched by launch links from WebHost"""
|
||||
|
||||
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,
|
||||
@@ -61,7 +87,7 @@ class Component:
|
||||
processes = weakref.WeakSet()
|
||||
|
||||
|
||||
def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] = ()) -> None:
|
||||
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||
global processes
|
||||
import multiprocessing
|
||||
process = multiprocessing.Process(target=func, name=name, args=args)
|
||||
@@ -69,6 +95,14 @@ def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] =
|
||||
processes.add(process)
|
||||
|
||||
|
||||
def launch(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||
from Utils import is_kivy_running
|
||||
if is_kivy_running():
|
||||
launch_subprocess(func, name, args)
|
||||
else:
|
||||
func(*args)
|
||||
|
||||
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
@@ -85,7 +119,7 @@ class SuffixIdentifier:
|
||||
|
||||
def launch_textclient(*args):
|
||||
import CommonClient
|
||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||
launch(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||
|
||||
|
||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
|
||||
@@ -55,6 +55,7 @@ async def lock(ctx) -> None
|
||||
async def unlock(ctx) -> None
|
||||
|
||||
async def get_hash(ctx) -> str
|
||||
async def get_memory_size(ctx, domain: str) -> int
|
||||
async def get_system(ctx) -> str
|
||||
async def get_cores(ctx) -> dict[str, str]
|
||||
async def ping(ctx) -> None
|
||||
@@ -168,9 +169,10 @@ select dialog and they will be associated with BizHawkClient. This does not affe
|
||||
associate the file extension with Archipelago.
|
||||
|
||||
`validate_rom` is called to figure out whether a given ROM belongs to your client. It will only be called when a ROM is
|
||||
running on a system you specified in your `system` class variable. In most cases, that will be a single system and you
|
||||
can be sure that you're not about to try to read from nonexistent domains or out of bounds. If you decide to claim this
|
||||
ROM as yours, this is where you should do setup for things like `items_handling`.
|
||||
running on a system you specified in your `system` class variable. Take extra care here, because your code will run
|
||||
against ROMs that you have no control over. If you're reading an address deep in ROM, you might want to check the size
|
||||
of ROM before you attempt to read it using `get_memory_size`. If you decide to claim this ROM as yours, this is where
|
||||
you should do setup for things like `items_handling`.
|
||||
|
||||
`game_watcher` is the "main loop" of your client where you should be checking memory and sending new items to the ROM.
|
||||
`BizHawkClient` will make sure that your `game_watcher` only runs when your client has validated the ROM, and will do
|
||||
@@ -268,6 +270,8 @@ server connection before trying to interact with it.
|
||||
- By default, the player will be asked to provide their slot name after connecting to the server and validating, and
|
||||
that input will be used to authenticate with the `Connect` command. You can override `set_auth` in your own client to
|
||||
set it automatically based on data in the ROM or on your client instance.
|
||||
- Use `get_memory_size` inside `validate_rom` if you need to read at large addresses, in case some other game has a
|
||||
smaller ROM size.
|
||||
- You can override `on_package` in your client to watch raw packages, but don't forget you also have access to a
|
||||
subclass of `CommonContext` and its API.
|
||||
- You can import `BizHawkClientContext` for type hints using `typing.TYPE_CHECKING`. Importing it without conditions at
|
||||
|
||||
@@ -10,7 +10,7 @@ import base64
|
||||
import enum
|
||||
import json
|
||||
import sys
|
||||
import typing
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
BIZHAWK_SOCKET_PORT_RANGE_START = 43055
|
||||
@@ -44,10 +44,10 @@ class SyncError(Exception):
|
||||
|
||||
|
||||
class BizHawkContext:
|
||||
streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]]
|
||||
streams: tuple[asyncio.StreamReader, asyncio.StreamWriter] | None
|
||||
connection_status: ConnectionStatus
|
||||
_lock: asyncio.Lock
|
||||
_port: typing.Optional[int]
|
||||
_port: int | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.streams = None
|
||||
@@ -122,12 +122,12 @@ async def get_script_version(ctx: BizHawkContext) -> int:
|
||||
return int(await ctx._send_message("VERSION"))
|
||||
|
||||
|
||||
async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[str, typing.Any]]) -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
async def send_requests(ctx: BizHawkContext, req_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Sends a list of requests to the BizHawk connector and returns their responses.
|
||||
|
||||
It's likely you want to use the wrapper functions instead of this."""
|
||||
responses = json.loads(await ctx._send_message(json.dumps(req_list)))
|
||||
errors: typing.List[ConnectorError] = []
|
||||
errors: list[ConnectorError] = []
|
||||
|
||||
for response in responses:
|
||||
if response["type"] == "ERROR":
|
||||
@@ -151,7 +151,7 @@ async def ping(ctx: BizHawkContext) -> None:
|
||||
|
||||
|
||||
async def get_hash(ctx: BizHawkContext) -> str:
|
||||
"""Gets the system name for the currently loaded ROM"""
|
||||
"""Gets the hash value of the currently loaded ROM"""
|
||||
res = (await send_requests(ctx, [{"type": "HASH"}]))[0]
|
||||
|
||||
if res["type"] != "HASH_RESPONSE":
|
||||
@@ -160,6 +160,16 @@ async def get_hash(ctx: BizHawkContext) -> str:
|
||||
return res["value"]
|
||||
|
||||
|
||||
async def get_memory_size(ctx: BizHawkContext, domain: str) -> int:
|
||||
"""Gets the size in bytes of the specified memory domain"""
|
||||
res = (await send_requests(ctx, [{"type": "MEMORY_SIZE", "domain": domain}]))[0]
|
||||
|
||||
if res["type"] != "MEMORY_SIZE_RESPONSE":
|
||||
raise SyncError(f"Expected response of type MEMORY_SIZE_RESPONSE but got {res['type']}")
|
||||
|
||||
return res["value"]
|
||||
|
||||
|
||||
async def get_system(ctx: BizHawkContext) -> str:
|
||||
"""Gets the system name for the currently loaded ROM"""
|
||||
res = (await send_requests(ctx, [{"type": "SYSTEM"}]))[0]
|
||||
@@ -170,7 +180,7 @@ async def get_system(ctx: BizHawkContext) -> str:
|
||||
return res["value"]
|
||||
|
||||
|
||||
async def get_cores(ctx: BizHawkContext) -> typing.Dict[str, str]:
|
||||
async def get_cores(ctx: BizHawkContext) -> dict[str, str]:
|
||||
"""Gets the preferred cores for systems with multiple cores. Only systems with multiple available cores have
|
||||
entries."""
|
||||
res = (await send_requests(ctx, [{"type": "PREFERRED_CORES"}]))[0]
|
||||
@@ -223,8 +233,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.Sequence[typing.Tuple[int, int, str]],
|
||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> typing.Optional[typing.List[bytes]]:
|
||||
async def guarded_read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]],
|
||||
guard_list: Sequence[tuple[int, Sequence[int], str]]) -> list[bytes] | None:
|
||||
"""Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
|
||||
value.
|
||||
|
||||
@@ -252,7 +262,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
|
||||
"domain": domain
|
||||
} for address, size, domain in read_list])
|
||||
|
||||
ret: typing.List[bytes] = []
|
||||
ret: list[bytes] = []
|
||||
for item in res:
|
||||
if item["type"] == "GUARD_RESPONSE":
|
||||
if not item["value"]:
|
||||
@@ -266,7 +276,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
|
||||
return ret
|
||||
|
||||
|
||||
async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
|
||||
async def read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]]) -> list[bytes]:
|
||||
"""Reads data at 1 or more addresses.
|
||||
|
||||
Items in `read_list` should be organized `(address, size, domain)` where
|
||||
@@ -278,8 +288,8 @@ async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int,
|
||||
return await guarded_read(ctx, read_list, [])
|
||||
|
||||
|
||||
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:
|
||||
async def guarded_write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]],
|
||||
guard_list: Sequence[tuple[int, 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 +326,7 @@ async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.
|
||||
return True
|
||||
|
||||
|
||||
async def write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> None:
|
||||
async def write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]]) -> None:
|
||||
"""Writes data to 1 or more addresses.
|
||||
|
||||
Items in write_list should be organized `(address, value, domain)` where
|
||||
|
||||
@@ -5,9 +5,9 @@ A module containing the BizHawkClient base class and metaclass
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch as launch_component
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import BizHawkClientContext
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
def launch_client(*args) -> None:
|
||||
from .context import launch
|
||||
launch_subprocess(launch, name="BizHawkClient", args=args)
|
||||
launch_component(launch, name="BizHawkClient", args=args)
|
||||
|
||||
|
||||
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
|
||||
@@ -24,9 +24,9 @@ components.append(component)
|
||||
|
||||
|
||||
class AutoBizHawkClientRegister(abc.ABCMeta):
|
||||
game_handlers: ClassVar[Dict[Tuple[str, ...], Dict[str, BizHawkClient]]] = {}
|
||||
game_handlers: ClassVar[dict[tuple[str, ...], dict[str, BizHawkClient]]] = {}
|
||||
|
||||
def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any]) -> AutoBizHawkClientRegister:
|
||||
def __new__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]) -> AutoBizHawkClientRegister:
|
||||
new_class = super().__new__(cls, name, bases, namespace)
|
||||
|
||||
# Register handler
|
||||
@@ -54,7 +54,7 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
async def get_handler(ctx: "BizHawkClientContext", system: str) -> Optional[BizHawkClient]:
|
||||
async def get_handler(ctx: "BizHawkClientContext", system: str) -> BizHawkClient | None:
|
||||
for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
|
||||
if system in systems:
|
||||
for handler in handlers.values():
|
||||
@@ -65,13 +65,13 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
|
||||
|
||||
|
||||
class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
|
||||
system: ClassVar[Union[str, Tuple[str, ...]]]
|
||||
system: ClassVar[str | tuple[str, ...]]
|
||||
"""The system(s) that the game this client is for runs on"""
|
||||
|
||||
game: ClassVar[str]
|
||||
"""The game this client is for"""
|
||||
|
||||
patch_suffix: ClassVar[Optional[Union[str, Tuple[str, ...]]]]
|
||||
patch_suffix: ClassVar[str | tuple[str, ...] | None]
|
||||
"""The file extension(s) this client is meant to open and patch (e.g. ".apz3")"""
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -6,7 +6,7 @@ checking or launching the client, otherwise it will probably cause circular impo
|
||||
import asyncio
|
||||
import enum
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any
|
||||
|
||||
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
|
||||
import Patch
|
||||
@@ -43,15 +43,15 @@ class BizHawkClientContext(CommonContext):
|
||||
command_processor = BizHawkClientCommandProcessor
|
||||
auth_status: AuthStatus
|
||||
password_requested: bool
|
||||
client_handler: Optional[BizHawkClient]
|
||||
slot_data: Optional[Dict[str, Any]] = None
|
||||
rom_hash: Optional[str] = None
|
||||
client_handler: BizHawkClient | None
|
||||
slot_data: dict[str, Any] | None = None
|
||||
rom_hash: str | None = None
|
||||
bizhawk_ctx: BizHawkContext
|
||||
|
||||
watcher_timeout: float
|
||||
"""The maximum amount of time the game watcher loop will wait for an update from the server before executing"""
|
||||
|
||||
def __init__(self, server_address: Optional[str], password: Optional[str]):
|
||||
def __init__(self, server_address: str | None, password: str | None):
|
||||
super().__init__(server_address, password)
|
||||
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
||||
self.password_requested = False
|
||||
@@ -231,20 +231,28 @@ async def _run_game(rom: str):
|
||||
)
|
||||
|
||||
|
||||
async def _patch_and_run_game(patch_file: str):
|
||||
def _patch_and_run_game(patch_file: str):
|
||||
try:
|
||||
metadata, output_file = Patch.create_rom_file(patch_file)
|
||||
Utils.async_start(_run_game(output_file))
|
||||
return metadata
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
Utils.messagebox("Error Patching Game", str(exc), True)
|
||||
return {}
|
||||
|
||||
|
||||
def launch(*launch_args) -> None:
|
||||
def launch(*launch_args: str) -> 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(launch_args)
|
||||
|
||||
if args.patch_file != "":
|
||||
metadata = _patch_and_run_game(args.patch_file)
|
||||
if "server" in metadata:
|
||||
args.connect = metadata["server"]
|
||||
|
||||
ctx = BizHawkClientContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
@@ -252,9 +260,6 @@ def launch(*launch_args) -> None:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
if args.patch_file != "":
|
||||
Utils.async_start(_patch_and_run_game(args.patch_file))
|
||||
|
||||
watcher_task = asyncio.create_task(_game_watcher(ctx), name="GameWatcher")
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
|
||||
|
||||
from Options import Choice, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
|
||||
|
||||
|
||||
class FreeincarnateMax(Range):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
|
||||
from Options import PerGameCommonOptions
|
||||
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
|
||||
from .Locations import location_table, AdventureLocation, dragon_room_to_region
|
||||
|
||||
|
||||
def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
|
||||
|
||||
@@ -2,15 +2,15 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from typing import Optional, Any
|
||||
|
||||
import Utils
|
||||
from .Locations import AdventureLocation, LocationData
|
||||
from settings import get_settings
|
||||
from worlds.Files import APPatch, AutoPatchRegister
|
||||
from typing import Any
|
||||
|
||||
import bsdiff4
|
||||
|
||||
import Utils
|
||||
from settings import get_settings
|
||||
from worlds.Files import APPatch, AutoPatchRegister
|
||||
from .Locations import LocationData
|
||||
|
||||
ADVENTUREHASH: str = "157bddb7192754a45372be196797f284"
|
||||
|
||||
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import base64
|
||||
import copy
|
||||
import itertools
|
||||
import math
|
||||
import os
|
||||
import settings
|
||||
import typing
|
||||
from enum import IntFlag
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple
|
||||
from typing import ClassVar, Dict, Optional, Tuple
|
||||
|
||||
from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial, \
|
||||
LocationProgressType
|
||||
import settings
|
||||
from BaseClasses import Item, ItemClassification, MultiWorld, Tutorial, LocationProgressType
|
||||
from Utils import __version__
|
||||
from Options import AssembleOptions
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from Fill import fill_restrictive
|
||||
from worlds.generic.Rules import add_rule, set_rule
|
||||
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
|
||||
AdventureOptions
|
||||
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
|
||||
AdventureAutoCollectLocation
|
||||
from worlds.LauncherComponents import Component, components, SuffixIdentifier
|
||||
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
|
||||
from .Locations import location_table, base_location_id, LocationData, get_random_room_in_regions
|
||||
from .Offsets import static_item_data_location, items_ram_start, static_item_element_size, item_position_table, \
|
||||
static_first_dragon_index, connector_port_offset, yorgle_speed_data_location, grundle_speed_data_location, \
|
||||
rhindle_speed_data_location, item_ram_addresses, start_castle_values, start_castle_offset
|
||||
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, AdventureOptions
|
||||
from .Regions import create_regions
|
||||
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, AdventureAutoCollectLocation
|
||||
from .Rules import set_rules
|
||||
|
||||
|
||||
from worlds.LauncherComponents import Component, components, SuffixIdentifier
|
||||
|
||||
# Adventure
|
||||
components.append(Component('Adventure Client', 'AdventureClient', file_identifier=SuffixIdentifier('.apadvn')))
|
||||
|
||||
|
||||
@@ -141,9 +141,12 @@ def set_dw_rules(world: "HatInTimeWorld"):
|
||||
add_dw_rules(world, all_clear)
|
||||
add_rule(main_stamp, main_objective.access_rule)
|
||||
add_rule(all_clear, main_objective.access_rule)
|
||||
# Only set bonus stamp rules if we don't auto complete bonuses
|
||||
# Only set bonus stamp rules to require All Clear if we don't auto complete bonuses
|
||||
if not world.options.DWAutoCompleteBonuses and not world.is_bonus_excluded(all_clear.name):
|
||||
add_rule(bonus_stamps, all_clear.access_rule)
|
||||
else:
|
||||
# As soon as the Main Objective is completed, the bonuses auto-complete.
|
||||
add_rule(bonus_stamps, main_objective.access_rule)
|
||||
|
||||
if world.options.DWShuffle:
|
||||
for i in range(len(world.dw_shuffle)-1):
|
||||
@@ -343,6 +346,7 @@ def create_enemy_events(world: "HatInTimeWorld"):
|
||||
|
||||
def set_enemy_rules(world: "HatInTimeWorld"):
|
||||
no_tourist = "Camera Tourist" in world.excluded_dws or "Camera Tourist" in world.excluded_bonuses
|
||||
difficulty = get_difficulty(world)
|
||||
|
||||
for enemy, regions in hit_list.items():
|
||||
if no_tourist and enemy in bosses:
|
||||
@@ -372,6 +376,14 @@ def set_enemy_rules(world: "HatInTimeWorld"):
|
||||
or state.has("Zipline Unlock - The Lava Cake Path", world.player)
|
||||
or state.has("Zipline Unlock - The Windmill Path", world.player))
|
||||
|
||||
elif enemy == "Toilet":
|
||||
if area == "Toilet of Doom":
|
||||
# The boss firewall is in the way and can only be skipped on Expert logic using a cherry hover.
|
||||
add_rule(event, lambda state: has_paintings(state, world, 1, allow_skip=difficulty == Difficulty.EXPERT))
|
||||
if difficulty < Difficulty.HARD:
|
||||
# Hard logic and above can cross the boss arena gap with a cherry bridge.
|
||||
add_rule(event, lambda state: can_use_hookshot(state, world))
|
||||
|
||||
elif enemy == "Director":
|
||||
if area == "Dead Bird Studio Basement":
|
||||
add_rule(event, lambda state: can_use_hookshot(state, world))
|
||||
@@ -430,7 +442,7 @@ hit_list = {
|
||||
# Bosses
|
||||
"Mafia Boss": ["Down with the Mafia!", "Encore! Encore!", "Boss Rush"],
|
||||
|
||||
"Conductor": ["Dead Bird Studio Basement", "Killing Two Birds", "Boss Rush"],
|
||||
"Director": ["Dead Bird Studio Basement", "Killing Two Birds", "Boss Rush"],
|
||||
"Toilet": ["Toilet of Doom", "Boss Rush"],
|
||||
|
||||
"Snatcher": ["Your Contract has Expired", "Breaching the Contract", "Boss Rush",
|
||||
@@ -454,7 +466,7 @@ triple_enemy_locations = [
|
||||
|
||||
bosses = [
|
||||
"Mafia Boss",
|
||||
"Conductor",
|
||||
"Director",
|
||||
"Toilet",
|
||||
"Snatcher",
|
||||
"Toxic Flower",
|
||||
|
||||
@@ -264,7 +264,6 @@ ahit_locations = {
|
||||
required_hats=[HatType.DWELLER], paintings=3),
|
||||
|
||||
"Subcon Forest - Tall Tree Hookshot Swing": LocData(2000324766, "Subcon Forest Area",
|
||||
required_hats=[HatType.DWELLER],
|
||||
hookshot=True,
|
||||
paintings=3),
|
||||
|
||||
@@ -323,7 +322,7 @@ ahit_locations = {
|
||||
"Alpine Skyline - The Twilight Path": LocData(2000334434, "Alpine Skyline Area", required_hats=[HatType.DWELLER]),
|
||||
"Alpine Skyline - The Twilight Bell: Wide Purple Platform": LocData(2000336478, "The Twilight Bell"),
|
||||
"Alpine Skyline - The Twilight Bell: Ice Platform": LocData(2000335826, "The Twilight Bell"),
|
||||
"Alpine Skyline - Goat Outpost Horn": LocData(2000334760, "Alpine Skyline Area"),
|
||||
"Alpine Skyline - Goat Outpost Horn": LocData(2000334760, "Alpine Skyline Area (TIHS)", hookshot=True),
|
||||
"Alpine Skyline - Windy Passage": LocData(2000334776, "Alpine Skyline Area (TIHS)", hookshot=True),
|
||||
"Alpine Skyline - The Windmill: Inside Pon Cluster": LocData(2000336395, "The Windmill"),
|
||||
"Alpine Skyline - The Windmill: Entrance": LocData(2000335783, "The Windmill"),
|
||||
@@ -407,7 +406,7 @@ act_completions = {
|
||||
hit_type=HitType.umbrella_or_brewing, hookshot=True, paintings=1),
|
||||
|
||||
"Act Completion (Queen Vanessa's Manor)": LocData(2000312017, "Queen Vanessa's Manor",
|
||||
hit_type=HitType.umbrella, paintings=1),
|
||||
hit_type=HitType.dweller_bell, paintings=1),
|
||||
|
||||
"Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service",
|
||||
required_hats=[HatType.SPRINT]),
|
||||
@@ -878,7 +877,7 @@ snatcher_coins = {
|
||||
dlc_flags=HatDLC.death_wish),
|
||||
|
||||
"Snatcher Coin - Top of HQ (DW: BTH)": LocData(0, "Beat the Heat", snatcher_coin="Snatcher Coin - Top of HQ",
|
||||
dlc_flags=HatDLC.death_wish),
|
||||
hit_type=HitType.umbrella, dlc_flags=HatDLC.death_wish),
|
||||
|
||||
"Snatcher Coin - Top of Tower": LocData(0, "Mafia Town Area (HUMT)", snatcher_coin="Snatcher Coin - Top of Tower",
|
||||
dlc_flags=HatDLC.death_wish),
|
||||
|
||||
@@ -338,7 +338,7 @@ class MinExtraYarn(Range):
|
||||
There must be at least this much more yarn over the total number of yarn needed to craft all hats.
|
||||
For example, if this option's value is 10, and the total yarn needed to craft all hats is 40,
|
||||
there must be at least 50 yarn in the pool."""
|
||||
display_name = "Max Extra Yarn"
|
||||
display_name = "Min Extra Yarn"
|
||||
range_start = 5
|
||||
range_end = 15
|
||||
default = 10
|
||||
|
||||
@@ -414,7 +414,7 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
|
||||
# Moderate: Mystifying Time Mesa time trial without hats
|
||||
set_rule(world.multiworld.get_location("Alpine Skyline - Mystifying Time Mesa: Zipline", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
lambda state: True)
|
||||
|
||||
# Moderate: Goat Refinery from TIHS with Sprint only
|
||||
add_rule(world.multiworld.get_location("Alpine Skyline - Goat Refinery", world.player),
|
||||
@@ -493,9 +493,6 @@ def set_hard_rules(world: "HatInTimeWorld"):
|
||||
lambda state: has_paintings(state, world, 3, True))
|
||||
|
||||
# SDJ
|
||||
add_rule(world.multiworld.get_location("Subcon Forest - Long Tree Climb Chest", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT) and has_paintings(state, world, 2), "or")
|
||||
|
||||
add_rule(world.multiworld.get_location("Act Completion (Time Rift - Curly Tail Trail)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT), "or")
|
||||
|
||||
@@ -533,7 +530,10 @@ def set_expert_rules(world: "HatInTimeWorld"):
|
||||
# Expert: Mafia Town - Above Boats, Top of Lighthouse, and Hot Air Balloon with nothing
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Above Boats", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Top of Lighthouse", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Hot Air Balloon", world.player), lambda state: True)
|
||||
# There are not enough buckets/beach balls to bucket/ball hover in Heating Up Mafia Town, so any other Mafia Town
|
||||
# act is required.
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Hot Air Balloon", world.player),
|
||||
lambda state: state.can_reach_region("Mafia Town Area", world.player), "or")
|
||||
|
||||
# Expert: Clear Dead Bird Studio with nothing
|
||||
for loc in world.multiworld.get_region("Dead Bird Studio - Post Elevator Area", world.player).locations:
|
||||
@@ -590,7 +590,7 @@ def set_expert_rules(world: "HatInTimeWorld"):
|
||||
|
||||
if world.is_dlc2():
|
||||
# Expert: clear Rush Hour with nothing
|
||||
if not world.options.NoTicketSkips:
|
||||
if world.options.NoTicketSkips != NoTicketSkips.option_true:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player), lambda state: True)
|
||||
else:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||
@@ -739,7 +739,7 @@ def set_dlc1_rules(world: "HatInTimeWorld"):
|
||||
|
||||
# This particular item isn't present in Act 3 for some reason, yes in vanilla too
|
||||
add_rule(world.multiworld.get_location("The Arctic Cruise - Toilet", world.player),
|
||||
lambda state: state.can_reach("Bon Voyage!", "Region", world.player)
|
||||
lambda state: (state.can_reach("Bon Voyage!", "Region", world.player) and can_use_hookshot(state, world))
|
||||
or state.can_reach("Ship Shape", "Region", world.player))
|
||||
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ 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 worlds.LauncherComponents import Component, components, icon_paths, launch as launch_component, Type
|
||||
from Utils import local_path
|
||||
|
||||
|
||||
def launch_client():
|
||||
from .Client import launch
|
||||
launch_subprocess(launch, name="AHITClient")
|
||||
launch_component(launch, name="AHITClient")
|
||||
|
||||
|
||||
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
|
||||
3. Click the **Betas** tab. In the **Beta Participation** dropdown, select `tcplink`.
|
||||
While it downloads, you can subscribe to the [Archipelago workshop mod.]((https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601))
|
||||
While it downloads, you can subscribe to the [Archipelago workshop mod](https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601).
|
||||
|
||||
|
||||
4. Once the game finishes downloading, start it up.
|
||||
@@ -62,4 +62,4 @@ The level that the relic set unlocked will stay unlocked.
|
||||
|
||||
### When I start a new save file, the intro cinematic doesn't get skipped, Hat Kid's body is missing and the mod doesn't work!
|
||||
There is a bug on older versions of A Hat in Time that causes save file creation to fail to work properly
|
||||
if you have too many save files. Delete them and it should fix the problem.
|
||||
if you have too many save files. Delete them and it should fix the problem.
|
||||
|
||||
@@ -119,7 +119,9 @@ def KholdstareDefeatRule(state, player: int) -> bool:
|
||||
|
||||
|
||||
def VitreousDefeatRule(state, player: int) -> bool:
|
||||
return can_shoot_arrows(state, player) or has_melee_weapon(state, player)
|
||||
return ((can_shoot_arrows(state, player) and can_use_bombs(state, player, 10))
|
||||
or can_shoot_arrows(state, player, 35) or state.has("Silver Bow", player)
|
||||
or has_melee_weapon(state, player))
|
||||
|
||||
|
||||
def TrinexxDefeatRule(state, player: int) -> bool:
|
||||
|
||||
@@ -464,7 +464,7 @@ async def track_locations(ctx, roomid, roomdata) -> bool:
|
||||
snes_logger.info(f"Discarding recent {len(new_locations)} checks as ROM Status has changed.")
|
||||
return False
|
||||
else:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
await ctx.check_locations(new_locations)
|
||||
await snes_flush_writes(ctx)
|
||||
return True
|
||||
|
||||
|
||||
@@ -484,8 +484,7 @@ def generate_itempool(world):
|
||||
if multiworld.randomize_cost_types[player]:
|
||||
# Heart and Arrow costs require all Heart Container/Pieces and Arrow Upgrades to be advancement items for logic
|
||||
for item in items:
|
||||
if (item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart")
|
||||
or "Arrow Upgrade" in item.name):
|
||||
if item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart"):
|
||||
item.classification = ItemClassification.progression
|
||||
else:
|
||||
# Otherwise, logic has some branches where having 4 hearts is one possible requirement (of several alternatives)
|
||||
@@ -713,7 +712,7 @@ def get_pool_core(world, player: int):
|
||||
pool.remove("Rupees (20)")
|
||||
|
||||
if retro_bow:
|
||||
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (50)'}
|
||||
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (70)'}
|
||||
pool = ['Rupees (5)' if item in replace else item for item in pool]
|
||||
if world.small_key_shuffle[player] == small_key_shuffle.option_universal:
|
||||
pool.extend(diff.universal_keys)
|
||||
|
||||
@@ -7,7 +7,7 @@ from worlds.AutoWorld import World
|
||||
def GetBeemizerItem(world, player: int, item):
|
||||
item_name = item if isinstance(item, str) else item.name
|
||||
|
||||
if item_name not in trap_replaceable:
|
||||
if item_name not in trap_replaceable or player in world.groups:
|
||||
return item
|
||||
|
||||
# first roll - replaceable item should be replaced, within beemizer_total_chance
|
||||
@@ -110,9 +110,9 @@ item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\
|
||||
'Crystal 7': ItemData(IC.progression, 'Crystal', (0x08, 0x34, 0x64, 0x40, 0x7C, 0x06), None, None, None, None, None, None, "a blue crystal"),
|
||||
'Single Arrow': ItemData(IC.filler, None, 0x43, 'a lonely arrow\nsits here.', 'and the arrow', 'stick-collecting kid', 'sewing needle for sale', 'fungus for arrow', 'archer boy sews again', 'an arrow'),
|
||||
'Arrows (10)': ItemData(IC.filler, None, 0x44, 'This will give\nyou ten shots\nwith your bow!', 'and the arrow pack','stick-collecting kid', 'sewing kit for sale', 'fungus for arrows', 'archer boy sews again','ten arrows'),
|
||||
'Arrow Upgrade (+10)': ItemData(IC.useful, None, 0x54, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||
'Arrow Upgrade (+5)': ItemData(IC.useful, None, 0x53, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||
'Arrow Upgrade (70)': ItemData(IC.useful, None, 0x4D, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||
'Arrow Upgrade (+10)': ItemData(IC.progression_skip_balancing, None, 0x54, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||
'Arrow Upgrade (+5)': ItemData(IC.progression_skip_balancing, None, 0x53, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||
'Arrow Upgrade (70)': ItemData(IC.progression_skip_balancing, None, 0x4D, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||
'Single Bomb': ItemData(IC.filler, None, 0x27, 'I make things\ngo BOOM! But\njust once.', 'and the explosion', 'the bomb-holding kid', 'firecracker for sale', 'blend fungus into bomb', '\'splosion boy explodes again', 'a bomb'),
|
||||
'Bombs (3)': ItemData(IC.filler, None, 0x28, 'I make things\ngo triple\nBOOM!!!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'three bombs'),
|
||||
'Bombs (10)': ItemData(IC.filler, None, 0x31, 'I make things\ngo BOOM! Ten\ntimes!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'ten bombs'),
|
||||
|
||||
@@ -592,9 +592,9 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
lambda state: can_kill_most_things(state, player, 8) and has_fire_source(state, player) and state.multiworld.get_entrance('Ganons Tower Torch Rooms', player).parent_region.dungeon.bosses['middle'].can_defeat(state))
|
||||
set_rule(multiworld.get_location('Ganons Tower - Mini Helmasaur Key Drop', player), lambda state: can_kill_most_things(state, player, 1))
|
||||
set_rule(multiworld.get_location('Ganons Tower - Pre-Moldorm Chest', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 7))
|
||||
lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 7) and can_use_bombs(state, player))
|
||||
set_rule(multiworld.get_entrance('Ganons Tower Moldorm Door', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8))
|
||||
lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) and can_use_bombs(state, player))
|
||||
set_rule(multiworld.get_entrance('Ganons Tower Moldorm Gap', player),
|
||||
lambda state: state.has('Hookshot', player) and state.multiworld.get_entrance('Ganons Tower Moldorm Gap', player).parent_region.dungeon.bosses['top'].can_defeat(state))
|
||||
set_defeat_dungeon_boss_rule(multiworld.get_location('Agahnim 2', player))
|
||||
@@ -1125,7 +1125,7 @@ def set_trock_key_rules(world, player):
|
||||
for entrance in ['Turtle Rock Dark Room Staircase', 'Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room) (South)', 'Turtle Rock (Pokey Room) (North)', 'Turtle Rock Big Key Door']:
|
||||
set_rule(world.get_entrance(entrance, player), lambda state: False)
|
||||
|
||||
all_state = world.get_all_state(use_cache=False)
|
||||
all_state = world.get_all_state(use_cache=False, allow_partial_entrances=True)
|
||||
all_state.reachable_regions[player] = set() # wipe reachable regions so that the locked doors actually work
|
||||
all_state.stale[player] = True
|
||||
|
||||
|
||||
@@ -170,7 +170,8 @@ def push_shop_inventories(multiworld):
|
||||
# Retro Bow arrows will already have been pushed
|
||||
if (not multiworld.retro_bow[location.player]) or ((item_name, location.item.player)
|
||||
!= ("Single Arrow", location.player)):
|
||||
location.shop.push_inventory(location.shop_slot, item_name, location.shop_price,
|
||||
location.shop.push_inventory(location.shop_slot, item_name,
|
||||
round(location.shop_price * get_price_modifier(location.item)),
|
||||
1, location.item.player if location.item.player != location.player else 0,
|
||||
location.shop_price_type)
|
||||
location.shop_price = location.shop.inventory[location.shop_slot]["price"] = min(location.shop_price,
|
||||
|
||||
@@ -15,18 +15,18 @@ def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bo
|
||||
|
||||
def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool:
|
||||
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for
|
||||
shop in state.multiworld.shops)
|
||||
shop in state.multiworld.shops)
|
||||
|
||||
|
||||
def can_buy(state: CollectionState, item: str, player: int) -> bool:
|
||||
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for
|
||||
shop in state.multiworld.shops)
|
||||
shop in state.multiworld.shops)
|
||||
|
||||
|
||||
def can_shoot_arrows(state: CollectionState, player: int) -> bool:
|
||||
def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool:
|
||||
if state.multiworld.retro_bow[player]:
|
||||
return (state.has('Bow', player) or state.has('Silver Bow', player)) and can_buy(state, 'Single Arrow', player)
|
||||
return state.has('Bow', player) or state.has('Silver Bow', player)
|
||||
return (state.has('Bow', player) or state.has('Silver Bow', player)) and can_hold_arrows(state, player, count)
|
||||
|
||||
|
||||
def has_triforce_pieces(state: CollectionState, player: int) -> bool:
|
||||
@@ -61,13 +61,13 @@ def heart_count(state: CollectionState, player: int) -> int:
|
||||
# Warning: This only considers items that are marked as advancement items
|
||||
diff = state.multiworld.worlds[player].difficulty_requirements
|
||||
return min(state.count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
||||
+ state.count('Sanctuary Heart Container', player) \
|
||||
+ state.count('Sanctuary Heart Container', player) \
|
||||
+ min(state.count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
||||
+ 3 # starting hearts
|
||||
+ 3 # starting hearts
|
||||
|
||||
|
||||
def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16,
|
||||
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
|
||||
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
|
||||
basemagic = 8
|
||||
if state.has('Magic Upgrade (1/4)', player):
|
||||
basemagic = 32
|
||||
@@ -84,11 +84,18 @@ def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16,
|
||||
|
||||
|
||||
def can_hold_arrows(state: CollectionState, player: int, quantity: int):
|
||||
arrows = 30 + ((state.count("Arrow Upgrade (+5)", player) * 5) + (state.count("Arrow Upgrade (+10)", player) * 10)
|
||||
+ (state.count("Bomb Upgrade (50)", player) * 50))
|
||||
# Arrow Upgrade (+5) beyond the 6th gives +10
|
||||
arrows += max(0, ((state.count("Arrow Upgrade (+5)", player) - 6) * 10))
|
||||
return min(70, arrows) >= quantity
|
||||
if state.multiworld.worlds[player].options.shuffle_capacity_upgrades:
|
||||
if quantity == 0:
|
||||
return True
|
||||
if state.has("Arrow Upgrade (70)", player):
|
||||
arrows = 70
|
||||
else:
|
||||
arrows = (30 + (state.count("Arrow Upgrade (+5)", player) * 5)
|
||||
+ (state.count("Arrow Upgrade (+10)", player) * 10))
|
||||
# Arrow Upgrade (+5) beyond the 6th gives +10
|
||||
arrows += max(0, ((state.count("Arrow Upgrade (+5)", player) - 6) * 10))
|
||||
return min(70, arrows) >= quantity
|
||||
return quantity <= 30 or state.has("Capacity Upgrade Shop", player)
|
||||
|
||||
|
||||
def can_use_bombs(state: CollectionState, player: int, quantity: int = 1) -> bool:
|
||||
@@ -146,19 +153,19 @@ def can_get_good_bee(state: CollectionState, player: int) -> bool:
|
||||
def can_retrieve_tablet(state: CollectionState, player: int) -> bool:
|
||||
return state.has('Book of Mudora', player) and (has_beam_sword(state, player) or
|
||||
(state.multiworld.swordless[player] and
|
||||
state.has("Hammer", player)))
|
||||
state.has("Hammer", player)))
|
||||
|
||||
|
||||
def has_sword(state: CollectionState, player: int) -> bool:
|
||||
return state.has('Fighter Sword', player) \
|
||||
or state.has('Master Sword', player) \
|
||||
or state.has('Tempered Sword', player) \
|
||||
or state.has('Golden Sword', player)
|
||||
or state.has('Master Sword', player) \
|
||||
or state.has('Tempered Sword', player) \
|
||||
or state.has('Golden Sword', player)
|
||||
|
||||
|
||||
def has_beam_sword(state: CollectionState, player: int) -> bool:
|
||||
return state.has('Master Sword', player) or state.has('Tempered Sword', player) or state.has('Golden Sword',
|
||||
player)
|
||||
player)
|
||||
|
||||
|
||||
def has_melee_weapon(state: CollectionState, player: int) -> bool:
|
||||
@@ -171,9 +178,9 @@ def has_fire_source(state: CollectionState, player: int) -> bool:
|
||||
|
||||
def can_melt_things(state: CollectionState, player: int) -> bool:
|
||||
return state.has('Fire Rod', player) or \
|
||||
(state.has('Bombos', player) and
|
||||
(state.multiworld.swordless[player] or
|
||||
has_sword(state, player)))
|
||||
(state.has('Bombos', player) and
|
||||
(state.multiworld.swordless[player] or
|
||||
has_sword(state, player)))
|
||||
|
||||
|
||||
def has_misery_mire_medallion(state: CollectionState, player: int) -> bool:
|
||||
|
||||
@@ -1,224 +1,123 @@
|
||||
# Guía de instalación para A Link to the Past Randomizer Multiworld
|
||||
|
||||
<div id="tutorial-video-container">
|
||||
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/mJKEHaiyR_Y" frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
## Software requerido
|
||||
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Incluido en Multiworld Utilities)
|
||||
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
|
||||
- Un emulador capaz de ejecutar scripts Lua
|
||||
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||
- [SNI](https://github.com/alttpo/sni/releases). Esto está incluido automáticamente en la instalación de Archipelago.
|
||||
- SNI no es compatible con (Q)Usb2Snes.
|
||||
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES, por ejemplo:
|
||||
- Un emulador capaz de conectarse a SNI
|
||||
([snes9x-nwa](https://github.com/Skarsnik/snes9x-emunwa/releases), [snes9x-rr](https://github.com/gocha/snes9x-rr/releases),
|
||||
[BSNES-plus](https://github.com/black-sliver/bsnes-plus),
|
||||
[BizHawk](https://tasvideos.org/BizHawk), o
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo). O,
|
||||
- Un flashcart SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), o otro hardware compatible
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo).
|
||||
- Un SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), u otro hardware compatible. **nota:
|
||||
Las SNES minis modificadas no tienen soporte de SNI. Algunos usuarios dicen haber tenido éxito con Qusb2Snes para esta consola,
|
||||
pero no tiene soporte.**
|
||||
- Tu archivo ROM japones v1.0, probablemente se llame `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Procedimiento de instalación
|
||||
|
||||
### Instalación en Windows
|
||||
|
||||
1. Descarga e instala MultiWorld Utilities desde el enlace anterior, asegurando que instalamos la versión más reciente.
|
||||
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu
|
||||
intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.Archipelago.exe`
|
||||
- Si estas interesado en jugar la variante que aleatoriza las puertas internas de las mazmorras, necesitaras bajar '
|
||||
Setup.BerserkerMultiWorld.Doors.exe'
|
||||
- Durante el proceso de instalación, se te pedirá donde esta situado tu archivo ROM japonés v1.0. Si ya habías
|
||||
instalado este software con anterioridad y simplemente estas actualizando, no se te pedirá la localización del
|
||||
archivo una segunda vez.
|
||||
- Puede ser que el programa pida la instalación de Microsoft Visual C++. Si ya lo tienes en tu ordenador (
|
||||
posiblemente por que un juego de Steam ya lo haya instalado), el instalador no te pedirá su instalación.
|
||||
|
||||
2. Si estas usando un emulador, deberías asignar la versión capaz de ejecutar scripts Lua como programa por defecto para
|
||||
lanzar ficheros de ROM de SNES.
|
||||
1. Extrae tu emulador al escritorio, o cualquier sitio que después recuerdes.
|
||||
2. Haz click derecho en un fichero de ROM (ha de tener la extensión sfc) y selecciona **Abrir con...**
|
||||
3. Marca la opción **Usar siempre esta aplicación para abrir los archivos .sfc**
|
||||
4. Baja hasta el final de la lista y haz click en la opción **Buscar otra aplicación en el equipo** (Si usas Windows
|
||||
10 es posible que debas hacer click en **Más aplicaciones**)
|
||||
5. Busca el archivo .exe de tu emulador y haz click en **Abrir**. Este archivo debe estar en el directorio donde
|
||||
extrajiste en el paso 1.
|
||||
|
||||
### Instalación en Macintosh
|
||||
|
||||
- ¡Necesitamos voluntarios para rellenar esta seccion! Contactad con **Farrak Kilhn** (en inglés) en Discord si queréis
|
||||
ayudar.
|
||||
|
||||
## Configurar tu archivo YAML
|
||||
|
||||
### Que es un archivo YAML y por qué necesito uno?
|
||||
|
||||
Tu archivo YAML contiene un conjunto de opciones de configuración que proveen al generador con información sobre como
|
||||
debe generar tu juego. Cada jugador en una partida de multiworld proveerá su propio fichero YAML. Esta configuración
|
||||
permite que cada jugador disfrute de una experiencia personalizada a su gusto, y cada jugador dentro de la misma partida
|
||||
de multiworld puede tener diferentes opciones.
|
||||
|
||||
### Donde puedo obtener un fichero YAML?
|
||||
|
||||
La página "[Generate Game](/games/A%20Link%20to%20the%20Past/player-options)" en el sitio web te permite configurar tu
|
||||
configuración personal y descargar un fichero "YAML".
|
||||
|
||||
### Configuración YAML avanzada
|
||||
|
||||
Una version mas avanzada del fichero Yaml puede ser creada usando la pagina
|
||||
["Weighted settings"](/games/A Link to the Past/weighted-options),
|
||||
la cual te permite tener almacenadas hasta 3 preajustes. La pagina "Weighted Settings" tiene muchas opciones
|
||||
representadas con controles deslizantes. Esto permite elegir cuan probable los valores de una categoría pueden ser
|
||||
elegidos sobre otros de la misma.
|
||||
|
||||
Por ejemplo, imagina que el generador crea un cubo llamado "map_shuffle", y pone trozos de papel doblado en él por cada
|
||||
sub-opción. Ademas imaginemos que tu valor elegido para "on" es 20 y el elegido para "off" es 40.
|
||||
|
||||
Por tanto, en este ejemplo, habrán 60 trozos de papel. 20 para "on" y 40 para "off". Cuando el generador esta decidiendo
|
||||
si activar o no "map shuffle" para tu partida, meterá la mano en el cubo y sacara un trozo de papel al azar. En este
|
||||
ejemplo, es mucho mas probable (2 de cada 3 veces (40/60)) que "map shuffle" esté desactivado.
|
||||
|
||||
Si quieres que una opción no pueda ser escogida, simplemente asigna el valor 0 a dicha opción. Recuerda que cada opción
|
||||
debe tener al menos un valor mayor que cero, si no la generación fallará.
|
||||
|
||||
### Verificando tu archivo YAML
|
||||
|
||||
Si quieres validar que tu fichero YAML para asegurarte que funciona correctamente, puedes hacerlo en la pagina
|
||||
[YAML Validator](/check).
|
||||
|
||||
## Generar una partida para un jugador
|
||||
|
||||
1. Navega a [la pagina Generate game](/games/A%20Link%20to%20the%20Past/player-options), configura tus opciones, haz
|
||||
click en el boton "Generate game".
|
||||
2. Se te redigirá a una pagina "Seed Info", donde puedes descargar tu archivo de parche.
|
||||
3. Haz doble click en tu fichero de parche, y el emulador debería ejecutar tu juego automáticamente. Como el Cliente no
|
||||
es necesario para partidas de un jugador, puedes cerrarlo junto a la pagina web (que tiene como titulo "Multiworld
|
||||
WebUI") que se ha abierto automáticamente.
|
||||
|
||||
## Unirse a una partida MultiWorld
|
||||
1. Descarga e instala [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
||||
**El archivo del instalador se encuentra en la sección de assets al final de la información de version**.
|
||||
2. La primera vez que realices una generación local o parchees tu juego, se te pedirá que ubiques tu archivo ROM base.
|
||||
Este es tu archivo ROM de Link to the Past japonés. Esto sólo debe hacerse una vez.
|
||||
|
||||
4. Si estás usando un emulador, deberías de asignar tu emulador con compatibilidad con Lua como el programa por defecto para abrir archivos
|
||||
ROM.
|
||||
1. Extrae la carpeta de tu emulador al Escritorio, o algún otro sitio que vayas a recordar.
|
||||
2. Haz click derecho en un archivo ROM y selecciona **Abrir con...**
|
||||
3. Marca la casilla junto a **Usar siempre este programa para abrir archivos .sfc**
|
||||
4. Baja al final de la lista y haz click en el texto gris **Buscar otro programa en este PC**
|
||||
5. Busca el archivo `.exe` de tu emulador y haz click en **Abrir**. Este archivo debería de encontrarse dentro de la carpeta que
|
||||
extrajiste en el paso uno.
|
||||
|
||||
### Obtener el fichero de parche y crea tu ROM
|
||||
|
||||
Cuando te unes a una partida multiworld, debes proveer tu fichero YAML a quien sea el creador de la partida. Una vez
|
||||
Cuando te unas a una partida multiworld, se te pedirá enviarle tu archivo de configuración a quien quiera que esté creando. Una vez eso
|
||||
este hecho, el creador te devolverá un enlace para descargar el parche o un fichero zip conteniendo todos los ficheros
|
||||
de parche de la partida Tu fichero de parche debe tener la extensión `.aplttp`.
|
||||
de parche de la partida. Tu fichero de parche debe de tener la extensión `.aplttp`.
|
||||
|
||||
Pon tu fichero de parche en el escritorio o en algún sitio conveniente, y haz doble click. Esto debería ejecutar
|
||||
automáticamente el cliente, y ademas creara la rom en el mismo directorio donde este el fichero de parche.
|
||||
Pon tu fichero de parche en el escritorio o en algún sitio conveniente, y hazle doble click. Esto debería ejecutar
|
||||
automáticamente el cliente, y además creará la rom en el mismo directorio donde este el fichero de parche.
|
||||
|
||||
### Conectar al cliente
|
||||
|
||||
#### Con emulador
|
||||
|
||||
Cuando el cliente se lance automáticamente, QUsb2Snes debería haberse ejecutado también. Si es la primera vez que lo
|
||||
ejecutas, puedes ser que el firewall de Windows te pregunte si le permites la comunicación.
|
||||
Cuando el cliente se lance automáticamente, SNI debería de ejecutarse en segundo plano. Si es la
|
||||
primera vez que se ejecuta, tal vez se te pida permitir que se comunique a través del firewall de Windows
|
||||
|
||||
#### snes9x-nwa
|
||||
|
||||
1. Haz click en el menu Network y marca 'Enable Emu Network Control
|
||||
2. Carga tu archivo ROM si no lo habías hecho antes
|
||||
|
||||
##### snes9x-rr
|
||||
|
||||
1. Carga tu fichero de ROM, si no lo has hecho ya
|
||||
1. Carga tu fichero ROM, si no lo has hecho ya
|
||||
2. Abre el menu "File" y situa el raton en **Lua Scripting**
|
||||
3. Haz click en **New Lua Script Window...**
|
||||
4. En la nueva ventana, haz click en **Browse...**
|
||||
5. Navega hacia el directorio donde este situado snes9x-rr, entra en el directorio `lua`, y
|
||||
escoge `multibridge.lua`
|
||||
6. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo
|
||||
nombre en la esquina superior izquierda.
|
||||
5. Selecciona el archivo lua conector incluido con tu cliente
|
||||
- Busca en la carpeta de Archipelago `/SNI/lua/`.
|
||||
6. Si ves un error mientras carga el script que dice `socket.dll missing` o algo similar, ve a la carpeta de
|
||||
el lua que estas usando en tu gestor de archivos y copia el `socket.dll` a la raíz de tu instalación de snes9x.
|
||||
|
||||
##### BNES-Plus
|
||||
|
||||
1. Cargue su archivo ROM si aún no se ha cargado.
|
||||
2. El emulador debería conectarse automáticamente mientras SNI se está ejecutando.
|
||||
|
||||
##### BizHawk
|
||||
|
||||
1. Asegurate que se ha cargado el nucleo BSNES. Debes hacer esto en el menu Tools y siguiento estas opciones:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Una vez cambiado el nucleo cargado, BizHawk ha de ser reiniciado.
|
||||
1. Asegurate que se ha cargado el núcleo BSNES. Se hace en la barra de menú principal, bajo:
|
||||
- (≤ 2.8) `Config` 〉 `Cores` 〉 `SNES` 〉 `BSNES`
|
||||
- (≥ 2.9) `Config` 〉 `Preferred Cores` 〉 `SNES` 〉 `BSNESv115+`
|
||||
2. Carga tu fichero de ROM, si no lo has hecho ya.
|
||||
3. Haz click en el menu Tools y en la opción **Lua Console**
|
||||
4. Haz click en el botón para abrir un nuevo script Lua.
|
||||
5. Navega al directorio de instalación de MultiWorld Utilities, y en los siguiente directorios:
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Selecciona `luabridge.lua` y haz click en Abrir.
|
||||
7. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo
|
||||
nombre en la esquina superior izquierda.
|
||||
Si has cambiado tu preferencia de núcleo tras haber cargado la ROM, no te olvides de volverlo a cargar (atajo por defecto: Ctrl+R).
|
||||
3. Arrastra el archivo `Connector.lua` que has descargado a la ventana principal de EmuHawk.
|
||||
- Busca en la carpeta de Archipelago `/SNI/lua/`.
|
||||
- También podrías abrir la consola de Lua manualmente, hacer click en `Script` 〉 `Open Script`, e ir a `Connector.lua`
|
||||
con el selector de archivos.
|
||||
|
||||
##### RetroArch 1.10.1 o más nuevo
|
||||
|
||||
Sólo hay que segiur estos pasos una vez.
|
||||
Sólo hay que seguir estos pasos una vez.
|
||||
|
||||
1. Comienza en la pantalla del menú principal de RetroArch.
|
||||
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
|
||||
3. Ve a Ajustes --> Red. Configura "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 (el
|
||||
default) el Puerto de comandos de red.
|
||||
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
|
||||
el Puerto de comandos de red.
|
||||
|
||||

|
||||
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
||||
SFC (bsnes-mercury Performance)".
|
||||
|
||||
Cuando cargas un ROM, asegúrate de seleccionar un núcleo **bsnes-mercury**. Estos son los sólos núcleos que permiten
|
||||
Cuando cargas un ROM, asegúrate de seleccionar un núcleo **bsnes-mercury**. Estos son los únicos núcleos que permiten
|
||||
que herramientas externas lean datos del ROM.
|
||||
|
||||
#### Con Hardware
|
||||
|
||||
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, hazlo ahora. Los
|
||||
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, por favor hazlo ahora. Los
|
||||
usuarios de SD2SNES y FXPak Pro pueden descargar el firmware apropiado
|
||||
[aqui](https://github.com/RedGuyyyy/sd2snes/releases). Los usuarios de otros dispositivos pueden encontrar información
|
||||
[aqui](https://github.com/RedGuyyyy/sd2snes/releases). Puede que los usuarios de otros dispositivos encuentren informacion útil
|
||||
[en esta página](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
1. Cierra tu emulador, el cual debe haberse autoejecutado.
|
||||
2. Cierra QUsb2Snes, el cual fue ejecutado junto al cliente.
|
||||
3. Ejecuta la version correcta de QUsb2Snes (v0.7.16).
|
||||
4. Enciende tu dispositivo y carga la ROM.
|
||||
5. Observa en el cliente que ahora muestra "SNES Device: Connected", y aparece el nombre del dispositivo.
|
||||
2. Enciende tu dispositivo y carga la ROM.
|
||||
|
||||
### Conecta al MultiServer
|
||||
### Conecta al Servidor Archipelago
|
||||
|
||||
El fichero de parche que ha lanzado el cliente debe haberte conectado automaticamente al MultiServer. Hay algunas
|
||||
razonas por las que esto puede que no pase, incluyendo que el juego este hospedado en el sitio web pero se genero en
|
||||
algún otro sitio. Si el cliente muestra "Server Status: Not Connected", preguntale al creador de la partida la dirección
|
||||
del servidor, copiala en el campo "Server" y presiona Enter.
|
||||
El fichero de parche que ha lanzado el cliente debería de haberte conectado automaticamente al MultiServer. Sin embargo hay algunas
|
||||
razones por las que puede que esto no suceda, como que la partida este hospedada en la página web pero generada en otra parte. Si la
|
||||
ventana del cliente muestra "Server Status: Not Connected", simplemente preguntale al creador de la partida la dirección
|
||||
del servidor, cópiala en el campo "Server" y presiona Enter.
|
||||
|
||||
El cliente intentara conectarse a esta nueva dirección, y debería mostrar "Server Status: Connected" en algún momento.
|
||||
Si el cliente no se conecta al cabo de un rato, puede ser que necesites refrescar la pagina web.
|
||||
El cliente intentará conectarse a esta nueva dirección, y debería mostrar "Server Status: Connected" momentáneamente.
|
||||
|
||||
### Jugando
|
||||
### Jugar al juego
|
||||
|
||||
Cuando ambos SNES Device and Server aparezcan como "connected", estas listo para empezar a jugar. Felicidades por unirte
|
||||
satisfactoriamente a una partida de multiworld!
|
||||
|
||||
## Hospedando una partida de multiworld
|
||||
|
||||
La manera recomendad para hospedar una partida es usar el servicio proveído en
|
||||
[el sitio web](/generate). El proceso es relativamente sencillo:
|
||||
|
||||
1. Recolecta los ficheros YAML de todos los jugadores que participen.
|
||||
2. Crea un fichero ZIP conteniendo esos ficheros.
|
||||
3. Carga el fichero zip en el sitio web enlazado anteriormente.
|
||||
4. Espera a que la seed sea generada.
|
||||
5. Cuando esto acabe, se te redigirá a una pagina titulada "Seed Info".
|
||||
6. Haz click en "Create New Room". Esto te llevara a la pagina del servidor. Pasa el enlace a esta pagina a los
|
||||
jugadores para que puedan descargar los ficheros de parche de ahi.
|
||||
**Nota:** Los ficheros de parche de esta pagina permiten a los jugadores conectarse al servidor automaticamente,
|
||||
mientras que los de la pagina "Seed info" no.
|
||||
7. Hay un enlace a un MultiWorld Tracker en la parte superior de la pagina de la sala. Deberías pasar también este
|
||||
enlace a los jugadores para que puedan ver el progreso de la partida. A los observadores también se les puede pasar
|
||||
este enlace.
|
||||
8. Una vez todos los jugadores se han unido, podeis empezar a jugar.
|
||||
|
||||
## Auto-Tracking
|
||||
|
||||
Si deseas usar auto-tracking para tu partida, varios programas ofrecen esta funcionalidad.
|
||||
El programa recomentdado actualmente es:
|
||||
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
|
||||
|
||||
### Instalación
|
||||
|
||||
1. Descarga el fichero de instalacion apropiado para tu ordenador (Usuarios de windows quieren el fichero ".msi").
|
||||
2. Durante el proceso de insatalación, puede que se te pida instalar Microsoft Visual Studio Build Tools. Un enlace este
|
||||
programa se muestra durante la proceso, y debe ser ejecutado manualmente.
|
||||
|
||||
### Activar auto-tracking
|
||||
|
||||
1. Con OpenTracker ejecutado, haz click en el menu Tracking en la parte superior de la ventana, y elige **
|
||||
AutoTracker...**
|
||||
2. Click the **Get Devices** button
|
||||
3. Selecciona tu "SNES device" de la lista
|
||||
4. Si quieres que las llaves y los objetos de mazmorra tambien sean marcados, activa la caja con nombre **Race Illegal
|
||||
Tracking**
|
||||
5. Haz click en el boton **Start Autotracking**
|
||||
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria
|
||||
Cuando el cliente muestre tanto el dispositivo SNES como el servidor como conectados, estas listo para empezar a jugar. Felicidades por
|
||||
haberte unido a una partida multiworld con exito! Puedes ejecutar varios comandos en tu cliente. Para mas informacion
|
||||
acerca de estos comando puedes usar `/help` para comandos locales del cliente y `!help` para comandos de servidor.
|
||||
|
||||
@@ -130,19 +130,21 @@ class TestGanonsTower(TestDungeon):
|
||||
|
||||
["Ganons Tower - Pre-Moldorm Chest", False, []],
|
||||
["Ganons Tower - Pre-Moldorm Chest", False, [], ['Progressive Bow']],
|
||||
["Ganons Tower - Pre-Moldorm Chest", False, [], ['Bomb Upgrade (50)']],
|
||||
["Ganons Tower - Pre-Moldorm Chest", False, [], ['Big Key (Ganons Tower)']],
|
||||
["Ganons Tower - Pre-Moldorm Chest", False, [], ['Lamp', 'Fire Rod']],
|
||||
["Ganons Tower - Pre-Moldorm Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp']],
|
||||
["Ganons Tower - Pre-Moldorm Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod']],
|
||||
["Ganons Tower - Pre-Moldorm Chest", True, ['Bomb Upgrade (50)', 'Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp']],
|
||||
["Ganons Tower - Pre-Moldorm Chest", True, ['Bomb Upgrade (50)', 'Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod']],
|
||||
|
||||
["Ganons Tower - Validation Chest", False, []],
|
||||
["Ganons Tower - Validation Chest", False, [], ['Hookshot']],
|
||||
["Ganons Tower - Validation Chest", False, [], ['Progressive Bow']],
|
||||
["Ganons Tower - Validation Chest", False, [], ['Bomb Upgrade (50)']],
|
||||
["Ganons Tower - Validation Chest", False, [], ['Big Key (Ganons Tower)']],
|
||||
["Ganons Tower - Validation Chest", False, [], ['Lamp', 'Fire Rod']],
|
||||
["Ganons Tower - Validation Chest", False, [], ['Progressive Sword', 'Hammer']],
|
||||
["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp', 'Hookshot', 'Progressive Sword']],
|
||||
["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Hookshot', 'Progressive Sword']],
|
||||
["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp', 'Hookshot', 'Hammer']],
|
||||
["Ganons Tower - Validation Chest", True, ['Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Hookshot', 'Hammer']],
|
||||
["Ganons Tower - Validation Chest", True, ['Bomb Upgrade (50)', 'Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp', 'Hookshot', 'Progressive Sword']],
|
||||
["Ganons Tower - Validation Chest", True, ['Bomb Upgrade (50)', 'Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Hookshot', 'Progressive Sword']],
|
||||
["Ganons Tower - Validation Chest", True, ['Bomb Upgrade (50)', 'Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Lamp', 'Hookshot', 'Hammer']],
|
||||
["Ganons Tower - Validation Chest", True, ['Bomb Upgrade (50)', 'Progressive Bow', 'Big Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Fire Rod', 'Hookshot', 'Hammer']],
|
||||
])
|
||||
@@ -77,5 +77,5 @@ class TestMiseryMire(TestDungeon):
|
||||
["Misery Mire - Boss", False, [], ['Bomb Upgrade (+5)', 'Bomb Upgrade (+10)', 'Bomb Upgrade (50)']],
|
||||
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Sword', 'Pegasus Boots']],
|
||||
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Hammer', 'Pegasus Boots']],
|
||||
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Bow', 'Pegasus Boots']],
|
||||
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Bow', 'Arrow Upgrade (+5)', 'Pegasus Boots']],
|
||||
])
|
||||
@@ -93,7 +93,7 @@ class AquariaWorld(World):
|
||||
options: AquariaOptions
|
||||
"Every options of the world"
|
||||
|
||||
regions: AquariaRegions
|
||||
regions: AquariaRegions | None
|
||||
"Used to manage Regions"
|
||||
|
||||
exclude: List[str]
|
||||
@@ -101,10 +101,17 @@ class AquariaWorld(World):
|
||||
def __init__(self, multiworld: MultiWorld, player: int):
|
||||
"""Initialisation of the Aquaria World"""
|
||||
super(AquariaWorld, self).__init__(multiworld, player)
|
||||
self.regions = AquariaRegions(multiworld, player)
|
||||
self.regions = None
|
||||
self.ingredients_substitution = []
|
||||
self.exclude = []
|
||||
|
||||
def generate_early(self) -> None:
|
||||
"""
|
||||
Run before any general steps of the MultiWorld other than options. Useful for getting and adjusting option
|
||||
results and determining layouts for entrance rando etc. start inventory gets pushed after this step.
|
||||
"""
|
||||
self.regions = AquariaRegions(self.multiworld, self.player)
|
||||
|
||||
def create_regions(self) -> None:
|
||||
"""
|
||||
Create every Region in `regions`
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
## Required Software
|
||||
|
||||
- The original Aquaria Game (purchasable from most online game stores)
|
||||
- The [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases)
|
||||
- The [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest)
|
||||
|
||||
## Optional Software
|
||||
|
||||
- For sending [commands](/tutorial/Archipelago/commands/en) like `!hint`: the TextClient from [the most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- For sending [commands](/tutorial/Archipelago/commands/en) like `!hint`: the TextClient from [the most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
|
||||
- [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), for use with
|
||||
[PopTracker](https://github.com/black-sliver/PopTracker/releases/latest)
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
## Logiciels nécessaires
|
||||
|
||||
- Une copie du jeu Aquaria non-modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne)
|
||||
- Le client du Randomizer d'Aquaria [Aquaria randomizer]
|
||||
(https://github.com/tioui/Aquaria_Randomizer/releases)
|
||||
- Le client du Randomizer d'Aquaria [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest)
|
||||
|
||||
## Logiciels optionnels
|
||||
|
||||
- De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
|
||||
- [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), pour utiliser avec [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest)
|
||||
|
||||
## Procédures d'installation et d'exécution
|
||||
|
||||
@@ -4,14 +4,17 @@ import random
|
||||
|
||||
|
||||
class ChoiceIsRandom(Choice):
|
||||
randomized: bool = False
|
||||
randomized: bool
|
||||
|
||||
def __init__(self, value: int, randomized: bool = False):
|
||||
super().__init__(value)
|
||||
self.randomized = randomized
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Choice:
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
cls.randomized = True
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
return cls(random.choice(list(cls.name_lookup)), True)
|
||||
for option_name, value in cls.options.items():
|
||||
if option_name == text:
|
||||
return cls(value)
|
||||
|
||||
@@ -103,6 +103,9 @@ class BlasphemousWorld(World):
|
||||
if not self.options.wall_climb_shuffle:
|
||||
self.multiworld.push_precollected(self.create_item("Wall Climb Ability"))
|
||||
|
||||
if self.options.thorn_shuffle == "local_only":
|
||||
self.options.local_items.value.add("Thorn Upgrade")
|
||||
|
||||
if not self.options.boots_of_pleading:
|
||||
self.disabled_locations.append("RE401")
|
||||
|
||||
@@ -200,9 +203,6 @@ class BlasphemousWorld(World):
|
||||
|
||||
if not self.options.skill_randomizer:
|
||||
self.place_items_from_dict(skill_dict)
|
||||
|
||||
if self.options.thorn_shuffle == "local_only":
|
||||
self.options.local_items.value.add("Thorn Upgrade")
|
||||
|
||||
|
||||
def place_items_from_set(self, location_set: Set[str], name: str):
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
|
||||
1. Download the above release and extract it.
|
||||
|
||||
## Installation Procedures (Linux and Steam Deck)
|
||||
|
||||
1. Download the above release and extract it.
|
||||
|
||||
2. Add Celeste64.exe to Steam as a Non-Steam Game. In the properties for it on Steam, set it to use Proton as the compatibility tool. Launch the game through Steam in order to run it.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Before launching the game, edit the `AP.json` file in the root of the Celeste 64 install.
|
||||
@@ -33,5 +39,3 @@ An Example `AP.json` file:
|
||||
"Password": ""
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -1366,7 +1366,8 @@ class DarkSouls3World(World):
|
||||
text = "\n" + text + "\n"
|
||||
spoiler_handle.write(text)
|
||||
|
||||
def post_fill(self):
|
||||
@classmethod
|
||||
def stage_post_fill(cls, multiworld: MultiWorld):
|
||||
"""If item smoothing is enabled, rearrange items so they scale up smoothly through the run.
|
||||
|
||||
This determines the approximate order a given silo of items (say, soul items) show up in the
|
||||
@@ -1375,106 +1376,125 @@ class DarkSouls3World(World):
|
||||
items, later spheres get higher-level ones. Within a sphere, items in DS3 are distributed in
|
||||
region order, and then the best items in a sphere go into the multiworld.
|
||||
"""
|
||||
ds3_worlds = [world for world in cast(List[DarkSouls3World], multiworld.get_game_worlds(cls.game)) if
|
||||
world.options.smooth_upgrade_items
|
||||
or world.options.smooth_soul_items
|
||||
or world.options.smooth_upgraded_weapons]
|
||||
if not ds3_worlds:
|
||||
# No worlds need item smoothing.
|
||||
return
|
||||
|
||||
locations_by_sphere = [
|
||||
sorted(loc for loc in sphere if loc.item.player == self.player and not loc.locked)
|
||||
for sphere in self.multiworld.get_spheres()
|
||||
]
|
||||
spheres_per_player: Dict[int, List[List[Location]]] = {world.player: [] for world in ds3_worlds}
|
||||
for sphere in multiworld.get_spheres():
|
||||
locations_per_item_player: Dict[int, List[Location]] = {player: [] for player in spheres_per_player.keys()}
|
||||
for location in sphere:
|
||||
if location.locked:
|
||||
continue
|
||||
item_player = location.item.player
|
||||
if item_player in locations_per_item_player:
|
||||
locations_per_item_player[item_player].append(location)
|
||||
for player, locations in locations_per_item_player.items():
|
||||
# Sort for deterministic results.
|
||||
locations.sort()
|
||||
spheres_per_player[player].append(locations)
|
||||
|
||||
# All items in the base game in approximately the order they appear
|
||||
all_item_order: List[DS3ItemData] = [
|
||||
item_dictionary[location.default_item_name]
|
||||
for region in region_order
|
||||
# Shuffle locations within each region.
|
||||
for location in self._shuffle(location_tables[region])
|
||||
if self._is_location_available(location)
|
||||
]
|
||||
for ds3_world in ds3_worlds:
|
||||
locations_by_sphere = spheres_per_player[ds3_world.player]
|
||||
|
||||
# All DarkSouls3Items for this world that have been assigned anywhere, grouped by name
|
||||
full_items_by_name: Dict[str, List[DarkSouls3Item]] = defaultdict(list)
|
||||
for location in self.multiworld.get_filled_locations():
|
||||
if location.item.player == self.player and (
|
||||
location.player != self.player or self._is_location_available(location)
|
||||
):
|
||||
full_items_by_name[location.item.name].append(location.item)
|
||||
# All items in the base game in approximately the order they appear
|
||||
all_item_order: List[DS3ItemData] = [
|
||||
item_dictionary[location.default_item_name]
|
||||
for region in region_order
|
||||
# Shuffle locations within each region.
|
||||
for location in ds3_world._shuffle(location_tables[region])
|
||||
if ds3_world._is_location_available(location)
|
||||
]
|
||||
|
||||
def smooth_items(item_order: List[Union[DS3ItemData, DarkSouls3Item]]) -> None:
|
||||
"""Rearrange all items in item_order to match that order.
|
||||
# All DarkSouls3Items for this world that have been assigned anywhere, grouped by name
|
||||
full_items_by_name: Dict[str, List[DarkSouls3Item]] = defaultdict(list)
|
||||
for location in multiworld.get_filled_locations():
|
||||
if location.item.player == ds3_world.player and (
|
||||
location.player != ds3_world.player or ds3_world._is_location_available(location)
|
||||
):
|
||||
full_items_by_name[location.item.name].append(location.item)
|
||||
|
||||
Note: this requires that item_order exactly matches the number of placed items from this
|
||||
world matching the given names.
|
||||
"""
|
||||
def smooth_items(item_order: List[Union[DS3ItemData, DarkSouls3Item]]) -> None:
|
||||
"""Rearrange all items in item_order to match that order.
|
||||
|
||||
# Convert items to full DarkSouls3Items.
|
||||
converted_item_order: List[DarkSouls3Item] = [
|
||||
item for item in (
|
||||
(
|
||||
# full_items_by_name won't contain DLC items if the DLC is disabled.
|
||||
(full_items_by_name[item.name] or [None]).pop(0)
|
||||
if isinstance(item, DS3ItemData) else item
|
||||
Note: this requires that item_order exactly matches the number of placed items from this
|
||||
world matching the given names.
|
||||
"""
|
||||
|
||||
# Convert items to full DarkSouls3Items.
|
||||
converted_item_order: List[DarkSouls3Item] = [
|
||||
item for item in (
|
||||
(
|
||||
# full_items_by_name won't contain DLC items if the DLC is disabled.
|
||||
(full_items_by_name[item.name] or [None]).pop(0)
|
||||
if isinstance(item, DS3ItemData) else item
|
||||
)
|
||||
for item in item_order
|
||||
)
|
||||
for item in item_order
|
||||
)
|
||||
# Never re-order event items, because they weren't randomized in the first place.
|
||||
if item and item.code is not None
|
||||
]
|
||||
# Never re-order event items, because they weren't randomized in the first place.
|
||||
if item and item.code is not None
|
||||
]
|
||||
|
||||
names = {item.name for item in converted_item_order}
|
||||
names = {item.name for item in converted_item_order}
|
||||
|
||||
all_matching_locations = [
|
||||
loc
|
||||
for sphere in locations_by_sphere
|
||||
for loc in sphere
|
||||
if loc.item.name in names
|
||||
]
|
||||
all_matching_locations = [
|
||||
loc
|
||||
for sphere in locations_by_sphere
|
||||
for loc in sphere
|
||||
if loc.item.name in names
|
||||
]
|
||||
|
||||
# It's expected that there may be more total items than there are matching locations if
|
||||
# the player has chosen a more limited accessibility option, since the matching
|
||||
# locations *only* include items in the spheres of accessibility.
|
||||
if len(converted_item_order) < len(all_matching_locations):
|
||||
raise Exception(
|
||||
f"DS3 bug: there are {len(all_matching_locations)} locations that can " +
|
||||
f"contain smoothed items, but only {len(converted_item_order)} items to smooth."
|
||||
)
|
||||
# It's expected that there may be more total items than there are matching locations if
|
||||
# the player has chosen a more limited accessibility option, since the matching
|
||||
# locations *only* include items in the spheres of accessibility.
|
||||
if len(converted_item_order) < len(all_matching_locations):
|
||||
raise Exception(
|
||||
f"DS3 bug: there are {len(all_matching_locations)} locations that can " +
|
||||
f"contain smoothed items, but only {len(converted_item_order)} items to smooth."
|
||||
)
|
||||
|
||||
for sphere in locations_by_sphere:
|
||||
locations = [loc for loc in sphere if loc.item.name in names]
|
||||
for sphere in locations_by_sphere:
|
||||
locations = [loc for loc in sphere if loc.item.name in names]
|
||||
|
||||
# Check the game, not the player, because we know how to sort within regions for DS3
|
||||
offworld = self._shuffle([loc for loc in locations if loc.game != "Dark Souls III"])
|
||||
onworld = sorted((loc for loc in locations if loc.game == "Dark Souls III"),
|
||||
key=lambda loc: loc.data.region_value)
|
||||
# Check the game, not the player, because we know how to sort within regions for DS3
|
||||
offworld = ds3_world._shuffle([loc for loc in locations if loc.game != "Dark Souls III"])
|
||||
onworld = sorted((loc for loc in locations if loc.game == "Dark Souls III"),
|
||||
key=lambda loc: loc.data.region_value)
|
||||
|
||||
# Give offworld regions the last (best) items within a given sphere
|
||||
for location in onworld + offworld:
|
||||
new_item = self._pop_item(location, converted_item_order)
|
||||
location.item = new_item
|
||||
new_item.location = location
|
||||
# Give offworld regions the last (best) items within a given sphere
|
||||
for location in onworld + offworld:
|
||||
new_item = ds3_world._pop_item(location, converted_item_order)
|
||||
location.item = new_item
|
||||
new_item.location = location
|
||||
|
||||
if self.options.smooth_upgrade_items:
|
||||
base_names = {
|
||||
"Titanite Shard", "Large Titanite Shard", "Titanite Chunk", "Titanite Slab",
|
||||
"Titanite Scale", "Twinkling Titanite", "Farron Coal", "Sage's Coal", "Giant's Coal",
|
||||
"Profaned Coal"
|
||||
}
|
||||
smooth_items([item for item in all_item_order if item.base_name in base_names])
|
||||
if ds3_world.options.smooth_upgrade_items:
|
||||
base_names = {
|
||||
"Titanite Shard", "Large Titanite Shard", "Titanite Chunk", "Titanite Slab",
|
||||
"Titanite Scale", "Twinkling Titanite", "Farron Coal", "Sage's Coal", "Giant's Coal",
|
||||
"Profaned Coal"
|
||||
}
|
||||
smooth_items([item for item in all_item_order if item.base_name in base_names])
|
||||
|
||||
if self.options.smooth_soul_items:
|
||||
smooth_items([
|
||||
item for item in all_item_order
|
||||
if item.souls and item.classification != ItemClassification.progression
|
||||
])
|
||||
if ds3_world.options.smooth_soul_items:
|
||||
smooth_items([
|
||||
item for item in all_item_order
|
||||
if item.souls and item.classification != ItemClassification.progression
|
||||
])
|
||||
|
||||
if self.options.smooth_upgraded_weapons:
|
||||
upgraded_weapons = [
|
||||
location.item
|
||||
for location in self.multiworld.get_filled_locations()
|
||||
if location.item.player == self.player
|
||||
and location.item.level and location.item.level > 0
|
||||
and location.item.classification != ItemClassification.progression
|
||||
]
|
||||
upgraded_weapons.sort(key=lambda item: item.level)
|
||||
smooth_items(upgraded_weapons)
|
||||
if ds3_world.options.smooth_upgraded_weapons:
|
||||
upgraded_weapons = [
|
||||
location.item
|
||||
for location in multiworld.get_filled_locations()
|
||||
if location.item.player == ds3_world.player
|
||||
and location.item.level and location.item.level > 0
|
||||
and location.item.classification != ItemClassification.progression
|
||||
]
|
||||
upgraded_weapons.sort(key=lambda item: item.level)
|
||||
smooth_items(upgraded_weapons)
|
||||
|
||||
def _shuffle(self, seq: Sequence) -> List:
|
||||
"""Returns a shuffled copy of a sequence."""
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
## Required Software
|
||||
|
||||
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
|
||||
- [Dark Souls III AP Client](https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest)
|
||||
- [Dark Souls III AP Client]
|
||||
|
||||
[Dark Souls III AP Client]: https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest
|
||||
|
||||
## Optional Software
|
||||
|
||||
- Map tracker not yet updated for 3.0.0
|
||||
- [Map tracker](https://github.com/TVV1GK/DS3_AP_Maptracker)
|
||||
|
||||
## Setting Up
|
||||
|
||||
@@ -73,3 +75,65 @@ things to keep in mind:
|
||||
|
||||
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
|
||||
[WINE]: https://www.winehq.org/
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Enemy randomizer issues
|
||||
|
||||
The DS3 Archipelago randomizer uses [thefifthmatt's DS3 enemy randomizer],
|
||||
essentially unchanged. Unfortunately, this randomizer has a few known issues,
|
||||
including enemy AI not working, enemies spawning in places they can't be killed,
|
||||
and, in a few rare cases, enemies spawning in ways that crash the game when they
|
||||
load. These bugs should be [reported upstream], but unfortunately the
|
||||
Archipelago devs can't help much with them.
|
||||
|
||||
[thefifthmatt's DS3 enemy randomizer]: https://www.nexusmods.com/darksouls3/mods/484
|
||||
[reported upstream]: https://github.com/thefifthmatt/SoulsRandomizers/issues
|
||||
|
||||
Because in rare cases the enemy randomizer can cause seeds to be impossible to
|
||||
complete, we recommend disabling it for large async multiworlds for safety
|
||||
purposes.
|
||||
|
||||
### `launchmod_darksouls3.bat` isn't working
|
||||
|
||||
Sometimes `launchmod_darksouls3.bat` will briefly flash a terminal on your
|
||||
screen and then terminate without actually starting the game. This is usually
|
||||
caused by some issue communicating with Steam either to find `DarkSoulsIII.exe`
|
||||
or to launch it properly. If this is happening to you, make sure:
|
||||
|
||||
* You have DS3 1.15.2 installed. This is the latest patch as of January 2025.
|
||||
(Note that older versions of Archipelago required an older patch, but that
|
||||
_will not work_ with the current version.)
|
||||
|
||||
* You own the DS3 DLC if your randomizer config has DLC enabled. (It's possible,
|
||||
but unconfirmed, that you need the DLC even when it's disabled in your config).
|
||||
|
||||
* Steam is not running in administrator mode. To fix this, right-click
|
||||
`steam.exe` (by default this is in `C:\Program Files\Steam`), select
|
||||
"Properties", open the "Compatiblity" tab, and uncheck "Run this program as an
|
||||
administrator".
|
||||
|
||||
* There is no `dinput8.dll` file in your DS3 game directory. This is the old way
|
||||
of installing mods, and it can interfere with the new ModEngine2 workflow.
|
||||
|
||||
If you've checked all of these, you can also try:
|
||||
|
||||
* Running `launchmod_darksouls3.bat` as an administrator.
|
||||
|
||||
* Reinstalling DS3 or even reinstalling Steam itself.
|
||||
|
||||
* Making sure DS3 is installed on the same drive as Steam and as the randomizer.
|
||||
(A number of users are able to run these on different drives, but this has
|
||||
helped some users.)
|
||||
|
||||
If none of this works, unfortunately there's not much we can do. We use
|
||||
ModEngine2 to launch DS3 with the Archipelago mod enabled, but unfortunately
|
||||
it's no longer maintained and its successor, ModEngine3, isn't usable yet.
|
||||
|
||||
### `DS3Randomizer.exe` isn't working
|
||||
|
||||
This is almost always caused by using a version of the randomizer client that's
|
||||
not compatible with the version used to generate the multiworld. If you're
|
||||
generating your multiworld on archipelago.gg, you *must* use the latest [Dark
|
||||
Souls III AP Client]. If you want to use a different client version, you *must*
|
||||
generate the multiworld locally using the apworld bundled with the client.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from NetUtils import ClientStatus, color
|
||||
from worlds.AutoSNIClient import SNIClient
|
||||
@@ -32,7 +31,7 @@ class DKC3SNIClient(SNIClient):
|
||||
|
||||
|
||||
async def validate_rom(self, ctx):
|
||||
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
|
||||
from SNIClient import snes_read
|
||||
|
||||
rom_name = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE)
|
||||
if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:2] != b"D3":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
from BaseClasses import Item
|
||||
from .Names import ItemName
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
import typing
|
||||
|
||||
from Options import Choice, Range, Toggle, DeathLink, DefaultOnToggle, OptionGroup, PerGameCommonOptions
|
||||
from Options import Choice, Range, Toggle, DefaultOnToggle, OptionGroup, PerGameCommonOptions
|
||||
|
||||
|
||||
class Goal(Choice):
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import MultiWorld, Region, Entrance
|
||||
from .Items import DKC3Item
|
||||
from BaseClasses import Region, Entrance
|
||||
from worlds.AutoWorld import World
|
||||
from .Locations import DKC3Location
|
||||
from .Names import LocationName, ItemName
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
|
||||
def create_regions(world: World, active_locations):
|
||||
|
||||
@@ -2,7 +2,6 @@ import Utils
|
||||
from Utils import read_snes_rom
|
||||
from worlds.AutoWorld import World
|
||||
from worlds.Files import APDeltaPatch
|
||||
from .Locations import lookup_id_to_name, all_locations
|
||||
from .Levels import level_list, level_dict
|
||||
|
||||
USHASH = '120abf304f0c40fe059f6a192ed4f947'
|
||||
@@ -436,7 +435,7 @@ level_music_ids = [
|
||||
|
||||
class LocalRom:
|
||||
|
||||
def __init__(self, file, patch=True, vanillaRom=None, name=None, hash=None):
|
||||
def __init__(self, file, name=None, hash=None):
|
||||
self.name = name
|
||||
self.hash = hash
|
||||
self.orig_buffer = None
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import math
|
||||
|
||||
from worlds.AutoWorld import World
|
||||
from worlds.generic.Rules import add_rule
|
||||
from .Names import LocationName, ItemName
|
||||
from worlds.AutoWorld import LogicMixin, World
|
||||
from worlds.generic.Rules import add_rule, set_rule
|
||||
|
||||
|
||||
def set_rules(world: World):
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import dataclasses
|
||||
import os
|
||||
import typing
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import typing
|
||||
|
||||
import settings
|
||||
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
|
||||
from Options import PerGameCommonOptions
|
||||
import Patch
|
||||
import settings
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
|
||||
from .Client import DKC3SNIClient
|
||||
from .Items import DKC3Item, ItemData, item_table, inventory_table, junk_table
|
||||
from .Levels import level_list
|
||||
|
||||
@@ -234,8 +234,7 @@ async def game_watcher(ctx: FactorioContext):
|
||||
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||
else:
|
||||
data = data["info"]
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
research_data: set[int] = {int(tech_name.split("-")[1]) for tech_name in data["research_done"]}
|
||||
victory = data["victory"]
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
ctx.multiplayer = data.get("multiplayer", False)
|
||||
@@ -249,7 +248,7 @@ async def game_watcher(ctx: FactorioContext):
|
||||
f"New researches done: "
|
||||
f"{[ctx.location_names.lookup_in_game(rid) for rid in research_data - ctx.locations_checked]}")
|
||||
ctx.locations_checked = research_data
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||
await ctx.check_locations(research_data)
|
||||
death_link_tick = data.get("death_link_tick", 0)
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
|
||||
@@ -37,8 +37,8 @@ base_info = {
|
||||
"description": "Integration client for the Archipelago Randomizer",
|
||||
"factorio_version": "2.0",
|
||||
"dependencies": [
|
||||
"base >= 2.0.15",
|
||||
"? quality >= 2.0.15",
|
||||
"base >= 2.0.28",
|
||||
"? quality >= 2.0.28",
|
||||
"! space-age",
|
||||
"? science-not-invited",
|
||||
"? factory-levels"
|
||||
|
||||
@@ -3,13 +3,23 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import typing
|
||||
|
||||
from schema import Schema, Optional, And, Or
|
||||
from schema import Schema, Optional, And, Or, SchemaError
|
||||
|
||||
from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \
|
||||
StartInventoryPool, PerGameCommonOptions, OptionGroup
|
||||
|
||||
# schema helpers
|
||||
FloatRange = lambda low, high: And(Or(int, float), lambda f: low <= f <= high)
|
||||
class FloatRange:
|
||||
def __init__(self, low, high):
|
||||
self._low = low
|
||||
self._high = high
|
||||
|
||||
def validate(self, value):
|
||||
if not isinstance(value, (float, int)):
|
||||
raise SchemaError(f"should be instance of float or int, but was {value!r}")
|
||||
if not self._low <= value <= self._high:
|
||||
raise SchemaError(f"{value} is not between {self._low} and {self._high}")
|
||||
|
||||
LuaBool = Or(bool, And(int, lambda n: n in (0, 1)))
|
||||
|
||||
|
||||
@@ -225,6 +235,12 @@ class FactorioStartItems(OptionDict):
|
||||
"""Mapping of Factorio internal item-name to amount granted on start."""
|
||||
display_name = "Starting Items"
|
||||
default = {"burner-mining-drill": 4, "stone-furnace": 4, "raw-fish": 50}
|
||||
schema = Schema(
|
||||
{
|
||||
str: And(int, lambda n: n > 0,
|
||||
error="amount of starting items has to be a positive integer"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FactorioFreeSampleBlacklist(OptionSet):
|
||||
@@ -247,7 +263,8 @@ class AttackTrapCount(TrapCount):
|
||||
|
||||
|
||||
class TeleportTrapCount(TrapCount):
|
||||
"""Trap items that when received trigger a random teleport."""
|
||||
"""Trap items that when received trigger a random teleport.
|
||||
It is ensured the player can walk back to where they got teleported from."""
|
||||
display_name = "Teleport Traps"
|
||||
|
||||
|
||||
@@ -294,6 +311,11 @@ class EvolutionTrapIncrease(Range):
|
||||
range_end = 100
|
||||
|
||||
|
||||
class InventorySpillTrapCount(TrapCount):
|
||||
"""Trap items that when received trigger dropping your main inventory and trash inventory onto the ground."""
|
||||
display_name = "Inventory Spill Traps"
|
||||
|
||||
|
||||
class FactorioWorldGen(OptionDict):
|
||||
"""World Generation settings. Overview of options at https://wiki.factorio.com/Map_generator,
|
||||
with in-depth documentation at https://lua-api.factorio.com/latest/Concepts.html#MapGenSettings"""
|
||||
@@ -474,6 +496,7 @@ class FactorioOptions(PerGameCommonOptions):
|
||||
artillery_traps: ArtilleryTrapCount
|
||||
atomic_rocket_traps: AtomicRocketTrapCount
|
||||
atomic_cliff_remover_traps: AtomicCliffRemoverTrapCount
|
||||
inventory_spill_traps: InventorySpillTrapCount
|
||||
attack_traps: AttackTrapCount
|
||||
evolution_traps: EvolutionTrapCount
|
||||
evolution_trap_increase: EvolutionTrapIncrease
|
||||
@@ -508,6 +531,7 @@ option_groups: list[OptionGroup] = [
|
||||
ArtilleryTrapCount,
|
||||
AtomicRocketTrapCount,
|
||||
AtomicCliffRemoverTrapCount,
|
||||
InventorySpillTrapCount,
|
||||
],
|
||||
start_collapsed=True
|
||||
),
|
||||
|
||||
@@ -63,17 +63,19 @@ class FactorioElement:
|
||||
|
||||
|
||||
class Technology(FactorioElement): # maybe make subclass of Location?
|
||||
has_modifier: bool
|
||||
factorio_id: int
|
||||
progressive: Tuple[str]
|
||||
unlocks: Union[Set[str], bool] # bool case is for progressive technologies
|
||||
modifiers: list[str]
|
||||
|
||||
def __init__(self, technology_name: str, factorio_id: int, progressive: Tuple[str] = (),
|
||||
has_modifier: bool = False, unlocks: Union[Set[str], bool] = None):
|
||||
modifiers: list[str] = None, unlocks: Union[Set[str], bool] = None):
|
||||
self.name = technology_name
|
||||
self.factorio_id = factorio_id
|
||||
self.progressive = progressive
|
||||
self.has_modifier = has_modifier
|
||||
if modifiers is None:
|
||||
modifiers = []
|
||||
self.modifiers = modifiers
|
||||
if unlocks:
|
||||
self.unlocks = unlocks
|
||||
else:
|
||||
@@ -82,6 +84,10 @@ class Technology(FactorioElement): # maybe make subclass of Location?
|
||||
def __hash__(self):
|
||||
return self.factorio_id
|
||||
|
||||
@property
|
||||
def has_modifier(self) -> bool:
|
||||
return bool(self.modifiers)
|
||||
|
||||
def get_custom(self, world, allowed_packs: Set[str], player: int) -> CustomTechnology:
|
||||
return CustomTechnology(self, world, allowed_packs, player)
|
||||
|
||||
@@ -191,13 +197,14 @@ class Machine(FactorioElement):
|
||||
|
||||
|
||||
recipe_sources: Dict[str, Set[str]] = {} # recipe_name -> technology source
|
||||
mining_with_fluid_sources: set[str] = set()
|
||||
|
||||
# recipes and technologies can share names in Factorio
|
||||
for technology_name, data in sorted(techs_future.result().items()):
|
||||
technology = Technology(
|
||||
technology_name,
|
||||
factorio_tech_id,
|
||||
has_modifier=data["has_modifier"],
|
||||
modifiers=data.get("modifiers", []),
|
||||
unlocks=set(data["unlocks"]) - start_unlocked_recipes,
|
||||
)
|
||||
factorio_tech_id += 1
|
||||
@@ -205,7 +212,8 @@ for technology_name, data in sorted(techs_future.result().items()):
|
||||
technology_table[technology_name] = technology
|
||||
for recipe_name in technology.unlocks:
|
||||
recipe_sources.setdefault(recipe_name, set()).add(technology_name)
|
||||
|
||||
if "mining-with-fluid" in technology.modifiers:
|
||||
mining_with_fluid_sources.add(technology_name)
|
||||
del techs_future
|
||||
|
||||
recipes = {}
|
||||
@@ -221,6 +229,8 @@ for resource_name, resource_data in resources_future.result().items():
|
||||
"energy": resource_data["mining_time"],
|
||||
"category": resource_data["category"]
|
||||
}
|
||||
if "required_fluid" in resource_data:
|
||||
recipe_sources.setdefault(f"mining-{resource_name}", set()).update(mining_with_fluid_sources)
|
||||
del resources_future
|
||||
|
||||
for recipe_name, recipe_data in raw_recipes.items():
|
||||
@@ -431,7 +441,9 @@ for root in sorted_rows:
|
||||
factorio_tech_id += 1
|
||||
progressive_technology = Technology(root, factorio_tech_id,
|
||||
tuple(progressive),
|
||||
has_modifier=any(technology_table[tech].has_modifier for tech in progressive),
|
||||
modifiers=sorted(set.union(
|
||||
*(set(technology_table[tech].modifiers) for tech in progressive)
|
||||
)),
|
||||
unlocks=any(technology_table[tech].unlocks for tech in progressive),)
|
||||
progressive_tech_table[root] = progressive_technology.factorio_id
|
||||
progressive_technology_table[root] = progressive_technology
|
||||
|
||||
@@ -8,7 +8,7 @@ import Utils
|
||||
import settings
|
||||
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from worlds.LauncherComponents import Component, components, Type, launch_subprocess
|
||||
from worlds.LauncherComponents import Component, components, Type, launch as launch_component
|
||||
from worlds.generic import Rules
|
||||
from .Locations import location_pools, location_table
|
||||
from .Mod import generate_mod
|
||||
@@ -24,7 +24,7 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table
|
||||
|
||||
def launch_client():
|
||||
from .Client import launch
|
||||
launch_subprocess(launch, name="FactorioClient")
|
||||
launch_component(launch, name="FactorioClient")
|
||||
|
||||
|
||||
components.append(Component("Factorio Client", "FactorioClient", func=launch_client, component_type=Type.CLIENT))
|
||||
@@ -78,6 +78,7 @@ all_items["Cluster Grenade Trap"] = factorio_base_id - 5
|
||||
all_items["Artillery Trap"] = factorio_base_id - 6
|
||||
all_items["Atomic Rocket Trap"] = factorio_base_id - 7
|
||||
all_items["Atomic Cliff Remover Trap"] = factorio_base_id - 8
|
||||
all_items["Inventory Spill Trap"] = factorio_base_id - 9
|
||||
|
||||
|
||||
class Factorio(World):
|
||||
@@ -112,6 +113,8 @@ class Factorio(World):
|
||||
science_locations: typing.List[FactorioScienceLocation]
|
||||
removed_technologies: typing.Set[str]
|
||||
settings: typing.ClassVar[FactorioSettings]
|
||||
trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery",
|
||||
"Atomic Rocket", "Atomic Cliff Remover", "Inventory Spill")
|
||||
|
||||
def __init__(self, world, player: int):
|
||||
super(Factorio, self).__init__(world, player)
|
||||
@@ -136,15 +139,11 @@ class Factorio(World):
|
||||
random = self.random
|
||||
nauvis = Region("Nauvis", player, self.multiworld)
|
||||
|
||||
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \
|
||||
self.options.evolution_traps + \
|
||||
self.options.attack_traps + \
|
||||
self.options.teleport_traps + \
|
||||
self.options.grenade_traps + \
|
||||
self.options.cluster_grenade_traps + \
|
||||
self.options.atomic_rocket_traps + \
|
||||
self.options.atomic_cliff_remover_traps + \
|
||||
self.options.artillery_traps
|
||||
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo
|
||||
|
||||
for name in self.trap_names:
|
||||
name = name.replace(" ", "_").lower()+"_traps"
|
||||
location_count += getattr(self.options, name)
|
||||
|
||||
location_pool = []
|
||||
|
||||
@@ -196,9 +195,8 @@ class Factorio(World):
|
||||
def create_items(self) -> None:
|
||||
self.custom_technologies = self.set_custom_technologies()
|
||||
self.set_custom_recipes()
|
||||
traps = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", "Atomic Rocket",
|
||||
"Atomic Cliff Remover")
|
||||
for trap_name in traps:
|
||||
|
||||
for trap_name in self.trap_names:
|
||||
self.multiworld.itempool.extend(self.create_item(f"{trap_name} Trap") for _ in
|
||||
range(getattr(self.options,
|
||||
f"{trap_name.lower().replace(' ', '_')}_traps")))
|
||||
@@ -280,9 +278,6 @@ class Factorio(World):
|
||||
self.get_location("Rocket Launch").access_rule = lambda state: all(state.has(technology, player)
|
||||
for technology in
|
||||
victory_tech_names)
|
||||
for tech_name in victory_tech_names:
|
||||
if not self.multiworld.get_all_state(True).has(tech_name, player):
|
||||
print(tech_name)
|
||||
self.multiworld.completion_condition[player] = lambda state: state.has('Victory', player)
|
||||
|
||||
def get_recipe(self, name: str) -> Recipe:
|
||||
|
||||
@@ -48,3 +48,107 @@ function fire_entity_at_entities(entity_name, entities, speed)
|
||||
target=target, speed=speed}
|
||||
end
|
||||
end
|
||||
|
||||
local teleport_requests = {}
|
||||
local teleport_attempts = {}
|
||||
local max_attempts = 100
|
||||
|
||||
function attempt_teleport_player(player, attempt)
|
||||
-- global attempt storage as metadata can't be stored
|
||||
if attempt == nil then
|
||||
attempt = teleport_attempts[player.index]
|
||||
else
|
||||
teleport_attempts[player.index] = attempt
|
||||
end
|
||||
|
||||
if attempt > max_attempts then
|
||||
player.print("Teleport failed: No valid position found after " .. max_attempts .. " attempts!")
|
||||
teleport_attempts[player.index] = 0
|
||||
return
|
||||
end
|
||||
|
||||
local surface = player.character.surface
|
||||
local prototype_name = player.character.prototype.name
|
||||
local original_position = player.character.position
|
||||
local candidate_position = random_offset_position(original_position, 1024)
|
||||
|
||||
local non_colliding_position = surface.find_non_colliding_position(
|
||||
prototype_name, candidate_position, 0, 1
|
||||
)
|
||||
|
||||
if non_colliding_position then
|
||||
-- Request pathfinding asynchronously
|
||||
local path_id = surface.request_path{
|
||||
bounding_box = player.character.prototype.collision_box,
|
||||
collision_mask = { layers = { ["player"] = true } },
|
||||
start = original_position,
|
||||
goal = non_colliding_position,
|
||||
force = player.force.name,
|
||||
radius = 1,
|
||||
pathfind_flags = {cache = true, low_priority = true, allow_paths_through_own_entities = true},
|
||||
}
|
||||
|
||||
-- Store the request with the player index as the key
|
||||
teleport_requests[player.index] = path_id
|
||||
else
|
||||
attempt_teleport_player(player, attempt + 1)
|
||||
end
|
||||
end
|
||||
|
||||
function handle_teleport_attempt(event)
|
||||
for player_index, path_id in pairs(teleport_requests) do
|
||||
-- Check if the event matches the stored path_id
|
||||
if path_id == event.id then
|
||||
local player = game.players[player_index]
|
||||
|
||||
if event.path then
|
||||
if player.character then
|
||||
player.character.teleport(event.path[#event.path].position) -- Teleport to the last point in the path
|
||||
-- Clear the attempts for this player
|
||||
teleport_attempts[player_index] = 0
|
||||
return
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
attempt_teleport_player(player, nil)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
function spill_character_inventory(character)
|
||||
if not (character and character.valid) then
|
||||
return false
|
||||
end
|
||||
|
||||
-- grab attrs once pre-loop
|
||||
local position = character.position
|
||||
local surface = character.surface
|
||||
|
||||
local inventories_to_spill = {
|
||||
defines.inventory.character_main, -- Main inventory
|
||||
defines.inventory.character_trash, -- Logistic trash slots
|
||||
}
|
||||
|
||||
for _, inventory_type in pairs(inventories_to_spill) do
|
||||
local inventory = character.get_inventory(inventory_type)
|
||||
if inventory and inventory.valid then
|
||||
-- Spill each item stack onto the ground
|
||||
for i = 1, #inventory do
|
||||
local stack = inventory[i]
|
||||
if stack and stack.valid_for_read then
|
||||
local spilled_items = surface.spill_item_stack{
|
||||
position = position,
|
||||
stack = stack,
|
||||
enable_looted = false, -- do not mark for auto-pickup
|
||||
force = nil, -- do not mark for auto-deconstruction
|
||||
allow_belts = true, -- do mark for putting it onto belts
|
||||
}
|
||||
if #spilled_items > 0 then
|
||||
stack.clear() -- only delete if spilled successfully
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user