Compare commits

..

3 Commits

Author SHA1 Message Date
NewSoupVi
a9e79854a8 Merge branch 'main' into NewSoupVi-patch-30 2024-12-12 14:57:04 +01:00
NewSoupVi
2e81774a1f Update Utils.py
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-12-09 22:24:04 +01:00
NewSoupVi
409c915375 Fix crash when trying to log an exception
In https://github.com/ArchipelagoMW/Archipelago/pull/3028, we added a new logging filter which checked `record.msg`. 

However, you can pass whatever you want into a logging call. In this case, what we missed was ecc3094c70/MultiServer.py (L530C1-L530C37), where we pass an Exception object as the message. This currently causes a crash with the new filter.

The logging module supports this. It has no typing and can handle passing objects as messages just fine.

What you're supposed to use, as far as I understand it, is `record.getMessage()` instead of `record.msg`.
2024-12-01 12:37:17 +01:00
83 changed files with 621 additions and 5342 deletions

View File

@@ -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:

20
Fill.py
View File

@@ -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)
@@ -549,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,
)

View File

@@ -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:

View File

@@ -10,14 +10,6 @@ import websockets
from Utils import ByValue, Version
class HintStatus(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,

View File

@@ -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:
@@ -817,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]:
@@ -1111,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:
@@ -1379,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})

View File

@@ -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

View File

@@ -514,8 +514,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
def filter(self, record: logging.LogRecord) -> bool:
return self.condition(record)
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.msg))
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
root_logger.addHandler(file_handler)
if sys.stdout:
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
@@ -534,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

View File

@@ -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)),

View File

@@ -81,9 +81,6 @@
# Hylics 2
/worlds/hylics2/ @TRPG0
# Inscryption
/worlds/inscryption/ @DrBibop @Glowbuzz
# Kirby's Dream Land 3
/worlds/kdl3/ @Silvris

View File

@@ -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 &rarr; region dependencies, making indirect conditions preferred because they are much faster.

View File

@@ -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.

View File

@@ -540,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
@@ -554,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.
@@ -570,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. |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -52,68 +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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -294,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."""
@@ -307,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

View File

@@ -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}
]

View File

@@ -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": []
}

View File

@@ -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

View File

@@ -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": []
}

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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.

View File

@@ -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).

View File

@@ -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).

View File

@@ -1,221 +0,0 @@
from . import InscryptionTestBase
class AccessTestGeneral(InscryptionTestBase):
def test_dagger(self) -> None:
self.assertAccessDependency(["Act 1 - Magnificus Eye"], [["Dagger"]])
def test_caged_wolf(self) -> None:
self.assertAccessDependency(["Act 1 - Dagger"], [["Caged Wolf Card"]])
def test_magnificus_eye(self) -> None:
self.assertAccessDependency(["Act 1 - Clock Main Compartment"], [["Magnificus Eye"]])
def test_wardrobe_key(self) -> None:
self.assertAccessDependency(
["Act 1 - Wardrobe Drawer 1", "Act 1 - Wardrobe Drawer 2",
"Act 1 - Wardrobe Drawer 3", "Act 1 - Wardrobe Drawer 4"],
[["Wardrobe Key"]]
)
def test_ancient_obol(self) -> None:
self.assertAccessDependency(
["Act 2 - Bone Lord Femur", "Act 2 - Bone Lord Horn", "Act 2 - Bone Lord Holo Key"],
[["Ancient Obol"]]
)
def test_holo_pelt(self) -> None:
self.assertAccessDependency(
["Act 3 - Trader 1", "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5"],
[["Holo Pelt"]]
)
def test_inspectometer_battery(self) -> None:
self.assertAccessDependency(
["Act 3 - Boss Photographer", "Act 3 - Boss Archivist", "Act 3 - Boss Unfinished", "Act 3 - Boss G0lly",
"Act 3 - Trader 1", "Act 3 - Trader 2", "Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5",
"Act 3 - Shop Holo Pelt", "Act 3 - Middle Holo Pelt", "Act 3 - Forest Holo Pelt", "Act 3 - Clock",
"Act 3 - Crypt Holo Pelt", "Act 3 - Gems Drone", "Act 3 - Nano Armor Generator", "Act 3 - Extra Battery",
"Act 3 - Tower Holo Pelt", "Act 3 - The Great Transcendence", "Act 3 - Boss Mycologists",
"Act 3 - Bone Lord Room", "Act 3 - Well", "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 - Goobert's Painting"],
[["Inspectometer Battery"]]
)
def test_gem_drone(self) -> None:
self.assertAccessDependency(
["Act 3 - Boss Unfinished", "Act 3 - Boss G0lly", "Act 3 - Trader 1", "Act 3 - Trader 2",
"Act 3 - Trader 3", "Act 3 - Trader 4", "Act 3 - Trader 5", "Act 3 - Shop Holo Pelt", "Act 3 - Clock",
"Act 3 - Tower Holo Pelt", "Act 3 - The Great Transcendence", "Act 3 - Luke's File Entry 4",
"Act 3 - Boss Mycologists", "Act 3 - Nano Armor Generator", "Act 3 - Goobert's Painting"],
[["Gems Module"]]
)
def test_mycologists_holo_key(self) -> None:
self.assertAccessDependency(
["Act 3 - Boss Mycologists"],
[["Mycologists Holo Key"]]
)
def test_bone_lord_holo_key(self) -> None:
self.assertAccessDependency(
["Act 3 - Bone Lord Room"],
[["Bone Lord Holo Key"]]
)
def test_quill(self) -> None:
self.assertAccessDependency(
["Act 3 - Boss Archivist", "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 - The Great Transcendence",
"Act 3 - Boss Mycologists"],
[["Quill"]]
)
class AccessTestOrdered(InscryptionTestBase):
options = {
"goal": 0,
}
def test_film_roll(self) -> None:
self.assertAccessDependency(
["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 Pike Mage", "Act 2 - Battle Goobert",
"Act 2 - Battle Lonely Wizard", "Act 2 - Battle Inspector", "Act 2 - Battle Melter",
"Act 2 - Battle Dredger", "Act 2 - Tower Chest 1", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3",
"Act 2 - Forest Meadow Chest", "Act 2 - Forest Cabin 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 - Camera Replica", "Act 2 - Clover", "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", "Act 2 - Dock Chest",
"Act 2 - Tentacle", "Act 2 - Factory Trash Can", "Act 2 - Factory Drawer 1",
"Act 2 - Ancient Obol", "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 - Monocle", "Act 2 - Boss Leshy",
"Act 2 - Boss Grimora", "Act 2 - Boss Magnificus", "Act 2 - Boss P03", "Act 2 - Mycologists Holo Key",
"Act 2 - Bone Lord Femur", "Act 2 - Bone Lord Horn", "Act 2 - Bone Lord Holo Key",
"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 - Gems Drone",
"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"],
[["Film Roll"]]
)
def test_epitaphs_and_forest_items(self) -> None:
self.assertAccessDependency(
["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper",
"Act 2 - Battle Pike Mage", "Act 2 - Battle Goobert", "Act 2 - Battle Lonely Wizard",
"Act 2 - Battle Inspector", "Act 2 - Battle Melter", "Act 2 - Battle Dredger",
"Act 2 - Tower Chest 1", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3", "Act 2 - Forest Meadow Chest",
"Act 2 - Tentacle", "Act 2 - Factory Trash Can", "Act 2 - Factory Drawer 1", "Act 2 - Ancient Obol",
"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 - Monocle", "Act 2 - Boss Leshy",
"Act 2 - Boss Grimora", "Act 2 - Boss Magnificus", "Act 2 - Boss P03", "Act 2 - Mycologists Holo Key",
"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 - Gems Drone",
"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"],
[["Epitaph Piece", "Camera Replica", "Pile Of Meat"]]
)
def test_epitaphs(self) -> None:
self.assertAccessDependency(
["Act 2 - Boss Grimora",
"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 - Gems Drone",
"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"],
[["Epitaph Piece"]]
)
def test_forest_items(self) -> None:
self.assertAccessDependency(
["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper",
"Act 2 - Boss Leshy", "Act 2 - Forest Meadow Chest",
"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 - Gems Drone",
"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"],
[["Camera Replica", "Pile Of Meat"]]
)
def test_monocle(self) -> None:
self.assertAccessDependency(
["Act 2 - Battle Goobert", "Act 2 - Battle Pike Mage", "Act 2 - Battle Lonely Wizard",
"Act 2 - Boss Magnificus", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3",
"Act 2 - Tentacle", "Act 2 - Ancient Obol", "Act 2 - Mycologists Holo Key",
"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 - Gems Drone",
"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"],
[["Monocle"]]
)
class AccessTestUnordered(InscryptionTestBase):
options = {
"goal": 1,
}
def test_epitaphs_and_forest_items(self) -> None:
self.assertAccessDependency(
["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper",
"Act 2 - Battle Pike Mage", "Act 2 - Battle Goobert", "Act 2 - Battle Lonely Wizard",
"Act 2 - Battle Inspector", "Act 2 - Battle Melter", "Act 2 - Battle Dredger",
"Act 2 - Tower Chest 1", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3", "Act 2 - Forest Meadow Chest",
"Act 2 - Tentacle", "Act 2 - Factory Trash Can", "Act 2 - Factory Drawer 1", "Act 2 - Ancient Obol",
"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 - Monocle", "Act 2 - Boss Leshy",
"Act 2 - Boss Grimora", "Act 2 - Boss Magnificus", "Act 2 - Boss P03", "Act 2 - Mycologists Holo Key"],
[["Epitaph Piece", "Camera Replica", "Pile Of Meat"]]
)
def test_epitaphs(self) -> None:
self.assertAccessDependency(
["Act 2 - Boss Grimora"],
[["Epitaph Piece"]]
)
def test_forest_items(self) -> None:
self.assertAccessDependency(
["Act 2 - Battle Prospector", "Act 2 - Battle Angler", "Act 2 - Battle Trapper",
"Act 2 - Boss Leshy", "Act 2 - Forest Meadow Chest"],
[["Camera Replica", "Pile Of Meat"]]
)
def test_monocle(self) -> None:
self.assertAccessDependency(
["Act 2 - Battle Goobert", "Act 2 - Battle Pike Mage", "Act 2 - Battle Lonely Wizard",
"Act 2 - Boss Magnificus", "Act 2 - Tower Chest 2", "Act 2 - Tower Chest 3",
"Act 2 - Tentacle", "Act 2 - Ancient Obol", "Act 2 - Mycologists Holo Key"],
[["Monocle"]]
)
class AccessTestBalancedPaintings(InscryptionTestBase):
options = {
"painting_checks_balancing": 1,
}
def test_paintings(self) -> None:
self.assertAccessDependency(["Act 1 - Painting 2", "Act 1 - Painting 3"],
[["Oil Painting's Clover Plant", "Squirrel Totem Head"]])

View File

@@ -1,108 +0,0 @@
from . import InscryptionTestBase
class GoalTestOrdered(InscryptionTestBase):
options = {
"goal": 0,
}
def test_beatable(self) -> None:
for item_name in self.required_items_all_acts:
item = self.get_item_by_name(item_name)
self.collect(item)
for i in range(9):
item = self.get_item_by_name("Epitaph Piece")
self.collect(item)
self.assertBeatable(True)
for item_name in self.required_items_all_acts:
item = self.get_item_by_name(item_name)
self.remove(item)
self.assertBeatable(False)
self.collect(item)
item = self.get_item_by_name("Epitaph Piece")
self.remove(item)
self.assertBeatable(False)
self.collect(item)
class GoalTestUnordered(InscryptionTestBase):
options = {
"goal": 1,
}
def test_beatable(self) -> None:
for item_name in self.required_items_all_acts:
item = self.get_item_by_name(item_name)
self.collect(item)
for i in range(9):
item = self.get_item_by_name("Epitaph Piece")
self.collect(item)
self.assertBeatable(True)
for item_name in self.required_items_all_acts:
item = self.get_item_by_name(item_name)
self.remove(item)
self.assertBeatable(False)
self.collect(item)
item = self.get_item_by_name("Epitaph Piece")
self.remove(item)
self.assertBeatable(False)
self.collect(item)
class GoalTestAct1(InscryptionTestBase):
options = {
"goal": 2,
}
def test_beatable(self) -> None:
self.assertBeatable(False)
film_roll = self.get_item_by_name("Film Roll")
self.collect(film_roll)
self.assertBeatable(True)
class GoalTestGroupedEpitaphs(InscryptionTestBase):
options = {
"epitaph_pieces_randomization": 1,
}
def test_beatable(self) -> None:
for item_name in self.required_items_all_acts:
item = self.get_item_by_name(item_name)
self.collect(item)
for i in range(3):
item = self.get_item_by_name("Epitaph Pieces")
self.collect(item)
self.assertBeatable(True)
for item_name in self.required_items_all_acts:
item = self.get_item_by_name(item_name)
self.remove(item)
self.assertBeatable(False)
self.collect(item)
item = self.get_item_by_name("Epitaph Pieces")
self.remove(item)
self.assertBeatable(False)
self.collect(item)
class GoalTestEpitaphsAsOne(InscryptionTestBase):
options = {
"epitaph_pieces_randomization": 2,
}
def test_beatable(self) -> None:
for item_name in self.required_items_all_acts:
item = self.get_item_by_name(item_name)
self.collect(item)
item = self.get_item_by_name("Epitaph Pieces")
self.collect(item)
self.assertBeatable(True)
for item_name in self.required_items_all_acts:
item = self.get_item_by_name(item_name)
self.remove(item)
self.assertBeatable(False)
self.collect(item)
item = self.get_item_by_name("Epitaph Pieces")
self.remove(item)
self.assertBeatable(False)
self.collect(item)

View File

@@ -1,7 +0,0 @@
from test.bases import WorldTestBase
class InscryptionTestBase(WorldTestBase):
game = "Inscryption"
required_items_all_acts = ["Film Roll", "Camera Replica", "Pile Of Meat", "Monocle",
"Inspectometer Battery", "Gems Module", "Quill"]

View File

@@ -2,7 +2,7 @@ import logging
from typing import List
from BaseClasses import Tutorial, ItemClassification
from Fill import fast_fill
from Fill import fill_restrictive
from worlds.LauncherComponents import Component, components, Type, launch_subprocess
from worlds.AutoWorld import World, WebWorld
from .Items import *
@@ -287,7 +287,7 @@ class KH2World(World):
def pre_fill(self):
"""
Plandoing Events and Fast_Fill for donald,goofy and sora
Plandoing Events and Fill_Restrictive for donald,goofy and sora
"""
self.donald_pre_fill()
self.goofy_pre_fill()
@@ -431,10 +431,9 @@ class KH2World(World):
Fills keyblade slots with abilities determined on player's setting
"""
keyblade_locations = [self.multiworld.get_location(location, self.player) for location in Keyblade_Slots.keys()]
state = self.multiworld.get_all_state(False)
keyblade_ability_pool_copy = self.keyblade_ability_pool.copy()
fast_fill(self.multiworld, keyblade_ability_pool_copy, keyblade_locations)
for location in keyblade_locations:
location.locked = True
fill_restrictive(self.multiworld, state, keyblade_locations, keyblade_ability_pool_copy, True, True, allow_excluded=True)
def starting_invo_verify(self):
"""

View File

@@ -1,531 +0,0 @@
BLOCKED_ASSOCIATIONS = [
# MAX_ARROWS_UPGRADE, MAX_BOMBS_UPGRADE, MAX_POWDER_UPGRADE
# arrows and bombs will be matched to arrow and bomb respectively through pluralization
"ARROWS",
"BOMBS",
"MAX",
"UPGRADE",
"TAIL", # TAIL_KEY
"ANGLER", # ANGLER_KEY
"FACE", # FACE_KEY
"BIRD", # BIRD_KEY
"SLIME", # SLIME_KEY
"NIGHTMARE",# NIGHTMARE_KEY
"BLUE", # BLUE_TUNIC
"RED", # RED_TUNIC
"TRADING", # TRADING_ITEM_*
"ITEM", # TRADING_ITEM_*
"BAD", # BAD_HEART_CONTAINER
"GOLD", # GOLD_LEAF
"MAGIC", # MAGIC_POWDER, MAGIC_ROD
"MESSAGE", # MESSAGE (Master Stalfos' Message)
"PEGASUS", # PEGASUS_BOOTS
"PIECE", # HEART_PIECE, PIECE_OF_POWER
"POWER", # POWER_BRACELET, PIECE_OF_POWER
"SINGLE", # SINGLE_ARROW
"STONE", # STONE_BEAK
"BEAK1",
"BEAK2",
"BEAK3",
"BEAK4",
"BEAK5",
"BEAK6",
"BEAK7",
"BEAK8",
"COMPASS1",
"COMPASS2",
"COMPASS3",
"COMPASS4",
"COMPASS5",
"COMPASS6",
"COMPASS7",
"COMPASS8",
"MAP1",
"MAP2",
"MAP3",
"MAP4",
"MAP5",
"MAP6",
"MAP7",
"MAP8",
]
# Single word synonyms for Link's Awakening items, for generic matching.
SYNONYMS = {
# POWER_BRACELET
'ANKLET': 'POWER_BRACELET',
'ARMLET': 'POWER_BRACELET',
'BAND': 'POWER_BRACELET',
'BANGLE': 'POWER_BRACELET',
'BRACER': 'POWER_BRACELET',
'CARRY': 'POWER_BRACELET',
'CIRCLET': 'POWER_BRACELET',
'CROISSANT': 'POWER_BRACELET',
'GAUNTLET': 'POWER_BRACELET',
'GLOVE': 'POWER_BRACELET',
'RING': 'POWER_BRACELET',
'STRENGTH': 'POWER_BRACELET',
# SHIELD
'AEGIS': 'SHIELD',
'BUCKLER': 'SHIELD',
'SHLD': 'SHIELD',
# BOW
'BALLISTA': 'BOW',
# HOOKSHOT
'GRAPPLE': 'HOOKSHOT',
'GRAPPLING': 'HOOKSHOT',
'ROPE': 'HOOKSHOT',
# MAGIC_ROD
'BEAM': 'MAGIC_ROD',
'CANE': 'MAGIC_ROD',
'STAFF': 'MAGIC_ROD',
'WAND': 'MAGIC_ROD',
# PEGASUS_BOOTS
'BOOT': 'PEGASUS_BOOTS',
'GREAVES': 'PEGASUS_BOOTS',
'RUN': 'PEGASUS_BOOTS',
'SHOE': 'PEGASUS_BOOTS',
'SPEED': 'PEGASUS_BOOTS',
# OCARINA
'FLUTE': 'OCARINA',
'RECORDER': 'OCARINA',
# FEATHER
'JUMP': 'FEATHER',
'PLUME': 'FEATHER',
'WING': 'FEATHER',
# SHOVEL
'DIG': 'SHOVEL',
# MAGIC_POWDER
'BAG': 'MAGIC_POWDER',
'CASE': 'MAGIC_POWDER',
'DUST': 'MAGIC_POWDER',
'POUCH': 'MAGIC_POWDER',
'SACK': 'MAGIC_POWDER',
# BOMB
'BLAST': 'BOMB',
'BOMBCHU': 'BOMB',
'FIRECRACKER': 'BOMB',
'TNT': 'BOMB',
# SWORD
'BLADE': 'SWORD',
'CUT': 'SWORD',
'DAGGER': 'SWORD',
'DIRK': 'SWORD',
'EDGE': 'SWORD',
'EPEE': 'SWORD',
'EXCALIBUR': 'SWORD',
'FALCHION': 'SWORD',
'KATANA': 'SWORD',
'KNIFE': 'SWORD',
'MACHETE': 'SWORD',
'MASAMUNE': 'SWORD',
'MURASAME': 'SWORD',
'SABER': 'SWORD',
'SABRE': 'SWORD',
'SCIMITAR': 'SWORD',
'SLASH': 'SWORD',
# FLIPPERS
'FLIPPER': 'FLIPPERS',
'SWIM': 'FLIPPERS',
# MEDICINE
'BOTTLE': 'MEDICINE',
'FLASK': 'MEDICINE',
'LEMONADE': 'MEDICINE',
'POTION': 'MEDICINE',
'TEA': 'MEDICINE',
# TAIL_KEY
# ANGLER_KEY
# FACE_KEY
# BIRD_KEY
# SLIME_KEY
# GOLD_LEAF
'HERB': 'GOLD_LEAF',
# RUPEES_20
'COIN': 'RUPEES_20',
'MONEY': 'RUPEES_20',
'RUPEE': 'RUPEES_20',
# RUPEES_50
# RUPEES_100
# RUPEES_200
# RUPEES_500
'GEM': 'RUPEES_500',
'JEWEL': 'RUPEES_500',
# SEASHELL
'CARAPACE': 'SEASHELL',
'CONCH': 'SEASHELL',
'SHELL': 'SEASHELL',
# MESSAGE (master stalfos message)
'NOTHING': 'MESSAGE',
'TRAP': 'MESSAGE',
# BOOMERANG
'BOOMER': 'BOOMERANG',
# HEART_PIECE
# BOWWOW
'BEAST': 'BOWWOW',
'PET': 'BOWWOW',
# ARROWS_10
# SINGLE_ARROW
'MISSILE': 'SINGLE_ARROW',
'QUIVER': 'SINGLE_ARROW',
# ROOSTER
'BIRD': 'ROOSTER',
'CHICKEN': 'ROOSTER',
'CUCCO': 'ROOSTER',
'FLY': 'ROOSTER',
'GRIFFIN': 'ROOSTER',
'GRYPHON': 'ROOSTER',
# MAX_POWDER_UPGRADE
# MAX_BOMBS_UPGRADE
# MAX_ARROWS_UPGRADE
# RED_TUNIC
# BLUE_TUNIC
'ARMOR': 'BLUE_TUNIC',
'MAIL': 'BLUE_TUNIC',
'SUIT': 'BLUE_TUNIC',
# HEART_CONTAINER
'TANK': 'HEART_CONTAINER',
# TOADSTOOL
'FUNGAL': 'TOADSTOOL',
'FUNGUS': 'TOADSTOOL',
'MUSHROOM': 'TOADSTOOL',
'SHROOM': 'TOADSTOOL',
# GUARDIAN_ACORN
'NUT': 'GUARDIAN_ACORN',
'SEED': 'GUARDIAN_ACORN',
# KEY
'DOOR': 'KEY',
'GATE': 'KEY',
'KEY': 'KEY', # Without this, foreign keys show up as nightmare keys
'LOCK': 'KEY',
'PANEL': 'KEY',
'UNLOCK': 'KEY',
# NIGHTMARE_KEY
# MAP
# COMPASS
# STONE_BEAK
'FOSSIL': 'STONE_BEAK',
'RELIC': 'STONE_BEAK',
# SONG1
'BOLERO': 'SONG1',
'LULLABY': 'SONG1',
'MELODY': 'SONG1',
'MINUET': 'SONG1',
'NOCTURNE': 'SONG1',
'PRELUDE': 'SONG1',
'REQUIEM': 'SONG1',
'SERENADE': 'SONG1',
'SONG': 'SONG1',
# SONG2
'FISH': 'SONG2',
'SURF': 'SONG2',
# SONG3
'FROG': 'SONG3',
# INSTRUMENT1
'CELLO': 'INSTRUMENT1',
'GUITAR': 'INSTRUMENT1',
'LUTE': 'INSTRUMENT1',
'VIOLIN': 'INSTRUMENT1',
# INSTRUMENT2
'HORN': 'INSTRUMENT2',
# INSTRUMENT3
'BELL': 'INSTRUMENT3',
'CHIME': 'INSTRUMENT3',
# INSTRUMENT4
'HARP': 'INSTRUMENT4',
'KANTELE': 'INSTRUMENT4',
# INSTRUMENT5
'MARIMBA': 'INSTRUMENT5',
'XYLOPHONE': 'INSTRUMENT5',
# INSTRUMENT6 (triangle)
# INSTRUMENT7
'KEYBOARD': 'INSTRUMENT7',
'ORGAN': 'INSTRUMENT7',
'PIANO': 'INSTRUMENT7',
# INSTRUMENT8
'DRUM': 'INSTRUMENT8',
# TRADING_ITEM_YOSHI_DOLL
'DINOSAUR': 'TRADING_ITEM_YOSHI_DOLL',
'DRAGON': 'TRADING_ITEM_YOSHI_DOLL',
'TOY': 'TRADING_ITEM_YOSHI_DOLL',
# TRADING_ITEM_RIBBON
'HAIRBAND': 'TRADING_ITEM_RIBBON',
'HAIRPIN': 'TRADING_ITEM_RIBBON',
# TRADING_ITEM_DOG_FOOD
'CAN': 'TRADING_ITEM_DOG_FOOD',
# TRADING_ITEM_BANANAS
'BANANA': 'TRADING_ITEM_BANANAS',
# TRADING_ITEM_STICK
'BRANCH': 'TRADING_ITEM_STICK',
'TWIG': 'TRADING_ITEM_STICK',
# TRADING_ITEM_HONEYCOMB
'BEEHIVE': 'TRADING_ITEM_HONEYCOMB',
'HIVE': 'TRADING_ITEM_HONEYCOMB',
'HONEY': 'TRADING_ITEM_HONEYCOMB',
# TRADING_ITEM_PINEAPPLE
'FOOD': 'TRADING_ITEM_PINEAPPLE',
'FRUIT': 'TRADING_ITEM_PINEAPPLE',
'GOURD': 'TRADING_ITEM_PINEAPPLE',
# TRADING_ITEM_HIBISCUS
'FLOWER': 'TRADING_ITEM_HIBISCUS',
'PETAL': 'TRADING_ITEM_HIBISCUS',
# TRADING_ITEM_LETTER
'CARD': 'TRADING_ITEM_LETTER',
'MESSAGE': 'TRADING_ITEM_LETTER',
# TRADING_ITEM_BROOM
'SWEEP': 'TRADING_ITEM_BROOM',
# TRADING_ITEM_FISHING_HOOK
'CLAW': 'TRADING_ITEM_FISHING_HOOK',
# TRADING_ITEM_NECKLACE
'AMULET': 'TRADING_ITEM_NECKLACE',
'BEADS': 'TRADING_ITEM_NECKLACE',
'PEARLS': 'TRADING_ITEM_NECKLACE',
'PENDANT': 'TRADING_ITEM_NECKLACE',
'ROSARY': 'TRADING_ITEM_NECKLACE',
# TRADING_ITEM_SCALE
# TRADING_ITEM_MAGNIFYING_GLASS
'FINDER': 'TRADING_ITEM_MAGNIFYING_GLASS',
'LENS': 'TRADING_ITEM_MAGNIFYING_GLASS',
'MIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS',
'SCOPE': 'TRADING_ITEM_MAGNIFYING_GLASS',
'XRAY': 'TRADING_ITEM_MAGNIFYING_GLASS',
# PIECE_OF_POWER
'TRIANGLE': 'PIECE_OF_POWER',
'POWER': 'PIECE_OF_POWER',
'TRIFORCE': 'PIECE_OF_POWER',
}
# For generic multi-word matches.
PHRASES = {
'BIG KEY': 'NIGHTMARE_KEY',
'BOSS KEY': 'NIGHTMARE_KEY',
'HEART PIECE': 'HEART_PIECE',
'PIECE OF HEART': 'HEART_PIECE',
}
# All following will only be used to match items for the specific game.
# Item names will be uppercased when comparing.
# Can be multi-word.
GAME_SPECIFIC_PHRASES = {
'Final Fantasy': {
'OXYALE': 'MEDICINE',
'VORPAL': 'SWORD',
'XCALBER': 'SWORD',
},
'The Legend of Zelda': {
'WATER OF LIFE': 'MEDICINE',
},
'The Legend of Zelda - Oracle of Seasons': {
'RARE PEACH STONE': 'HEART_PIECE',
},
'Noita': {
'ALL-SEEING EYE': 'TRADING_ITEM_MAGNIFYING_GLASS', # lets you find secrets
},
'Ocarina of Time': {
'COJIRO': 'ROOSTER',
},
'SMZ3': {
'BIGKEY': 'NIGHTMARE_KEY',
'BYRNA': 'MAGIC_ROD',
'HEARTPIECE': 'HEART_PIECE',
'POWERBOMB': 'BOMB',
'SOMARIA': 'MAGIC_ROD',
'SUPER': 'SINGLE_ARROW',
},
'Sonic Adventure 2 Battle': {
'CHAOS EMERALD': 'PIECE_OF_POWER',
},
'Super Mario 64': {
'POWER STAR': 'PIECE_OF_POWER',
},
'Super Mario World': {
'P-BALLOON': 'FEATHER',
},
'Super Metroid': {
'POWER BOMB': 'BOMB',
},
'The Witness': {
'BONK': 'BOMB',
'BUNKER LASER': 'INSTRUMENT4',
'DESERT LASER': 'INSTRUMENT5',
'JUNGLE LASER': 'INSTRUMENT4',
'KEEP LASER': 'INSTRUMENT7',
'MONASTERY LASER': 'INSTRUMENT1',
'POWER SURGE': 'BOMB',
'PUZZLE SKIP': 'GOLD_LEAF',
'QUARRY LASER': 'INSTRUMENT8',
'SHADOWS LASER': 'INSTRUMENT1',
'SHORTCUTS': 'KEY',
'SLOWNESS': 'BOMB',
'SWAMP LASER': 'INSTRUMENT2',
'SYMMETRY LASER': 'INSTRUMENT6',
'TOWN LASER': 'INSTRUMENT3',
'TREEHOUSE LASER': 'INSTRUMENT2',
'WATER PUMPS': 'KEY',
},
'TUNIC': {
"AURA'S GEM": 'SHIELD', # card that enhances the shield
'DUSTY': 'TRADING_ITEM_BROOM', # a broom
'HERO RELIC - HP': 'TRADING_ITEM_HIBISCUS',
'HERO RELIC - MP': 'TOADSTOOL',
'HERO RELIC - SP': 'FEATHER',
'HP BERRY': 'GUARDIAN_ACORN',
'HP OFFERING': 'TRADING_ITEM_HIBISCUS', # a flower
'LUCKY CUP': 'HEART_CONTAINER', # card with a heart on it
'INVERTED ASH': 'MEDICINE', # card with a potion on it
'MAGIC ORB': 'HOOKSHOT',
'MP BERRY': 'GUARDIAN_ACORN',
'MP OFFERING': 'TOADSTOOL', # a mushroom
'QUESTAGON': 'PIECE_OF_POWER', # triforce piece equivalent
'SP OFFERING': 'FEATHER', # a feather
'SPRING FALLS': 'TRADING_ITEM_HIBISCUS', # a flower
},
'FNaFW': {
'Freddy': 'TRADING_ITEM_YOSHI_DOLL', # all of these are animatronics, aka dolls.
'Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Toy Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Toy Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Toy Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Mangle': 'TRADING_ITEM_YOSHI_DOLL',
'Balloon Boy': 'TRADING_ITEM_YOSHI_DOLL',
'JJ': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom BB': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Mangle': 'TRADING_ITEM_YOSHI_DOLL',
'Withered Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Withered Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Withered Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Withered Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Shadow Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Marionette': 'TRADING_ITEM_YOSHI_DOLL',
'Phantom Marionette': 'TRADING_ITEM_YOSHI_DOLL',
'Golden Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Paperpals': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Freddy': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Endo 01': 'TRADING_ITEM_YOSHI_DOLL',
'Endo 02': 'TRADING_ITEM_YOSHI_DOLL',
'Plushtrap': 'TRADING_ITEM_YOSHI_DOLL',
'Endoplush': 'TRADING_ITEM_YOSHI_DOLL',
'Springtrap': 'TRADING_ITEM_YOSHI_DOLL',
'RWQFSFASXC': 'TRADING_ITEM_YOSHI_DOLL',
'Crying Child': 'TRADING_ITEM_YOSHI_DOLL',
'Funtime Foxy': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare Fredbear': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare': 'TRADING_ITEM_YOSHI_DOLL',
'Fredbear': 'TRADING_ITEM_YOSHI_DOLL',
'Spring Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Jack-O-Chica': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmare BB': 'TRADING_ITEM_YOSHI_DOLL',
'Coffee': 'TRADING_ITEM_YOSHI_DOLL',
'Jack-O-Bonnie': 'TRADING_ITEM_YOSHI_DOLL',
'Purpleguy': 'TRADING_ITEM_YOSHI_DOLL',
'Nightmarionne': 'TRADING_ITEM_YOSHI_DOLL',
'Mr. Chipper': 'TRADING_ITEM_YOSHI_DOLL',
'Animdude': 'TRADING_ITEM_YOSHI_DOLL',
'Progressive Endoskeleton': 'BLUE_TUNIC', # basically armor you wear to give you more defense
'25 Tokens': 'RUPEES_20', # money
'50 Tokens': 'RUPEES_50',
'100 Tokens': 'RUPEES_100',
'250 Tokens': 'RUPEES_200',
'500 Tokens': 'RUPEES_500',
'1000 Tokens': 'RUPEES_500',
'2500 Tokens': 'RUPEES_500',
'5000 Tokens': 'RUPEES_500',
},
}

View File

@@ -98,7 +98,6 @@ class ItemName:
HEART_CONTAINER = "Heart Container"
BAD_HEART_CONTAINER = "Bad Heart Container"
TOADSTOOL = "Toadstool"
GUARDIAN_ACORN = "Guardian Acorn"
KEY = "Key"
KEY1 = "Small Key (Tail Cave)"
KEY2 = "Small Key (Bottle Grotto)"
@@ -174,7 +173,6 @@ class ItemName:
TRADING_ITEM_NECKLACE = "Necklace"
TRADING_ITEM_SCALE = "Scale"
TRADING_ITEM_MAGNIFYING_GLASS = "Magnifying Glass"
PIECE_OF_POWER = "Piece Of Power"
trade_item_prog = ItemClassification.progression
@@ -221,7 +219,6 @@ links_awakening_items = [
ItemData(ItemName.HEART_CONTAINER, "HEART_CONTAINER", ItemClassification.useful),
#ItemData(ItemName.BAD_HEART_CONTAINER, "BAD_HEART_CONTAINER", ItemClassification.trap),
ItemData(ItemName.TOADSTOOL, "TOADSTOOL", ItemClassification.progression),
ItemData(ItemName.GUARDIAN_ACORN, "GUARDIAN_ACORN", ItemClassification.filler),
DungeonItemData(ItemName.KEY, "KEY", ItemClassification.progression),
DungeonItemData(ItemName.KEY1, "KEY1", ItemClassification.progression),
DungeonItemData(ItemName.KEY2, "KEY2", ItemClassification.progression),
@@ -296,8 +293,7 @@ links_awakening_items = [
TradeItemData(ItemName.TRADING_ITEM_FISHING_HOOK, "TRADING_ITEM_FISHING_HOOK", trade_item_prog, "Grandma (Animal Village)"),
TradeItemData(ItemName.TRADING_ITEM_NECKLACE, "TRADING_ITEM_NECKLACE", trade_item_prog, "Fisher (Martha's Bay)"),
TradeItemData(ItemName.TRADING_ITEM_SCALE, "TRADING_ITEM_SCALE", trade_item_prog, "Mermaid (Martha's Bay)"),
TradeItemData(ItemName.TRADING_ITEM_MAGNIFYING_GLASS, "TRADING_ITEM_MAGNIFYING_GLASS", trade_item_prog, "Mermaid Statue (Martha's Bay)"),
ItemData(ItemName.PIECE_OF_POWER, "PIECE_OF_POWER", ItemClassification.filler),
TradeItemData(ItemName.TRADING_ITEM_MAGNIFYING_GLASS, "TRADING_ITEM_MAGNIFYING_GLASS", trade_item_prog, "Mermaid Statue (Martha's Bay)")
]
ladxr_item_to_la_item_name = {

View File

@@ -58,6 +58,7 @@ from . import hints
from .patches import bank34
from .utils import formatText
from ..Options import TrendyGame, Palette, Warps
from .roomEditor import RoomEditor, Object
from .patches.aesthetics import rgb_to_bin, bin_to_rgb
@@ -65,7 +66,7 @@ from .locations.keyLocation import KeyLocation
from BaseClasses import ItemClassification
from ..Locations import LinksAwakeningLocation
from ..Options import TrendyGame, Palette, MusicChangeCondition, Warps
from ..Options import TrendyGame, Palette, MusicChangeCondition, BootsControls
if TYPE_CHECKING:
from .. import LinksAwakeningWorld
@@ -155,8 +156,6 @@ def generateRom(args, world: "LinksAwakeningWorld"):
if not world.ladxr_settings.rooster:
patches.maptweaks.tweakMap(rom)
patches.maptweaks.tweakBirdKeyRoom(rom)
if world.ladxr_settings.overworld == "openmabe":
patches.maptweaks.openMabe(rom)
patches.chest.fixChests(rom)
patches.shop.fixShop(rom)
patches.rooster.patchRooster(rom)
@@ -248,7 +247,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.core.quickswap(rom, 1)
elif world.ladxr_settings.quickswap == 'b':
patches.core.quickswap(rom, 0)
patches.core.addBootsControls(rom, world.options.boots_controls)
@@ -398,7 +397,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
# Then put new text in
for bucket_idx, (orig_idx, data) in enumerate(bucket):
rom.texts[shuffled[bucket_idx][0]] = data
if world.options.trendy_game != TrendyGame.option_normal:

View File

@@ -87,8 +87,6 @@ CHEST_ITEMS = {
TOADSTOOL: 0x50,
GUARDIAN_ACORN: 0x51,
HEART_PIECE: 0x80,
BOWWOW: 0x81,
ARROWS_10: 0x82,
@@ -130,6 +128,4 @@ CHEST_ITEMS = {
TRADING_ITEM_NECKLACE: 0xA2,
TRADING_ITEM_SCALE: 0xA3,
TRADING_ITEM_MAGNIFYING_GLASS: 0xA4,
PIECE_OF_POWER: 0xA5,
}

View File

@@ -44,8 +44,6 @@ BAD_HEART_CONTAINER = "BAD_HEART_CONTAINER"
TOADSTOOL = "TOADSTOOL"
GUARDIAN_ACORN = "GUARDIAN_ACORN"
KEY = "KEY"
KEY1 = "KEY1"
KEY2 = "KEY2"
@@ -126,5 +124,3 @@ TRADING_ITEM_FISHING_HOOK = "TRADING_ITEM_FISHING_HOOK"
TRADING_ITEM_NECKLACE = "TRADING_ITEM_NECKLACE"
TRADING_ITEM_SCALE = "TRADING_ITEM_SCALE"
TRADING_ITEM_MAGNIFYING_GLASS = "TRADING_ITEM_MAGNIFYING_GLASS"
PIECE_OF_POWER = "PIECE_OF_POWER"

View File

@@ -144,12 +144,7 @@ class World:
self._addEntrance("moblin_cave", graveyard, moblin_cave, None)
# "Ukuku Prairie"
ukuku_prairie = Location()
if options.overworld == "openmabe":
ukuku_prairie.connect(mabe_village, r.bush)
else:
ukuku_prairie.connect(mabe_village, POWER_BRACELET)
ukuku_prairie.connect(graveyard, POWER_BRACELET)
ukuku_prairie = Location().connect(mabe_village, POWER_BRACELET).connect(graveyard, POWER_BRACELET)
ukuku_prairie.connect(Location().add(TradeSequenceItem(0x07B, TRADING_ITEM_STICK)), TRADING_ITEM_BANANAS)
ukuku_prairie.connect(Location().add(TradeSequenceItem(0x087, TRADING_ITEM_HONEYCOMB)), TRADING_ITEM_STICK)
self._addEntrance("prairie_left_phone", ukuku_prairie, None, None)

View File

@@ -835,7 +835,6 @@ ItemSpriteTable:
db $46, $1C ; NIGHTMARE_KEY8
db $46, $1C ; NIGHTMARE_KEY9
db $4C, $1C ; Toadstool
db $AE, $14 ; Guardian Acorn
LargeItemSpriteTable:
db $AC, $02, $AC, $22 ; heart piece
@@ -875,7 +874,6 @@ LargeItemSpriteTable:
db $D8, $0D, $DA, $0D ; TradeItem12
db $DC, $0D, $DE, $0D ; TradeItem13
db $E0, $0D, $E2, $0D ; TradeItem14
db $14, $42, $14, $62 ; Piece Of Power
ItemMessageTable:
db $90, $3D, $89, $93, $94, $95, $96, $97, $98, $99, $9A, $9B, $9C, $9D, $D9, $A2
@@ -890,7 +888,7 @@ ItemMessageTable:
; $80
db $4F, $C8, $CA, $CB, $E2, $E3, $E4, $CC, $CD, $2A, $2B, $C9, $C9, $C9, $C9, $C9
db $C9, $C9, $C9, $C9, $C9, $C9, $B8, $44, $C9, $C9, $C9, $C9, $C9, $C9, $C9, $C9
db $C9, $C9, $C9, $C9, $9D, $C9
db $C9, $C9, $C9, $C9, $9D
RenderDroppedKey:
;TODO: See EntityInitKeyDropPoint for a few special cases to unload.

View File

@@ -170,7 +170,7 @@ ItemNamePointers:
dw ItemNameNightmareKey8
dw ItemNameNightmareKey9
dw ItemNameToadstool
dw ItemNameGuardianAcorn
dw ItemNameNone ; 0x51
dw ItemNameNone ; 0x52
dw ItemNameNone ; 0x53
dw ItemNameNone ; 0x54
@@ -254,7 +254,6 @@ ItemNamePointers:
dw ItemTradeQuest12
dw ItemTradeQuest13
dw ItemTradeQuest14
dw ItemPieceOfPower
ItemNameNone:
db m"NONE", $ff
@@ -419,8 +418,6 @@ ItemNameNightmareKey9:
db m"Got the {NIGHTMARE_KEY9}", $ff
ItemNameToadstool:
db m"Got the {TOADSTOOL}", $ff
ItemNameGuardianAcorn:
db m"Got a Guardian Acorn", $ff
ItemNameHeartPiece:
db m"Got the {HEART_PIECE}", $ff
@@ -499,8 +496,5 @@ ItemTradeQuest13:
db m"You've got the Scale", $ff
ItemTradeQuest14:
db m"You've got the Magnifying Lens", $ff
ItemPieceOfPower:
db m"You've got a Piece of Power", $ff
MultiNamePointers:

View File

@@ -96,9 +96,7 @@ StartGameMarinMessage:
ldi [hl], a ;hour counter
ld hl, $B010
ld a, $01 ;tarin's gift gets skipped for some reason, so inflate count by 1
ldi [hl], a ;check counter low
xor a
ldi [hl], a ;check counter high
; Show the normal message

View File

@@ -24,10 +24,14 @@ notSpecialSideView:
ld a, $06 ; giveItemMultiworld
rst 8
;Show message
ldh a, [$F1] ; Load active sprite variant to see if this is just a normal small key
cp $1A
jr z, isAKey
;Show message (if not a key)
ld a, $0A ; showMessageMultiworld
rst 8
isAKey:
ret
"""))
rom.patch(0x03, 0x24B7, "3E", "3E") # sanity check

