mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-19 22:08:21 -07:00
Compare commits
3 Commits
0-6-0-rc1
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e79854a8 | ||
|
|
2e81774a1f | ||
|
|
409c915375 |
16
.github/pyright-config.json
vendored
16
.github/pyright-config.json
vendored
@@ -1,20 +1,8 @@
|
||||
{
|
||||
"include": [
|
||||
"../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",
|
||||
"type_check.py",
|
||||
"../worlds/AutoSNIClient.py",
|
||||
"type_check.py"
|
||||
"../Patch.py"
|
||||
],
|
||||
|
||||
"exclude": [
|
||||
|
||||
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.392.post0
|
||||
python -m pip install --upgrade pip pyright==1.1.358
|
||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||
|
||||
- name: "pyright: strict check on specific files"
|
||||
|
||||
@@ -19,7 +19,6 @@ import Options
|
||||
import Utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from entrance_rando import ERPlacementState
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
@@ -427,12 +426,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, allow_partial_entrances: bool = False) -> CollectionState:
|
||||
def get_all_state(self, use_cache: bool) -> CollectionState:
|
||||
cached = getattr(self, "_all_state", None)
|
||||
if use_cache and cached:
|
||||
return cached.copy()
|
||||
|
||||
ret = CollectionState(self, allow_partial_entrances)
|
||||
ret = CollectionState(self)
|
||||
|
||||
for item in self.itempool:
|
||||
self.worlds[item.player].collect(ret, item)
|
||||
@@ -718,11 +717,10 @@ 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, allow_partial_entrances: bool = False):
|
||||
def __init__(self, parent: MultiWorld):
|
||||
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()}
|
||||
@@ -731,7 +729,6 @@ 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():
|
||||
@@ -766,8 +763,6 @@ 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)
|
||||
@@ -793,9 +788,7 @@ 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"
|
||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
||||
reachable_regions.add(new_region)
|
||||
blocked_connections.remove(connection)
|
||||
blocked_connections.update(new_region.exits)
|
||||
@@ -815,7 +808,6 @@ 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
|
||||
@@ -980,11 +972,6 @@ 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
|
||||
@@ -992,24 +979,19 @@ 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,
|
||||
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> 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 self not in state.path:
|
||||
if not self.hide_path and not self in state.path:
|
||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||
return True
|
||||
|
||||
@@ -1021,32 +1003,6 @@ 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})'
|
||||
@@ -1196,16 +1152,6 @@ 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]:
|
||||
"""
|
||||
@@ -1308,26 +1254,13 @@ class Location:
|
||||
|
||||
|
||||
class ItemClassification(IntFlag):
|
||||
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. """
|
||||
|
||||
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.
|
||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
||||
|
||||
def as_flag(self) -> int:
|
||||
|
||||
@@ -31,7 +31,6 @@ import ssl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import kvui
|
||||
import argparse
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
@@ -460,13 +459,6 @@ 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()
|
||||
@@ -1049,32 +1041,6 @@ 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
|
||||
@@ -1116,7 +1082,17 @@ def run_as_textclient(*args):
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
args = handle_url_arg(args, parser=parser)
|
||||
# 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")
|
||||
|
||||
# use colorama to display colored text highlighting on windows
|
||||
colorama.init()
|
||||
|
||||
43
Fill.py
43
Fill.py
@@ -235,30 +235,18 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
locations: typing.List[Location],
|
||||
itempool: typing.List[Item],
|
||||
name: str = "Remaining",
|
||||
move_unplaceable_to_start_inventory: bool = False,
|
||||
check_location_can_fill: bool = False) -> None:
|
||||
move_unplaceable_to_start_inventory: 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_can_fill_item(location, item_to_place):
|
||||
if location.item_rule(item_to_place):
|
||||
# popping by index is faster than removing by content,
|
||||
spot_to_fill = locations.pop(i)
|
||||
# skipping a scan for the element
|
||||
@@ -279,7 +267,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
if location_can_fill_item(location, item_to_place):
|
||||
if location.item_rule(item_to_place):
|
||||
# Add this item to the existing placement, and
|
||||
# add the old item to the back of the queue
|
||||
spot_to_fill = placements.pop(i)
|
||||
@@ -531,8 +519,7 @@ 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.\n"
|
||||
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
|
||||
f"There are {len(progitempool)} more progression items than there are available locations.",
|
||||
multiworld=multiworld,
|
||||
)
|
||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||
@@ -550,7 +537,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 excludable items.",
|
||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
|
||||
multiworld=multiworld,
|
||||
)
|
||||
|
||||
@@ -571,26 +558,6 @@ 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,9 +42,7 @@ 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=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('--log_level', default='info', help='Sets log level')
|
||||
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,
|
||||
@@ -77,7 +75,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
|
||||
@@ -440,7 +438,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
valid_keys = {"triggers"}
|
||||
valid_keys = set()
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
||||
|
||||
@@ -499,23 +497,15 @@ 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)
|
||||
|
||||
# TODO remove plando_items after moving it to the options system
|
||||
valid_keys.add("plando_items")
|
||||
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.")
|
||||
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) 2025 Berserker66
|
||||
Copyright (c) 2022 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(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
||||
ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
||||
else:
|
||||
ok = response.startswith(command.encode())
|
||||
if not ok:
|
||||
@@ -560,10 +560,6 @@ 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()
|
||||
|
||||
|
||||
@@ -444,7 +444,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: set(slot_info.group_members) 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()
|
||||
if slot_info.type == SlotType.group}
|
||||
|
||||
self.clients = {0: {}}
|
||||
@@ -743,17 +743,16 @@ 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
|
||||
|
||||
# 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)
|
||||
# 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:
|
||||
@@ -1888,8 +1887,7 @@ 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": 'Locations has to be a list of integers',
|
||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
|
||||
@@ -1992,7 +1990,6 @@ 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"])
|
||||
|
||||
33
NetUtils.py
33
NetUtils.py
@@ -10,14 +10,6 @@ import websockets
|
||||
from Utils import ByValue, Version
|
||||
|
||||
|
||||
class HintStatus(ByValue, enum.IntEnum):
|
||||
HINT_FOUND = 0
|
||||
HINT_UNSPECIFIED = 1
|
||||
HINT_NO_PRIORITY = 10
|
||||
HINT_AVOID = 20
|
||||
HINT_PRIORITY = 30
|
||||
|
||||
|
||||
class JSONMessagePart(typing.TypedDict, total=False):
|
||||
text: str
|
||||
# optional
|
||||
@@ -27,8 +19,6 @@ 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):
|
||||
@@ -39,6 +29,14 @@ 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
|
||||
@@ -194,7 +192,6 @@ 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):
|
||||
@@ -276,10 +273,6 @@ 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):
|
||||
@@ -326,13 +319,6 @@ 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
|
||||
@@ -377,7 +363,8 @@ class Hint(typing.NamedTuple):
|
||||
else:
|
||||
add_json_text(parts, "'s World")
|
||||
add_json_text(parts, ". ")
|
||||
add_json_hint_status(parts, self.status)
|
||||
add_json_text(parts, status_names.get(self.status, "(unknown)"), type="color",
|
||||
color=status_colors.get(self.status, "red"))
|
||||
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||
"receiving": self.receiving_player,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import tkinter as tk
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
@@ -196,6 +197,7 @@ 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()):
|
||||
|
||||
52
Options.py
52
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 `WebWorld.rich_text_options_doc`. For
|
||||
If this is None, it inherits the value of `World.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, 0.0))
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
||||
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], 0.0))
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[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], 1.0))
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
||||
else:
|
||||
return cls(random.randint(random_range[0], random_range[1]))
|
||||
|
||||
@@ -739,16 +739,8 @@ class Range(NumericOption):
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
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))
|
||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
||||
return int(round(random.triangular(lower, end, tri), 0))
|
||||
|
||||
|
||||
class NamedRange(Range):
|
||||
@@ -825,15 +817,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]:
|
||||
@@ -1119,11 +1111,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:
|
||||
@@ -1387,8 +1379,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})
|
||||
|
||||
@@ -79,7 +79,6 @@ 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,9 +243,6 @@ 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
|
||||
|
||||
26
Utils.py
26
Utils.py
@@ -152,15 +152,8 @@ def home_path(*path: str) -> str:
|
||||
if hasattr(home_path, 'cached_path'):
|
||||
pass
|
||||
elif sys.platform.startswith('linux'):
|
||||
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)
|
||||
home_path.cached_path = os.path.expanduser('~/Archipelago')
|
||||
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
|
||||
@@ -541,8 +534,7 @@ 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),
|
||||
extra={"NoStream": exception_logger is None})
|
||||
exc_info=(exc_type, exc_value, exc_traceback))
|
||||
return orig_hook(exc_type, exc_value, exc_traceback)
|
||||
|
||||
handle_exception._wrapped = True
|
||||
@@ -940,7 +932,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, regions_to_highlight: set[Region] | None = None) -> None:
|
||||
linetype_ortho: bool = True) -> 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.)
|
||||
@@ -956,22 +948,16 @@ 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
|
||||
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])
|
||||
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
|
||||
|
||||
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
|
||||
@@ -1024,7 +1010,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)}\" {'#00FF00' if region in regions_to_highlight else ''}")
|
||||
uml.append(f"class \"{fmt(region)}\"")
|
||||
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, Slot
|
||||
from ..models import Seed
|
||||
|
||||
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.order_by(Slot.player_id)]
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
||||
|
||||
|
||||
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)
|
||||
@@ -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": 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)),
|
||||
"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),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||
"server_password": str(options_source.get("server_password", None)),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
}
|
||||
generator_options = {
|
||||
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% block footer %}
|
||||
<footer id="island-footer">
|
||||
<div id="copyright-notice">Copyright 2025 Archipelago</div>
|
||||
<div id="copyright-notice">Copyright 2024 Archipelago</div>
|
||||
<div id="links">
|
||||
<a href="/sitemap">Site Map</a>
|
||||
-
|
||||
|
||||
@@ -147,8 +147,3 @@
|
||||
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,14 +121,6 @@ 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`
|
||||
@@ -224,12 +216,6 @@ 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.
|
||||
|
||||
@@ -390,15 +376,6 @@ 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"])
|
||||
@@ -636,11 +613,9 @@ 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 APreceive()
|
||||
function receive()
|
||||
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
|
||||
APreceive()
|
||||
receive()
|
||||
end
|
||||
elseif (curstate == STATE_UNINITIALIZED) then
|
||||
if (frame % 60 == 0) then
|
||||
|
||||
@@ -81,9 +81,6 @@
|
||||
# Hylics 2
|
||||
/worlds/hylics2/ @TRPG0
|
||||
|
||||
# Inscryption
|
||||
/worlds/inscryption/ @DrBibop @Glowbuzz
|
||||
|
||||
# Kirby's Dream Land 3
|
||||
/worlds/kdl3/ @Silvris
|
||||
|
||||
@@ -99,9 +96,6 @@
|
||||
# Lingo
|
||||
/worlds/lingo/ @hatkirby
|
||||
|
||||
# Links Awakening DX
|
||||
/worlds/ladx/ @threeandthreee
|
||||
|
||||
# Lufia II Ancient Cave
|
||||
/worlds/lufia2ac/ @el-u
|
||||
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
||||
@@ -155,7 +149,7 @@
|
||||
/worlds/saving_princess/ @LeonarthCG
|
||||
|
||||
# Shivers
|
||||
/worlds/shivers/ @GodlFire @korydondzila
|
||||
/worlds/shivers/ @GodlFire
|
||||
|
||||
# A Short Hike
|
||||
/worlds/shorthike/ @chandler05 @BrandenEK
|
||||
@@ -239,6 +233,9 @@
|
||||
# Final Fantasy (1)
|
||||
# /worlds/ff1/
|
||||
|
||||
# Links Awakening DX
|
||||
# /worlds/ladx/
|
||||
|
||||
# Ocarina of Time
|
||||
# /worlds/oot/
|
||||
|
||||
|
||||
@@ -43,26 +43,3 @@ 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.
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
# 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 short answer is that you will almost always want to do ER in `pre_fill`. For more information why, continue reading.
|
||||
|
||||
ER begins by collecting the entire item pool and then uses your access rules to try and prevent some kinds of failures.
|
||||
This means 2 things about when you can call ER:
|
||||
1. You must supply your item pool before calling ER, or call ER before setting any rules which require items.
|
||||
2. If you have rules dependent on anything other than items (e.g. `Entrance`s or events), you must set your rules
|
||||
and create your events before you call ER if you want to guarantee a correct output.
|
||||
|
||||
If the conditions above are met, you could theoretically do ER as early as `create_regions`. However, plando is also
|
||||
a consideration. Since item plando happens between `set_rules` and `pre_fill` and modifies the item pool, doing ER
|
||||
in `pre_fill` is the only way to account for placements made by item plando, otherwise you risk impossible seeds or
|
||||
generation failures. Obviously, if your world implements entrance plando, you will likely want to do that before ER as
|
||||
well.
|
||||
|
||||
#### 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.
|
||||
@@ -261,7 +261,6 @@ 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.
|
||||
|
||||
@@ -541,7 +540,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 especially useful |
|
||||
| 0b010 | If set, indicates the item is important but not in a way that unlocks advancement |
|
||||
| 0b100 | If set, indicates the item is a trap |
|
||||
|
||||
### JSONMessagePart
|
||||
@@ -555,7 +554,6 @@ 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.
|
||||
@@ -571,7 +569,6 @@ 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. |
|
||||
|
||||
|
||||
|
||||
@@ -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 `WebWorld.rich_text_options_doc = True`.
|
||||
reStructuredText and enable rich text rendering by setting `World.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 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`
|
||||
* 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`
|
||||
|
||||
|
||||
## macOS
|
||||
|
||||
@@ -248,8 +248,7 @@ will all have the same ID. Name must not be numeric (must contain at least 1 let
|
||||
Other classifications include:
|
||||
|
||||
* `filler`: a regular item or trash item
|
||||
* `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".
|
||||
* `useful`: generally quite useful, but not required for anything logical. Cannot be placed on excluded locations
|
||||
* `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)
|
||||
@@ -700,92 +699,9 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
#### pre_fill
|
||||
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
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 = sum(er_state.collection_state.prog_items[world.player].values())
|
||||
# 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 loc.can_reach(er_state.collection_state):
|
||||
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
|
||||
65
kvui.py
65
kvui.py
@@ -40,7 +40,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, NumericProperty
|
||||
from kivy.properties import BooleanProperty, ObjectProperty
|
||||
from kivy.metrics import dp
|
||||
from kivy.effects.scroll import ScrollEffect
|
||||
from kivy.uix.widget import Widget
|
||||
@@ -64,7 +64,6 @@ 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)
|
||||
@@ -306,50 +305,6 @@ 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)
|
||||
@@ -615,10 +570,8 @@ class GameManager(App):
|
||||
# show Archipelago tab if other logging is present
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
hint_panel = self.add_client_tab("Hints", HintLayout())
|
||||
self.hint_log = HintLog(self.json_to_kivy_parser)
|
||||
hint_panel = self.add_client_tab("Hints", 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"
|
||||
@@ -745,7 +698,7 @@ class GameManager(App):
|
||||
|
||||
def update_hints(self):
|
||||
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
|
||||
self.hint_log.refresh_hints(hints)
|
||||
self.log_panels["Hints"].refresh_hints(hints)
|
||||
|
||||
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
|
||||
def open_settings(self, *largs):
|
||||
@@ -800,17 +753,6 @@ 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",
|
||||
@@ -827,7 +769,6 @@ status_colors: typing.Dict[HintStatus, str] = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
class HintLog(RecycleView):
|
||||
header = {
|
||||
"receiving": {"text": "[u]Receiving Player[/u]"},
|
||||
|
||||
@@ -2,6 +2,3 @@
|
||||
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.12.14
|
||||
certifi>=2024.8.30
|
||||
cython>=3.0.11
|
||||
cymem>=2.0.8
|
||||
orjson>=3.10.7
|
||||
|
||||
@@ -678,8 +678,6 @@ 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):
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
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_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)
|
||||
@@ -1,8 +1,6 @@
|
||||
import unittest
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from BaseClasses import CollectionState, MultiWorld, Region
|
||||
|
||||
|
||||
@@ -10,7 +8,6 @@ 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"
|
||||
@@ -41,15 +38,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"):
|
||||
@@ -76,7 +73,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"}:
|
||||
if game_name in {"Ocarina of Time", "Zillion"}:
|
||||
continue
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
with self.subTest(game=game_name, seed=multiworld.seed):
|
||||
@@ -52,77 +52,3 @@ 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)
|
||||
|
||||
@@ -5,7 +5,7 @@ from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestWorldMemory(unittest.TestCase):
|
||||
def test_leak(self) -> None:
|
||||
def test_leak(self):
|
||||
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
|
||||
import gc
|
||||
import weakref
|
||||
|
||||
@@ -3,7 +3,7 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestNames(unittest.TestCase):
|
||||
def test_item_names_format(self) -> None:
|
||||
def test_item_names_format(self):
|
||||
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
@@ -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) -> None:
|
||||
def test_location_name_format(self):
|
||||
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
|
||||
@@ -86,7 +86,3 @@ 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, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
|
||||
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping, Optional, Set, TextIO, Tuple,
|
||||
TYPE_CHECKING, Type, Union)
|
||||
|
||||
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
||||
@@ -534,24 +534,12 @@ 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,42 +18,16 @@ 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,
|
||||
|
||||
@@ -10,7 +10,7 @@ import base64
|
||||
import enum
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Sequence
|
||||
import typing
|
||||
|
||||
|
||||
BIZHAWK_SOCKET_PORT_RANGE_START = 43055
|
||||
@@ -44,10 +44,10 @@ class SyncError(Exception):
|
||||
|
||||
|
||||
class BizHawkContext:
|
||||
streams: tuple[asyncio.StreamReader, asyncio.StreamWriter] | None
|
||||
streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]]
|
||||
connection_status: ConnectionStatus
|
||||
_lock: asyncio.Lock
|
||||
_port: int | None
|
||||
_port: typing.Optional[int]
|
||||
|
||||
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: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[str, typing.Any]]) -> typing.List[typing.Dict[str, typing.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: list[ConnectorError] = []
|
||||
errors: typing.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 hash value of the currently loaded ROM"""
|
||||
"""Gets the system name for the currently loaded ROM"""
|
||||
res = (await send_requests(ctx, [{"type": "HASH"}]))[0]
|
||||
|
||||
if res["type"] != "HASH_RESPONSE":
|
||||
@@ -160,16 +160,6 @@ 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]
|
||||
@@ -180,7 +170,7 @@ async def get_system(ctx: BizHawkContext) -> str:
|
||||
return res["value"]
|
||||
|
||||
|
||||
async def get_cores(ctx: BizHawkContext) -> dict[str, str]:
|
||||
async def get_cores(ctx: BizHawkContext) -> typing.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]
|
||||
@@ -233,8 +223,8 @@ async def set_message_interval(ctx: BizHawkContext, value: float) -> None:
|
||||
raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}")
|
||||
|
||||
|
||||
async def guarded_read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]],
|
||||
guard_list: Sequence[tuple[int, Sequence[int], str]]) -> list[bytes] | None:
|
||||
async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]],
|
||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> typing.Optional[typing.List[bytes]]:
|
||||
"""Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
|
||||
value.
|
||||
|
||||
@@ -262,7 +252,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int,
|
||||
"domain": domain
|
||||
} for address, size, domain in read_list])
|
||||
|
||||
ret: list[bytes] = []
|
||||
ret: typing.List[bytes] = []
|
||||
for item in res:
|
||||
if item["type"] == "GUARD_RESPONSE":
|
||||
if not item["value"]:
|
||||
@@ -276,7 +266,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int,
|
||||
return ret
|
||||
|
||||
|
||||
async def read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]]) -> list[bytes]:
|
||||
async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
|
||||
"""Reads data at 1 or more addresses.
|
||||
|
||||
Items in `read_list` should be organized `(address, size, domain)` where
|
||||
@@ -288,8 +278,8 @@ async def read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]]) -
|
||||
return await guarded_read(ctx, read_list, [])
|
||||
|
||||
|
||||
async def guarded_write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]],
|
||||
guard_list: Sequence[tuple[int, Sequence[int], str]]) -> bool:
|
||||
async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]],
|
||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> bool:
|
||||
"""Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value.
|
||||
|
||||
Items in `write_list` should be organized `(address, value, domain)` where
|
||||
@@ -326,7 +316,7 @@ async def guarded_write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Seq
|
||||
return True
|
||||
|
||||
|
||||
async def write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]]) -> None:
|
||||
async def write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> None:
|
||||
"""Writes data to 1 or more addresses.
|
||||
|
||||
Items in write_list should be organized `(address, value, domain)` where
|
||||
|
||||
@@ -5,7 +5,7 @@ A module containing the BizHawkClient base class and metaclass
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Tuple, Union
|
||||
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess
|
||||
|
||||
@@ -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) -> BizHawkClient | None:
|
||||
async def get_handler(ctx: "BizHawkClientContext", system: str) -> Optional[BizHawkClient]:
|
||||
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[str | tuple[str, ...]]
|
||||
system: ClassVar[Union[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[str | tuple[str, ...] | None]
|
||||
patch_suffix: ClassVar[Optional[Union[str, Tuple[str, ...]]]]
|
||||
"""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
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
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: BizHawkClient | None
|
||||
slot_data: dict[str, Any] | None = None
|
||||
rom_hash: str | None = None
|
||||
client_handler: Optional[BizHawkClient]
|
||||
slot_data: Optional[Dict[str, Any]] = None
|
||||
rom_hash: Optional[str] = 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: str | None, password: str | None):
|
||||
def __init__(self, server_address: Optional[str], password: Optional[str]):
|
||||
super().__init__(server_address, password)
|
||||
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
||||
self.password_requested = False
|
||||
@@ -231,27 +231,20 @@ async def _run_game(rom: str):
|
||||
)
|
||||
|
||||
|
||||
def _patch_and_run_game(patch_file: str):
|
||||
async 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)
|
||||
return {}
|
||||
|
||||
|
||||
def launch(*launch_args: str) -> None:
|
||||
def launch(*launch_args) -> None:
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
|
||||
args = parser.parse_args(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")
|
||||
|
||||
@@ -259,6 +252,9 @@ def launch(*launch_args: str) -> 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,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict
|
||||
|
||||
from Options import Choice, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Option, 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, AdventureLocation, dragon_room_to_region
|
||||
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
|
||||
|
||||
|
||||
def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
|
||||
|
||||
@@ -2,14 +2,14 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from typing import Any
|
||||
|
||||
import bsdiff4
|
||||
from typing import Optional, Any
|
||||
|
||||
import Utils
|
||||
from .Locations import AdventureLocation, LocationData
|
||||
from settings import get_settings
|
||||
from worlds.Files import APPatch, AutoPatchRegister
|
||||
from .Locations import LocationData
|
||||
|
||||
import bsdiff4
|
||||
|
||||
ADVENTUREHASH: str = "157bddb7192754a45372be196797f284"
|
||||
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import base64
|
||||
import copy
|
||||
import itertools
|
||||
import math
|
||||
import os
|
||||
import typing
|
||||
from typing import ClassVar, Dict, Optional, Tuple
|
||||
|
||||
import settings
|
||||
from BaseClasses import Item, ItemClassification, MultiWorld, Tutorial, LocationProgressType
|
||||
import typing
|
||||
from enum import IntFlag
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial, \
|
||||
LocationProgressType
|
||||
from Utils import __version__
|
||||
from Options import AssembleOptions
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from worlds.LauncherComponents import Component, components, SuffixIdentifier
|
||||
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 .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,12 +141,9 @@ 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 to require All Clear if we don't auto complete bonuses
|
||||
# Only set bonus stamp rules 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):
|
||||
@@ -346,7 +343,6 @@ 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:
|
||||
@@ -376,14 +372,6 @@ 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))
|
||||
@@ -442,7 +430,7 @@ hit_list = {
|
||||
# Bosses
|
||||
"Mafia Boss": ["Down with the Mafia!", "Encore! Encore!", "Boss Rush"],
|
||||
|
||||
"Director": ["Dead Bird Studio Basement", "Killing Two Birds", "Boss Rush"],
|
||||
"Conductor": ["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",
|
||||
@@ -466,7 +454,7 @@ triple_enemy_locations = [
|
||||
|
||||
bosses = [
|
||||
"Mafia Boss",
|
||||
"Director",
|
||||
"Conductor",
|
||||
"Toilet",
|
||||
"Snatcher",
|
||||
"Toxic Flower",
|
||||
|
||||
@@ -264,6 +264,7 @@ 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),
|
||||
|
||||
@@ -322,7 +323,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 (TIHS)", hookshot=True),
|
||||
"Alpine Skyline - Goat Outpost Horn": LocData(2000334760, "Alpine Skyline Area"),
|
||||
"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"),
|
||||
@@ -406,7 +407,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.dweller_bell, paintings=1),
|
||||
hit_type=HitType.umbrella, paintings=1),
|
||||
|
||||
"Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service",
|
||||
required_hats=[HatType.SPRINT]),
|
||||
@@ -877,7 +878,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",
|
||||
hit_type=HitType.umbrella, dlc_flags=HatDLC.death_wish),
|
||||
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),
|
||||
|
||||
@@ -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: True)
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
|
||||
# Moderate: Goat Refinery from TIHS with Sprint only
|
||||
add_rule(world.multiworld.get_location("Alpine Skyline - Goat Refinery", world.player),
|
||||
@@ -493,6 +493,9 @@ 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")
|
||||
|
||||
@@ -530,10 +533,7 @@ 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)
|
||||
# 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")
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Hot Air Balloon", world.player), lambda state: True)
|
||||
|
||||
# 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 world.options.NoTicketSkips != NoTicketSkips.option_true:
|
||||
if not world.options.NoTicketSkips:
|
||||
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) and can_use_hookshot(state, world))
|
||||
lambda state: state.can_reach("Bon Voyage!", "Region", world.player)
|
||||
or state.can_reach("Ship Shape", "Region", world.player))
|
||||
|
||||
|
||||
|
||||
@@ -119,9 +119,7 @@ def KholdstareDefeatRule(state, player: int) -> bool:
|
||||
|
||||
|
||||
def VitreousDefeatRule(state, player: int) -> bool:
|
||||
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))
|
||||
return can_shoot_arrows(state, 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.check_locations(new_locations)
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
await snes_flush_writes(ctx)
|
||||
return True
|
||||
|
||||
|
||||
@@ -484,7 +484,8 @@ 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"):
|
||||
if (item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart")
|
||||
or "Arrow Upgrade" in item.name):
|
||||
item.classification = ItemClassification.progression
|
||||
else:
|
||||
# Otherwise, logic has some branches where having 4 hearts is one possible requirement (of several alternatives)
|
||||
@@ -712,7 +713,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 (70)'}
|
||||
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (50)'}
|
||||
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 or player in world.groups:
|
||||
if item_name not in trap_replaceable:
|
||||
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.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'),
|
||||
'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'),
|
||||
'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) and can_use_bombs(state, player))
|
||||
lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 7))
|
||||
set_rule(multiworld.get_entrance('Ganons Tower Moldorm Door', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) and can_use_bombs(state, player))
|
||||
lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8))
|
||||
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))
|
||||
|
||||
@@ -170,8 +170,7 @@ 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,
|
||||
round(location.shop_price * get_price_modifier(location.item)),
|
||||
location.shop.push_inventory(location.shop_slot, item_name, location.shop_price,
|
||||
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, count: int = 0) -> bool:
|
||||
def can_shoot_arrows(state: CollectionState, player: int) -> 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)) and can_hold_arrows(state, player, count)
|
||||
return state.has('Bow', player) or state.has('Silver Bow', player)
|
||||
|
||||
|
||||
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,18 +84,11 @@ def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16,
|
||||
|
||||
|
||||
def can_hold_arrows(state: CollectionState, player: int, quantity: int):
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
def can_use_bombs(state: CollectionState, player: int, quantity: int = 1) -> bool:
|
||||
@@ -153,19 +146,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:
|
||||
@@ -178,9 +171,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,123 +1,224 @@
|
||||
# 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).
|
||||
- [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),
|
||||
- [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),
|
||||
[BizHawk](https://tasvideos.org/BizHawk), o
|
||||
[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.**
|
||||
[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
|
||||
- Tu archivo ROM japones v1.0, probablemente se llame `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Procedimiento de instalación
|
||||
|
||||
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.
|
||||
### 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
|
||||
|
||||
### Obtener el fichero de parche y crea tu ROM
|
||||
|
||||
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
|
||||
Cuando te unes a una partida multiworld, debes proveer tu fichero YAML a quien sea el creador de la partida. Una vez
|
||||
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 de tener la extensión `.aplttp`.
|
||||
de parche de la partida Tu fichero de parche debe tener la extensión `.aplttp`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Conectar al cliente
|
||||
|
||||
#### Con emulador
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
##### snes9x-rr
|
||||
|
||||
1. Carga tu fichero ROM, si no lo has hecho ya
|
||||
1. Carga tu fichero de 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. 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.
|
||||
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.
|
||||
|
||||
##### BizHawk
|
||||
|
||||
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+`
|
||||
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.
|
||||
2. Carga tu fichero de ROM, si no lo has hecho ya.
|
||||
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.
|
||||
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.
|
||||
|
||||
##### RetroArch 1.10.1 o más nuevo
|
||||
|
||||
Sólo hay que seguir estos pasos una vez.
|
||||
Sólo hay que segiur 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. 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.
|
||||
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.
|
||||
|
||||

|
||||
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 únicos núcleos que permiten
|
||||
Cuando cargas un ROM, asegúrate de seleccionar un núcleo **bsnes-mercury**. Estos son los sólos 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, por favor hazlo ahora. Los
|
||||
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, hazlo ahora. Los
|
||||
usuarios de SD2SNES y FXPak Pro pueden descargar el firmware apropiado
|
||||
[aqui](https://github.com/RedGuyyyy/sd2snes/releases). Puede que los usuarios de otros dispositivos encuentren informacion útil
|
||||
[aqui](https://github.com/RedGuyyyy/sd2snes/releases). Los usuarios de otros dispositivos pueden encontrar información
|
||||
[en esta página](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
1. Cierra tu emulador, el cual debe haberse autoejecutado.
|
||||
2. Enciende tu dispositivo y carga la ROM.
|
||||
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.
|
||||
|
||||
### Conecta al Servidor Archipelago
|
||||
### Conecta al MultiServer
|
||||
|
||||
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 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 cliente intentará conectarse a esta nueva dirección, y debería mostrar "Server Status: Connected" momentáneamente.
|
||||
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.
|
||||
|
||||
### Jugar al juego
|
||||
### Jugando
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -130,21 +130,19 @@ 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, ['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 - 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 - 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, ['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']],
|
||||
["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']],
|
||||
])
|
||||
@@ -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', 'Arrow Upgrade (+5)', 'Pegasus Boots']],
|
||||
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Bow', 'Pegasus Boots']],
|
||||
])
|
||||
@@ -93,7 +93,7 @@ class AquariaWorld(World):
|
||||
options: AquariaOptions
|
||||
"Every options of the world"
|
||||
|
||||
regions: AquariaRegions | None
|
||||
regions: AquariaRegions
|
||||
"Used to manage Regions"
|
||||
|
||||
exclude: List[str]
|
||||
@@ -101,17 +101,10 @@ class AquariaWorld(World):
|
||||
def __init__(self, multiworld: MultiWorld, player: int):
|
||||
"""Initialisation of the Aquaria World"""
|
||||
super(AquariaWorld, self).__init__(multiworld, player)
|
||||
self.regions = None
|
||||
self.regions = AquariaRegions(multiworld, player)
|
||||
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/latest)
|
||||
- The [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases)
|
||||
|
||||
## 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/latest)
|
||||
- For sending [commands](/tutorial/Archipelago/commands/en) like `!hint`: the TextClient from [the most recent Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [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,11 +3,12 @@
|
||||
## 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/latest)
|
||||
- Le client du Randomizer d'Aquaria [Aquaria randomizer]
|
||||
(https://github.com/tioui/Aquaria_Randomizer/releases)
|
||||
|
||||
## 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/latest)
|
||||
- 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)
|
||||
- [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,17 +4,14 @@ import random
|
||||
|
||||
|
||||
class ChoiceIsRandom(Choice):
|
||||
randomized: bool
|
||||
|
||||
def __init__(self, value: int, randomized: bool = False):
|
||||
super().__init__(value)
|
||||
self.randomized = randomized
|
||||
randomized: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Choice:
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)), True)
|
||||
cls.randomized = True
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
for option_name, value in cls.options.items():
|
||||
if option_name == text:
|
||||
return cls(value)
|
||||
|
||||
@@ -103,9 +103,6 @@ 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")
|
||||
|
||||
@@ -203,6 +200,9 @@ 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):
|
||||
|
||||
@@ -1366,8 +1366,7 @@ class DarkSouls3World(World):
|
||||
text = "\n" + text + "\n"
|
||||
spoiler_handle.write(text)
|
||||
|
||||
@classmethod
|
||||
def stage_post_fill(cls, multiworld: MultiWorld):
|
||||
def post_fill(self):
|
||||
"""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
|
||||
@@ -1376,125 +1375,106 @@ 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
|
||||
|
||||
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)
|
||||
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()
|
||||
]
|
||||
|
||||
for ds3_world in ds3_worlds:
|
||||
locations_by_sphere = spheres_per_player[ds3_world.player]
|
||||
# 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)
|
||||
]
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
def smooth_items(item_order: List[Union[DS3ItemData, DarkSouls3Item]]) -> None:
|
||||
"""Rearrange all items in item_order to match that order.
|
||||
|
||||
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
|
||||
)
|
||||
# Never re-order event items, because they weren't randomized in the first place.
|
||||
if item and item.code is not None
|
||||
]
|
||||
|
||||
# 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)
|
||||
names = {item.name for item in converted_item_order}
|
||||
|
||||
def smooth_items(item_order: List[Union[DS3ItemData, DarkSouls3Item]]) -> None:
|
||||
"""Rearrange all items in item_order to match that order.
|
||||
all_matching_locations = [
|
||||
loc
|
||||
for sphere in locations_by_sphere
|
||||
for loc in sphere
|
||||
if loc.item.name in names
|
||||
]
|
||||
|
||||
Note: this requires that item_order exactly matches the number of placed items from this
|
||||
world matching the given 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."
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
# Never re-order event items, because they weren't randomized in the first place.
|
||||
if item and item.code is not None
|
||||
]
|
||||
for sphere in locations_by_sphere:
|
||||
locations = [loc for loc in sphere if loc.item.name in names]
|
||||
|
||||
names = {item.name for item in converted_item_order}
|
||||
# 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)
|
||||
|
||||
all_matching_locations = [
|
||||
loc
|
||||
for sphere in locations_by_sphere
|
||||
for loc in sphere
|
||||
if loc.item.name in names
|
||||
]
|
||||
# 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
|
||||
|
||||
# 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."
|
||||
)
|
||||
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])
|
||||
|
||||
for sphere in locations_by_sphere:
|
||||
locations = [loc for loc in sphere if loc.item.name in names]
|
||||
if self.options.smooth_soul_items:
|
||||
smooth_items([
|
||||
item for item in all_item_order
|
||||
if item.souls and item.classification != ItemClassification.progression
|
||||
])
|
||||
|
||||
# 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 = ds3_world._pop_item(location, converted_item_order)
|
||||
location.item = new_item
|
||||
new_item.location = location
|
||||
|
||||
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 ds3_world.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_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)
|
||||
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)
|
||||
|
||||
def _shuffle(self, seq: Sequence) -> List:
|
||||
"""Returns a shuffled copy of a sequence."""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from NetUtils import ClientStatus, color
|
||||
from worlds.AutoSNIClient import SNIClient
|
||||
@@ -31,7 +32,7 @@ class DKC3SNIClient(SNIClient):
|
||||
|
||||
|
||||
async def validate_rom(self, ctx):
|
||||
from SNIClient import snes_read
|
||||
from SNIClient import snes_buffered_write, snes_flush_writes, 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
|
||||
from BaseClasses import Item, ItemClassification
|
||||
from .Names import ItemName
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
import typing
|
||||
|
||||
from Options import Choice, Range, Toggle, DefaultOnToggle, OptionGroup, PerGameCommonOptions
|
||||
from Options import Choice, Range, Toggle, DeathLink, DefaultOnToggle, OptionGroup, PerGameCommonOptions
|
||||
|
||||
|
||||
class Goal(Choice):
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import typing
|
||||
|
||||
from BaseClasses import Region, Entrance
|
||||
from worlds.AutoWorld import World
|
||||
from BaseClasses import MultiWorld, Region, Entrance
|
||||
from .Items import DKC3Item
|
||||
from .Locations import DKC3Location
|
||||
from .Names import LocationName, ItemName
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
|
||||
def create_regions(world: World, active_locations):
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -435,7 +436,7 @@ level_music_ids = [
|
||||
|
||||
class LocalRom:
|
||||
|
||||
def __init__(self, file, name=None, hash=None):
|
||||
def __init__(self, file, patch=True, vanillaRom=None, 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,13 +1,15 @@
|
||||
import dataclasses
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import typing
|
||||
import math
|
||||
import threading
|
||||
|
||||
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,7 +234,8 @@ 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: set[int] = {int(tech_name.split("-")[1]) for tech_name in data["research_done"]}
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
ctx.multiplayer = data.get("multiplayer", False)
|
||||
@@ -248,7 +249,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.check_locations(research_data)
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||
death_link_tick = data.get("death_link_tick", 0)
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
|
||||
@@ -37,8 +37,8 @@ base_info = {
|
||||
"description": "Integration client for the Archipelago Randomizer",
|
||||
"factorio_version": "2.0",
|
||||
"dependencies": [
|
||||
"base >= 2.0.28",
|
||||
"? quality >= 2.0.28",
|
||||
"base >= 2.0.15",
|
||||
"? quality >= 2.0.15",
|
||||
"! space-age",
|
||||
"? science-not-invited",
|
||||
"? factory-levels"
|
||||
|
||||
@@ -3,23 +3,13 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import typing
|
||||
|
||||
from schema import Schema, Optional, And, Or, SchemaError
|
||||
from schema import Schema, Optional, And, Or
|
||||
|
||||
from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \
|
||||
StartInventoryPool, PerGameCommonOptions, OptionGroup
|
||||
|
||||
# schema helpers
|
||||
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}")
|
||||
|
||||
FloatRange = lambda low, high: And(Or(int, float), lambda f: low <= f <= high)
|
||||
LuaBool = Or(bool, And(int, lambda n: n in (0, 1)))
|
||||
|
||||
|
||||
|
||||
@@ -63,19 +63,17 @@ 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] = (),
|
||||
modifiers: list[str] = None, unlocks: Union[Set[str], bool] = None):
|
||||
has_modifier: bool = False, unlocks: Union[Set[str], bool] = None):
|
||||
self.name = technology_name
|
||||
self.factorio_id = factorio_id
|
||||
self.progressive = progressive
|
||||
if modifiers is None:
|
||||
modifiers = []
|
||||
self.modifiers = modifiers
|
||||
self.has_modifier = has_modifier
|
||||
if unlocks:
|
||||
self.unlocks = unlocks
|
||||
else:
|
||||
@@ -84,10 +82,6 @@ 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)
|
||||
|
||||
@@ -197,14 +191,13 @@ 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,
|
||||
modifiers=data.get("modifiers", []),
|
||||
has_modifier=data["has_modifier"],
|
||||
unlocks=set(data["unlocks"]) - start_unlocked_recipes,
|
||||
)
|
||||
factorio_tech_id += 1
|
||||
@@ -212,8 +205,7 @@ 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 = {}
|
||||
@@ -229,8 +221,6 @@ 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():
|
||||
@@ -441,9 +431,7 @@ for root in sorted_rows:
|
||||
factorio_tech_id += 1
|
||||
progressive_technology = Technology(root, factorio_tech_id,
|
||||
tuple(progressive),
|
||||
modifiers=sorted(set.union(
|
||||
*(set(technology_table[tech].modifiers) for tech in progressive)
|
||||
)),
|
||||
has_modifier=any(technology_table[tech].has_modifier 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
|
||||
|
||||
@@ -445,10 +445,6 @@ end
|
||||
|
||||
script.on_event(defines.events.on_player_main_inventory_changed, update_player_event)
|
||||
|
||||
-- Update players when the cutscene is cancelled or finished. (needed for skins_factored)
|
||||
script.on_event(defines.events.on_cutscene_cancelled, update_player_event)
|
||||
script.on_event(defines.events.on_cutscene_finished, update_player_event)
|
||||
|
||||
function add_samples(force, name, count)
|
||||
local function add_to_table(t)
|
||||
if count <= 0 then
|
||||
@@ -717,10 +713,8 @@ TRAP_TABLE = {
|
||||
game.surfaces["nauvis"].build_enemy_base(game.forces["player"].get_spawn_position(game.get_surface(1)), 25)
|
||||
end,
|
||||
["Evolution Trap"] = function ()
|
||||
local new_factor = game.forces["enemy"].get_evolution_factor("nauvis") +
|
||||
(TRAP_EVO_FACTOR * (1 - game.forces["enemy"].get_evolution_factor("nauvis")))
|
||||
game.forces["enemy"].set_evolution_factor(new_factor, "nauvis")
|
||||
game.print({"", "New evolution factor:", new_factor})
|
||||
game.forces["enemy"].evolution_factor = game.forces["enemy"].evolution_factor + (TRAP_EVO_FACTOR * (1 - game.forces["enemy"].evolution_factor))
|
||||
game.print({"", "New evolution factor:", game.forces["enemy"].evolution_factor})
|
||||
end,
|
||||
["Teleport Trap"] = function ()
|
||||
for _, player in ipairs(game.forces["player"].players) do
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% from "macros.lua" import dict_to_recipe, variable_to_lua %}
|
||||
-- this file gets written automatically by the Archipelago Randomizer and is in its raw form a Jinja2 Template
|
||||
require('lib')
|
||||
data.raw["item"]["rocket-part"].hidden = false
|
||||
data.raw["rocket-silo"]["rocket-silo"].fluid_boxes = {
|
||||
{
|
||||
production_type = "input",
|
||||
@@ -163,7 +162,6 @@ data.raw["ammo"]["artillery-shell"].stack_size = 10
|
||||
{# each randomized tech gets set to be invisible, with new nodes added that trigger those #}
|
||||
{%- for original_tech_name in base_tech_table -%}
|
||||
technologies["{{ original_tech_name }}"].hidden = true
|
||||
technologies["{{ original_tech_name }}"].hidden_in_factoriopedia = true
|
||||
{% endfor %}
|
||||
{%- for location, item in locations %}
|
||||
{#- the tech researched by the local player #}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,39 +0,0 @@
|
||||
"""Tests for error messages from YAML validation."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import WebHostLib.check
|
||||
|
||||
FACTORIO_YAML="""
|
||||
game: Factorio
|
||||
Factorio:
|
||||
world_gen:
|
||||
autoplace_controls:
|
||||
coal:
|
||||
richness: 1
|
||||
frequency: {}
|
||||
size: 1
|
||||
"""
|
||||
|
||||
def yamlWithFrequency(f):
|
||||
return FACTORIO_YAML.format(f)
|
||||
|
||||
|
||||
class TestFileValidation(unittest.TestCase):
|
||||
def test_out_of_range(self):
|
||||
results, _ = WebHostLib.check.roll_options({"bob.yaml": yamlWithFrequency(1000)})
|
||||
self.assertIn("between 0 and 6", results["bob.yaml"])
|
||||
|
||||
def test_bad_non_numeric(self):
|
||||
results, _ = WebHostLib.check.roll_options({"bob.yaml": yamlWithFrequency("not numeric")})
|
||||
self.assertIn("float", results["bob.yaml"])
|
||||
self.assertIn("int", results["bob.yaml"])
|
||||
|
||||
def test_good_float(self):
|
||||
results, _ = WebHostLib.check.roll_options({"bob.yaml": yamlWithFrequency(1.0)})
|
||||
self.assertIs(results["bob.yaml"], True)
|
||||
|
||||
def test_good_int(self):
|
||||
results, _ = WebHostLib.check.roll_options({"bob.yaml": yamlWithFrequency(1)})
|
||||
self.assertIs(results["bob.yaml"], True)
|
||||
@@ -44,13 +44,8 @@ class FaxanaduWorld(World):
|
||||
location_name_to_id = {loc.name: loc.id for loc in Locations.locations if loc.id is not None}
|
||||
|
||||
def __init__(self, world: MultiWorld, player: int):
|
||||
self.filler_ratios: Dict[str, int] = {
|
||||
item.name: item.count
|
||||
for item in Items.items
|
||||
if item.classification in [ItemClassification.filler, ItemClassification.trap]
|
||||
}
|
||||
# Remove poison by default to respect itemlinking
|
||||
self.filler_ratios["Poison"] = 0
|
||||
self.filler_ratios: Dict[str, int] = {}
|
||||
|
||||
super().__init__(world, player)
|
||||
|
||||
def create_regions(self):
|
||||
@@ -165,13 +160,19 @@ class FaxanaduWorld(World):
|
||||
for i in range(item.progression_count):
|
||||
itempool.append(FaxanaduItem(item.name, ItemClassification.progression, item.id, self.player))
|
||||
|
||||
# Adjust filler ratios
|
||||
# Set up filler ratios
|
||||
self.filler_ratios = {
|
||||
item.name: item.count
|
||||
for item in Items.items
|
||||
if item.classification in [ItemClassification.filler, ItemClassification.trap]
|
||||
}
|
||||
|
||||
# If red potions are locked in shops, remove the count from the ratio.
|
||||
self.filler_ratios["Red Potion"] -= red_potion_in_shop_count
|
||||
|
||||
# Add poisons if desired
|
||||
if self.options.include_poisons:
|
||||
self.filler_ratios["Poison"] = self.item_name_to_item["Poison"].count
|
||||
# Remove poisons if not desired
|
||||
if not self.options.include_poisons:
|
||||
self.filler_ratios["Poison"] = 0
|
||||
|
||||
# Randomly add fillers to the pool with ratios based on og game occurrence counts.
|
||||
filler_count = len(Locations.locations) - len(itempool) - prefilled_count
|
||||
|
||||
@@ -260,8 +260,7 @@ def create_items(self) -> None:
|
||||
items.append(i)
|
||||
|
||||
for item_group in ("Key Items", "Spells", "Armors", "Helms", "Shields", "Accessories", "Weapons"):
|
||||
# Sort for deterministic order
|
||||
for item in sorted(self.item_name_groups[item_group]):
|
||||
for item in self.item_name_groups[item_group]:
|
||||
add_item(item)
|
||||
|
||||
if self.options.brown_boxes == "include":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from Options import Choice, FreeText, ItemsAccessibility, Toggle, Range, PerGameCommonOptions
|
||||
from Options import Choice, FreeText, Toggle, Range, PerGameCommonOptions
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -324,7 +324,6 @@ class KaelisMomFightsMinotaur(Toggle):
|
||||
|
||||
@dataclass
|
||||
class FFMQOptions(PerGameCommonOptions):
|
||||
accessibility: ItemsAccessibility
|
||||
logic: Logic
|
||||
brown_boxes: BrownBoxes
|
||||
sky_coin_mode: SkyCoinMode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Final Fantasy Mystic Quest
|
||||
|
||||
## Game page in other languages:
|
||||
* [Français](/games/Final%20Fantasy%20Mystic%20Quest/info/fr)
|
||||
* [Français](/games/Final%20Fantasy%20Mystic%20Quest/info/fr)
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
|
||||
@@ -131,8 +131,8 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en)
|
||||
the location without using any hint points.
|
||||
* `start_location_hints` is the same as `start_hints` but for locations, allowing you to hint for the item contained
|
||||
there without using any hint points.
|
||||
* `exclude_locations` lets you define any locations that you don't want to do and prevents items classified as
|
||||
"progression" or "useful" from being placed on them.
|
||||
* `exclude_locations` lets you define any locations that you don't want to do and forces a filler or trap item which
|
||||
isn't necessary for progression into these locations.
|
||||
* `priority_locations` lets you define any locations that you want to do and forces a progression item into these
|
||||
locations.
|
||||
* `item_links` allows players to link their items into a group with the same item link name and game. The items declared
|
||||
|
||||
@@ -27,7 +27,6 @@ including the exclamation point.
|
||||
- `!countdown <number of seconds>` Starts a countdown using the given seconds value. Useful for synchronizing starts.
|
||||
Defaults to 10 seconds if no argument is provided.
|
||||
- `!alias <alias>` Sets your alias, which allows you to use commands with the alias rather than your provided name.
|
||||
`!alias` on its own will reset the alias to the player's original name.
|
||||
- `!admin <command>` Executes a command as if you typed it into the server console. Remote administration must be
|
||||
enabled.
|
||||
|
||||
@@ -66,7 +65,6 @@ including the exclamation point.
|
||||
argument is provided.
|
||||
- `/option <option name> <option value>` Set a server option. For a list of options, use the `/options` command.
|
||||
- `/alias <player name> <alias name>` Assign a player an alias, allowing you to reference the player by the alias in commands.
|
||||
`!alias <player name>` on its own will reset the alias to the player's original name.
|
||||
|
||||
|
||||
### Collect/Release
|
||||
|
||||
@@ -132,13 +132,7 @@ splitter_pattern = re.compile(r'(?<!^)(?=[A-Z])')
|
||||
for option_name, option_data in pool_options.items():
|
||||
extra_data = {"__module__": __name__, "items": option_data[0], "locations": option_data[1]}
|
||||
if option_name in option_docstrings:
|
||||
if option_name == "RandomizeFocus":
|
||||
# pool options for focus are just lying
|
||||
count = 1
|
||||
else:
|
||||
count = len([loc for loc in option_data[1] if loc != "Start"])
|
||||
extra_data["__doc__"] = option_docstrings[option_name] + \
|
||||
f"\n This option adds approximately {count} location{'s' if count != 1 else ''}."
|
||||
extra_data["__doc__"] = option_docstrings[option_name]
|
||||
if option_name in default_on:
|
||||
option = type(option_name, (DefaultOnToggle,), extra_data)
|
||||
else:
|
||||
@@ -219,7 +213,6 @@ class MaximumEssencePrice(MinimumEssencePrice):
|
||||
class MinimumEggPrice(Range):
|
||||
"""The minimum rancid egg price in the range of prices that an item should cost from Jiji.
|
||||
Only takes effect if the EggSlotShops option is greater than 0."""
|
||||
rich_text_doc = False
|
||||
display_name = "Minimum Egg Price"
|
||||
range_start = 1
|
||||
range_end = 20
|
||||
@@ -229,7 +222,6 @@ class MinimumEggPrice(Range):
|
||||
class MaximumEggPrice(MinimumEggPrice):
|
||||
"""The maximum rancid egg price in the range of prices that an item should cost from Jiji.
|
||||
Only takes effect if the EggSlotShops option is greater than 0."""
|
||||
rich_text_doc = False
|
||||
display_name = "Maximum Egg Price"
|
||||
default = 10
|
||||
|
||||
@@ -273,7 +265,6 @@ class RandomCharmCosts(NamedRange):
|
||||
Set to -1 or vanilla for vanilla costs.
|
||||
Set to -2 or shuffle to shuffle around the vanilla costs to different charms."""
|
||||
|
||||
rich_text_doc = False
|
||||
display_name = "Randomize Charm Notch Costs"
|
||||
range_start = 0
|
||||
range_end = 240
|
||||
@@ -303,10 +294,6 @@ class RandomCharmCosts(NamedRange):
|
||||
return charms
|
||||
|
||||
|
||||
class CharmCost(Range):
|
||||
range_end = 6
|
||||
|
||||
|
||||
class PlandoCharmCosts(OptionDict):
|
||||
"""Allows setting a Charm's Notch costs directly, mapping {name: cost}.
|
||||
This is set after any random Charm Notch costs, if applicable."""
|
||||
@@ -316,27 +303,6 @@ class PlandoCharmCosts(OptionDict):
|
||||
Optional(name): And(int, lambda n: 6 >= n >= 0, error="Charm costs must be integers in the range 0-6.") for name in charm_names
|
||||
})
|
||||
|
||||
def __init__(self, value):
|
||||
# To handle keys of random like other options, create an option instance from their values
|
||||
# Additionally a vanilla keyword is added to plando individual charms to vanilla costs
|
||||
# and default is disabled so as to not cause confusion
|
||||
self.value = {}
|
||||
for key, data in value.items():
|
||||
if isinstance(data, str):
|
||||
if data.lower() == "vanilla" and key in self.valid_keys:
|
||||
self.value[key] = vanilla_costs[charm_names.index(key)]
|
||||
continue
|
||||
elif data.lower() == "default":
|
||||
# default is too easily confused with vanilla but actually 0
|
||||
# skip CharmCost resolution to fail schema afterwords
|
||||
self.value[key] = data
|
||||
continue
|
||||
try:
|
||||
self.value[key] = CharmCost.from_any(data).value
|
||||
except ValueError as ex:
|
||||
# will fail schema afterwords
|
||||
self.value[key] = data
|
||||
|
||||
def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]:
|
||||
for name, cost in self.value.items():
|
||||
charm_costs[charm_names.index(name)] = cost
|
||||
@@ -446,7 +412,6 @@ class Goal(Choice):
|
||||
class GrubHuntGoal(NamedRange):
|
||||
"""The amount of grubs required to finish Grub Hunt.
|
||||
On 'All' any grubs from item links replacements etc. will be counted"""
|
||||
rich_text_doc = False
|
||||
display_name = "Grub Hunt Goal"
|
||||
range_start = 1
|
||||
range_end = 46
|
||||
@@ -456,7 +421,7 @@ class GrubHuntGoal(NamedRange):
|
||||
|
||||
class WhitePalace(Choice):
|
||||
"""
|
||||
Whether or not to include White Palace or not. Note: Even if excluded, the King Fragment check may still be
|
||||
Whether or not to include White Palace or not. Note: Even if excluded, the King Fragment check may still be
|
||||
required if charms are vanilla.
|
||||
"""
|
||||
display_name = "White Palace"
|
||||
@@ -493,7 +458,6 @@ class DeathLinkShade(Choice):
|
||||
** Self-death shade behavior is not changed; if a self-death normally creates a shade in vanilla, it will override
|
||||
your existing shade, if any.
|
||||
"""
|
||||
rich_text_doc = False
|
||||
option_vanilla = 0
|
||||
option_shadeless = 1
|
||||
option_shade = 2
|
||||
@@ -508,7 +472,6 @@ class DeathLinkBreaksFragileCharms(Toggle):
|
||||
** Self-death fragile charm behavior is not changed; if a self-death normally breaks fragile charms in vanilla, it
|
||||
will continue to do so.
|
||||
"""
|
||||
rich_text_doc = False
|
||||
display_name = "Deathlink Breaks Fragile Charms"
|
||||
|
||||
|
||||
@@ -527,7 +490,6 @@ class CostSanity(Choice):
|
||||
|
||||
These costs can be in Geo (except Grubfather, Seer and Eggshop), Grubs, Charms, Essence and/or Rancid Eggs
|
||||
"""
|
||||
rich_text_doc = False
|
||||
option_off = 0
|
||||
alias_no = 0
|
||||
option_on = 1
|
||||
|
||||
@@ -134,9 +134,7 @@ shop_cost_types: typing.Dict[str, typing.Tuple[str, ...]] = {
|
||||
|
||||
|
||||
class HKWeb(WebWorld):
|
||||
rich_text_options_doc = True
|
||||
|
||||
setup_en = Tutorial(
|
||||
setup_en = Tutorial(
|
||||
"Mod Setup and Use Guide",
|
||||
"A guide to playing Hollow Knight with Archipelago.",
|
||||
"English",
|
||||
@@ -145,7 +143,7 @@ class HKWeb(WebWorld):
|
||||
["Ijwu"]
|
||||
)
|
||||
|
||||
setup_pt_br = Tutorial(
|
||||
setup_pt_br = Tutorial(
|
||||
setup_en.tutorial_name,
|
||||
setup_en.description,
|
||||
"Português Brasileiro",
|
||||
@@ -181,7 +179,6 @@ class HKWorld(World):
|
||||
charm_costs: typing.List[int]
|
||||
cached_filler_items = {}
|
||||
grub_count: int
|
||||
grub_player_count: typing.Dict[int, int]
|
||||
|
||||
def __init__(self, multiworld, player):
|
||||
super(HKWorld, self).__init__(multiworld, player)
|
||||
@@ -191,6 +188,7 @@ class HKWorld(World):
|
||||
self.ranges = {}
|
||||
self.created_shop_items = 0
|
||||
self.vanilla_shop_costs = deepcopy(vanilla_shop_costs)
|
||||
self.grub_count = 0
|
||||
|
||||
def generate_early(self):
|
||||
options = self.options
|
||||
@@ -204,14 +202,7 @@ class HKWorld(World):
|
||||
mini.value = min(mini.value, maxi.value)
|
||||
self.ranges[term] = mini.value, maxi.value
|
||||
self.multiworld.push_precollected(HKItem(starts[options.StartLocation.current_key],
|
||||
True, None, "Event", self.player))
|
||||
|
||||
# defaulting so completion condition isn't incorrect before pre_fill
|
||||
self.grub_count = (
|
||||
46 if options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]
|
||||
else options.GrubHuntGoal
|
||||
)
|
||||
self.grub_player_count = {self.player: self.grub_count}
|
||||
True, None, "Event", self.player))
|
||||
|
||||
def white_palace_exclusions(self):
|
||||
exclusions = set()
|
||||
@@ -476,20 +467,25 @@ class HKWorld(World):
|
||||
elif goal == Goal.option_godhome_flower:
|
||||
multiworld.completion_condition[player] = lambda state: state.count("Godhome_Flower_Quest", player)
|
||||
elif goal == Goal.option_grub_hunt:
|
||||
multiworld.completion_condition[player] = lambda state: self.can_grub_goal(state)
|
||||
pass # will set in stage_pre_fill()
|
||||
else:
|
||||
# Any goal
|
||||
multiworld.completion_condition[player] = lambda state: _hk_siblings_ending(state, player) and \
|
||||
_hk_can_beat_radiance(state, player) and state.count("Godhome_Flower_Quest", player) and \
|
||||
self.can_grub_goal(state)
|
||||
_hk_can_beat_radiance(state, player) and state.count("Godhome_Flower_Quest", player)
|
||||
|
||||
set_rules(self)
|
||||
|
||||
def can_grub_goal(self, state: CollectionState) -> bool:
|
||||
return all(state.has("Grub", owner, count) for owner, count in self.grub_player_count.items())
|
||||
|
||||
@classmethod
|
||||
def stage_pre_fill(cls, multiworld: "MultiWorld"):
|
||||
def set_goal(player, grub_rule: typing.Callable[[CollectionState], bool]):
|
||||
world = multiworld.worlds[player]
|
||||
|
||||
if world.options.Goal == "grub_hunt":
|
||||
multiworld.completion_condition[player] = grub_rule
|
||||
else:
|
||||
old_rule = multiworld.completion_condition[player]
|
||||
multiworld.completion_condition[player] = lambda state: old_rule(state) and grub_rule(state)
|
||||
|
||||
worlds = [world for world in multiworld.get_game_worlds(cls.game) if world.options.Goal in ["any", "grub_hunt"]]
|
||||
if worlds:
|
||||
grubs = [item for item in multiworld.get_items() if item.name == "Grub"]
|
||||
@@ -527,13 +523,13 @@ class HKWorld(World):
|
||||
|
||||
for player, grub_player_count in per_player_grubs_per_player.items():
|
||||
if player in all_grub_players:
|
||||
multiworld.worlds[player].grub_player_count = grub_player_count
|
||||
set_goal(player, lambda state, g=grub_player_count: all(state.has("Grub", owner, count) for owner, count in g.items()))
|
||||
|
||||
for world in worlds:
|
||||
if world.player not in all_grub_players:
|
||||
world.grub_count = world.options.GrubHuntGoal.value
|
||||
player = world.player
|
||||
world.grub_player_count = {player: world.grub_count}
|
||||
set_goal(player, lambda state, p=player, c=world.grub_count: state.has("Grub", p, c))
|
||||
|
||||
def fill_slot_data(self):
|
||||
slot_data = {}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
from BaseClasses import ItemClassification
|
||||
from typing import TypedDict, List
|
||||
|
||||
from BaseClasses import Item
|
||||
|
||||
|
||||
base_id = 147000
|
||||
|
||||
|
||||
class InscryptionItem(Item):
|
||||
name: str = "Inscryption"
|
||||
|
||||
|
||||
class ItemDict(TypedDict):
|
||||
name: str
|
||||
count: int
|
||||
classification: ItemClassification
|
||||
|
||||
|
||||
act1_items: List[ItemDict] = [
|
||||
{'name': "Stinkbug Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Stunted Wolf Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Wardrobe Key",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Skink Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Ant Cards",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Caged Wolf Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Squirrel Totem Head",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Dagger",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Film Roll",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Ring",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Magnificus Eye",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Oil Painting's Clover Plant",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Extra Candle",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Bee Figurine",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Greater Smoke",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Angler Hook",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful}
|
||||
]
|
||||
|
||||
|
||||
act2_items: List[ItemDict] = [
|
||||
{'name': "Camera Replica",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Pile Of Meat",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Epitaph Piece",
|
||||
'count': 9,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Epitaph Pieces",
|
||||
'count': 3,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Monocle",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Bone Lord Femur",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Bone Lord Horn",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Bone Lord Holo Key",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Mycologists Holo Key",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Ancient Obol",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Great Kraken Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Drowned Soul Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Salmon Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Dock's Clover Plant",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful}
|
||||
]
|
||||
|
||||
|
||||
act3_items: List[ItemDict] = [
|
||||
{'name': "Extra Battery",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Nano Armor Generator",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Mrs. Bomb's Remote",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Inspectometer Battery",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Gems Module",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Lonely Wizbot Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Fishbot Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Ourobot Card",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.useful},
|
||||
{'name': "Holo Pelt",
|
||||
'count': 5,
|
||||
'classification': ItemClassification.progression},
|
||||
{'name': "Quill",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.progression},
|
||||
]
|
||||
|
||||
filler_items: List[ItemDict] = [
|
||||
{'name': "Currency",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.filler},
|
||||
{'name': "Card Pack",
|
||||
'count': 1,
|
||||
'classification': ItemClassification.filler}
|
||||
]
|
||||
@@ -1,127 +0,0 @@
|
||||
from typing import Dict, List
|
||||
|
||||
from BaseClasses import Location
|
||||
|
||||
base_id = 147000
|
||||
|
||||
|
||||
class InscryptionLocation(Location):
|
||||
game: str = "Inscryption"
|
||||
|
||||
|
||||
act1_locations = [
|
||||
"Act 1 - Boss Prospector",
|
||||
"Act 1 - Boss Angler",
|
||||
"Act 1 - Boss Trapper",
|
||||
"Act 1 - Boss Leshy",
|
||||
"Act 1 - Safe",
|
||||
"Act 1 - Clock Main Compartment",
|
||||
"Act 1 - Clock Upper Compartment",
|
||||
"Act 1 - Dagger",
|
||||
"Act 1 - Wardrobe Drawer 1",
|
||||
"Act 1 - Wardrobe Drawer 2",
|
||||
"Act 1 - Wardrobe Drawer 3",
|
||||
"Act 1 - Wardrobe Drawer 4",
|
||||
"Act 1 - Magnificus Eye",
|
||||
"Act 1 - Painting 1",
|
||||
"Act 1 - Painting 2",
|
||||
"Act 1 - Painting 3",
|
||||
"Act 1 - Greater Smoke"
|
||||
]
|
||||
|
||||
act2_locations = [
|
||||
"Act 2 - Boss Leshy",
|
||||
"Act 2 - Boss Magnificus",
|
||||
"Act 2 - Boss Grimora",
|
||||
"Act 2 - Boss P03",
|
||||
"Act 2 - Battle Prospector",
|
||||
"Act 2 - Battle Angler",
|
||||
"Act 2 - Battle Trapper",
|
||||
"Act 2 - Battle Sawyer",
|
||||
"Act 2 - Battle Royal",
|
||||
"Act 2 - Battle Kaycee",
|
||||
"Act 2 - Battle Goobert",
|
||||
"Act 2 - Battle Pike Mage",
|
||||
"Act 2 - Battle Lonely Wizard",
|
||||
"Act 2 - Battle Inspector",
|
||||
"Act 2 - Battle Melter",
|
||||
"Act 2 - Battle Dredger",
|
||||
"Act 2 - Dock Chest",
|
||||
"Act 2 - Forest Cabin Chest",
|
||||
"Act 2 - Forest Meadow Chest",
|
||||
"Act 2 - Cabin Wardrobe Drawer",
|
||||
"Act 2 - Cabin Safe",
|
||||
"Act 2 - Crypt Casket 1",
|
||||
"Act 2 - Crypt Casket 2",
|
||||
"Act 2 - Crypt Well",
|
||||
"Act 2 - Tower Chest 1",
|
||||
"Act 2 - Tower Chest 2",
|
||||
"Act 2 - Tower Chest 3",
|
||||
"Act 2 - Tentacle",
|
||||
"Act 2 - Factory Trash Can",
|
||||
"Act 2 - Factory Drawer 1",
|
||||
"Act 2 - Factory Drawer 2",
|
||||
"Act 2 - Factory Chest 1",
|
||||
"Act 2 - Factory Chest 2",
|
||||
"Act 2 - Factory Chest 3",
|
||||
"Act 2 - Factory Chest 4",
|
||||
"Act 2 - Ancient Obol",
|
||||
"Act 2 - Bone Lord Femur",
|
||||
"Act 2 - Bone Lord Horn",
|
||||
"Act 2 - Bone Lord Holo Key",
|
||||
"Act 2 - Mycologists Holo Key",
|
||||
"Act 2 - Camera Replica",
|
||||
"Act 2 - Clover",
|
||||
"Act 2 - Monocle",
|
||||
"Act 2 - Epitaph Piece 1",
|
||||
"Act 2 - Epitaph Piece 2",
|
||||
"Act 2 - Epitaph Piece 3",
|
||||
"Act 2 - Epitaph Piece 4",
|
||||
"Act 2 - Epitaph Piece 5",
|
||||
"Act 2 - Epitaph Piece 6",
|
||||
"Act 2 - Epitaph Piece 7",
|
||||
"Act 2 - Epitaph Piece 8",
|
||||
"Act 2 - Epitaph Piece 9"
|
||||
]
|
||||
|
||||
act3_locations = [
|
||||
"Act 3 - Boss Photographer",
|
||||
"Act 3 - Boss Archivist",
|
||||
"Act 3 - Boss Unfinished",
|
||||
"Act 3 - Boss G0lly",
|
||||
"Act 3 - Boss Mycologists",
|
||||
"Act 3 - Bone Lord Room",
|
||||
"Act 3 - Shop Holo Pelt",
|
||||
"Act 3 - Middle Holo Pelt",
|
||||
"Act 3 - Forest Holo Pelt",
|
||||
"Act 3 - Crypt Holo Pelt",
|
||||
"Act 3 - Tower Holo Pelt",
|
||||
"Act 3 - Trader 1",
|
||||
"Act 3 - Trader 2",
|
||||
"Act 3 - Trader 3",
|
||||
"Act 3 - Trader 4",
|
||||
"Act 3 - Trader 5",
|
||||
"Act 3 - Drawer 1",
|
||||
"Act 3 - Drawer 2",
|
||||
"Act 3 - Clock",
|
||||
"Act 3 - Extra Battery",
|
||||
"Act 3 - Nano Armor Generator",
|
||||
"Act 3 - Chest",
|
||||
"Act 3 - Goobert's Painting",
|
||||
"Act 3 - Luke's File Entry 1",
|
||||
"Act 3 - Luke's File Entry 2",
|
||||
"Act 3 - Luke's File Entry 3",
|
||||
"Act 3 - Luke's File Entry 4",
|
||||
"Act 3 - Inspectometer Battery",
|
||||
"Act 3 - Gems Drone",
|
||||
"Act 3 - The Great Transcendence",
|
||||
"Act 3 - Well"
|
||||
]
|
||||
|
||||
regions_to_locations: Dict[str, List[str]] = {
|
||||
"Menu": [],
|
||||
"Act 1": act1_locations,
|
||||
"Act 2": act2_locations,
|
||||
"Act 3": act3_locations,
|
||||
"Epilogue": []
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from Options import Toggle, Choice, DeathLinkMixin, StartInventoryPool, PerGameCommonOptions, DefaultOnToggle
|
||||
|
||||
|
||||
class Act1DeathLinkBehaviour(Choice):
|
||||
"""If DeathLink is enabled, determines what counts as a death in act 1. This affects deaths sent and received.
|
||||
|
||||
- Sacrificed: Send a death when sacrificed by Leshy. Receiving a death will extinguish all candles.
|
||||
|
||||
- Candle Extinguished: Send a death when a candle is extinguished. Receiving a death will extinguish a candle."""
|
||||
display_name = "Act 1 Death Link Behaviour"
|
||||
option_sacrificed = 0
|
||||
option_candle_extinguished = 1
|
||||
default = 0
|
||||
|
||||
|
||||
class Goal(Choice):
|
||||
"""Defines the goal to accomplish in order to complete the randomizer.
|
||||
|
||||
- Full Story In Order: Complete each act in order. You can return to previously completed acts.
|
||||
|
||||
- Full Story Any Order: Complete each act in any order. All acts are available from the start.
|
||||
|
||||
- First Act: Complete Act 1 by finding the New Game button. Great for a smaller scale randomizer."""
|
||||
display_name = "Goal"
|
||||
option_full_story_in_order = 0
|
||||
option_full_story_any_order = 1
|
||||
option_first_act = 2
|
||||
default = 0
|
||||
|
||||
|
||||
class RandomizeCodes(Toggle):
|
||||
"""Randomize codes and passwords in the game (clocks, safes, etc.)"""
|
||||
display_name = "Randomize Codes"
|
||||
|
||||
|
||||
class RandomizeDeck(Choice):
|
||||
"""Randomize cards in your deck into new cards.
|
||||
Disable: Disable the feature.
|
||||
|
||||
- Every Encounter Within Same Type: Randomize cards within the same type every encounter (keep rarity/scrybe type).
|
||||
|
||||
- Every Encounter Any Type: Randomize cards into any possible card every encounter.
|
||||
|
||||
- Starting Only: Only randomize cards given at the beginning of runs and acts."""
|
||||
display_name = "Randomize Deck"
|
||||
option_disable = 0
|
||||
option_every_encounter_within_same_type = 1
|
||||
option_every_encounter_any_type = 2
|
||||
option_starting_only = 3
|
||||
default = 0
|
||||
|
||||
|
||||
class RandomizeSigils(Choice):
|
||||
"""Randomize sigils printed on the cards into new sigils every encounter.
|
||||
|
||||
- Disable: Disable the feature.
|
||||
|
||||
- Randomize Addons: Only randomize sigils added from sacrifices or other means.
|
||||
|
||||
- Randomize All: Randomize all sigils."""
|
||||
display_name = "Randomize Abilities"
|
||||
option_disable = 0
|
||||
option_randomize_addons = 1
|
||||
option_randomize_all = 2
|
||||
default = 0
|
||||
|
||||
|
||||
class OptionalDeathCard(Choice):
|
||||
"""Add a moment after death in act 1 where you can decide to create a death card or not.
|
||||
|
||||
- Disable: Disable the feature.
|
||||
|
||||
- Always On: The choice is always offered after losing all candles.
|
||||
|
||||
- DeathLink Only: The choice is only offered after receiving a DeathLink event."""
|
||||
display_name = "Optional Death Card"
|
||||
option_disable = 0
|
||||
option_always_on = 1
|
||||
option_deathlink_only = 2
|
||||
default = 2
|
||||
|
||||
|
||||
class SkipTutorial(DefaultOnToggle):
|
||||
"""Skips the first few tutorial runs of act 1. Bones are available from the start."""
|
||||
display_name = "Skip Tutorial"
|
||||
|
||||
|
||||
class SkipEpilogue(Toggle):
|
||||
"""Completes the goal as soon as the required acts are completed without the need of completing the epilogue."""
|
||||
display_name = "Skip Epilogue"
|
||||
|
||||
|
||||
class EpitaphPiecesRandomization(Choice):
|
||||
"""Determines how epitaph pieces in act 2 are randomized. This can affect your chances of getting stuck.
|
||||
|
||||
- All Pieces: Randomizes all nine pieces as their own item.
|
||||
|
||||
- In Groups: Randomizes pieces in groups of three.
|
||||
|
||||
- As One Item: Group all nine pieces as a single item."""
|
||||
display_name = "Epitaph Pieces Randomization"
|
||||
option_all_pieces = 0
|
||||
option_in_groups = 1
|
||||
option_as_one_item = 2
|
||||
default = 0
|
||||
|
||||
|
||||
class PaintingChecksBalancing(Choice):
|
||||
"""Generation options for the second and third painting checks in act 1.
|
||||
|
||||
- None: Adds no progression logic to these painting checks. They will all count as sphere 1 (early game checks).
|
||||
|
||||
- Balanced: Adds rules to these painting checks. Early game items are less likely to appear into these paintings.
|
||||
|
||||
- Force Filler: For when you dislike doing these last two paintings. Their checks will only contain filler items."""
|
||||
display_name = "Painting Checks Balancing"
|
||||
option_none = 0
|
||||
option_balanced = 1
|
||||
option_force_filler = 2
|
||||
default = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class InscryptionOptions(DeathLinkMixin, PerGameCommonOptions):
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
act1_death_link_behaviour: Act1DeathLinkBehaviour
|
||||
goal: Goal
|
||||
randomize_codes: RandomizeCodes
|
||||
randomize_deck: RandomizeDeck
|
||||
randomize_sigils: RandomizeSigils
|
||||
optional_death_card: OptionalDeathCard
|
||||
skip_tutorial: SkipTutorial
|
||||
skip_epilogue: SkipEpilogue
|
||||
epitaph_pieces_randomization: EpitaphPiecesRandomization
|
||||
painting_checks_balancing: PaintingChecksBalancing
|
||||
@@ -1,14 +0,0 @@
|
||||
from typing import Dict, List
|
||||
|
||||
inscryption_regions_all: Dict[str, List[str]] = {
|
||||
"Menu": ["Act 1", "Act 2", "Act 3", "Epilogue"],
|
||||
"Act 1": [],
|
||||
"Act 2": [],
|
||||
"Act 3": [],
|
||||
"Epilogue": []
|
||||
}
|
||||
|
||||
inscryption_regions_act_1: Dict[str, List[str]] = {
|
||||
"Menu": ["Act 1"],
|
||||
"Act 1": []
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
from typing import Dict, Callable, TYPE_CHECKING
|
||||
from BaseClasses import CollectionState, LocationProgressType
|
||||
from .Options import Goal, PaintingChecksBalancing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import InscryptionWorld
|
||||
else:
|
||||
InscryptionWorld = object
|
||||
|
||||
|
||||
# Based on The Messenger's implementation
|
||||
class InscryptionRules:
|
||||
player: int
|
||||
world: InscryptionWorld
|
||||
location_rules: Dict[str, Callable[[CollectionState], bool]]
|
||||
region_rules: Dict[str, Callable[[CollectionState], bool]]
|
||||
|
||||
def __init__(self, world: InscryptionWorld) -> None:
|
||||
self.player = world.player
|
||||
self.world = world
|
||||
self.location_rules = {
|
||||
"Act 1 - Wardrobe Drawer 1": self.has_wardrobe_key,
|
||||
"Act 1 - Wardrobe Drawer 2": self.has_wardrobe_key,
|
||||
"Act 1 - Wardrobe Drawer 3": self.has_wardrobe_key,
|
||||
"Act 1 - Wardrobe Drawer 4": self.has_wardrobe_key,
|
||||
"Act 1 - Dagger": self.has_caged_wolf,
|
||||
"Act 1 - Magnificus Eye": self.has_dagger,
|
||||
"Act 1 - Clock Main Compartment": self.has_magnificus_eye,
|
||||
"Act 2 - Battle Prospector": self.has_camera_and_meat,
|
||||
"Act 2 - Battle Angler": self.has_camera_and_meat,
|
||||
"Act 2 - Battle Trapper": self.has_camera_and_meat,
|
||||
"Act 2 - Battle Pike Mage": self.has_tower_requirements,
|
||||
"Act 2 - Battle Goobert": self.has_tower_requirements,
|
||||
"Act 2 - Battle Lonely Wizard": self.has_tower_requirements,
|
||||
"Act 2 - Battle Inspector": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Battle Melter": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Battle Dredger": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Forest Meadow Chest": self.has_camera_and_meat,
|
||||
"Act 2 - Tower Chest 1": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Tower Chest 2": self.has_tower_requirements,
|
||||
"Act 2 - Tower Chest 3": self.has_tower_requirements,
|
||||
"Act 2 - Tentacle": self.has_tower_requirements,
|
||||
"Act 2 - Factory Trash Can": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Factory Drawer 1": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Factory Drawer 2": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Factory Chest 1": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Factory Chest 2": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Factory Chest 3": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Factory Chest 4": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Monocle": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Boss Grimora": self.has_all_epitaph_pieces,
|
||||
"Act 2 - Boss Leshy": self.has_camera_and_meat,
|
||||
"Act 2 - Boss Magnificus": self.has_tower_requirements,
|
||||
"Act 2 - Boss P03": self.has_act2_bridge_requirements,
|
||||
"Act 2 - Bone Lord Femur": self.has_obol,
|
||||
"Act 2 - Bone Lord Horn": self.has_obol,
|
||||
"Act 2 - Bone Lord Holo Key": self.has_obol,
|
||||
"Act 2 - Mycologists Holo Key": self.has_tower_requirements, # Could need money
|
||||
"Act 2 - Ancient Obol": self.has_tower_requirements, # Need money for the pieces? Use the tower mannequin.
|
||||
"Act 3 - Boss Photographer": self.has_inspectometer_battery,
|
||||
"Act 3 - Boss Archivist": self.has_battery_and_quill,
|
||||
"Act 3 - Boss Unfinished": self.has_gems_and_battery,
|
||||
"Act 3 - Boss G0lly": self.has_gems_and_battery,
|
||||
"Act 3 - Extra Battery": self.has_inspectometer_battery, # Hard to miss but soft lock still possible.
|
||||
"Act 3 - Nano Armor Generator": self.has_gems_and_battery, # Costs money, so can need multiple battles.
|
||||
"Act 3 - Shop Holo Pelt": self.has_gems_and_battery, # Costs money, so can need multiple battles.
|
||||
"Act 3 - Middle Holo Pelt": self.has_inspectometer_battery, # Can be reached without but possible soft lock
|
||||
"Act 3 - Forest Holo Pelt": self.has_inspectometer_battery,
|
||||
"Act 3 - Crypt Holo Pelt": self.has_inspectometer_battery,
|
||||
"Act 3 - Tower Holo Pelt": self.has_gems_and_battery,
|
||||
"Act 3 - Trader 1": self.has_pelts(1),
|
||||
"Act 3 - Trader 2": self.has_pelts(2),
|
||||
"Act 3 - Trader 3": self.has_pelts(3),
|
||||
"Act 3 - Trader 4": self.has_pelts(4),
|
||||
"Act 3 - Trader 5": self.has_pelts(5),
|
||||
"Act 3 - Goobert's Painting": self.has_gems_and_battery,
|
||||
"Act 3 - The Great Transcendence": self.has_transcendence_requirements,
|
||||
"Act 3 - Boss Mycologists": self.has_mycologists_boss_requirements,
|
||||
"Act 3 - Bone Lord Room": self.has_bone_lord_room_requirements,
|
||||
"Act 3 - Luke's File Entry 1": self.has_battery_and_quill,
|
||||
"Act 3 - Luke's File Entry 2": self.has_battery_and_quill,
|
||||
"Act 3 - Luke's File Entry 3": self.has_battery_and_quill,
|
||||
"Act 3 - Luke's File Entry 4": self.has_transcendence_requirements,
|
||||
"Act 3 - Well": self.has_inspectometer_battery,
|
||||
"Act 3 - Gems Drone": self.has_inspectometer_battery,
|
||||
"Act 3 - Clock": self.has_gems_and_battery, # Can be brute-forced, but the solution needs those items.
|
||||
}
|
||||
self.region_rules = {
|
||||
"Act 2": self.has_act2_requirements,
|
||||
"Act 3": self.has_act3_requirements,
|
||||
"Epilogue": self.has_epilogue_requirements
|
||||
}
|
||||
|
||||
def has_wardrobe_key(self, state: CollectionState) -> bool:
|
||||
return state.has("Wardrobe Key", self.player)
|
||||
|
||||
def has_caged_wolf(self, state: CollectionState) -> bool:
|
||||
return state.has("Caged Wolf Card", self.player)
|
||||
|
||||
def has_dagger(self, state: CollectionState) -> bool:
|
||||
return state.has("Dagger", self.player)
|
||||
|
||||
def has_magnificus_eye(self, state: CollectionState) -> bool:
|
||||
return state.has("Magnificus Eye", self.player)
|
||||
|
||||
def has_useful_act1_items(self, state: CollectionState) -> bool:
|
||||
return state.has_all(("Oil Painting's Clover Plant", "Squirrel Totem Head"), self.player)
|
||||
|
||||
def has_all_epitaph_pieces(self, state: CollectionState) -> bool:
|
||||
return state.has(self.world.required_epitaph_pieces_name, self.player, self.world.required_epitaph_pieces_count)
|
||||
|
||||
def has_camera_and_meat(self, state: CollectionState) -> bool:
|
||||
return state.has_all(("Camera Replica", "Pile Of Meat"), self.player)
|
||||
|
||||
def has_monocle(self, state: CollectionState) -> bool:
|
||||
return state.has("Monocle", self.player)
|
||||
|
||||
def has_obol(self, state: CollectionState) -> bool:
|
||||
return state.has("Ancient Obol", self.player)
|
||||
|
||||
def has_epitaphs_and_forest_items(self, state: CollectionState) -> bool:
|
||||
return self.has_camera_and_meat(state) and self.has_all_epitaph_pieces(state)
|
||||
|
||||
def has_act2_bridge_requirements(self, state: CollectionState) -> bool:
|
||||
return self.has_camera_and_meat(state) or self.has_all_epitaph_pieces(state)
|
||||
|
||||
def has_tower_requirements(self, state: CollectionState) -> bool:
|
||||
return self.has_monocle(state) and self.has_act2_bridge_requirements(state)
|
||||
|
||||
def has_inspectometer_battery(self, state: CollectionState) -> bool:
|
||||
return state.has("Inspectometer Battery", self.player)
|
||||
|
||||
def has_gems_and_battery(self, state: CollectionState) -> bool:
|
||||
return state.has("Gems Module", self.player) and self.has_inspectometer_battery(state)
|
||||
|
||||
def has_pelts(self, count: int) -> Callable[[CollectionState], bool]:
|
||||
return lambda state: state.has("Holo Pelt", self.player, count) and self.has_gems_and_battery(state)
|
||||
|
||||
def has_mycologists_boss_requirements(self, state: CollectionState) -> bool:
|
||||
return state.has("Mycologists Holo Key", self.player) and self.has_transcendence_requirements(state)
|
||||
|
||||
def has_bone_lord_room_requirements(self, state: CollectionState) -> bool:
|
||||
return state.has("Bone Lord Holo Key", self.player) and self.has_inspectometer_battery(state)
|
||||
|
||||
def has_battery_and_quill(self, state: CollectionState) -> bool:
|
||||
return state.has("Quill", self.player) and self.has_inspectometer_battery(state)
|
||||
|
||||
def has_transcendence_requirements(self, state: CollectionState) -> bool:
|
||||
return state.has("Quill", self.player) and self.has_gems_and_battery(state)
|
||||
|
||||
def has_act2_requirements(self, state: CollectionState) -> bool:
|
||||
return state.has("Film Roll", self.player)
|
||||
|
||||
def has_act3_requirements(self, state: CollectionState) -> bool:
|
||||
return self.has_act2_requirements(state) and self.has_all_epitaph_pieces(state) and \
|
||||
self.has_camera_and_meat(state) and self.has_monocle(state)
|
||||
|
||||
def has_epilogue_requirements(self, state: CollectionState) -> bool:
|
||||
return self.has_act3_requirements(state) and self.has_transcendence_requirements(state)
|
||||
|
||||
def set_all_rules(self) -> None:
|
||||
multiworld = self.world.multiworld
|
||||
if self.world.options.goal != Goal.option_first_act:
|
||||
multiworld.completion_condition[self.player] = self.has_epilogue_requirements
|
||||
else:
|
||||
multiworld.completion_condition[self.player] = self.has_act2_requirements
|
||||
for region in multiworld.get_regions(self.player):
|
||||
if self.world.options.goal == Goal.option_full_story_in_order:
|
||||
if region.name in self.region_rules:
|
||||
for entrance in region.entrances:
|
||||
entrance.access_rule = self.region_rules[region.name]
|
||||
for loc in region.locations:
|
||||
if loc.name in self.location_rules:
|
||||
loc.access_rule = self.location_rules[loc.name]
|
||||
|
||||
if self.world.options.painting_checks_balancing == PaintingChecksBalancing.option_balanced:
|
||||
self.world.get_location("Act 1 - Painting 2").access_rule = self.has_useful_act1_items
|
||||
self.world.get_location("Act 1 - Painting 3").access_rule = self.has_useful_act1_items
|
||||
elif self.world.options.painting_checks_balancing == PaintingChecksBalancing.option_force_filler:
|
||||
self.world.get_location("Act 1 - Painting 2").progress_type = LocationProgressType.EXCLUDED
|
||||
self.world.get_location("Act 1 - Painting 3").progress_type = LocationProgressType.EXCLUDED
|
||||
@@ -1,144 +0,0 @@
|
||||
from .Options import InscryptionOptions, Goal, EpitaphPiecesRandomization, PaintingChecksBalancing
|
||||
from .Items import act1_items, act2_items, act3_items, filler_items, base_id, InscryptionItem, ItemDict
|
||||
from .Locations import act1_locations, act2_locations, act3_locations, regions_to_locations
|
||||
from .Regions import inscryption_regions_all, inscryption_regions_act_1
|
||||
from typing import Dict, Any
|
||||
from . import Rules
|
||||
from BaseClasses import Region, Item, Tutorial, ItemClassification
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
|
||||
|
||||
class InscrypWeb(WebWorld):
|
||||
theme = "dirt"
|
||||
|
||||
guide_en = Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
"A guide to setting up the Inscryption Archipelago Multiworld",
|
||||
"English",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["DrBibop"]
|
||||
)
|
||||
|
||||
guide_fr = Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
"Un guide pour configurer Inscryption Archipelago Multiworld",
|
||||
"Français",
|
||||
"setup_fr.md",
|
||||
"setup/fr",
|
||||
["Glowbuzz"]
|
||||
)
|
||||
|
||||
tutorials = [guide_en, guide_fr]
|
||||
|
||||
bug_report_page = "https://github.com/DrBibop/Archipelago_Inscryption/issues"
|
||||
|
||||
|
||||
class InscryptionWorld(World):
|
||||
"""
|
||||
Inscryption is an inky black card-based odyssey that blends the deckbuilding roguelike,
|
||||
escape-room style puzzles, and psychological horror into a blood-laced smoothie.
|
||||
Darker still are the secrets inscrybed upon the cards...
|
||||
"""
|
||||
game = "Inscryption"
|
||||
web = InscrypWeb()
|
||||
options_dataclass = InscryptionOptions
|
||||
options: InscryptionOptions
|
||||
all_items = act1_items + act2_items + act3_items + filler_items
|
||||
item_name_to_id = {item["name"]: i + base_id for i, item in enumerate(all_items)}
|
||||
all_locations = act1_locations + act2_locations + act3_locations
|
||||
location_name_to_id = {location: i + base_id for i, location in enumerate(all_locations)}
|
||||
required_epitaph_pieces_count = 9
|
||||
required_epitaph_pieces_name = "Epitaph Piece"
|
||||
|
||||
def generate_early(self) -> None:
|
||||
self.all_items = [item.copy() for item in self.all_items]
|
||||
|
||||
if self.options.epitaph_pieces_randomization == EpitaphPiecesRandomization.option_all_pieces:
|
||||
self.required_epitaph_pieces_name = "Epitaph Piece"
|
||||
self.required_epitaph_pieces_count = 9
|
||||
elif self.options.epitaph_pieces_randomization == EpitaphPiecesRandomization.option_in_groups:
|
||||
self.required_epitaph_pieces_name = "Epitaph Pieces"
|
||||
self.required_epitaph_pieces_count = 3
|
||||
else:
|
||||
self.required_epitaph_pieces_name = "Epitaph Pieces"
|
||||
self.required_epitaph_pieces_count = 1
|
||||
|
||||
if self.options.painting_checks_balancing == PaintingChecksBalancing.option_balanced:
|
||||
self.all_items[6]["classification"] = ItemClassification.progression
|
||||
self.all_items[11]["classification"] = ItemClassification.progression
|
||||
|
||||
if self.options.painting_checks_balancing == PaintingChecksBalancing.option_force_filler \
|
||||
and self.options.goal == Goal.option_first_act:
|
||||
self.all_items[3]["classification"] = ItemClassification.filler
|
||||
|
||||
if self.options.epitaph_pieces_randomization != EpitaphPiecesRandomization.option_all_pieces:
|
||||
self.all_items[len(act1_items) + 3]["count"] = self.required_epitaph_pieces_count
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
return self.random.choice(filler_items)["name"]
|
||||
|
||||
def create_item(self, name: str) -> Item:
|
||||
item_id = self.item_name_to_id[name]
|
||||
item_data = self.all_items[item_id - base_id]
|
||||
return InscryptionItem(name, item_data["classification"], item_id, self.player)
|
||||
|
||||
def create_items(self) -> None:
|
||||
nb_items_added = 0
|
||||
useful_items = self.all_items.copy()
|
||||
|
||||
if self.options.goal != Goal.option_first_act:
|
||||
useful_items = [item for item in useful_items
|
||||
if not any(filler_item["name"] == item["name"] for filler_item in filler_items)]
|
||||
if self.options.epitaph_pieces_randomization == EpitaphPiecesRandomization.option_all_pieces:
|
||||
useful_items.pop(len(act1_items) + 3)
|
||||
else:
|
||||
useful_items.pop(len(act1_items) + 2)
|
||||
else:
|
||||
useful_items = [item for item in useful_items
|
||||
if any(act1_item["name"] == item["name"] for act1_item in act1_items)]
|
||||
|
||||
for item in useful_items:
|
||||
for _ in range(item["count"]):
|
||||
new_item = self.create_item(item["name"])
|
||||
self.multiworld.itempool.append(new_item)
|
||||
nb_items_added += 1
|
||||
|
||||
filler_count = len(self.all_locations if self.options.goal != Goal.option_first_act else act1_locations)
|
||||
filler_count -= nb_items_added
|
||||
|
||||
for i in range(filler_count):
|
||||
index = i % len(filler_items)
|
||||
filler_item = filler_items[index]
|
||||
new_item = self.create_item(filler_item["name"])
|
||||
self.multiworld.itempool.append(new_item)
|
||||
|
||||
def create_regions(self) -> None:
|
||||
used_regions = inscryption_regions_all if self.options.goal != Goal.option_first_act \
|
||||
else inscryption_regions_act_1
|
||||
for region_name in used_regions.keys():
|
||||
self.multiworld.regions.append(Region(region_name, self.player, self.multiworld))
|
||||
|
||||
for region_name, region_connections in used_regions.items():
|
||||
region = self.get_region(region_name)
|
||||
region.add_exits(region_connections)
|
||||
region.add_locations({
|
||||
location: self.location_name_to_id[location] for location in regions_to_locations[region_name]
|
||||
})
|
||||
|
||||
def set_rules(self) -> None:
|
||||
Rules.InscryptionRules(self).set_all_rules()
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
return self.options.as_dict(
|
||||
"death_link",
|
||||
"act1_death_link_behaviour",
|
||||
"goal",
|
||||
"randomize_codes",
|
||||
"randomize_deck",
|
||||
"randomize_sigils",
|
||||
"optional_death_card",
|
||||
"skip_tutorial",
|
||||
"skip_epilogue",
|
||||
"epitaph_pieces_randomization"
|
||||
)
|
||||
@@ -1,22 +0,0 @@
|
||||
# Inscryption
|
||||
|
||||
## Where is the options page?
|
||||
You can configure your player options with the Inscryption options page. [Click here](../player-options) to start configuring them to your liking.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Due to the nature of the randomizer, you are allowed to return to a previous act you've previously completed if there are location checks you've missed. The "New Game" option is replaced with a "Chapter Select" option and is enabled after you beat act 1. If you prefer, you can also make all acts available from the start by changing the goal option. All items that you can find lying around, in containers, or from puzzles are randomized and replaced with location checks. Boss fights from all acts and battles from act 2 also count as location checks.
|
||||
|
||||
## What is the goal of Inscryption when randomized?
|
||||
By default, the goal is considered reached once you open the OLD_DATA file. This means playing through all three acts in order and the epilogue. You can change the goal option to instead complete all acts in any order or simply complete act 1.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
All key items necessary for progression such as the film roll, the dagger, Grimora's epitaphs, etc. Unique cards that aren't randomly found in the base game (e.g. talking cards) are also included. For filler items, you can receive currency which will be added to every act's bank or card packs that you can open at any time when inspecting your deck.
|
||||
|
||||
## What does another world's item look like in Inscryption?
|
||||
Items from other worlds usually take the appearance of a normal card from the current act you're playing. The card's name contains the item that will be sent when picked up and its portrait is the Archipelago logo (a ring of six circles). Picking up these cards does not add them to your deck.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
The item is instantly granted to you. A yellow message appears in the Archipelago logs at the top-right of your screen. An audio cue is also played. If the item received is a holdable item (wardrobe key, inspectometer battery, gems module), the item will be placed where you would usually collect it in a vanilla playthrough (safe, inspectometer, drone).
|
||||
|
||||
## How many items can I find or receive in my world?
|
||||
By default, if all three acts are played, there are **100** randomized locations in your world and **100** of your items shuffled in the multiworld. There are **17** locations in act 1 (this will be the total amount if you decide to only play act 1), **52** locations in act 2, and **31** locations in act 3.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Inscryption Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Inscryption](https://store.steampowered.com/app/1092790/Inscryption/)
|
||||
- For easy setup (recommended):
|
||||
- [r2modman](https://inscryption.thunderstore.io/package/ebkr/r2modman/) OR [Thunderstore Mod Manager](https://www.overwolf.com/app/Thunderstore-Thunderstore_Mod_Manager)
|
||||
- For manual setup:
|
||||
- [BepInEx pack for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/BepInExPack_Inscryption/)
|
||||
- [ArchipelagoMod](https://inscryption.thunderstore.io/package/Ballin_Inc/ArchipelagoMod/)
|
||||
|
||||
## Installation
|
||||
Before starting the installation process, here's what you should know:
|
||||
- Only install the mods mentioned in this guide if you want a guaranteed smooth experience! Other mods were NOT tested with ArchipelagoMod and could cause unwanted issues.
|
||||
- The ArchipelagoMod uses its own save file system when playing, but for safety measures, back up your save file by going to your Inscryption installation directory and copy the `SaveFile.gwsave` file to another folder.
|
||||
- It is strongly recommended to use a mod manager if you want a quicker and easier installation process, but if you don't like installing extra software and are comfortable moving files around, you can refer to the manual setup guide instead.
|
||||
|
||||
### Easy setup (mod manager)
|
||||
1. Download [r2modman](https://inscryption.thunderstore.io/package/ebkr/r2modman/) using the "Manual Download" button, then install it using the executable in the downloaded zip package (You can also use [Thunderstore Mod Manager](https://www.overwolf.com/app/Thunderstore-Thunderstore_Mod_Manager) which works the same, but it requires [Overwolf](https://www.overwolf.com/))
|
||||
2. Open the mod manager and select Inscryption in the game selection screen.
|
||||
3. Select the default profile or create a new one.
|
||||
4. Open the `Online` tab on the left, then search for `ArchipelagoMod`.
|
||||
5. Expand ArchipelagoMod and click the `Download` button to install the latest version and all its dependencies.
|
||||
6. Click `Start Modded` to open the game with the mods (a console should appear if everything was done correctly).
|
||||
|
||||
### Manual setup
|
||||
1. Download the following mods using the `Manual Download` button:
|
||||
- [BepInEx pack for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/BepInExPack_Inscryption/)
|
||||
- [ArchipelagoMod](https://inscryption.thunderstore.io/package/Ballin_Inc/ArchipelagoMod/)
|
||||
2. Open your Inscryption installation directory. On Steam, you can find it easily by right-clicking the game and clicking `Manage` > `Browse local files`.
|
||||
3. Open the BepInEx pack zip file, then open the `BepInExPack_Inscryption` folder.
|
||||
4. Drag all folders and files located inside the `BepInExPack_Inscryption` folder and drop them in your Inscryption directory.
|
||||
5. Open the `BepInEx` folder in your Inscryption directory.
|
||||
6. Open the ArchipelagoMod zip file.
|
||||
7. Drag and drop the `plugins` folder in the `BepInEx` folder to fuse with the existing `plugins` folder.
|
||||
8. Open the game normally to play with mods (if BepInEx was installed correctly, a console should appear).
|
||||
|
||||
## Joining a new MultiWorld Game
|
||||
1. After opening the game, you should see a new menu for browsing and creating save files.
|
||||
2. Click on the `New Game` button, then write a unique name for your save file.
|
||||
3. On the next screen, enter the information needed to connect to the MultiWorld server, then press the `Connect` button.
|
||||
4. If successful, the status on the top-right will change to "Connected". If not, a red error message will appear.
|
||||
5. After connecting to the server and receiving items, the game menu will appear.
|
||||
|
||||
## Continuing a MultiWorld Game
|
||||
1. After opening the game, you should see a list of your save files and a button to add a new one.
|
||||
2. Find the save file you want to use, then click its `Play` button.
|
||||
3. On the next screen, the input fields will be filled with the information you've written previously. You can adjust some fields if needed, then press the `Connect` button.
|
||||
4. If successful, the status on the top-right will change to "connected". If not, a red error message will appear.
|
||||
5. After connecting to the server and receiving items, the game menu will appear.
|
||||
|
||||
## Troubleshooting
|
||||
### The game opens normally without the new menu.
|
||||
If the new menu mentioned previously doesn't appear, it can be one of two issues:
|
||||
- If there was no console appearing when opening the game, this means the mods didn't load correctly. Here's what you can try:
|
||||
- If you are using the mod manager, make sure to open it and press `Start Modded`. Opening the game normally from Steam won't load any mods.
|
||||
- Check if the mod manager correctly found the game path. In the mod manager, click `Settings` then go to the `Locations` tab. Make sure the path listed under `Change Inscryption directory` is correct. You can verify the real path if you right-click the game on steam and click `Manage` > `Browse local files`. If the path is wrong, click that setting and change the path.
|
||||
- If you installed the mods manually, this usually means BepInEx was not correctly installed. Make sure to read the installation guide carefully.
|
||||
- If there is still no console when opening the game modded, try asking in the [Archipelago Discord Server](https://discord.gg/8Z65BR2) for help.
|
||||
- If there is a console, this means the mods loaded but the ArchipelagoMod wasn't found or had errors while loading.
|
||||
- Look in the console and make sure you can find a message about ArchipelagoMod being loaded.
|
||||
- If you see any red text, there was an error. Report the issue in the [Archipelago Discord Server](https://discord.gg/8Z65BR2) or create an issue in our [GitHub](https://github.com/DrBibop/Archipelago_Inscryption/issues).
|
||||
|
||||
### I'm getting a different issue.
|
||||
You can ask for help in the [Archipelago Discord Server](https://discord.gg/8Z65BR2) or, if you think you've found a bug with the mod, create an issue in our [GitHub](https://github.com/DrBibop/Archipelago_Inscryption/issues).
|
||||
@@ -1,67 +0,0 @@
|
||||
# Guide d'Installation de Inscryption Randomizer
|
||||
|
||||
## Logiciel Exigé
|
||||
|
||||
- [Inscryption](https://store.steampowered.com/app/1092790/Inscryption/)
|
||||
- Pour une installation facile (recommandé):
|
||||
- [r2modman](https://inscryption.thunderstore.io/package/ebkr/r2modman/) OU [Thunderstore Mod Manager](https://www.overwolf.com/app/Thunderstore-Thunderstore_Mod_Manager)
|
||||
- Pour une installation manuelle:
|
||||
- [BepInEx pack for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/BepInExPack_Inscryption/)
|
||||
- [MonoMod Loader for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/MonoMod_Loader_Inscryption/)
|
||||
- [Inscryption API](https://inscryption.thunderstore.io/package/API_dev/API/)
|
||||
- [ArchipelagoMod](https://inscryption.thunderstore.io/package/Ballin_Inc/ArchipelagoMod/)
|
||||
|
||||
## Installation
|
||||
Avant de commencer le processus d'installation, voici ce que vous deviez savoir:
|
||||
- Installez uniquement les mods mentionnés dans ce guide si vous souhaitez une expérience stable! Les autres mods n'ont PAS été testés avec ArchipelagoMod et peuvent provoquer des problèmes.
|
||||
- ArchipelagoMod utilise son propre système de sauvegarde lorsque vous jouez, mais pour des raisons de sécurité, sauvegardez votre fichier de sauvegarde en accédant à votre répertoire d'installation Inscryption et copiez le fichier `SaveFile.gwsave` dans un autre dossier.
|
||||
- Il est fortement recommandé d'utiliser un mod manager si vous souhaitez avoir un processus d'installation plus rapide et plus facile, mais si vous n'aimez pas installer de logiciels supplémentaires et que vous êtes à l'aise pour déplacer des fichiers, vous pouvez vous référer au guide de configuration manuelle.
|
||||
|
||||
### Installation facile (mod manager)
|
||||
1. Téléchargez [r2modman](https://inscryption.thunderstore.io/package/ebkr/r2modman/) à l'aide du bouton `Manual Download`, puis installez-le à l'aide de l'exécutable contenu dans le zip téléchargé (vous pouvez également utiliser [Thunderstore Mod Manager](https://www.overwolf.com/app/Thunderstore-Thunderstore_Mod_Manager) qui fonctionne de la même manière, mais cela nécessite [Overwolf](https://www.overwolf.com/))
|
||||
2. Ouvrez le mod manager et sélectionnez Inscryption dans l'écran de sélection de jeu.
|
||||
3. Sélectionnez le profil par défaut ou créez-en un nouveau.
|
||||
4. Ouvrez l'onglet `Online` à gauche, puis recherchez `ArchipelagoMod`.
|
||||
5. Développez ArchipelagoMod et cliquez sur le bouton `Download` pour installer la dernière version disponible et toutes ses dépendances.
|
||||
6. Cliquez sur `Start Modded` pour ouvrir le jeu avec les mods (une console devrait apparaître si tout a été fait correctement).
|
||||
|
||||
### Installation manuelle
|
||||
1. Téléchargez les mods suivants en utilisant le bouton `Manual Download`:
|
||||
- [BepInEx pack for Inscryption](https://inscryption.thunderstore.io/package/BepInEx/BepInExPack_Inscryption/)
|
||||
- [ArchipelagoMod](https://inscryption.thunderstore.io/package/Ballin_Inc/ArchipelagoMod/)
|
||||
2. Ouvrez votre dossier d'installation d'Inscryption. Sur Steam, vous pouvez le trouver facilement en faisant un clic droit sur le jeu et en cliquant sur `Gérer` > `Parcourir les fichiers locaux`.
|
||||
3. Ouvrez le fichier zip du pack BepInEx, puis ouvrez le dossier `BepInExPack_Inscryption`.
|
||||
4. Prenez tous les dossiers et fichiers situés dans le dossier `BepInExPack_Inscryption` et déposez-les dans votre dossier Inscryption.
|
||||
5. Ouvrez le dossier `BepInEx` dans votre dossier Inscryption.
|
||||
6. Ouvrez le fichier zip d'ArchipelagoMod.
|
||||
7. Prenez et déposez le dossier `plugins` dans le dossier `BepInEx` pour fusionner avec le dossier `plugins` existant.
|
||||
8. Ouvrez le jeu normalement pour jouer avec les mods (si BepInEx a été correctement installé, une console devrait apparaitre).
|
||||
|
||||
## Rejoindre un nouveau MultiWorld
|
||||
1. Après avoir ouvert le jeu, vous devriez voir un nouveau menu pour parcourir et créer des fichiers de sauvegarde.
|
||||
2. Cliquez sur le bouton `New Game`, puis écrivez un nom unique pour votre fichier de sauvegarde.
|
||||
3. Sur l'écran suivant, saisissez les informations nécessaires pour vous connecter au serveur MultiWorld, puis appuyez sur le bouton `Connect`.
|
||||
4. En cas de succès, l'état de connexion en haut à droite changera pour "Connected". Sinon, un message d'erreur rouge apparaîtra.
|
||||
5. Après s'être connecté au server et avoir reçu les items, le menu du jeu apparaîtra.
|
||||
|
||||
## Poursuivre une session MultiWorld
|
||||
1. Après avoir ouvert le jeu, vous devriez voir une liste de vos fichiers de sauvegarde et un bouton pour en ajouter un nouveau.
|
||||
2. Choisissez le fichier de sauvegarde que vous souhaitez utiliser, puis cliquez sur son bouton `Play`.
|
||||
3. Sur l'écran suivant, les champs de texte seront remplis avec les informations que vous avez écrites précédemment. Vous pouvez ajuster certains champs si nécessaire, puis appuyer sur le bouton `Connect`.
|
||||
4. En cas de succès, l'état de connexion en haut à droite changera pour "Connected". Sinon, un message d'erreur rouge apparaîtra.
|
||||
5. Après s'être connecté au server et avoir reçu les items, le menu du jeu apparaîtra.
|
||||
|
||||
## Dépannage
|
||||
### Le jeu ouvre normalement sans nouveau menu.
|
||||
Si le nouveau menu mentionné précédemment n'apparaît pas, c'est peut-être l'un des deux problèmes suivants:
|
||||
- Si aucune console n'apparait à l'ouverture du jeu, cela signifie que les mods ne se sont pas chargés correctement. Voici ce que vous pouvez essayer:
|
||||
- Si vous utilisez le mod manager, assurez-vous de l'ouvrir et d'appuyer sur `Start Modded`. Ouvrir le jeu normalement depuis Steam ne chargera aucun mod.
|
||||
- Vérifiez si le mod manager a correctement trouvé le répertoire du jeu. Dans le mod manager, cliquez sur `Settings` puis allez dans l'onglet `Locations`. Assurez-vous que le répertoire sous `Change Inscryption directory` est correct. Vous pouvez vérifier le répertoire correct si vous faites un clic droit sur le jeu Inscription sur Steam et cliquez sur `Gérer` > `Parcourir les fichiers locaux`. Si le répertoire est erroné, cliquez sur ce paramètre et modifiez le répertoire.
|
||||
- Si vous avez installé les mods manuellement, cela signifie généralement que BepInEx n'a pas été correctement installé. Assurez-vous de lire attentivement le guide d'installation.
|
||||
- S'il n'y a toujours pas de console lors de l'ouverture du jeu modifié, essayez de demander de l'aide sur [Archipelago Discord Server](https://discord.gg/8Z65BR2).
|
||||
- S'il y a une console, cela signifie que les mods ont été chargés, mais que ArchipelagoMod n'a pas été trouvé ou a eu des erreurs lors du chargement.
|
||||
- Regardez dans la console et assurez-vous que vous trouvez un message concernant le chargement d'ArchipelagoMod.
|
||||
- Si vous voyez du texte rouge, il y a eu une erreur. Signalez le problème dans [Archipelago Discord Server](https://discord.gg/8Z65BR2) ou dans notre [GitHub](https://github.com/DrBibop/Archipelago_Inscryption/issues).
|
||||
|
||||
### J'ai un autre problème.
|
||||
Vous pouvez demander de l'aide sur [le serveur Discord d'Archipelago](https://discord.gg/8Z65BR2) ou, si vous pensez avoir trouvé un bug avec le mod, signalez-le dans notre [GitHub](https://github.com/DrBibop/Archipelago_Inscryption/issues).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user