View File

@@ -38,12 +38,3 @@ def tweakBirdKeyRoom(rom):
re.moveObject(2, 5, 3, 6)
re.addEntity(3, 5, 0x9D)
re.store(rom)
def openMabe(rom):
# replaces rocks on east side of Mabe Village with bushes
re = RoomEditor(rom, 0x094)
re.changeObject(5, 1, 0x5C)
re.overlay[5 + 1 * 10] = 0x5C
re.overlay[5 + 2 * 10] = 0x5C
re.store(rom)

View File

@@ -169,7 +169,7 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
[Never] you can never steal from the shop."""),
Setting('bowwow', 'Special', 'g', 'Good boy mode', options=[('normal', '', 'Disabled'), ('always', 'a', 'Enabled'), ('swordless', 's', 'Enabled (swordless)')], default='normal',
description='Allows BowWow to be taken into any area, damage bosses and more enemies. If enabled you always start with bowwow. Swordless option removes the swords from the game and requires you to beat the game without a sword and just bowwow.'),
Setting('overworld', 'Special', 'O', 'Overworld', options=[('normal', '', 'Normal'), ('dungeondive', 'D', 'Dungeon dive'), ('nodungeons', 'N', 'No dungeons'), ('random', 'R', 'Randomized'), ('openmabe', 'M', 'Open Mabe')], default='normal',
Setting('overworld', 'Special', 'O', 'Overworld', options=[('normal', '', 'Normal'), ('dungeondive', 'D', 'Dungeon dive'), ('nodungeons', 'N', 'No dungeons'), ('random', 'R', 'Randomized')], default='normal',
description="""
[Dungeon Dive] Create a different overworld where all the dungeons are directly accessible and almost no chests are located in the overworld.
[No dungeons] All dungeons only consist of a boss fight and a instrument reward. Rest of the dungeon is removed.
@@ -181,7 +181,7 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
Setting('quickswap', 'User options', 'Q', 'Quickswap', options=[('none', '', 'Disabled'), ('a', 'a', 'Swap A button'), ('b', 'b', 'Swap B button')], default='none',
description='Adds that the select button swaps with either A or B. The item is swapped with the top inventory slot. The map is not available when quickswap is enabled.',
aesthetic=True),
Setting('textmode', 'User options', 'f', 'Text mode', options=[('fast', '', 'Fast'), ('normal', 'd', 'Normal'), ('none', 'n', 'No-text')], default='fast',
Setting('textmode', 'User options', 'f', 'Text mode', options=[('fast', '', 'Fast'), ('default', 'd', 'Normal'), ('none', 'n', 'No-text')], default='fast',
description="""[Fast] makes text appear twice as fast.
[No-Text] removes all text from the game""", aesthetic=True),
Setting('lowhpbeep', 'User options', 'p', 'Low HP beeps', options=[('none', 'D', 'Disabled'), ('slow', 'S', 'Slow'), ('default', 'N', 'Normal')], default='slow',

View File

@@ -57,7 +57,7 @@ class TextShuffle(DefaultOffToggle):
class Rooster(DefaultOnToggle, LADXROption):
"""
[On] Adds the rooster to the item pool.
[On] Adds the rooster to the item pool.
[Off] The rooster spot is still a check giving an item. But you will never find the rooster. In that case, any rooster spot is accessible without rooster by other means.
"""
display_name = "Rooster"
@@ -70,7 +70,7 @@ class Boomerang(Choice):
[Gift] The boomerang salesman will give you a random item, and the boomerang is shuffled.
"""
display_name = "Boomerang"
normal = 0
gift = 1
default = gift
@@ -156,7 +156,7 @@ class ShuffleSmallKeys(DungeonItemShuffle):
[Own Dungeons] The item will be within a dungeon in your world
[Own World] The item will be somewhere in your world
[Any World] The item could be anywhere
[Different World] The item will be somewhere in another world
[Different World] The item will be somewhere in another world
"""
display_name = "Shuffle Small Keys"
ladxr_item = "KEY"
@@ -223,7 +223,7 @@ class Goal(Choice, LADXROption):
The Goal of the game
[Instruments] The Wind Fish's Egg will only open if you have the required number of Instruments of the Sirens, and play the Ballad of the Wind Fish.
[Seashells] The Egg will open when you bring 20 seashells. The Ballad and Ocarina are not needed.
[Open] The Egg will start pre-opened.
[Open] The Egg will start pre-opened.
"""
display_name = "Goal"
ladxr_name = "goal"
@@ -278,21 +278,11 @@ class MusicChangeCondition(Choice):
# [Start with 1] normal game, you just start with 1 heart instead of 3.
# [Low max] replace heart containers with heart pieces."""),
class HardMode(Choice, LADXROption):
"""
[Oracle] Less iframes and health from drops. Bombs damage yourself. Water damages you without flippers. No piece of power or acorn.
[Hero] Switch version hero mode, double damage, no heart/fairy drops.
[One hit KO] You die on a single hit, always.
"""
display_name = "Hard Mode"
ladxr_name = "hardmode"
option_none = 0
option_oracle = 1
option_hero = 2
option_ohko = 3
default = option_none
# Setting('hardmode', 'Gameplay', 'X', 'Hard mode', options=[('none', '', 'Disabled'), ('oracle', 'O', 'Oracle'), ('hero', 'H', 'Hero'), ('ohko', '1', 'One hit KO')], default='none',
# description="""
# [Oracle] Less iframes and heath from drops. Bombs damage yourself. Water damages you without flippers. No piece of power or acorn.
# [Hero] Switch version hero mode, double damage, no heart/fairy drops.
# [One hit KO] You die on a single hit, always."""),
# Setting('steal', 'Gameplay', 't', 'Stealing from the shop',
# options=[('always', 'a', 'Always'), ('never', 'n', 'Never'), ('default', '', 'Normal')], default='default',
@@ -313,61 +303,49 @@ class Bowwow(Choice):
class Overworld(Choice, LADXROption):
"""
[Open Mabe] Replaces rock on the east side of Mabe Village with bushes, allowing access to Ukuku Prairie without Power Bracelet.
[Dungeon Dive] Create a different overworld where all the dungeons are directly accessible and almost no chests are located in the overworld.
[Tiny dungeons] All dungeons only consist of a boss fight and a instrument reward. Rest of the dungeon is removed.
"""
display_name = "Overworld"
ladxr_name = "overworld"
option_normal = 0
option_open_mabe = 1
option_dungeon_dive = 1
option_tiny_dungeons = 2
# option_shuffled = 3
default = option_normal
# Setting('superweapons', 'Special', 'q', 'Enable super weapons', default=False,
# description='All items will be more powerful, faster, harder, bigger stronger. You name it.'),
class Quickswap(Choice, LADXROption):
"""
Adds that the SELECT button swaps with either A or B. The item is swapped with the top inventory slot. The map is not available when quickswap is enabled.
"""
display_name = "Quickswap"
ladxr_name = "quickswap"
option_none = 0
option_a = 1
option_b = 2
default = option_none
class TextMode(Choice, LADXROption):
"""
[Fast] Makes text appear twice as fast
"""
display_name = "Text Mode"
ladxr_name = "textmode"
option_normal = 0
option_fast = 1
default = option_fast
class LowHpBeep(Choice, LADXROption):
"""
Slows or disables the low health beeping sound.
"""
display_name = "Low HP Beep"
ladxr_name = "lowhpbeep"
option_default = 0
option_slow = 1
option_none = 2
default = option_default
class NoFlash(DefaultOnToggle, LADXROption):
"""
Remove the flashing light effects from Mamu, shopkeeper and MadBatter. Useful for capture cards and people that are sensitive to these things.
"""
display_name = "No Flash"
ladxr_name = "noflash"
# Setting('quickswap', 'User options', 'Q', 'Quickswap', options=[('none', '', 'Disabled'), ('a', 'a', 'Swap A button'), ('b', 'b', 'Swap B button')], default='none',
# description='Adds that the select button swaps with either A or B. The item is swapped with the top inventory slot. The map is not available when quickswap is enabled.',
# aesthetic=True),
# Setting('textmode', 'User options', 'f', 'Text mode', options=[('fast', '', 'Fast'), ('default', 'd', 'Normal'), ('none', 'n', 'No-text')], default='fast',
# description="""[Fast] makes text appear twice as fast.
# [No-Text] removes all text from the game""", aesthetic=True),
# Setting('lowhpbeep', 'User options', 'p', 'Low HP beeps', options=[('none', 'D', 'Disabled'), ('slow', 'S', 'Slow'), ('default', 'N', 'Normal')], default='slow',
# description='Slows or disables the low health beeping sound', aesthetic=True),
# Setting('noflash', 'User options', 'l', 'Remove flashing lights', default=True,
# description='Remove the flashing light effects from Mamu, shopkeeper and MadBatter. Useful for capture cards and people that are sensitive for these things.',
# aesthetic=True),
# Setting('nagmessages', 'User options', 'S', 'Show nag messages', default=False,
# description='Enables the nag messages normally shown when touching stones and crystals',
# aesthetic=True),
# Setting('gfxmod', 'User options', 'c', 'Graphics', options=gfx_options, default='',
# description='Generally affects at least Link\'s sprite, but can alter any graphics in the game',
# aesthetic=True),
# Setting('linkspalette', 'User options', 'C', "Link's color",
# options=[('-1', '-', 'Normal'), ('0', '0', 'Green'), ('1', '1', 'Yellow'), ('2', '2', 'Red'), ('3', '3', 'Blue'),
# ('4', '4', '?? A'), ('5', '5', '?? B'), ('6', '6', '?? C'), ('7', '7', '?? D')], default='-1', aesthetic=True,
# description="""Allows you to force a certain color on link.
# [Normal] color of link depends on the tunic.
# [Green/Yellow/Red/Blue] forces link into one of these colors.
# [?? A/B/C/D] colors of link are usually inverted and color depends on the area you are in."""),
# Setting('music', 'User options', 'M', 'Music', options=[('', '', 'Default'), ('random', 'r', 'Random'), ('off', 'o', 'Disable')], default='',
# description="""
# [Random] Randomizes overworld and dungeon music'
# [Disable] no music in the whole game""",
# aesthetic=True),
class BootsControls(Choice):
"""
@@ -469,7 +447,7 @@ class GfxMod(FreeText, LADXROption):
class Palette(Choice):
"""
Sets the palette for the game.
Sets the palette for the game.
Note: A few places aren't patched, such as the menu and a few color dungeon tiles.
[Normal] The vanilla palette
[1-Bit] One bit of color per channel
@@ -527,18 +505,6 @@ class InGameHints(DefaultOnToggle):
display_name = "In-game Hints"
class ForeignItemIcons(Choice):
"""
Choose how to display foreign items.
[Guess By Name] Foreign items can look like any Link's Awakening item.
[Indicate Progression] Foreign items are either a Piece of Power (progression) or Guardian Acorn (non-progression).
"""
display_name = "Foreign Item Icons"
option_guess_by_name = 0
option_indicate_progression = 1
default = option_guess_by_name
ladx_option_groups = [
OptionGroup("Goal Options", [
Goal,
@@ -558,12 +524,9 @@ ladx_option_groups = [
OptionGroup("Miscellaneous", [
TradeQuest,
Rooster,
Overworld,
TrendyGame,
InGameHints,
NagMessages,
Quickswap,
HardMode,
BootsControls
]),
OptionGroup("Experimental", [
@@ -574,26 +537,22 @@ ladx_option_groups = [
LinkPalette,
Palette,
TextShuffle,
ForeignItemIcons,
APTitleScreen,
GfxMod,
Music,
MusicChangeCondition,
LowHpBeep,
TextMode,
NoFlash,
MusicChangeCondition
])
]
@dataclass
class LinksAwakeningOptions(PerGameCommonOptions):
logic: Logic
# 'heartpiece': DefaultOnToggle, # description='Includes heart pieces in the item pool'),
# 'seashells': DefaultOnToggle, # description='Randomizes the secret sea shells hiding in the ground/trees. (chest are always randomized)'),
# 'heartcontainers': DefaultOnToggle, # description='Includes boss heart container drops in the item pool'),
# 'instruments': DefaultOffToggle, # description='Instruments are placed on random locations, dungeon goal will just contain a random item.'),
# 'heartpiece': DefaultOnToggle, # description='Includes heart pieces in the item pool'),
# 'seashells': DefaultOnToggle, # description='Randomizes the secret sea shells hiding in the ground/trees. (chest are always randomized)'),
# 'heartcontainers': DefaultOnToggle, # description='Includes boss heart container drops in the item pool'),
# 'instruments': DefaultOffToggle, # description='Instruments are placed on random locations, dungeon goal will just contain a random item.'),
tradequest: TradeQuest # description='Trade quest items are randomized, each NPC takes its normal trade quest item, but gives a random item'),
# 'witch': DefaultOnToggle, # description='Adds both the toadstool and the reward for giving the toadstool to the witch to the item pool'),
# 'witch': DefaultOnToggle, # description='Adds both the toadstool and the reward for giving the toadstool to the witch to the item pool'),
rooster: Rooster # description='Adds the rooster to the item pool. Without this option, the rooster spot is still a check giving an item. But you will never find the rooster. Any rooster spot is accessible without rooster by other means.'),
# 'boomerang': Boomerang,
# 'randomstartlocation': DefaultOffToggle, # 'Randomize where your starting house is located'),
@@ -612,7 +571,6 @@ class LinksAwakeningOptions(PerGameCommonOptions):
gfxmod: GfxMod
palette: Palette
text_shuffle: TextShuffle
foreign_item_icons: ForeignItemIcons
shuffle_nightmare_keys: ShuffleNightmareKeys
shuffle_small_keys: ShuffleSmallKeys
shuffle_maps: ShuffleMaps
@@ -624,13 +582,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
nag_messages: NagMessages
ap_title_screen: APTitleScreen
boots_controls: BootsControls
quickswap: Quickswap
hard_mode: HardMode
low_hp_beep: LowHpBeep
text_mode: TextMode
no_flash: NoFlash
in_game_hints: InGameHints
overworld: Overworld
warp_improvements: Removed
additional_warp_points: Removed

View File

@@ -4,7 +4,6 @@ import os
import pkgutil
import tempfile
import typing
import re
import bsdiff4
@@ -13,7 +12,6 @@ from BaseClasses import Entrance, Item, ItemClassification, Location, Tutorial,
from Fill import fill_restrictive
from worlds.AutoWorld import WebWorld, World
from .Common import *
from . import ItemIconGuessing
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
ladxr_item_to_la_item_name, links_awakening_items, links_awakening_items_by_name,
links_awakening_item_name_groups)
@@ -382,36 +380,66 @@ class LinksAwakeningWorld(World):
name_cache = {}
# Tries to associate an icon from another game with an icon we have
def guess_icon_for_other_world(self, foreign_item):
def guess_icon_for_other_world(self, other):
if not self.name_cache:
forbidden = [
"TRADING",
"ITEM",
"BAD",
"SINGLE",
"UPGRADE",
"BLUE",
"RED",
"NOTHING",
"MESSAGE",
]
for item in ladxr_item_to_la_item_name.keys():
self.name_cache[item] = item
splits = item.split("_")
self.name_cache["".join(splits)] = item
if 'RUPEES' in splits:
self.name_cache["".join(reversed(splits))] = item
for word in item.split("_"):
if word not in ItemIconGuessing.BLOCKED_ASSOCIATIONS and not word.isnumeric():
if word not in forbidden and not word.isnumeric():
self.name_cache[word] = item
for name in ItemIconGuessing.SYNONYMS.values():
others = {
'KEY': 'KEY',
'COMPASS': 'COMPASS',
'BIGKEY': 'NIGHTMARE_KEY',
'MAP': 'MAP',
'FLUTE': 'OCARINA',
'SONG': 'OCARINA',
'MUSHROOM': 'TOADSTOOL',
'GLOVE': 'POWER_BRACELET',
'BOOT': 'PEGASUS_BOOTS',
'SHOE': 'PEGASUS_BOOTS',
'SHOES': 'PEGASUS_BOOTS',
'SANCTUARYHEARTCONTAINER': 'HEART_CONTAINER',
'BOSSHEARTCONTAINER': 'HEART_CONTAINER',
'HEARTCONTAINER': 'HEART_CONTAINER',
'ENERGYTANK': 'HEART_CONTAINER',
'MISSILE': 'SINGLE_ARROW',
'BOMBS': 'BOMB',
'BLUEBOOMERANG': 'BOOMERANG',
'MAGICMIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS',
'MIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS',
'MESSAGE': 'TRADING_ITEM_LETTER',
# TODO: Also use AP item name
}
for name in others.values():
assert name in self.name_cache, name
assert name in CHEST_ITEMS, name
self.name_cache.update(ItemIconGuessing.SYNONYMS)
pluralizations = {k + "S": v for k, v in self.name_cache.items()}
self.name_cache = pluralizations | self.name_cache
uppered = foreign_item.name.upper()
foreign_game = self.multiworld.game[foreign_item.player]
phrases = ItemIconGuessing.PHRASES.copy()
if foreign_game in ItemIconGuessing.GAME_SPECIFIC_PHRASES:
phrases.update(ItemIconGuessing.GAME_SPECIFIC_PHRASES[foreign_game])
for phrase, icon in phrases.items():
if phrase in uppered:
return icon
# pattern for breaking down camelCase, also separates out digits
pattern = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=\d)")
possibles = pattern.sub(' ', foreign_item.name).upper()
for ch in "[]()_":
possibles = possibles.replace(ch, " ")
possibles = possibles.split()
self.name_cache.update(others)
uppered = other.upper()
if "BIG KEY" in uppered:
return 'NIGHTMARE_KEY'
possibles = other.upper().split(" ")
rejoined = "".join(possibles)
if rejoined in self.name_cache:
return self.name_cache[rejoined]
for name in possibles:
if name in self.name_cache:
return self.name_cache[name]
@@ -437,15 +465,8 @@ class LinksAwakeningWorld(World):
# If the item name contains "sword", use a sword icon, etc
# Otherwise, use a cute letter as the icon
elif self.options.foreign_item_icons == 'guess_by_name':
loc.ladxr_item.item = self.guess_icon_for_other_world(loc.item)
loc.ladxr_item.custom_item_name = loc.item.name
else:
if loc.item.advancement:
loc.ladxr_item.item = 'PIECE_OF_POWER'
else:
loc.ladxr_item.item = 'GUARDIAN_ACORN'
loc.ladxr_item.item = self.guess_icon_for_other_world(loc.item.name)
loc.ladxr_item.custom_item_name = loc.item.name
if loc.item:
@@ -514,23 +535,10 @@ class LinksAwakeningWorld(World):
slot_options = ["instrument_count"]
slot_options_display_name = [
"goal",
"logic",
"tradequest",
"rooster",
"experimental_dungeon_shuffle",
"experimental_entrance_shuffle",
"trendy_game",
"gfxmod",
"shuffle_nightmare_keys",
"shuffle_small_keys",
"shuffle_maps",
"shuffle_compasses",
"shuffle_stone_beaks",
"shuffle_instruments",
"nag_messages",
"hard_mode",
"overworld",
"goal", "logic", "tradequest", "rooster",
"experimental_dungeon_shuffle", "experimental_entrance_shuffle", "trendy_game", "gfxmod",
"shuffle_nightmare_keys", "shuffle_small_keys", "shuffle_maps",
"shuffle_compasses", "shuffle_stone_beaks", "shuffle_instruments", "nag_messages"
]
# use the default behaviour to grab options

View File

@@ -1,28 +0,0 @@
BASE_ITEM_ID = 4000
BASE_LOCATION_ID = 4000
BASE_GROUND_LOCATION_ID = BASE_LOCATION_ID + 256
BASE_SHOP_LOCATION_ID = BASE_GROUND_LOCATION_ID + 30
BASE_REWARD_LOCATION_ID = BASE_SHOP_LOCATION_ID + 50
ENDGAME_REGIONS = [
"kazalt",
"king_nole_labyrinth_pre_door",
"king_nole_labyrinth_post_door",
"king_nole_labyrinth_exterior",
"king_nole_labyrinth_fall_from_exterior",
"king_nole_labyrinth_path_to_palace",
"king_nole_labyrinth_raft_entrance",
"king_nole_labyrinth_raft",
"king_nole_labyrinth_sacred_tree",
"king_nole_palace"
]
ENDGAME_PROGRESSION_ITEMS = [
"Gola's Nail",
"Gola's Fang",
"Gola's Horn",
"Logs",
"Snow Spikes"
]

View File

@@ -45,7 +45,7 @@ def generate_lithograph_hint(world: "LandstalkerWorld"):
words.append(item.name.split(" ")[0].upper())
if item.location.player != world.player:
# Add player name if it's not in our own world
player_name = world.multiworld.get_player_name(item.location.player)
player_name = world.multiworld.get_player_name(world.player)
words.append(player_name.upper())
world.random.shuffle(words)
hint_text += " ".join(words) + "\n"

View File

@@ -1,7 +1,8 @@
from typing import Dict, List, NamedTuple
from BaseClasses import Item, ItemClassification
from .Constants import BASE_ITEM_ID
BASE_ITEM_ID = 4000
class LandstalkerItem(Item):

View File

@@ -1,11 +1,15 @@
from typing import Dict, Optional
from BaseClasses import Location, ItemClassification, Item
from .Constants import *
from .Regions import LandstalkerRegion
from .data.item_source import ITEM_SOURCES_JSON
from .data.world_path import WORLD_PATHS_JSON
BASE_LOCATION_ID = 4000
BASE_GROUND_LOCATION_ID = BASE_LOCATION_ID + 256
BASE_SHOP_LOCATION_ID = BASE_GROUND_LOCATION_ID + 30
BASE_REWARD_LOCATION_ID = BASE_SHOP_LOCATION_ID + 50
class LandstalkerLocation(Location):
game: str = "Landstalker - The Treasures of King Nole"
@@ -17,14 +21,10 @@ class LandstalkerLocation(Location):
self.type_string = type_string
def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion],
name_to_id_table: Dict[str, int], reach_kazalt_goal: bool):
def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion], name_to_id_table: Dict[str, int]):
# Create real locations from the data inside the corresponding JSON file
for data in ITEM_SOURCES_JSON:
region_id = data["nodeId"]
# If "Reach Kazalt" goal is enabled and location is beyond Kazalt, don't create it
if reach_kazalt_goal and region_id in ENDGAME_REGIONS:
continue
region = regions_table[region_id]
new_location = LandstalkerLocation(player, data["name"], name_to_id_table[data["name"]], region, data["type"])
region.locations.append(new_location)
@@ -32,10 +32,6 @@ def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion],
# Create fake event locations that will be used to determine if some key regions has been visited
regions_with_entrance_checks = []
for data in WORLD_PATHS_JSON:
# If "Reach Kazalt" goal is enabled and region is beyond Kazalt, don't create any event for it since it would
# be useless anyway
if reach_kazalt_goal and data["fromId"] in ENDGAME_REGIONS:
continue
if "requiredNodes" in data:
regions_with_entrance_checks.extend([region_id for region_id in data["requiredNodes"]])
regions_with_entrance_checks = sorted(set(regions_with_entrance_checks))

View File

@@ -37,8 +37,7 @@ def add_path_requirements(world: "LandstalkerWorld"):
name = data["fromId"] + " -> " + data["toId"]
# Determine required items to reach this region
# WORLD_PATHS_JSON is shared by all Landstalker worlds, so a copy is made to prevent modifying the original
required_items = data["requiredItems"].copy() if "requiredItems" in data else []
required_items = data["requiredItems"] if "requiredItems" in data else []
if "itemsPlacedWhenCrossing" in data:
required_items += data["itemsPlacedWhenCrossing"]

View File

@@ -2,7 +2,6 @@ from typing import ClassVar, Set
from BaseClasses import LocationProgressType, Tutorial
from worlds.AutoWorld import WebWorld, World
from .Constants import *
from .Hints import *
from .Items import *
from .Locations import *
@@ -88,8 +87,7 @@ class LandstalkerWorld(World):
def create_regions(self):
self.regions_table = Regions.create_regions(self)
Locations.create_locations(self.player, self.regions_table, self.location_name_to_id,
self.options.goal == "reach_kazalt")
Locations.create_locations(self.player, self.regions_table, self.location_name_to_id)
self.create_teleportation_trees()
def create_item(self, name: str, classification_override: Optional[ItemClassification] = None) -> LandstalkerItem:
@@ -111,16 +109,7 @@ class LandstalkerWorld(World):
# If item is an armor and progressive armors are enabled, transform it into a progressive armor item
if self.options.progressive_armors and "Breast" in name:
name = "Progressive Armor"
qty = data.quantity
if self.options.goal == "reach_kazalt":
# In "Reach Kazalt" goal, remove all endgame progression items that would be useless anyway
if name in ENDGAME_PROGRESSION_ITEMS:
continue
# Also reduce quantities for most filler items to let space for more EkeEke (see end of function)
if data.classification == ItemClassification.filler:
qty = int(qty * 0.8)
item_pool += [self.create_item(name) for _ in range(qty)]
item_pool += [self.create_item(name) for _ in range(data.quantity)]
# If the appropriate setting is on, place one EkeEke in one shop in every town in the game
if self.options.ensure_ekeeke_in_shops:
@@ -131,10 +120,9 @@ class LandstalkerWorld(World):
"Mercator: Shop item #1",
"Verla: Shop item #1",
"Destel: Inn item",
"Route to Lake Shrine: Greedly's shop item #1"
"Route to Lake Shrine: Greedly's shop item #1",
"Kazalt: Shop item #1"
]
if self.options.goal != "reach_kazalt":
shops_to_fill.append("Kazalt: Shop item #1")
for location_name in shops_to_fill:
self.multiworld.get_location(location_name, self.player).place_locked_item(self.create_item("EkeEke"))

View File

@@ -73,22 +73,6 @@ WORLD_NODES_JSON = {
"between Gumi and Ryuma"
]
},
"tibor_tree": {
"name": "Route from Gumi to Ryuma (Tibor tree)",
"hints": [
"on a route",
"in a region inhabited by bears",
"between Gumi and Ryuma"
]
},
"mercator_gate_tree": {
"name": "Route from Gumi to Ryuma (Mercator gate tree)",
"hints": [
"on a route",
"in a region inhabited by bears",
"between Gumi and Ryuma"
]
},
"tibor": {
"name": "Tibor",
"hints": [
@@ -239,13 +223,6 @@ WORLD_NODES_JSON = {
"in the infamous Greenmaze"
]
},
"greenmaze_post_whistle_tree": {
"name": "Greenmaze (post-whistle tree)",
"hints": [
"among the trees",
"in the infamous Greenmaze"
]
},
"verla_shore": {
"name": "Verla shore",
"hints": [
@@ -253,13 +230,6 @@ WORLD_NODES_JSON = {
"near the town of Verla"
]
},
"verla_shore_tree": {
"name": "Verla shore tree",
"hints": [
"on a route",
"near the town of Verla"
]
},
"verla_shore_cliff": {
"name": "Verla shore cliff (accessible from Verla Mines)",
"hints": [
@@ -356,12 +326,6 @@ WORLD_NODES_JSON = {
"in a mountainous area"
]
},
"mountainous_area_tree": {
"name": "Mountainous Area tree",
"hints": [
"in a mountainous area"
]
},
"king_nole_cave": {
"name": "King Nole's Cave",
"hints": [

View File

@@ -54,16 +54,6 @@ WORLD_PATHS_JSON = [
"toId": "ryuma",
"twoWay": True
},
{
"fromId": "route_gumi_ryuma",
"toId": "tibor_tree",
"twoWay": True
},
{
"fromId": "route_gumi_ryuma",
"toId": "mercator_gate_tree",
"twoWay": True
},
{
"fromId": "ryuma",
"toId": "ryuma_after_thieves_hideout",
@@ -221,11 +211,6 @@ WORLD_PATHS_JSON = [
],
"twoWay": True
},
{
"fromId": "greenmaze_post_whistle",
"toId": "greenmaze_post_whistle_tree",
"twoWay": True
},
{
"fromId": "greenmaze_post_whistle",
"toId": "route_massan_gumi"
@@ -268,11 +253,6 @@ WORLD_PATHS_JSON = [
"fromId": "verla_shore_cliff",
"toId": "verla_shore"
},
{
"fromId": "verla_shore",
"toId": "verla_shore_tree",
"twoWay": True
},
{
"fromId": "verla_shore",
"toId": "mir_tower_sector",
@@ -336,11 +316,6 @@ WORLD_PATHS_JSON = [
"Axe Magic"
]
},
{
"fromId": "mountainous_area",
"toId": "mountainous_area_tree",
"twoWay": True
},
{
"fromId": "mountainous_area",
"toId": "route_lake_shrine_cliff",

View File

@@ -57,9 +57,7 @@ WORLD_REGIONS_JSON = [
"name": "Route between Gumi and Ryuma",
"canBeHintedAsRequired": False,
"nodeIds": [
"route_gumi_ryuma",
"tibor_tree",
"mercator_gate_tree"
"route_gumi_ryuma"
]
},
{
@@ -159,8 +157,7 @@ WORLD_REGIONS_JSON = [
"hintName": "in Greenmaze",
"nodeIds": [
"greenmaze_pre_whistle",
"greenmaze_post_whistle",
"greenmaze_post_whistle_tree"
"greenmaze_post_whistle"
]
},
{
@@ -168,8 +165,7 @@ WORLD_REGIONS_JSON = [
"canBeHintedAsRequired": False,
"nodeIds": [
"verla_shore",
"verla_shore_cliff",
"verla_shore_tree"
"verla_shore_cliff"
]
},
{
@@ -248,8 +244,7 @@ WORLD_REGIONS_JSON = [
"name": "Mountainous Area",
"hintName": "in the mountainous area",
"nodeIds": [
"mountainous_area",
"mountainous_area_tree"
"mountainous_area"
]
},
{

View File

@@ -8,19 +8,19 @@ WORLD_TELEPORT_TREES_JSON = [
{
"name": "Tibor tree",
"treeMapId": 534,
"nodeId": "tibor_tree"
"nodeId": "route_gumi_ryuma"
}
],
[
{
"name": "Mercator front gate tree",
"treeMapId": 539,
"nodeId": "mercator_gate_tree"
"nodeId": "route_gumi_ryuma"
},
{
"name": "Verla shore tree",
"treeMapId": 537,
"nodeId": "verla_shore_tree"
"nodeId": "verla_shore"
}
],
[
@@ -44,7 +44,7 @@ WORLD_TELEPORT_TREES_JSON = [
{
"name": "Mountainous area tree",
"treeMapId": 535,
"nodeId": "mountainous_area_tree"
"nodeId": "mountainous_area"
}
],
[
@@ -56,7 +56,7 @@ WORLD_TELEPORT_TREES_JSON = [
{
"name": "Greenmaze end tree",
"treeMapId": 511,
"nodeId": "greenmaze_post_whistle_tree"
"nodeId": "greenmaze_post_whistle"
}
]
]

View File

@@ -50,8 +50,8 @@ class TestVerifyItemName(L2ACTestBase):
def test_verify_item_name(self) -> None:
self.assertRaisesRegex(Exception,
"Item 'The car blade' from option 'CustomItemPool\\(The car blade: 2\\)' is not a "
"valid item name from 'Lufia II Ancient Cave'\\. Did you mean 'Dekar blade'",
"Item The car blade from option CustomItemPool\\(The car blade: 2\\) is not a "
"valid item name from Lufia II Ancient Cave\\. Did you mean 'Dekar blade'",
lambda: handle_option(Namespace(game="Lufia II Ancient Cave", name="Player"),
self.options, "custom_item_pool", CustomItemPool,
PlandoOptions(0)))

View File

@@ -53,7 +53,6 @@ def create_random_items(world: NoitaWorld, weights: Dict[str, int], count: int)
filler_pool = weights.copy()
if not world.options.bad_effects:
del filler_pool["Trap"]
del filler_pool["Greed Die"]
return world.random.choices(population=list(filler_pool.keys()),
weights=list(filler_pool.values()),
@@ -115,7 +114,7 @@ item_table: Dict[str, ItemData] = {
"Secret Potion": ItemData(110024, "Items", ItemClassification.filler),
"Powder Pouch": ItemData(110025, "Items", ItemClassification.filler),
"Chaos Die": ItemData(110026, "Items", ItemClassification.filler),
"Greed Die": ItemData(110027, "Items", ItemClassification.trap),
"Greed Die": ItemData(110027, "Items", ItemClassification.filler),
"Kammi": ItemData(110028, "Items", ItemClassification.filler, 1),
"Refreshing Gourd": ItemData(110029, "Items", ItemClassification.filler, 1),
"Sädekivi": ItemData(110030, "Items", ItemClassification.filler),

View File

@@ -397,13 +397,13 @@ def _init() -> None:
label = []
for word in map_name[4:].split("_"):
# 1F, B1F, 2R, etc.
re_match = re.match(r"^B?\d+[FRP]$", word)
re_match = re.match("^B?\d+[FRP]$", word)
if re_match:
label.append(word)
continue
# Route 103, Hall 1, House 5, etc.
re_match = re.match(r"^([A-Z]+)(\d+)$", word)
re_match = re.match("^([A-Z]+)(\d+)$", word)
if re_match:
label.append(re_match.group(1).capitalize())
label.append(re_match.group(2).lstrip("0"))
@@ -1459,6 +1459,9 @@ def _init() -> None:
for warp, destination in extracted_data["warps"].items():
data.warp_map[warp] = None if destination == "" else destination
if encoded_warp not in data.warp_map:
data.warp_map[encoded_warp] = None
# Create trainer data
for i, trainer_json in enumerate(extracted_data["trainers"]):
party_json = trainer_json["party"]

View File

@@ -416,16 +416,13 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
)
# Dewford Town
entrance = get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH")
set_rule(
entrance,
get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH"),
lambda state:
state.can_reach("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN", "Entrance", world.player)
and state.has("EVENT_TALK_TO_MR_STONE", world.player)
and state.has("EVENT_DELIVER_LETTER", world.player)
)
world.multiworld.register_indirect_condition(
get_entrance("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN").parent_region, entrance)
set_rule(
get_entrance("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN"),
lambda state:
@@ -454,17 +451,14 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
)
# Route 109
entrance = get_entrance("REGION_ROUTE109/BEACH -> REGION_DEWFORD_TOWN/MAIN")
set_rule(
entrance,
get_entrance("REGION_ROUTE109/BEACH -> REGION_DEWFORD_TOWN/MAIN"),
lambda state:
state.can_reach("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN", "Entrance", world.player)
and state.can_reach("REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH", "Entrance", world.player)
and state.has("EVENT_TALK_TO_MR_STONE", world.player)
and state.has("EVENT_DELIVER_LETTER", world.player)
)
world.multiworld.register_indirect_condition(
get_entrance("REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN").parent_region, entrance)
set_rule(
get_entrance("REGION_ROUTE109/BEACH -> REGION_ROUTE109/SEA"),
hm_rules["HM03 Surf"]

View File

@@ -140,8 +140,7 @@ class GanonsTower(Z3Region):
# added for AP completion_condition when TowerCrystals is lower than GanonCrystals
def CanComplete(self, items: Progression):
return self.world.CanAcquireAtLeast(self.world.GanonCrystals, items, RewardType.AnyCrystal) and \
self.world.CanAcquireAtLeast(self.world.TourianBossTokens, items, RewardType.AnyBossToken)
return self.world.CanAcquireAtLeast(self.world.GanonCrystals, items, RewardType.AnyCrystal)
def CanFill(self, item: Item):
if (self.Config.Multiworld):

View File

@@ -230,7 +230,7 @@ class SMZ3World(World):
self.multiworld.itempool += itemPool
def set_rules(self):
# SM G4 is logically required to complete Ganon's Tower
# SM G4 is logically required to access Ganon's Tower in SMZ3
self.multiworld.completion_condition[self.player] = lambda state: \
self.smz3World.GetRegion("Ganon's Tower").CanEnter(state.smz3state[self.player]) and \
self.smz3World.GetRegion("Ganon's Tower").TowerAscend(state.smz3state[self.player]) and \

View File

@@ -1,8 +1,7 @@
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union
from logging import warning
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
from .items import (item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names,
combat_items)
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld
from .items import item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names
from .locations import location_table, location_name_groups, location_name_to_id, hexagon_locations
from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon
from .er_rules import set_er_location_rules
@@ -11,7 +10,6 @@ from .er_scripts import create_er_regions
from .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections,
LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage)
from .combat_logic import area_data, CombatState
from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection
from decimal import Decimal, ROUND_HALF_UP
@@ -129,21 +127,11 @@ class TunicWorld(World):
self.options.shuffle_ladders.value = passthrough["shuffle_ladders"]
self.options.fixed_shop.value = self.options.fixed_shop.option_false
self.options.laurels_location.value = self.options.laurels_location.option_anywhere
self.options.combat_logic.value = passthrough["combat_logic"]
@classmethod
def stage_generate_early(cls, multiworld: MultiWorld) -> None:
tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC")
for tunic in tunic_worlds:
# setting up state combat logic stuff, see has_combat_reqs for its use
# and this is magic so pycharm doesn't like it, unfortunately
if tunic.options.combat_logic:
multiworld.state.tunic_need_to_reset_combat_from_collect[tunic.player] = False
multiworld.state.tunic_need_to_reset_combat_from_remove[tunic.player] = False
multiworld.state.tunic_area_combat_state[tunic.player] = {}
for area_name in area_data.keys():
multiworld.state.tunic_area_combat_state[tunic.player][area_name] = CombatState.unchecked
# if it's one of the options, then it isn't a custom seed group
if tunic.options.entrance_rando.value in EntranceRando.options.values():
continue
@@ -202,12 +190,10 @@ class TunicWorld(World):
def create_item(self, name: str, classification: ItemClassification = None) -> TunicItem:
item_data = item_table[name]
# if item_data.combat_ic is None, it'll take item_data.classification instead
itemclass: ItemClassification = ((item_data.combat_ic if self.options.combat_logic else None)
or item_data.classification)
return TunicItem(name, classification or itemclass, self.item_name_to_id[name], self.player)
return TunicItem(name, classification or item_data.classification, self.item_name_to_id[name], self.player)
def create_items(self) -> None:
tunic_items: List[TunicItem] = []
self.slot_data_items = []
@@ -336,15 +322,15 @@ class TunicWorld(World):
self.ability_unlocks["Pages 42-43 (Holy Cross)"] = passthrough["Hexagon Quest Holy Cross"]
self.ability_unlocks["Pages 52-53 (Icebolt)"] = passthrough["Hexagon Quest Icebolt"]
# Ladders and Combat Logic uses ER rules with vanilla connections for easier maintenance
if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic:
# ladder rando uses ER with vanilla connections, so that we're not managing more rules files
if self.options.entrance_rando or self.options.shuffle_ladders:
portal_pairs = create_er_regions(self)
if self.options.entrance_rando:
# these get interpreted by the game to tell it which entrances to connect
for portal1, portal2 in portal_pairs.items():
self.tunic_portal_pairs[portal1.scene_destination()] = portal2.scene_destination()
else:
# uses the original rules, easier to navigate and reference
# for non-ER, non-ladders
for region_name in tunic_regions:
region = Region(region_name, self.player, self.multiworld)
self.multiworld.regions.append(region)
@@ -365,8 +351,7 @@ class TunicWorld(World):
victory_region.locations.append(victory_location)
def set_rules(self) -> None:
# same reason as in create_regions, could probably be put into create_regions
if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic:
if self.options.entrance_rando or self.options.shuffle_ladders:
set_er_location_rules(self)
else:
set_region_rules(self)
@@ -375,19 +360,6 @@ class TunicWorld(World):
def get_filler_item_name(self) -> str:
return self.random.choice(filler_items)
# cache whether you can get through combat logic areas
def collect(self, state: CollectionState, item: Item) -> bool:
change = super().collect(state, item)
if change and self.options.combat_logic and item.name in combat_items:
state.tunic_need_to_reset_combat_from_collect[self.player] = True
return change
def remove(self, state: CollectionState, item: Item) -> bool:
change = super().remove(state, item)
if change and self.options.combat_logic and item.name in combat_items:
state.tunic_need_to_reset_combat_from_remove[self.player] = True
return change
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None:
if self.options.entrance_rando:
hint_data.update({self.player: {}})
@@ -454,7 +426,6 @@ class TunicWorld(World):
"maskless": self.options.maskless.value,
"entrance_rando": int(bool(self.options.entrance_rando.value)),
"shuffle_ladders": self.options.shuffle_ladders.value,
"combat_logic": self.options.combat_logic.value,
"Hexagon Quest Prayer": self.ability_unlocks["Pages 24-25 (Prayer)"],
"Hexagon Quest Holy Cross": self.ability_unlocks["Pages 42-43 (Holy Cross)"],
"Hexagon Quest Icebolt": self.ability_unlocks["Pages 52-53 (Icebolt)"],

View File

@@ -1,422 +0,0 @@
from typing import Dict, List, NamedTuple, Tuple, Optional
from enum import IntEnum
from collections import defaultdict
from BaseClasses import CollectionState
from .rules import has_sword, has_melee
from worlds.AutoWorld import LogicMixin
# the vanilla stats you are expected to have to get through an area, based on where they are in vanilla
class AreaStats(NamedTuple):
att_level: int
def_level: int
potion_level: int # all 3 are before your first bonfire after getting the upgrade page, third costs 1k
hp_level: int
sp_level: int
mp_level: int
potion_count: int
equipment: List[str] = []
is_boss: bool = False
# the vanilla upgrades/equipment you would have
area_data: Dict[str, AreaStats] = {
"Overworld": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Stick"]),
"East Forest": AreaStats(1, 1, 1, 1, 1, 1, 0, ["Sword"]),
"Before Well": AreaStats(1, 1, 1, 1, 1, 1, 3, ["Sword", "Shield"]),
# learn how to upgrade
"Beneath the Well": AreaStats(2, 1, 3, 3, 1, 1, 3, ["Sword", "Shield"]),
"Dark Tomb": AreaStats(2, 2, 3, 3, 1, 1, 3, ["Sword", "Shield"]),
"West Garden": AreaStats(2, 3, 3, 3, 1, 1, 4, ["Sword", "Shield"]),
"Garden Knight": AreaStats(3, 3, 3, 3, 2, 1, 4, ["Sword", "Shield"], is_boss=True),
# get the wand here
"Beneath the Vault": AreaStats(3, 3, 3, 3, 2, 1, 4, ["Sword", "Shield", "Magic"]),
"Eastern Vault Fortress": AreaStats(3, 3, 3, 4, 3, 2, 4, ["Sword", "Shield", "Magic"]),
"Siege Engine": AreaStats(3, 3, 3, 4, 3, 2, 4, ["Sword", "Shield", "Magic"], is_boss=True),
"Frog's Domain": AreaStats(3, 4, 3, 5, 3, 3, 4, ["Sword", "Shield", "Magic"]),
# the second half of Atoll is the part you need the stats for, so putting it after frogs
"Ruined Atoll": AreaStats(4, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"]),
"The Librarian": AreaStats(4, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"], is_boss=True),
"Quarry": AreaStats(5, 4, 3, 5, 3, 3, 5, ["Sword", "Shield", "Magic"]),
"Rooted Ziggurat": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic"]),
"Boss Scavenger": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic"], is_boss=True),
"Swamp": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"]),
"Cathedral": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"]),
# marked as boss because the garden knights can't get hurt by stick
"Gauntlet": AreaStats(1, 1, 1, 1, 1, 1, 6, ["Sword", "Shield", "Magic"], is_boss=True),
"The Heir": AreaStats(5, 5, 3, 5, 3, 3, 6, ["Sword", "Shield", "Magic", "Laurels"], is_boss=True),
}
# these are used for caching which areas can currently be reached in state
boss_areas: List[str] = [name for name, data in area_data.items() if data.is_boss and name != "Gauntlet"]
non_boss_areas: List[str] = [name for name, data in area_data.items() if not data.is_boss]
class CombatState(IntEnum):
unchecked = 0
failed = 1
succeeded = 2
def has_combat_reqs(area_name: str, state: CollectionState, player: int) -> bool:
# we're caching whether you've met the combat reqs before if the state didn't change first
# if the combat state is stale, mark each area's combat state as stale
if state.tunic_need_to_reset_combat_from_collect[player]:
state.tunic_need_to_reset_combat_from_collect[player] = False
for name in area_data.keys():
if state.tunic_area_combat_state[player][name] == CombatState.failed:
state.tunic_area_combat_state[player][name] = CombatState.unchecked
if state.tunic_need_to_reset_combat_from_remove[player]:
state.tunic_need_to_reset_combat_from_remove[player] = False
for name in area_data.keys():
if state.tunic_area_combat_state[player][name] == CombatState.succeeded:
state.tunic_area_combat_state[player][name] = CombatState.unchecked
if state.tunic_area_combat_state[player][area_name] > CombatState.unchecked:
return state.tunic_area_combat_state[player][area_name] == CombatState.succeeded
met_combat_reqs = check_combat_reqs(area_name, state, player)
# we want to skip the "none area" since we don't record its results
if area_name not in area_data.keys():
return met_combat_reqs
# loop through the lists and set the easier/harder area states accordingly
if area_name in boss_areas:
area_list = boss_areas
elif area_name in non_boss_areas:
area_list = non_boss_areas
else:
area_list = [area_name]
if met_combat_reqs:
# set the state as true for each area until you get to the area we're looking at
for name in area_list:
state.tunic_area_combat_state[player][name] = CombatState.succeeded
if name == area_name:
break
else:
# set the state as false for the area we're looking at and each area after that
reached_name = False
for name in area_list:
if name == area_name:
reached_name = True
if reached_name:
state.tunic_area_combat_state[player][name] = CombatState.failed
return met_combat_reqs
def check_combat_reqs(area_name: str, state: CollectionState, player: int, alt_data: Optional[AreaStats] = None) -> bool:
data = alt_data or area_data[area_name]
extra_att_needed = 0
extra_def_needed = 0
extra_mp_needed = 0
has_magic = state.has_any({"Magic Wand", "Gun"}, player)
stick_bool = False
sword_bool = False
for item in data.equipment:
if item == "Stick":
if not has_melee(state, player):
if has_magic:
# magic can make up for the lack of stick
extra_mp_needed += 2
extra_att_needed -= 16
else:
return False
else:
stick_bool = True
elif item == "Sword":
if not has_sword(state, player):
# need sword for bosses
if data.is_boss:
return False
if has_magic:
# +4 mp pretty much makes up for the lack of sword, at least in Quarry
extra_mp_needed += 4
# stick is a backup plan, and doesn't scale well, so let's require a little less
extra_att_needed -= 2
elif has_melee(state, player):
# may revise this later based on feedback
extra_att_needed += 3
extra_def_needed += 2
else:
return False
else:
sword_bool = True
elif item == "Shield":
if not state.has("Shield", player):
extra_def_needed += 2
elif item == "Laurels":
if not state.has("Hero's Laurels", player):
# these are entirely based on vibes
extra_att_needed += 2
extra_def_needed += 3
elif item == "Magic":
if not has_magic:
extra_att_needed += 2
extra_def_needed += 2
extra_mp_needed -= 16
modified_stats = AreaStats(data.att_level + extra_att_needed, data.def_level + extra_def_needed, data.potion_level,
data.hp_level, data.sp_level, data.mp_level + extra_mp_needed, data.potion_count)
if not has_required_stats(modified_stats, state, player):
# we may need to check if you would have the required stats if you were missing a weapon
# it's kinda janky, but these only get hit in less than once per 100 generations, so whatever
if sword_bool and "Sword" in data.equipment and "Magic" in data.equipment:
# we need to check if you would have the required stats if you didn't have melee
equip_list = [item for item in data.equipment if item != "Sword"]
more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level,
data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count,
equip_list)
if check_combat_reqs("none", state, player, more_modified_stats):
return True
# and we need to check if you would have the required stats if you didn't have magic
equip_list = [item for item in data.equipment if item != "Magic"]
more_modified_stats = AreaStats(data.att_level + 2, data.def_level + 2, data.potion_level,
data.hp_level, data.sp_level, data.mp_level - 16, data.potion_count,
equip_list)
if check_combat_reqs("none", state, player, more_modified_stats):
return True
return False
elif stick_bool and "Stick" in data.equipment and "Magic" in data.equipment:
# we need to check if you would have the required stats if you didn't have the stick
equip_list = [item for item in data.equipment if item != "Stick"]
more_modified_stats = AreaStats(data.att_level - 16, data.def_level, data.potion_level,
data.hp_level, data.sp_level, data.mp_level + 4, data.potion_count,
equip_list)
if check_combat_reqs("none", state, player, more_modified_stats):
return True
return False
else:
return False
return True
# check if you have the required stats, and the money to afford them
# it may be innaccurate due to poor spending, and it may even require you to "spend poorly"
# but that's fine -- it's already pretty generous to begin with
def has_required_stats(data: AreaStats, state: CollectionState, player: int) -> bool:
money_required = 0
player_att = 0
# check if we actually need the stat before checking state
if data.att_level > 1:
player_att, att_offerings = get_att_level(state, player)
if player_att < data.att_level:
return False
else:
extra_att = player_att - data.att_level
paid_att = max(0, att_offerings - extra_att)
# attack upgrades cost 100 for the first, +50 for each additional
money_per_att = 100
for _ in range(paid_att):
money_required += money_per_att
money_per_att += 50
# adding defense and sp together since they accomplish similar things: making you take less damage
if data.def_level + data.sp_level > 2:
player_def, def_offerings = get_def_level(state, player)
player_sp, sp_offerings = get_sp_level(state, player)
if player_def + player_sp < data.def_level + data.sp_level:
return False
else:
free_def = player_def - def_offerings
free_sp = player_sp - sp_offerings
paid_stats = data.def_level + data.sp_level - free_def - free_sp
sp_to_buy = 0
if paid_stats <= 0:
# if you don't have to pay for any stats, you don't need money for these upgrades
def_to_buy = 0
elif paid_stats <= def_offerings:
# get the amount needed to buy these def offerings
def_to_buy = paid_stats
else:
def_to_buy = def_offerings
sp_to_buy = max(0, paid_stats - def_offerings)
# if you have to buy more than 3 def, it's cheaper to buy 1 extra sp
if def_to_buy > 3 and sp_offerings > 0:
def_to_buy -= 1
sp_to_buy += 1
# def costs 100 for the first, +50 for each additional
money_per_def = 100
for _ in range(def_to_buy):
money_required += money_per_def
money_per_def += 50
# sp costs 200 for the first, +200 for each additional
money_per_sp = 200
for _ in range(sp_to_buy):
money_required += money_per_sp
money_per_sp += 200
# if you have 2 more attack than needed, we can forego needing mp
if data.mp_level > 1 and player_att < data.att_level + 2:
player_mp, mp_offerings = get_mp_level(state, player)
if player_mp < data.mp_level:
return False
else:
extra_mp = player_mp - data.mp_level
paid_mp = max(0, mp_offerings - extra_mp)
# mp costs 300 for the first, +50 for each additional
money_per_mp = 300
for _ in range(paid_mp):
money_required += money_per_mp
money_per_mp += 50
req_effective_hp = calc_effective_hp(data.hp_level, data.potion_level, data.potion_count)
player_potion, potion_offerings = get_potion_level(state, player)
player_hp, hp_offerings = get_hp_level(state, player)
player_potion_count = get_potion_count(state, player)
player_effective_hp = calc_effective_hp(player_hp, player_potion, player_potion_count)
if player_effective_hp < req_effective_hp:
return False
else:
# need a way to determine which of potion offerings or hp offerings you can reduce
# your level if you didn't pay for offerings
free_potion = player_potion - potion_offerings
free_hp = player_hp - hp_offerings
paid_hp_count = 0
paid_potion_count = 0
if calc_effective_hp(free_hp, free_potion, player_potion_count) >= req_effective_hp:
# you don't need to buy upgrades
pass
# if you have no potions, or no potion upgrades, you only need to check your hp upgrades
elif player_potion_count == 0 or potion_offerings == 0:
# check if you have enough hp at each paid hp offering
for i in range(hp_offerings):
paid_hp_count = i + 1
if calc_effective_hp(paid_hp_count, 0, player_potion_count) > req_effective_hp:
break
else:
for i in range(potion_offerings):
paid_potion_count = i + 1
if calc_effective_hp(free_hp, free_potion + paid_potion_count, player_potion_count) > req_effective_hp:
break
for j in range(hp_offerings):
paid_hp_count = j + 1
if (calc_effective_hp(free_hp + paid_hp_count, free_potion + paid_potion_count, player_potion_count)
> req_effective_hp):
break
# hp costs 200 for the first, +50 for each additional
money_per_hp = 200
for _ in range(paid_hp_count):
money_required += money_per_hp
money_per_hp += 50
# potion costs 100 for the first, 300 for the second, 1,000 for the third, and +200 for each additional
# currently we assume you will not buy past the second potion upgrade, but we might change our minds later
money_per_potion = 100
for _ in range(paid_potion_count):
money_required += money_per_potion
if money_per_potion == 100:
money_per_potion = 300
elif money_per_potion == 300:
money_per_potion = 1000
else:
money_per_potion += 200
if money_required > get_money_count(state, player):
return False
return True
# returns a tuple of your max attack level, the number of attack offerings
def get_att_level(state: CollectionState, player: int) -> Tuple[int, int]:
att_offerings = state.count("ATT Offering", player)
att_upgrades = state.count("Hero Relic - ATT", player)
sword_level = state.count("Sword Upgrade", player)
if sword_level >= 3:
att_upgrades += min(2, sword_level - 2)
# attack falls off, can just cap it at 8 for simplicity
return min(8, 1 + att_offerings + att_upgrades), att_offerings
# returns a tuple of your max defense level, the number of defense offerings
def get_def_level(state: CollectionState, player: int) -> Tuple[int, int]:
def_offerings = state.count("DEF Offering", player)
# defense falls off, can just cap it at 8 for simplicity
return (min(8, 1 + def_offerings
+ state.count_from_list({"Hero Relic - DEF", "Secret Legend", "Phonomath"}, player)),
def_offerings)
# returns a tuple of your max potion level, the number of potion offerings
def get_potion_level(state: CollectionState, player: int) -> Tuple[int, int]:
potion_offerings = min(2, state.count("Potion Offering", player))
# your third potion upgrade (from offerings) costs 1,000 money, reasonable to assume you won't do that
return (1 + potion_offerings
+ state.count_from_list({"Hero Relic - POTION", "Just Some Pals", "Spring Falls", "Back To Work"}, player),
potion_offerings)
# returns a tuple of your max hp level, the number of hp offerings
def get_hp_level(state: CollectionState, player: int) -> Tuple[int, int]:
hp_offerings = state.count("HP Offering", player)
return 1 + hp_offerings + state.count("Hero Relic - HP", player), hp_offerings
# returns a tuple of your max sp level, the number of sp offerings
def get_sp_level(state: CollectionState, player: int) -> Tuple[int, int]:
sp_offerings = state.count("SP Offering", player)
return (1 + sp_offerings
+ state.count_from_list({"Hero Relic - SP", "Mr Mayor", "Power Up",
"Regal Weasel", "Forever Friend"}, player),
sp_offerings)
def get_mp_level(state: CollectionState, player: int) -> Tuple[int, int]:
mp_offerings = state.count("MP Offering", player)
return (1 + mp_offerings
+ state.count_from_list({"Hero Relic - MP", "Sacred Geometry", "Vintage", "Dusty"}, player),
mp_offerings)
def get_potion_count(state: CollectionState, player: int) -> int:
return state.count("Potion Flask", player) + state.count("Flask Shard", player) // 3
def calc_effective_hp(hp_level: int, potion_level: int, potion_count: int) -> int:
player_hp = 60 + hp_level * 20
# since you don't tend to use potions efficiently all the time, scale healing by .75
total_healing = int(.75 * potion_count * min(player_hp, 20 + 10 * potion_level))
return player_hp + total_healing
# returns the total amount of progression money the player has
def get_money_count(state: CollectionState, player: int) -> int:
money: int = 0
# this could be done with something to parse the money count at the end of the string, but I don't wanna
money += state.count("Money x255", player) * 255 # 1 in pool
money += state.count("Money x200", player) * 200 # 1 in pool
money += state.count("Money x128", player) * 128 # 3 in pool
# total from regular money: 839
# first effigy is 8, doubles until it reaches 512 at number 7, after effigy 28 they stop dropping money
# with the vanilla count of 12, you get 3,576 money from effigies
effigy_count = min(28, state.count("Effigy", player)) # 12 in pool
money_per_break = 8
for _ in range(effigy_count):
money += money_per_break
money_per_break = min(512, money_per_break * 2)
return money
class TunicState(LogicMixin):
tunic_need_to_reset_combat_from_collect: Dict[int, bool]
tunic_need_to_reset_combat_from_remove: Dict[int, bool]
tunic_area_combat_state: Dict[int, Dict[str, int]]
def init_mixin(self, _):
# the per-player need to reset the combat state when collecting a combat item
self.tunic_need_to_reset_combat_from_collect = defaultdict(lambda: False)
# the per-player need to reset the combat state when removing a combat item
self.tunic_need_to_reset_combat_from_remove = defaultdict(lambda: False)
# the per-player, per-area state of combat checking -- unchecked, failed, or succeeded
self.tunic_area_combat_state = defaultdict(lambda: defaultdict(lambda: CombatState.unchecked))

View File

@@ -175,7 +175,7 @@ portal_mapping: List[Portal] = [
Portal(name="Temple Door Exit", region="Sealed Temple",
destination="Overworld Redux", tag="_main"),
Portal(name="Forest Belltower to Fortress", region="Forest Belltower Main behind bushes",
Portal(name="Forest Belltower to Fortress", region="Forest Belltower Main",
destination="Fortress Courtyard", tag="_"),
Portal(name="Forest Belltower to Forest", region="Forest Belltower Lower",
destination="East Forest Redux", tag="_"),
@@ -221,7 +221,7 @@ portal_mapping: List[Portal] = [
Portal(name="Guard House 2 Lower Exit", region="Guard House 2 Lower",
destination="East Forest Redux", tag="_lower"),
Portal(name="Guard House 2 Upper Exit", region="Guard House 2 Upper before bushes",
Portal(name="Guard House 2 Upper Exit", region="Guard House 2 Upper",
destination="East Forest Redux", tag="_upper"),
Portal(name="Guard Captain Room Non-Gate Exit", region="Forest Boss Room",
@@ -235,12 +235,12 @@ portal_mapping: List[Portal] = [
destination="Sewer_Boss", tag="_"),
Portal(name="Well Exit towards Furnace", region="Beneath the Well Back",
destination="Overworld Redux", tag="_west_aqueduct"),
Portal(name="Well Boss to Well", region="Well Boss",
destination="Sewer", tag="_"),
Portal(name="Checkpoint to Dark Tomb", region="Dark Tomb Checkpoint",
destination="Crypt Redux", tag="_"),
Portal(name="Dark Tomb to Overworld", region="Dark Tomb Entry Point",
destination="Overworld Redux", tag="_"),
Portal(name="Dark Tomb to Furnace", region="Dark Tomb Dark Exit",
@@ -248,13 +248,13 @@ portal_mapping: List[Portal] = [
Portal(name="Dark Tomb to Checkpoint", region="Dark Tomb Entry Point",
destination="Sewer_Boss", tag="_"),
Portal(name="West Garden Exit near Hero's Grave", region="West Garden before Terry",
Portal(name="West Garden Exit near Hero's Grave", region="West Garden",
destination="Overworld Redux", tag="_lower"),
Portal(name="West Garden to Magic Dagger House", region="West Garden at Dagger House",
Portal(name="West Garden to Magic Dagger House", region="West Garden",
destination="archipelagos_house", tag="_"),
Portal(name="West Garden Exit after Boss", region="West Garden after Boss",
destination="Overworld Redux", tag="_upper"),
Portal(name="West Garden Shop", region="West Garden before Terry",
Portal(name="West Garden Shop", region="West Garden",
destination="Shop", tag="_"),
Portal(name="West Garden Laurels Exit", region="West Garden Laurels Exit Region",
destination="Overworld Redux", tag="_lowest"),
@@ -262,7 +262,7 @@ portal_mapping: List[Portal] = [
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="West Garden to Far Shore", region="West Garden Portal",
destination="Transit", tag="_teleporter_archipelagos_teleporter"),
Portal(name="Magic Dagger House Exit", region="Magic Dagger House",
destination="Archipelagos Redux", tag="_"),
@@ -308,7 +308,7 @@ portal_mapping: List[Portal] = [
Portal(name="East Fortress to Interior Upper", region="Fortress East Shortcut Upper",
destination="Fortress Main", tag="_upper"),
Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path Entry",
Portal(name="Fortress Grave Path Lower Exit", region="Fortress Grave Path",
destination="Fortress Courtyard", tag="_Lower"),
Portal(name="Fortress Hero's Grave", region="Fortress Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"),
@@ -339,7 +339,7 @@ portal_mapping: List[Portal] = [
destination="Frog Stairs", tag="_eye"),
Portal(name="Frog Stairs Mouth Entrance", region="Ruined Atoll Frog Mouth",
destination="Frog Stairs", tag="_mouth"),
Portal(name="Frog Stairs Eye Exit", region="Frog Stairs Eye Exit",
destination="Atoll Redux", tag="_eye"),
Portal(name="Frog Stairs Mouth Exit", region="Frog Stairs Upper",
@@ -348,39 +348,39 @@ portal_mapping: List[Portal] = [
destination="frog cave main", tag="_Entrance"),
Portal(name="Frog Stairs to Frog's Domain's Exit", region="Frog Stairs Lower",
destination="frog cave main", tag="_Exit"),
Portal(name="Frog's Domain Ladder Exit", region="Frog's Domain Entry",
destination="Frog Stairs", tag="_Entrance"),
Portal(name="Frog's Domain Orb Exit", region="Frog's Domain Back",
destination="Frog Stairs", tag="_Exit"),
Portal(name="Library Exterior Tree", region="Library Exterior Tree Region",
destination="Atoll Redux", tag="_"),
Portal(name="Library Exterior Ladder", region="Library Exterior Ladder Region",
destination="Library Hall", tag="_"),
Portal(name="Library Hall Bookshelf Exit", region="Library Hall Bookshelf",
destination="Library Exterior", tag="_"),
Portal(name="Library Hero's Grave", region="Library Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Library Hall to Rotunda", region="Library Hall to Rotunda",
destination="Library Rotunda", tag="_"),
Portal(name="Library Rotunda Lower Exit", region="Library Rotunda to Hall",
destination="Library Hall", tag="_"),
Portal(name="Library Rotunda Upper Exit", region="Library Rotunda to Lab",
destination="Library Lab", tag="_"),
Portal(name="Library Lab to Rotunda", region="Library Lab Lower",
destination="Library Rotunda", tag="_"),
Portal(name="Library to Far Shore", region="Library Portal",
destination="Transit", tag="_teleporter_library teleporter"),
Portal(name="Library Lab to Librarian Arena", region="Library Lab to Librarian",
destination="Library Arena", tag="_"),
Portal(name="Librarian Arena Exit", region="Library Arena",
destination="Library Lab", tag="_"),
Portal(name="Stairs to Top of the Mountain", region="Lower Mountain Stairs",
destination="Mountaintop", tag="_"),
Portal(name="Mountain to Quarry", region="Lower Mountain",
@@ -433,7 +433,7 @@ portal_mapping: List[Portal] = [
Portal(name="Ziggurat Tower to Ziggurat Lower", region="Rooted Ziggurat Middle Bottom",
destination="ziggurat2020_3", tag="_"),
Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Entry",
Portal(name="Ziggurat Lower to Ziggurat Tower", region="Rooted Ziggurat Lower Front",
destination="ziggurat2020_2", tag="_"),
Portal(name="Ziggurat Portal Room Entrance", region="Rooted Ziggurat Portal Room Entrance",
destination="ziggurat2020_FTRoom", tag="_"),
@@ -461,7 +461,7 @@ portal_mapping: List[Portal] = [
Portal(name="Swamp Hero's Grave", region="Swamp Hero's Grave Region",
destination="RelicVoid", tag="_teleporter_relic plinth"),
Portal(name="Cathedral Main Exit", region="Cathedral Entry",
Portal(name="Cathedral Main Exit", region="Cathedral",
destination="Swamp Redux 2", tag="_main"),
Portal(name="Cathedral Elevator", region="Cathedral to Gauntlet",
destination="Cathedral Arena", tag="_"),
@@ -523,6 +523,7 @@ class RegionInfo(NamedTuple):
game_scene: str # the name of the scene in the actual game
dead_end: int = 0 # if a region has only one exit
outlet_region: Optional[str] = None
is_fake_region: bool = False
# gets the outlet region name if it exists, the region if it doesn't
@@ -562,8 +563,6 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Overworld to West Garden Upper": RegionInfo("Overworld Redux"), # usually leads to garden knight
"Overworld to West Garden from Furnace": RegionInfo("Overworld Redux"), # isolated stairway with one chest
"Overworld Well Ladder": RegionInfo("Overworld Redux"), # just the ladder entrance itself as a region
"Overworld Well Entry Area": RegionInfo("Overworld Redux"), # the page, the bridge, etc.
"Overworld Tunnel to Beach": RegionInfo("Overworld Redux"), # the tunnel with the chest
"Overworld Beach": RegionInfo("Overworld Redux"), # from the two turrets to invisble maze, and lower atoll entry
"Overworld Tunnel Turret": RegionInfo("Overworld Redux"), # the tunnel turret by the southwest beach ladder
"Overworld to Atoll Upper": RegionInfo("Overworld Redux"), # the little ledge before the ladder
@@ -601,7 +600,6 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Sealed Temple Rafters": RegionInfo("Temple"),
"Forest Belltower Upper": RegionInfo("Forest Belltower"),
"Forest Belltower Main": RegionInfo("Forest Belltower"),
"Forest Belltower Main behind bushes": RegionInfo("Forest Belltower"),
"Forest Belltower Lower": RegionInfo("Forest Belltower"),
"East Forest": RegionInfo("East Forest Redux"),
"East Forest Dance Fox Spot": RegionInfo("East Forest Redux"),
@@ -609,8 +607,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Lower Forest": RegionInfo("East Forest Redux"), # bottom of the forest
"Guard House 1 East": RegionInfo("East Forest Redux Laddercave"),
"Guard House 1 West": RegionInfo("East Forest Redux Laddercave"),
"Guard House 2 Upper before bushes": RegionInfo("East Forest Redux Interior"),
"Guard House 2 Upper after bushes": RegionInfo("East Forest Redux Interior"),
"Guard House 2 Upper": RegionInfo("East Forest Redux Interior"),
"Guard House 2 Lower": RegionInfo("East Forest Redux Interior"),
"Forest Boss Room": RegionInfo("Forest Boss Room"),
"Forest Grave Path Main": RegionInfo("Sword Access"),
@@ -627,18 +624,14 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Beneath the Well Front": RegionInfo("Sewer"), # the front, to separate it from the weapon requirement in the mid
"Beneath the Well Main": RegionInfo("Sewer"), # the main section of it, requires a weapon
"Beneath the Well Back": RegionInfo("Sewer"), # the back two portals, and all 4 upper chests
"West Garden before Terry": RegionInfo("Archipelagos Redux"), # the lower entry point, near hero grave
"West Garden after Terry": RegionInfo("Archipelagos Redux"), # after Terry, up until next chompignons
"West Garden at Dagger House": RegionInfo("Archipelagos Redux"), # just outside magic dagger house
"West Garden South Checkpoint": RegionInfo("Archipelagos Redux"),
"West Garden": RegionInfo("Archipelagos Redux"),
"Magic Dagger House": RegionInfo("archipelagos_house", dead_end=DeadEnd.all_cats),
"West Garden Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted, outlet_region="West Garden by Portal"),
"West Garden by Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted),
"West Garden Portal Item": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted),
"West Garden Laurels Exit Region": RegionInfo("Archipelagos Redux"),
"West Garden before Boss": RegionInfo("Archipelagos Redux"), # main west garden
"West Garden after Boss": RegionInfo("Archipelagos Redux"),
"West Garden Hero's Grave Region": RegionInfo("Archipelagos Redux", outlet_region="West Garden before Terry"),
"West Garden Hero's Grave Region": RegionInfo("Archipelagos Redux", outlet_region="West Garden"),
"Ruined Atoll": RegionInfo("Atoll Redux"),
"Ruined Atoll Lower Entry Area": RegionInfo("Atoll Redux"),
"Ruined Atoll Ladder Tops": RegionInfo("Atoll Redux"), # at the top of the 5 ladders in south Atoll
@@ -650,9 +643,8 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Frog Stairs Upper": RegionInfo("Frog Stairs"),
"Frog Stairs Lower": RegionInfo("Frog Stairs"),
"Frog Stairs to Frog's Domain": RegionInfo("Frog Stairs"),
"Frog's Domain Entry": RegionInfo("frog cave main"), # just the ladder
"Frog's Domain Front": RegionInfo("frog cave main"), # before combat
"Frog's Domain Main": RegionInfo("frog cave main"),
"Frog's Domain Entry": RegionInfo("frog cave main"),
"Frog's Domain": RegionInfo("frog cave main"),
"Frog's Domain Back": RegionInfo("frog cave main"),
"Library Exterior Tree Region": RegionInfo("Library Exterior", outlet_region="Library Exterior by Tree"),
"Library Exterior by Tree": RegionInfo("Library Exterior"),
@@ -666,8 +658,8 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Library Rotunda to Lab": RegionInfo("Library Rotunda"),
"Library Lab": RegionInfo("Library Lab"),
"Library Lab Lower": RegionInfo("Library Lab"),
"Library Lab on Portal Pad": RegionInfo("Library Lab"),
"Library Portal": RegionInfo("Library Lab", outlet_region="Library Lab on Portal Pad"),
"Library Lab on Portal Pad": RegionInfo("Library Lab"),
"Library Lab to Librarian": RegionInfo("Library Lab"),
"Library Arena": RegionInfo("Library Arena", dead_end=DeadEnd.all_cats),
"Fortress Exterior from East Forest": RegionInfo("Fortress Courtyard"),
@@ -683,12 +675,10 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Eastern Vault Fortress Gold Door": RegionInfo("Fortress Main"),
"Fortress East Shortcut Upper": RegionInfo("Fortress East"),
"Fortress East Shortcut Lower": RegionInfo("Fortress East"),
"Fortress Grave Path Entry": RegionInfo("Fortress Reliquary"),
"Fortress Grave Path Combat": RegionInfo("Fortress Reliquary"), # the combat is basically just a barrier here
"Fortress Grave Path by Grave": RegionInfo("Fortress Reliquary"),
"Fortress Grave Path": RegionInfo("Fortress Reliquary"),
"Fortress Grave Path Upper": RegionInfo("Fortress Reliquary", dead_end=DeadEnd.restricted),
"Fortress Grave Path Dusty Entrance Region": RegionInfo("Fortress Reliquary"),
"Fortress Hero's Grave Region": RegionInfo("Fortress Reliquary", outlet_region="Fortress Grave Path by Grave"),
"Fortress Hero's Grave Region": RegionInfo("Fortress Reliquary", outlet_region="Fortress Grave Path"),
"Fortress Leaf Piles": RegionInfo("Dusty", dead_end=DeadEnd.all_cats),
"Fortress Arena": RegionInfo("Fortress Arena"),
"Fortress Arena Portal": RegionInfo("Fortress Arena", outlet_region="Fortress Arena"),
@@ -707,7 +697,6 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Monastery Rope": RegionInfo("Quarry Redux"),
"Lower Quarry": RegionInfo("Quarry Redux"),
"Even Lower Quarry": RegionInfo("Quarry Redux"),
"Even Lower Quarry Isolated Chest": RegionInfo("Quarry Redux"), # a region for that one chest
"Lower Quarry Zig Door": RegionInfo("Quarry Redux"),
"Rooted Ziggurat Entry": RegionInfo("ziggurat2020_0"),
"Rooted Ziggurat Upper Entry": RegionInfo("ziggurat2020_1"),
@@ -715,15 +704,13 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Rooted Ziggurat Upper Back": RegionInfo("ziggurat2020_1"), # after the administrator
"Rooted Ziggurat Middle Top": RegionInfo("ziggurat2020_2"),
"Rooted Ziggurat Middle Bottom": RegionInfo("ziggurat2020_2"),
"Rooted Ziggurat Lower Entry": RegionInfo("ziggurat2020_3"), # the vanilla entry point side
"Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the front for combat logic
"Rooted Ziggurat Lower Mid Checkpoint": RegionInfo("ziggurat2020_3"), # the mid-checkpoint before double admin
"Rooted Ziggurat Lower Front": RegionInfo("ziggurat2020_3"), # the vanilla entry point side
"Rooted Ziggurat Lower Back": RegionInfo("ziggurat2020_3"), # the boss side
"Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Entry"), # for use with fixed shop on
"Zig Skip Exit": RegionInfo("ziggurat2020_3", dead_end=DeadEnd.special, outlet_region="Rooted Ziggurat Lower Front"), # the exit from zig skip, for use with fixed shop on
"Rooted Ziggurat Portal Room Entrance": RegionInfo("ziggurat2020_3", outlet_region="Rooted Ziggurat Lower Back"), # the door itself on the zig 3 side
"Rooted Ziggurat Portal": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"),
"Rooted Ziggurat Portal Room": RegionInfo("ziggurat2020_FTRoom"),
"Rooted Ziggurat Portal Room Exit": RegionInfo("ziggurat2020_FTRoom", outlet_region="Rooted Ziggurat Portal Room"),
"Rooted Ziggurat Portal Room Exit": RegionInfo("ziggurat2020_FTRoom"),
"Swamp Front": RegionInfo("Swamp Redux 2"), # from the main entry to the top of the ladder after south
"Swamp Mid": RegionInfo("Swamp Redux 2"), # from the bottom of the ladder to the cathedral door
"Swamp Ledge under Cathedral Door": RegionInfo("Swamp Redux 2"), # the ledge with the chest and secret door
@@ -732,8 +719,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Back of Swamp": RegionInfo("Swamp Redux 2"), # the area with hero grave and gauntlet entrance
"Swamp Hero's Grave Region": RegionInfo("Swamp Redux 2", outlet_region="Back of Swamp"),
"Back of Swamp Laurels Area": RegionInfo("Swamp Redux 2"), # the spots you need laurels to traverse
"Cathedral Entry": RegionInfo("Cathedral Redux"), # the checkpoint and easily-accessible chests
"Cathedral Main": RegionInfo("Cathedral Redux"), # the majority of Cathedral
"Cathedral": RegionInfo("Cathedral Redux"),
"Cathedral to Gauntlet": RegionInfo("Cathedral Redux"), # the elevator
"Cathedral Secret Legend Room": RegionInfo("Cathedral Redux", dead_end=DeadEnd.all_cats),
"Cathedral Gauntlet Checkpoint": RegionInfo("Cathedral Arena"),
@@ -755,7 +741,7 @@ tunic_er_regions: Dict[str, RegionInfo] = {
"Purgatory": RegionInfo("Purgatory"),
"Shop": RegionInfo("Shop", dead_end=DeadEnd.all_cats),
"Spirit Arena": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats),
"Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats),
"Spirit Arena Victory": RegionInfo("Spirit Arena", dead_end=DeadEnd.all_cats)
}
@@ -773,8 +759,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld": {
"Overworld Beach":
[],
"Overworld Tunnel to Beach":
[],
"Overworld to Atoll Upper":
[["Hyperdash"]],
"Overworld Belltower":
@@ -785,7 +769,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
[],
"Overworld Special Shop Entry":
[["Hyperdash"], ["LS1"]],
"Overworld Well Entry Area":
"Overworld Well Ladder":
[],
"Overworld Ruined Passage Door":
[],
@@ -863,12 +847,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
# "Overworld":
# [],
# },
"Overworld Tunnel to Beach": {
# "Overworld":
# [],
"Overworld Beach":
[],
},
"Overworld Beach": {
# "Overworld":
# [],
@@ -895,15 +873,9 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld Beach":
[],
},
"Overworld Well Entry Area": {
"Overworld Well Ladder": {
# "Overworld":
# [],
"Overworld Well Ladder":
[],
},
"Overworld Well Ladder": {
"Overworld Well Entry Area":
[],
},
"Overworld at Patrol Cave": {
"East Overworld":
@@ -982,7 +954,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Overworld":
[],
},
"Old House Front": {
"Old House Back":
[],
@@ -991,7 +962,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Old House Front":
[["Hyperdash", "Zip"]],
},
"Furnace Fuse": {
"Furnace Ladder Area":
[["Hyperdash"]],
@@ -1006,7 +976,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Furnace Ladder Area":
[["Hyperdash"]],
},
"Sealed Temple": {
"Sealed Temple Rafters":
[],
@@ -1015,12 +984,10 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Sealed Temple":
[["Hyperdash"]],
},
"Hourglass Cave": {
"Hourglass Cave Tower":
[],
},
"Forest Belltower Upper": {
"Forest Belltower Main":
[],
@@ -1028,14 +995,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Forest Belltower Main": {
"Forest Belltower Lower":
[],
"Forest Belltower Main behind bushes":
[],
},
"Forest Belltower Main behind bushes": {
"Forest Belltower Main":
[],
},
"East Forest": {
"East Forest Dance Fox Spot":
[["Hyperdash"], ["IG1"], ["LS1"]],
@@ -1056,7 +1016,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"East Forest":
[],
},
"Guard House 1 East": {
"Guard House 1 West":
[],
@@ -1065,21 +1024,14 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Guard House 1 East":
[["Hyperdash"], ["LS1"]],
},
"Guard House 2 Upper before bushes": {
"Guard House 2 Upper after bushes":
[],
},
"Guard House 2 Upper after bushes": {
"Guard House 2 Upper": {
"Guard House 2 Lower":
[],
"Guard House 2 Upper before bushes":
[],
},
"Guard House 2 Lower": {
"Guard House 2 Upper after bushes":
"Guard House 2 Upper":
[],
},
"Forest Grave Path Main": {
"Forest Grave Path Upper":
[["Hyperdash"], ["LS2"], ["IG3"]],
@@ -1092,7 +1044,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
},
"Forest Grave Path by Grave": {
"Forest Hero's Grave":
[],
[],
"Forest Grave Path Main":
[["IG1"]],
},
@@ -1100,7 +1052,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Forest Grave Path by Grave":
[],
},
"Beneath the Well Ladder Exit": {
"Beneath the Well Front":
[],
@@ -1121,7 +1072,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Beneath the Well Main":
[],
},
"Well Boss": {
"Dark Tomb Checkpoint":
[],
@@ -1130,7 +1080,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Well Boss":
[["Hyperdash", "Zip"]],
},
"Dark Tomb Entry Point": {
"Dark Tomb Upper":
[],
@@ -1151,72 +1100,44 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Dark Tomb Main":
[],
},
"West Garden before Terry": {
"West Garden after Terry":
[],
"West Garden Hero's Grave Region":
[],
},
"West Garden Hero's Grave Region": {
"West Garden before Terry":
[],
},
"West Garden after Terry": {
"West Garden before Terry":
[],
"West Garden South Checkpoint":
[],
"West Garden": {
"West Garden Laurels Exit Region":
[["LS1"]],
},
"West Garden South Checkpoint": {
"West Garden before Boss":
[],
"West Garden at Dagger House":
[],
"West Garden after Terry":
[],
},
"West Garden before Boss": {
[["Hyperdash"], ["LS1"]],
"West Garden after Boss":
[],
"West Garden South Checkpoint":
[],
},
"West Garden after Boss": {
"West Garden before Boss":
[["Hyperdash"]],
},
"West Garden at Dagger House": {
"West Garden Laurels Exit Region":
[["Hyperdash"]],
"West Garden South Checkpoint":
[],
"West Garden Hero's Grave Region":
[],
"West Garden Portal Item":
[["IG2"]],
},
"West Garden Laurels Exit Region": {
"West Garden at Dagger House":
"West Garden":
[["Hyperdash"]],
},
"West Garden after Boss": {
"West Garden":
[["Hyperdash"]],
},
"West Garden Portal Item": {
"West Garden at Dagger House":
"West Garden":
[["IG1"]],
"West Garden by Portal":
[["Hyperdash"]],
},
"West Garden by Portal": {
"West Garden Portal":
[["West Garden South Checkpoint"]],
"West Garden Portal Item":
[["Hyperdash"]],
"West Garden Portal":
[["West Garden"]],
},
"West Garden Portal": {
"West Garden by Portal":
[],
},
"West Garden Hero's Grave Region": {
"West Garden":
[],
},
"Ruined Atoll": {
"Ruined Atoll Lower Entry Area":
[["Hyperdash"], ["LS1"]],
@@ -1255,7 +1176,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Ruined Atoll":
[],
},
"Frog Stairs Eye Exit": {
"Frog Stairs Upper":
[],
@@ -1276,25 +1196,16 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Frog Stairs Lower":
[],
},
"Frog's Domain Entry": {
"Frog's Domain Front":
"Frog's Domain":
[],
},
"Frog's Domain Front": {
"Frog's Domain": {
"Frog's Domain Entry":
[],
"Frog's Domain Main":
[],
},
"Frog's Domain Main": {
"Frog's Domain Front":
[],
"Frog's Domain Back":
[],
},
# cannot get from frogs back to front
"Library Exterior Ladder Region": {
"Library Exterior by Tree":
[],
@@ -1309,7 +1220,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Library Exterior by Tree":
[],
},
"Library Hall Bookshelf": {
"Library Hall":
[],
@@ -1330,7 +1240,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Library Hall":
[],
},
"Library Rotunda to Hall": {
"Library Rotunda":
[],
@@ -1372,10 +1281,9 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Library Lab":
[],
},
"Fortress Exterior from East Forest": {
"Fortress Exterior from Overworld":
[],
[],
"Fortress Courtyard Upper":
[["LS2"]],
"Fortress Courtyard":
@@ -1383,9 +1291,9 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
},
"Fortress Exterior from Overworld": {
"Fortress Exterior from East Forest":
[["Hyperdash"]],
[["Hyperdash"]],
"Fortress Exterior near cave":
[],
[],
"Fortress Courtyard":
[["Hyperdash"], ["IG1"], ["LS1"]],
},
@@ -1413,7 +1321,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Fortress Courtyard":
[],
},
"Beneath the Vault Ladder Exit": {
"Beneath the Vault Main":
[],
@@ -1430,7 +1337,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Beneath the Vault Ladder Exit":
[],
},
"Fortress East Shortcut Lower": {
"Fortress East Shortcut Upper":
[["IG1"]],
@@ -1439,7 +1345,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Fortress East Shortcut Lower":
[],
},
"Eastern Vault Fortress": {
"Eastern Vault Fortress Gold Door":
[["IG2"], ["Fortress Exterior from Overworld", "Beneath the Vault Back", "Fortress Courtyard Upper"]],
@@ -1448,44 +1353,24 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Eastern Vault Fortress":
[["IG1"]],
},
"Fortress Grave Path Entry": {
"Fortress Grave Path Combat":
[],
# redundant here, keeping a comment to show it's intentional
# "Fortress Grave Path Dusty Entrance Region":
# [["Hyperdash"]],
},
"Fortress Grave Path Combat": {
"Fortress Grave Path Entry":
[],
"Fortress Grave Path by Grave":
[],
},
"Fortress Grave Path by Grave": {
"Fortress Grave Path Entry":
[],
# unnecessary, you can just skip it
# "Fortress Grave Path Combat":
# [],
"Fortress Grave Path": {
"Fortress Hero's Grave Region":
[],
[],
"Fortress Grave Path Dusty Entrance Region":
[["Hyperdash"]],
},
"Fortress Grave Path Upper": {
"Fortress Grave Path Entry":
"Fortress Grave Path":
[["IG1"]],
},
"Fortress Grave Path Dusty Entrance Region": {
"Fortress Grave Path by Grave":
"Fortress Grave Path":
[["Hyperdash"]],
},
"Fortress Hero's Grave Region": {
"Fortress Grave Path by Grave":
"Fortress Grave Path":
[],
},
"Fortress Arena": {
"Fortress Arena Portal":
[["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]],
@@ -1494,7 +1379,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Fortress Arena":
[],
},
"Lower Mountain": {
"Lower Mountain Stairs":
[],
@@ -1503,7 +1387,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Lower Mountain":
[],
},
"Monastery Back": {
"Monastery Front":
[["Hyperdash", "Zip"]],
@@ -1518,7 +1401,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Monastery Back":
[],
},
"Quarry Entry": {
"Quarry Portal":
[["Quarry Connector"]],
@@ -1554,17 +1436,15 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
[],
"Quarry Monastery Entry":
[],
"Lower Quarry Zig Door":
[["IG3"]],
},
"Lower Quarry": {
"Even Lower Quarry":
[],
},
"Even Lower Quarry": {
"Even Lower Quarry Isolated Chest":
[],
},
"Even Lower Quarry Isolated Chest": {
"Even Lower Quarry":
"Lower Quarry":
[],
"Lower Quarry Zig Door":
[["Quarry", "Quarry Connector"], ["IG3"]],
@@ -1573,7 +1453,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Quarry Back":
[],
},
"Rooted Ziggurat Upper Entry": {
"Rooted Ziggurat Upper Front":
[],
@@ -1586,38 +1465,17 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Rooted Ziggurat Upper Front":
[["Hyperdash"]],
},
"Rooted Ziggurat Middle Top": {
"Rooted Ziggurat Middle Bottom":
[],
},
"Rooted Ziggurat Lower Entry": {
"Rooted Ziggurat Lower Front":
[],
# can zip through to the checkpoint
"Rooted Ziggurat Lower Mid Checkpoint":
[["Hyperdash"]],
},
"Rooted Ziggurat Lower Front": {
"Rooted Ziggurat Lower Entry":
[],
"Rooted Ziggurat Lower Mid Checkpoint":
[],
},
"Rooted Ziggurat Lower Mid Checkpoint": {
"Rooted Ziggurat Lower Entry":
[["Hyperdash"]],
"Rooted Ziggurat Lower Front":
[],
"Rooted Ziggurat Lower Back":
[],
},
"Rooted Ziggurat Lower Back": {
"Rooted Ziggurat Lower Entry":
[["LS2"]],
"Rooted Ziggurat Lower Mid Checkpoint":
[["Hyperdash"], ["IG1"]],
"Rooted Ziggurat Lower Front":
[["Hyperdash"], ["LS2"], ["IG1"]],
"Rooted Ziggurat Portal Room Entrance":
[],
},
@@ -1629,22 +1487,20 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Rooted Ziggurat Lower Back":
[],
},
"Rooted Ziggurat Portal Room Exit": {
"Rooted Ziggurat Portal Room":
[],
},
"Rooted Ziggurat Portal Room": {
"Rooted Ziggurat Portal Room Exit":
[["Rooted Ziggurat Lower Back"]],
"Rooted Ziggurat Portal":
[],
"Rooted Ziggurat Portal Room Exit":
[["Rooted Ziggurat Lower Back"]],
},
"Rooted Ziggurat Portal": {
"Rooted Ziggurat Portal Room":
[],
},
"Swamp Front": {
"Swamp Mid":
[],
@@ -1701,26 +1557,14 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Back of Swamp":
[],
},
"Cathedral Entry": {
"Cathedral to Gauntlet":
[],
"Cathedral Main":
[],
},
"Cathedral Main": {
"Cathedral Entry":
[],
"Cathedral": {
"Cathedral to Gauntlet":
[],
},
"Cathedral to Gauntlet": {
"Cathedral Entry":
[],
"Cathedral Main":
"Cathedral":
[],
},
"Cathedral Gauntlet Checkpoint": {
"Cathedral Gauntlet":
[],
@@ -1733,7 +1577,6 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Cathedral Gauntlet":
[["Hyperdash"]],
},
"Far Shore": {
"Far Shore to Spawn Region":
[["Hyperdash"]],
@@ -1744,7 +1587,7 @@ traversal_requirements: Dict[str, Dict[str, List[List[str]]]] = {
"Far Shore to Library Region":
[["Library Lab"]],
"Far Shore to West Garden Region":
[["West Garden South Checkpoint"]],
[["West Garden"]],
"Far Shore to Fortress Region":
[["Fortress Exterior from Overworld", "Beneath the Vault Back", "Eastern Vault Fortress"]],
},

View File

@@ -1,11 +1,10 @@
from typing import Dict, FrozenSet, Tuple, TYPE_CHECKING
from worlds.generic.Rules import set_rule, add_rule, forbid_item
from .options import IceGrappling, LadderStorage, CombatLogic
from .rules import (has_ability, has_sword, has_melee, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage,
from worlds.generic.Rules import set_rule, forbid_item
from .options import IceGrappling, LadderStorage
from .rules import (has_ability, has_sword, has_stick, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage,
laurels_zip, bomb_walls)
from .er_data import Portal, get_portal_outlet_region
from .ladder_storage_data import ow_ladder_groups, region_ladders, easy_ls, medium_ls, hard_ls
from .combat_logic import has_combat_reqs
from BaseClasses import Region, CollectionState
if TYPE_CHECKING:
@@ -40,34 +39,10 @@ def can_shop(state: CollectionState, world: "TunicWorld") -> bool:
return has_sword(state, world.player) and state.can_reach_region("Shop", world.player)
# for the ones that are not early bushes where ER can screw you over a bit
def can_get_past_bushes(state: CollectionState, world: "TunicWorld") -> bool:
# add in glass cannon + stick for grass rando
return has_sword(state, world.player) or state.has_any((fire_wand, laurels, gun), world.player)
def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_pairs: Dict[Portal, Portal]) -> None:
player = world.player
options = world.options
# input scene destination tag, returns portal's name and paired portal's outlet region or region
def get_portal_info(portal_sd: str) -> Tuple[str, str]:
for portal1, portal2 in portal_pairs.items():
if portal1.scene_destination() == portal_sd:
return portal1.name, get_portal_outlet_region(portal2, world)
if portal2.scene_destination() == portal_sd:
return portal2.name, get_portal_outlet_region(portal1, world)
raise Exception("No matches found in get_portal_info")
# input scene destination tag, returns paired portal's name and region
def get_paired_portal(portal_sd: str) -> Tuple[str, str]:
for portal1, portal2 in portal_pairs.items():
if portal1.scene_destination() == portal_sd:
return portal2.name, portal2.region
if portal2.scene_destination() == portal_sd:
return portal1.name, portal1.region
raise Exception("no matches found in get_paired_portal")
regions["Menu"].connect(
connecting_region=regions["Overworld"])
@@ -81,18 +56,10 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Overworld Beach"],
rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
or state.has_any({laurels, grapple}, player))
# regions["Overworld Beach"].connect(
# connecting_region=regions["Overworld"],
# rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
# or state.has_any({laurels, grapple}, player))
# region for combat logic, no need to connect it to beach since it would be the same as the ow -> beach cxn
ow_tunnel_beach = regions["Overworld"].connect(
connecting_region=regions["Overworld Tunnel to Beach"])
regions["Overworld Beach"].connect(
connecting_region=regions["Overworld Tunnel to Beach"],
rule=lambda state: state.has(laurels, player) or has_ladder("Ladders in Overworld Town", state, world))
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
or state.has_any({laurels, grapple}, player))
regions["Overworld Beach"].connect(
connecting_region=regions["Overworld West Garden Laurels Entry"],
@@ -310,17 +277,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["East Overworld"],
rule=lambda state: state.has(laurels, player))
# region made for combat logic
ow_to_well_entry = regions["Overworld"].connect(
connecting_region=regions["Overworld Well Entry Area"])
regions["Overworld Well Entry Area"].connect(
connecting_region=regions["Overworld"])
regions["Overworld Well Entry Area"].connect(
regions["Overworld"].connect(
connecting_region=regions["Overworld Well Ladder"],
rule=lambda state: has_ladder("Ladders in Well", state, world))
regions["Overworld Well Ladder"].connect(
connecting_region=regions["Overworld Well Entry Area"],
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladders in Well", state, world))
# nmg: can ice grapple through the door
@@ -345,7 +306,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Overworld Fountain Cross Door"].connect(
connecting_region=regions["Overworld"])
ow_to_town_portal = regions["Overworld"].connect(
regions["Overworld"].connect(
connecting_region=regions["Overworld Town Portal"],
rule=lambda state: has_ability(prayer, state, world))
regions["Overworld Town Portal"].connect(
@@ -376,7 +337,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: has_ladder("Ladders in Overworld Town", state, world)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
# don't need the ice grapple rule since you can go from ow -> beach -> tunnel
regions["Overworld"].connect(
connecting_region=regions["Overworld Tunnel Turret"],
rule=lambda state: state.has(laurels, player))
@@ -443,14 +403,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Forest Belltower Lower"],
rule=lambda state: has_ladder("Ladder to East Forest", state, world))
regions["Forest Belltower Main behind bushes"].connect(
connecting_region=regions["Forest Belltower Main"],
rule=lambda state: can_get_past_bushes(state, world)
or has_ice_grapple_logic(False, IceGrappling.option_easy, state, world))
# you can use the slimes to break the bushes
regions["Forest Belltower Main"].connect(
connecting_region=regions["Forest Belltower Main behind bushes"])
# ice grapple up to dance fox spot, and vice versa
regions["East Forest"].connect(
connecting_region=regions["East Forest Dance Fox Spot"],
@@ -481,18 +433,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Guard House 1 East"],
rule=lambda state: state.has(laurels, player))
regions["Guard House 2 Upper before bushes"].connect(
connecting_region=regions["Guard House 2 Upper after bushes"],
rule=lambda state: can_get_past_bushes(state, world))
regions["Guard House 2 Upper after bushes"].connect(
connecting_region=regions["Guard House 2 Upper before bushes"],
rule=lambda state: can_get_past_bushes(state, world))
regions["Guard House 2 Upper after bushes"].connect(
regions["Guard House 2 Upper"].connect(
connecting_region=regions["Guard House 2 Lower"],
rule=lambda state: has_ladder("Ladders to Lower Forest", state, world))
regions["Guard House 2 Lower"].connect(
connecting_region=regions["Guard House 2 Upper after bushes"],
connecting_region=regions["Guard House 2 Upper"],
rule=lambda state: has_ladder("Ladders to Lower Forest", state, world))
# ice grapple from upper grave path exit to the rest of it
@@ -528,28 +473,29 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Beneath the Well Ladder Exit"],
rule=lambda state: has_ladder("Ladders in Well", state, world))
btw_front_main = regions["Beneath the Well Front"].connect(
regions["Beneath the Well Front"].connect(
connecting_region=regions["Beneath the Well Main"],
rule=lambda state: has_melee(state, player) or state.has(fire_wand, player))
rule=lambda state: has_stick(state, player) or state.has(fire_wand, player))
regions["Beneath the Well Main"].connect(
connecting_region=regions["Beneath the Well Front"])
connecting_region=regions["Beneath the Well Front"],
rule=lambda state: has_stick(state, player) or state.has(fire_wand, player))
regions["Beneath the Well Main"].connect(
connecting_region=regions["Beneath the Well Back"],
rule=lambda state: has_ladder("Ladders in Well", state, world))
btw_back_main = regions["Beneath the Well Back"].connect(
regions["Beneath the Well Back"].connect(
connecting_region=regions["Beneath the Well Main"],
rule=lambda state: has_ladder("Ladders in Well", state, world)
and (has_melee(state, player) or state.has(fire_wand, player)))
and (has_stick(state, player) or state.has(fire_wand, player)))
well_boss_to_dt = regions["Well Boss"].connect(
regions["Well Boss"].connect(
connecting_region=regions["Dark Tomb Checkpoint"])
# can laurels through the gate, no setup needed
regions["Dark Tomb Checkpoint"].connect(
connecting_region=regions["Well Boss"],
rule=lambda state: laurels_zip(state, world))
dt_entry_to_upper = regions["Dark Tomb Entry Point"].connect(
regions["Dark Tomb Entry Point"].connect(
connecting_region=regions["Dark Tomb Upper"],
rule=lambda state: has_lantern(state, world))
regions["Dark Tomb Upper"].connect(
@@ -566,57 +512,34 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Dark Tomb Main"].connect(
connecting_region=regions["Dark Tomb Dark Exit"])
dt_exit_to_main = regions["Dark Tomb Dark Exit"].connect(
regions["Dark Tomb Dark Exit"].connect(
connecting_region=regions["Dark Tomb Main"],
rule=lambda state: has_lantern(state, world))
# West Garden
# combat logic regions
wg_before_to_after_terry = regions["West Garden before Terry"].connect(
connecting_region=regions["West Garden after Terry"])
wg_after_to_before_terry = regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden before Terry"])
regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden South Checkpoint"])
wg_checkpoint_to_after_terry = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden after Terry"])
wg_checkpoint_to_dagger = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden at Dagger House"])
regions["West Garden at Dagger House"].connect(
connecting_region=regions["West Garden South Checkpoint"])
wg_checkpoint_to_before_boss = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden before Boss"])
regions["West Garden before Boss"].connect(
connecting_region=regions["West Garden South Checkpoint"])
regions["West Garden Laurels Exit Region"].connect(
connecting_region=regions["West Garden at Dagger House"],
connecting_region=regions["West Garden"],
rule=lambda state: state.has(laurels, player))
regions["West Garden at Dagger House"].connect(
regions["West Garden"].connect(
connecting_region=regions["West Garden Laurels Exit Region"],
rule=lambda state: state.has(laurels, player))
# laurels past, or ice grapple it off, or ice grapple to it then fight
after_gk_to_wg = regions["West Garden after Boss"].connect(
connecting_region=regions["West Garden before Boss"],
# you can grapple Garden Knight to aggro it, then ledge it
regions["West Garden after Boss"].connect(
connecting_region=regions["West Garden"],
rule=lambda state: state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or (has_ice_grapple_logic(False, IceGrappling.option_easy, state, world)
and has_sword(state, player)))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
# ice grapple push Garden Knight off the side
wg_to_after_gk = regions["West Garden before Boss"].connect(
regions["West Garden"].connect(
connecting_region=regions["West Garden after Boss"],
rule=lambda state: state.has(laurels, player) or has_sword(state, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
regions["West Garden before Terry"].connect(
regions["West Garden"].connect(
connecting_region=regions["West Garden Hero's Grave Region"],
rule=lambda state: has_ability(prayer, state, world))
regions["West Garden Hero's Grave Region"].connect(
connecting_region=regions["West Garden before Terry"])
connecting_region=regions["West Garden"])
regions["West Garden Portal"].connect(
connecting_region=regions["West Garden by Portal"])
@@ -633,9 +556,9 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
# can ice grapple to and from the item behind the magic dagger house
regions["West Garden Portal Item"].connect(
connecting_region=regions["West Garden at Dagger House"],
connecting_region=regions["West Garden"],
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
regions["West Garden at Dagger House"].connect(
regions["West Garden"].connect(
connecting_region=regions["West Garden Portal Item"],
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_medium, state, world))
@@ -673,7 +596,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Ruined Atoll Portal"].connect(
connecting_region=regions["Ruined Atoll"])
atoll_statue = regions["Ruined Atoll"].connect(
regions["Ruined Atoll"].connect(
connecting_region=regions["Ruined Atoll Statue"],
rule=lambda state: has_ability(prayer, state, world)
and (has_ladder("Ladders in South Atoll", state, world)
@@ -706,13 +629,10 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: has_ladder("Ladders to Frog's Domain", state, world))
regions["Frog's Domain Entry"].connect(
connecting_region=regions["Frog's Domain Front"],
connecting_region=regions["Frog's Domain"],
rule=lambda state: has_ladder("Ladders to Frog's Domain", state, world))
frogs_front_to_main = regions["Frog's Domain Front"].connect(
connecting_region=regions["Frog's Domain Main"])
regions["Frog's Domain Main"].connect(
regions["Frog's Domain"].connect(
connecting_region=regions["Frog's Domain Back"],
rule=lambda state: state.has(grapple, player))
@@ -832,7 +752,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
fort_upper_lower = regions["Fortress Courtyard Upper"].connect(
regions["Fortress Courtyard Upper"].connect(
connecting_region=regions["Fortress Courtyard"])
# nmg: can ice grapple to the upper ledge
regions["Fortress Courtyard"].connect(
@@ -842,12 +762,12 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Fortress Courtyard Upper"].connect(
connecting_region=regions["Fortress Exterior from Overworld"])
btv_front_to_main = regions["Beneath the Vault Ladder Exit"].connect(
regions["Beneath the Vault Ladder Exit"].connect(
connecting_region=regions["Beneath the Vault Main"],
rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, world)
and has_lantern(state, world)
# there's some boxes in the way
and (has_melee(state, player) or state.has_any((gun, grapple, fire_wand, laurels), player)))
and (has_stick(state, player) or state.has_any((gun, grapple, fire_wand, laurels), player)))
# on the reverse trip, you can lure an enemy over to break the boxes if needed
regions["Beneath the Vault Main"].connect(
connecting_region=regions["Beneath the Vault Ladder Exit"],
@@ -855,11 +775,11 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Beneath the Vault Main"].connect(
connecting_region=regions["Beneath the Vault Back"])
btv_back_to_main = regions["Beneath the Vault Back"].connect(
regions["Beneath the Vault Back"].connect(
connecting_region=regions["Beneath the Vault Main"],
rule=lambda state: has_lantern(state, world))
fort_east_upper_lower = regions["Fortress East Shortcut Upper"].connect(
regions["Fortress East Shortcut Upper"].connect(
connecting_region=regions["Fortress East Shortcut Lower"])
regions["Fortress East Shortcut Lower"].connect(
connecting_region=regions["Fortress East Shortcut Upper"],
@@ -874,31 +794,21 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Eastern Vault Fortress"],
rule=lambda state: has_ice_grapple_logic(False, IceGrappling.option_easy, state, world))
fort_grave_entry_to_combat = regions["Fortress Grave Path Entry"].connect(
connecting_region=regions["Fortress Grave Path Combat"])
regions["Fortress Grave Path Combat"].connect(
connecting_region=regions["Fortress Grave Path Entry"])
regions["Fortress Grave Path"].connect(
connecting_region=regions["Fortress Grave Path Dusty Entrance Region"],
rule=lambda state: state.has(laurels, player))
regions["Fortress Grave Path Dusty Entrance Region"].connect(
connecting_region=regions["Fortress Grave Path"],
rule=lambda state: state.has(laurels, player))
regions["Fortress Grave Path Combat"].connect(
connecting_region=regions["Fortress Grave Path by Grave"])
# run past the enemies
regions["Fortress Grave Path by Grave"].connect(
connecting_region=regions["Fortress Grave Path Entry"])
regions["Fortress Grave Path by Grave"].connect(
regions["Fortress Grave Path"].connect(
connecting_region=regions["Fortress Hero's Grave Region"],
rule=lambda state: has_ability(prayer, state, world))
regions["Fortress Hero's Grave Region"].connect(
connecting_region=regions["Fortress Grave Path by Grave"])
regions["Fortress Grave Path by Grave"].connect(
connecting_region=regions["Fortress Grave Path Dusty Entrance Region"],
rule=lambda state: state.has(laurels, player))
# reverse connection is conditionally made later, depending on whether combat logic is on, and the details of ER
connecting_region=regions["Fortress Grave Path"])
regions["Fortress Grave Path Upper"].connect(
connecting_region=regions["Fortress Grave Path Entry"],
connecting_region=regions["Fortress Grave Path"],
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
regions["Fortress Arena"].connect(
@@ -921,19 +831,19 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Quarry Portal"].connect(
connecting_region=regions["Quarry Entry"])
quarry_entry_to_main = regions["Quarry Entry"].connect(
regions["Quarry Entry"].connect(
connecting_region=regions["Quarry"],
rule=lambda state: state.has(fire_wand, player) or has_sword(state, player))
regions["Quarry"].connect(
connecting_region=regions["Quarry Entry"])
quarry_back_to_main = regions["Quarry Back"].connect(
regions["Quarry Back"].connect(
connecting_region=regions["Quarry"],
rule=lambda state: state.has(fire_wand, player) or has_sword(state, player))
regions["Quarry"].connect(
connecting_region=regions["Quarry Back"])
monastery_to_quarry_main = regions["Quarry Monastery Entry"].connect(
regions["Quarry Monastery Entry"].connect(
connecting_region=regions["Quarry"],
rule=lambda state: state.has(fire_wand, player) or has_sword(state, player))
regions["Quarry"].connect(
@@ -959,24 +869,18 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
rule=lambda state: has_ladder("Ladders in Lower Quarry", state, world)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
# nmg: bring a scav over, then ice grapple through the door, only with ER on to avoid soft lock
regions["Even Lower Quarry"].connect(
connecting_region=regions["Even Lower Quarry Isolated Chest"])
# you grappled down, might as well loot the rest too
lower_quarry_empty_to_combat = regions["Even Lower Quarry Isolated Chest"].connect(
connecting_region=regions["Even Lower Quarry"],
rule=lambda state: has_mask(state, world))
regions["Even Lower Quarry Isolated Chest"].connect(
connecting_region=regions["Lower Quarry Zig Door"],
rule=lambda state: state.has("Activate Quarry Fuse", player)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
# don't need the mask for this either, please don't complain about not needing a mask here, you know what you did
# nmg: use ice grapple to get from the beginning of Quarry to the door without really needing mask only with ER on
regions["Quarry"].connect(
connecting_region=regions["Even Lower Quarry Isolated Chest"],
connecting_region=regions["Lower Quarry Zig Door"],
rule=lambda state: has_ice_grapple_logic(True, IceGrappling.option_hard, state, world))
monastery_front_to_back = regions["Monastery Front"].connect(
regions["Monastery Front"].connect(
connecting_region=regions["Monastery Back"])
# laurels through the gate, no setup needed
regions["Monastery Back"].connect(
@@ -993,7 +897,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Rooted Ziggurat Upper Entry"].connect(
connecting_region=regions["Rooted Ziggurat Upper Front"])
zig_upper_front_back = regions["Rooted Ziggurat Upper Front"].connect(
regions["Rooted Ziggurat Upper Front"].connect(
connecting_region=regions["Rooted Ziggurat Upper Back"],
rule=lambda state: state.has(laurels, player) or has_sword(state, player))
regions["Rooted Ziggurat Upper Back"].connect(
@@ -1003,23 +907,13 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Rooted Ziggurat Middle Top"].connect(
connecting_region=regions["Rooted Ziggurat Middle Bottom"])
zig_low_entry_to_front = regions["Rooted Ziggurat Lower Entry"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
regions["Rooted Ziggurat Lower Front"].connect(
connecting_region=regions["Rooted Ziggurat Lower Entry"])
regions["Rooted Ziggurat Lower Front"].connect(
connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"])
zig_low_mid_to_front = regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
zig_low_mid_to_back = regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"],
rule=lambda state: state.has(laurels, player)
or (has_sword(state, player) and has_ability(prayer, state, world)))
# can ice grapple to the voidlings to get to the double admin fight, still need to pray at the fuse
zig_low_back_to_mid = regions["Rooted Ziggurat Lower Back"].connect(
connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"],
# nmg: can ice grapple on the voidlings to the double admin fight, still need to pray at the fuse
regions["Rooted Ziggurat Lower Back"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"],
rule=lambda state: (state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
and has_ability(prayer, state, world)
@@ -1031,10 +925,8 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Rooted Ziggurat Portal Room Entrance"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"])
# zig skip region only gets made if entrance rando and fewer shops are on
if options.entrance_rando and options.fixed_shop:
regions["Zig Skip Exit"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
regions["Zig Skip Exit"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
regions["Rooted Ziggurat Portal"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room"])
@@ -1060,6 +952,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
or state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_hard, state, world))
# a whole lot of stuff to basically say "you need to pray at the overworld fuse"
swamp_mid_to_cath = regions["Swamp Mid"].connect(
connecting_region=regions["Swamp to Cathedral Main Entrance Region"],
rule=lambda state: (has_ability(prayer, state, world)
@@ -1072,9 +965,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
"Ladder to Swamp",
"Ladders near Weathervane"}, player)
or (state.has("Ladder to Ruined Atoll", player)
and state.can_reach_region("Overworld Beach", player)))))
and (not options.combat_logic
or has_combat_reqs("Swamp", state, player)))
and state.can_reach_region("Overworld Beach", player))))))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
if options.ladder_storage >= LadderStorage.option_hard and options.shuffle_ladders:
@@ -1126,23 +1017,13 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
regions["Swamp Hero's Grave Region"].connect(
connecting_region=regions["Back of Swamp"])
cath_entry_to_elev = regions["Cathedral Entry"].connect(
regions["Cathedral"].connect(
connecting_region=regions["Cathedral to Gauntlet"],
rule=lambda state: (has_ability(prayer, state, world)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
or options.entrance_rando) # elevator is always there in ER
regions["Cathedral to Gauntlet"].connect(
connecting_region=regions["Cathedral Entry"])
cath_entry_to_main = regions["Cathedral Entry"].connect(
connecting_region=regions["Cathedral Main"])
regions["Cathedral Main"].connect(
connecting_region=regions["Cathedral Entry"])
cath_elev_to_main = regions["Cathedral to Gauntlet"].connect(
connecting_region=regions["Cathedral Main"])
regions["Cathedral Main"].connect(
connecting_region=regions["Cathedral to Gauntlet"])
connecting_region=regions["Cathedral"])
regions["Cathedral Gauntlet Checkpoint"].connect(
connecting_region=regions["Cathedral Gauntlet"])
@@ -1194,7 +1075,7 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
connecting_region=regions["Far Shore"])
# Misc
heir_fight = regions["Spirit Arena"].connect(
regions["Spirit Arena"].connect(
connecting_region=regions["Spirit Arena Victory"],
rule=lambda state: (state.has(gold_hexagon, player, world.options.hexagon_goal.value) if
world.options.hexagon_quest else
@@ -1338,192 +1219,6 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_
for region in ladder_regions.values():
world.multiworld.regions.append(region)
# for combat logic, easiest to replace or add to existing rules
if world.options.combat_logic >= CombatLogic.option_bosses_only:
set_rule(wg_to_after_gk,
lambda state: state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or has_combat_reqs("Garden Knight", state, player))
# laurels past, or ice grapple it off, or ice grapple to it and fight
set_rule(after_gk_to_wg,
lambda state: state.has(laurels, player)
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or (has_ice_grapple_logic(False, IceGrappling.option_easy, state, world)
and has_combat_reqs("Garden Knight", state, player)))
if not world.options.hexagon_quest:
add_rule(heir_fight,
lambda state: has_combat_reqs("The Heir", state, player))
if world.options.combat_logic == CombatLogic.option_on:
# these are redundant with combat logic off
regions["Fortress Grave Path Entry"].connect(
connecting_region=regions["Fortress Grave Path Dusty Entrance Region"],
rule=lambda state: state.has(laurels, player))
regions["Rooted Ziggurat Lower Entry"].connect(
connecting_region=regions["Rooted Ziggurat Lower Mid Checkpoint"],
rule=lambda state: state.has(laurels, player))
regions["Rooted Ziggurat Lower Mid Checkpoint"].connect(
connecting_region=regions["Rooted Ziggurat Lower Entry"],
rule=lambda state: state.has(laurels, player))
add_rule(ow_to_town_portal,
lambda state: has_combat_reqs("Before Well", state, player))
# need to fight through the rudelings and turret, or just laurels from near the windmill
set_rule(ow_to_well_entry,
lambda state: state.has(laurels, player)
or has_combat_reqs("East Forest", state, player))
set_rule(ow_tunnel_beach,
lambda state: has_combat_reqs("East Forest", state, player))
add_rule(atoll_statue,
lambda state: has_combat_reqs("Ruined Atoll", state, player))
set_rule(frogs_front_to_main,
lambda state: has_combat_reqs("Frog's Domain", state, player))
set_rule(btw_front_main,
lambda state: state.has(laurels, player) or has_combat_reqs("Beneath the Well", state, player))
set_rule(btw_back_main,
lambda state: has_ladder("Ladders in Well", state, world)
and (state.has(laurels, player) or has_combat_reqs("Beneath the Well", state, player)))
set_rule(well_boss_to_dt,
lambda state: has_combat_reqs("Beneath the Well", state, player)
or laurels_zip(state, world))
add_rule(dt_entry_to_upper,
lambda state: has_combat_reqs("Dark Tomb", state, player))
add_rule(dt_exit_to_main,
lambda state: has_combat_reqs("Dark Tomb", state, player))
set_rule(wg_before_to_after_terry,
lambda state: state.has_any({laurels, ice_dagger}, player)
or has_combat_reqs("West Garden", state, player))
set_rule(wg_after_to_before_terry,
lambda state: state.has_any({laurels, ice_dagger}, player)
or has_combat_reqs("West Garden", state, player))
# laurels through, probably to the checkpoint, or just fight
set_rule(wg_checkpoint_to_after_terry,
lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player))
set_rule(wg_checkpoint_to_before_boss,
lambda state: has_combat_reqs("West Garden", state, player))
add_rule(btv_front_to_main,
lambda state: has_combat_reqs("Beneath the Vault", state, player))
add_rule(btv_back_to_main,
lambda state: has_combat_reqs("Beneath the Vault", state, player))
add_rule(fort_upper_lower,
lambda state: state.has(ice_dagger, player)
or has_combat_reqs("Eastern Vault Fortress", state, player))
set_rule(fort_grave_entry_to_combat,
lambda state: has_combat_reqs("Eastern Vault Fortress", state, player))
set_rule(quarry_entry_to_main,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(quarry_back_to_main,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(monastery_to_quarry_main,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(monastery_front_to_back,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(lower_quarry_empty_to_combat,
lambda state: has_combat_reqs("Quarry", state, player))
set_rule(zig_upper_front_back,
lambda state: state.has(laurels, player)
or has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(zig_low_entry_to_front,
lambda state: has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(zig_low_mid_to_front,
lambda state: has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(zig_low_mid_to_back,
lambda state: state.has(laurels, player)
or (has_ability(prayer, state, world) and has_combat_reqs("Rooted Ziggurat", state, player)))
set_rule(zig_low_back_to_mid,
lambda state: (state.has(laurels, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world))
and has_ability(prayer, state, world)
and has_combat_reqs("Rooted Ziggurat", state, player))
# only activating the fuse requires combat logic
set_rule(cath_entry_to_elev,
lambda state: options.entrance_rando
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world)
or (has_ability(prayer, state, world) and has_combat_reqs("Cathedral", state, player)))
set_rule(cath_entry_to_main,
lambda state: has_combat_reqs("Cathedral", state, player))
set_rule(cath_elev_to_main,
lambda state: has_combat_reqs("Cathedral", state, player))
# for spots where you can go into and come out of an entrance to reset enemy aggro
if world.options.entrance_rando:
# for the chest outside of magic dagger house
dagger_entry_paired_name, dagger_entry_paired_region = (
get_paired_portal("Archipelagos Redux, archipelagos_house_"))
try:
dagger_entry_paired_entrance = world.get_entrance(dagger_entry_paired_name)
except KeyError:
# there is no paired entrance, so you must fight or dash past, which is done in the finally
pass
else:
set_rule(wg_checkpoint_to_dagger,
lambda state: dagger_entry_paired_entrance.can_reach(state))
world.multiworld.register_indirect_condition(region=regions["West Garden at Dagger House"],
entrance=dagger_entry_paired_entrance)
finally:
add_rule(wg_checkpoint_to_dagger,
lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player),
combine="or")
# zip past enemies in fortress grave path to enter the dusty entrance, then come back out
fort_dusty_paired_name, fort_dusty_paired_region = get_paired_portal("Fortress Reliquary, Dusty_")
try:
fort_dusty_paired_entrance = world.get_entrance(fort_dusty_paired_name)
except KeyError:
# there is no paired entrance, so you can't run past to deaggro
# the path to dusty can be done via combat, so no need to do anything here
pass
else:
# there is a paired entrance, so you can use that to deaggro enemies
regions["Fortress Grave Path Dusty Entrance Region"].connect(
connecting_region=regions["Fortress Grave Path by Grave"],
rule=lambda state: state.has(laurels, player) and fort_dusty_paired_entrance.can_reach(state))
world.multiworld.register_indirect_condition(region=regions["Fortress Grave Path by Grave"],
entrance=fort_dusty_paired_entrance)
# for activating the ladder switch to get from fortress east upper to lower
fort_east_upper_right_paired_name, fort_east_upper_right_paired_region = (
get_paired_portal("Fortress East, Fortress Courtyard_"))
try:
fort_east_upper_right_paired_entrance = (
world.get_entrance(fort_east_upper_right_paired_name))
except KeyError:
# no paired entrance, so you must fight, which is done in the finally
pass
else:
set_rule(fort_east_upper_lower,
lambda state: fort_east_upper_right_paired_entrance.can_reach(state))
world.multiworld.register_indirect_condition(region=regions["Fortress East Shortcut Lower"],
entrance=fort_east_upper_right_paired_entrance)
finally:
add_rule(fort_east_upper_lower,
lambda state: has_combat_reqs("Eastern Vault Fortress", state, player)
or has_ice_grapple_logic(True, IceGrappling.option_easy, state, world),
combine="or")
else:
# if combat logic is on and ER is off, we can make this entrance freely
regions["Fortress Grave Path Dusty Entrance Region"].connect(
connecting_region=regions["Fortress Grave Path by Grave"],
rule=lambda state: state.has(laurels, player))
else:
# if combat logic is off, we can make this entrance freely
regions["Fortress Grave Path Dusty Entrance Region"].connect(
connecting_region=regions["Fortress Grave Path by Grave"],
rule=lambda state: state.has(laurels, player))
def set_er_location_rules(world: "TunicWorld") -> None:
player = world.player
@@ -1620,11 +1315,6 @@ def set_er_location_rules(world: "TunicWorld") -> None:
set_rule(world.get_location("East Forest - Ice Rod Grapple Chest"), lambda state: (
state.has_all({grapple, ice_dagger, fire_wand}, player) and has_ability(icebolt, state, world)))
# Dark Tomb
# added to make combat logic smoother
set_rule(world.get_location("Dark Tomb - 2nd Laser Room"),
lambda state: has_lantern(state, world))
# West Garden
set_rule(world.get_location("West Garden - [North] Across From Page Pickup"),
lambda state: state.has(laurels, player))
@@ -1658,11 +1348,11 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# Library Lab
set_rule(world.get_location("Library Lab - Page 1"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 2"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 3"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
# Eastern Vault Fortress
set_rule(world.get_location("Fortress Arena - Hexagon Red"),
@@ -1671,11 +1361,11 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# gun isn't included since it can only break one leaf pile at a time, and we don't check how much mana you have
# but really, I expect the player to just throw a bomb at them if they don't have melee
set_rule(world.get_location("Fortress Leaf Piles - Secret Chest"),
lambda state: has_melee(state, player) or state.has(ice_dagger, player))
lambda state: has_stick(state, player) or state.has(ice_dagger, player))
# Beneath the Vault
set_rule(world.get_location("Beneath the Fortress - Bridge"),
lambda state: has_melee(state, player) or state.has_any({laurels, fire_wand}, player))
lambda state: has_stick(state, player) or state.has_any({laurels, fire_wand}, player))
# Quarry
set_rule(world.get_location("Quarry - [Central] Above Ladder Dash Chest"),
@@ -1731,9 +1421,9 @@ def set_er_location_rules(world: "TunicWorld") -> None:
# Events
set_rule(world.get_location("Eastern Bell"),
lambda state: (has_melee(state, player) or state.has(fire_wand, player)))
lambda state: (has_stick(state, player) or state.has(fire_wand, player)))
set_rule(world.get_location("Western Bell"),
lambda state: (has_melee(state, player) or state.has(fire_wand, player)))
lambda state: (has_stick(state, player) or state.has(fire_wand, player)))
set_rule(world.get_location("Furnace Fuse"),
lambda state: has_ability(prayer, state, world))
set_rule(world.get_location("South and West Fortress Exterior Fuses"),
@@ -1780,129 +1470,3 @@ def set_er_location_rules(world: "TunicWorld") -> None:
lambda state: has_sword(state, player))
set_rule(world.get_location("Shop - Coin 2"),
lambda state: has_sword(state, player))
def combat_logic_to_loc(loc_name: str, combat_req_area: str, set_instead: bool = False,
dagger: bool = False, laurel: bool = False) -> None:
# dagger means you can use magic dagger instead of combat for that check
# laurel means you can dodge the enemies freely with the laurels
if set_instead:
set_rule(world.get_location(loc_name),
lambda state: has_combat_reqs(combat_req_area, state, player)
or (dagger and state.has(ice_dagger, player))
or (laurel and state.has(laurels, player)))
else:
add_rule(world.get_location(loc_name),
lambda state: has_combat_reqs(combat_req_area, state, player)
or (dagger and state.has(ice_dagger, player))
or (laurel and state.has(laurels, player)))
if world.options.combat_logic >= CombatLogic.option_bosses_only:
# garden knight is in the regions part above
combat_logic_to_loc("Fortress Arena - Siege Engine/Vault Key Pickup", "Siege Engine", set_instead=True)
combat_logic_to_loc("Librarian - Hexagon Green", "The Librarian", set_instead=True)
set_rule(world.get_location("Librarian - Hexagon Green"),
rule=lambda state: has_combat_reqs("The Librarian", state, player)
and has_ladder("Ladders in Library", state, world))
combat_logic_to_loc("Rooted Ziggurat Lower - Hexagon Blue", "Boss Scavenger", set_instead=True)
if world.options.ice_grappling >= IceGrappling.option_medium:
add_rule(world.get_location("Rooted Ziggurat Lower - Hexagon Blue"),
lambda state: has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
combat_logic_to_loc("Cathedral Gauntlet - Gauntlet Reward", "Gauntlet", set_instead=True)
if world.options.combat_logic == CombatLogic.option_on:
combat_logic_to_loc("Overworld - [Northeast] Flowers Holy Cross", "Garden Knight")
combat_logic_to_loc("Overworld - [Northwest] Chest Near Quarry Gate", "Before Well", dagger=True)
combat_logic_to_loc("Overworld - [Northeast] Chest Above Patrol Cave", "Garden Knight", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret", "Overworld", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] West Beach Guarded By Turret 2", "Overworld")
combat_logic_to_loc("Overworld - [Southwest] Bombable Wall Near Fountain", "East Forest", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] Fountain Holy Cross", "East Forest", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] South Chest Near Guard", "East Forest", dagger=True)
combat_logic_to_loc("Overworld - [Southwest] Tunnel Guarded By Turret", "East Forest", dagger=True)
combat_logic_to_loc("Overworld - [Northwest] Chest Near Turret", "Before Well")
add_rule(world.get_location("Hourglass Cave - Hourglass Chest"),
lambda state: has_sword(state, player) and (state.has("Shield", player)
# kill the turrets through the wall with a longer sword
or state.has("Sword Upgrade", player, 3)))
add_rule(world.get_location("Hourglass Cave - Holy Cross Chest"),
lambda state: has_sword(state, player) and (state.has("Shield", player)
or state.has("Sword Upgrade", player, 3)))
# the first spider chest they literally do not attack you until you open the chest
# the second one, you can still just walk past them, but I guess /something/ would be wanted
combat_logic_to_loc("East Forest - Beneath Spider Chest", "East Forest", dagger=True, laurel=True)
combat_logic_to_loc("East Forest - Golden Obelisk Holy Cross", "East Forest", dagger=True)
combat_logic_to_loc("East Forest - Dancing Fox Spirit Holy Cross", "East Forest", dagger=True, laurel=True)
combat_logic_to_loc("East Forest - From Guardhouse 1 Chest", "East Forest", dagger=True, laurel=True)
combat_logic_to_loc("East Forest - Above Save Point", "East Forest", dagger=True)
combat_logic_to_loc("East Forest - Above Save Point Obscured", "East Forest", dagger=True)
combat_logic_to_loc("Forest Grave Path - Above Gate", "East Forest", dagger=True, laurel=True)
combat_logic_to_loc("Forest Grave Path - Obscured Chest", "East Forest", dagger=True, laurel=True)
# most of beneath the well is covered by the region access rule
combat_logic_to_loc("Beneath the Well - [Entryway] Chest", "Beneath the Well")
combat_logic_to_loc("Beneath the Well - [Entryway] Obscured Behind Waterfall", "Beneath the Well")
combat_logic_to_loc("Beneath the Well - [Back Corridor] Left Secret", "Beneath the Well")
combat_logic_to_loc("Beneath the Well - [Side Room] Chest By Phrends", "Overworld")
# laurels past the enemies, then use the wand or gun to take care of the fairies that chased you
add_rule(world.get_location("West Garden - [West Lowlands] Tree Holy Cross Chest"),
lambda state: state.has_any({fire_wand, "Gun"}, player))
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Faeries", "West Garden")
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Save Point", "West Garden")
combat_logic_to_loc("West Garden - [West Highlands] Upper Left Walkway", "West Garden")
# with combat logic on, I presume the player will want to be able to see to avoid the spiders
set_rule(world.get_location("Beneath the Fortress - Bridge"),
lambda state: has_lantern(state, world)
and (state.has_any({laurels, fire_wand, "Gun"}, player) or has_melee(state, player)))
combat_logic_to_loc("Eastern Vault Fortress - [West Wing] Candles Holy Cross", "Eastern Vault Fortress",
dagger=True)
# could just do the last two, but this outputs better in the spoiler log
# dagger is maybe viable here, but it's sketchy -- activate ladder switch, save to reset enemies, climb up
combat_logic_to_loc("Upper and Central Fortress Exterior Fuses", "Eastern Vault Fortress")
combat_logic_to_loc("Beneath the Vault Fuse", "Beneath the Vault")
combat_logic_to_loc("Eastern Vault West Fuses", "Eastern Vault Fortress")
# if you come in from the left, you only need to fight small crabs
add_rule(world.get_location("Ruined Atoll - [South] Near Birds"),
lambda state: has_melee(state, player) or state.has_any({laurels, "Gun"}, player))
# can get this one without fighting if you have laurels
add_rule(world.get_location("Frog's Domain - Above Vault"),
lambda state: state.has(laurels, player) or has_combat_reqs("Frog's Domain", state, player))
# with wand, you can get this chest. Non-ER, you need laurels to continue down. ER, you can just torch
set_rule(world.get_location("Rooted Ziggurat Upper - Near Bridge Switch"),
lambda state: (state.has(fire_wand, player)
and (state.has(laurels, player) or world.options.entrance_rando))
or has_combat_reqs("Rooted Ziggurat", state, player))
set_rule(world.get_location("Rooted Ziggurat Lower - After Guarded Fuse"),
lambda state: has_ability(prayer, state, world)
and has_combat_reqs("Rooted Ziggurat", state, player))
# replace the sword rule with this one
combat_logic_to_loc("Swamp - [South Graveyard] 4 Orange Skulls", "Swamp", set_instead=True)
combat_logic_to_loc("Swamp - [South Graveyard] Guarded By Big Skeleton", "Swamp", dagger=True)
# don't really agree with this one but eh
combat_logic_to_loc("Swamp - [South Graveyard] Above Big Skeleton", "Swamp", dagger=True, laurel=True)
# the tentacles deal with everything else reasonably, and you can hide on the island, so no rule for it
add_rule(world.get_location("Swamp - [South Graveyard] Obscured Beneath Telescope"),
lambda state: state.has(laurels, player) # can dash from swamp mid to here and grab it
or has_combat_reqs("Swamp", state, player))
add_rule(world.get_location("Swamp - [Central] South Secret Passage"),
lambda state: state.has(laurels, player) # can dash from swamp front to here and grab it
or has_combat_reqs("Swamp", state, player))
combat_logic_to_loc("Swamp - [South Graveyard] Upper Walkway On Pedestal", "Swamp")
combat_logic_to_loc("Swamp - [Central] Beneath Memorial", "Swamp")
combat_logic_to_loc("Swamp - [Central] Near Ramps Up", "Swamp")
combat_logic_to_loc("Swamp - [Upper Graveyard] Near Telescope", "Swamp")
combat_logic_to_loc("Swamp - [Upper Graveyard] Near Shield Fleemers", "Swamp")
combat_logic_to_loc("Swamp - [Upper Graveyard] Obscured Behind Hill", "Swamp")
# zip through the rubble to sneakily grab this chest, or just fight to it
add_rule(world.get_location("Cathedral - [1F] Near Spikes"),
lambda state: laurels_zip(state, world) or has_combat_reqs("Cathedral", state, player))

View File

@@ -22,19 +22,10 @@ class TunicERLocation(Location):
def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
regions: Dict[str, Region] = {}
for region_name, region_data in world.er_regions.items():
regions[region_name] = Region(region_name, world.player, world.multiworld)
if world.options.entrance_rando:
for region_name, region_data in world.er_regions.items():
# if fewer shops is off, zig skip is not made
if region_name == "Zig Skip Exit":
# need to check if there's a seed group for this first
if world.options.entrance_rando.value not in EntranceRando.options.values():
if not world.seed_groups[world.options.entrance_rando.value]["fixed_shop"]:
continue
elif not world.options.fixed_shop:
continue
regions[region_name] = Region(region_name, world.player, world.multiworld)
portal_pairs = pair_portals(world, regions)
# output the entrances to the spoiler log here for convenience
@@ -42,21 +33,16 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:
for portal1, portal2 in sorted_portal_pairs.items():
world.multiworld.spoiler.set_entrance(portal1, portal2, "both", world.player)
else:
for region_name, region_data in world.er_regions.items():
# filter out regions that are inaccessible in non-er
if region_name not in ["Zig Skip Exit", "Purgatory"]:
regions[region_name] = Region(region_name, world.player, world.multiworld)
portal_pairs = vanilla_portals(world, regions)
create_randomized_entrances(portal_pairs, regions)
set_er_region_rules(world, regions, portal_pairs)
for location_name, location_id in world.location_name_to_id.items():
region = regions[location_table[location_name].er_region]
location = TunicERLocation(world.player, location_name, location_id, region)
region.locations.append(location)
create_randomized_entrances(portal_pairs, regions)
for region in regions.values():
world.multiworld.regions.append(region)
@@ -84,7 +70,7 @@ tunic_events: Dict[str, str] = {
"Quarry Connector Fuse": "Quarry Connector",
"Quarry Fuse": "Quarry Entry",
"Ziggurat Fuse": "Rooted Ziggurat Lower Back",
"West Garden Fuse": "West Garden South Checkpoint",
"West Garden Fuse": "West Garden",
"Library Fuse": "Library Lab",
"Place Questagons": "Sealed Temple",
}
@@ -122,8 +108,7 @@ def create_shop_region(world: "TunicWorld", regions: Dict[str, Region]) -> None:
def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal, Portal]:
portal_pairs: Dict[Portal, Portal] = {}
# we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here
portal_map = [portal for portal in portal_mapping if portal.name not in
["Ziggurat Lower Falling Entrance", "Purgatory Bottom Exit", "Purgatory Top Exit"]]
portal_map = [portal for portal in portal_mapping if portal.name != "Ziggurat Lower Falling Entrance"]
while portal_map:
portal1 = portal_map[0]
@@ -136,6 +121,9 @@ def vanilla_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Por
destination="Previous Region", tag="_")
create_shop_region(world, regions)
elif portal2_sdt == "Purgatory, Purgatory_bottom":
portal2_sdt = "Purgatory, Purgatory_top"
for portal in portal_map:
if portal.scene_destination() == portal2_sdt:
portal2 = portal
@@ -426,7 +414,6 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
cr.add(portal.region)
if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels, logic_tricks):
continue
# if not waterfall_plando, then we just want to pair secret gathering place now
elif portal.region != "Secret Gathering Place":
continue
portal2 = portal

View File

@@ -1,5 +1,5 @@
from itertools import groupby
from typing import Dict, List, Set, NamedTuple, Optional
from typing import Dict, List, Set, NamedTuple
from BaseClasses import ItemClassification as IC
@@ -8,8 +8,6 @@ class TunicItemData(NamedTuple):
quantity_in_item_pool: int
item_id_offset: int
item_group: str = ""
# classification if combat logic is on
combat_ic: Optional[IC] = None
item_base_id = 509342400
@@ -29,7 +27,7 @@ item_table: Dict[str, TunicItemData] = {
"Lure x2": TunicItemData(IC.filler, 1, 11, "Consumables"),
"Pepper x2": TunicItemData(IC.filler, 4, 12, "Consumables"),
"Ivy x3": TunicItemData(IC.filler, 2, 13, "Consumables"),
"Effigy": TunicItemData(IC.useful, 12, 14, "Money", combat_ic=IC.progression),
"Effigy": TunicItemData(IC.useful, 12, 14, "Money"),
"HP Berry": TunicItemData(IC.filler, 2, 15, "Consumables"),
"HP Berry x2": TunicItemData(IC.filler, 4, 16, "Consumables"),
"HP Berry x3": TunicItemData(IC.filler, 2, 17, "Consumables"),
@@ -46,32 +44,32 @@ item_table: Dict[str, TunicItemData] = {
"Hero's Laurels": TunicItemData(IC.progression | IC.useful, 1, 28),
"Lantern": TunicItemData(IC.progression, 1, 29),
"Gun": TunicItemData(IC.progression | IC.useful, 1, 30, "Weapons"),
"Shield": TunicItemData(IC.useful, 1, 31, combat_ic=IC.progression | IC.useful),
"Shield": TunicItemData(IC.useful, 1, 31),
"Dath Stone": TunicItemData(IC.useful, 1, 32),
"Hourglass": TunicItemData(IC.useful, 1, 33),
"Old House Key": TunicItemData(IC.progression, 1, 34, "Keys"),
"Key": TunicItemData(IC.progression, 2, 35, "Keys"),
"Fortress Vault Key": TunicItemData(IC.progression, 1, 36, "Keys"),
"Flask Shard": TunicItemData(IC.useful, 12, 37, combat_ic=IC.progression),
"Potion Flask": TunicItemData(IC.useful, 5, 38, "Flask", combat_ic=IC.progression),
"Flask Shard": TunicItemData(IC.useful, 12, 37),
"Potion Flask": TunicItemData(IC.useful, 5, 38, "Flask"),
"Golden Coin": TunicItemData(IC.progression, 17, 39),
"Card Slot": TunicItemData(IC.useful, 4, 40),
"Red Questagon": TunicItemData(IC.progression_skip_balancing, 1, 41, "Hexagons"),
"Green Questagon": TunicItemData(IC.progression_skip_balancing, 1, 42, "Hexagons"),
"Blue Questagon": TunicItemData(IC.progression_skip_balancing, 1, 43, "Hexagons"),
"Gold Questagon": TunicItemData(IC.progression_skip_balancing, 0, 44, "Hexagons"),
"ATT Offering": TunicItemData(IC.useful, 4, 45, "Offerings", combat_ic=IC.progression),
"DEF Offering": TunicItemData(IC.useful, 4, 46, "Offerings", combat_ic=IC.progression),
"Potion Offering": TunicItemData(IC.useful, 3, 47, "Offerings", combat_ic=IC.progression),
"HP Offering": TunicItemData(IC.useful, 6, 48, "Offerings", combat_ic=IC.progression),
"MP Offering": TunicItemData(IC.useful, 3, 49, "Offerings", combat_ic=IC.progression),
"SP Offering": TunicItemData(IC.useful, 2, 50, "Offerings", combat_ic=IC.progression),
"Hero Relic - ATT": TunicItemData(IC.progression_skip_balancing, 1, 51, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - DEF": TunicItemData(IC.progression_skip_balancing, 1, 52, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - HP": TunicItemData(IC.progression_skip_balancing, 1, 53, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - MP": TunicItemData(IC.progression_skip_balancing, 1, 54, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - POTION": TunicItemData(IC.progression_skip_balancing, 1, 55, "Hero Relics", combat_ic=IC.progression),
"Hero Relic - SP": TunicItemData(IC.progression_skip_balancing, 1, 56, "Hero Relics", combat_ic=IC.progression),
"ATT Offering": TunicItemData(IC.useful, 4, 45, "Offerings"),
"DEF Offering": TunicItemData(IC.useful, 4, 46, "Offerings"),
"Potion Offering": TunicItemData(IC.useful, 3, 47, "Offerings"),
"HP Offering": TunicItemData(IC.useful, 6, 48, "Offerings"),
"MP Offering": TunicItemData(IC.useful, 3, 49, "Offerings"),
"SP Offering": TunicItemData(IC.useful, 2, 50, "Offerings"),
"Hero Relic - ATT": TunicItemData(IC.progression_skip_balancing, 1, 51, "Hero Relics"),
"Hero Relic - DEF": TunicItemData(IC.progression_skip_balancing, 1, 52, "Hero Relics"),
"Hero Relic - HP": TunicItemData(IC.progression_skip_balancing, 1, 53, "Hero Relics"),
"Hero Relic - MP": TunicItemData(IC.progression_skip_balancing, 1, 54, "Hero Relics"),
"Hero Relic - POTION": TunicItemData(IC.progression_skip_balancing, 1, 55, "Hero Relics"),
"Hero Relic - SP": TunicItemData(IC.progression_skip_balancing, 1, 56, "Hero Relics"),
"Orange Peril Ring": TunicItemData(IC.useful, 1, 57, "Cards"),
"Tincture": TunicItemData(IC.useful, 1, 58, "Cards"),
"Scavenger Mask": TunicItemData(IC.progression, 1, 59, "Cards"),
@@ -88,18 +86,18 @@ item_table: Dict[str, TunicItemData] = {
"Louder Echo": TunicItemData(IC.useful, 1, 70, "Cards"),
"Aura's Gem": TunicItemData(IC.useful, 1, 71, "Cards"),
"Bone Card": TunicItemData(IC.useful, 1, 72, "Cards"),
"Mr Mayor": TunicItemData(IC.useful, 1, 73, "Golden Treasures", combat_ic=IC.progression),
"Secret Legend": TunicItemData(IC.useful, 1, 74, "Golden Treasures", combat_ic=IC.progression),
"Sacred Geometry": TunicItemData(IC.useful, 1, 75, "Golden Treasures", combat_ic=IC.progression),
"Vintage": TunicItemData(IC.useful, 1, 76, "Golden Treasures", combat_ic=IC.progression),
"Just Some Pals": TunicItemData(IC.useful, 1, 77, "Golden Treasures", combat_ic=IC.progression),
"Regal Weasel": TunicItemData(IC.useful, 1, 78, "Golden Treasures", combat_ic=IC.progression),
"Spring Falls": TunicItemData(IC.useful, 1, 79, "Golden Treasures", combat_ic=IC.progression),
"Power Up": TunicItemData(IC.useful, 1, 80, "Golden Treasures", combat_ic=IC.progression),
"Back To Work": TunicItemData(IC.useful, 1, 81, "Golden Treasures", combat_ic=IC.progression),
"Phonomath": TunicItemData(IC.useful, 1, 82, "Golden Treasures", combat_ic=IC.progression),
"Dusty": TunicItemData(IC.useful, 1, 83, "Golden Treasures", combat_ic=IC.progression),
"Forever Friend": TunicItemData(IC.useful, 1, 84, "Golden Treasures", combat_ic=IC.progression),
"Mr Mayor": TunicItemData(IC.useful, 1, 73, "Golden Treasures"),
"Secret Legend": TunicItemData(IC.useful, 1, 74, "Golden Treasures"),
"Sacred Geometry": TunicItemData(IC.useful, 1, 75, "Golden Treasures"),
"Vintage": TunicItemData(IC.useful, 1, 76, "Golden Treasures"),
"Just Some Pals": TunicItemData(IC.useful, 1, 77, "Golden Treasures"),
"Regal Weasel": TunicItemData(IC.useful, 1, 78, "Golden Treasures"),
"Spring Falls": TunicItemData(IC.useful, 1, 79, "Golden Treasures"),
"Power Up": TunicItemData(IC.useful, 1, 80, "Golden Treasures"),
"Back To Work": TunicItemData(IC.useful, 1, 81, "Golden Treasures"),
"Phonomath": TunicItemData(IC.useful, 1, 82, "Golden Treasures"),
"Dusty": TunicItemData(IC.useful, 1, 83, "Golden Treasures"),
"Forever Friend": TunicItemData(IC.useful, 1, 84, "Golden Treasures"),
"Fool Trap": TunicItemData(IC.trap, 0, 85),
"Money x1": TunicItemData(IC.filler, 3, 86, "Money"),
"Money x10": TunicItemData(IC.filler, 1, 87, "Money"),
@@ -114,9 +112,9 @@ item_table: Dict[str, TunicItemData] = {
"Money x50": TunicItemData(IC.filler, 7, 96, "Money"),
"Money x64": TunicItemData(IC.filler, 1, 97, "Money"),
"Money x100": TunicItemData(IC.filler, 5, 98, "Money"),
"Money x128": TunicItemData(IC.useful, 3, 99, "Money", combat_ic=IC.progression),
"Money x200": TunicItemData(IC.useful, 1, 100, "Money", combat_ic=IC.progression),
"Money x255": TunicItemData(IC.useful, 1, 101, "Money", combat_ic=IC.progression),
"Money x128": TunicItemData(IC.useful, 3, 99, "Money"),
"Money x200": TunicItemData(IC.useful, 1, 100, "Money"),
"Money x255": TunicItemData(IC.useful, 1, 101, "Money"),
"Pages 0-1": TunicItemData(IC.useful, 1, 102, "Pages"),
"Pages 2-3": TunicItemData(IC.useful, 1, 103, "Pages"),
"Pages 4-5": TunicItemData(IC.useful, 1, 104, "Pages"),
@@ -208,10 +206,6 @@ slot_data_item_names = [
"Gold Questagon",
]
combat_items: List[str] = [name for name, data in item_table.items()
if data.combat_ic and IC.progression in data.combat_ic]
combat_items.extend(["Stick", "Sword", "Sword Upgrade", "Magic Wand", "Hero's Laurels"])
item_name_to_id: Dict[str, int] = {name: item_base_id + data.item_id_offset for name, data in item_table.items()}
filler_items: List[str] = [name for name, data in item_table.items() if data.classification == IC.filler]

View File

@@ -78,11 +78,9 @@ easy_ls: List[LadderInfo] = [
# West Garden
# exit after Garden Knight
LadderInfo("West Garden before Boss", "Archipelagos Redux, Overworld Redux_upper"),
LadderInfo("West Garden", "Archipelagos Redux, Overworld Redux_upper"),
# West Garden laurels exit
LadderInfo("West Garden after Terry", "Archipelagos Redux, Overworld Redux_lowest"),
# Magic dagger house, only relevant with combat logic on
LadderInfo("West Garden after Terry", "Archipelagos Redux, archipelagos_house_"),
LadderInfo("West Garden", "Archipelagos Redux, Overworld Redux_lowest"),
# Atoll, use the little ladder you fix at the beginning
LadderInfo("Ruined Atoll", "Atoll Redux, Overworld Redux_lower"),
@@ -161,8 +159,7 @@ medium_ls: List[LadderInfo] = [
LadderInfo("Quarry Back", "Quarry Redux, Monastery_back"),
LadderInfo("Rooted Ziggurat Lower Back", "ziggurat2020_3, ziggurat2020_2_"),
LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Entry", dest_is_region=True),
LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Mid Checkpoint", dest_is_region=True),
LadderInfo("Rooted Ziggurat Lower Back", "Rooted Ziggurat Lower Front", dest_is_region=True),
# Swamp to Overworld upper
LadderInfo("Swamp Mid", "Swamp Redux 2, Overworld Redux_wall", "Ladders in Swamp"),
@@ -175,9 +172,9 @@ hard_ls: List[LadderInfo] = [
LadderInfo("Beneath the Well Front", "Sewer, Overworld Redux_west_aqueduct", "Ladders in Well"),
LadderInfo("Beneath the Well Front", "Beneath the Well Back", "Ladders in Well", dest_is_region=True),
# go through the hexagon engraving above the vault door
LadderInfo("Frog's Domain Front", "frog cave main, Frog Stairs_Exit", "Ladders to Frog's Domain"),
LadderInfo("Frog's Domain", "frog cave main, Frog Stairs_Exit", "Ladders to Frog's Domain"),
# the turret at the end here is not affected by enemy rando
LadderInfo("Frog's Domain Front", "Frog's Domain Back", "Ladders to Frog's Domain", dest_is_region=True),
LadderInfo("Frog's Domain", "Frog's Domain Back", "Ladders to Frog's Domain", dest_is_region=True),
# todo: see if we can use that new laurels strat here
# LadderInfo("Rooted Ziggurat Lower Back", "ziggurat2020_3, ziggurat2020_FTRoom_"),
# go behind the cathedral to reach the door, pretty easily doable

View File

@@ -25,23 +25,23 @@ location_table: Dict[str, TunicLocationData] = {
"Beneath the Well - [Side Room] Chest By Phrends": TunicLocationData("Beneath the Well", "Beneath the Well Back"),
"Beneath the Well - [Second Room] Page": TunicLocationData("Beneath the Well", "Beneath the Well Main"),
"Dark Tomb Checkpoint - [Passage To Dark Tomb] Page Pickup": TunicLocationData("Overworld", "Dark Tomb Checkpoint"),
"Cathedral - [1F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [1F] Near Spikes": TunicLocationData("Cathedral", "Cathedral Entry"), # entry because special rules
"Cathedral - [2F] Bird Room": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [2F] Entryway Upper Walkway": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [1F] Library": TunicLocationData("Cathedral", "Cathedral Entry"),
"Cathedral - [2F] Library": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [2F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [2F] Bird Room Secret": TunicLocationData("Cathedral", "Cathedral Main"),
"Cathedral - [1F] Library Secret": TunicLocationData("Cathedral", "Cathedral Entry"),
"Cathedral - [1F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [1F] Near Spikes": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Bird Room": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Entryway Upper Walkway": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [1F] Library": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Library": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Guarded By Lasers": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [2F] Bird Room Secret": TunicLocationData("Cathedral", "Cathedral"),
"Cathedral - [1F] Library Secret": TunicLocationData("Cathedral", "Cathedral"),
"Dark Tomb - Spike Maze Near Exit": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - 2nd Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Dark Exit"),
"Dark Tomb - 2nd Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - 1st Laser Room": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - Spike Maze Upper Walkway": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - Skulls Chest": TunicLocationData("Dark Tomb", "Dark Tomb Upper"),
"Dark Tomb - Spike Maze Near Stairs": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Dark Tomb - 1st Laser Room Obscured": TunicLocationData("Dark Tomb", "Dark Tomb Main"),
"Guardhouse 2 - Upper Floor": TunicLocationData("East Forest", "Guard House 2 Upper after bushes"),
"Guardhouse 2 - Upper Floor": TunicLocationData("East Forest", "Guard House 2 Upper"),
"Guardhouse 2 - Bottom Floor Secret": TunicLocationData("East Forest", "Guard House 2 Lower"),
"Guardhouse 1 - Upper Floor Obscured": TunicLocationData("East Forest", "Guard House 1 East"),
"Guardhouse 1 - Upper Floor": TunicLocationData("East Forest", "Guard House 1 East"),
@@ -81,25 +81,25 @@ location_table: Dict[str, TunicLocationData] = {
"Eastern Vault Fortress - [East Wing] Bombable Wall": TunicLocationData("Eastern Vault Fortress", "Eastern Vault Fortress"),
"Eastern Vault Fortress - [West Wing] Page Pickup": TunicLocationData("Eastern Vault Fortress", "Eastern Vault Fortress"),
"Fortress Grave Path - Upper Walkway": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path Upper"),
"Fortress Grave Path - Chest Right of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path by Grave"),
"Fortress Grave Path - Obscured Chest Left of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path by Grave"),
"Fortress Grave Path - Chest Right of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path"),
"Fortress Grave Path - Obscured Chest Left of Grave": TunicLocationData("Eastern Vault Fortress", "Fortress Grave Path"),
"Hero's Grave - Flowers Relic": TunicLocationData("Eastern Vault Fortress", "Hero Relic - Fortress"),
"Beneath the Fortress - Bridge": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Cell Chest 1": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Obscured Behind Waterfall": TunicLocationData("Beneath the Vault", "Beneath the Vault Main"),
"Beneath the Fortress - Back Room Chest": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Cell Chest 2": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Frog's Domain - Near Vault": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Slorm Room": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Near Vault": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Slorm Room": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Escape Chest": TunicLocationData("Frog's Domain", "Frog's Domain Back"),
"Frog's Domain - Grapple Above Hot Tub": TunicLocationData("Frog's Domain", "Frog's Domain Front"),
"Frog's Domain - Above Vault": TunicLocationData("Frog's Domain", "Frog's Domain Front"),
"Frog's Domain - Main Room Top Floor": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Main Room Bottom Floor": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Side Room Secret Passage": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Side Room Chest": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Side Room Grapple Secret": TunicLocationData("Frog's Domain", "Frog's Domain Front"),
"Frog's Domain - Magic Orb Pickup": TunicLocationData("Frog's Domain", "Frog's Domain Main"),
"Frog's Domain - Grapple Above Hot Tub": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Above Vault": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Main Room Top Floor": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Main Room Bottom Floor": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Side Room Secret Passage": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Side Room Chest": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Side Room Grapple Secret": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Frog's Domain - Magic Orb Pickup": TunicLocationData("Frog's Domain", "Frog's Domain"),
"Librarian - Hexagon Green": TunicLocationData("Library", "Library Arena", location_group="Bosses"),
"Library Hall - Holy Cross Chest": TunicLocationData("Library", "Library Hall", location_group="Holy Cross"),
"Library Lab - Chest By Shrine 2": TunicLocationData("Library", "Library Lab"),
@@ -131,7 +131,7 @@ location_table: Dict[str, TunicLocationData] = {
"Overworld - [Southwest] West Beach Guarded By Turret": TunicLocationData("Overworld", "Overworld Beach"),
"Overworld - [Southwest] Chest Guarded By Turret": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Shadowy Corner Chest": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Southwest] Obscured In Tunnel To Beach": TunicLocationData("Overworld", "Overworld Tunnel to Beach"),
"Overworld - [Southwest] Obscured In Tunnel To Beach": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Southwest] Grapple Chest Over Walkway": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Chest Beneath Quarry Gate": TunicLocationData("Overworld", "Overworld after Envoy"),
"Overworld - [Southeast] Chest Near Swamp": TunicLocationData("Overworld", "Overworld Swamp Lower Entry"),
@@ -158,7 +158,7 @@ location_table: Dict[str, TunicLocationData] = {
"Overworld - [Northwest] Page on Pillar by Dark Tomb": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Fire Wand Pickup": TunicLocationData("Overworld", "Upper Overworld"),
"Overworld - [West] Page On Teleporter": TunicLocationData("Overworld", "Overworld"),
"Overworld - [Northwest] Page By Well": TunicLocationData("Overworld", "Overworld Well Entry Area"),
"Overworld - [Northwest] Page By Well": TunicLocationData("Overworld", "Overworld"),
"Patrol Cave - Normal Chest": TunicLocationData("Overworld", "Patrol Cave"),
"Ruined Shop - Chest 1": TunicLocationData("Overworld", "Ruined Shop"),
"Ruined Shop - Chest 2": TunicLocationData("Overworld", "Ruined Shop"),
@@ -233,17 +233,17 @@ location_table: Dict[str, TunicLocationData] = {
"Quarry - [Lowlands] Upper Walkway": TunicLocationData("Lower Quarry", "Even Lower Quarry"),
"Quarry - [West] Lower Area Below Bridge": TunicLocationData("Lower Quarry", "Lower Quarry"),
"Quarry - [West] Lower Area Isolated Chest": TunicLocationData("Lower Quarry", "Lower Quarry"),
"Quarry - [Lowlands] Near Elevator": TunicLocationData("Lower Quarry", "Even Lower Quarry Isolated Chest"),
"Quarry - [Lowlands] Near Elevator": TunicLocationData("Lower Quarry", "Even Lower Quarry"),
"Quarry - [West] Lower Area After Bridge": TunicLocationData("Lower Quarry", "Lower Quarry"),
"Rooted Ziggurat Upper - Near Bridge Switch": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Upper Front"),
"Rooted Ziggurat Upper - Beneath Bridge To Administrator": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Upper Back"),
"Rooted Ziggurat Tower - Inside Tower": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Middle Top"),
"Rooted Ziggurat Lower - Near Corpses": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Entry"),
"Rooted Ziggurat Lower - Spider Ambush": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Entry"),
"Rooted Ziggurat Lower - Left Of Checkpoint Before Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - After Guarded Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - Near Corpses": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Spider Ambush": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Left Of Checkpoint Before Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - After Guarded Fuse": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Guarded By Double Turrets": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - After 2nd Double Turret Chest": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Mid Checkpoint"),
"Rooted Ziggurat Lower - After 2nd Double Turret Chest": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Guarded By Double Turrets 2": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Front"),
"Rooted Ziggurat Lower - Hexagon Blue": TunicLocationData("Rooted Ziggurat", "Rooted Ziggurat Lower Back", location_group="Bosses"),
"Ruined Atoll - [West] Near Kevin Block": TunicLocationData("Ruined Atoll", "Ruined Atoll"),
@@ -290,26 +290,26 @@ location_table: Dict[str, TunicLocationData] = {
"Hero's Grave - Feathers Relic": TunicLocationData("Swamp", "Hero Relic - Swamp"),
"West Furnace - Chest": TunicLocationData("West Garden", "Furnace Walking Path"),
"Overworld - [West] Near West Garden Entrance": TunicLocationData("West Garden", "Overworld to West Garden from Furnace"),
"West Garden - [Central Highlands] Holy Cross (Blue Lines)": TunicLocationData("West Garden", "West Garden before Boss", location_group="Holy Cross"),
"West Garden - [West Lowlands] Tree Holy Cross Chest": TunicLocationData("West Garden", "West Garden after Terry", location_group="Holy Cross"),
"West Garden - [Southeast Lowlands] Outside Cave": TunicLocationData("West Garden", "West Garden at Dagger House"),
"West Garden - [Central Lowlands] Chest Beneath Faeries": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [North] Behind Holy Cross Door": TunicLocationData("West Garden", "West Garden before Terry", location_group="Holy Cross"),
"West Garden - [Central Highlands] Top of Ladder Before Boss": TunicLocationData("West Garden", "West Garden before Boss"),
"West Garden - [Central Lowlands] Passage Beneath Bridge": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [North] Across From Page Pickup": TunicLocationData("West Garden", "West Garden before Terry"),
"West Garden - [Central Lowlands] Below Left Walkway": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [West] In Flooded Walkway": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [West] Past Flooded Walkway": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [North] Obscured Beneath Hero's Memorial": TunicLocationData("West Garden", "West Garden before Terry"),
"West Garden - [Central Lowlands] Chest Near Shortcut Bridge": TunicLocationData("West Garden", "West Garden after Terry"),
"West Garden - [West Highlands] Upper Left Walkway": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [Central Lowlands] Chest Beneath Save Point": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [Central Highlands] Behind Guard Captain": TunicLocationData("West Garden", "West Garden before Boss"),
"West Garden - [Central Highlands] Holy Cross (Blue Lines)": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"),
"West Garden - [West Lowlands] Tree Holy Cross Chest": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"),
"West Garden - [Southeast Lowlands] Outside Cave": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Chest Beneath Faeries": TunicLocationData("West Garden", "West Garden"),
"West Garden - [North] Behind Holy Cross Door": TunicLocationData("West Garden", "West Garden", location_group="Holy Cross"),
"West Garden - [Central Highlands] Top of Ladder Before Boss": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Passage Beneath Bridge": TunicLocationData("West Garden", "West Garden"),
"West Garden - [North] Across From Page Pickup": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Below Left Walkway": TunicLocationData("West Garden", "West Garden"),
"West Garden - [West] In Flooded Walkway": TunicLocationData("West Garden", "West Garden"),
"West Garden - [West] Past Flooded Walkway": TunicLocationData("West Garden", "West Garden"),
"West Garden - [North] Obscured Beneath Hero's Memorial": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Chest Near Shortcut Bridge": TunicLocationData("West Garden", "West Garden"),
"West Garden - [West Highlands] Upper Left Walkway": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Lowlands] Chest Beneath Save Point": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Highlands] Behind Guard Captain": TunicLocationData("West Garden", "West Garden"),
"West Garden - [Central Highlands] After Garden Knight": TunicLocationData("Overworld", "West Garden after Boss", location_group="Bosses"),
"West Garden - [South Highlands] Secret Chest Beneath Fuse": TunicLocationData("West Garden", "West Garden South Checkpoint"),
"West Garden - [South Highlands] Secret Chest Beneath Fuse": TunicLocationData("West Garden", "West Garden"),
"West Garden - [East Lowlands] Page Behind Ice Dagger House": TunicLocationData("West Garden", "West Garden Portal Item"),
"West Garden - [North] Page Pickup": TunicLocationData("West Garden", "West Garden before Terry"),
"West Garden - [North] Page Pickup": TunicLocationData("West Garden", "West Garden"),
"West Garden House - [Southeast Lowlands] Ice Dagger Pickup": TunicLocationData("West Garden", "Magic Dagger House"),
"Hero's Grave - Effigy Relic": TunicLocationData("West Garden", "Hero Relic - West Garden"),
}

View File

@@ -168,22 +168,6 @@ class TunicPlandoConnections(PlandoConnections):
duplicate_exits = True
class CombatLogic(Choice):
"""
If enabled, the player will logically require a combination of stat upgrade items and equipment to get some checks or navigate to some areas, with a goal of matching the vanilla combat difficulty.
The player may still be expected to run past enemies, reset aggro (by using a checkpoint or doing a scene transition), or find sneaky paths to checks.
This option marks many more items as progression and may force weapons much earlier than normal.
Bosses Only makes it so that additional combat logic is only added to the boss fights and the Gauntlet.
If disabled, the standard, looser logic is used. The standard logic does not include stat upgrades, just minimal weapon requirements, such as requiring a Sword or Magic Wand for Quarry, or not requiring a weapon for Swamp.
"""
internal_name = "combat_logic"
display_name = "More Combat Logic"
option_off = 0
option_bosses_only = 1
option_on = 2
default = 0
class LaurelsZips(Toggle):
"""
Choose whether to include using the Hero's Laurels to zip through gates, doors, and tricky spots.
@@ -275,7 +259,6 @@ class TunicOptions(PerGameCommonOptions):
hexagon_goal: HexagonGoal
extra_hexagon_percentage: ExtraHexagonPercentage
laurels_location: LaurelsLocation
combat_logic: CombatLogic
lanternless: Lanternless
maskless: Maskless
laurels_zips: LaurelsZips
@@ -289,7 +272,6 @@ class TunicOptions(PerGameCommonOptions):
tunic_option_groups = [
OptionGroup("Logic Options", [
CombatLogic,
Lanternless,
Maskless,
LaurelsZips,

View File

@@ -56,8 +56,9 @@ def has_ability(ability: str, state: CollectionState, world: "TunicWorld") -> bo
# a check to see if you can whack things in melee at all
def has_melee(state: CollectionState, player: int) -> bool:
return state.has_any({"Stick", "Sword", "Sword Upgrade"}, player)
def has_stick(state: CollectionState, player: int) -> bool:
return (state.has("Stick", player) or state.has("Sword Upgrade", player, 1)
or state.has("Sword", player))
def has_sword(state: CollectionState, player: int) -> bool:
@@ -82,7 +83,7 @@ def can_ladder_storage(state: CollectionState, world: "TunicWorld") -> bool:
return False
if world.options.ladder_storage_without_items:
return True
return has_melee(state, world.player) or state.has_any((grapple, shield), world.player)
return has_stick(state, world.player) or state.has_any((grapple, shield), world.player)
def has_mask(state: CollectionState, world: "TunicWorld") -> bool:
@@ -100,7 +101,7 @@ def set_region_rules(world: "TunicWorld") -> None:
world.get_entrance("Overworld -> Overworld Holy Cross").access_rule = \
lambda state: has_ability(holy_cross, state, world)
world.get_entrance("Overworld -> Beneath the Well").access_rule = \
lambda state: has_melee(state, player) or state.has(fire_wand, player)
lambda state: has_stick(state, player) or state.has(fire_wand, player)
world.get_entrance("Overworld -> Dark Tomb").access_rule = \
lambda state: has_lantern(state, world)
# laurels in, ladder storage in through the furnace, or ice grapple down the belltower
@@ -116,7 +117,7 @@ def set_region_rules(world: "TunicWorld") -> None:
world.get_entrance("Overworld -> Beneath the Vault").access_rule = \
lambda state: (has_lantern(state, world) and has_ability(prayer, state, world)
# there's some boxes in the way
and (has_melee(state, player) or state.has_any((gun, grapple, fire_wand), player)))
and (has_stick(state, player) or state.has_any((gun, grapple, fire_wand), player)))
world.get_entrance("Ruined Atoll -> Library").access_rule = \
lambda state: state.has_any({grapple, laurels}, player) and has_ability(prayer, state, world)
world.get_entrance("Overworld -> Quarry").access_rule = \
@@ -236,7 +237,7 @@ def set_location_rules(world: "TunicWorld") -> None:
or (has_lantern(state, world) and (has_sword(state, player) or state.has(fire_wand, player)))
or has_ice_grapple_logic(False, IceGrappling.option_medium, state, world))
set_rule(world.get_location("West Furnace - Lantern Pickup"),
lambda state: has_melee(state, player) or state.has_any({fire_wand, laurels}, player))
lambda state: has_stick(state, player) or state.has_any({fire_wand, laurels}, player))
set_rule(world.get_location("Secret Gathering Place - 10 Fairy Reward"),
lambda state: state.has(fairies, player, 10))
@@ -300,18 +301,18 @@ def set_location_rules(world: "TunicWorld") -> None:
# Library Lab
set_rule(world.get_location("Library Lab - Page 1"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 2"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
set_rule(world.get_location("Library Lab - Page 3"),
lambda state: has_melee(state, player) or state.has_any((fire_wand, gun), player))
lambda state: has_stick(state, player) or state.has_any((fire_wand, gun), player))
# Eastern Vault Fortress
# yes, you can clear the leaves with dagger
# gun isn't included since it can only break one leaf pile at a time, and we don't check how much mana you have
# but really, I expect the player to just throw a bomb at them if they don't have melee
set_rule(world.get_location("Fortress Leaf Piles - Secret Chest"),
lambda state: state.has(laurels, player) and (has_melee(state, player) or state.has(ice_dagger, player)))
lambda state: state.has(laurels, player) and (has_stick(state, player) or state.has(ice_dagger, player)))
set_rule(world.get_location("Fortress Arena - Siege Engine/Vault Key Pickup"),
lambda state: has_sword(state, player)
and (has_ability(prayer, state, world)
@@ -323,9 +324,9 @@ def set_location_rules(world: "TunicWorld") -> None:
# Beneath the Vault
set_rule(world.get_location("Beneath the Fortress - Bridge"),
lambda state: has_melee(state, player) or state.has_any({laurels, fire_wand}, player))
lambda state: has_stick(state, player) or state.has_any({laurels, fire_wand}, player))
set_rule(world.get_location("Beneath the Fortress - Obscured Behind Waterfall"),
lambda state: has_melee(state, player) and has_lantern(state, world))
lambda state: has_stick(state, player) and has_lantern(state, world))
# Quarry
set_rule(world.get_location("Quarry - [Central] Above Ladder Dash Chest"),

View File

@@ -3,8 +3,6 @@ from .. import options
class TestAccess(TunicTestBase):
options = {options.CombatLogic.internal_name: options.CombatLogic.option_off}
# test whether you can get into the temple without laurels
def test_temple_access(self) -> None:
self.collect_all_but(["Hero's Laurels", "Lantern"])
@@ -63,9 +61,7 @@ class TestNormalGoal(TunicTestBase):
class TestER(TunicTestBase):
options = {options.EntranceRando.internal_name: options.EntranceRando.option_yes,
options.AbilityShuffling.internal_name: options.AbilityShuffling.option_true,
options.HexagonQuest.internal_name: options.HexagonQuest.option_false,
options.CombatLogic.internal_name: options.CombatLogic.option_off,
options.FixedShop.internal_name: options.FixedShop.option_true}
options.HexagonQuest.internal_name: options.HexagonQuest.option_false}
def test_overworld_hc_chest(self) -> None:
# test to see that static connections are working properly -- this chest requires holy cross and is in Overworld

View File

@@ -84,8 +84,7 @@ class WitnessWorld(World):
"victory_location": int(self.player_logic.VICTORY_LOCATION, 16),
"panelhex_to_id": self.player_locations.CHECK_PANELHEX_TO_ID,
"item_id_to_door_hexes": static_witness_items.get_item_to_door_mappings(),
"door_items_in_the_pool": self.player_items.get_door_item_ids_in_pool(),
"doors_that_shouldnt_be_locked": [int(h, 16) for h in self.player_logic.FORBIDDEN_DOORS],
"door_hexes_in_the_pool": self.player_items.get_door_ids_in_pool(),
"symbols_not_in_the_game": self.player_items.get_symbol_ids_not_in_pool(),
"disabled_entities": [int(h, 16) for h in self.player_logic.COMPLETELY_DISABLED_ENTITIES],
"hunt_entities": [int(h, 16) for h in self.player_logic.HUNT_ENTITIES],
@@ -151,8 +150,7 @@ class WitnessWorld(World):
)
self.player_regions: WitnessPlayerRegions = WitnessPlayerRegions(self.player_locations, self)
self.log_ids_to_hints: Dict[int, CompactHintData] = {}
self.laser_ids_to_hints: Dict[int, CompactHintData] = {}
self.log_ids_to_hints = {}
self.determine_sufficient_progression()
@@ -327,6 +325,9 @@ class WitnessWorld(World):
self.options.local_items.value.add(item_name)
def fill_slot_data(self) -> Dict[str, Any]:
self.log_ids_to_hints: Dict[int, CompactHintData] = {}
self.laser_ids_to_hints: Dict[int, CompactHintData] = {}
already_hinted_locations = set()
# Laser hints

View File

@@ -1,5 +1,5 @@
from collections import defaultdict
from logging import debug, warning
from logging import debug
from pprint import pformat
from typing import TYPE_CHECKING, Dict, List, Set, Tuple
@@ -48,8 +48,6 @@ class EntityHuntPicker:
self.PRE_PICKED_HUNT_ENTITIES = pre_picked_entities.copy()
self.HUNT_ENTITIES: Set[str] = set()
self._add_plandoed_hunt_panels_to_pre_picked()
self.ALL_ELIGIBLE_ENTITIES, self.ELIGIBLE_ENTITIES_PER_AREA = self._get_eligible_panels()
def pick_panel_hunt_panels(self, total_amount: int) -> Set[str]:
@@ -71,51 +69,24 @@ class EntityHuntPicker:
return self.HUNT_ENTITIES
def _entity_is_eligible(self, panel_hex: str, plando: bool = False) -> bool:
def _entity_is_eligible(self, panel_hex: str) -> bool:
"""
Determine whether an entity is eligible for entity hunt based on player options.
"""
panel_obj = static_witness_logic.ENTITIES_BY_HEX[panel_hex]
if not self.player_logic.solvability_guaranteed(panel_hex) or panel_hex in self.player_logic.EXCLUDED_ENTITIES:
if plando:
warning(f"Panel {panel_obj['checkName']} is disabled / excluded and thus not eligible for panel hunt.")
return False
return plando or not (
# Due to an edge case, Discards have to be on in disable_non_randomized even if Discard Shuffle is off.
# However, I don't think they should be hunt panels in this case.
self.player_options.disable_non_randomized_puzzles
and not self.player_options.shuffle_discarded_panels
and panel_obj["locationType"] == "Discard"
return (
self.player_logic.solvability_guaranteed(panel_hex)
and panel_hex not in self.player_logic.EXCLUDED_ENTITIES
and not (
# Due to an edge case, Discards have to be on in disable_non_randomized even if Discard Shuffle is off.
# However, I don't think they should be hunt panels in this case.
self.player_options.disable_non_randomized_puzzles
and not self.player_options.shuffle_discarded_panels
and panel_obj["locationType"] == "Discard"
)
)
def _add_plandoed_hunt_panels_to_pre_picked(self) -> None:
"""
Add panels the player explicitly specified to be included in panel hunt to the pre picked hunt panels.
Output a warning if a panel could not be added for some reason.
"""
# Plandoed hunt panels should be in random order, but deterministic by seed, so we sort, then shuffle
panels_to_plando = sorted(self.player_options.panel_hunt_plando.value)
self.random.shuffle(panels_to_plando)
for location_name in panels_to_plando:
entity_hex = static_witness_logic.ENTITIES_BY_NAME[location_name]["entity_hex"]
if entity_hex in self.PRE_PICKED_HUNT_ENTITIES:
continue
if self._entity_is_eligible(entity_hex, plando=True):
if len(self.PRE_PICKED_HUNT_ENTITIES) == self.player_options.panel_hunt_total:
warning(
f"Panel {location_name} could not be plandoed as a hunt panel for {self.player_name}'s world, "
f"because it would exceed their panel hunt total."
)
continue
self.PRE_PICKED_HUNT_ENTITIES.add(entity_hex)
def _get_eligible_panels(self) -> Tuple[List[str], Dict[str, Set[str]]]:
"""
There are some entities that are not allowed for panel hunt for various technical of gameplay reasons.
@@ -244,10 +215,6 @@ class EntityHuntPicker:
if good_entity in self.HUNT_ENTITIES or good_entity not in self.ALL_ELIGIBLE_ENTITIES:
continue
# ... and it's not a forced pick that should stay the same ...
if bad_entitiy in self.PRE_PICKED_HUNT_ENTITIES:
continue
# ... replace the bad entity with the good entity.
self.HUNT_ENTITIES.remove(bad_entitiy)
self.HUNT_ENTITIES.add(good_entity)

View File

@@ -5,7 +5,6 @@ from schema import And, Schema
from Options import (
Choice,
DefaultOnToggle,
LocationSet,
OptionDict,
OptionError,
OptionGroup,
@@ -18,7 +17,6 @@ from Options import (
from .data import static_logic as static_witness_logic
from .data.item_definition_classes import ItemCategory, WeightedItemDefinition
from .entity_hunt import ALL_HUNTABLE_PANELS
class DisableNonRandomizedPuzzles(Toggle):
@@ -270,16 +268,6 @@ class PanelHuntDiscourageSameAreaFactor(Range):
default = 40
class PanelHuntPlando(LocationSet):
"""
Specify specific hunt panels you want for your panel hunt game.
"""
display_name = "Panel Hunt Plando"
valid_keys = [static_witness_logic.ENTITIES_BY_HEX[panel_hex]["checkName"] for panel_hex in ALL_HUNTABLE_PANELS]
class PuzzleRandomization(Choice):
"""
Puzzles in this randomizer are randomly generated. This option changes the difficulty/types of puzzles.
@@ -489,7 +477,6 @@ class TheWitnessOptions(PerGameCommonOptions):
panel_hunt_required_percentage: PanelHuntRequiredPercentage
panel_hunt_postgame: PanelHuntPostgame
panel_hunt_discourage_same_area_factor: PanelHuntDiscourageSameAreaFactor
panel_hunt_plando: PanelHuntPlando
early_caves: EarlyCaves
early_symbol_item: EarlySymbolItem
elevators_come_to_you: ElevatorsComeToYou
@@ -518,7 +505,6 @@ witness_option_groups = [
PanelHuntTotal,
PanelHuntPostgame,
PanelHuntDiscourageSameAreaFactor,
PanelHuntPlando,
], start_collapsed=True),
OptionGroup("Locations", [
ShuffleDiscardedPanels,

View File

@@ -222,15 +222,20 @@ class WitnessPlayerItems:
# Sort the output for consistency across versions if the implementation changes but the logic does not.
return sorted(output)
def get_door_item_ids_in_pool(self) -> List[int]:
def get_door_ids_in_pool(self) -> List[int]:
"""
Returns the ids of all door items that exist in the pool.
Returns the total set of all door IDs that are controlled by items in the pool.
"""
output: List[int] = []
return [
cast_not_none(item_data.ap_code) for item_data in self.item_data.values()
if isinstance(item_data.definition, DoorItemDefinition)
]
for item_name, item_data in self.item_data.items():
if not isinstance(item_data.definition, DoorItemDefinition):
continue
output += [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes
if hex_string not in self._logic.FORBIDDEN_DOORS]
return output
def get_symbol_ids_not_in_pool(self) -> List[int]:
"""
@@ -252,3 +257,5 @@ class WitnessPlayerItems:
output[cast_not_none(item.ap_code)] = [cast_not_none(static_witness_items.ITEM_DATA[child_item].ap_code)
for child_item in item.definition.child_item_names]
return output

View File

@@ -927,6 +927,7 @@ class WitnessPlayerLogic:
# Gather quick references to relevant options
eps_shuffled = world.options.shuffle_EPs
come_to_you = world.options.elevators_come_to_you
difficulty = world.options.puzzle_randomization
discards_shuffled = world.options.shuffle_discarded_panels
boat_shuffled = world.options.shuffle_boat
@@ -938,9 +939,6 @@ class WitnessPlayerLogic:
shortbox_req = world.options.mountain_lasers
longbox_req = world.options.challenge_lasers
swamp_bridge_comes_to_you = "Swamp Long Bridge" in world.options.elevators_come_to_you
quarry_elevator_comes_to_you = "Quarry Elevator" in world.options.elevators_come_to_you
# Make some helper booleans so it is easier to follow what's going on
mountain_upper_is_in_postgame = (
goal == "mountain_box_short"
@@ -958,8 +956,8 @@ class WitnessPlayerLogic:
"0x17D02": eps_shuffled, # Windmill Turn Control
"0x0368A": symbols_shuffled or door_panels, # Quarry Stoneworks Stairs Door
"0x3865F": symbols_shuffled or door_panels or eps_shuffled, # Quarry Boathouse 2nd Barrier
"0x17CC4": quarry_elevator_comes_to_you or eps_shuffled, # Quarry Elevator Panel
"0x17E2B": swamp_bridge_comes_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge
"0x17CC4": come_to_you or eps_shuffled, # Quarry Elevator Panel
"0x17E2B": come_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge
"0x0CF2A": False, # Jungle Monastery Garden Shortcut
"0x0364E": False, # Monastery Laser Shortcut Door
"0x03713": remote_doors, # Monastery Laser Shortcut Panel

View File

@@ -1,6 +1,3 @@
from typing import cast
from .. import WitnessWorld
from ..test import WitnessMultiworldTestBase, WitnessTestBase
@@ -35,10 +32,6 @@ class TestForbiddenDoors(WitnessMultiworldTestBase):
{
"early_caves": "add_to_pool",
},
{
"early_caves": "add_to_pool",
"door_groupings": "regional",
},
]
common_options = {
@@ -47,35 +40,11 @@ class TestForbiddenDoors(WitnessMultiworldTestBase):
}
def test_forbidden_doors(self) -> None:
with self.subTest("Test that Caves Mountain Shortcut (Panel) exists if Early Caves is off"):
self.assertTrue(
self.get_items_by_name("Caves Mountain Shortcut (Panel)", 1),
"Caves Mountain Shortcut (Panel) should exist in panels shuffle, but it didn't."
)
with self.subTest("Test that Caves Mountain Shortcut (Panel) doesn't exist if Early Caves is start_to_pool"):
self.assertFalse(
self.get_items_by_name("Caves Mountain Shortcut (Panel)", 2),
"Caves Mountain Shortcut (Panel) should be removed when Early Caves is enabled, but it still exists."
)
with self.subTest("Test that slot data is set up correctly for a panels seed with Early Caves"):
slot_data = cast(WitnessWorld, self.multiworld.worlds[3])._get_slot_data()
self.assertIn(
WitnessWorld.item_name_to_id["Caves Panels"],
slot_data["door_items_in_the_pool"],
'Caves Panels should still exist in slot_data under "door_items_in_the_pool".'
)
self.assertIn(
0x021D7,
slot_data["item_id_to_door_hexes"][WitnessWorld.item_name_to_id["Caves Panels"]],
"Caves Panels should still contain Caves Mountain Shortcut Panel as a door they unlock.",
)
self.assertIn(
0x021D7,
slot_data["doors_that_shouldnt_be_locked"],
"Caves Mountain Shortcut Panel should be marked as \"shouldn't be locked\".",
)
self.assertTrue(
self.get_items_by_name("Caves Mountain Shortcut (Panel)", 1),
"Caves Mountain Shortcut (Panel) should exist in panels shuffle, but it didn't."
)
self.assertFalse(
self.get_items_by_name("Caves Mountain Shortcut (Panel)", 2),
"Caves Mountain Shortcut (Panel) should be removed when Early Caves is enabled, but it still exists."
)