diff --git a/.run/APQuest Tests.run.xml b/.run/APQuest Tests.run.xml
new file mode 100644
index 00000000000..4a735f23811
--- /dev/null
+++ b/.run/APQuest Tests.run.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BaseClasses.py b/BaseClasses.py
index 69b900212c5..2629fc69636 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -8,10 +8,10 @@
import warnings
from argparse import Namespace
from collections import Counter, deque, defaultdict
-from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set
+from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set as AbstractSet
from enum import IntEnum, IntFlag
-from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
- Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
+from typing import (Any, ClassVar, Dict, List, Literal, NamedTuple,
+ Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, overload)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -49,7 +49,7 @@ def __getattr__(self, name: str) -> Any:
return getattr(self.obj, name)
else:
raise RuntimeError("You are in a threaded context and global random state was removed for your safety. "
- "Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
+ "Please use world.random or randomize ahead of output.")
class HasNameAndPlayer(Protocol):
@@ -100,8 +100,6 @@ class MultiWorld():
game: Dict[int, str]
random: random.Random
- per_slot_randoms: Utils.DeprecateDict[int, random.Random]
- """Deprecated. Please use `self.random` instead."""
class AttributeProxy():
def __init__(self, rule):
@@ -177,8 +175,6 @@ def set_player_attr(attr: str, val) -> None:
set_player_attr('game', "Archipelago")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
- self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
- "world's random object instead (usually self.random)", True)
self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]:
@@ -514,7 +510,7 @@ def register_indirect_condition(self, region: Region, entrance: Entrance):
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance)
- def get_locations(self, player: Optional[int] = None) -> Iterable[Location]:
+ def get_locations(self, player: int | None = None) -> Collection[Location]:
if player is not None:
return self.regions.location_cache[player].values()
return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values()
@@ -1038,6 +1034,8 @@ def count(self, item: str, player: int) -> int:
def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list."""
+ if count <= 0:
+ return True
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in items:
@@ -1049,6 +1047,8 @@ def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
Ignores duplicates of the same item."""
+ if count <= 0:
+ return True
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in items:
@@ -1077,6 +1077,8 @@ def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
# item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
"""Returns True if the state contains at least `count` items present in a specified item group."""
+ if count <= 0:
+ return True
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
@@ -1089,6 +1091,8 @@ def has_group_unique(self, item_name_group: str, player: int, count: int = 1) ->
"""Returns True if the state contains at least `count` items present in a specified item group.
Ignores duplicates of the same item.
"""
+ if count <= 0:
+ return True
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
diff --git a/CommonClient.py b/CommonClient.py
index 2da41f83607..3d4d7fedf2f 100755
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -341,6 +341,10 @@ def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int])
"""Name used in Connect packet"""
seed_name: str | None
"""Seed name that will be validated on opening a socket if present"""
+ server_seed_name: str | None
+ """Actual seed_name reported by the server in RoomInfo"""
+ connected_identity: tuple[str, int, int] | None
+ """(server_seed_name, team, slot) of the last successful connection, used to detect a session switch"""
# locations
locations_checked: set[int]
@@ -399,6 +403,10 @@ def __init__(self, server_address: typing.Optional[str] = None, password: typing
self.slot = None
self.auth = None
self.seed_name = None
+ self.server_seed_name = None
+ self.connected_identity = None
+ # decouple from the class-level mutable set so tag changes (e.g. DeathLink) don't leak across instances
+ self.tags = set(type(self).tags)
self.locations_checked = set() # local state
self.locations_scouted = set()
@@ -459,6 +467,7 @@ def reset_server_state(self):
self.team = None
self.items_received = []
self.locations_info = {}
+ self.server_seed_name = None
self.server_version = Version(0, 0, 0)
self.generator_version = Version(0, 0, 0)
self.server = None
@@ -470,6 +479,21 @@ def reset_server_state(self):
"remaining": "disabled",
}
+ def reset_session_state(self):
+ """Clear local progress and data-storage state tied to a single session."""
+ self.locations_checked = set()
+ self.locations_scouted = set()
+ self.locations_info = {}
+ self.items_received = []
+ self.missing_locations = set()
+ self.checked_locations = set()
+ self.server_locations = set()
+ self.finished_game = False
+ self.ready = False
+ self.stored_data = {}
+ self.stored_data_notification_keys = set()
+ self.current_energy_link_value = None
+
async def disconnect(self, allow_autoreconnect: bool = False):
if not allow_autoreconnect:
self.disconnected_intentionally = True
@@ -924,6 +948,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.exception(f"Could not get command from {args}")
raise
if cmd == 'RoomInfo':
+ ctx.server_seed_name = args["seed_name"]
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
@@ -1004,6 +1029,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.username = ctx.auth
ctx.team = args["team"]
ctx.slot = args["slot"]
+ # on a switch to a different session, clear session state before stale checks/goal are replayed below
+ identity = (ctx.server_seed_name, ctx.team, ctx.slot)
+ if ctx.connected_identity is not None and identity != ctx.connected_identity:
+ ctx.reset_session_state()
+ ctx.connected_identity = identity
# int keys get lost in JSON transfer
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
@@ -1092,9 +1122,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == "Bounced":
tags = args.get("tags", [])
- # we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
- if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
- ctx.on_deathlink(args["data"])
+ # we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been sent this
+ if "DeathLink" in tags:
+ data = args.get("data", {})
+ time = data.get("time")
+ if time is not None and ctx.last_death_link != time:
+ ctx.on_deathlink(args["data"])
elif cmd == "Retrieved":
ctx.stored_data.update(args["keys"])
diff --git a/Fill.py b/Fill.py
index 7bd57566270..ac12fdcb586 100644
--- a/Fill.py
+++ b/Fill.py
@@ -1,8 +1,9 @@
-import collections
+import abc
+from collections import Counter, defaultdict, deque
+from collections.abc import Iterable, Sequence
import itertools
import logging
-import typing
-from collections import Counter, deque
+from typing import Callable, Literal, ClassVar
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
from Options import Accessibility
@@ -12,10 +13,11 @@
class FillError(RuntimeError):
- def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
- if "multiworld" in kwargs and isinstance(args[0], str):
- placements = (args[0] + f"\nAll Placements:\n" +
- f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
+ def __init__(self, *args: str | object, **kwargs: object) -> None:
+ multiworld = kwargs.get("multiworld")
+ if isinstance(multiworld, MultiWorld) and isinstance(args[0], str):
+ placements = (args[0] + "\nAll Placements:\n" +
+ f"{[(loc, loc.item) for loc in multiworld.get_filled_locations()]}")
args = (placements, *args[1:])
super().__init__(*args)
@@ -24,8 +26,9 @@ def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
-def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(),
- locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
+def sweep_from_pool(base_state: CollectionState,
+ itempool: Sequence[Item] = (),
+ locations: Iterable[Location] | None = None) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
@@ -33,9 +36,736 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
return new_state
-def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
- item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
- swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
+class _RestrictiveFillBatcher:
+ """
+ Batcher for item placements in fill_restrictive.
+
+ Breaks up the item_pool into batches of placements at a time.
+
+ The batcher reduces CollectionState sweeping costs by each batch creating two extra CollectionStates:
+ - A 'batch base state' that contains only the inventory of items which have yet to be placed and are not being
+ placed as part of the current batch, and reachable items that were reachable before fill_restrictive was called.
+ This assumes that copying the batch's base state is more performant than copying the `base_state` argument of the
+ fill_restrictive call and collecting the items into that copy of `base_state`.
+ - A 'partial exploration state' that starts from the 'base state' and explores filled locations, collecting all
+ reachable items.
+
+ Creating a maximum exploration state to determine where items can be placed is done by sweeping for advancements
+ from the batch's partial exploration state, this saves a lot of work compared to sweeping for advancements from the
+ `base_state` argument of the `fill_restrictive` call.
+
+ Creating a partial exploration state is a cost that typically occurs once per batch (may occur multiple times due to
+ some swaps). If batches are too small then the extra cost of creating the partial exploration state can outweigh the
+ savings of sweeping from the partial exploration state rather than from the `base_state` of the `fill_restrictive`
+ call.
+
+ Swap states can also be created from the partial exploration state, but only if the partial exploration state has
+ not collected the item from the swap location. If the partial exploration state has collected the item from the swap
+ location, then the swap state will have to be swept from the base state instead.
+
+ When a swap displaces an item that the partial exploration state had collected, the partial exploration state must
+ be destroyed in some cases. For this reason, the partial exploration state is created lazily and is only re-created
+ when needed.
+
+ :param base_state: The base_state passed as an argument to the fill_restrictive call. When there are lots of items
+ already placed, this state should have already swept for advancements to collect every reachable advancement
+ item.
+ :param reachable_items: Items remaining to place, per-player. Because fill uses a reverse-fill algorithm, these
+ items are always considered reachable until they are removed and placed.
+ :param item_pool: The entire item pool of items to place. Items are removed from it when placed.
+ :param one_item_per_player: True to place one item per player simultaneously, False to place one item at a time.
+ When True, this significantly increases fill performance at the cost of introducing a small bias towards
+ placing items in earlier locations. This bias occurs because items can, usually, only be placed at locations
+ that are reachable without themselves, but when multiple items are placed simultaneously the reachable
+ locations will be reduced to locations reachable without *any* of the items that are being placed
+ simultaneously.
+ """
+
+ # Adjust these ClassVars to adjust batch sizes, as needed, when external changes to generation performance are made.
+
+ _MIN_BATCH_ITEMS_PER_PLAYER: ClassVar[int] = 5
+ """
+ Pick no fewer than this many items per player for each batch, unless that player does not have enough items
+ remaining. This is a magic number and will typically be used for most batches.
+ """
+ _MIN_TOTAL_ITEMS_PER_BATCH: ClassVar[int] = 40
+ """
+ Try to pick no fewer than this many items total for each batch. If a player does not have enough items remaining to
+ fully fill out a batch, then the total number of items in a batch can end up lower than this value.
+ With a low number of players, try to keep the total number of items in the batch from being too small, to prevent
+ fills with few players from creating lots of very small batches.
+ """
+ _MAX_PERCENT_OF_LARGEST_STARTING_POOL_TO_PLACE: ClassVar[float] = 0.02 # 2%
+ """
+ When players have items pools with different sizes, the percentage of remaining items to place gradually approaches
+ being 100% the player with the largest item pool. While this happens, gradually increase the number of items in each
+ batch until the last player with items remaining places this percentage of their starting item pool in each batch.
+ """
+
+ class _RestrictiveFillBatch(abc.ABC):
+ """
+ Base class for a batch of items to fill in _RestrictiveFillBatcher.
+
+ :param batch_base_state: Base state for the batch. It must have collected all items that will be placed in
+ future batches and must not have explored any locations besides those in the base_state at the start of
+ fill_restrictive.
+ :param batch_empty_spaces: The number of empty spaces for items for each player in the batch. If the size of
+ the batch if 5 items per player and one player only had 3 items remaining, they would have 2 empty
+ spaces in the batch.
+ When swapping items out of already filled locations, the swapped out item can be added into the current
+ batch if there is space for it.
+ :param batch_item_pool: All items to be placed in this batch. Items are removed from it when picked to be
+ placed.
+ :param item_pool: All items remaining to be placed. Items are removed from it when picked to be placed. The
+ items in this list must also be present in the deques in `reachable_items`.
+ :param batched_placements_remaining: The maximum number of placements the batch can make before being
+ exhausted.
+ :param reachable_items: All items remaining to be placed, by player. The items in the deques in this dict
+ must also be present in `item_pool`.
+ """
+ batch_base_state: CollectionState
+ batch_empty_spaces: dict[int, int]
+ batch_item_pool: list[Item]
+ reachable_items: dict[int, deque[Item]]
+ batched_placements_remaining: int
+ item_pool: list[Item]
+
+ # An exploration state containing the items reachable starting from `batch_base_state`.
+ # Maximum exploration states and some swap states will be swept from `_partial_exploration_state`.
+ # Swaps may cause the partial exploration state to become invalid, which the batch accounts for when sweeping
+ # new states from it, but the partial exploration state would otherwise be invalid to use in isolation, so it is
+ # not exposed as a public attribute.
+ _partial_exploration_state: CollectionState | None
+
+ # When an item is swapped from an existing placement, that _partial_exploration_state has already collected
+ # from, into the current batch, the _partial_exploration_state must be destroyed and re-created once it is time
+ # to place the swapped item.
+ # The set tracks the unique object identifiers of swapped items because Item implements __eq__, so a set[Item]
+ # would not be usable here.
+ # The list tracks the Item instances, whose unique object identifiers must be in the set.
+ _deferred_swap_item_ids: set[int]
+ _deferred_swap_items: list[Item]
+
+ def __init__(self,
+ batch_base_state: CollectionState,
+ batch_empty_spaces: dict[int, int],
+ batch_item_pool: list[Item],
+ item_pool: list[Item],
+ batched_placements_remaining: int,
+ reachable_items: dict[int, deque[Item]]):
+ self.batch_base_state = batch_base_state
+ self.batch_empty_spaces = batch_empty_spaces
+ self.batch_item_pool = batch_item_pool
+ self.item_pool = item_pool
+ self.batched_placements_remaining = batched_placements_remaining
+ self.reachable_items = reachable_items
+
+ self._partial_exploration_state = None
+
+ self._deferred_swap_item_ids = set()
+ self._deferred_swap_items = []
+
+ @abc.abstractmethod
+ def _pop_items_to_place(self) -> list[Item]:
+ """
+ Get and remove items to place from `self.reachable_items`. `self.batched_placements_remaining > 0` must be
+ checked before calling this and `self.batched_placements_remaining` must be reduced by 1 afterward.
+
+ :return: A list of items to place, or an empty list if the batch is exhausted and a new batch should be
+ created.
+ """
+ ...
+
+ def _update_pools_for_items_to_place(self, items_to_place: list[Item]) -> None:
+ """
+ After getting items to place, update the item pools.
+
+ :param items_to_place: Items that have been removed from the batch, and are going to be placed.
+ """
+ batch_item_pool = self.batch_item_pool
+ item_pool = self.item_pool
+ for item in items_to_place:
+ for p, batch_pool_item in enumerate(batch_item_pool):
+ if batch_pool_item is item:
+ del batch_item_pool[p]
+ break
+ # The items added into `reachable_items` are placed starting from the end of each deque in
+ # `reachable_items`, so the items being placed are more likely to found towards the end of `item_pool`.
+ for p, pool_item in enumerate(reversed(item_pool), start=1):
+ if pool_item is item:
+ del item_pool[-p]
+ break
+
+ def pop_items_to_place(self) -> list[Item]:
+ """
+ Get and remove items to place from this batch.
+
+ :return: A list of items to place, or an empty list if the batch is exhausted.
+ """
+ batched_placements_remaining = self.batched_placements_remaining
+ if batched_placements_remaining <= 0:
+ return []
+ self.batched_placements_remaining = batched_placements_remaining - 1
+ items_to_place = self._pop_items_to_place()
+
+ deferred_swap_item_ids = self._deferred_swap_item_ids
+ if deferred_swap_item_ids and not deferred_swap_item_ids.isdisjoint(map(id, items_to_place)):
+ assert self._partial_exploration_state is not None
+ # One of the items being placed was swapped into the current batch, but was already collected by the
+ # partial exploration state. It is time to place that item now, so the partial exploration state is
+ # invalid and will need to be re-created.
+ self._partial_exploration_state = None
+
+ # Any deferred swap items that are not being placed will need to be added back into the batch item pool.
+ # The deferred swap items that *are* being placed do not need to be added back into the batch item pool,
+ # but it is simpler to add all the items and then remove the ones being placed because determining which
+ # items do not need to be added needs to be done by comparing by identity.
+ for item in self._deferred_swap_items:
+ self.batch_item_pool.append(item)
+
+ # Clear the list of deferred swap items and the set of their item IDs.
+ self._deferred_swap_items.clear()
+ deferred_swap_item_ids.clear()
+
+ self._update_pools_for_items_to_place(items_to_place)
+ return items_to_place
+
+ def get_maximum_exploration_state(self, explore_locations: list[Location] | None, unplaced_items: list[Item]
+ ) -> CollectionState:
+ """
+ Get the maximum exploration state for the currently reachable items.
+
+ An item is considered reachable if it matches any one of the following:
+ A. It is in `self.item_pool` and is not being placed in this batch. `self.batch_base_state` collected these
+ items in advance because they do not change within a batch.
+ B. It is in both `self.item_pool` and `self.batch_item_pool` (the items in batch_item_pool should always be
+ present in item_pool), so it is an item that is going to be placed as part of this batch, but later on.
+ C. It is in `unplaced_items`, so could not be placed at any location. These items must be included because
+ fill_restrictive allows for partial fills and retries depending on its arguments.
+ D. It is at a location in `explore_locations` that is reachable with all items in A-D.
+
+ Items already placed at reachable locations, items not being placed in this batch, items yet to be removed
+ from this batch in order to be placed, and items that could not be placed at any location are all considered
+ reachable.
+
+ :param explore_locations: The locations to explore for reachable items. Defaults to all filled locations
+ when None.
+ :param unplaced_items: All items that could not be placed at any location.
+ :return: A CollectionState that has collected all reachable advancement items.
+ """
+ partial_exploration_state = self._partial_exploration_state
+
+ # Create the initial partial exploration state or recreate it if it was destroyed by a swap.
+ if partial_exploration_state is None:
+ # `locations=None` defaults to `multiworld.get_filled_locations()`, so only get it once for both the
+ # batch's partial exploration state and the maximum exploration state.
+ if explore_locations is None:
+ explore_locations = self.batch_base_state.multiworld.get_filled_locations()
+ # batch_base_state has already collected all items that still need to be placed, but are not being
+ # placed in this batch (A. items).
+ # The sweep to create `partial_exploration_state` collects many additional reachable items (some D.
+ # items).
+ partial_exploration_state = sweep_from_pool(self.batch_base_state, locations=explore_locations)
+ self._partial_exploration_state = partial_exploration_state
+
+ # Collect items in this batch that have yet to be removed in order to be placed (B. items), and collect all
+ # items that could not be placed at any location (C. items), and then sweep to collect all remaining
+ # reachable items (the remaining D. items).
+ maximum_exploration_state = sweep_from_pool(
+ partial_exploration_state, self.batch_item_pool + unplaced_items, explore_locations)
+
+ return maximum_exploration_state
+
+ def _add_swapped_item_into_batch(self, displaced_item: Item, swap_location: Location) -> None:
+ """
+ Add an item displaced by a swap into the current batch.
+ `self.batch_empty_spaces[displaced_item.player] > 0` must be True.
+
+ :param displaced_item: The previously placed item that the swap displaced.
+ :param swap_location: The location at which displaced_item was displaced from, and at which the item being
+ placed was placed at.
+ """
+ assert self.batch_empty_spaces[displaced_item.player] > 0
+ self.batch_empty_spaces[displaced_item.player] -= 1
+ partial_exploration_state = self._partial_exploration_state
+ if partial_exploration_state is not None and swap_location in partial_exploration_state.advancements:
+ # The partial exploration state exists and has already collected `displaced_item`.
+ # `displaced_item` should be added into `self.batch_item_pool`, but doing so would mean that a maximum
+ # exploration state or swap state swept from the partial exploration state would have collected
+ # `displaced_item` twice, which would be incorrect.
+ # Instead of adding `displaced_item` into `self.batch_item_pool` immediately and re-creating the partial
+ # exploration state, adding `displaced_item` into `self.batch_item_pool` is deferred until it is time to
+ # place `displaced_item`. Only then does the partial exploration state need to be re-created. This
+ # reduces how often the partial exploration state needs to be re-created, increasing performance.
+ assert id(displaced_item) not in self._deferred_swap_item_ids, \
+ (f"Displaced item {displaced_item} has already been swapped into the batch. This should never"
+ f" happen because the set of displaced item IDs should be cleared whenever it is time to place one"
+ f" of the displaced items. If this does happen, it is more likely that a world submitted the same"
+ f" Item instance to the item pool multiple times instead of creating multiple Item instances.")
+ # Multiple items can be displaced into the current batch, and the items need to be compared by identity,
+ # so a set of their unique object IDs is used to track them.
+ self._deferred_swap_item_ids.add(id(displaced_item))
+ self._deferred_swap_items.append(displaced_item)
+
+ # If a location has already been checked by a state, the state will ignore that location in further
+ # sweeps, so `swap_location` needs to be removed so that sweeping will check the location again.
+ partial_exploration_state.advancements.remove(swap_location)
+ # Also remove it from locations_checked for completeness.
+ partial_exploration_state.locations_checked.remove(swap_location)
+ else:
+ # If the partial exploration state does not currently exist, or has not collected the displaced item,
+ # the displaced item can simply be added into the batch's item pool.
+ self.batch_item_pool.append(displaced_item)
+
+ def _add_swapped_item_into_future_batch(self, displaced_item: Item, item_to_place: Item,
+ swap_location: Location) -> None:
+ """
+ Add an item displaced by a swap into a future batch.
+
+ :param displaced_item: The previously placed item that the swap displaced.
+ :param item_to_place: The item that was placed into the swap location.
+ :param swap_location: The location at which displaced_item was displaced from and item_to_place was placed
+ at.
+ """
+ # A batch's base state collects all items which are yet to be placed and which are not going to be placed as
+ # part of the batch. The displaced item matches these criteria, so collect the item.
+ self.batch_base_state.collect(displaced_item, True)
+ # A batch's partial exploration state is swept from its batch's base state, so it needs to collect the item
+ # too, but only if it had not already collected the item.
+ partial_exploration_state = self._partial_exploration_state
+ # The batch's partial exploration state may have been destroyed by a previous `_add_swapped_item_into_batch`
+ # swap, in which case, it will not exist.
+ if partial_exploration_state is not None:
+ if swap_location not in partial_exploration_state.advancements:
+ # The partial exploration state had not already collected the un-placed item by sweeping, so collect
+ # the item.
+ partial_exploration_state.collect(displaced_item, True)
+ else:
+ # The partial exploration state had already collected the displaced item from `spot_to_fill`.
+ # Rather than destroying the partial exploration state, it can be adjusted.
+ # Collect the item being placed if `spot_to_fill` is still reachable.
+ if swap_location.can_reach(partial_exploration_state):
+ # This is slightly faster than removing `spot_to_fill` from the state's collected advancement
+ # locations and requiring future maximum exploration states sweep to pick up `item_to_place`
+ # from `swap_location`.
+ partial_exploration_state.collect(item_to_place, True)
+ else:
+ # In rare cases, `spot_to_fill` was only reachable because of the item that was placed at it,
+ # which can happen with some self-locking item implementations.
+ # Future sweeps will have to retry `spot_to_fill`, so remove it from the state's set of
+ # collected advancement locations.
+ partial_exploration_state.advancements.remove(swap_location)
+ # Also remove the location from locations_checked for completeness.
+ partial_exploration_state.locations_checked.remove(swap_location)
+
+ def update_for_swap(self, displaced_item: Item, item_to_place: Item, swap_location: Location) -> None:
+ """
+ Update the batch for the result of a successful swap.
+
+ :param displaced_item: The item that was previously placed at swap_location and has been displaced by the
+ swap.
+ :param item_to_place: The item that has been placed at swap_location.
+ :param swap_location: The location where the two items have been swapped.
+ """
+ # Determine if the displaced item can be added to the current batch.
+ empty_spaces_for_items = self.batch_empty_spaces[displaced_item.player]
+ if empty_spaces_for_items > 0:
+ # There are some empty spaces for items in this batch for this player, so add the item into this batch.
+ self._add_swapped_item_into_batch(displaced_item, swap_location)
+ else:
+ # There are no empty spaces in this batch for this player, so the item will need to be placed in a
+ # different batch.
+ self._add_swapped_item_into_future_batch(displaced_item, item_to_place, swap_location)
+
+ def get_swap_state(self, displaced_item: Item, location: Location, explore_locations: list[Location] | None,
+ unsafe: bool) -> CollectionState:
+ """
+ Get the maximum exploration state for a potential swap.
+
+ :param displaced_item: The item displaced from `location`.
+ :param location: The location of the swap attempt.
+ :param explore_locations: The locations the swap state should sweep. `None` will sweep all filled locations.
+ :param unsafe: Assume it will be possible to collect `displaced_item` before the item that is being placed,
+ by continuing to swap.
+ :return: A fully swept CollectionState for the swap attempt.
+ """
+ # Use the batch's partial exploration state as the base state if it has not explored the location of the
+ # swap attempt. This reduces sweeping costs.
+ partial_exploration_state = self._partial_exploration_state
+ if partial_exploration_state is not None and location not in partial_exploration_state.advancements:
+ swap_base_state = partial_exploration_state
+ deferred_swap_items_to_collect = None
+ else:
+ # Unfortunately, the partial exploration state has explored the location, so the swap state will have to
+ # be swept from the batch's base state instead, which has collected fewer items, so will take longer to
+ # sweep from.
+ swap_base_state = self.batch_base_state
+ # The base state won't have collected any deferred swap items, so those will also need to be collected.
+ if self._deferred_swap_items:
+ deferred_swap_items_to_collect = self._deferred_swap_items
+ else:
+ deferred_swap_items_to_collect = None
+
+ if unsafe:
+ # Assume we can somehow collect `displaced_item` before the item that is being placed, by continuing to
+ # swap.
+ if deferred_swap_items_to_collect is not None:
+ items_to_collect = self.batch_item_pool + deferred_swap_items_to_collect
+ items_to_collect.append(displaced_item)
+ else:
+ items_to_collect = self.batch_item_pool + [displaced_item]
+ else:
+ if deferred_swap_items_to_collect is not None:
+ items_to_collect = self.batch_item_pool + deferred_swap_items_to_collect
+ else:
+ items_to_collect = self.batch_item_pool
+
+ return sweep_from_pool(swap_base_state, items_to_collect, explore_locations)
+
+ class _RestrictiveFillBatchOneItemPerPlayer(_RestrictiveFillBatch):
+ """
+ Batch that places one item per player at a time.
+
+ This is less accurate because when placing an item for a player, the items belonging to other players that are
+ also going to be placed won't be included when determining if a location to place at is reachable. This results
+ in a bias towards earlier locations.
+
+ The tradeoff for reduced accuracy is that placing one item per player is significantly faster than placing one
+ item at a time when there are multiple players with items remaining to be placed.
+ """
+
+ def _pop_items_to_place(self) -> list[Item]:
+ # Pop one item per player that has items remaining.
+ # The batch is carefully constructed such that `self.batched_placements_remaining` will reach zero before
+ # attempting to pop an item from `self.reachable_items` that is not in `self.batch_item_pool`.
+ return [items.pop() for items in self.reachable_items.values() if items]
+
+ class _RestrictiveFillBatchOneItemAtATime(_RestrictiveFillBatch):
+ """
+ A batch that places one item at a time.
+
+ This improves placement accuracy, at the cost of performance.
+
+ :param batch_base_state: See _RestrictiveFillBatch
+ :param batch_empty_spaces: See _RestrictiveFillBatch
+ :param batch_item_pool: See _RestrictiveFillBatch
+ :param item_pool: See _RestrictiveFillBatch
+ :param batched_placements_remaining: See _RestrictiveFillBatch
+ :param reachable_items: See _RestrictiveFillBatch
+ :param items_per_player_in_batch: The number of items, per player, remaining in the batch.
+ :param next_player_override: When set, specifies which player the next item to place will belong to, instead of
+ picking randomly.
+ This should be set when the previous batch attempted to place an item belonging to a player that still has
+ items remaining to be placed, but had no items remaining in the batch.
+ """
+
+ next_player_override: int | None
+ items_per_player_in_batch: dict[int, int]
+
+ def __init__(self,
+ batch_base_state: CollectionState,
+ batch_empty_spaces: dict[int, int],
+ batch_item_pool: list[Item],
+ item_pool: list[Item],
+ batched_placements_remaining: int,
+ reachable_items: dict[int, deque[Item]],
+ items_per_player_in_batch: dict[int, int],
+ next_player_override: int | None):
+ super().__init__(batch_base_state, batch_empty_spaces, batch_item_pool, item_pool,
+ batched_placements_remaining, reachable_items)
+ self.items_per_player_in_batch = items_per_player_in_batch
+ self.next_player_override = next_player_override
+
+ def _pop_items_to_place(self) -> list[Item]:
+ if self.next_player_override is None:
+ # Randomly pick the next player that will have an item placed.
+ multiworld = self.batch_base_state.multiworld
+ # Only pick from players with items remaining to be placed, including players with no items remaining in
+ # this batch.
+ player_choices = [player for player, items in self.reachable_items.items() if items]
+ next_player = multiworld.random.choice(player_choices)
+ player_remaining_items_in_batch = self.items_per_player_in_batch[next_player]
+
+ # Check that the picked player still has items remaining in this batch.
+ if player_remaining_items_in_batch <= 0:
+ assert player_remaining_items_in_batch == 0, ("The count of remaining items should never be"
+ " negative.")
+ # This player still has items to place, but their items in the batch have been exhausted.
+ # A new batch needs to be built. This prevents unfairness in picked items at the boundary between
+ # two batches.
+ self.batched_placements_remaining = 0
+ # The current batch is passed as an argument to create the next batch, where this override will be
+ # read to force the next batch to start by picking an item belonging to this player.
+ self.next_player_override = next_player
+ return []
+ else:
+ # The override being set means that a new batch was started by trying to pick an item belonging to a
+ # player that had run out of items in the previous batch, but still has items remaining to be placed.
+ next_player = self.next_player_override
+ # Clear the override.
+ self.next_player_override = None
+ player_remaining_items_in_batch = self.items_per_player_in_batch[next_player]
+ assert player_remaining_items_in_batch > 0, "The override player should always have items remaining."
+ # Pop an item for the chosen player and reduce the count of their remaining items in this batch.
+ items_to_place = [self.reachable_items[next_player].pop()]
+ self.items_per_player_in_batch[next_player] = player_remaining_items_in_batch - 1
+
+ return items_to_place
+
+ def _add_swapped_item_into_batch(self, placed_item: Item, swap_location: Location) -> None:
+ super()._add_swapped_item_into_batch(placed_item, swap_location)
+ # Update the count of items remaining in the batch for this player.
+ self.items_per_player_in_batch[placed_item.player] += 1
+ # When placing one item at a time, the number of batched placements remaining is equal to the total number
+ # of items in the batch to place.
+ self.batched_placements_remaining += 1
+
+ # Protected attributes for each of the __init__ parameters.
+ _base_state: CollectionState
+ _reachable_items: dict[int, deque[Item]]
+ _item_pool: list[Item]
+ _one_item_per_player: bool
+
+ # The maximum batch size to be reached once only the player with the largest number of items has items remaining to
+ # be placed.
+ _max_one_item_per_player_batch_size: float
+
+ # The minimum batch size for this batcher, typically equal to MIN_BATCH_SIZE.
+ _min_batch_size: int
+
+ # The current batch of the batcher, or `None` when there are no more items to place.
+ _current_batch: _RestrictiveFillBatch | None
+
+ def __init__(self,
+ base_state: CollectionState,
+ reachable_items: dict[int, deque[Item]],
+ item_pool: list[Item],
+ one_item_per_player: bool):
+ self._base_state = base_state
+ self._reachable_items = reachable_items
+ self._item_pool = item_pool
+ self._one_item_per_player = one_item_per_player
+ # With a low number of players, adjust the minimum items to take per player so that there is at least
+ # MIN_TOTAL_ITEMS_PER_BATCH total items in the batch. Some players could have fewer items remaining assuming
+ # each player has enough items remaining to fully fill out the batch.
+ num_players = len(reachable_items)
+ if num_players > 0 and self._MIN_BATCH_ITEMS_PER_PLAYER * num_players < self._MIN_TOTAL_ITEMS_PER_BATCH:
+ self._min_batch_size = self._MIN_TOTAL_ITEMS_PER_BATCH // num_players
+ else:
+ self._min_batch_size = self._MIN_BATCH_ITEMS_PER_PLAYER
+
+ # Gradually increase the number of items placed in each batch until only the player with the largest item pool
+ # has items remaining, at which point, place a percentage of their original item pool in each batch. Most fills
+ # won't go above `min_batch_size`, so this is mostly to account for progression fill with outlier worlds with
+ # very large numbers of advancement items that are likely to individually have minimal effect on progression.
+ # 0-274 items: 5 # This will be most fills.
+ # 275-324 items: 6
+ # 325-374 items: 7 # Few worlds will be higher than this in progression fill.
+ # 475-524 items: 10
+ # 975-1024 items: 20
+ # etc.
+ largest_player_pool = max(map(len, reachable_items.values()), default=0)
+ self._max_one_item_per_player_batch_size = (
+ largest_player_pool * self._MAX_PERCENT_OF_LARGEST_STARTING_POOL_TO_PLACE)
+
+ self._current_batch = self._new_batch(None)
+
+ def _new_batch(self, previous_batch: _RestrictiveFillBatch | None = None) -> _RestrictiveFillBatch | None:
+ """
+ Create and return a new batch of items to place.
+
+ :param previous_batch: The previous batch, or `None` if there was no previous batch.
+ :return: A new batch, or `None` if there are no more items to place.
+ """
+ # Calculate the number of items, per player, to place in the new batch.
+ reachable_items = self._reachable_items
+
+ # Get the count of, and individual lengths of, non-empty remaining per-player item pools.
+ nonzero_remaining_per_player = [len(items) for items in reachable_items.values() if items]
+ num_players_with_remaining_items = len(nonzero_remaining_per_player)
+
+ if num_players_with_remaining_items == 0:
+ # No more items to place, so return None to signal this.
+ # Later code in this function does not check for iterables being empty, and would need to be updated if this
+ # early return is changed to occur later, or if this early return is removed entirely.
+ return None
+
+ # Find the length of the largest remaining item pool.
+ largest_remaining = max(nonzero_remaining_per_player)
+ if num_players_with_remaining_items > 1:
+ # Adjust the batch size by the ratio of the average remaining item pool length to the largest remaining item
+ # pool length.
+ # Find the average length of the remaining item pools.
+ average_remaining = sum(nonzero_remaining_per_player) / num_players_with_remaining_items
+ # As the average remaining item pool length approaches the largest remaining item pool length, the batch
+ # size approaches `max_one_item_per_player_batch_size`.
+ batch_size_float = self._max_one_item_per_player_batch_size * average_remaining / largest_remaining
+ else:
+ batch_size_float = self._max_one_item_per_player_batch_size
+
+ # Round to the nearest integer.
+ batch_size = round(batch_size_float)
+ # Limit the minimum number of items per player in the batch.
+ batch_size = max(self._min_batch_size, batch_size)
+ # Don't make the batch larger than the largest remaining item pool.
+ batch_size = min(batch_size, largest_remaining)
+
+ # Make per-batch arguments.
+
+ # If a player has fewer items remaining than the size of the batch, then that player has some empty spaces for
+ # items in the batch. This allows for items belonging to that player, that were displaced by a swap, to be added
+ # to the current batch until the empty spaces for that player are used up.
+ batch_empty_spaces = {player: batch_size - min(len(player_items), batch_size)
+ for player, player_items in reachable_items.items()}
+
+ # Iterate the first `batch_size` items of each player's remaining items into a list of all items that will be
+ # placed in the current batch.
+ # Items to place are picked from the end of each player's item pool, so, to get the items in the order they will
+ # be placed, the item pools must be iterated in reverse.
+ item_iters = [reversed(items) for items in reachable_items.values() if items]
+ batch_item_pool = [item for item_iter in item_iters
+ for item in itertools.islice(item_iter, batch_size)]
+
+ # Collect the remaining items, which won't be placed in this batch, into a copy of `base_state` and make that
+ # the base state for the batch.
+ batch_base_state = self._base_state.copy()
+ for item_iter in item_iters:
+ for item in item_iter:
+ batch_base_state.collect(item, True)
+
+ if self._one_item_per_player:
+ batched_placements_remaining = batch_size
+
+ return _RestrictiveFillBatcher._RestrictiveFillBatchOneItemPerPlayer(
+ batch_base_state,
+ batch_empty_spaces,
+ batch_item_pool,
+ self._item_pool,
+ batched_placements_remaining,
+ reachable_items)
+ else:
+ items_per_player_in_batch = {player: batch_size - empty_spaces
+ for player, empty_spaces in batch_empty_spaces.items()}
+ batched_placements_remaining = sum(items_per_player_in_batch.values())
+
+ if isinstance(previous_batch, _RestrictiveFillBatcher._RestrictiveFillBatchOneItemAtATime):
+ # If the previous batch ended early by picking a player who had no items remaining in the batch, that
+ # player should be picked when getting the first item to place in the new batch. This maintains fairness
+ # at the boundary between one almost empty batch and the next batch.
+ next_player_override = previous_batch.next_player_override
+ else:
+ next_player_override = None
+
+ return _RestrictiveFillBatcher._RestrictiveFillBatchOneItemAtATime(
+ batch_base_state,
+ batch_empty_spaces,
+ batch_item_pool,
+ self._item_pool,
+ batched_placements_remaining,
+ reachable_items,
+ items_per_player_in_batch,
+ next_player_override)
+
+ def pop_items_to_place(self) -> list[Item]:
+ """
+ Get and remove items to place from the ends of deques in `self.reachable_items`, also removing those items from
+ `self.item_pool`.
+
+ Automatically creates new batches internally as needed until all items have been placed.
+
+ :return: A list of items to place, or an empty list when there are no more items to place.
+ """
+ current_batch = self._current_batch
+ if current_batch is None:
+ # No more items to place.
+ return []
+ # Batches are given references to `self.reachable_items` and `self.item_pool` when they are created, so do not
+ # need to be given these collections of items again.
+ popped_items = current_batch.pop_items_to_place()
+ if not popped_items:
+ # The current batch is exhausted, so create a new one and retry with the new batch.
+ self._current_batch = self._new_batch(current_batch)
+ # This recursive call is expected to only recurse at most once.
+ # In the recursive call, either there are no more items to place, so `self._current_batch` became `None` and
+ # `current_batch` will be `None`, or there are items to place, so `popped_items` will be non-empty. Either
+ # way will not result in additional recursion.
+ return self.pop_items_to_place()
+ else:
+ return popped_items
+
+ def get_maximum_exploration_state(self, explore_locations: list[Location] | None, unplaced_items: list[Item]
+ ) -> CollectionState:
+ """
+ Get the maximum exploration state for the currently reachable items.
+
+ Must not be called after self.pop_items_to_place() has returned an empty list, indicating that there are no more
+ items to place, or after self.finish_fill() has been called.
+
+ An item is reachable if it matches any one of the following:
+ A. It is in `self.item_pool`, so has not been placed yet and is not currently being placed.
+ B. It is in `unplaced_items`, so could not be placed at any location.
+ C. It is at a location in `explore_locations` that is reachable with all items in A-C.
+
+ :param explore_locations: The locations to explore for reachable items. Defaults to all filled locations when
+ `None`.
+ :param unplaced_items: All items that could not be placed at any location.
+ :return: A CollectionState that has collected all reachable advancement items.
+ """
+ assert self._current_batch is not None, "Cannot call when there are no more items to place."
+ return self._current_batch.get_maximum_exploration_state(explore_locations, unplaced_items)
+
+ def get_swap_state(self, displaced_item: Item, location: Location, explore_locations: list[Location] | None,
+ unsafe: bool) -> CollectionState:
+ """
+ Get the maximum exploration state for a potential swap.
+
+ Must not be called after self.pop_items_to_place() has returned an empty list, indicating that there are no more
+ items to place, or after self.finish_fill() has been called.
+
+ :param displaced_item: The item displaced from `location`.
+ :param location: The location of the swap attempt.
+ :param explore_locations: The locations the swap state should sweep. `None` will sweep all filled locations.
+ :param unsafe: Assume it will be possible to collect `displaced_item` before the item that is being placed, by
+ continuing to swap.
+ :return: A CollectionState that has collected all reachable items, additionally including `displaced_item` when
+ `unsafe` is `True`.
+ """
+ assert self._current_batch is not None, "Cannot call when there are no more items to place."
+ return self._current_batch.get_swap_state(displaced_item, location, explore_locations, unsafe)
+
+ def update_for_swap(self, displaced_item: Item, item_to_place: Item, swap_location: Location) -> None:
+ """
+ Update the current batch for the result of a successful swap.
+
+ Must not be called after self.pop_items_to_place() has returned an empty list, indicating that there are no more
+ items to place, or after self.finish_fill() has been called.
+
+ :param displaced_item: The item that was previously placed at swap_location and has been displaced by the swap.
+ :param item_to_place: The item that has been placed at swap_location.
+ :param swap_location: The location where the two items have been swapped.
+ """
+ assert self._current_batch is not None, "Cannot call when there are no more items to place."
+ # Add the item back into the item_pool and the per-player pools in reachable_items.
+ self._item_pool.append(displaced_item)
+ self._reachable_items[displaced_item.player].appendleft(displaced_item)
+ # Update the batch for the swap.
+ return self._current_batch.update_for_swap(displaced_item, item_to_place, swap_location)
+
+ def finish_fill(self, unplaced_items: list[Item]) -> None:
+ """
+ Add items that could not be placed back into the `item_pool` argument passed in __init__, and mark the batcher
+ as having finished filling.
+
+ :param unplaced_items: Items that could not be placed.
+ """
+ # After this, assertions will fail if an attempt is made to call a function that uses the current batch.
+ self._current_batch = None
+ self._item_pool.extend(unplaced_items)
+
+
+def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: list[Location],
+ item_pool: list[Item], single_player_placement: bool = False, lock: bool = False,
+ swap: bool = True, on_place: Callable[[Location], None] | None = None,
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
name: str = "Unknown") -> None:
"""
@@ -51,11 +781,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
:param name: name of this fill step for progress logging purposes
"""
- unplaced_items: typing.List[Item] = []
- placements: typing.List[Location] = []
+ unplaced_items: list[Item] = []
+ placements: list[Location] = []
cleanup_required = False
- swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
- reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
+ swapped_items: Counter[tuple[int, str, bool]] = Counter()
+ reachable_items: dict[int, deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
@@ -63,28 +793,25 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
total = min(len(item_pool), len(locations))
placed = 0
- while any(reachable_items.values()) and locations:
- if one_item_per_player:
- # grab one item per player
- items_to_place = [items.pop()
- for items in reachable_items.values() if items]
- else:
- next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items])
- items_to_place = []
- if item_pool:
- items_to_place.append(reachable_items[next_player].pop())
-
- for item in items_to_place:
- # The items added into `reachable_items` are placed starting from the end of each deque in
- # `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
- for p, pool_item in enumerate(reversed(item_pool), start=1):
- if pool_item is item:
- del item_pool[-p]
- break
+ # Fill is performed in batches so that sweeping to produce a maximum exploration state can begin from the state at
+ # the start of each batch, rather than having to sweep from `base_state`.
+ # The batcher manages all the batches internally, creating new batches automatically when the current batch runs out
+ # of items to place.
+ batcher = _RestrictiveFillBatcher(base_state, reachable_items, item_pool, one_item_per_player)
+ # The batcher is responsible for modifying these from this point onwards.
+ del item_pool
+ del reachable_items
- maximum_exploration_state = sweep_from_pool(
- base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
- if single_player_placement else None)
+ while locations:
+ # Pop items to place from the ends of deques in `reachable_items` and pop those same items from `item_pool`.
+ items_to_place = batcher.pop_items_to_place()
+ if not items_to_place:
+ # There are no more items to place.
+ break
+
+ explore_locations = multiworld.get_filled_locations(item.player) if single_player_placement else None
+
+ maximum_exploration_state = batcher.get_maximum_exploration_state(explore_locations, unplaced_items)
has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
@@ -95,13 +822,15 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
break
item_to_place = items_to_place.pop(0)
- spot_to_fill: typing.Optional[Location] = None
+ spot_to_fill: Location | None = None
# if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
- perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
- item_to_place.player) \
- if single_player_placement else not has_beaten_game
+ perform_access_check = (
+ (not multiworld.has_beaten_game(maximum_exploration_state, item_to_place.player))
+ if single_player_placement
+ else not has_beaten_game
+ )
else:
perform_access_check = True
@@ -118,7 +847,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if swap:
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
# swap state, instead of sweeping from `base_state` each time.
- previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
+ previous_safe_swap_state_cache: deque[CollectionState] = deque()
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
max_swap_base_state_cache_length = 3
@@ -155,9 +884,9 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
break
else:
# No previous swap_state was usable as a base state to sweep from, so create a new one.
- swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
- multiworld.get_filled_locations(item.player)
- if single_player_placement else None)
+ explore_locations = (multiworld.get_filled_locations(item.player)
+ if single_player_placement else None)
+ swap_state = batcher.get_swap_state(placed_item, location, explore_locations, unsafe)
# Unsafe states should not be added to the cache because they have collected `placed_item`.
if not unsafe:
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
@@ -177,13 +906,10 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
- reachable_items[placed_item.player].appendleft(
- placed_item)
- item_pool.append(placed_item)
-
# cleanup at the end to hopefully get better errors
cleanup_required = True
+ batcher.update_for_swap(placed_item, item_to_place, spot_to_fill)
break
# Item can't be placed here, restore original item
@@ -215,7 +941,10 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
base_state, [], multiworld.get_filled_locations(item.player)
if single_player_placement else None)
for placement in placements:
- if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
+ if (
+ multiworld.worlds[placement.item.player].options.accessibility != "minimal" and
+ not placement.can_reach(state)
+ ):
placement.item.location = None
unplaced_items.append(placement.item)
placement.item = None
@@ -251,18 +980,18 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
- item_pool.extend(unplaced_items)
+ batcher.finish_fill(unplaced_items)
def remaining_fill(multiworld: MultiWorld,
- locations: typing.List[Location],
- itempool: typing.List[Item],
- name: str = "Remaining",
+ locations: list[Location],
+ itempool: list[Item],
+ name: str = "Remaining",
move_unplaceable_to_start_inventory: bool = False,
check_location_can_fill: bool = False) -> None:
- unplaced_items: typing.List[Item] = []
- placements: typing.List[Location] = []
- swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
+ unplaced_items: list[Item] = []
+ placements: list[Location] = []
+ swapped_items: Counter[tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
placed = 0
@@ -270,15 +999,15 @@ def remaining_fill(multiworld: MultiWorld,
if check_location_can_fill:
state = CollectionState(multiworld)
- def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
+ def location_can_fill_item(location_to_fill: Location, item_to_fill: Item) -> bool:
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):
+ def location_can_fill_item(location_to_fill: Location, item_to_fill: Item) -> bool:
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
+ spot_to_fill: Location | None = None
# going through locations in the same order as the provided `locations` argument
for i, location in enumerate(locations):
@@ -336,7 +1065,7 @@ def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
if move_unplaceable_to_start_inventory:
- last_batch = []
+ last_batch: list[Item] = []
for item in unplaced_items:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
@@ -355,8 +1084,8 @@ def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
def fast_fill(multiworld: MultiWorld,
- item_pool: typing.List[Item],
- fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
+ item_pool: list[Item],
+ fill_locations: list[Location]) -> tuple[list[Item], list[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
multiworld.push_item(location, item, False)
@@ -370,11 +1099,14 @@ def accessibility_corrections(multiworld: MultiWorld,
if pool is None:
pool = []
maximum_exploration_state = sweep_from_pool(state, pool)
- minimal_players = {player for player in multiworld.player_ids if
- multiworld.worlds[player].options.accessibility == "minimal"}
- unreachable_locations = [location for location in multiworld.get_locations() if
- location.player in minimal_players and
- not location.can_reach(maximum_exploration_state)]
+ minimal_players = {player
+ for player in multiworld.player_ids
+ if multiworld.worlds[player].options.accessibility == "minimal"}
+ unreachable_locations = [
+ location
+ for location in multiworld.get_locations()
+ if location.player in minimal_players and not location.can_reach(maximum_exploration_state)
+ ]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
@@ -389,33 +1121,36 @@ def accessibility_corrections(multiworld: MultiWorld,
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
-def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
+def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations: Iterable[Location]) -> None:
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
- def forbid_important_item_rule(item: Item):
- return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
+ def forbid_important_item_rule(item: Item) -> bool:
+ return not ((item.classification & 0b0011) and
+ multiworld.worlds[item.player].options.accessibility != "minimal")
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(multiworld: MultiWorld,
- fill_locations: typing.List[Location],
- itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
+ fill_locations: list[Location],
+ itempool: list[Item]) -> tuple[list[Location], list[Item]]:
""" returns new fill_locations and itempool """
- early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
+ early_items_count: dict[tuple[str, int], list[int]] = {}
for player in multiworld.player_ids:
items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
for item in items:
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
multiworld.local_early_items[player].get(item, 0)]
if early_items_count:
- early_locations: typing.List[Location] = []
- early_priority_locations: typing.List[Location] = []
- loc_indexes_to_remove: typing.Set[int] = set()
+ early_locations: list[Location] = []
+ early_priority_locations: list[Location] = []
+ loc_indexes_to_remove: set[int] = set()
base_state = multiworld.state.copy()
- base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
+ base_state.sweep_for_advancements(locations=(loc
+ for loc in multiworld.get_filled_locations()
+ if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
@@ -425,11 +1160,11 @@ def distribute_early_items(multiworld: MultiWorld,
loc_indexes_to_remove.add(i)
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
- early_prog_items: typing.List[Item] = []
- early_rest_items: typing.List[Item] = []
- early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
- early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
- item_indexes_to_remove: typing.Set[int] = set()
+ early_prog_items: list[Item] = []
+ early_rest_items: list[Item] = []
+ early_local_prog_items: dict[int, list[Item]] = {player: [] for player in multiworld.player_ids}
+ early_local_rest_items: dict[int, list[Item]] = {player: [] for player in multiworld.player_ids}
+ item_indexes_to_remove: set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
if item.advancement:
@@ -487,7 +1222,7 @@ def distribute_early_items(multiworld: MultiWorld,
def distribute_items_restrictive(multiworld: MultiWorld,
- panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
+ panic_method: Literal["swap", "raise", "start_inventory"] = "swap") -> None:
assert all(item.location is None for item in multiworld.itempool), (
"At the start of distribute_items_restrictive, "
"there are items in the multiworld itempool that are already placed on locations:\n"
@@ -502,9 +1237,9 @@ def distribute_items_restrictive(multiworld: MultiWorld,
fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
- progitempool: typing.List[Item] = []
- usefulitempool: typing.List[Item] = []
- filleritempool: typing.List[Item] = []
+ progitempool: list[Item] = []
+ usefulitempool: list[Item] = []
+ filleritempool: list[Item] = []
for item in itempool:
if item.advancement:
@@ -516,7 +1251,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
- locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
+ locations: dict[LocationProgressType, list[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
for loc in fill_locations:
@@ -527,17 +1262,17 @@ def distribute_items_restrictive(multiworld: MultiWorld,
excludedlocations = locations[LocationProgressType.EXCLUDED]
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
- lock_later = []
+ lock_later: list[Location] = []
- def mark_for_locking(location: Location):
+ def mark_for_locking(location: Location) -> None:
nonlocal lock_later
lock_later.append(location)
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations:
- regular_progression = []
- deprioritized_progression = []
+ regular_progression: list[Item] = []
+ deprioritized_progression: list[Item] = []
for item in progitempool:
if item.deprioritized:
deprioritized_progression.append(item)
@@ -623,7 +1358,7 @@ def mark_for_locking(location: Location):
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
- move_unplaceable_to_start_inventory=panic_method=="start_inventory")
+ move_unplaceable_to_start_inventory=(panic_method == "start_inventory"))
if excludedlocations:
raise FillError(
@@ -635,7 +1370,7 @@ def mark_for_locking(location: Location):
restitempool = filleritempool + usefulitempool
remaining_fill(multiworld, defaultlocations, restitempool,
- move_unplaceable_to_start_inventory=panic_method=="start_inventory")
+ move_unplaceable_to_start_inventory=(panic_method == "start_inventory"))
unplaced = restitempool
unfilled = defaultlocations
@@ -654,18 +1389,19 @@ def mark_for_locking(location: Location):
for player in multiworld.player_ids:
if more_locations[player]:
logging.error(
- f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
+ f"Player {multiworld.get_player_name(player)} had "
+ f"{more_locations[player]} more locations than items.")
elif more_items[player]:
logging.warning(
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
if unfilled:
raise FillError(
- f"Unable to fill all locations.\n" +
+ "Unable to fill all locations.\n"
f"Unfilled locations({len(unfilled)}): {unfilled}"
)
else:
logging.warning(
- f"Unable to place all items.\n" +
+ "Unable to place all items.\n"
f"Unplaced items({len(unplaced)}): {unplaced}"
)
@@ -715,7 +1451,7 @@ def flood_items(multiworld: MultiWorld) -> None:
if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place
else:
- raise FillError('No more progress items left to place.', multiworld=multiworld)
+ raise FillError("No more progress items left to place.", multiworld=multiworld)
# find item to replace with progress item
location_list = multiworld.get_reachable_locations()
@@ -738,7 +1474,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# Define a threshold value based on the player with the most available locations.
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
- balanceable_players: typing.Dict[int, float] = {
+ balanceable_players: dict[int, float] = {
player: multiworld.worlds[player].options.progression_balancing / 100
for player in multiworld.player_ids
if multiworld.worlds[player].options.progression_balancing > 0
@@ -749,15 +1485,15 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld)
- checked_locations: typing.Set[Location] = set()
- unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
+ checked_locations: set[Location] = set()
+ unchecked_locations: set[Location] = set(multiworld.get_locations())
- total_locations_count: typing.Counter[int] = Counter(
+ total_locations_count: Counter[int] = Counter(
location.player
for location in multiworld.get_locations()
if not location.locked
)
- reachable_locations_count: typing.Dict[int, int] = {
+ reachable_locations_count: dict[int, int] = {
player: 0
for player in multiworld.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
@@ -771,7 +1507,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
moved_item_count: int = 0
def get_sphere_locations(sphere_state: CollectionState,
- locations: typing.Set[Location]) -> typing.Set[Location]:
+ locations: set[Location]) -> set[Location]:
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
@@ -819,7 +1555,7 @@ def item_percentage(player: int, num: int) -> float:
balancing_unchecked_locations = unchecked_locations.copy()
balancing_reachables = reachable_locations_count.copy()
balancing_sphere = sphere_locations.copy()
- candidate_items: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
+ candidate_items: dict[int, set[Location]] = defaultdict(set)
while True:
# Check locations in the current sphere and gather progression items to swap earlier
for location in balancing_sphere:
@@ -846,11 +1582,11 @@ def item_percentage(player: int, num: int) -> float:
elif not balancing_sphere:
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
# Gather a set of locations which we can swap items into
- unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
+ unlocked_locations: dict[int, set[Location]] = defaultdict(set)
for l in unchecked_locations:
if l not in balancing_unchecked_locations:
unlocked_locations[l.player].add(l)
- items_to_replace: typing.List[Location] = []
+ items_to_replace: list[Location] = []
for player in balancing_players:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
@@ -956,6 +1692,12 @@ def failed(warning: str, force: bool | str) -> None:
for block in multiworld.worlds[player].options.plando_items:
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
target_world = block.world
+ # TODO: This doesn't handle the plando API correctly
+ # It says `world` can be other containers other than list,
+ # but this is checking specifically for list.
+ # (But it's not simple to just change the check to Iterable,
+ # because that will catch the str case too early.)
+ # (And it will be broken without failing unit tests. So also TODO: unit test this.)
if target_world is False or multiworld.players == 1: # target own world
worlds: set[int] = {player}
elif target_world is True: # target any worlds besides own
@@ -985,7 +1727,7 @@ def failed(warning: str, force: bool | str) -> None:
worlds = {world_name_lookup[target_world]}
new_block.worlds = worlds
- items: list[str] | dict[str, typing.Any] = block.items
+ items = block.items
if isinstance(items, dict):
item_list: list[str] = []
for key, value in items.items():
@@ -1046,8 +1788,8 @@ def failed(warning: str, force: bool | str) -> None:
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
- early_locations: dict[int, list[Location]] = collections.defaultdict(list)
- non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
+ early_locations: dict[int, list[Location]] = defaultdict(list)
+ non_early_locations: dict[int, list[Location]] = defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
@@ -1055,7 +1797,7 @@ def failed(warning: str, force: bool | str) -> None:
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
- removed = []
+ removed: list[PlandoItemBlock] = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
@@ -1079,8 +1821,7 @@ def failed(warning: str, force: bool | str) -> None:
block.count["max"] = len(block.resolved_locations)
if block.count["min"] > len(block.resolved_locations):
block.count["min"] = len(block.resolved_locations)
- block.count["target"] = multiworld.random.randint(block.count["min"],
- block.count["max"])
+ block.count["target"] = multiworld.random.randint(block.count["min"], block.count["max"])
if not block.count["target"]:
removed.append(block)
@@ -1118,7 +1859,7 @@ def failed(warning: str, force: bool | str) -> None:
maxcount = placement.count["target"]
from_pool = placement.from_pool
- item_candidates = []
+ item_candidates: list[Item] = []
if from_pool:
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
for item in multiworld.random.sample(items, maxcount):
@@ -1144,8 +1885,9 @@ def failed(warning: str, force: bool | str) -> None:
continue
else:
is_real = item_candidates[0].code is not None
- candidates = [candidate for candidate in locations if candidate.item is None
- and bool(candidate.address) == is_real]
+ candidates = [candidate
+ for candidate in locations
+ if candidate.item is None and bool(candidate.address) == is_real]
multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False)
mincount = placement.count["min"]
diff --git a/Generate.py b/Generate.py
index ff76b3c6490..0c2cd1567f7 100644
--- a/Generate.py
+++ b/Generate.py
@@ -279,7 +279,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"(name: {args.name.get(player, name)})")
player_errors.append(
f"{len(player_errors) + 1}. "
- f"File {path} document #{doc_index + 1} (name: {args.name.get(player, name)}) is invalid. "
+ f"File {path} document #{doc_index + 1} (with name: {args.name.get(player, name)}) is invalid. "
f"Please fix your yaml.\n{Utils.get_all_causes(e)}")
# increment for each yaml document in the file
diff --git a/Launcher.py b/Launcher.py
index d1e076b4c3a..7d6bc017a15 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -12,130 +12,33 @@
import logging
import multiprocessing
import os
-import shlex
import subprocess
import sys
import urllib.parse
-import webbrowser
from collections.abc import Callable, Sequence
-from os.path import isfile
-from shutil import which
-from typing import Any
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from worlds.LauncherComponents import Component, Type
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
-import settings
import Utils
-from Utils import (env_cleared_lib_path, init_logging, is_frozen, is_linux, is_macos, is_windows, local_path,
- messagebox, open_filename, user_path)
+from Utils import init_logging, local_path
if __name__ == "__main__":
init_logging('Launcher')
-from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
-from worlds import failed_world_loads
-
-
-def open_host_yaml():
- s = settings.get_settings()
- file = s.filename
- s.save()
- assert file, "host.yaml missing"
- if is_linux:
- exe = which('sensible-editor') or which('gedit') or \
- which('xdg-open') or which('gnome-open') or which('kde-open')
- elif is_macos:
- exe = which("open")
- else:
- webbrowser.open(file)
- return
-
- env = env_cleared_lib_path()
- subprocess.Popen([exe, file], env=env)
-
-def open_patch():
- suffixes = []
- for c in components:
- if c.type == Type.CLIENT and \
- isinstance(c.file_identifier, SuffixIdentifier) and \
- (c.script_name is None or isfile(get_exe(c)[-1])):
- suffixes += c.file_identifier.suffixes
- try:
- filename = open_filename("Select patch", (("Patches", suffixes),))
- except Exception as e:
- messagebox("Error", str(e), error=True)
- else:
- file, component = identify(filename)
- if file and component:
- exe = get_exe(component)
- if exe is None or not isfile(exe[-1]):
- exe = get_exe("Launcher")
-
- launch([*exe, file], component.cli)
-
-
-def generate_yamls(*args):
- from Options import generate_yaml_templates
-
- parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
- parser.add_argument("--skip_open_folder", action="store_true")
- args = parser.parse_args(args)
-
- target = Utils.user_path("Players", "Templates")
- generate_yaml_templates(target, False)
- if not args.skip_open_folder:
- open_folder(target)
-
-
-def browse_files():
- open_folder(user_path())
-
-
-def open_folder(folder_path):
- if is_linux:
- exe = which('xdg-open') or which('gnome-open') or which('kde-open')
- elif is_macos:
- exe = which("open")
- else:
- webbrowser.open(folder_path)
- return
-
- if exe:
- env = env_cleared_lib_path()
- subprocess.Popen([exe, folder_path], env=env)
- else:
- logging.warning(f"No file browser available to open {folder_path}")
-
-
def update_settings():
from settings import get_settings
get_settings().save()
-components.extend([
- # Functions
- Component("Open host.yaml", func=open_host_yaml,
- description="Open the host.yaml file to change settings for generation, games, and more."),
- Component("Open Patch", func=open_patch,
- description="Open a patch file, downloaded from the room page or provided by the host."),
- Component("Generate Template Options", func=generate_yamls,
- description="Generate template YAMLs for currently installed games."),
- Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
- description="Open archipelago.gg in your browser."),
- Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
- description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
- Component("Unrated/18+ Discord Server", icon="discord",
- func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
- description="Find unrated and 18+ games in the After Dark Discord server."),
- Component("Browse Files", func=browse_files,
- description="Open the Archipelago installation folder in your file browser."),
-])
-
-
-def handle_uri(path: str) -> tuple[list[Component], Component]:
+def handle_uri(path: str) -> tuple[list["Component"], "Component"]:
+ from worlds.LauncherComponents import components
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
client_components = []
@@ -149,7 +52,7 @@ def handle_uri(path: str) -> tuple[list[Component], Component]:
return client_components, text_client_component
-def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
+def build_uri_popup(component_list: list["Component"], launch_args: tuple[str, ...]) -> None:
from kvui import ButtonsPrompt
component_options = {
component.display_name: component for component in component_list
@@ -161,71 +64,19 @@ def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...
popup.open()
-def identify(path: None | str) -> tuple[None | str, None | Component]:
- if path is None:
- return None, None
- for component in components:
- if component.handles_file(path):
- return path, component
- elif path == component.display_name or path == component.script_name:
- return None, component
- return None, None
-
-
-def get_exe(component: str | Component) -> Sequence[str] | None:
- if isinstance(component, str):
- name = component
- component = None
- if name.startswith("Archipelago"):
- name = name[11:]
- if name.endswith(".exe"):
- name = name[:-4]
- if name.endswith(".py"):
- name = name[:-3]
- if not name:
- return None
- for c in components:
- if c.script_name == name or c.frozen_name == f"Archipelago{name}":
- component = c
- break
- if not component:
- return None
- if is_frozen():
- suffix = ".exe" if is_windows else ""
- return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
- else:
- return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
-
def launch(exe: Sequence[str], in_terminal: bool = False) -> bool:
"""Runs the given command/args in `exe` in a new process.
If `in_terminal` is True, it will attempt to run in a terminal window,
and the return value will indicate whether one was found."""
- if in_terminal:
- if is_windows:
- # intentionally using a window title with a space so it gets quoted and treated as a title
- subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
- return True
- elif is_linux:
- terminal = which("x-terminal-emulator") or which("konsole") or which("gnome-terminal") or which("xterm")
- if terminal:
- # Clear LD_LIB_PATH during terminal startup, but set it again when running command in case it's needed
- ld_lib_path = os.environ.get("LD_LIBRARY_PATH")
- lib_path_setter = f"env LD_LIBRARY_PATH={shlex.quote(ld_lib_path)} " if ld_lib_path else ""
- env = env_cleared_lib_path()
-
- subprocess.Popen([terminal, "-e", lib_path_setter + shlex.join(exe)], env=env)
- return True
- elif is_macos:
- terminal = [which("open"), "-W", "-a", "Terminal.app"]
- subprocess.Popen([*terminal, *exe])
- return True
+ if in_terminal and Utils.run_in_terminal(exe):
+ return True
subprocess.Popen(exe)
return False
-def create_shortcut(button: Any, component: Component) -> None:
+def create_shortcut(button: Any, component: "Component") -> None:
from pyshortcuts import make_shortcut
env = os.environ
if "APPIMAGE" in env:
@@ -244,11 +95,14 @@ def create_shortcut(button: Any, component: Component) -> None:
refresh_components: Callable[[], None] | None = None
-def run_gui(launch_components: list[Component], args: Any) -> None:
- from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
+def run_gui(launch_components: list["Component"], args: Any) -> None:
+ import threading
+ from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox,
+ MDScreenManager, MDScreen, LoadingScreen, LogtoLoadingScreen)
from kivy.properties import ObjectProperty
from kivy.core.window import Window
from kivy.metrics import dp
+ from kivy.clock import Clock
from kivymd.uix.button import MDIconButton, MDButton
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
@@ -258,11 +112,11 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
from kivy.lang.builder import Builder
class LauncherCard(MDCard):
- component: Component | None
+ component: "Component | None"
image: str
context_button: MDIconButton = ObjectProperty(None)
- def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
+ def __init__(self, *args, component: "Component | None" = None, image_path: str = "", **kwargs):
self.component = component
self.image = image_path
super().__init__(args, kwargs)
@@ -275,8 +129,8 @@ class Launcher(ThemedApp):
button_layout: ScrollBox = ObjectProperty(None)
search_box: MDTextField = ObjectProperty(None)
cards: list[LauncherCard]
- current_filter: Sequence[str | Type] | None
- failed_worlds: bool = bool(failed_world_loads)
+ current_filter: Sequence[str, "Type"] | None
+ failed_worlds: bool = False
def __init__(self, ctx=None, components=None, args=None):
self.title = self.base_title + " " + Utils.__version__
@@ -286,6 +140,11 @@ def __init__(self, ctx=None, components=None, args=None):
self.launch_components = components
self.launch_args = args
self.cards = []
+ self.current_filter = ()
+ super().__init__()
+
+ def load_filter(self):
+ from worlds.LauncherComponents import Type
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
persistent = Utils.persistent_load()
if "launcher" in persistent:
@@ -300,7 +159,6 @@ def __init__(self, ctx=None, components=None, args=None):
else:
filters.append(Type[filter])
self.current_filter = filters
- super().__init__()
def set_favorite(self, caller):
if caller.component.display_name in self.favorites:
@@ -310,7 +168,7 @@ def set_favorite(self, caller):
self.favorites.append(caller.component.display_name)
caller.icon = "star"
- def build_card(self, component: Component) -> LauncherCard:
+ def build_card(self, component: "Component") -> LauncherCard:
"""
Builds a card widget for a given component.
@@ -318,6 +176,7 @@ def build_card(self, component: Component) -> LauncherCard:
:return: The created Card Widget.
"""
+ from worlds.LauncherComponents import icon_paths
button_card = LauncherCard(component=component,
image_path=icon_paths[component.icon])
@@ -336,8 +195,9 @@ def open_menu(caller):
return button_card
- def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
+ def _refresh_components(self, type_filter: Sequence["Type"] | None = None) -> None:
if not type_filter:
+ from worlds.LauncherComponents import Type
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
favorites = "favorites" in type_filter
@@ -368,6 +228,7 @@ def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
if len(name) == 0:
self._refresh_components(self.current_filter)
return
+ from worlds.LauncherComponents import Type
sub_matches = [
card for card in self.cards
@@ -378,7 +239,10 @@ def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
self.button_layout.layout.add_widget(card)
def build(self):
+ self.screen_manager = MDScreenManager()
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
+ self.loading_screen = LoadingScreen(name="loading")
+ self.screen_manager.add_widget(self.loading_screen)
self.grid = self.top_screen.ids.grid
self.navigation = self.top_screen.ids.navigation
self.button_layout = self.top_screen.ids.button_layout
@@ -386,30 +250,61 @@ def build(self):
self.set_colors()
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
- global refresh_components
- refresh_components = self._refresh_components
-
Window.bind(on_drop_file=self._on_drop_file)
Window.bind(on_keyboard=self._on_keyboard)
- for component in components:
- self.cards.append(self.build_card(component))
-
- self._refresh_components(self.current_filter)
-
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# create_console(Window, self.top_screen)
- return self.top_screen
+ main_screen = MDScreen(name="main")
+ main_screen.add_widget(self.top_screen)
+ self.screen_manager.add_widget(main_screen)
+
+ return self.screen_manager
def on_start(self):
+ super().on_start()
+ logger = logging.getLogger("Worlds")
+ logger.propagate = False
+ self.loading_handler = LogtoLoadingScreen(self.loading_screen.update_text)
+ logger.addHandler(self.loading_handler)
+ threading.Thread(target=self.do_loading, name="WorldLoading").start()
+
if self.launch_components:
build_uri_popup(self.launch_components, self.launch_args)
self.launch_components = None
self.launch_args = None
+ def do_loading(self):
+ import importlib
+ import time
+ start = time.perf_counter()
+ assert "worlds" not in sys.modules, "worlds module already loaded."
+ importlib.import_module("worlds")
+ logging.error(f"Worlds module loaded in {time.perf_counter() - start:.2f} seconds")
+
+ global refresh_components
+ logger = logging.getLogger("Worlds")
+ logger.info("User Data")
+ self.load_filter()
+
+ refresh_components = self._refresh_components
+ logger.info("Finalizing startup")
+ Clock.schedule_once(self.finish_loading)
+
+ def finish_loading(self, dt):
+ from worlds.LauncherComponents import components
+ from worlds import failed_world_loads
+ logger = logging.getLogger("Worlds")
+ self.failed_worlds = bool(failed_world_loads)
+ for component in components:
+ self.cards.append(self.build_card(component))
+ self._refresh_components(self.current_filter)
+ logger.removeHandler(self.loading_handler)
+ self.screen_manager.current = "main"
+
@staticmethod
def component_action(button):
open_text = "Opening in a new window..."
@@ -418,6 +313,7 @@ def component_action(button):
button.component.func()
else:
# if launch returns False, it started the process in background (not in a new terminal)
+ from worlds.LauncherComponents import get_exe
if not launch(get_exe(button.component), button.component.cli) and button.component.cli:
open_text = "Running in the background..."
@@ -438,6 +334,7 @@ def display_failed(self):
return
from kivymd.uix.dialog import MDDialog, MDDialogIcon, MDDialogHeadlineText, MDDialogContentContainer
from kivymd.uix.divider import MDDivider
+ from worlds import failed_world_loads
from kivymd.uix.list import MDListItem, MDListItemHeadlineText, MDListItemSupportingText
entries = []
for world, reason in failed_world_loads.items():
@@ -459,6 +356,7 @@ def display_failed(self):
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
""" When a patch file is dropped into the window, run the associated component. """
+ from worlds.LauncherComponents import identify
file, component = identify(filename.decode())
if file and component:
run_component(component, file)
@@ -481,6 +379,7 @@ def _stop(self, *largs):
super()._stop(*largs)
def on_stop(self):
+ from worlds.LauncherComponents import Type
Utils.persistent_store("launcher", "favorites", self.favorites)
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
for filter in self.current_filter))
@@ -494,15 +393,11 @@ def on_stop(self):
refresh_components = None
-def run_component(component: Component, *args):
- if component.func:
- component.func(*args)
- if refresh_components:
- refresh_components()
- elif component.script_name:
- subprocess.run([*get_exe(component.script_name), *args])
- else:
- logging.warning(f"Component {component} does not appear to be executable.")
+def run_component(component: "Component", *args):
+ global refresh_components
+ component.run(*args)
+ if refresh_components:
+ refresh_components()
def main(args: argparse.Namespace | dict | None = None):
@@ -522,6 +417,7 @@ def main(args: argparse.Namespace | dict | None = None):
else:
args['launch_components'] = [text_client_component, *components]
else:
+ from worlds.LauncherComponents import identify
file, component = identify(path)
if file:
args['file'] = file
diff --git a/MMBN3Client.py b/MMBN3Client.py
index bd36fae4b80..e2d6e77db69 100644
--- a/MMBN3Client.py
+++ b/MMBN3Client.py
@@ -279,7 +279,7 @@ async def gba_sync_task(ctx: MMBN3Context):
logger.debug("Attempting to connect to GBA")
ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10)
ctx.gba_status = CONNECTION_TENTATIVE_STATUS
- except TimeoutError:
+ except (TimeoutError, OSError):
logger.debug("Connection Timed Out, Trying Again")
ctx.gba_status = CONNECTION_TIMING_OUT_STATUS
continue
diff --git a/Main.py b/Main.py
index 924def653b2..a192d5dbd46 100644
--- a/Main.py
+++ b/Main.py
@@ -207,6 +207,8 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
else:
logger.info("Progression balancing skipped.")
+ logger.info("Running pre-output steps.")
+
AutoWorld.call_all(multiworld, "finalize_multiworld")
AutoWorld.call_all(multiworld, "pre_output")
diff --git a/ModuleUpdate.py b/ModuleUpdate.py
index a8bf3993b3d..80fc64e478e 100644
--- a/ModuleUpdate.py
+++ b/ModuleUpdate.py
@@ -38,7 +38,8 @@ def update(self, *s):
local_dir = os.path.dirname(__file__)
-requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
+core_constraints = os.path.join(local_dir, 'requirements.txt')
+requirements_files = RequirementsSet((core_constraints,))
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
@@ -69,7 +70,7 @@ def confirm(msg: str):
def update_command():
check_pip()
for file in requirements_files:
- subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
+ subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--constraint", core_constraints])
def install_pkg_resources(yes=False):
@@ -79,7 +80,7 @@ def install_pkg_resources(yes=False):
check_pip()
if not yes:
confirm("pkg_resources not found, press enter to install it")
- subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
+ subprocess.call([sys.executable, "-m", "pip", "install", "setuptools>=75,<81"])
def update(yes: bool = False, force: bool = False) -> None:
diff --git a/MultiServer.py b/MultiServer.py
index 28ab853cccf..22c00fbcf07 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -22,6 +22,7 @@
import weakref
import zlib
from signal import SIGINT, SIGTERM, signal
+from typing import Sequence
import ModuleUpdate
@@ -44,8 +45,9 @@
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
- SlotType, LocationStore, MultiData, Hint, HintStatus
+ SlotType, LocationStore, MultiData, Hint, HintStatus, GamesPackage
from BaseClasses import ItemClassification
+from apmw.multiserver.gamespackagecache import GamesPackageCache
min_client_version = Version(0, 5, 0)
@@ -60,6 +62,67 @@
compress_settings={"memLevel": 4},
)
+class BounceTarget(typing.NamedTuple):
+ teams: set[int] | None
+ games: set[str] | None
+ tags: set[str] | None
+ slots: set[int] | None
+
+ def _teams_match(self, target: Client) -> bool:
+ return target.team in self.teams
+
+ def _games_match(self, target: Client) -> bool:
+ return target.ctx().games[target.slot] in self.games
+
+ def _tags_match(self, target: Client) -> bool:
+ return bool(set(target.tags) & self.tags)
+
+ def _slots_match(self, target: Client) -> bool:
+ return target.slot in self.slots
+
+ def _get_conditions(self, include_teams: bool = True) -> Sequence[typing.Callable[[Client], bool]]:
+ conditions = []
+ if self.teams is not None and include_teams:
+ conditions.append(self._teams_match)
+ if self.games is not None:
+ conditions.append(self._games_match)
+ if self.tags is not None:
+ conditions.append(self._tags_match)
+ if self.slots is not None:
+ conditions.append(self._slots_match)
+
+ return conditions
+
+ def match_clients_legacy(self, clients: typing.Iterable[Client]) -> typing.Generator[Client, None, None]:
+ non_team_conditions = self._get_conditions(include_teams=False)
+
+ # Do as little work as possible: Pre-check teams is None
+ if self.teams is None:
+ for bounce_client in clients:
+ if any(condition(bounce_client) for condition in non_team_conditions):
+ yield bounce_client
+ else:
+ for bounce_client in clients:
+ if self._teams_match(bounce_client) and any(
+ condition(bounce_client) for condition in non_team_conditions
+ ):
+ yield bounce_client
+
+ def match_clients_or(self, clients: typing.Iterable[Client]) -> typing.Generator[Client, None, None]:
+ conditions = self._get_conditions(include_teams=True)
+
+ for bounce_client in clients:
+ if any(condition(bounce_client) for condition in conditions):
+ yield bounce_client
+
+ def match_clients_and(self, clients: typing.Iterable[Client]) -> typing.Generator[Client, None, None]:
+ conditions = self._get_conditions(include_teams=True)
+
+ for bounce_client in clients:
+ if all(condition(bounce_client) for condition in conditions):
+ yield bounce_client
+
+
def remove_from_list(container, value):
try:
@@ -241,21 +304,38 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str]
+ played_games: set[str]
item_names: typing.Dict[str, typing.Dict[int, str]]
- item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
+ item_name_groups: typing.Dict[str, typing.Dict[str, list[str]]]
location_names: typing.Dict[str, typing.Dict[int, str]]
- location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
+ location_name_groups: typing.Dict[str, typing.Dict[str, list[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
""" each sphere is { player: { location_id, ... } } """
+ games_package_cache: GamesPackageCache
logger: logging.Logger
- def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
- hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
- countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
- compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ server_password: str,
+ password: str,
+ location_check_points: int,
+ hint_cost: int,
+ item_cheat: bool,
+ release_mode: str = "disabled",
+ collect_mode="disabled",
+ countdown_mode: str = "auto",
+ remaining_mode: str = "disabled",
+ auto_shutdown: typing.SupportsFloat = 0,
+ compatibility: int = 2,
+ log_network: bool = False,
+ games_package_cache: GamesPackageCache | None = None,
+ logger: logging.Logger = logging.getLogger(),
+ ) -> None:
self.logger = logger
super(Context, self).__init__()
self.slot_info = {}
@@ -306,6 +386,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
self.save_dirty = False
self.tags = ['AP']
self.games: typing.Dict[int, str] = {}
+ self.played_games = set()
self.minimum_client_versions: typing.Dict[int, Version] = {}
self.seed_name = ""
self.groups = {}
@@ -315,9 +396,10 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
self.read_data = {}
self.spheres = []
+ self.games_package_cache = games_package_cache or GamesPackageCache()
# init empty to satisfy linter, I suppose
- self.gamespackage = {}
+ self.reduced_games_package = {}
self.checksums = {}
self.item_name_groups = {}
self.location_name_groups = {}
@@ -329,50 +411,11 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
self.non_hintable_names = collections.defaultdict(frozenset)
- self._load_game_data()
-
- # Data package retrieval
- def _load_game_data(self):
- import worlds
- self.gamespackage = worlds.network_data_package["games"]
-
- self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
- worlds.AutoWorldRegister.world_types.items()}
- self.location_name_groups = {world_name: world.location_name_groups for world_name, world in
- worlds.AutoWorldRegister.world_types.items()}
- for world_name, world in worlds.AutoWorldRegister.world_types.items():
- self.non_hintable_names[world_name] = world.hint_blacklist
-
- for game_package in self.gamespackage.values():
- # remove groups from data sent to clients
- del game_package["item_name_groups"]
- del game_package["location_name_groups"]
-
- def _init_game_data(self):
- for game_name, game_package in self.gamespackage.items():
- if "checksum" in game_package:
- self.checksums[game_name] = game_package["checksum"]
- for item_name, item_id in game_package["item_name_to_id"].items():
- self.item_names[game_name][item_id] = item_name
- for location_name, location_id in game_package["location_name_to_id"].items():
- self.location_names[game_name][location_id] = location_name
- self.all_item_and_group_names[game_name] = \
- set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
- self.all_location_and_group_names[game_name] = \
- set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
-
- archipelago_item_names = self.item_names["Archipelago"]
- archipelago_location_names = self.location_names["Archipelago"]
- for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
- # Add Archipelago items and locations to each data package.
- self.item_names[game].update(archipelago_item_names)
- self.location_names[game].update(archipelago_location_names)
-
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
- return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
+ return self.reduced_games_package[game]["item_name_to_id"] if game in self.reduced_games_package else None
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
- return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
+ return self.reduced_games_package[game]["location_name_to_id"] if game in self.reduced_games_package else None
# General networking
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
@@ -482,19 +525,17 @@ def load(self, multidatapath: str, use_embedded_server_options: bool = False):
with open(multidatapath, 'rb') as f:
data = f.read()
- self._load(self.decompress(data), {}, use_embedded_server_options)
+ self._load(self.decompress(data), use_embedded_server_options)
self.data_filename = multidatapath
@staticmethod
- def decompress(data: bytes) -> dict:
+ def decompress(data: bytes) -> typing.Any:
format_version = data[0]
if format_version > 3:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
- def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
- use_embedded_server_options: bool):
-
+ def _load(self, decoded_obj: MultiData, use_embedded_server_options: bool) -> None:
self.read_data = {}
# there might be a better place to put this.
race_mode = decoded_obj.get("race_mode", 0)
@@ -515,6 +556,7 @@ def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typ
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
+ self.played_games = {"Archipelago"} | {self.games[x] for x in range(1, len(self.games) + 1)}
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group}
@@ -559,18 +601,11 @@ def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typ
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
- # embedded data package
- for game_name, data in decoded_obj.get("datapackage", {}).items():
- if game_name in game_data_packages:
- data = game_data_packages[game_name]
- self.logger.info(f"Loading embedded data package for game {game_name}")
- self.gamespackage[game_name] = data
- self.item_name_groups[game_name] = data["item_name_groups"]
- if "location_name_groups" in data:
- self.location_name_groups[game_name] = data["location_name_groups"]
- del data["location_name_groups"]
- del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
+ # load and apply world data and (embedded) data package
+ self._load_world_data()
+ self._load_data_package(decoded_obj.get("datapackage", {}))
self._init_game_data()
+
for game_name, data in self.item_name_groups.items():
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
for game_name, data in self.location_name_groups.items():
@@ -579,6 +614,58 @@ def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typ
# sorted access spheres
self.spheres = decoded_obj.get("spheres", [])
+ def _load_world_data(self) -> None:
+ import worlds
+
+ for world_name, world in worlds.AutoWorldRegister.world_types.items():
+ # TODO: move hint_blacklist into GamesPackage?
+ self.non_hintable_names[world_name] = world.hint_blacklist
+
+ def _load_data_package(self, data_package: dict[str, GamesPackage]) -> None:
+ """Populates reduced_games_package, item_name_groups, location_name_groups from static data and data_package"""
+ # NOTE: for worlds loaded from db, only checksum is set in GamesPackage, but this is handled by cache
+ for game_name in sorted(self.played_games):
+ if game_name in data_package:
+ self.logger.info(f"Loading embedded data package for game {game_name}")
+ data = self.games_package_cache.get(game_name, data_package[game_name])
+ else:
+ # NOTE: we still allow uploading a game without datapackage. Once that is changed, we could drop this.
+ try:
+ data = self.games_package_cache.get_static(game_name)
+ except KeyError:
+ raise Exception(f"Data package for {game_name} missing from Multidata") from None
+ (
+ self.reduced_games_package[game_name],
+ self.item_name_groups[game_name],
+ self.location_name_groups[game_name],
+ ) = data
+
+ del self.games_package_cache # Not used past this point. Free memory.
+
+ def _init_game_data(self) -> None:
+ """Update internal values from previously loaded data packages"""
+ for game_name, game_package in self.reduced_games_package.items():
+ if game_name not in self.played_games:
+ continue
+ if "checksum" in game_package:
+ self.checksums[game_name] = game_package["checksum"]
+ # NOTE: we could save more memory by moving the stuff below to data package cache as well
+ for item_name, item_id in game_package["item_name_to_id"].items():
+ self.item_names[game_name][item_id] = item_name
+ for location_name, location_id in game_package["location_name_to_id"].items():
+ self.location_names[game_name][location_id] = location_name
+ self.all_item_and_group_names[game_name] = \
+ set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
+ self.all_location_and_group_names[game_name] = \
+ set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
+
+ archipelago_item_names = self.item_names["Archipelago"]
+ archipelago_location_names = self.location_names["Archipelago"]
+ for game in [game_name for game_name in self.reduced_games_package if game_name != "Archipelago"]:
+ # Add Archipelago items and locations to each data package.
+ self.item_names[game].update(archipelago_item_names)
+ self.location_names[game].update(archipelago_location_names)
+
# saving
def save(self, now=False) -> bool:
@@ -919,12 +1006,10 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
async def on_client_connected(ctx: Context, client: Client):
- games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
- games.add("Archipelago")
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': bool(ctx.password),
- 'games': games,
+ 'games': sorted(ctx.played_games),
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
@@ -933,8 +1018,7 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
- 'datapackage_checksums': {game: game_data["checksum"] for game, game_data
- in ctx.gamespackage.items() if game in games and "checksum" in game_data},
+ 'datapackage_checksums': ctx.checksums,
'seed_name': ctx.seed_name,
'time': time.time(),
}])
@@ -1940,25 +2024,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
await ctx.send_msgs(client, reply)
elif cmd == "GetDataPackage":
- exclusions = args.get("exclusions", [])
- if "games" in args:
- games = {name: game_data for name, game_data in ctx.gamespackage.items()
- if name in set(args.get("games", []))}
- await ctx.send_msgs(client, [{"cmd": "DataPackage",
- "data": {"games": games}}])
- # TODO: remove exclusions behaviour around 0.5.0
- elif exclusions:
- exclusions = set(exclusions)
- games = {name: game_data for name, game_data in ctx.gamespackage.items()
- if name not in exclusions}
-
- package = {"games": games}
- await ctx.send_msgs(client, [{"cmd": "DataPackage",
- "data": package}])
-
- else:
- await ctx.send_msgs(client, [{"cmd": "DataPackage",
- "data": {"games": ctx.gamespackage}}])
+ games = {
+ name: game_data for name, game_data in ctx.reduced_games_package.items()
+ if name in set(args.get("games", []))
+ }
+ await ctx.send_msgs(client, [{"cmd": "DataPackage", "data": {"games": games}}])
elif client.auth:
if cmd == "ConnectUpdate":
@@ -2147,17 +2217,56 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.messageprocessor(args["text"])
elif cmd == "Bounce":
- games = set(args.get("games", []))
- tags = set(args.get("tags", []))
- slots = set(args.get("slots", []))
+ for name, expected_type in (
+ ("teams", int), ("games", str), ("tags", str), ("slots", int)
+ ):
+ if name not in args:
+ continue
+
+ value = args[name]
+
+ if (
+ value is None
+ or not isinstance(value, (list, set))
+ or not all(isinstance(entry, expected_type) for entry in value)
+ ):
+ await ctx.send_msgs(client, [{
+ "cmd": "InvalidPacket", "type": "arguments",
+ "text": f'Bounce: "{name}" list provided did not have the correct format.',
+ "original_cmd": cmd}])
+ return
+
+ # We now know that if a key is present, it is not None, so this should be the best way to get "set or None"
+ teams = set(args["teams"]) if "teams" in args else {client.team} # Team default is only same team
+ games = set(args["games"]) if "games" in args else None
+ tags = set(args["tags"]) if "tags" in args else None
+ slots = set(args["slots"]) if "slots" in args else None
+
+ bounce_target = BounceTarget(teams, games, tags, slots)
+
args["cmd"] = "Bounced"
msg = ctx.dumper([args])
- for bounceclient in ctx.endpoints:
- if client.team == bounceclient.team and (ctx.games[bounceclient.slot] in games or
- set(bounceclient.tags) & tags or
- bounceclient.slot in slots):
- await ctx.send_encoded_msgs(bounceclient, msg)
+ boolean_operator = args.get("operator", "legacy")
+
+ if boolean_operator == "legacy":
+ match_function = bounce_target.match_clients_legacy
+ elif boolean_operator == "or":
+ match_function = bounce_target.match_clients_or
+ elif boolean_operator == "and":
+ match_function = bounce_target.match_clients_and
+ else:
+ await ctx.send_msgs(client, [{
+ 'cmd': 'InvalidPacket', "type": "arguments",
+ 'text': f'Bounce: Unknown operator. Supported: legacy, or, and. Found: {operator}',
+ 'original_cmd': cmd
+ }])
+ return
+
+ for matching_client in match_function(ctx.endpoints):
+ await ctx.send_encoded_msgs(matching_client, msg)
+
+ return
elif cmd == "Get":
if "keys" not in args or type(args["keys"]) != list:
@@ -2541,7 +2650,14 @@ def value_type(input_text: str):
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
elif option_name in {"hint_cost", "location_check_points"}:
- self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
+ # Update hint point amounts per slot
+ for team, players in self.ctx.clients.items():
+ for slot, clients in players.items():
+ self.ctx.broadcast(clients, [{
+ "cmd": "RoomUpdate",
+ option_name: getattr(self.ctx, option_name),
+ "hint_points": get_slot_points(self.ctx, team, slot),
+ }])
return True
def _cmd_datastore(self):
diff --git a/NetUtils.py b/NetUtils.py
index f792a27288e..3db8a8bd425 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -170,7 +170,39 @@ def _object_hook(o: typing.Any) -> typing.Any:
return o
-decode = JSONDecoder(object_hook=_object_hook).decode
+_decode = JSONDecoder(object_hook=_object_hook).decode
+
+
+try:
+ from _speedups import check_json_depth as _check_depth
+except ImportError:
+ def _check_depth(s: str, limit: int = 16) -> None:
+ depth = 1
+ in_quotes = False
+ escape = False
+ for c in s:
+ if c == "\\" and not escape:
+ escape = True
+ continue
+ if c == '"' and not escape:
+ in_quotes = not in_quotes
+ continue
+ if not in_quotes:
+ if c in "[{":
+ depth += 1
+ if depth > limit:
+ raise ValueError("JSON document too complex")
+ elif c in "}]":
+ depth -= 1
+ escape = False
+
+ if depth != 1: # free check
+ raise ValueError("JSON document malformed")
+
+
+def decode(s: str) -> typing.Any:
+ _check_depth(s) # raises ValueError
+ return _decode(s)
class Endpoint:
diff --git a/Options.py b/Options.py
index 47f4b1d45c1..cf2b04670de 100644
--- a/Options.py
+++ b/Options.py
@@ -938,26 +938,27 @@ class OptionCounter(OptionDict):
min: int | None = None
max: int | None = None
cull_zeroes: bool = False
+ value: collections.Counter[str]
def __init__(self, value: dict[str, int]) -> None:
- cleaned_dict = {}
+ cleaned_dict = collections.Counter()
invalid_value_errors = []
- for key, value in value.items():
- if not isinstance(value, (int, float)) or int(value) != value:
- invalid_value_errors += [f"Invalid value {value} for key {key}, must be an integer."]
+ for key, subvalue in value.items():
+ if not isinstance(subvalue, (int, float)) or int(subvalue) != subvalue:
+ invalid_value_errors += [f"Invalid value {subvalue} for key {key}, must be an integer."]
continue
- if self.cull_zeroes and value == 0:
+ if self.cull_zeroes and subvalue == 0:
continue
- cleaned_dict[key] = int(value)
+ cleaned_dict[key] = int(subvalue)
if invalid_value_errors:
type_errors = [f"For option {self.__class__.__name__}:"] + invalid_value_errors
- raise TypeError("\n".join(invalid_value_errors))
+ raise TypeError("\n".join(type_errors))
- super(OptionCounter, self).__init__(collections.Counter(cleaned_dict))
+ self.value = cleaned_dict
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
@@ -1198,14 +1199,14 @@ def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any
class PlandoConnection(typing.NamedTuple):
- class Direction:
+ class Direction(enum.StrEnum):
entrance = "entrance"
exit = "exit"
both = "both"
entrance: str
exit: str
- direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
+ direction: Direction
percentage: int = 100
diff --git a/README.md b/README.md
index ffe96503588..dd5ad9ea7df 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,7 @@ Currently, the following games are supported:
* Satisfactory
* EarthBound
* Mega Man 3
+* Gauntlet Legends
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
diff --git a/Utils.py b/Utils.py
index b32d863cdd2..b863717fba5 100644
--- a/Utils.py
+++ b/Utils.py
@@ -3,6 +3,7 @@
import asyncio
import concurrent.futures
import json
+import shlex
import typing
import builtins
import os
@@ -16,13 +17,16 @@
import importlib
import logging
import warnings
+import pathlib
from argparse import Namespace
+from collections.abc import Collection, Iterable, Sequence
from datetime import datetime, timezone
+from shutil import which
from settings import Settings, get_settings
from time import sleep
-from typing import BinaryIO, Coroutine, Mapping, Optional, Set, Dict, Any, Union, TypeGuard
+from typing import BinaryIO, Coroutine, Generic, Mapping, Optional, Set, Dict, Any, TypeVar, Union, TypeGuard
from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
@@ -34,7 +38,6 @@
if typing.TYPE_CHECKING:
import tkinter
- import pathlib
from BaseClasses import Region
import multiprocessing
@@ -52,7 +55,7 @@ def as_simple_string(self) -> str:
return ".".join(str(item) for item in self)
-__version__ = "0.6.7"
+__version__ = "0.6.8"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -759,6 +762,48 @@ def env_cleared_lib_path() -> Mapping[str, str]:
return env
+def run_in_terminal(exe: Sequence[str]) -> bool:
+ """
+ Runs the given command/args in `exe` in a new terminal window
+
+ Returns value indicates if a valid terminal was located
+ """
+ if is_windows:
+ # intentionally using a window title with a space so it gets quoted and treated as a title
+ subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
+ return True
+ elif is_linux:
+ # Terminals have started deprecating `-e` flag with some not implementing it at all
+ # `modern_terminals` is a list of terminals which we want/need to use `--` instead
+ # `legacy_terminals` are common aliases for terminals people want to use so checking these are prioritized
+ legacy_terminals = ("x-terminal-emulator", "konsole", "alacritty", "kitty")
+ modern_terminals = ("gnome-terminal", "cosmic-term", "ptyxis")
+
+ terminal: str | None = None
+ for term in itertools.chain(legacy_terminals, modern_terminals, ("xterm",)):
+ terminal = which(term)
+ if terminal:
+ break
+
+ if terminal:
+ # Clear LD_LIB_PATH during terminal startup, but set it again when running command in case it's needed
+ ld_lib_path = os.environ.get("LD_LIBRARY_PATH")
+ lib_path_setter = f"env LD_LIBRARY_PATH={shlex.quote(ld_lib_path)} " if ld_lib_path else ""
+ env = env_cleared_lib_path()
+
+ real_terminal_name = pathlib.Path(terminal).resolve().name
+ if real_terminal_name in modern_terminals:
+ subprocess.Popen([terminal, "--", "sh", "-c", lib_path_setter + shlex.join(exe)], env=env)
+ else:
+ subprocess.Popen([terminal, "-e", "sh", "-c", lib_path_setter + shlex.join(exe)], env=env)
+ return True
+ elif is_macos:
+ terminal = [which("open"), "-W", "-a", "Terminal.app"]
+ subprocess.Popen([*terminal, *exe])
+ return True
+ return False
+
+
def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
@@ -1011,23 +1056,6 @@ def deprecate(message: str, add_stacklevels: int = 0):
warnings.warn(message, stacklevel=2 + add_stacklevels)
-class DeprecateDict(dict):
- log_message: str
- should_error: bool
-
- def __init__(self, message: str, error: bool = False) -> None:
- self.log_message = message
- self.should_error = error
- super().__init__()
-
- def __getitem__(self, item: Any) -> Any:
- if self.should_error:
- deprecate(self.log_message, add_stacklevels=1)
- elif __debug__:
- warnings.warn(self.log_message, stacklevel=2)
- return super().__getitem__(item)
-
-
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
# original upstream issue: https://github.com/python/cpython/issues/76327
@@ -1087,6 +1115,7 @@ def visualize_regions(
file_name: str,
*,
show_entrance_names: bool = False,
+ show_entrance_rules: bool = False,
show_locations: bool = True,
show_other_regions: bool = True,
linetype_ortho: bool = True,
@@ -1099,6 +1128,7 @@ def visualize_regions(
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
:param file_name: The name of the destination .puml file.
:param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection.
+ :param show_entrance_rules: (default False) If enabled, the Rule Builder explanation of the entrance's access rule will be shown near each connection.
:param show_locations: (default True) If enabled, the locations will be listed inside each region.
Priority locations will be shown in bold.
Excluded locations will be stricken out.
@@ -1188,13 +1218,22 @@ def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
return re.sub("[\".:]", "", name)
def visualize_exits(region: Region) -> None:
+ import rule_builder.rules
for exit_ in region.exits:
color_code: str = ""
if exit_.randomization_group in entrance_highlighting:
color_code = f" #{entrance_highlighting[exit_.randomization_group]:0>6X}"
if exit_.connected_region:
+ label = ""
if show_entrance_names:
- uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
+ label += fmt(exit_)
+ if show_entrance_rules:
+ if isinstance(exit_.access_rule, rule_builder.rules.Rule.Resolved):
+ if label:
+ label += "\\n"
+ label += exit_.access_rule.explain_str()
+ if label:
+ uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{label}\"{color_code}")
else:
try:
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
@@ -1270,8 +1309,11 @@ def visualize_other_regions() -> None:
f.write("\n".join(uml))
-class RepeatableChain:
- def __init__(self, iterable: typing.Iterable):
+_T_co = TypeVar("_T_co", covariant=True)
+
+
+class RepeatableChain(Generic[_T_co]):
+ def __init__(self, iterable: Iterable[Collection[_T_co]]):
self.iterable = iterable
def __iter__(self):
@@ -1283,6 +1325,9 @@ def __bool__(self):
def __len__(self):
return sum(len(iterable) for iterable in self.iterable)
+ def __contains__(self, o: object) -> bool:
+ return any(o in sub_iterable for sub_iterable in self.iterable)
+
def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
""" `str` is `Iterable`, but that's not what we want """
@@ -1367,3 +1412,13 @@ def get_all_causes(ex: Exception) -> str:
top = causes[-1]
others = "".join(f"\n{' ' * (i + 1)}Which caused: {c}" for i, c in enumerate(reversed(causes[:-1])))
return f"{top}{others}"
+
+
+_empty_frozenset = frozenset() # empty frozenset singleton
+
+
+def empty_frozenset_factory() -> frozenset:
+ """
+ returns empty frozenset singleton when called
+ """
+ return _empty_frozenset
diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py
index b3dc203a3de..90bd0c2fddc 100644
--- a/WebHostLib/__init__.py
+++ b/WebHostLib/__init__.py
@@ -42,6 +42,7 @@
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
+app.config["GAME_PORTS"] = ["49152-65535", 0]
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py
index 165e08a103f..d101f149ef0 100644
--- a/WebHostLib/autolauncher.py
+++ b/WebHostLib/autolauncher.py
@@ -193,6 +193,7 @@ def __init__(self, config: dict, id: int):
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"]
+ self.game_ports = config["GAME_PORTS"]
self.rooms_to_start = multiprocessing.Queue()
self.rooms_shutting_down = multiprocessing.Queue()
self.name = f"MultiHoster{id}"
@@ -203,7 +204,7 @@ def start(self):
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.name, self.ponyconfig, get_static_server_data(),
- self.cert, self.key, self.host,
+ self.cert, self.key, self.host, self.game_ports,
self.rooms_to_start, self.rooms_shutting_down),
name=self.name)
process.start()
diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py
index 4257c6aff3e..10e44f664d0 100644
--- a/WebHostLib/customserver.py
+++ b/WebHostLib/customserver.py
@@ -4,6 +4,7 @@
import collections
import datetime
import functools
+import itertools
import logging
import multiprocessing
import pickle
@@ -13,7 +14,10 @@
import time
import typing
import sys
+from asyncio import AbstractEventLoop
+from collections.abc import Iterable
+import psutil
import websockets
from pony.orm import commit, db_session, select
@@ -24,8 +28,11 @@
server_per_message_deflate_factory,
)
from Utils import restricted_loads, cache_argsless
+from NetUtils import GamesPackage
+from apmw.webhost.customserver.gamespackagecache import DBGamesPackageCache
+
from .locker import Locker
-from .models import Command, GameDataPackage, Room, db
+from .models import Command, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -62,32 +69,43 @@ def output(self, text: str):
class WebHostContext(Context):
room_id: int
-
- def __init__(self, static_server_data: dict, logger: logging.Logger):
+ video: dict[tuple[int, int], tuple[str, str]]
+ main_loop: AbstractEventLoop
+ static_server_data: StaticServerData
+
+ def __init__(
+ self,
+ static_server_data: StaticServerData,
+ games_package_cache: DBGamesPackageCache,
+ logger: logging.Logger,
+ ) -> None:
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
- self.static_server_data = static_server_data
- super(WebHostContext, self).__init__("", 0, "", "", 1,
- 40, True, "enabled", "enabled",
- "enabled", 0, 2, logger=logger)
- del self.static_server_data
- self.main_loop = asyncio.get_running_loop()
- self.video = {}
+ super(WebHostContext, self).__init__(
+ "",
+ 0,
+ "",
+ "",
+ 1,
+ 40,
+ True,
+ "enabled",
+ "enabled",
+ "enabled",
+ 0,
+ 2,
+ games_package_cache=games_package_cache,
+ logger=logger,
+ )
self.tags = ["AP", "WebHost"]
+ self.video = {}
+ self.main_loop = asyncio.get_running_loop()
+ self.static_server_data = static_server_data
+ self.games_package_cache = games_package_cache
def __del__(self):
- try:
- import psutil
- from Utils import format_SI_prefix
- self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
- except ImportError:
- self.logger.debug("Context destroyed")
-
- def _load_game_data(self):
- for key, value in self.static_server_data.items():
- # NOTE: attributes are mutable and shared, so they will have to be copied before being modified
- setattr(self, key, value)
- self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
+ from Utils import format_SI_prefix
+ self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
async def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self)
@@ -115,45 +133,21 @@ def load(self, room_id: int):
if room.last_port:
self.port = room.last_port
else:
- self.port = get_random_port()
+ self.port = 0
multidata = self.decompress(room.seed.multidata)
- game_data_packages = {}
-
- static_gamespackage = self.gamespackage # this is shared across all rooms
- static_item_name_groups = self.item_name_groups
- static_location_name_groups = self.location_name_groups
- self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
- self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
- self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
- missing_checksum = False
-
- for game in list(multidata.get("datapackage", {})):
- game_data = multidata["datapackage"][game]
- if "checksum" in game_data:
- if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
- # non-custom. remove from multidata and use static data
- # games package could be dropped from static data once all rooms embed data package
- del multidata["datapackage"][game]
- else:
- row = GameDataPackage.get(checksum=game_data["checksum"])
- if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
- game_data_packages[game] = restricted_loads(row.data)
- continue
- else:
- self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
- else:
- missing_checksum = True # Game rolled on old AP and will load data package from multidata
- self.gamespackage[game] = static_gamespackage.get(game, {})
- self.item_name_groups[game] = static_item_name_groups.get(game, {})
- self.location_name_groups[game] = static_location_name_groups.get(game, {})
-
- if not game_data_packages and not missing_checksum:
- # all static -> use the static dicts directly
- self.gamespackage = static_gamespackage
- self.item_name_groups = static_item_name_groups
- self.location_name_groups = static_location_name_groups
- return self._load(multidata, game_data_packages, True)
+ return self._load(multidata, True)
+
+ def _load_world_data(self):
+ # Use static_server_data, but skip static data package since that is in cache anyway.
+ # Also NOT importing worlds here!
+ non_hintable_names = {
+ game: item_names
+ for game, item_names in self.static_server_data["non_hintable_names"].items()
+ if game in self.played_games
+ }
+ self.non_hintable_names = collections.defaultdict(Utils.empty_frozenset_factory, non_hintable_names)
+ del self.static_server_data # Not used past this point. Free memory.
def init_save(self, enabled: bool = True):
self.saving = enabled
@@ -181,38 +175,117 @@ def get_save(self) -> dict:
return d
-def get_random_port():
- return random.randint(49152, 65535)
+class GameRangePorts(typing.NamedTuple):
+ valid_ports: list[int]
+ ephemeral_allowed: bool
+
+
+class RandomPortSocketCreator:
+ """ Creates server sockets on random available ports from a configured range. """
+
+ _next_port_index: int
+ _used_ports_cache: tuple[frozenset[int], int] | None
+ _parsed_ports: GameRangePorts
+
+ def __init__(self, game_ports: Iterable[str | int]) -> None:
+ self._next_port_index = 0
+ self._used_ports_cache = None
+ self._parsed_ports = self._parse_game_ports(game_ports)
+
+ @staticmethod
+ def _parse_game_ports(game_ports: Iterable[str | int]) -> GameRangePorts:
+ """ Parse the game ports configuration into a structured format. """
+ valid_ports: list[int] = []
+ ephemeral_allowed = False
+
+ for item in game_ports:
+ if isinstance(item, str) and "-" in item:
+ start, end = map(int, item.split("-"))
+ x = range(start, end + 1)
+ valid_ports.extend(x)
+ elif int(item) == 0:
+ ephemeral_allowed = True
+ else:
+ valid_ports.append(int(item))
+
+ random.shuffle(valid_ports)
+ return GameRangePorts(valid_ports, ephemeral_allowed)
+
+ @staticmethod
+ def _try_conns_per_process(p: psutil.Process) -> Iterable[int]:
+ """ Get ports from a single process's connections. """
+ try:
+ return (c.laddr.port for c in p.net_connections("tcp4") if c.laddr)
+ except psutil.AccessDenied:
+ return ()
+
+ @staticmethod
+ def _get_active_net_connections() -> Iterable[int]:
+ """ Get all active TCP4 connections on the system. """
+ # Don't even try to check if system using AIX
+ if psutil.AIX:
+ return ()
+
+ try:
+ return (c.laddr.port for c in psutil.net_connections("tcp4") if c.laddr)
+ # raises AccessDenied when done on macOS
+ except psutil.AccessDenied:
+ # flatten the list of iterables
+ return itertools.chain.from_iterable(map(
+ RandomPortSocketCreator._try_conns_per_process,
+ psutil.process_iter(["net_connections"])
+ ))
+
+ def _get_used_ports(self) -> frozenset[int]:
+ """ Get currently used ports with 90-second caching. """
+ t_hash = round(time.monotonic() / 90)
+ if self._used_ports_cache is None or self._used_ports_cache[1] != t_hash:
+ self._used_ports_cache = (frozenset(self._get_active_net_connections()), t_hash)
+
+ return self._used_ports_cache[0]
+
+ def create(self, host: str) -> socket.socket:
+ """ Create a server socket on an available port. """
+ valid_ports, ephemeral_allowed = self._parsed_ports
+ used_ports = self._get_used_ports()
+
+ next_index = self._next_port_index
+ for i, port in enumerate(itertools.chain(valid_ports[next_index:], valid_ports[:next_index])):
+ if port in used_ports:
+ continue
+
+ try:
+ res = socket.create_server((host, port))
+ next_index = (next_index + i + 1) % len(valid_ports)
+ self._next_port_index = next_index
+ return res
+ except OSError:
+ pass
+
+ if ephemeral_allowed:
+ return socket.create_server((host, 0))
+
+ raise OSError(98, "No available ports")
+
+
+class StaticServerData(typing.TypedDict, total=True):
+ non_hintable_names: dict[str, typing.AbstractSet[str]]
+ games_package: dict[str, GamesPackage]
@cache_argsless
-def get_static_server_data() -> dict:
+def get_static_server_data() -> StaticServerData:
import worlds
- data = {
+
+ return {
"non_hintable_names": {
world_name: world.hint_blacklist
for world_name, world in worlds.AutoWorldRegister.world_types.items()
+ if world.hint_blacklist
},
- "gamespackage": {
- world_name: {
- key: value
- for key, value in game_package.items()
- if key not in ("item_name_groups", "location_name_groups")
- }
- for world_name, game_package in worlds.network_data_package["games"].items()
- },
- "item_name_groups": {
- world_name: world.item_name_groups
- for world_name, world in worlds.AutoWorldRegister.world_types.items()
- },
- "location_name_groups": {
- world_name: world.location_name_groups
- for world_name, world in worlds.AutoWorldRegister.world_types.items()
- },
+ "games_package": worlds.network_data_package["games"]
}
- return data
-
def set_up_logging(room_id) -> logging.Logger:
import os
@@ -245,9 +318,19 @@ def tear_down_logging(room_id):
del logging.Logger.manager.loggerDict[logger_name]
-def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
- cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
- host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
+def run_server_process(
+ name: str,
+ ponyconfig: dict[str, typing.Any],
+ static_server_data: StaticServerData,
+ cert_file: str | None,
+ cert_key_file: str | None,
+ host: str,
+ game_ports: Iterable[str | int],
+ rooms_to_run: multiprocessing.Queue,
+ rooms_shutting_down: multiprocessing.Queue,
+) -> None:
+ import gc
+
from setproctitle import setproctitle
setproctitle(name)
@@ -263,6 +346,9 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
del resource, file_limit
+ # prime the data package cache with static data
+ games_package_cache = DBGamesPackageCache(static_server_data["games_package"])
+
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
@@ -270,8 +356,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
- import gc
-
if not cert_file:
def get_ssl_context():
return None
@@ -291,29 +375,36 @@ def get_ssl_context():
gc.collect() # free intermediate objects used during setup
loop = asyncio.get_event_loop()
+ socket_creator = RandomPortSocketCreator(game_ports)
async def start_room(room_id):
with Locker(f"RoomLocker {room_id}"):
try:
logger = set_up_logging(room_id)
- ctx = WebHostContext(static_server_data, logger)
+ ctx = WebHostContext(static_server_data, games_package_cache, logger)
ctx.load(room_id)
ctx.init_save()
assert ctx.server is None
- try:
+ if ctx.port != 0:
+ try:
+ ctx.server = websockets.serve(
+ functools.partial(server, ctx=ctx),
+ ctx.host,
+ ctx.port,
+ ssl=get_ssl_context(),
+ extensions=[server_per_message_deflate_factory],
+ )
+ await ctx.server
+ except OSError:
+ ctx.port = 0
+ if ctx.port == 0:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
- ctx.host,
- ctx.port,
+ sock=socket_creator.create(ctx.host),
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
await ctx.server
- except OSError: # likely port in use
- ctx.server = websockets.serve(
- functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
-
- await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
@@ -388,7 +479,7 @@ def _done(self, task: asyncio.Future):
def run(self):
while 1:
- next_room = rooms_to_run.get(block=True, timeout=None)
+ next_room = rooms_to_run.get(block=True, timeout=None)
gc.collect()
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt
index fd194f22322..25c19cf8692 100644
--- a/WebHostLib/requirements.txt
+++ b/WebHostLib/requirements.txt
@@ -10,5 +10,6 @@ Flask-Cors==6.0.2
bokeh==3.8.2
markupsafe==3.0.3
setproctitle==1.3.7
-mistune==3.2.0
+mistune==3.3.0
docutils==0.22.4
+psutil==7.2.2
diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css
index ab12f320716..9458437c434 100644
--- a/WebHostLib/static/styles/supportedGames.css
+++ b/WebHostLib/static/styles/supportedGames.css
@@ -42,3 +42,7 @@
#games .page-controls button{
margin-left: 0.5rem;
}
+
+#games .author-label{
+ margin-top: 0.5rem;
+}
diff --git a/WebHostLib/templates/supportedGames.html b/WebHostLib/templates/supportedGames.html
index 128278c4b27..01f7fa2cb99 100644
--- a/WebHostLib/templates/supportedGames.html
+++ b/WebHostLib/templates/supportedGames.html
@@ -49,6 +49,13 @@
Currently Supported Games
{{ game_name }}
{{ world.__doc__ | default("No description provided.", true) }}
+ {% if "authors" in world.manifest %}
+ {% if world.manifest["authors"]|length == 1 %}
+ Author: {{ world.manifest["authors"][0] }}
+ {% else %}
+ Authors: {{ world.manifest["authors"] | join(", ") }}
+ {% endif %}
+ {% endif %}
Game Page
{% if world.web.tutorials %}
|
@@ -68,9 +75,6 @@ Currently Supported Games
Report a Bug
{% endif %}
- {% if "authors" in world.manifest %}
- Authors: {{ world.manifest["authors"] | join(", ") }}
- {% endif %}
{% endfor %}
{% endblock %}
diff --git a/_speedups.pyx b/_speedups.pyx
index 2ad1a2953a2..8112a95846a 100644
--- a/_speedups.pyx
+++ b/_speedups.pyx
@@ -376,3 +376,27 @@ cdef class PlayerLocationProxy:
count = self._store.sender_index[self._player].count
for entry in self._store.entries[start:start+count]:
yield entry.location, (entry.item, entry.receiver, entry.flags)
+
+
+cpdef void check_json_depth(s: str, limit: int = 16):
+ cdef Py_ssize_t depth = 1
+ cdef bool in_quotes = False
+ cdef bool escape = False
+ for c in s:
+ if c == "\\" and not escape:
+ escape = True
+ continue
+ if c == '"' and not escape:
+ in_quotes = not in_quotes
+ continue
+ if not in_quotes:
+ if c in "[{":
+ depth += 1
+ if depth > limit:
+ raise ValueError("JSON document too complex")
+ elif c in "}]":
+ depth -= 1
+ escape = False
+
+ if depth != 1: # free check
+ raise ValueError("JSON document malformed")
diff --git a/apmw/__init__.py b/apmw/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apmw/multiserver/__init__.py b/apmw/multiserver/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apmw/multiserver/gamespackagecache.py b/apmw/multiserver/gamespackagecache.py
new file mode 100644
index 00000000000..c90200c7445
--- /dev/null
+++ b/apmw/multiserver/gamespackagecache.py
@@ -0,0 +1,96 @@
+import typing as t
+from weakref import WeakValueDictionary
+
+from NetUtils import GamesPackage
+
+GameAndChecksum = tuple[str, str | None]
+ItemNameGroups = dict[str, list[str]]
+LocationNameGroups = dict[str, list[str]]
+
+
+K = t.TypeVar("K")
+V = t.TypeVar("V")
+
+
+class DictLike(dict[K, V]):
+ __slots__ = ("__weakref__",)
+
+
+class GamesPackageCache:
+ # NOTE: this uses 3 separate collections because unpacking the get() result would end the container lifetime
+ _reduced_games_packages: WeakValueDictionary[GameAndChecksum, GamesPackage]
+ """Does not include item_name_groups nor location_name_groups"""
+ _item_name_groups: WeakValueDictionary[GameAndChecksum, dict[str, list[str]]]
+ _location_name_groups: WeakValueDictionary[GameAndChecksum, dict[str, list[str]]]
+
+ def __init__(self) -> None:
+ self._reduced_games_packages = WeakValueDictionary()
+ self._item_name_groups = WeakValueDictionary()
+ self._location_name_groups = WeakValueDictionary()
+
+ def _get(
+ self,
+ cache_key: GameAndChecksum,
+ ) -> tuple[GamesPackage | None, ItemNameGroups | None, LocationNameGroups | None]:
+ if cache_key[1] is None:
+ return None, None, None
+ return (
+ self._reduced_games_packages.get(cache_key, None),
+ self._item_name_groups.get(cache_key, None),
+ self._location_name_groups.get(cache_key, None),
+ )
+
+ def get(
+ self,
+ game: str,
+ full_games_package: GamesPackage,
+ ) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
+ """Loads and caches embedded data package provided by multidata"""
+ cache_key = (game, full_games_package.get("checksum", None))
+ cached_reduced_games_package, cached_item_name_groups, cached_location_name_groups = self._get(cache_key)
+
+ if cached_reduced_games_package is None:
+ cached_reduced_games_package = t.cast(
+ t.Any,
+ DictLike(
+ {
+ "item_name_to_id": full_games_package["item_name_to_id"],
+ "location_name_to_id": full_games_package["location_name_to_id"],
+ "checksum": full_games_package.get("checksum", None),
+ }
+ ),
+ )
+ if cache_key[1] is not None: # only cache if checksum is available
+ self._reduced_games_packages[cache_key] = cached_reduced_games_package
+
+ if cached_item_name_groups is None:
+ # optimize strings to be references instead of copies
+ item_names = {name: name for name in cached_reduced_games_package["item_name_to_id"].keys()}
+ cached_item_name_groups = DictLike(
+ {
+ group_name: [item_names.get(item_name, item_name) for item_name in group_items]
+ for group_name, group_items in full_games_package["item_name_groups"].items()
+ }
+ )
+ if cache_key[1] is not None: # only cache if checksum is available
+ self._item_name_groups[cache_key] = cached_item_name_groups
+
+ if cached_location_name_groups is None:
+ # optimize strings to be references instead of copies
+ location_names = {name: name for name in cached_reduced_games_package["location_name_to_id"].keys()}
+ cached_location_name_groups = DictLike(
+ {
+ group_name: [location_names.get(location_name, location_name) for location_name in group_locations]
+ for group_name, group_locations in full_games_package.get("location_name_groups", {}).items()
+ }
+ )
+ if cache_key[1] is not None: # only cache if checksum is available
+ self._location_name_groups[cache_key] = cached_location_name_groups
+
+ return cached_reduced_games_package, cached_item_name_groups, cached_location_name_groups
+
+ def get_static(self, game: str) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
+ """Loads legacy data package from installed worlds"""
+ import worlds
+
+ return self.get(game, worlds.network_data_package["games"][game])
diff --git a/apmw/webhost/__init__.py b/apmw/webhost/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apmw/webhost/customserver/__init__.py b/apmw/webhost/customserver/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/apmw/webhost/customserver/gamespackagecache.py b/apmw/webhost/customserver/gamespackagecache.py
new file mode 100644
index 00000000000..5714d072dde
--- /dev/null
+++ b/apmw/webhost/customserver/gamespackagecache.py
@@ -0,0 +1,42 @@
+from typing_extensions import override
+
+from NetUtils import GamesPackage
+from Utils import restricted_loads
+from apmw.multiserver.gamespackagecache import GamesPackageCache, ItemNameGroups, LocationNameGroups
+
+
+class DBGamesPackageCache(GamesPackageCache):
+ _static: dict[str, tuple[GamesPackage, ItemNameGroups, LocationNameGroups]]
+
+ def __init__(self, static_games_package: dict[str, GamesPackage]) -> None:
+ super().__init__()
+ self._static = {
+ game: GamesPackageCache.get(self, game, games_package)
+ for game, games_package in static_games_package.items()
+ }
+
+ @override
+ def get(
+ self,
+ game: str,
+ full_games_package: GamesPackage,
+ ) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
+ # for games started on webhost, full_games_package is likely unpopulated and only has the checksum field
+ cache_key = (game, full_games_package.get("checksum", None))
+ cached = self._get(cache_key)
+ if all(value is not None for value in cached):
+ return cached # type: ignore # mypy doesn't understand all value is not None
+
+ if "checksum" not in full_games_package:
+ return super().get(game, full_games_package) # no checksum, assume fully populated
+
+ from WebHostLib.models import GameDataPackage
+
+ row: GameDataPackage | None = GameDataPackage.get(checksum=full_games_package["checksum"])
+ if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8 ...
+ return super().get(game, restricted_loads(row.data))
+ return super().get(game, full_games_package) # ... in which case full_games_package should be populated
+
+ @override
+ def get_static(self, game: str) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
+ return self._static[game]
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 00000000000..f884bb41e1f
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,31 @@
+import os
+
+
+def pytest_configure(config):
+ # AP_TEST_WORLDS implies `-m world` unless an explicit -m was given; the scoping itself lives in
+ # worlds/__init__
+ if os.environ.get("AP_TEST_WORLDS") and not config.option.markexpr:
+ config.option.markexpr = "world"
+
+
+def pytest_ignore_collect(collection_path, config):
+ # skip worlds//... for any world not named, so other worlds are never imported
+ env = os.environ.get("AP_TEST_WORLDS")
+ if not env:
+ return None
+ selected = {name.strip() for name in env.split(",") if name.strip()}
+ parts = collection_path.parts
+ if "worlds" in parts:
+ i = parts.index("worlds")
+ if i + 1 < len(parts) and parts[i + 1] not in selected:
+ return True
+ return None
+
+
+def pytest_collection_modifyitems(items):
+ # mark for `-m world`: classes with `world_relevant = True`, plus anything under worlds/ (nodeid is
+ # always "/"-separated and relative to rootdir)
+ for item in items:
+ if getattr(getattr(item, "cls", None), "world_relevant", False) or \
+ item.nodeid.split("/", 1)[0] == "worlds":
+ item.add_marker("world")
diff --git a/data/launcher.kv b/data/launcher.kv
index a52214a7a4d..53877c7fcbb 100644
--- a/data/launcher.kv
+++ b/data/launcher.kv
@@ -1,3 +1,4 @@
+#:import Utils Utils
:
id: main
style: "filled"
@@ -61,7 +62,6 @@
text: "Open"
-#:import Type worlds.LauncherComponents.Type
MDFloatLayout:
id: top_screen
@@ -79,7 +79,7 @@ MDFloatLayout:
MDButton:
id: all
style: "text"
- type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
+ type: ("CLIENT", "TOOL", "ADJUSTER", "MISC")
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -89,7 +89,7 @@ MDFloatLayout:
MDButton:
id: client
style: "text"
- type: (Type.CLIENT, )
+ type: ("CLIENT", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -99,7 +99,7 @@ MDFloatLayout:
MDButton:
id: Tool
style: "text"
- type: (Type.TOOL, )
+ type: ("TOOL", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -109,7 +109,7 @@ MDFloatLayout:
MDButton:
id: adjuster
style: "text"
- type: (Type.ADJUSTER, )
+ type: ("ADJUSTER", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -119,7 +119,7 @@ MDFloatLayout:
MDButton:
id: misc
style: "text"
- type: (Type.MISC, )
+ type: ("MISC", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -168,3 +168,24 @@ MDFloatLayout:
ScrollBox:
id: button_layout
+
+
+:
+ label: label
+ md_bg_color: self.theme_cls.backgroundColor
+ ApAsyncImage:
+ source: Utils.local_path("data/icon.png")
+ pos_hint: {"center_x": 0.5, "center_y": 0.5}
+ size_hint: None, None
+ size: 512, 512
+ opacity: 0.5
+ MDCircularProgressIndicator:
+ pos_hint: {"center_x": 0.5, "center_y": 0.5}
+ size: 550, 550
+ size_hint: None, None
+ MDLabel:
+ id: label
+ text: "Loading..."
+ halign: "center"
+ pos_hint: {"center_x": 0.5, "center_y": 0.5}
+ theme_text_color: "Primary"
diff --git a/data/lua/base64.lua b/data/lua/base64.lua
index ebe80643531..2f02340df99 100644
--- a/data/lua/base64.lua
+++ b/data/lua/base64.lua
@@ -5,11 +5,7 @@ local base64 = {}
local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode
if not extract then
- if _G._VERSION == "Lua 5.4" then
- extract = load[[return function( v, from, width )
- return ( v >> from ) & ((1 << width) - 1)
- end]]()
- elseif _G.bit then -- LuaJIT
+ if _G.bit then -- LuaJIT
local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band
extract = function( v, from, width )
return band( shr( v, from ), shl( 1, width ) - 1 )
@@ -27,6 +23,10 @@ if not extract then
end
return w
end
+ else -- Lua 5.3+
+ extract = load[[return function( v, from, width )
+ return ( v >> from ) & ((1 << width) - 1)
+ end]]()
end
end
diff --git a/data/lua/connector_bizhawk_generic.lua b/data/lua/connector_bizhawk_generic.lua
index 387ca2c6f3b..98507af45bd 100644
--- a/data/lua/connector_bizhawk_generic.lua
+++ b/data/lua/connector_bizhawk_generic.lua
@@ -633,8 +633,8 @@ end)
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
print("Must use BizHawk 2.7.0 or newer")
else
- if bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 10) then
- print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.10.")
+ if bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 11) then
+ print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.11.")
end
if emu.getsystemid() == "NULL" then
diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS
index 357c4c5a35a..3665a6070c0 100644
--- a/docs/CODEOWNERS
+++ b/docs/CODEOWNERS
@@ -80,6 +80,9 @@
# Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0
+# Gauntlet Legends
+/worlds/gl/ @jamesbrq
+
# Heretic
/worlds/heretic/ @Daivuk @KScl
@@ -209,7 +212,7 @@
/worlds/subnautica/ @Berserker66
# Terraria
-/worlds/terraria/ @Seldom-SE
+/worlds/terraria/ @desperandos101
# Timespinner
/worlds/timespinner/ @Jarno458
diff --git a/docs/apworld specification.md b/docs/apworld specification.md
index 64d273b0387..cd311e01489 100644
--- a/docs/apworld specification.md
+++ b/docs/apworld specification.md
@@ -44,6 +44,26 @@ These get automatically added to the `archipelago.json` of an .apworld if it is
["Build APWorlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
+### Choosing `minimum_ap_version` and `maximum_ap_version`
+
+Both fields are optional, and most worlds only ever need `minimum_ap_version`.
+
+* **`minimum_ap_version`** - the most cost-effective approach is to set it to the latest stable Archipelago
+ version when you first create your world, then only raise it when you deliberately start using a new core
+ feature that requires a newer version. When you want such a feature you can choose to either bump
+ `minimum_ap_version`, write code that supports both the old and new core conditionally (for example a
+ `try`/`except` around a moved import), or decide the feature is not worth it and leave it alone. There is
+ usually no need to determine your world's "true" minimum version, since most players run the latest release
+ or close to it.
+* **`maximum_ap_version`** - rarely needed. Only set it when you already know a particular Archipelago version
+ breaks your world and you cannot quickly fix it or handle the difference conditionally. Most incompatibilities
+ are better resolved by updating the world instead. The main legitimate use case is a world or tool that is
+ tightly coupled to core's generation behavior, where supporting both sides of a breaking change in a single
+ release is not feasible.
+
+When present, both fields use the same `"major.minor.build"` string format as the Archipelago version itself,
+for example `"0.6.4"`.
+
### "Build APWorlds" Launcher Component
In the Archipelago Launcher (on [source only](/docs/running%20from%20source.md)), there is a "Build APWorlds"
diff --git a/docs/network protocol.md b/docs/network protocol.md
index c3e49565575..1ed115d0ab2 100644
--- a/docs/network protocol.md
+++ b/docs/network protocol.md
@@ -220,12 +220,14 @@ Sent to clients to provide what is known as a 'data package' which contains info
Sent to clients after a client requested this message be sent to them, more info in the [Bounce](#Bounce) package.
#### Arguments
-| Name | Type | Notes |
-| ---- | ---- | ----- |
-| games | list\[str\] | Optional. Game names this message is targeting |
-| slots | list\[int\] | Optional. Player slot IDs that this message is targeting |
-| tags | list\[str\] | Optional. Client [Tags](#Tags) this message is targeting |
-| data | dict | Optional. The data in the [Bounce](#Bounce) package copied |
+| Name | Type | Notes |
+|----------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------|
+| teams | list\[int\] | Optional. Teams this message is targeting. Default assumed value when the key is not present is own team. |
+| games | list\[str\] | Optional. Game names this message is targeting |
+| slots | list\[int\] | Optional. Player slot IDs that this message is targeting |
+| tags | list\[str\] | Optional. Client [Tags](#Tags) this message is targeting |
+| operator | str | Optional. Specifies whether the "teams", games", "slots" and "tags" conditions are chained via "or", "and" or using the "legacy" operator. |
+| data | dict | Optional. The data in the [Bounce](#Bounce) package copied |
### InvalidPacket
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
@@ -416,16 +418,22 @@ Requests the data package from the server. Does not require client authenticatio
| games | list\[str\] | Optional. If specified, will only send back the specified data. Such as, \["Factorio"\] -> Datapackage with only Factorio data. |
### Bounce
-Send this message to the server, tell it which clients should receive the message and
-the server will forward the message to all those targets to which any one requirement applies.
+Send this message to the server, tell it which clients should receive the message and the server will forward
+the message to all those targets to which the requirements ("teams", "games", "slots", "tags") apply according
+to the operator chosen:
+- "or": Conditions are chained with "or".
+- "and": Conditions are chained with "and". (Important note: A completely missing key evaluates as **True**, whereas an empty list evaluates as **False**)
+- "legacy": Evaluates as `teams and (games or slots or tags)`.
#### Arguments
-| Name | Type | Notes |
-| ------ | ----- | ------ |
-| games | list\[str\] | Optional. Game names that should receive this message |
-| slots | list\[int\] | Optional. Player IDs that should receive this message |
-| tags | list\[str\] | Optional. Client tags that should receive this message |
-| data | dict | Optional. Any data you want to send |
+| Name | Type | Notes |
+|----------|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
+| teams | list\[int\] | Optional. Teams that should receive this message. Defaults to own team. |
+| games | list\[str\] | Optional. Game names that should receive this message |
+| slots | list\[int\] | Optional. Player IDs that should receive this message |
+| tags | list\[str\] | Optional. Client tags that should receive this message |
+| operator | str | Optional. Controls how the "teams", games", "slots" and "tags" conditions are chained. Options are "or", "and" and "legacy". Default: "legacy". |
+| data | dict | Optional. Any data you want to send |
### Get
Used to request a single or multiple values from the server's data storage, see the [Set](#Set) package for how to write values to the data storage. A Get package will be answered with a [Retrieved](#Retrieved) package.
diff --git a/docs/tests.md b/docs/tests.md
index 3c2956db930..95f2abd6341 100644
--- a/docs/tests.md
+++ b/docs/tests.md
@@ -137,10 +137,26 @@ tests folder within your world.
You can also find the 'Archipelago Unittests' as an option in the dropdown at the top of the window
next to the run and debug buttons.
+To run the suite scoped to a single world, use the shared **APQuest Tests** run configuration in the dropdown.
+To test your own world, duplicate it in *Edit Configurations…* and change the `AP_TEST_WORLDS` environment
+variable to your world's folder name.
+
#### Running Tests without Pycharm
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
+#### Running Tests for Specific Worlds
+
+Set the `AP_TEST_WORLDS` environment variable to a comma-separated list of world **folder** names to scope a run
+to just those worlds:
+
+```
+AP_TEST_WORLDS=apquest pytest
+```
+
+Pass several worlds with a comma, e.g. `AP_TEST_WORLDS=apquest,pokemon_emerald`. Add
+`--continue-on-collection-errors` if your environment is missing the webhost requirements (`flask`, etc.).
+
#### Running Tests Multithreaded
pytest can run multiple test runners in parallel with the pytest-xdist extension.
diff --git a/docs/webhost configuration sample.yaml b/docs/webhost configuration sample.yaml
index 93094f1ce73..a250e8eae7f 100644
--- a/docs/webhost configuration sample.yaml
+++ b/docs/webhost configuration sample.yaml
@@ -17,6 +17,12 @@
# Web hosting port
#PORT: 80
+# Ports used for game hosting. Values can be specific ports, port ranges or both. Default is: [49152-65535, 0]
+# Zero means it will use a random free port if there is no free port in the ranges specified
+# Examples of valid values: [40000-41000, 49152-65535]
+# If ports within the range(s) are already in use, the WebHost will fallback to the default [49152-65535, 0] range.
+#GAME_PORTS: [49152-65535, 0]
+
# Place where uploads go.
#UPLOAD_FOLDER: uploads
diff --git a/inno_setup.iss b/inno_setup.iss
index 50c400550ba..2a31d80d55a 100644
--- a/inno_setup.iss
+++ b/inno_setup.iss
@@ -206,11 +206,17 @@ Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archi
Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
+
Root: HKCR; Subkey: ".apmm3"; ValueData: "{#MyAppName}mm3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch"; ValueData: "Archipelago Mega Man 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
+Root: HKCR; Subkey: ".apgl"; ValueData: "{#MyAppName}glpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
+Root: HKCR; Subkey: "{#MyAppName}glpatch"; ValueData: "Archipelago Gauntlet Legends Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
+Root: HKCR; Subkey: "{#MyAppName}glpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
+Root: HKCR; Subkey: "{#MyAppName}glpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
+
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
diff --git a/kvui.py b/kvui.py
index 8fcf9a75426..d02769dde04 100644
--- a/kvui.py
+++ b/kvui.py
@@ -113,6 +113,10 @@
remove_between_brackets = re.compile(r"\[.*?]")
+def kv_unescape(text: str) -> str:
+ return text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]")
+
+
class ThemedApp(MDApp):
def set_colors(self):
text_colors = KivyJSONtoTextParser.TextColors()
@@ -122,6 +126,22 @@ def set_colors(self):
self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast
+class LogtoLoadingScreen(logging.Handler):
+ def __init__(self, on_log):
+ super().__init__()
+ self.on_log = on_log
+
+ def handle(self, record: logging.LogRecord):
+ self.on_log(record.getMessage())
+
+
+class LoadingScreen(MDScreen):
+ label = ObjectProperty(None)
+
+ def update_text(self, text):
+ self.label.text = text
+
+
class ImageIcon(MDButtonIcon, AsyncImage):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -350,7 +370,7 @@ def get_text(self):
ctx = self.ctx
text = f"Connected to: {ctx.server_address}."
if ctx.slot is not None:
- text += f"\nYou are Slot Number {ctx.slot} in Team Number {ctx.team}, " \
+ text += f"\nYou are Slot Number {ctx.slot} in Team Number {ctx.team + 1}, " \
f"named {ctx.player_names[ctx.slot]}."
if ctx.items_received:
text += f"\nYou have received {len(ctx.items_received)} items. " \
@@ -424,14 +444,14 @@ def on_touch_down(self, touch):
else:
# Not a fan of the following few lines, but they work.
temp = MarkupLabel(text=self.text).markup
- text = "".join(part for part in temp if not part.startswith("["))
+ text = kv_unescape("".join(part for part in temp if not part.startswith("[")))
cmdinput = MDApp.get_running_app().textinput
if not cmdinput.text:
input_text = get_input_text_from_response(text, MDApp.get_running_app().last_autofillable_command)
if input_text is not None:
cmdinput.text = input_text
- Clipboard.copy(text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
+ Clipboard.copy(text)
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
@@ -544,9 +564,7 @@ def on_text(self, instance, value):
item_names = ctx.item_names._game_store[ctx.game].values()
def on_press(text):
- split_text = MarkupLabel(text=text).markup
- self.set_text(self, "".join(text_frag for text_frag in split_text
- if not text_frag.startswith("[")))
+ self.set_text(self, text)
self.dropdown.dismiss()
self.focus = True
@@ -557,11 +575,13 @@ def on_press(text):
except ValueError:
pass # substring not found
else:
- text = escape_markup(item_name)
- text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
+ prefix = escape_markup(item_name[:index])
+ matching = escape_markup(item_name[index:index+len(value)])
+ postfix = escape_markup(item_name[index+len(value):])
+ text = f"{prefix}[b]{matching}[/b]{postfix}"
self.dropdown.items.append({
"text": text,
- "on_release": lambda txt=text: on_press(txt),
+ "on_release": lambda txt=item_name: on_press(txt),
"markup": True
})
if not self.dropdown.parent:
@@ -653,7 +673,7 @@ def on_touch_down(self, touch):
else "", ". (", self.status_text.lower(), ")"))
temp = MarkupLabel(text).markup
text = "".join(part for part in temp if not part.startswith("["))
- Clipboard.copy(escape_markup(text).replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
+ Clipboard.copy(kv_unescape(text))
return self.parent.select_with_touch(self.index, touch)
else:
parent = self.parent
diff --git a/pytest.ini b/pytest.ini
index f050d58b70b..a447573bead 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -5,3 +5,5 @@ python_functions = test
testpaths =
test
worlds
+markers =
+ world: general tests that iterate over every registered world (scope with AP_TEST_WORLDS)
diff --git a/requirements.txt b/requirements.txt
index 3a91b166802..f75b29a03d3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,7 +6,7 @@ jinja2==3.1.6
schema==0.7.8
kivy==2.3.1
bsdiff4==1.2.6
-platformdirs==4.9.4
+platformdirs==4.10.1
certifi==2026.2.25
cython==3.2.4
cymem==2.0.13
diff --git a/rule_builder/rules.py b/rule_builder/rules.py
index d940eeb386f..ac5037d2a0f 100644
--- a/rule_builder/rules.py
+++ b/rule_builder/rules.py
@@ -425,6 +425,7 @@ def entrance_dependencies(self) -> dict[str, set[int]]:
return combined_deps
+@dataclasses.dataclass(init=False)
class AtLeast(NestedRule[TWorld], game="Archipelago"):
"""A rule that returns true when at least N child rules evaluate as true"""
@@ -593,7 +594,7 @@ def from_resolved(cls, world: TWorld, children_to_process: list[Rule.Resolved])
clauses.append(child)
if not clauses and not items:
- return true_rule or False_().resolve(world)
+ return true_rule or True_().resolve(world)
if len(items) == 1:
item, count = next(iter(items.items()))
@@ -843,9 +844,12 @@ class Has(Rule[TWorld], game="Archipelago"):
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
+ count = resolve_field(self.count, world, int)
+ if count <= 0:
+ return True_().resolve(world)
return self.Resolved(
resolve_field(self.item_name, world, str),
- count=resolve_field(self.count, world, int),
+ count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1403,14 +1407,16 @@ def __init__(
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
+ count = resolve_field(self.count, world, int)
+ if count <= 0:
+ return True_().resolve(world)
if len(self.item_names) == 0:
- # match state.has_from_list
return False_().resolve(world)
if len(self.item_names) == 1:
return Has(self.item_names[0], self.count).resolve(world)
return self.Resolved(
self.item_names,
- count=resolve_field(self.count, world, int),
+ count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1538,8 +1544,9 @@ def __init__(
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
count = resolve_field(self.count, world, int)
- if len(self.item_names) == 0 or len(self.item_names) < count:
- # match state.has_from_list_unique
+ if count <= 0:
+ return True_().resolve(world)
+ if len(self.item_names) < count:
return False_().resolve(world)
if len(self.item_names) == 1:
return Has(self.item_names[0]).resolve(world)
@@ -1657,11 +1664,14 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
+ count = resolve_field(self.count, world, int)
+ if count <= 0:
+ return True_().resolve(world)
item_names = tuple(sorted(world.item_name_groups[self.item_name_group]))
return self.Resolved(
self.item_name_group,
item_names,
- count=resolve_field(self.count, world, int),
+ count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1731,11 +1741,16 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
+ count = resolve_field(self.count, world, int)
+ if count <= 0:
+ return True_().resolve(world)
item_names = tuple(sorted(world.item_name_groups[self.item_name_group]))
+ if len(item_names) < count:
+ return False_().resolve(world)
return self.Resolved(
self.item_name_group,
item_names,
- count=resolve_field(self.count, world, int),
+ count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
diff --git a/setup.py b/setup.py
index 9aa240462ba..e999faba812 100644
--- a/setup.py
+++ b/setup.py
@@ -22,7 +22,7 @@
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
-requirement = 'cx-Freeze==8.4.0'
+requirement = "cx-Freeze==8.6.4"
try:
import pkg_resources
try:
@@ -41,9 +41,9 @@
except ImportError:
raise RuntimeError("pip not available. Please install pip.")
# install and import cx_freeze
- if '--yes' not in sys.argv and '-y' not in sys.argv:
- input(f'Requirement {requirement} is not satisfied, press enter to install it')
- subprocess.call([sys.executable, '-m', 'pip', 'install', requirement, '--upgrade'])
+ if "--yes" not in sys.argv and "-y" not in sys.argv:
+ input(f"Requirement {requirement} is not satisfied, press enter to install it")
+ subprocess.call([sys.executable, "-m", "pip", "install", requirement, "-c", "setup_constraints.txt"])
import pkg_resources
import cx_Freeze
@@ -188,7 +188,7 @@ def resolve_icon(icon_name: str):
script=f"{c.script_name}.py",
target_name=c.frozen_name + (".exe" if is_windows else ""),
icon=resolve_icon(c.icon),
- base="Win32GUI" if is_windows and not c.cli else None
+ base="gui" if is_windows and not c.cli else None
) for c in components if c.script_name and c.frozen_name
]
diff --git a/setup_constraints.txt b/setup_constraints.txt
new file mode 100644
index 00000000000..d87552fb022
--- /dev/null
+++ b/setup_constraints.txt
@@ -0,0 +1 @@
+setuptools>=75,<81
diff --git a/test/bases.py b/test/bases.py
index 19b19bea67e..999865a74b8 100644
--- a/test/bases.py
+++ b/test/bases.py
@@ -202,7 +202,7 @@ def test_all_state_can_reach_everything(self):
if not (self.run_default_tests and self.constructed):
return
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
- state = self.multiworld.get_all_state(False)
+ state = self.multiworld.get_all_state()
for location in self.multiworld.get_locations():
with self.subTest("Location should be reached", location=location.name):
reachable = location.can_reach(state)
diff --git a/test/benchmark/locations.py b/test/benchmark/locations.py
index 245dcb604e0..f7c1775225f 100644
--- a/test/benchmark/locations.py
+++ b/test/benchmark/locations.py
@@ -88,7 +88,7 @@ def main(self):
if not locations:
continue
- all_state = multiworld.get_all_state(False)
+ all_state = multiworld.get_all_state()
for location in locations:
time_taken = self.location_test(location, multiworld.state, "empty_state")
summary_data["empty_state"][location.name] = time_taken
diff --git a/test/general/__init__.py b/test/general/__init__.py
index 53f77a516af..dbe6a178857 100644
--- a/test/general/__init__.py
+++ b/test/general/__init__.py
@@ -70,10 +70,12 @@ def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str,
class TestWebWorld(WebWorld):
+ __test__ = False # World subclass, not a test case; opt out of pytest.ini's python_classes = Test
tutorials = []
class TestWorld(World):
+ __test__ = False # World subclass, not a test case; opt out of pytest.ini's python_classes = Test
game = f"Test Game"
item_name_to_id = {}
location_name_to_id = {}
diff --git a/test/general/test_bounce.py b/test/general/test_bounce.py
new file mode 100644
index 00000000000..65c94cac007
--- /dev/null
+++ b/test/general/test_bounce.py
@@ -0,0 +1,273 @@
+import unittest
+from unittest.mock import MagicMock
+
+from MultiServer import BounceTarget
+
+
+def create_mock_client(team: int, game: str, tags: set[str], slot: int):
+ client = MagicMock()
+ client.team = team
+ client.tags = tags
+ client.slot = slot
+
+ ctx = MagicMock()
+ ctx.games = {slot: game}
+
+ client.ctx = lambda: ctx
+
+ return client
+
+
+TEAM_1_SLOT_1 = create_mock_client(1, "TestGame", set(), 1)
+TEAM_1_SLOT_2 = create_mock_client(1, "TestGame", {"DeathLink", "TrapLink"}, 2)
+TEAM_1_SLOT_3 = create_mock_client(1, "TestGame 2", {"DeathLink"}, 3)
+TEAM_2_SLOT_1 = create_mock_client(2, "TestGame", set(), 1)
+TEAM_2_SLOT_2 = create_mock_client(2, "TestGame", {"TrapLink"}, 2)
+TEAM_2_SLOT_3 = create_mock_client(2, "TestGame 2", {"DeathLink"}, 3)
+
+ALL_CLIENTS = [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_1_SLOT_3, TEAM_2_SLOT_1, TEAM_2_SLOT_2, TEAM_2_SLOT_3]
+
+
+class TestBounceTargetLegacy(unittest.TestCase):
+ """
+ Test various BounceTarget configurations against six mock clients with different teams, games, tags and slots.
+
+ Uses the "legacy" operator, which is defined as: "team and (game or tags or slot)".
+ """
+
+ def test_legacy_bounce_target_default(self):
+ bounce_target = BounceTarget(None, None, None, None)
+
+ self.assertEqual(list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [])
+
+ def test_legacy_bounce_team_only(self):
+ bounce_target = BounceTarget({1}, None, None, None)
+
+ self.assertEqual(list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [])
+
+ def test_legacy_tags_condition_only(self):
+ bounce_target = BounceTarget(None, None, {"DeathLink"}, None)
+
+ self.assertEqual(
+ list(bounce_target.match_clients_legacy(ALL_CLIENTS)),
+ [TEAM_1_SLOT_2, TEAM_1_SLOT_3, TEAM_2_SLOT_3],
+ )
+
+ def test_legacy_slot_condition_only(self):
+ bounce_target = BounceTarget(None, None, None, {2})
+
+ self.assertEqual(list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [TEAM_1_SLOT_2, TEAM_2_SLOT_2])
+
+ def test_legacy_empty_conditions(self):
+ bounce_target = BounceTarget(set(), set(), set(), set())
+
+ self.assertEqual(list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [])
+
+ def test_legacy_empty_conditions_except_slot(self):
+ bounce_target = BounceTarget(set(), set(), set(), {3})
+
+ self.assertEqual(list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [])
+
+ def test_legacy_empty_conditions_except_tags_and_team(self):
+ bounce_target = BounceTarget({1}, set(), {"DeathLink"}, set())
+
+ self.assertEqual(list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [TEAM_1_SLOT_2, TEAM_1_SLOT_3])
+
+ def test_legacy_all(self):
+ bounce_target = BounceTarget({2}, {"TestGame 2"}, {"TrapLink"}, {1})
+
+ self.assertEqual(
+ list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [TEAM_2_SLOT_1, TEAM_2_SLOT_2, TEAM_2_SLOT_3]
+ )
+
+ def test_legacy_multiple_games(self):
+ bounce_target = BounceTarget({1}, {"TestGame", "TestGame 2"}, None, None)
+
+ self.assertEqual(
+ list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_1_SLOT_3]
+ )
+
+ def test_legacy_multiple_tags(self):
+ bounce_target = BounceTarget({2}, None, {"DeathLink", "TrapLink"}, None)
+
+ self.assertEqual(list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [TEAM_2_SLOT_2, TEAM_2_SLOT_3])
+
+ def test_legacy_multiple_slots(self):
+ bounce_target = BounceTarget(None, None, None, {1, 2})
+
+ self.assertEqual(
+ list(bounce_target.match_clients_legacy(ALL_CLIENTS)),
+ [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_2_SLOT_1, TEAM_2_SLOT_2],
+ )
+
+ def test_legacy_multiple_teams(self):
+ bounce_target = BounceTarget({1, 2}, None, None, {1})
+
+ self.assertEqual(list(bounce_target.match_clients_legacy(ALL_CLIENTS)), [TEAM_1_SLOT_1, TEAM_2_SLOT_1])
+
+
+class TestBounceTargetOr(unittest.TestCase):
+ """
+ Test various BounceTarget configurations against six mock clients with different teams, games, tags and slots.
+
+ Uses the "or" operator, which is defined as: "any(team, game, tags, slot)".
+
+ A missing condition (None) or an empty set is treated as always False.
+ """
+
+ def test_or_bounce_target_default(self):
+ bounce_target = BounceTarget(None, None, None, None)
+
+ self.assertEqual(list(bounce_target.match_clients_or(ALL_CLIENTS)), [])
+
+ def test_or_bounce_team_only(self):
+ bounce_target = BounceTarget({1}, None, None, None)
+
+ self.assertEqual(
+ list(bounce_target.match_clients_or(ALL_CLIENTS)),
+ [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_1_SLOT_3],
+ )
+
+ def test_or_tags_condition_only(self):
+ bounce_target = BounceTarget(None, None, {"DeathLink"}, None)
+
+ self.assertEqual(
+ list(bounce_target.match_clients_or(ALL_CLIENTS)),
+ [TEAM_1_SLOT_2, TEAM_1_SLOT_3, TEAM_2_SLOT_3],
+ )
+
+ def test_or_slot_condition_only(self):
+ bounce_target = BounceTarget(None, None, None, {2})
+
+ self.assertEqual(list(bounce_target.match_clients_or(ALL_CLIENTS)), [TEAM_1_SLOT_2, TEAM_2_SLOT_2])
+
+ def test_or_empty_conditions(self):
+ bounce_target = BounceTarget(set(), set(), set(), set())
+
+ self.assertEqual(list(bounce_target.match_clients_or(ALL_CLIENTS)), [])
+
+ def test_or_empty_conditions_except_slot(self):
+ bounce_target = BounceTarget(set(), set(), set(), {3})
+
+ self.assertEqual(list(bounce_target.match_clients_or(ALL_CLIENTS)), [TEAM_1_SLOT_3, TEAM_2_SLOT_3])
+
+ def test_or_empty_conditions_except_tags_and_team(self):
+ bounce_target = BounceTarget({1}, set(), {"DeathLink"}, set())
+
+ self.assertEqual(
+ list(bounce_target.match_clients_or(ALL_CLIENTS)),
+ [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_1_SLOT_3, TEAM_2_SLOT_3],
+ )
+
+ def test_or_all(self):
+ bounce_target = BounceTarget({2}, {"TestGame 2"}, {"DeathLink"}, {1})
+
+ self.assertEqual(list(bounce_target.match_clients_or(ALL_CLIENTS)), ALL_CLIENTS)
+
+ def test_or_multiple_games(self):
+ bounce_target = BounceTarget({1}, {"TestGame", "TestGame 2"}, None, None)
+
+ self.assertEqual(list(bounce_target.match_clients_or(ALL_CLIENTS)), ALL_CLIENTS)
+
+ def test_or_multiple_tags(self):
+ bounce_target = BounceTarget({2}, None, {"DeathLink", "TrapLink"}, None)
+
+ self.assertEqual(
+ list(bounce_target.match_clients_or(ALL_CLIENTS)),
+ [TEAM_1_SLOT_2, TEAM_1_SLOT_3, TEAM_2_SLOT_1, TEAM_2_SLOT_2, TEAM_2_SLOT_3],
+ )
+
+ def test_or_multiple_slots(self):
+ bounce_target = BounceTarget(None, None, None, {1, 2})
+
+ self.assertEqual(
+ list(bounce_target.match_clients_or(ALL_CLIENTS)),
+ [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_2_SLOT_1, TEAM_2_SLOT_2],
+ )
+
+ def test_or_multiple_teams(self):
+ bounce_target = BounceTarget({1, 2}, None, None, {1})
+
+ self.assertEqual(list(bounce_target.match_clients_or(ALL_CLIENTS)), ALL_CLIENTS)
+
+
+class TestBounceTargetAnd(unittest.TestCase):
+ """
+ Test various BounceTarget configurations against six mock clients with different teams, games, tags and slots.
+
+ Uses the "and" operator, which is defined as: "all(team, game, tags, slot)".
+
+ A missing condition (None) is interpreted as always True, where an empty set is treated as always False.
+ This means that for an "and"-type bounce packet, a single empty set means the whole condition selects no clients.
+ """
+
+ def test_and_bounce_target_default(self):
+ bounce_target = BounceTarget(None, None, None, None)
+
+ self.assertEqual(list(bounce_target.match_clients_and(ALL_CLIENTS)), ALL_CLIENTS)
+
+ def test_and_bounce_team_only(self):
+ bounce_target = BounceTarget({1}, None, None, None)
+
+ self.assertEqual(
+ list(bounce_target.match_clients_and(ALL_CLIENTS)),
+ [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_1_SLOT_3],
+ )
+
+ def test_and_tags_condition_only(self):
+ bounce_target = BounceTarget(None, None, {"DeathLink"}, None)
+
+ self.assertEqual(
+ list(bounce_target.match_clients_and(ALL_CLIENTS)),
+ [TEAM_1_SLOT_2, TEAM_1_SLOT_3, TEAM_2_SLOT_3],
+ )
+
+ def test_and_slot_condition_only(self):
+ bounce_target = BounceTarget(None, None, None, {2})
+
+ self.assertEqual(list(bounce_target.match_clients_and(ALL_CLIENTS)), [TEAM_1_SLOT_2, TEAM_2_SLOT_2])
+
+ def test_and_empty_conditions(self):
+ bounce_target = BounceTarget(set(), set(), set(), set())
+
+ self.assertEqual(list(bounce_target.match_clients_and(ALL_CLIENTS)), [])
+
+ def test_and_empty_conditions_except_slot(self):
+ bounce_target = BounceTarget(set(), set(), set(), {3})
+
+ self.assertEqual(list(bounce_target.match_clients_and(ALL_CLIENTS)), [])
+
+ def test_and_empty_conditions_except_tags_and_team(self):
+ bounce_target = BounceTarget({1}, None, {"DeathLink"}, None)
+
+ self.assertEqual(list(bounce_target.match_clients_and(ALL_CLIENTS)), [TEAM_1_SLOT_2, TEAM_1_SLOT_3])
+
+ def test_and_all(self):
+ bounce_target = BounceTarget({2}, {"TestGame 2"}, {"DeathLink"}, {3})
+
+ self.assertEqual(list(bounce_target.match_clients_and(ALL_CLIENTS)), [TEAM_2_SLOT_3])
+
+ def test_and_multiple_games(self):
+ bounce_target = BounceTarget({1}, {"TestGame", "TestGame 2"}, None, None)
+
+ self.assertEqual(
+ list(bounce_target.match_clients_and(ALL_CLIENTS)), [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_1_SLOT_3]
+ )
+
+ def test_and_multiple_tags(self):
+ bounce_target = BounceTarget({2}, None, {"DeathLink", "TrapLink"}, None)
+
+ self.assertEqual(list(bounce_target.match_clients_and(ALL_CLIENTS)), [TEAM_2_SLOT_2, TEAM_2_SLOT_3])
+
+ def test_and_multiple_slots(self):
+ bounce_target = BounceTarget(None, None, None, {1, 2})
+
+ self.assertEqual(
+ list(bounce_target.match_clients_and(ALL_CLIENTS)),
+ [TEAM_1_SLOT_1, TEAM_1_SLOT_2, TEAM_2_SLOT_1, TEAM_2_SLOT_2],
+ )
+
+ def test_and_multiple_teams(self):
+ bounce_target = BounceTarget({1, 2}, None, None, {1})
+
+ self.assertEqual(list(bounce_target.match_clients_and(ALL_CLIENTS)), [TEAM_1_SLOT_1, TEAM_2_SLOT_1])
diff --git a/test/general/test_entrances.py b/test/general/test_entrances.py
index 79025534ac6..effd932a37b 100644
--- a/test/general/test_entrances.py
+++ b/test/general/test_entrances.py
@@ -4,6 +4,8 @@
class TestBase(unittest.TestCase):
+ world_relevant = True
+
def test_entrance_connection_steps(self):
"""Tests that Entrances are connected and not changed after connect_entrances."""
def get_entrance_name_to_source_and_target_dict(world: World):
@@ -15,7 +17,7 @@ def get_entrance_name_to_source_and_target_dict(world: World):
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
additional_steps = ("generate_basic", "pre_fill")
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
@@ -42,7 +44,7 @@ def test_all_state_before_connect_entrances(self):
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, ())
diff --git a/test/general/test_groups.py b/test/general/test_groups.py
index 486d3311fa6..7a53d96886d 100644
--- a/test/general/test_groups.py
+++ b/test/general/test_groups.py
@@ -4,11 +4,13 @@
class TestNameGroups(TestCase):
+ world_relevant = True
+
def test_item_name_groups_not_empty(self) -> None:
"""
Test that there are no empty item name groups, which is likely a bug.
"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.item_id_to_name:
continue # ignore worlds without items
with self.subTest(game=game_name):
@@ -19,7 +21,7 @@ def test_location_name_groups_not_empty(self) -> None:
"""
Test that there are no empty location name groups, which is likely a bug.
"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.location_id_to_name:
continue # ignore worlds without locations
with self.subTest(game=game_name):
diff --git a/test/general/test_ids.py b/test/general/test_ids.py
index 08b4d0aa494..202dabfb268 100644
--- a/test/general/test_ids.py
+++ b/test/general/test_ids.py
@@ -7,23 +7,25 @@
class TestIDs(unittest.TestCase):
+ world_relevant = True
+
def test_range_items(self):
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
for item_id in world_type.item_id_to_name:
self.assertLess(item_id, 2**53)
def test_range_locations(self):
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
for location_id in world_type.location_id_to_name:
self.assertLess(location_id, 2**53)
def test_reserved_items(self):
"""negative item IDs are reserved to the special "Archipelago" world."""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
if gamename == "Archipelago":
for item_id in world_type.item_id_to_name:
@@ -34,7 +36,7 @@ def test_reserved_items(self):
def test_reserved_locations(self):
"""negative location IDs are reserved to the special "Archipelago" world."""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
if gamename == "Archipelago":
for location_id in world_type.location_id_to_name:
@@ -45,7 +47,7 @@ def test_reserved_locations(self):
def test_duplicate_item_ids(self):
"""Test that a game doesn't have item id overlap within its own datapackage"""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
len_item_id_to_name = len(world_type.item_id_to_name)
len_item_name_to_id = len(world_type.item_name_to_id)
@@ -64,7 +66,7 @@ def test_duplicate_item_ids(self):
def test_duplicate_location_ids(self):
"""Test that a game doesn't have location id overlap within its own datapackage"""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
len_location_id_to_name = len(world_type.location_id_to_name)
len_location_name_to_id = len(world_type.location_name_to_id)
@@ -83,7 +85,7 @@ def test_duplicate_location_ids(self):
def test_postgen_datapackage(self):
"""Generates a solo multiworld and checks that the datapackage is still valid"""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
multiworld = setup_solo_multiworld(world_type)
distribute_items_restrictive(multiworld)
diff --git a/test/general/test_implemented.py b/test/general/test_implemented.py
index 0906852201b..a388b09265e 100644
--- a/test/general/test_implemented.py
+++ b/test/general/test_implemented.py
@@ -8,9 +8,11 @@
class TestImplemented(unittest.TestCase):
+ world_relevant = True
+
def test_completion_condition(self):
"""Ensure a completion condition is set that has requirements."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type)
@@ -18,7 +20,7 @@ def test_completion_condition(self):
def test_entrance_parents(self):
"""Tests that the parents of created Entrances match the exiting Region."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type)
@@ -28,7 +30,7 @@ def test_entrance_parents(self):
def test_stage_methods(self):
"""Tests that worlds don't try to implement certain steps that are only ever called as stage."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
with self.subTest(game_name):
for method in ("assert_generate",):
@@ -40,7 +42,7 @@ def test_slot_data(self):
# has an await for generate_output which isn't being called
excluded_games = ("Ocarina of Time",)
worlds_to_test = {game: world
- for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
+ for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
multiworld = setup_solo_multiworld(world_type)
with self.subTest(game=game_name, seed=multiworld.seed):
@@ -58,12 +60,12 @@ def test_no_failed_world_loads(self):
def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill."""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
if gamename not in ("Archipelago", "Final Fantasy", "Test Game"):
with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic"))
- allstate = multiworld.get_all_state(False)
+ allstate = multiworld.get_all_state()
locations = multiworld.get_locations()
reachable = multiworld.get_reachable_locations(allstate)
unreachable = [location for location in locations if location not in reachable]
@@ -78,7 +80,7 @@ def test_explicit_indirect_conditions_spheres(self):
# 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():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
multiworld = setup_solo_multiworld(world_type)
world = multiworld.get_game_worlds(game_name)[0]
if not world.explicit_indirect_conditions:
@@ -140,7 +142,7 @@ def test_explicit_indirect_conditions_spheres(self):
def test_no_items_or_locations_or_regions_submitted_in_init(self):
"""Test that worlds don't submit items/locations/regions to the multiworld in __init__"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, ())
self.assertEqual(len(multiworld.itempool), 0)
diff --git a/test/general/test_items.py b/test/general/test_items.py
index 9c300cf94ed..a33bd3582cb 100644
--- a/test/general/test_items.py
+++ b/test/general/test_items.py
@@ -11,9 +11,11 @@
class TestBase(unittest.TestCase):
+ world_relevant = True
+
def test_create_item(self):
"""Test that a world can successfully create all items in its datapackage"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
proxy_world = multiworld.worlds[1]
for item_name in world_type.item_name_to_id:
@@ -53,7 +55,7 @@ def test_item_name_group_has_valid_item(self):
"Yu-Gi-Oh! 2006":
{"Campaign Boss Beaten"}
}
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game_name, game_name=game_name):
exclusions = exclusion_dict.get(game_name, frozenset())
for group_name, items in world_type.item_name_groups.items():
@@ -64,7 +66,7 @@ def test_item_name_group_has_valid_item(self):
def test_item_name_group_conflict(self):
"""Test that all item name groups aren't also item names."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game_name, game_name=game_name):
for group_name in world_type.item_name_groups:
with self.subTest(group_name, group_name=group_name):
@@ -72,7 +74,7 @@ def test_item_name_group_conflict(self):
def test_item_count_equal_locations(self):
"""Test that by the pre_fill step under default settings, each game submits items == locations"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
self.assertEqual(
@@ -84,7 +86,7 @@ def test_item_count_equal_locations(self):
def test_items_in_datapackage(self):
"""Test that any created items in the itempool are in the datapackage"""
archipelago = AutoWorldRegister.world_types["Archipelago"]
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
for item in multiworld.itempool:
@@ -126,7 +128,7 @@ def setup_link_multiworld(world: Type[World], link_replace: bool) -> None:
call_all(multiworld, "finalize_multiworld")
self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}")
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Can generate with link replacement", game=game_name):
setup_link_multiworld(world_type, True)
with self.subTest("Can generate without link replacement", game=game_name):
@@ -136,9 +138,9 @@ def test_itempool_not_modified(self):
"""Test that worlds don't modify the itempool after `create_items`"""
gen_steps = ("generate_early", "create_regions", "create_items")
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
- excluded_games = ("Links Awakening DX", "Ocarina of Time", "SMZ3")
+ excluded_games = ("SMZ3",)
worlds_to_test = {game: world
- for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
+ for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
@@ -153,7 +155,7 @@ def test_locality_not_modified(self):
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early",)
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
local_items = multiworld.worlds[1].options.local_items.value.copy()
diff --git a/test/general/test_locations.py b/test/general/test_locations.py
index 77ae2602e52..378d4209424 100644
--- a/test/general/test_locations.py
+++ b/test/general/test_locations.py
@@ -5,9 +5,11 @@
class TestBase(unittest.TestCase):
+ world_relevant = True
+
def test_create_duplicate_locations(self):
"""Tests that no two Locations share a name or ID."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
multiworld = setup_solo_multiworld(world_type)
locations = Counter(location.name for location in multiworld.get_locations())
if locations:
@@ -22,7 +24,7 @@ def test_create_duplicate_locations(self):
def test_locations_in_datapackage(self):
"""Tests that created locations not filled before fill starts exist in the datapackage."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type)
locations = multiworld.get_unfilled_locations() # do unfilled locations to avoid Events
@@ -35,7 +37,7 @@ def test_location_creation_steps(self):
gen_steps = ("generate_early", "create_regions", "create_items")
excluded_games = ("Ocarina of Time", "Pokemon Red and Blue")
worlds_to_test = {game: world
- for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
+ for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
@@ -68,7 +70,7 @@ def test_location_creation_steps(self):
def test_location_group(self):
"""Test that all location name groups contain valid locations and don't share names."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game_name, game_name=game_name):
for group_name, locations in world_type.location_name_groups.items():
with self.subTest(group_name, group_name=group_name):
diff --git a/test/general/test_memory.py b/test/general/test_memory.py
index a4b2f1bd25d..38edeb6b513 100644
--- a/test/general/test_memory.py
+++ b/test/general/test_memory.py
@@ -6,12 +6,14 @@
class TestWorldMemory(unittest.TestCase):
+ world_relevant = True
+
def test_leak(self) -> None:
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
import gc
import weakref
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game creation", game_name=game_name):
weak = weakref.ref(setup_solo_multiworld(world_type))
refs[game_name] = weak
diff --git a/test/general/test_names.py b/test/general/test_names.py
index 8ad74a33544..2af8ee5a1a5 100644
--- a/test/general/test_names.py
+++ b/test/general/test_names.py
@@ -3,9 +3,11 @@
class TestNames(unittest.TestCase):
+ world_relevant = True
+
def test_item_names_format(self) -> None:
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
for item_name in world_type.item_name_to_id:
self.assertFalse(item_name.isnumeric(),
@@ -13,7 +15,7 @@ def test_item_names_format(self) -> None:
def test_location_name_format(self) -> None:
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
for location_name in world_type.location_name_to_id:
self.assertFalse(location_name.isnumeric(),
diff --git a/test/general/test_options.py b/test/general/test_options.py
index 5d69b6820b8..673fc15795d 100644
--- a/test/general/test_options.py
+++ b/test/general/test_options.py
@@ -8,9 +8,11 @@
class TestOptions(unittest.TestCase):
+ world_relevant = True
+
def test_options_have_doc_string(self):
"""Test that submitted options have their own specified docstring"""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
@@ -18,7 +20,7 @@ def test_options_have_doc_string(self):
def test_option_defaults(self):
"""Test that defaults for submitted options are valid."""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
@@ -41,14 +43,14 @@ def test_option_defaults(self):
def test_options_are_not_set_by_world(self):
"""Test that options attribute is not already set"""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
self.assertFalse(hasattr(world_type, "options"),
f"Unexpected assignment to {world_type.__name__}.options!")
def test_duplicate_options(self) -> None:
"""Tests that a world doesn't reuse the same option class."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=game_name):
seen_options = set()
for option in world_type.options_dataclass.type_hints.values():
@@ -98,7 +100,7 @@ def test_item_links_resolve(self):
def test_pickle_dumps_default(self):
"""Test that default option values can be pickled into database for WebHost generation"""
- for gamename, world_type in AutoWorldRegister.world_types.items():
+ for gamename, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
@@ -108,16 +110,18 @@ def test_pickle_dumps_default(self):
def test_option_set_keys_random(self):
"""Tests that option sets do not contain 'random' and its variants as valid keys"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
- if game_name not in ("Archipelago", "Super Metroid"):
- for option_key, option in world_type.options_dataclass.type_hints.items():
- if issubclass(option, OptionSet):
- with self.subTest(game=game_name, option=option_key):
- self.assertFalse(any(random_key in option.valid_keys for random_key in ("random",
- "random-high",
- "random-low")))
- for key in option.valid_keys:
- self.assertFalse("random-range" in key)
+ excluded_games = ("Super Metroid",)
+ worlds_to_test = {game: world
+ for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
+ for game_name, world_type in worlds_to_test.items():
+ for option_key, option in world_type.options_dataclass.type_hints.items():
+ if issubclass(option, OptionSet):
+ with self.subTest(game=game_name, option=option_key):
+ self.assertFalse(any(random_key in option.valid_keys for random_key in ("random",
+ "random-high",
+ "random-low")))
+ for key in option.valid_keys:
+ self.assertFalse("random-range" in key)
def test_pickle_dumps_plando(self):
"""Test that plando options using containers of a custom type can be pickled"""
diff --git a/test/general/test_packages.py b/test/general/test_packages.py
index 1df6187ee00..804426e7d05 100644
--- a/test/general/test_packages.py
+++ b/test/general/test_packages.py
@@ -3,6 +3,8 @@
class TestPackages(unittest.TestCase):
+ world_relevant = True
+
def test_packages_have_init(self):
"""Test that all world folders containing .py files also have a __init__.py file,
to indicate full package rather than namespace package."""
diff --git a/test/general/test_patches.py b/test/general/test_patches.py
index 09782b8d3d8..ac1d0f9b133 100644
--- a/test/general/test_patches.py
+++ b/test/general/test_patches.py
@@ -4,6 +4,8 @@
class TestPatches(unittest.TestCase):
+ world_relevant = True
+
def test_patch_name_matches_game(self) -> None:
for game_name in AutoPatchRegister.patch_types:
with self.subTest(game=game_name):
diff --git a/test/general/test_reachability.py b/test/general/test_reachability.py
index b45a2bdfc0e..79ff3afb984 100644
--- a/test/general/test_reachability.py
+++ b/test/general/test_reachability.py
@@ -6,6 +6,7 @@
class TestBase(unittest.TestCase):
+ world_relevant = True
gen_steps = gen_steps
default_settings_unreachable_regions = {
@@ -45,11 +46,11 @@ class TestBase(unittest.TestCase):
def test_default_all_state_can_reach_everything(self):
"""Ensure all state can reach everything and complete the game with the defined options"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
- state = multiworld.get_all_state(False)
+ state = multiworld.get_all_state()
for location in multiworld.get_locations():
with self.subTest("Location should be reached", location=location.name):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
@@ -67,7 +68,7 @@ def test_default_all_state_can_reach_everything(self):
def test_default_empty_state_can_reach_something(self):
"""Ensure empty state can reach at least one location with the defined options"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
state = CollectionState(multiworld)
diff --git a/test/general/test_requirements.py b/test/general/test_requirements.py
index 620d3d610aa..5ab5b536755 100644
--- a/test/general/test_requirements.py
+++ b/test/general/test_requirements.py
@@ -3,6 +3,8 @@
class TestBase(unittest.TestCase):
+ world_relevant = True
+
def test_requirements_file_ends_on_newline(self):
"""Test that all requirements files end on a newline"""
import Utils
diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py
index 191ba3cba71..511ce55c270 100644
--- a/test/general/test_rule_builder.py
+++ b/test/general/test_rule_builder.py
@@ -159,6 +159,18 @@ def get_filler_item_name(self) -> str:
@classvar_matrix(
rules=(
+ (
+ And(),
+ True_.Resolved(player=1)
+ ),
+ (
+ Or(),
+ False_.Resolved(player=1)
+ ),
+ (
+ Has("A", 0),
+ True_.Resolved(player=1)
+ ),
(
And(Has("A", 1), Has("A", 2)),
Has.Resolved("A", 2, player=1),
diff --git a/test/general/test_settings.py b/test/general/test_settings.py
index 165d7982b5f..2192d630755 100644
--- a/test/general/test_settings.py
+++ b/test/general/test_settings.py
@@ -5,11 +5,13 @@
class TestSettings(TestCase):
+ world_relevant = True
+
def test_settings_can_update(self) -> None:
"""
Test that world settings can update.
"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=game_name):
if world_type.settings is not None:
assert isinstance(world_type.settings, Group)
diff --git a/test/general/test_state.py b/test/general/test_state.py
index 06c4046a694..9451077e6d1 100644
--- a/test/general/test_state.py
+++ b/test/general/test_state.py
@@ -5,6 +5,7 @@
class TestBase(unittest.TestCase):
+ world_relevant = True
gen_steps = (
"generate_early",
"create_regions",
@@ -20,10 +21,10 @@ class TestBase(unittest.TestCase):
def test_all_state_is_available(self):
"""Ensure all_state can be created at certain steps."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, self.gen_steps)
for step in self.test_steps:
with self.subTest("Step", step=step):
call_all(multiworld, step)
- self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True))
+ self.assertTrue(multiworld.get_all_state(allow_partial_entrances=True))
diff --git a/test/general/test_world_manifest.py b/test/general/test_world_manifest.py
index 3aaa93b0ac1..94953cac033 100644
--- a/test/general/test_world_manifest.py
+++ b/test/general/test_world_manifest.py
@@ -22,7 +22,7 @@
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
source_world_names = [
k
- for k, v in AutoWorldRegister.world_types.items()
+ for k, v in AutoWorldRegister.testable_worlds.items()
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
]
@@ -43,6 +43,7 @@ def get_source_world_manifest_path(game: str) -> Path | None:
# TODO: remove the filter once manifests are mandatory.
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
class TestWorldManifest(unittest.TestCase):
+ world_relevant = True
game: ClassVar[str]
manifest: ClassVar[dict[str, Any]]
diff --git a/test/multiserver/__init__.py b/test/multiserver/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test/multiserver/test_gamespackage_cache.py b/test/multiserver/test_gamespackage_cache.py
new file mode 100644
index 00000000000..440a46a08d1
--- /dev/null
+++ b/test/multiserver/test_gamespackage_cache.py
@@ -0,0 +1,132 @@
+import typing as t
+from copy import deepcopy
+from unittest import TestCase
+
+from typing_extensions import override
+
+import NetUtils
+from NetUtils import GamesPackage
+from apmw.multiserver.gamespackagecache import GamesPackageCache
+
+
+class GamesPackageCacheTest(TestCase):
+ cache: GamesPackageCache
+ any_game: t.ClassVar[str] = "APQuest"
+ example_games_package: GamesPackage = {
+ "item_name_to_id": {"Item 1": 1},
+ "item_name_groups": {"Everything": ["Item 1"]},
+ "location_name_to_id": {"Location 1": 1},
+ "location_name_groups": {"Everywhere": ["Location 1"]},
+ "checksum": "1234",
+ }
+
+ @override
+ def setUp(self) -> None:
+ self.cache = GamesPackageCache()
+
+ def test_get_static_is_same(self) -> None:
+ """Tests that get_static returns the same objects twice"""
+ reduced_games_package1, item_name_groups1, location_name_groups1 = self.cache.get_static(self.any_game)
+ reduced_games_package2, item_name_groups2, location_name_groups2 = self.cache.get_static(self.any_game)
+ self.assertIs(reduced_games_package1, reduced_games_package2)
+ self.assertIs(item_name_groups1, item_name_groups2)
+ self.assertIs(location_name_groups1, location_name_groups2)
+
+ def test_get_static_data_format(self) -> None:
+ """Tests that get_static returns data in the correct format"""
+ reduced_games_package, item_name_groups, location_name_groups = self.cache.get_static(self.any_game)
+ self.assertTrue(reduced_games_package["checksum"])
+ self.assertTrue(reduced_games_package["item_name_to_id"])
+ self.assertTrue(reduced_games_package["location_name_to_id"])
+ self.assertNotIn("item_name_groups", reduced_games_package)
+ self.assertNotIn("location_name_groups", reduced_games_package)
+ self.assertTrue(item_name_groups["Everything"])
+ self.assertTrue(location_name_groups["Everywhere"])
+
+ def test_get_static_is_serializable(self) -> None:
+ """Tests that get_static returns data that can be serialized"""
+ NetUtils.encode(self.cache.get_static(self.any_game))
+
+ def test_get_static_missing_raises(self) -> None:
+ """Tests that get_static raises KeyError if the world is missing"""
+ with self.assertRaises(KeyError):
+ _ = self.cache.get_static("Does not exist")
+
+ def test_eviction(self) -> None:
+ """Tests that unused items get evicted from cache"""
+ game_name = "Test"
+ before_add = len(self.cache._reduced_games_packages)
+ data = self.cache.get(game_name, self.example_games_package)
+ self.assertTrue(data)
+ self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
+
+ del data
+ if len(self.cache._reduced_games_packages) != before_add: # gc.collect() may not even be required
+ import gc
+
+ gc.collect()
+
+ self.assertEqual(before_add, len(self.cache._reduced_games_packages))
+
+ def test_get_required_field(self) -> None:
+ """Tests that missing required field raises a KeyError"""
+ for field in ("item_name_to_id", "location_name_to_id", "item_name_groups"):
+ with self.subTest(field=field):
+ games_package = deepcopy(self.example_games_package)
+ del games_package[field] # type: ignore
+ with self.assertRaises(KeyError):
+ _ = self.cache.get(self.any_game, games_package)
+
+ def test_get_optional_properties(self) -> None:
+ """Tests that missing optional field works"""
+ for field in ("checksum", "location_name_groups"):
+ with self.subTest(field=field):
+ games_package = deepcopy(self.example_games_package)
+ del games_package[field] # type: ignore
+ _, item_name_groups, location_name_groups = self.cache.get(self.any_game, games_package)
+ self.assertTrue(item_name_groups)
+ self.assertEqual(field != "location_name_groups", bool(location_name_groups))
+
+ def test_item_name_deduplication(self) -> None:
+ n = 1
+ s1 = f"Item {n}"
+ s2 = f"Item {n}"
+ # check if the deduplication is actually gonna do anything
+ self.assertIsNot(s1, s2)
+ self.assertEqual(s1, s2)
+ # do the thing
+ game_name = "Test"
+ games_package: GamesPackage = {
+ "item_name_to_id": {s1: n},
+ "item_name_groups": {"Everything": [s2]},
+ "location_name_to_id": {},
+ "location_name_groups": {},
+ "checksum": "1234",
+ }
+ reduced_games_package, item_name_groups, location_name_groups = self.cache.get(game_name, games_package)
+ self.assertIs(
+ next(iter(reduced_games_package["item_name_to_id"].keys())),
+ item_name_groups["Everything"][0],
+ )
+
+ def test_location_name_deduplication(self) -> None:
+ n = 1
+ s1 = f"Location {n}"
+ s2 = f"Location {n}"
+ # check if the deduplication is actually gonna do anything
+ self.assertIsNot(s1, s2)
+ self.assertEqual(s1, s2)
+ # do the thing
+ game_name = "Test"
+ games_package: GamesPackage = {
+ "item_name_to_id": {},
+ "item_name_groups": {},
+ "location_name_to_id": {s1: n},
+ "location_name_groups": {"Everywhere": [s2]},
+ "checksum": "1234",
+ }
+ reduced_games_package, item_name_groups, location_name_groups = self.cache.get(game_name, games_package)
+ self.assertIs(
+ next(iter(reduced_games_package["location_name_to_id"].keys())),
+ location_name_groups["Everywhere"][0],
+ )
diff --git a/test/multiworld/test_multiworlds.py b/test/multiworld/test_multiworlds.py
index d22013b4e07..f11efe65f6e 100644
--- a/test/multiworld/test_multiworlds.py
+++ b/test/multiworld/test_multiworlds.py
@@ -65,13 +65,14 @@ def test_fills(self) -> None:
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
-@classvar_matrix(game=AutoWorldRegister.world_types.keys())
+@classvar_matrix(game=AutoWorldRegister.testable_worlds.keys())
class TestTwoPlayerMulti(MultiworldTestBase):
+ world_relevant = True
game: ClassVar[str]
def test_two_player_single_game_fills(self) -> None:
"""Tests that a multiworld of two players for each registered game world can generate."""
- world_type = AutoWorldRegister.world_types[self.game]
+ world_type = AutoWorldRegister.testable_worlds[self.game]
self.multiworld = setup_multiworld([world_type, world_type], ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full
diff --git a/test/netutils/test_decode.py b/test/netutils/test_decode.py
new file mode 100644
index 00000000000..4ac42a66124
--- /dev/null
+++ b/test/netutils/test_decode.py
@@ -0,0 +1,68 @@
+import unittest
+from typing import Any
+
+from NetUtils import decode, encode
+
+
+class DecodeDepthLimitTest(unittest.TestCase):
+ LIMIT = 16
+
+ @staticmethod
+ def make_data(depth: int = LIMIT, cmd: str = "Cmd") -> list[dict[str, Any]]:
+ arg: Any = [1]
+ for _ in range(depth - 4):
+ arg = [arg]
+ res = {"cmd": cmd, "arg": arg}
+ # [{"arg": [[...[1]...]]}]
+ # ^1 ^depth
+ return [res]
+
+ @classmethod
+ def make_message(cls, depth: int = LIMIT, cmd: str = "Cmd") -> str:
+ return encode(cls.make_data(depth, cmd=cmd))
+
+ def test_below_limit(self) -> None:
+ data = self.make_data(depth=self.LIMIT - 1)
+ message = encode(data)
+ self.assertEqual(data, decode(message))
+
+ def test_at_limit(self) -> None:
+ data = self.make_data(depth=self.LIMIT)
+ message = encode(data)
+ self.assertEqual(data, decode(message))
+
+ def test_above_limit(self) -> None:
+ with self.assertRaises(ValueError):
+ decode(self.make_message(depth=self.LIMIT + 1))
+
+ def test_incomplete(self) -> None:
+ with self.assertRaises(ValueError):
+ decode(self.make_message()[:-1])
+
+ def test_invalid(self) -> None:
+ with self.assertRaises(ValueError):
+ decode(self.make_message().replace(":", ","))
+
+ def test_braces_in_str(self) -> None:
+ # should not raise
+ decode(self.make_message(cmd="["))
+ decode(self.make_message(cmd="{"))
+ decode(self.make_message(cmd="}"))
+ decode(self.make_message(cmd="]"))
+
+ def test_quote_in_str(self) -> None:
+ # should not raise
+ decode(self.make_message(cmd='"'))
+
+ def test_bs_quote_in_str(self) -> None:
+ # should not raise
+ decode(self.make_message(cmd=r'\"'))
+
+ def test_quoted_braces_in_str(self) -> None:
+ # should not raise
+ decode(self.make_message(cmd='"{["'))
+
+ def test_escape(self) -> None:
+ # should not raise
+ decode(r"""["\"\\\/\b\f\n\r\t{"]""")
+ self.assertEqual("new\nline", decode(r'"new\u000Aline"'))
diff --git a/test/programs/test_generate.py b/test/programs/test_generate.py
index 51800a0ec5c..66f1a1b0166 100644
--- a/test/programs/test_generate.py
+++ b/test/programs/test_generate.py
@@ -115,6 +115,7 @@ def test_generate_yaml(self):
settings = get_settings()
settings.generator.player_files_path = settings.generator.PlayerFilesPath(self.yaml_input_dir)
settings.generator.players = 5 # arbitrary number, should be enough
+ settings.generator.race = 0 # make sure race mode is disabled so the below seed is actually respected
settings._filename = None
user_path_backup = user_path.cached_path
user_path.cached_path = local_path()
diff --git a/test/webhost/__init__.py b/test/webhost/__init__.py
index f6181516cea..66610a1f541 100644
--- a/test/webhost/__init__.py
+++ b/test/webhost/__init__.py
@@ -31,6 +31,11 @@ def setUpClass(cls) -> None:
if "register_blueprint" not in e.args[0]:
raise
cls.app = raw_app
+ except ValueError as e:
+ # as above, for more recent versions of flask this is now a ValueError
+ if "is already registered for this blueprint" not in e.args[0]:
+ raise
+ cls.app = raw_app
def setUp(self) -> None:
from WebHostLib.models import db
diff --git a/test/webhost/test_descriptions.py b/test/webhost/test_descriptions.py
index 70f375b51cf..0eed68728f9 100644
--- a/test/webhost/test_descriptions.py
+++ b/test/webhost/test_descriptions.py
@@ -4,9 +4,11 @@
class TestWebDescriptions(unittest.TestCase):
+ world_relevant = True
+
def test_item_descriptions_have_valid_names(self) -> None:
"""Ensure all item descriptions match an item name or item group name"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
valid_names = world_type.item_names.union(world_type.item_name_groups)
for name in world_type.web.item_descriptions:
with self.subTest("Name should be valid", game=game_name, item=name):
@@ -15,7 +17,7 @@ def test_item_descriptions_have_valid_names(self) -> None:
def test_location_descriptions_have_valid_names(self) -> None:
"""Ensure all location descriptions match a location name or location group name"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
valid_names = world_type.location_names.union(world_type.location_name_groups)
for name in world_type.web.location_descriptions:
with self.subTest("Name should be valid", game=game_name, location=name):
diff --git a/test/webhost/test_docs.py b/test/webhost/test_docs.py
index a178a7cbf0b..02c0b51765f 100644
--- a/test/webhost/test_docs.py
+++ b/test/webhost/test_docs.py
@@ -9,12 +9,14 @@
class TestDocs(unittest.TestCase):
+ world_relevant = True
+
@classmethod
def setUpClass(cls) -> None:
WebHost.copy_tutorials_files_to_static()
def test_has_tutorial(self):
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
with self.subTest(game_name):
tutorials = world_type.web.tutorials
@@ -29,7 +31,7 @@ def test_has_tutorial(self):
)
def test_has_game_info(self):
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
safe_name = secure_filename(game_name)
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
diff --git a/test/webhost/test_file_generation.py b/test/webhost/test_file_generation.py
index 7b14ac871b9..e6c36e37efd 100644
--- a/test/webhost/test_file_generation.py
+++ b/test/webhost/test_file_generation.py
@@ -7,6 +7,8 @@
class TestFileGeneration(unittest.TestCase):
+ world_relevant = True
+
@classmethod
def setUpClass(cls) -> None:
cls.correct_path = os.path.join(os.path.dirname(WebHost.__file__), "WebHostLib")
diff --git a/test/webhost/test_option_presets.py b/test/webhost/test_option_presets.py
index 7f05de5d114..f1568befd13 100644
--- a/test/webhost/test_option_presets.py
+++ b/test/webhost/test_option_presets.py
@@ -6,9 +6,11 @@
class TestOptionPresets(unittest.TestCase):
+ world_relevant = True
+
def test_option_presets_have_valid_options(self):
"""Test that all predefined option presets are valid options."""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
presets = world_type.web.options_presets
for preset_name, preset in presets.items():
for option_name, option_value in preset.items():
@@ -38,7 +40,7 @@ def test_option_preset_values_are_explicitly_defined(self):
"""Test that option preset values are not a special flavor of 'random' or use from_text to resolve another
value.
"""
- for game_name, world_type in AutoWorldRegister.world_types.items():
+ for game_name, world_type in AutoWorldRegister.testable_worlds.items():
presets = world_type.web.options_presets
for preset_name, preset in presets.items():
for option_name, option_value in preset.items():
diff --git a/test/webhost/test_port_allocation.py b/test/webhost/test_port_allocation.py
new file mode 100644
index 00000000000..914e7b2c873
--- /dev/null
+++ b/test/webhost/test_port_allocation.py
@@ -0,0 +1,89 @@
+import os
+import unittest
+from socket import socket as Socket # noqa: N812
+
+from Utils import is_macos
+from WebHostLib.customserver import RandomPortSocketCreator
+
+ci = bool(os.environ.get("CI"))
+
+
+class TestPortAllocating(unittest.TestCase):
+ def test_parse_game_ports(self) -> None:
+ """Ensure that game ports with ranges are parsed correctly"""
+ val = RandomPortSocketCreator._parse_game_ports(("1000-2000", "2000-5000", "1000-2000", 20, 40, "20", "0"))
+
+ self.assertCountEqual(val.valid_ports,
+ [*range(1000, 2001), *range(2000, 5001), *range(1000, 2001), 20, 40, 20],
+ "The parsed game ports are not the expected length")
+ self.assertTrue(val.ephemeral_allowed, "The ephemeral allowed flag is not set even though it was passed")
+
+ val = RandomPortSocketCreator._parse_game_ports(())
+ self.assertListEqual(val.valid_ports, [], "Empty list of game port returned something")
+ self.assertFalse(val.ephemeral_allowed, "Empty list returned that ephemeral is allowed")
+
+ val = RandomPortSocketCreator._parse_game_ports((0,))
+ self.assertListEqual(val.valid_ports, [], "Empty list of ranges returned something")
+ self.assertTrue(val.ephemeral_allowed, "List with just 0 is not allowing ephemeral ports")
+
+ val = RandomPortSocketCreator._parse_game_ports((1,))
+ self.assertListEqual(val.valid_ports, [1], "Valid ports doesn't contain the expected values")
+ self.assertFalse(val.ephemeral_allowed, "List with just single port returned that ephemeral is allowed")
+
+ def test_parse_game_port_errors(self) -> None:
+ """Ensure that game ports with incorrect values raise the expected error"""
+ with self.assertRaises(ValueError, msg="Negative numbers didn't get interpreted as an invalid range"):
+ RandomPortSocketCreator._parse_game_ports(tuple("-50215"))
+ with self.assertRaises(ValueError, msg="Text got interpreted as a valid number"):
+ RandomPortSocketCreator._parse_game_ports(tuple("dwafawg"))
+ with self.assertRaises(
+ ValueError,
+ msg="A range with an extra dash at the end didn't get interpreted as an invalid number because of it's end dash"
+ ):
+ RandomPortSocketCreator._parse_game_ports(tuple("20-21215-"))
+ with self.assertRaises(ValueError, msg="Text got interpreted as a valid number for the start of a range"):
+ RandomPortSocketCreator._parse_game_ports(tuple("f-21215"))
+
+ def test_random_port_socket_edge_cases(self) -> None:
+ """Verify if edge cases on creation of random port socket is working fine"""
+ # Try giving an empty tuple and fail over it
+ creator = RandomPortSocketCreator(())
+ with self.assertRaises(OSError) as err:
+ creator.create("127.0.0.1")
+ self.assertEqual(err.exception.errno, 98, "Raised an unexpected error code")
+ self.assertEqual(err.exception.strerror, "No available ports", "Raised an unexpected error string")
+
+ # Try only having ephemeral ports enabled
+ creator = RandomPortSocketCreator(("0",))
+ try:
+ creator.create("127.0.0.1").close()
+ except OSError as err:
+ self.assertEqual(err.errno, 98, "Raised an unexpected error code")
+ # If it returns our error string that means something is wrong with our code
+ self.assertNotEqual(err.strerror, "No available ports",
+ "Raised an unexpected error string")
+
+ @unittest.skipUnless(ci, "can't guarantee free ports outside of CI")
+ def test_random_port_socket(self) -> None:
+ """Verify if returned sockets use the correct port ranges"""
+ creator = RandomPortSocketCreator(("8080-8085",))
+ sockets: list[Socket] = []
+ for _ in range(6):
+ socket = creator.create("127.0.0.1")
+ sockets.append(socket)
+ _, port = socket.getsockname()
+ self.assertIn(port, range(8080, 8086), "Port of socket was not inside the expected range")
+ for s in sockets:
+ s.close()
+
+ sockets.clear()
+ creator = RandomPortSocketCreator(("30000-65535",))
+ length = 5_000 if is_macos else (30_000 - len(creator._get_used_ports()))
+ for _ in range(length):
+ socket = creator.create("127.0.0.1")
+ sockets.append(socket)
+ _, port = socket.getsockname()
+ self.assertIn(port, range(30_000, 65536), "Port of socket was not inside the expected range")
+
+ for s in sockets:
+ s.close()
diff --git a/test/webhost_customserver/__init__.py b/test/webhost_customserver/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test/webhost_customserver/test_gamespackage_cache.py b/test/webhost_customserver/test_gamespackage_cache.py
new file mode 100644
index 00000000000..58ea2e37f9c
--- /dev/null
+++ b/test/webhost_customserver/test_gamespackage_cache.py
@@ -0,0 +1,147 @@
+import typing as t
+from copy import deepcopy
+
+from typing_extensions import override
+
+from test.multiserver.test_gamespackage_cache import GamesPackageCacheTest
+
+import Utils
+from NetUtils import GamesPackage
+from apmw.webhost.customserver.gamespackagecache import DBGamesPackageCache
+
+
+class FakeGameDataPackage:
+ _rows: "t.ClassVar[dict[str, FakeGameDataPackage]]" = {}
+ data: bytes
+
+ @classmethod
+ def get(cls, checksum: str) -> "FakeGameDataPackage | None":
+ return cls._rows.get(checksum, None)
+
+ @classmethod
+ def add(cls, checksum: str, full_games_package: GamesPackage) -> None:
+ row = FakeGameDataPackage()
+ row.data = Utils.restricted_dumps(full_games_package)
+ cls._rows[checksum] = row
+
+
+class DBGamesPackageCacheTest(GamesPackageCacheTest):
+ cache: DBGamesPackageCache
+ any_game: t.ClassVar[str] = "My Game"
+ static_data: t.ClassVar[dict[str, GamesPackage]] = { # noqa: pycharm doesn't understand this
+ "My Game": {
+ "item_name_to_id": {"Item 1": 1},
+ "location_name_to_id": {"Location 1": 1},
+ "item_name_groups": {"Everything": ["Item 1"]},
+ "location_name_groups": {"Everywhere": ["Location 1"]},
+ "checksum": "2345",
+ }
+ }
+ orig_db_type: t.ClassVar[type]
+
+ @override
+ @classmethod
+ def setUpClass(cls) -> None:
+ import WebHostLib.models
+
+ cls.orig_db_type = WebHostLib.models.GameDataPackage
+ WebHostLib.models.GameDataPackage = FakeGameDataPackage # type: ignore
+
+ @override
+ def setUp(self) -> None:
+ self.cache = DBGamesPackageCache(self.static_data)
+
+ @override
+ @classmethod
+ def tearDownClass(cls) -> None:
+ import WebHostLib.models
+
+ WebHostLib.models.GameDataPackage = cls.orig_db_type # type: ignore
+
+ def assert_conversion(
+ self,
+ full_games_package: GamesPackage,
+ reduced_games_package: dict[str, t.Any],
+ item_name_groups: dict[str, t.Any],
+ location_name_groups: dict[str, t.Any],
+ ) -> None:
+ for key in ("item_name_to_id", "location_name_to_id", "checksum"):
+ if key in full_games_package:
+ self.assertEqual(reduced_games_package[key], full_games_package[key]) # noqa: pycharm
+ self.assertEqual(item_name_groups, full_games_package["item_name_groups"])
+ self.assertEqual(location_name_groups, full_games_package["location_name_groups"])
+
+ def assert_static_conversion(
+ self,
+ full_games_package: GamesPackage,
+ reduced_games_package: dict[str, t.Any],
+ item_name_groups: dict[str, t.Any],
+ location_name_groups: dict[str, t.Any],
+ ) -> None:
+ self.assert_conversion(full_games_package, reduced_games_package, item_name_groups, location_name_groups)
+ for key in ("item_name_to_id", "location_name_to_id", "checksum"):
+ self.assertIs(reduced_games_package[key], full_games_package[key]) # noqa: pycharm
+
+ def test_get_static_contents(self) -> None:
+ """Tests that get_static returns the correct data"""
+ reduced_games_package, item_name_groups, location_name_groups = self.cache.get_static(self.any_game)
+ for key in ("item_name_to_id", "location_name_to_id", "checksum"):
+ self.assertIs(reduced_games_package[key], self.static_data[self.any_game][key]) # noqa: pycharm
+ self.assertEqual(item_name_groups, self.static_data[self.any_game]["item_name_groups"])
+ self.assertEqual(location_name_groups, self.static_data[self.any_game]["location_name_groups"])
+
+ def test_static_not_evicted(self) -> None:
+ """Tests that static data is not evicted from cache during gc"""
+ import gc
+
+ game_name = next(iter(self.static_data.keys()))
+ ids = [id(o) for o in self.cache.get_static(game_name)]
+ gc.collect()
+ self.assertEqual(ids, [id(o) for o in self.cache.get_static(game_name)])
+
+ def test_get_is_static(self) -> None:
+ """Tests that a get with correct checksum return the static items"""
+ # NOTE: this is only true for the DB cache, not the "regular" one, since we want to avoid loading worlds there
+ cks: GamesPackage = {"checksum": self.static_data[self.any_game]["checksum"]} # noqa: pycharm doesn't like this
+ reduced_games_package1, item_name_groups1, location_name_groups1 = self.cache.get(self.any_game, cks)
+ reduced_games_package2, item_name_groups2, location_name_groups2 = self.cache.get_static(self.any_game)
+ self.assertIs(reduced_games_package1, reduced_games_package2)
+ self.assertEqual(location_name_groups1, location_name_groups2)
+ self.assertEqual(item_name_groups1, item_name_groups2)
+
+ def test_get_from_db(self) -> None:
+ """Tests that a get with only checksum will load the full data from db and is cached"""
+ game_name = "Another Game"
+ full_games_package = deepcopy(self.static_data[self.any_game])
+ full_games_package["checksum"] = "3456"
+ cks: GamesPackage = {"checksum": full_games_package["checksum"]} # noqa: pycharm doesn't like this
+ FakeGameDataPackage.add(full_games_package["checksum"], full_games_package)
+ before_add = len(self.cache._reduced_games_packages)
+ data = self.cache.get(game_name, cks)
+ self.assert_conversion(full_games_package, *data) # type: ignore
+ self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
+
+ def test_get_missing_from_db_uses_full_games_package(self) -> None:
+ """Tests that a get with full data (missing from db) will use the full data and is cached"""
+ game_name = "Yet Another Game"
+ full_games_package = deepcopy(self.static_data[self.any_game])
+ full_games_package["checksum"] = "4567"
+ before_add = len(self.cache._reduced_games_packages)
+ data = self.cache.get(game_name, full_games_package)
+ self.assert_conversion(full_games_package, *data) # type: ignore
+ self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
+
+ def test_get_without_checksum_uses_full_games_package(self) -> None:
+ """Tests that a get with full data and no checksum will use the full data and is not cached"""
+ game_name = "Yet Another Game"
+ full_games_package = deepcopy(self.static_data[self.any_game])
+ del full_games_package["checksum"]
+ before_add = len(self.cache._reduced_games_packages)
+ data = self.cache.get(game_name, full_games_package)
+ self.assert_conversion(full_games_package, *data) # type: ignore
+ self.assertEqual(before_add, len(self.cache._reduced_games_packages))
+
+ def test_get_missing_from_db_raises(self) -> None:
+ """Tests that a get that requires a row to exist raise an exception if it doesn't"""
+ with self.assertRaises(Exception):
+ _ = self.cache.get("Does not exist", {"checksum": "0000"})
diff --git a/test/worlds/__init__.py b/test/worlds/__init__.py
index 4a4e3e07c15..8f6b77ffe87 100644
--- a/test/worlds/__init__.py
+++ b/test/worlds/__init__.py
@@ -35,7 +35,7 @@ def load_tests(loader: "TestLoader", standard_tests: "TestSuite", pattern: str):
folders = [os.path.join(os.path.split(world.__file__)[0], "test")
- for world in AutoWorldRegister.world_types.values()
+ for world in AutoWorldRegister.testable_worlds.values()
if fnmatch.fnmatch(world.__module__, world_glob)]
all_tests = [
diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py
index 468706a3474..99b75f7c7a7 100644
--- a/worlds/AutoWorld.py
+++ b/worlds/AutoWorld.py
@@ -28,7 +28,9 @@ class InvalidItemError(KeyError):
class AutoWorldRegister(type):
- world_types: Dict[str, Type[World]] = {}
+ world_types: dict[str, Type[World]] = {}
+ testable_worlds: dict[str, Type[World]] = world_types
+ """worlds under test; scoped to AP_TEST_WORLDS by worlds/__init__"""
__file__: str
zip_path: Optional[str]
settings_key: str
@@ -361,7 +363,6 @@ def __init__(self, multiworld: "MultiWorld", player: int):
self.multiworld = multiworld
self.player = player
self.random = Random(multiworld.random.getrandbits(64))
- multiworld.per_slot_randoms[player] = self.random
def __getattr__(self, item: str) -> Any:
if item == "settings":
diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py
index e0d981c6a3f..e1be46f87d0 100644
--- a/worlds/LauncherComponents.py
+++ b/worlds/LauncherComponents.py
@@ -2,19 +2,22 @@
import logging
import pathlib
import weakref
-from enum import Enum, auto
-from typing import Optional, Callable, List, Iterable, Tuple
+import sys
+import webbrowser
+from enum import Enum
+from typing import Optional, Callable, Iterable, Sequence
-from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore
+from Launcher import launch as launch_exe
+from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore, \
+ is_windows
-class Type(Enum):
- TOOL = auto()
- MISC = auto()
- CLIENT = auto()
- ADJUSTER = auto()
- FUNC = auto() # do not use anymore
- HIDDEN = auto()
+class Type(str, Enum):
+ TOOL = "TOOL"
+ MISC = "MISC"
+ CLIENT = "CLIENT"
+ ADJUSTER = "ADJUSTER"
+ HIDDEN = "HIDDEN"
class Component:
@@ -67,10 +70,6 @@ def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon
self.cli = cli
- if component_type == Type.FUNC:
- from Utils import deprecate
- deprecate(f"Launcher Component {self.display_name} is using Type.FUNC Type, which is pending removal.")
- component_type = Type.MISC
self.type = component_type or (
Type.CLIENT if "Client" in display_name else
@@ -86,18 +85,27 @@ def handles_file(self, path: str):
def __repr__(self):
return f"{self.__class__.__name__}({self.display_name})"
+ def run(self, *args) -> bool:
+ if self.func:
+ self.func(*args)
+ elif self.script_name:
+ import subprocess
+ subprocess.run([*get_exe(self.script_name), *args])
+ else:
+ logging.warning(f"Component {self} does not appear to be executable.")
+
processes = weakref.WeakSet()
-def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
+def launch_subprocess(func: Callable, name: str | None = None, args: tuple[str, ...] = ()) -> None:
import multiprocessing
process = multiprocessing.Process(target=func, name=name, args=args)
process.start()
processes.add(process)
-def launch(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
+def launch(func: Callable, name: str | None = None, args: tuple[str, ...] = ()) -> None:
from Utils import is_kivy_running
if is_kivy_running():
launch_subprocess(func, name, args)
@@ -124,7 +132,7 @@ def launch_textclient(*args):
launch(CommonClient.run_as_textclient, name="TextClient", args=args)
-def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
+def _install_apworld(apworld_src: str = "") -> Optional[tuple[pathlib.Path, pathlib.Path]]:
if not apworld_src:
apworld_src = open_filename('Select APWorld file to install', (('APWorld', ('.apworld',)),))
if not apworld_src:
@@ -182,7 +190,9 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
"so a Launcher restart is required to use the new installation.")
world_source = worlds.WorldSource(str(target), is_zip=True, relative=False)
bisect.insort(worlds.world_sources, world_source)
- world_source.load()
+ worlds.add_apworld_spec(world_source, worlds.APWorldContainer(world_source.resolved_path))
+ if not world_source.load():
+ raise Exception(f"World was installed, but failed to load. See the Launcher log for details.")
return apworld_path, target
@@ -215,8 +225,124 @@ def export_datapackage() -> None:
open_file(path)
+def open_patch():
+ from Utils import messagebox
+ from os.path import isfile
+ suffixes = []
+ for c in components:
+ if c.type == Type.CLIENT and \
+ isinstance(c.file_identifier, SuffixIdentifier) and \
+ (c.script_name is None or isfile(get_exe(c)[-1])):
+ suffixes += c.file_identifier.suffixes
+ try:
+ filename = open_filename("Select patch", (("Patches", suffixes),))
+ except Exception as e:
+ messagebox("Error", str(e), error=True)
+ else:
+ file, component = identify(filename)
+ if file and component:
+ exe = get_exe(component)
+ if exe is None or not isfile(exe[-1]):
+ exe = get_exe("Launcher")
+
+ launch_exe([*exe, file], component.cli)
+
+
+def get_exe(component: str | Component) -> Sequence[str] | None:
+ if isinstance(component, str):
+ name = component
+ component = None
+ if name.startswith("Archipelago"):
+ name = name[11:]
+ if name.endswith(".exe"):
+ name = name[:-4]
+ if name.endswith(".py"):
+ name = name[:-3]
+ if not name:
+ return None
+ for c in components:
+ if c.script_name == name or c.frozen_name == f"Archipelago{name}":
+ component = c
+ break
+ if not component:
+ return None
+ if is_frozen():
+ suffix = ".exe" if is_windows else ""
+ return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
+ else:
+ return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
+
+def identify(path: None | str) -> tuple[None | str, None | Component]:
+ if path is None:
+ return None, None
+ for component in components:
+ if component.handles_file(path):
+ return path, component
+ elif path == component.display_name or path == component.script_name:
+ return None, component
+ return None, None
+
+def open_host_yaml():
+ import settings
+ import subprocess
+ from shutil import which
+ from Utils import is_linux, is_macos, env_cleared_lib_path
+ s = settings.get_settings()
+ file = s.filename
+ s.save()
+ assert file, "host.yaml missing"
+ if is_linux:
+ exe = which('sensible-editor') or which('gedit') or \
+ which('xdg-open') or which('gnome-open') or which('kde-open')
+ elif is_macos:
+ exe = which("open")
+ else:
+ webbrowser.open(file)
+ return
+
+ env = env_cleared_lib_path()
+ subprocess.Popen([exe, file], env=env)
+
+def generate_yamls(*args):
+ import argparse
+
+ from Options import generate_yaml_templates
+
+ parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
+ parser.add_argument("--skip_open_folder", action="store_true")
+ args = parser.parse_args(args)
+
+ target = user_path("Players", "Templates")
+ generate_yaml_templates(target, False)
+ if not args.skip_open_folder:
+ open_folder(target)
+
+
+def browse_files():
+ open_folder(user_path())
+
+
+def open_folder(folder_path):
+ import subprocess
+ from shutil import which
+ from Utils import is_linux, is_macos, env_cleared_lib_path
+
+ if is_linux:
+ exe = which('xdg-open') or which('gnome-open') or which('kde-open')
+ elif is_macos:
+ exe = which("open")
+ else:
+ webbrowser.open(folder_path)
+ return
+
+ if exe:
+ env = env_cleared_lib_path()
+ subprocess.Popen([exe, folder_path], env=env)
+ else:
+ logging.warning(f"No file browser available to open {folder_path}")
+
-components: List[Component] = [
+components: list[Component] = [
# Launcher
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
# Core
@@ -231,6 +357,22 @@ def export_datapackage() -> None:
description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."),
+ # Functions
+ Component("Open host.yaml", func=open_host_yaml,
+ description="Open the host.yaml file to change settings for generation, games, and more."),
+ Component("Open Patch", func=open_patch,
+ description="Open a patch file, downloaded from the room page or provided by the host."),
+ Component("Generate Template Options", func=generate_yamls,
+ description="Generate template YAMLs for currently installed games."),
+ Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
+ description="Open archipelago.gg in your browser."),
+ Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
+ description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
+ Component("Unrated/18+ Discord Server", icon="discord",
+ func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
+ description="Find unrated and 18+ games in the After Dark Discord server."),
+ Component("Browse Files", func=browse_files,
+ description="Open the Archipelago installation folder in your file browser."),
Component('LttP Adjuster', 'LttPAdjuster'),
# Ocarina of Time
Component('OoT Client', 'OoTClient',
@@ -266,7 +408,6 @@ def _build_apworlds(*launch_args: str):
from worlds import AutoWorldRegister
from worlds.Files import APWorldContainer
- from Launcher import open_folder
import argparse
parser = argparse.ArgumentParser(prog="Build APWorlds", description="Build script for APWorlds")
diff --git a/worlds/__init__.py b/worlds/__init__.py
index 8684b742495..fe3d9e6c0c6 100644
--- a/worlds/__init__.py
+++ b/worlds/__init__.py
@@ -15,6 +15,7 @@
from NetUtils import DataPackage
from Utils import local_path, user_path, Version, version_tuple, tuplize_version, messagebox
+from .Files import APWorldContainer
local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
@@ -35,6 +36,9 @@
failed_world_loads: dict[str, str] = {}
+logger = logging.getLogger("Worlds")
+logger.propagate = False
+logger.setLevel(logging.INFO)
@dataclasses.dataclass(order=True)
class WorldSource:
@@ -56,7 +60,7 @@ def resolved_path(self) -> str:
def load(self) -> bool:
try:
start = time.perf_counter()
- importlib.import_module(f".{Path(self.path).stem}", "worlds")
+ importlib.import_module(f".{self.name}", "worlds")
self.time_taken = time.perf_counter()-start
return True
@@ -73,14 +77,44 @@ def load(self) -> bool:
failed_world_loads[os.path.basename(self.path).rsplit(".", 1)[0]] = reason
return False
+ @property
+ def name(self) -> str:
+ return Path(self.path).stem
+
+# AP_TEST_WORLDS scopes auto-loading to the named worlds; these are always loaded for the
+# suite's fixtures but aren't themselves worlds under test.
+_SUITE_FIXTURE_WORLDS = {"generic", "apquest"}
+_test_worlds_env = os.environ.get("AP_TEST_WORLDS") if "pytest" in sys.modules or "unittest" in sys.modules else None
+_requested_worlds = {name.strip() for name in _test_worlds_env.split(",") if name.strip()} if _test_worlds_env else None
+test_worlds_filter = _requested_worlds | _SUITE_FIXTURE_WORLDS if _requested_worlds else None
+
+apworld_module_specs = {}
+class APWorldModuleFinder(importlib.abc.MetaPathFinder):
+ def find_spec(
+ self, fullname: str, _path: Sequence[str] | None, _target: ModuleType | None = None
+ ) -> importlib.machinery.ModuleSpec | None:
+ return apworld_module_specs.get(fullname)
+sys.meta_path.insert(0, APWorldModuleFinder())
+
+
+def add_apworld_spec(source: WorldSource, container: APWorldContainer):
+ importer = zipimport.zipimporter(source.resolved_path)
+ world_name = Path(container.path).stem
+
+ spec = importer.find_spec(f"worlds.{world_name}")
+ apworld_module_specs[f"worlds.{world_name}"] = spec
+
# find potential world containers, currently folders and zip-importable .apworld's
+logger.info("Indexing worlds")
world_sources: List[WorldSource] = []
for folder in (folder for folder in (user_folder, local_folder) if folder):
relative = folder == local_folder
for entry in os.scandir(folder):
# prevent loading of __pycache__ and allow _* for non-world folders, disable files/folders starting with "."
if not entry.name.startswith(("_", ".")):
+ if test_worlds_filter is not None and Path(entry.name).stem not in test_worlds_filter:
+ continue
file_name = entry.name if relative else os.path.join(folder, entry.name)
if entry.is_dir():
if os.path.isfile(os.path.join(entry.path, '__init__.py')):
@@ -93,6 +127,7 @@ def load(self) -> bool:
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))
# import all submodules to trigger AutoWorldRegister
+logger.info("Processing found worlds")
world_sources.sort()
apworlds: list[WorldSource] = []
for world_source in world_sources:
@@ -100,6 +135,7 @@ def load(self) -> bool:
if world_source.is_zip:
apworlds.append(world_source)
else:
+ logger.info(world_source.name)
world_source.load()
from .AutoWorld import AutoWorldRegister
@@ -125,7 +161,7 @@ def load(self) -> bool:
# encapsulation for namespace / gc purposes
def load_apworlds() -> None:
global apworlds
- from .Files import APWorldContainer, InvalidDataError
+ from .Files import InvalidDataError
core_compatible: list[tuple[WorldSource, APWorldContainer]] = []
def fail_world(game_name: str, reason: str, add_as_failed_to_load: bool = True) -> None:
@@ -134,6 +170,7 @@ def fail_world(game_name: str, reason: str, add_as_failed_to_load: bool = True)
logging.warning(reason)
for apworld_source in apworlds:
+ logger.info(apworld_source.name)
apworld: APWorldContainer = APWorldContainer(apworld_source.resolved_path)
# populate metadata
try:
@@ -174,15 +211,6 @@ def fail_world(game_name: str, reason: str, add_as_failed_to_load: bool = True)
key=lambda element: element[1].world_version if element[1].world_version else Version(0, 0, 0),
reverse=True)
- apworld_module_specs = {}
- class APWorldModuleFinder(importlib.abc.MetaPathFinder):
- def find_spec(
- self, fullname: str, _path: Sequence[str] | None, _target: ModuleType = None
- ) -> importlib.machinery.ModuleSpec | None:
- return apworld_module_specs.get(fullname)
-
- sys.meta_path.insert(0, APWorldModuleFinder())
-
for apworld_source, apworld in core_compatible:
if apworld.game and apworld.game in AutoWorldRegister.world_types:
fail_world(apworld.game,
@@ -190,11 +218,8 @@ def find_spec(
f"as its game {apworld.game} is already loaded.",
add_as_failed_to_load=False)
else:
- importer = zipimport.zipimporter(apworld_source.resolved_path)
- world_name = Path(apworld.path).stem
-
- spec = importer.find_spec(f"worlds.{world_name}")
- apworld_module_specs[f"worlds.{world_name}"] = spec
+ logger.info(apworld_source.name)
+ add_apworld_spec(apworld_source, apworld)
apworld_source.load()
if apworld.game in AutoWorldRegister.world_types:
@@ -215,7 +240,18 @@ def find_spec(
del apworlds
+# snapshot the worlds under test (a copy, so tests reassigning world_types can't leak in), dropping the
+# force-loaded fixtures unless they were explicitly requested.
+if _requested_worlds:
+ AutoWorldRegister.testable_worlds = {
+ game: world for game, world in AutoWorldRegister.world_types.items()
+ if Path(world.__file__).parent.name in _requested_worlds
+ }
+else:
+ AutoWorldRegister.testable_worlds = dict(AutoWorldRegister.world_types)
+
# Build the data package for each game.
+logger.info("Datapackage")
network_data_package: DataPackage = {
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
}
diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py
index 142c2964009..bbac7f0ddb7 100644
--- a/worlds/_bizhawk/context.py
+++ b/worlds/_bizhawk/context.py
@@ -120,7 +120,6 @@ def _cmd_toggle_text(self, category: str | None = None, toggle: str | None = Non
class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor
text_passthrough_categories: set[str]
- server_seed_name: str | None = None
auth_status: AuthStatus
password_requested: bool
client_handler: BizHawkClient | None
@@ -169,8 +168,6 @@ def on_package(self, cmd, args):
if cmd == "Connected":
self.slot_data = args.get("slot_data", None)
self.auth_status = AuthStatus.AUTHENTICATED
- elif cmd == "RoomInfo":
- self.server_seed_name = args.get("seed_name", None)
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)
@@ -203,7 +200,6 @@ async def server_auth(self, password_requested: bool=False):
async def disconnect(self, allow_autoreconnect: bool=False):
self.auth_status = AuthStatus.NOT_AUTHENTICATED
- self.server_seed_name = None
await super().disconnect(allow_autoreconnect)
@@ -354,8 +350,9 @@ async def main():
if args.patch_file != "":
metadata = _patch_and_run_game(args.patch_file)
- if "server" in metadata:
- args.connect = metadata["server"]
+ server = metadata.get("server")
+ if server: # ignore missing and empty string servers
+ args.connect = server
ctx = BizHawkClientContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
diff --git a/worlds/alttp/EnemizerPatches.py b/worlds/alttp/EnemizerPatches.py
index 9d7618c075b..7b849c8e107 100644
--- a/worlds/alttp/EnemizerPatches.py
+++ b/worlds/alttp/EnemizerPatches.py
@@ -385,10 +385,11 @@ def _randomize_enemy_damage(rom: "LocalRom", rng: random.Random, allow_zero_dama
for sprite_id in range(0xF3):
if sprite_id in EXCLUDED_ENEMY_TABLE_SPRITE_IDS:
continue
+ damage_address = ENEMY_DAMAGE_TABLE_ADDRESS + sprite_id
new_damage = rng.randrange(8)
if not allow_zero_damage and new_damage == 2:
continue
- rom.write_byte(ENEMY_DAMAGE_TABLE_ADDRESS + sprite_id, new_damage)
+ rom.write_byte(damage_address, (rom.read_byte(damage_address) & 0xF0) | new_damage)
def _shuffle_damage_groups(
diff --git a/worlds/alttp/docs/multiworld_en.md b/worlds/alttp/docs/multiworld_en.md
index be0310f911c..d31a0430b13 100644
--- a/worlds/alttp/docs/multiworld_en.md
+++ b/worlds/alttp/docs/multiworld_en.md
@@ -41,19 +41,21 @@ files. Your patch file should have a `.aplttp` extension.
Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically launch the
client, and will also create your ROM in the same place as your patch file.
-### Connect to the client
+## Connect to the client
-#### With an emulator
+### With an emulator
-When the client launched automatically, SNI should have also automatically launched in the background. If this is its
-first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
+When patching the ROM, the client should launch automatically, and SNI should also start in the background.
+If this is the first time it runs, you may be prompted to allow it through the Windows Firewall.
+
+If the client did not launch automatically, use the `SNI Client` in the Archipelago Launcher to manually start the AP client.
#### snes9x-nwa
1. Click on the Network Menu and check **Enable Emu Network Control**
2. Load your ROM file if it hasn't already been loaded.
-##### snes9x-rr
+#### snes9x-rr
1. Load your ROM file if it hasn't already been loaded.
2. Click on the File menu and hover on **Lua Scripting**
@@ -69,7 +71,7 @@ the lua you are using in your file explorer and copy the `socket.dll` to the bas
1. Load your ROM file if it hasn't already been loaded.
2. The emulator should automatically connect while SNI is running.
-##### BizHawk
+#### BizHawk
1. Ensure you have the BSNES core loaded. This is done with the main menubar, under:
- (≤ 2.8) `Config` 〉 `Cores` 〉 `SNES` 〉 `BSNES`
@@ -81,7 +83,7 @@ the lua you are using in your file explorer and copy the `socket.dll` to the bas
- You could instead open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate to `Connector.lua`
with the file picker.
-##### RetroArch 1.10.1 or newer
+#### RetroArch 1.10.1 or newer
You only have to do these steps once.
@@ -96,7 +98,7 @@ You only have to do these steps once.
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
read ROM data.
-#### With hardware
+### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do
this now. SD2SNES and FXPak Pro users may download the appropriate firmware
@@ -106,7 +108,7 @@ this now. SD2SNES and FXPak Pro users may download the appropriate firmware
1. Close your emulator, which may have auto-launched.
2. Power on your device and load the ROM.
-### Connect to the Archipelago Server
+## Connect to the Archipelago Server
The patch file which launched your client should have automatically connected you to the AP Server. There are a few
reasons this may not happen however, including if the game is hosted on the website but was generated elsewhere. If the
@@ -115,7 +117,7 @@ into the "Server" input field then press enter.
The client will attempt to reconnect to the new server address, and should momentarily show "Server Status: Connected".
-### Play the game
+## Play the game
When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations on
successfully joining a multiworld game! You can execute various commands in your client. For more information regarding
diff --git a/worlds/alttp/test/TestEnemizerPatches.py b/worlds/alttp/test/TestEnemizerPatches.py
index 4aa09bb62e2..354688951ae 100644
--- a/worlds/alttp/test/TestEnemizerPatches.py
+++ b/worlds/alttp/test/TestEnemizerPatches.py
@@ -106,10 +106,12 @@ def test_bush_shuffle_and_remaining_tables_are_patched_natively(self) -> None:
included_hp_sprite_id = 0x01
included_damage_sprite_id = 0x02
+ boss_damage_sprite_id = 0x53
excluded_sprite_id = min(EXCLUDED_ENEMY_TABLE_SPRITE_IDS)
rom.write_byte(ENEMY_HP_TABLE_ADDRESS + included_hp_sprite_id, 0x06)
rom.write_byte(ENEMY_HP_TABLE_ADDRESS + excluded_sprite_id, 0x07)
- rom.write_byte(ENEMY_DAMAGE_TABLE_ADDRESS + included_damage_sprite_id, 0x06)
+ rom.write_byte(ENEMY_DAMAGE_TABLE_ADDRESS + included_damage_sprite_id, 0x86)
+ rom.write_byte(ENEMY_DAMAGE_TABLE_ADDRESS + boss_damage_sprite_id, 0x17)
rom.write_byte(ENEMY_DAMAGE_TABLE_ADDRESS + excluded_sprite_id, 0x05)
world = self._build_world(
@@ -133,7 +135,10 @@ def test_bush_shuffle_and_remaining_tables_are_patched_natively(self) -> None:
self.assertGreaterEqual(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + included_hp_sprite_id), 2)
self.assertLess(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + included_hp_sprite_id), 25)
self.assertEqual(rom.read_byte(ENEMY_HP_TABLE_ADDRESS + excluded_sprite_id), 0x07)
- self.assertIn(rom.read_byte(ENEMY_DAMAGE_TABLE_ADDRESS + included_damage_sprite_id), range(8))
+ randomized_damage = rom.read_byte(ENEMY_DAMAGE_TABLE_ADDRESS + included_damage_sprite_id)
+ self.assertEqual(randomized_damage & 0xF0, 0x80)
+ self.assertIn(randomized_damage & 0x0F, range(8))
+ self.assertEqual(rom.read_byte(ENEMY_DAMAGE_TABLE_ADDRESS + boss_damage_sprite_id), 0x17)
self.assertEqual(rom.read_byte(ENEMY_DAMAGE_TABLE_ADDRESS + excluded_sprite_id), 0x05)
for group_id in range(10):
group_address = DAMAGE_GROUP_TABLE_ADDRESS + (group_id * 3)
diff --git a/worlds/apquest/components.py b/worlds/apquest/components.py
index b50b5d0dcfb..b145a19e65f 100644
--- a/worlds/apquest/components.py
+++ b/worlds/apquest/components.py
@@ -1,6 +1,7 @@
from worlds.LauncherComponents import Component, Type, components, launch
+# Launcher components can be used to add entries to the launcher if desired.
# The most common type of component is a client, but there are other components, such as sprite/palette adjusters.
# (Note: Some worlds distribute their clients as separate, standalone programs,
# while others include them in the apworld itself. Standalone clients are not an apworld component,
diff --git a/worlds/apquest/game/game.py b/worlds/apquest/game/game.py
index f032e73e4ef..e35f58e128f 100644
--- a/worlds/apquest/game/game.py
+++ b/worlds/apquest/game/game.py
@@ -237,24 +237,20 @@ def do_auto_move(self) -> bool:
target_x, target_y = self.auto_target_path.pop(0)
movement = target_x - self.player.current_x, target_y - self.player.current_y
direction = Direction(movement)
+
+ facing_before = self.player.facing
moved = self.attempt_player_movement(direction, cancel_auto_move=False)
+ direction_changed = self.player.facing != facing_before
if moved:
return True
- # We are attempting to interact with something on the path.
- # First, make the player face it.
- if self.player.facing != direction:
- self.player.facing = direction
- self.auto_target_path.insert(0, (target_x, target_y))
- return True
-
- # If we are facing it, attempt to interact with it.
+ # We are trying to interact with something to clear the path.
changed = self.attempt_interact()
if not changed:
self.cancel_auto_move()
- return False
+ return direction_changed
# If the interaction was successful, and this was the end of the path, stop
# (i.e. don't try to attack the attacked enemy over and over until it's dead)
diff --git a/worlds/apquest/game/path_finding.py b/worlds/apquest/game/path_finding.py
index 92ddfeb26bc..0dd5725c6f2 100644
--- a/worlds/apquest/game/path_finding.py
+++ b/worlds/apquest/game/path_finding.py
@@ -1,84 +1,81 @@
import heapq
-from collections.abc import Generator
+from dataclasses import dataclass
-Point = tuple[int, int]
+MAX_DISTANCE = 1000000000
-def heuristic(a: Point, b: Point) -> int:
- # Manhattan distance (good for 4-directional grids)
- return abs(a[0] - b[0]) + abs(a[1] - b[1])
+@dataclass
+class Vertex:
+ x: int
+ y: int
+ euclidean_distance_to_target: float
+ distance: int = MAX_DISTANCE
+ prev: "Vertex | None" = None
-
-def reconstruct_path(came_from: dict[Point, Point], current: Point) -> list[Point]:
- path = [current]
- while current in came_from:
- current = came_from[current]
- path.append(current)
- path.reverse()
- return path
+ def __lt__(self, other: "Vertex") -> bool:
+ return self.distance < other.distance
def find_path_or_closest(
- grid: tuple[tuple[bool, ...], ...], source_x: int, source_y: int, target_x: int, target_y: int
-) -> list[Point]:
- start = source_x, source_y
- goal = target_x, target_y
+ traversibility_grid: tuple[tuple[bool, ...], ...], source_x: int, source_y: int, target_x: int, target_y: int
+) -> list[tuple[int, int]]:
+ vertex_grid = [
+ [Vertex(x, y, (x - target_x) ** 2 + (y - target_y) ** 2) for x in range(len(traversibility_grid[0]))]
+ for y in range(len(traversibility_grid))
+ ]
+ vertex_grid[source_y][source_x].distance = 0
- rows, cols = len(grid), len(grid[0])
+ vertices = [vertex for row in vertex_grid for vertex in row]
- def in_bounds(p: Point) -> bool:
- return 0 <= p[0] < rows and 0 <= p[1] < cols
+ heapq.heapify(vertices)
- def passable(p: Point) -> bool:
- return grid[p[1]][p[0]]
+ while vertices:
+ vertex = heapq.heappop(vertices)
- def neighbors(p: Point) -> Generator[Point, None, None]:
- x, y = p
- for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
- np = (x + dx, y + dy)
- if in_bounds(np) and passable(np):
- yield np
+ if vertex.distance == MAX_DISTANCE:
+ break
- open_heap: list[tuple[int, tuple[int, int]]] = []
- heapq.heappush(open_heap, (0, start))
+ for neighbor_x, neighbor_y in (
+ (vertex.x + 1, vertex.y),
+ (vertex.x - 1, vertex.y),
+ (vertex.x, vertex.y + 1),
+ (vertex.x, vertex.y - 1),
+ ):
+ if neighbor_x < 0:
+ continue
+ if neighbor_y < 0:
+ continue
+ if neighbor_x >= len(traversibility_grid[0]):
+ continue
+ if neighbor_y >= len(traversibility_grid):
+ continue
- came_from: dict[Point, Point] = {}
- g_score = {start: 0}
+ if not traversibility_grid[neighbor_y][neighbor_x]:
+ continue
- # Track best fallback node
- best_node = start
- best_dist = heuristic(start, goal)
+ other_vertex = vertex_grid[neighbor_y][neighbor_x]
- visited = set()
+ new_distance = vertex.distance + 1
- while open_heap:
- _, current = heapq.heappop(open_heap)
+ if new_distance < other_vertex.distance:
+ other_vertex.distance = new_distance
+ other_vertex.prev = vertex
- if current in visited:
- continue
- visited.add(current)
+ heapq.heapify(vertices)
- # Check if we reached the goal
- if current == goal:
- return reconstruct_path(came_from, current)
+ closest_vertex = min(
+ (vertex for row in vertex_grid for vertex in row if vertex.distance < MAX_DISTANCE),
+ key=lambda vertex: (vertex.euclidean_distance_to_target, vertex.distance),
+ )
- # Update "closest node" fallback
- dist = heuristic(current, goal)
- if dist < best_dist or (dist == best_dist and g_score[current] < g_score.get(best_node, float("inf"))):
- best_node = current
- best_dist = dist
+ path: list[tuple[int, int]] = []
- for neighbor in neighbors(current):
- tentative_g = g_score[current] + 1 # cost is 1 per move
+ while closest_vertex.prev is not None:
+ path.append((closest_vertex.x, closest_vertex.y))
- if tentative_g < g_score.get(neighbor, float("inf")):
- came_from[neighbor] = current
- g_score[neighbor] = tentative_g
- f_score = tentative_g + heuristic(neighbor, goal)
- heapq.heappush(open_heap, (f_score, neighbor))
+ closest_vertex = closest_vertex.prev
- # Goal not reachable → return path to closest node
- if best_node is not None:
- return reconstruct_path(came_from, best_node)
+ path.append((source_x, source_y))
+ path.reverse()
- return []
+ return path
diff --git a/worlds/apquest/rules.py b/worlds/apquest/rules.py
index 02c76daefcb..aa81fdaff8f 100644
--- a/worlds/apquest/rules.py
+++ b/worlds/apquest/rules.py
@@ -71,6 +71,7 @@ def set_all_entrance_rules(world: APQuestWorld) -> None:
# Rule Builder is quite comprehensive, and even if you have really esoteric rules,
# you can make custom rules by subclassing CustomRule.
+
def set_all_location_rules(world: APQuestWorld) -> None:
# Location rules work no differently from Entrance rules.
# Most of our locations are chests that can simply be opened by walking up to them.
diff --git a/worlds/civ_6/data/boosts.py b/worlds/civ_6/data/boosts.py
index d0bdecc0558..07049fd2fd6 100644
--- a/worlds/civ_6/data/boosts.py
+++ b/worlds/civ_6/data/boosts.py
@@ -165,9 +165,9 @@
"BOOST_TECH_CASTLES",
"ERA_MEDIEVAL",
[
- "CIVIC_SUFFRAGE",
- "CIVIC_TOTALITARIANISM",
- "CIVIC_CLASS_STRUGGLE",
+ "CIVIC_EXPLORATION",
+ "CIVIC_DIVINE_RIGHT",
+ "CIVIC_REFORMED_CHURCH",
],
1,
"DEFAULT",
diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py
index 00ef0d866a3..b53b0fa05f8 100644
--- a/worlds/factorio/Mod.py
+++ b/worlds/factorio/Mod.py
@@ -23,7 +23,6 @@
data_template: Optional[jinja2.Template] = None
data_final_template: Optional[jinja2.Template] = None
-locale_template: Optional[jinja2.Template] = None
control_template: Optional[jinja2.Template] = None
settings_template: Optional[jinja2.Template] = None
@@ -94,7 +93,7 @@ def generate_mod(world: "Factorio", output_directory: str):
multiworld = world.multiworld
random = world.random
- global data_final_template, locale_template, control_template, data_template, settings_template
+ global data_final_template, control_template, data_template, settings_template
with template_load_lock:
if not data_final_template:
def load_template(name: str):
@@ -107,7 +106,6 @@ def load_template(name: str):
data_template = template_env.get_template("data.lua")
data_final_template = template_env.get_template("data-final-fixes.lua")
- locale_template = template_env.get_template(r"locale/en/locale.cfg")
control_template = template_env.get_template("control.lua")
settings_template = template_env.get_template("settings.lua")
# get data for templates
@@ -195,9 +193,7 @@ def flop_random(low, high, base=None):
control_template.render(**template_data)))
mod.writing_tasks.append(lambda: (versioned_mod_name + "/settings.lua",
settings_template.render(**template_data)))
- mod.writing_tasks.append(lambda: (versioned_mod_name + "/locale/en/locale.cfg",
- locale_template.render(**template_data)))
-
+
info = base_info.copy()
info["name"] = mod_name
mod.writing_tasks.append(lambda: (versioned_mod_name + "/info.json",
diff --git a/worlds/factorio/data/mod/locale/en/locale.cfg b/worlds/factorio/data/mod/locale/en/locale.cfg
new file mode 100644
index 00000000000..1ddf2827422
--- /dev/null
+++ b/worlds/factorio/data/mod/locale/en/locale.cfg
@@ -0,0 +1,38 @@
+[entity-name]
+ap-energy-bridge=Archipelago EnergyLink Bridge
+
+[map-gen-preset-name]
+archipelago=Archipelago
+
+[map-gen-preset-description]
+archipelago=World preset created by the Archipelago Randomizer. World may or may not contain actual archipelagos.
+
+[technology-name]
+ap-technology-full=__1__'s __2__ (__3__)
+# this comment is added because without this second ' the rest of the file remains a different colour in my edit program
+ap-technology-hidden=__1__
+
+[technology-description]
+ap-technology-full=Researching this technology sends __1__ to __2____3__.
+ap-technology-item-advancement=, which is considered a logical advancement
+ap-technology-item-useful=, which is considered useful
+ap-technology-item-trap=, which is considered fun
+ap-technology-hidden=Researching this technology sends something to someone__1__.
+
+[mod-setting-name]
+archipelago-death-link=Death Link
+
+[mod-setting-description]
+archipelago-death-link=Kill other players in the same Archipelago Multiworld that also have Death Link turned on, when you die.
+
+[archipelago]
+receive-ap-item=Received __1__ from __2__.
+receive-ap-catchup=Received __1__ as it is already checked.
+receive-sample-item=Received __1__x __2__
+sample-inventory-full=Additional items will be sent when inventory space is available.
+sample-error=Unable to receive __1__x [item=__2__] as this item does not exist.
+fail-to-place=Failed to place __1__ in __2__
+death-link=Death was granted by __1__
+
+[traps]
+new-evolution-factor=New evolution factor: __1__
diff --git a/worlds/factorio/data/mod_template/control.lua b/worlds/factorio/data/mod_template/control.lua
index 3f754d925ce..4038748d4a8 100644
--- a/worlds/factorio/data/mod_template/control.lua
+++ b/worlds/factorio/data/mod_template/control.lua
@@ -407,11 +407,12 @@ function update_player(index)
--player.print(serpent.block(data['pending_samples']))
local stack = {}
+ local quality = "{{ free_sample_quality_name }}"
for name, count in pairs(samples) do
stack.name = name
stack.count = count
if script.active_mods["quality"] then
- stack.quality = "{{ free_sample_quality_name }}"
+ stack.quality = quality
end
if prototypes.item[name] then
if character.can_insert(stack) then
@@ -420,12 +421,12 @@ function update_player(index)
sent = 0
end
if sent > 0 then
- player.print("Received " .. sent .. "x [item=" .. name .. ",quality={{ free_sample_quality_name }}]")
+ player.print({"archipelago.receive-sample-item", sent, "[item=" .. name .. ",quality="..quality.."]"})
data.suppress_full_inventory_message = false
end
if sent ~= count then -- Couldn't full send.
if not data.suppress_full_inventory_message then
- player.print("Additional items will be sent when inventory space is available.", {r=1, g=1, b=0.25})
+ player.print({"archipelago.sample-inventory-full"}, {r=1, g=1, b=0.25})
end
data.suppress_full_inventory_message = true -- Avoid spamming them with repeated full inventory messages.
samples[name] = count - sent -- Buffer the remaining items
@@ -434,7 +435,7 @@ function update_player(index)
samples[name] = nil -- Remove from the list
end
else
- player.print("Unable to receive " .. count .. "x [item=" .. name .. "] as this item does not exist.")
+ player.print({"archipelago.sample-error", count, name})
samples[name] = nil
end
end
@@ -665,7 +666,7 @@ function spawn_entity(surface, force, name, x, y, radius, randomize, avoid_ores)
end
end
if new_entity == nil then
- force.print("Failed to place " .. args.name .. " in " .. serpent.line({x = x, y = y, radius = radius}))
+ force.print({"archipelago.fail-to-place", args.name, serpent.line({x = x, y = y, radius = radius})})
end
end
@@ -725,7 +726,7 @@ end,
local new_factor = game.forces["enemy"].get_evolution_factor("nauvis") +
(TRAP_EVO_FACTOR * (1 - game.forces["enemy"].get_evolution_factor("nauvis")))
game.forces["enemy"].set_evolution_factor(new_factor, "nauvis")
- game.print({"", "New evolution factor:", new_factor})
+ game.print({"traps.new-evolution-factor", new_factor})
end,
["Teleport Trap"] = function()
for _, player in ipairs(game.forces["player"].players) do
@@ -777,10 +778,10 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
if index == nil then
game.print("ap-get-technology is only to be used by the Archipelago Factorio Client")
return
- elseif index == -1 then -- for coop sync and restoring from an older savegame
+ elseif index == "-1" then -- for coop sync and restoring from an older savegame
tech = force.technologies[item_name]
if tech.researched ~= true then
- game.print({"", "Received [technology=" .. tech.name .. "] as it is already checked."})
+ game.print({"archipelago.receive-ap-catchup", "[technology=" .. tech.name .. "]"})
game.play_sound({path="utility/research_completed"})
tech.researched = true
end
@@ -792,7 +793,7 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
for _, item_name in ipairs(tech_stack) do
tech = force.technologies[item_name]
if tech.researched ~= true then
- game.print({"", "Received [technology=" .. tech.name .. "] from ", source})
+ game.print({"archipelago.receive-ap-item", "[technology=" .. tech.name .. "]", source})
game.play_sound({path="utility/research_completed"})
tech.researched = true
return
@@ -804,7 +805,7 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
if tech ~= nil then
storage.index_sync[index] = tech
if tech.researched ~= true then
- game.print({"", "Received [technology=" .. tech.name .. "] from ", source})
+ game.print({"archipelago.receive-ap-item", "[technology=" .. tech.name .. "]", source})
game.play_sound({path="utility/research_completed"})
tech.researched = true
end
@@ -812,7 +813,7 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
elseif TRAP_TABLE[item_name] ~= nil then
if storage.index_sync[index] ~= item_name then -- not yet received trap
storage.index_sync[index] = item_name
- game.print({"", "Received ", item_name, " from ", source})
+ game.print({"archipelago.receive-ap-item", item_name, source})
TRAP_TABLE[item_name]()
end
else
@@ -842,7 +843,7 @@ commands.add_command("ap-deathlink", "Kill all players", function(call)
local force = game.forces["player"]
local source = call.parameter or "Archipelago"
kill_players(force)
- game.print("Death was granted by " .. source)
+ game.print({"archipelago.death-link",source})
end)
commands.add_command("ap-energylink", "Used by the Archipelago client to manage Energy Link", function(call)
diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua
index e4595230198..bfffab6a61a 100644
--- a/worlds/factorio/data/mod_template/data-final-fixes.lua
+++ b/worlds/factorio/data/mod_template/data-final-fixes.lua
@@ -154,6 +154,13 @@ technologies["{{ original_tech_name }}"].hidden_in_factoriopedia = true
{#- the tech researched by the local player #}
new_tree_copy = table.deepcopy(template_tech)
new_tree_copy.name = "ap-{{ location.address }}-"{# use AP ID #}
+{%- if location.revealed %}
+new_tree_copy.localised_name = {"technology-name.ap-technology-full", "{{ player_names[item.player] }}", "{{ item.name }}", "{{ location.name }}"}
+new_tree_copy.localised_description = {"technology-description.ap-technology-full", "{{ item.name }}", "{{ player_names[item.player] }}", {% if item.advancement %}{"technology-description.ap-technology-item-advancement"}{% elif item.useful %}{"technology-description.ap-technology-item-useful"}{% elif item.trap %}{"technology-description.ap-technology-item-trap"}{% else %}""{% endif %}}
+{%- else %}
+new_tree_copy.localised_name = {"technology-name.ap-technology-hidden", "{{location.name}}"}
+new_tree_copy.localised_description = {"technology-description.ap-technology-hidden", {% if tech_tree_information == 1 and item.advancement %}{"technology-description.ap-technology-item-advancement"}{% else %}""{% endif %}}
+{% endif -%}
{% if location.crafted_item is not none %}
new_tree_copy.research_trigger = {
type = "{{ 'craft-fluid' if location.crafted_item in liquids else 'craft-item' }}",
diff --git a/worlds/factorio/data/mod_template/data.lua b/worlds/factorio/data/mod_template/data.lua
index 43151ff0084..562999d5df6 100644
--- a/worlds/factorio/data/mod_template/data.lua
+++ b/worlds/factorio/data/mod_template/data.lua
@@ -13,7 +13,6 @@ end
local energy_bridge = table.deepcopy(data.raw["accumulator"]["accumulator"])
energy_bridge.name = "ap-energy-bridge"
energy_bridge.minable.result = "ap-energy-bridge"
-energy_bridge.localised_name = "Archipelago EnergyLink Bridge"
energy_bridge.energy_source.buffer_capacity = "50MJ"
energy_bridge.energy_source.input_flow_limit = "10MW"
energy_bridge.energy_source.output_flow_limit = "10MW"
@@ -25,7 +24,6 @@ data.raw["accumulator"]["ap-energy-bridge"] = energy_bridge
local energy_bridge_item = table.deepcopy(data.raw["item"]["accumulator"])
energy_bridge_item.name = "ap-energy-bridge"
-energy_bridge_item.localised_name = "Archipelago EnergyLink Bridge"
energy_bridge_item.place_result = energy_bridge.name
tint_icon(energy_bridge_item, energy_bridge_tint())
data.raw["item"]["ap-energy-bridge"] = energy_bridge_item
@@ -35,7 +33,6 @@ energy_bridge_recipe.name = "ap-energy-bridge"
energy_bridge_recipe.results = { {type = "item", name = energy_bridge_item.name, amount = 1} }
energy_bridge_recipe.energy_required = 1
energy_bridge_recipe.enabled = {% if energy_link %}true{% else %}false{% endif %}
-energy_bridge_recipe.localised_name = "Archipelago EnergyLink Bridge"
data.raw["recipe"]["ap-energy-bridge"] = energy_bridge_recipe
data.raw["map-gen-presets"].default["archipelago"] = {{ dict_to_lua({"default": False, "order": "a", "basic_settings": world_gen["basic"], "advanced_settings": world_gen["advanced"]}) }}
diff --git a/worlds/factorio/data/mod_template/locale/en/locale.cfg b/worlds/factorio/data/mod_template/locale/en/locale.cfg
deleted file mode 100644
index 59dcffcd630..00000000000
--- a/worlds/factorio/data/mod_template/locale/en/locale.cfg
+++ /dev/null
@@ -1,31 +0,0 @@
-[map-gen-preset-name]
-archipelago=Archipelago
-
-[map-gen-preset-description]
-archipelago=World preset created by the Archipelago Randomizer. World may or may not contain actual archipelagos.
-
-[technology-name]
-{% for location, item in locations %}
-{%- if location.revealed %}
-ap-{{ location.address }}-={{ player_names[item.player] }}'s {{ item.name }} ({{ location.name }})
-{%- else %}
-ap-{{ location.address }}-= {{location.name}}
-{%- endif -%}
-{% endfor %}
-
-[technology-description]
-{% for location, item in locations %}
-{%- if location.revealed %}
-ap-{{ location.address }}-=Researching this technology sends {{ item.name }} to {{ player_names[item.player] }}{% if item.advancement %}, which is considered a logical advancement{% elif item.useful %}, which is considered useful{% elif item.trap %}, which is considered fun{% endif %}.
-{%- elif tech_tree_information == 1 and item.advancement %}
-ap-{{ location.address }}-=Researching this technology sends something to someone, which is considered a logical advancement.
-{%- else %}
-ap-{{ location.address }}-=Researching this technology sends something to someone.
-{%- endif -%}
-{% endfor %}
-
-[mod-setting-name]
-archipelago-death-link-{{ slot_player }}-{{ seed_name }}=Death Link
-
-[mod-setting-description]
-archipelago-death-link-{{ slot_player }}-{{ seed_name }}=Kill other players in the same Archipelago Multiworld that also have Death Link turned on, when you die.
\ No newline at end of file
diff --git a/worlds/factorio/data/mod_template/settings.lua b/worlds/factorio/data/mod_template/settings.lua
index 41d30e58d55..c88c4325631 100644
--- a/worlds/factorio/data/mod_template/settings.lua
+++ b/worlds/factorio/data/mod_template/settings.lua
@@ -21,6 +21,8 @@ data:extend({
type = "bool-setting",
name = "archipelago-death-link-{{ slot_player }}-{{ seed_name }}",
setting_type = "runtime-global",
+ localised_name = {"mod-setting-name.archipelago-death-link"},
+ localised_description = {"mod-setting-description.archipelago-death-link"},
{% if death_link %}
default_value = true
{% else %}
diff --git a/worlds/gl/Data.py b/worlds/gl/Data.py
new file mode 100644
index 00000000000..d0985e44845
--- /dev/null
+++ b/worlds/gl/Data.py
@@ -0,0 +1,459 @@
+from collections.abc import Mapping, Sequence
+from types import MappingProxyType
+from typing import Final
+
+from .Locations import LocationData, get_locations_by_tags
+
+# Item name to ram value conversion
+item_ids: Final[Mapping[str, int]] = MappingProxyType({
+ "Strength": 0x000002,
+ "Speed": 0x000003,
+ "Magic": 0x000004,
+ "Armour": 0x000005,
+ "Gold": 0x000006,
+ "Key": 0x000007,
+ "XP": 0x000009,
+ "Level": 0x00000A,
+ "Class": 0x00000B,
+ "Max": 0x00000D,
+ "Compass": 0x000270,
+ "Lightning Potion": 0x001008,
+ "Light Potion": 0x002008,
+ "Acid Potion": 0x003008,
+ "Fire Potion": 0x004008,
+ "Acid Breath": 0x800480,
+ "Lightning Breath": 0x8004F0,
+ "Fire Breath": 0x800460,
+ "Light Amulet": 0x400870,
+ "Acid Amulet": 0x400880,
+ "Lightning Amulet": 0x4008F0,
+ "Fire Amulet": 0x400860,
+ "Lightning Shield": 0x400CF0,
+ "Fire Shield": 0x400C60,
+ "Invisibility": 0x4000E0,
+ "Levitate": 0x400030,
+ "Speed Boots": 0x400173,
+ "3-Way Shot": 0x400020,
+ "5-Way Shot": 0x400160,
+ "Rapid Fire": 0x400050,
+ "Reflective Shot": 0x400840,
+ "Reflective Shield": 0x400C40,
+ "Super Shot": 0x8008B0,
+ "Timestop": 0x4000D0,
+ "Phoenix Familiar": 0x400110,
+ "Growth": 0x400120,
+ "Shrink": 0x400130,
+ "Thunder Hammer": 0x800940,
+ "Anti-Death Halo": 0x7000A0,
+ "Invulnerability": 0x7000C0,
+ "Health": 0x000001,
+ "Runestone": 0x008210,
+ "Mirror Shard": 0x008260,
+ "Ice Axe of Untar": 0x8201C0,
+ "Flame of Tarkana": 0x8201D0,
+ "Scimitar of Decapitation": 0x8201E0,
+ "Marker's Javelin": 0x8201F0,
+ "Soul Savior": 0x820200,
+ "Mountain": 0x00804A,
+ "Castle": 0x00801A,
+ "Hell": 0x00806A,
+ "Ice": 0x00807A,
+ "Town": 0x00808A,
+ "Temple": 0x00809A,
+ "Battlefield": 0x0080DA,
+ "Skorne": 0x0080EA,
+ "Secret": 0x0080FA,
+ "Obelisk": 0x00800C,
+ "Minotaur": 0x0001A1,
+ "Falconess": 0x0001A2,
+ "Jackal": 0x0001A3,
+ "Tigress": 0x0001A4,
+ "Sumner": 0x0001A5,
+ "Skorne's Mask": 0x840620,
+ "Skorne's Horns": 0x840630,
+ "Skorne's Right Gauntlet": 0x840A40,
+ "Skorne's Left Gauntlet": 0x840A50,
+ "Death": 0x0000F0,
+ "Crossbow Shooter": 0x0000F1,
+ "Bomb Thrower": 0x0000F2,
+ "Bomb Runner": 0x0000F3,
+ "Golem": 0x0000F4,
+})
+
+# Character names used for slot data
+characters: Final[tuple[str, ...]] = ("Minotaur", "Falconess", "Tigress", "Jackal", "Sumner")
+
+# Base item charge count per pickup
+# Some items are bitwise
+base_count: Final[Mapping[str, int]] = MappingProxyType({
+ "Key": 1,
+ "Lightning Potion": 1,
+ "Light Potion": 1,
+ "Acid Potion": 1,
+ "Fire Potion": 1,
+ "Acid Breath": 5,
+ "Lightning Breath": 5,
+ "Fire Breath": 5,
+ "Light Amulet": 30,
+ "Acid Amulet": 30,
+ "Lightning Amulet": 30,
+ "Fire Amulet": 30,
+ "Lightning Shield": 10,
+ "Fire Shield": 10,
+ "Invisibility": 30,
+ "Levitate": 30,
+ "Speed Boots": 30,
+ "3-Way Shot": 30,
+ "5-Way Shot": 30,
+ "Rapid Fire": 30,
+ "Reflective Shot": 30,
+ "Reflective Shield": 30,
+ "Super Shot": 3,
+ "Timestop": 15,
+ "Phoenix Familiar": 30,
+ "Growth": 30,
+ "Shrink": 30,
+ "Thunder Hammer": 3,
+ "Anti-Death Halo": 30,
+ "Invulnerability": 30,
+ "Fruit": 50,
+ "Meat": 100,
+ "Gold": 100,
+ "Runestone 1": 1,
+ "Runestone 2": 2,
+ "Runestone 3": 3,
+ "Runestone 4": 4,
+ "Runestone 5": 5,
+ "Runestone 6": 6,
+ "Runestone 7": 7,
+ "Runestone 8": 8,
+ "Runestone 9": 9,
+ "Runestone 10": 10,
+ "Runestone 11": 11,
+ "Runestone 12": 12,
+ "Runestone 13": 13,
+ "Dragon Mirror Shard": 1,
+ "Chimera Mirror Shard": 3,
+ "Plague Fiend Mirror Shard": 4,
+ "Yeti Mirror Shard": 2,
+ "Valley of Fire Obelisk": 1,
+ "Dagger Peak Obelisk": 2,
+ "Cliffs of Desolation Obelisk": 3,
+ "Poisoned Fields Obelisk": 4,
+ "Haunted Cemetery Obelisk": 5,
+ "Castle Courtyard Obelisk": 6,
+ "Dungeon of Torment Obelisk": 7,
+ "Ice Axe of Untar": 1,
+ "Flame of Tarkana": 1,
+ "Scimitar of Decapitation": 1,
+ "Marker's Javelin": 1,
+ "Soul Savior": 1,
+ "Minotaur": 1,
+ "Falconess": 1,
+ "Tigress": 1,
+ "Jackal": 1,
+ "Sumner": 1,
+ "Poison Fruit": -50,
+ "Skorne's Mask": 50,
+ "Skorne's Horns": 50,
+ "Skorne's Left Gauntlet": 100,
+ "Skorne's Right Gauntlet": 100,
+ "Portal to Dagger Peak": 1,
+ "Portal to Cliffs of Desolation": 2,
+ "Portal to Lost Cave": 3,
+ "Portal to Volcanic Caverns": 4,
+ "Portal to Dragon's Lair": 5,
+ "Portal to Dungeon of Torment": 1,
+ "Portal to Tower Armory": 2,
+ "Portal to Castle Treasury": 3,
+ "Portal to Chimera's Keep": 4,
+ "Portal to Haunted Cemetery": 1,
+ "Portal to Venomous Spire": 2,
+ "Portal to Toxic Air Ship": 3,
+ "Portal to Vat of the Plague Fiend": 4,
+ "Portal to Frozen Camp": 1,
+ "Portal to Crystal Mine": 2,
+ "Portal to Erupting Fissure": 3,
+ "Portal to Yeti's Cavern": 4,
+ "Portal to Fortified Towers": 1,
+ "Portal to Infernal Fortress": 2,
+ "Death": 1,
+ "Crossbow Shooter": 1,
+ "Bomb Thrower": 1,
+ "Bomb Runner": 1,
+ "Golem": 1
+})
+
+# (zone_config << 4) + room_config -> location list
+level_locations: Final[Mapping[int, Sequence[LocationData]]] = MappingProxyType({
+ # Zone 0x00 - Castle
+ 0x00: get_locations_by_tags("castle_courtyard"),
+ 0x01: get_locations_by_tags("dungeon_of_torment"),
+ 0x02: get_locations_by_tags("tower_armory"),
+ 0x03: get_locations_by_tags("castle_treasury"),
+ 0x08: get_locations_by_tags("chimeras_keep"),
+
+ # Zone 0x03 - Mountain
+ 0x30: get_locations_by_tags("valley_of_fire"),
+ 0x31: get_locations_by_tags("dagger_peak"),
+ 0x32: get_locations_by_tags("cliffs_of_desolation"),
+ 0x33: get_locations_by_tags("lost_cave"),
+ 0x34: get_locations_by_tags("volcanic_cavern"),
+ 0x38: get_locations_by_tags("dragons_lair"),
+
+ # Zone 0x07 - Town
+ 0x70: get_locations_by_tags("poisoned_fields"),
+ 0x71: get_locations_by_tags("haunted_cemetery"),
+ 0x72: get_locations_by_tags("venomous_spire"),
+ 0x73: get_locations_by_tags("toxic_air_ship"),
+ 0x78: get_locations_by_tags("plague_fiend"),
+
+ # Zone 0x05 - Underworld
+ 0x50: get_locations_by_tags("gates_of_the_underworld"),
+
+ # Zone 0x06 - Ice
+ 0x60: get_locations_by_tags("arctic_docks"),
+ 0x61: get_locations_by_tags("frozen_camp"),
+ 0x62: get_locations_by_tags("crystal_mine"),
+ 0x63: get_locations_by_tags("erupting_fissure"),
+ 0x68: get_locations_by_tags("yeti"),
+
+ # Zone 0x0D - Temple
+ 0xD0: get_locations_by_tags("desecrated_temple"),
+ 0xD8: get_locations_by_tags("altar_of_skorne"),
+
+ # Zone 0x0C - Battlefield
+ 0xC0: get_locations_by_tags("battle_trenches"),
+ 0xC1: get_locations_by_tags("fortified_towers"),
+ 0xC2: get_locations_by_tags("infernal_fortress"),
+})
+
+# Compressed level size in ROM
+level_size: Final[tuple[int, ...]] = (
+ 0x9E0,
+ 0x5E0,
+ 0x740,
+ 0x8A0,
+ 0x90,
+ 0x3B0,
+ 0x5A0,
+ 0x890,
+ 0x670,
+ 0x7D0,
+ 0x90,
+ 0xCE0,
+ 0xA50,
+ 0xA30,
+ 0x8E0,
+ 0x20,
+ 0x760,
+ 0xE90,
+ 0xE40,
+ 0xE00,
+ 0xCD0,
+ 0x20,
+ 0x3F0,
+ 0x0,
+ 0xB00,
+ 0xA30,
+ 0xB30
+)
+
+# Level address in ROM
+level_address: Final[tuple[int, ...]] = (
+ 0xF939B0,
+ 0xF958B0,
+ 0xF945B0,
+ 0xF94EE0,
+ 0xF84CC0,
+ 0xF910B0,
+ 0xF915B0,
+ 0xF91D00,
+ 0xF92710,
+ 0xF92F40,
+ 0xF84B70,
+ 0xF84FA0,
+ 0xF85EB0,
+ 0xF86B60,
+ 0xF877C0,
+ 0xF880E0,
+ 0xF901C0,
+ 0xF884E0,
+ 0xF89370,
+ 0xF8A5F0,
+ 0xF8B760,
+ 0xF8C7C0,
+ 0xF90B50,
+ 0x0,
+ 0xF8D960,
+ 0xF8E6E0,
+ 0xF8F110
+)
+
+# Level header address in ROM
+level_header: Final[tuple[int, ...]] = (
+ 0xF9DD9C,
+ 0xF9E07C,
+ 0xF9DE54,
+ 0xF9DF0C,
+ 0xF9DFC4,
+ 0xF9E6E0,
+ 0xF9E798,
+ 0xF9E850,
+ 0xF9E908,
+ 0xF9E9C0,
+ 0xF9EA78,
+ 0xF9F004,
+ 0xF9F0BC,
+ 0xF9F174,
+ 0xF9F22C,
+ 0xF9F2E4,
+ 0xF9E1C0,
+ 0xF9E330,
+ 0xF9E3E4,
+ 0xF9E498,
+ 0xF9E54C,
+ 0xF9E600,
+ 0xF9DC2C,
+ 0x0,
+ 0xF9DA04,
+ 0xF9DABC,
+ 0xF9DB74
+)
+
+# Runestones required to access difficulties
+# Used in Rules.py for access calculation
+difficulty_lambda: Final[Mapping[int, Sequence[int]]] = MappingProxyType({
+ 0x3: (0, 2, 3, 4),
+ 0x0: (0, 5, 6, 7),
+ 0x7: (0, 8, 9, 10),
+ 0x6: (0, 11, 12, 13),
+ 0xD: (0, 14, 15, 16),
+ 0xC: (0, 17, 18, 19),
+ 0x5: (0, 20, 21, 22)
+})
+
+# Runestones required to access difficulties
+# Used in Rules.py for access calculation
+difficulty_lambda_no_portal: Final[Mapping[int, Sequence[int]]] = MappingProxyType({
+ 0x3: (0, 0, 1, 2),
+ 0x0: (0, 2, 3, 4),
+ 0x7: (0, 5, 6, 7),
+ 0x6: (0, 8, 9, 10),
+ 0xD: (0, 11, 12, 13),
+ 0xC: (0, 14, 15, 16),
+ 0x5: (0, 17, 18, 19)
+})
+
+# ID's for names said by announcer
+sounds: Final[Mapping[int, int]] = MappingProxyType({
+ 0: 0x9D,
+ 1: 0x9E,
+ 2: 0xA7,
+ 3: 0xA5,
+ 5: 0x9F,
+ 6: 0xA1,
+ 7: 0xA0,
+ 8: 0xA2,
+ 9: 0xA7
+})
+
+# ID's for colors said by announcer
+colors: Final[Mapping[int, int]] = MappingProxyType({
+ 0: 0xA3,
+ 1: 0xA6,
+ 2: 0x9C,
+ 3: 0xA4
+})
+
+obelisks: Final[tuple[str, ...]] = (
+ "Valley of Fire Obelisk",
+ "Dagger Peak Obelisk",
+ "Cliffs of Desolation Obelisk",
+ "Poisoned Fields Obelisk",
+ "Haunted Cemetery Obelisk",
+ "Castle Courtyard Obelisk",
+ "Dungeon of Torment Obelisk"
+)
+
+mirror_shards: Final[tuple[str, ...]] = (
+ "Dragon Mirror Shard",
+ "Chimera Mirror Shard",
+ "Yeti Mirror Shard",
+ "Plague Fiend Mirror Shard"
+)
+
+portals: Final[Mapping[str, str]] = MappingProxyType({
+ "Portal to Dagger Peak": "Mountain",
+ "Portal to Cliffs of Desolation": "Mountain",
+ "Portal to Lost Cave": "Mountain",
+ "Portal to Volcanic Caverns": "Mountain",
+ "Portal to Dragon's Lair": "Mountain",
+ "Portal to Dungeon of Torment": "Castle",
+ "Portal to Tower Armory": "Castle",
+ "Portal to Castle Treasury": "Castle",
+ "Portal to Chimera's Keep": "Castle",
+ "Portal to Frozen Camp": "Ice",
+ "Portal to Crystal Mine": "Ice",
+ "Portal to Erupting Fissure": "Ice",
+ "Portal to Yeti's Cavern": "Ice",
+ "Portal to Haunted Cemetery": "Town",
+ "Portal to Venomous Spire": "Town",
+ "Portal to Toxic Air Ship": "Town",
+ "Portal to Vat of the Plague Fiend": "Town",
+ "Portal to Fortified Towers": "Battlefield",
+ "Portal to Infernal Fortress": "Battlefield"
+})
+
+excluded_portals: Final[Mapping[str, list[str]]] = MappingProxyType({
+ "Castle" : ["Portal to Dungeon of Torment", "Portal to Tower Armory", "Portal to Castle Treasury", "Portal to Chimera's Keep"],
+ "Town" : ["Portal to Haunted Cemetery", "Portal to Venomous Spire", "Portal to Toxic Air Ship", "Portal to Vat of the Plague Fiend"],
+ "Ice" : ["Portal to Frozen Camp", "Portal to Crystal Mine", "Portal to Erupting Fissure", "Portal to Yeti's Cavern"],
+ "Battlefield" : ["Portal to Fortified Towers", "Portal to Infernal Fortress"]
+})
+
+excluded_levels: Final[Mapping[str, list[str]]] = MappingProxyType({
+ "Mountain" : ["Valley of Fire", "Dagger Peak", "Cliffs of Desolation", "Lost Cave", "Volcanic Caverns", "Dragon's Lair"],
+ "Castle" : ["Castle Courtyard" ,"Dungeon of Torment", "Tower Armory", "Castle Treasury", "Chimera's Keep"],
+ "Ice" : ["Arctic Docks", "Frozen Camp", "Crystal Mine", "Erupting Fissure", "Yeti's Cavern"],
+ "Town" : ["Poisoned Fields", "Haunted Cemetery", "Venomous Spire", "Toxic Air Ship", "Vat of the Plague Fiend"],
+ "Battlefield" : ["Battle Trenches", "Fortified Towers", "Infernal Fortress"]
+})
+
+excluded_obelisks: Final[Mapping[str, list[str]]] = MappingProxyType({
+ "Castle" : ["Valley of Fire Obelisk", "Dagger Peak Obelisk", "Cliffs of Desolation Obelisk"],
+ "Town" : ["Castle Courtyard Obelisk", "Dungeon of Torment Obelisk"],
+ "Ice" : ["Poisoned Fields Obelisk", "Haunted Cemetery Obelisk"],
+})
+
+# Map location names to offsets in decompressed boss.bin
+boss_location_offsets: Final[Mapping[str, int]] = MappingProxyType({
+ "Dragon's Lair - Dragon Mirror Shard": 0x9C08,
+ "Yeti's Cavern - Yeti Mirror Shard": 0x9C18,
+ "Chimera's Keep - Chimera Mirror Shard": 0x9C28,
+ "Vat of the Plague Fiend - Plague Fiend Mirror Shard": 0x9C38,
+ "Altar of Skorne - Skorne's Mask": 0x9C48,
+ "Altar of Skorne - Skorne's Horns": 0x9C58,
+ "Altar of Skorne - Skorne's Left Gauntlet": 0x9C68,
+ "Altar of Skorne - Skorne's Right Gauntlet": 0x9C78
+})
+
+boss_regions: Final[tuple[str, ...]] = (
+ "Dragon's Lair",
+ "Yeti's Cavern",
+ "Chimera's Keep",
+ "Vat of the Plague Fiend",
+ "Altar of Skorne",
+ "Gates of the Underworld",
+)
+
+
+# Item IDs for spawner traps (77780087-77780090)
+spawner_trap_ids: Final[tuple[int, ...]] = (77780087, 77780088, 77780089, 77780090)
+
+player_compass_index: Final[Mapping[int, int]] = MappingProxyType({
+ 1: 1,
+ 2: 0,
+ 3: 3,
+ 4: 2
+})
diff --git a/worlds/gl/GauntletLegendsClient.py b/worlds/gl/GauntletLegendsClient.py
new file mode 100644
index 00000000000..0d2c66877fc
--- /dev/null
+++ b/worlds/gl/GauntletLegendsClient.py
@@ -0,0 +1,728 @@
+import asyncio
+import Utils
+import settings
+import os
+import re
+import socket
+import traceback
+import subprocess
+import Patch
+
+from typing import Optional
+from BaseClasses import ItemClassification
+from CommonClient import ClientCommandProcessor, CommonContext, get_base_parser, gui_enabled, logger, server_loop
+from NetUtils import ClientStatus, NetworkItem
+
+from .Data import (
+ base_count,
+ item_ids,
+ level_locations,
+ sounds,
+ colors,
+ portals,
+ spawner_trap_ids,
+ player_compass_index
+)
+from .Items import ItemData, items_by_id
+
+READ = "READ_CORE_RAM"
+WRITE = "WRITE_CORE_RAM"
+PLAYER_CLASS = 0xFD30F
+PLAYER_COLOR = 0xFD30E
+SOUND_ADDRESS = 0xAE740
+SOUND_START = 0xEEFC
+PLAYER_KILL = 0xFD300
+PLAYER_MENU = 0x9D3C
+BOSS_GOAL = 0x45D34
+BOSS_GOAL_BACKUP = 0x45D3C
+LOCATIONS_BASE_ADDRESS = 0x64A68
+ZONE_ID = 0x6CA58
+LEVEL_ID = 0x6CA5C
+
+MOD_ITEM_ID = 0x3FC800
+MOD_QUANTITY = 0x3FC804
+MOD_PLAYER_ID = 0x3FC808
+MOD_OBELISK_QUANTITY = 0x3FC7E4
+MOD_BOSS_GOAL = 0x3FC7E5
+MOD_PLAYERS_LIST = 0x3FC7D0
+MOD_COMPASS_COUNT = 0x3FC7D4
+
+GOAL_POLLS = 5
+
+
+class RetroSocket:
+ def __init__(self):
+ self.host = "localhost"
+ self.port = 55355
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ self.socket.setblocking(False)
+
+ def send(self, message: str):
+ try:
+ self.socket.sendto(message.encode(), (self.host, self.port))
+ except Exception as e:
+ raise Exception("An error occurred while sending a message.")
+
+ async def read(self, message: str) -> Optional[bytes]:
+ self.send(message)
+ try:
+ response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 30000), 1.0)
+ data = response.decode().strip("\n").split(" ")
+ b = b""
+ for s in data[2:]:
+ if "-1" in s:
+ raise Exception("Client tried to read from an invalid address or ROM is not open...")
+ b += bytes.fromhex(s)
+ return b
+ except asyncio.TimeoutError:
+ logger.error("Timeout while waiting for socket response...")
+ await asyncio.sleep(2)
+ return None
+ except ConnectionResetError:
+ logger.error("The connection was reset...")
+ await asyncio.sleep(2)
+ return None
+ except OSError as e:
+ logger.error(f"Socket error during read: {e}")
+ await asyncio.sleep(2)
+ return None
+
+ async def status(self) -> str:
+ message = "GET_STATUS"
+ self.send(message)
+ try:
+ data = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), 1.0)
+ return data.decode()
+ except (asyncio.TimeoutError, ConnectionResetError, OSError):
+ pass
+
+
+def message_format(arg: str, params: str) -> str:
+ return f"{arg} {params}"
+
+
+def param_format(adr: int, arr: bytes) -> str:
+ return " ".join([hex(adr)] + [f"0x{byte:02X}" for byte in arr])
+
+
+class GauntletLegendsCommandProcessor(ClientCommandProcessor):
+ def __init__(self, ctx: CommonContext):
+ super().__init__(ctx)
+
+ def _cmd_deathlink_toggle(self):
+ """Toggle Deathlink on or off"""
+ self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
+ self.ctx.update_death_link(self.ctx.deathlink_enabled)
+ logger.info(f"Deathlink {('Enabled.' if self.ctx.deathlink_enabled else 'Disabled.')}")
+
+
+class GauntletLegendsContext(CommonContext):
+ command_processor = GauntletLegendsCommandProcessor
+ game = "Gauntlet Legends"
+ items_handling = 0b101
+
+ def __init__(self, server_address, password):
+ super().__init__(server_address, password)
+ self.useful: list[NetworkItem] = []
+ self.deathlink_pending: bool = False
+ self.deathlink_enabled: bool = False
+ self.deathlink_triggered: bool = False
+ self.gl_sync_task = None
+ self.glslotdata = None
+ self.socket = RetroSocket()
+ self.rom_loaded: bool = False
+ self.locations_checked: list[int] = []
+ self.retro_connected: bool = False
+ self.scouted: bool = False
+ self.obelisks: list[NetworkItem] = []
+ self.item_locations: list[int] = []
+ self.obelisk_locations: list[int] = []
+ self.chest_locations: list[int] = []
+ self.spawner_locations: list[int] = []
+ self.item_address: int = 0
+ self.chest_address: int = 0
+ self.spawner_address: int = 0
+ self.vanilla_spawner_count: int = 0
+ self.zone: int = 0
+ self.level: int = 0
+ self.current_zone: int = -1
+ self.current_level: int = -1
+ self.level_id: int = 0
+ self.location_scouts: list[NetworkItem] = []
+ self.players: list[int] = []
+ self.queued_traps: list[tuple[str, int, bool]] = []
+ self.spawned_traps: int = 0
+ self.item_ram_indices: dict[int, int] = {}
+ self.chest_ram_indices: dict[int, int] = {}
+ self.goal_streak: int = 0
+
+ def on_deathlink(self, data: dict):
+ super().on_deathlink(data)
+ self.deathlink_pending = True
+
+ async def update_stage(self):
+ self.zone = await self._read_ram_int(ZONE_ID, 1)
+ self.level = await self._read_ram_int(LEVEL_ID, 1)
+
+ async def check_goal(self) -> bool:
+ if self.glslotdata is None:
+ return False
+ if self.glslotdata["goal"] == 1:
+ goal = await self._read_ram_int(BOSS_GOAL, 4)
+ backup = await self._read_ram_int(BOSS_GOAL_BACKUP, 4)
+ return goal == 0xA or backup == 0xA
+ elif self.glslotdata["goal"] == 2:
+ goal = await self._read_ram_int(MOD_BOSS_GOAL, 1, True)
+ if goal >= self.glslotdata["boss_goal_count"]:
+ self.goal_streak += 1
+ else:
+ self.goal_streak = 0
+ return self.goal_streak >= GOAL_POLLS
+
+ def _normalize_item_name(self, name: str) -> str:
+ if "Runestone" in name:
+ return "Runestone"
+ if "Fruit" in name or "Meat" in name:
+ return "Health"
+ if "Obelisk" in name:
+ return "Obelisk"
+ if "Mirror" in name:
+ return "Mirror Shard"
+ if portals.get(name, False):
+ return portals[name]
+ return name
+
+ async def update_item(self, name: str, count: int, player: int = None, infinite_count: bool = False):
+ name = self._normalize_item_name(name)
+ await self._write_ram(MOD_ITEM_ID,
+ int.to_bytes((item_ids[name] if not infinite_count else item_ids[name] & 0xFFFF), 4,
+ "little"))
+ await self._write_ram(MOD_QUANTITY, int.to_bytes(count, 4, "little", signed=True))
+ await self._write_ram(MOD_PLAYER_ID, int.to_bytes(player, 4, "little"))
+
+ async def server_auth(self, password_requested: bool = False):
+ if password_requested and not self.password:
+ await super(GauntletLegendsContext, self).server_auth(password_requested)
+ await self.get_username()
+ await self.send_connect()
+
+ def on_package(self, cmd: str, args: dict):
+ if cmd in {"Connected"}:
+ self.glslotdata = args["slot_data"]
+ self.deathlink_enabled = bool(self.glslotdata["death_link"])
+ if self.deathlink_enabled:
+ Utils.async_start(self.update_death_link(self.deathlink_enabled))
+ elif cmd == "LocationInfo":
+ self.location_scouts = args["locations"]
+ elif cmd == "RoomInfo":
+ self.seed_name = args["seed_name"]
+
+ # Update inventory based on items received from server
+ # Also adds starting items based on a few yaml options
+ async def handle_items(self):
+ self.players = list(await self._read_ram(MOD_PLAYERS_LIST, 4))
+ self.players = [player for player in self.players if player != 0]
+ if not self.players:
+ return
+ for player in self.players:
+ active = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (player - 1)), 1)
+ if active != 0x4:
+ continue
+ compass = await self._read_ram_int(MOD_COMPASS_COUNT + (2 * player_compass_index[player]), 2)
+ if compass - 1 < len(self.items_received):
+ for index in range(compass - 1, len(self.items_received)):
+ active = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (player - 1)), 1)
+ if active != 0x4:
+ break
+ item = self.items_received[index].item
+ if player != self.players[0] and item in spawner_trap_ids:
+ continue
+ item_name = items_by_id[item].item_name
+ if self.current_zone in (0x8, 0xE) and item in spawner_trap_ids:
+ if len([trap for trap in self.queued_traps if trap[1] == index]) < 1:
+ self.queued_traps.append((item_name, index, False))
+ await self.give_item(item_name, player)
+ await self.update_item("Compass", 1, player)
+
+ async def give_item(self, item_name: str, player: int):
+ await self.wait_for_mod_clear()
+ await asyncio.sleep(0.02)
+ await self.update_item(item_name, base_count[item_name], player)
+ await self.wait_for_mod_clear()
+ await asyncio.sleep(0.02)
+
+ async def wait_for_mod_clear(self, poll_interval: float = 0.05):
+ while True:
+ mod_data = await self.socket.read(message_format(READ, f"0x{MOD_ITEM_ID:x} 12"))
+ if mod_data and all(byte == 0 for byte in mod_data):
+ return True
+ await asyncio.sleep(poll_interval)
+
+ async def _read_ram(self, address: int, size: int) -> bytes:
+ return await self.socket.read(message_format(READ, f"0x{address:x} {size}"))
+
+ async def _read_ram_int(self, address: int, size: int, signed: bool = False) -> int:
+ data = await self._read_ram(address, size)
+ return int.from_bytes(data, "little", signed=signed) if data else 0
+
+ async def _write_ram(self, address: int, data: bytes):
+ self.socket.send(message_format(WRITE, param_format(address, data)))
+
+ async def dead(self) -> bool:
+ val = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (self.players[0] - 1)), 1)
+ return (val & 0xF) == 0x8
+
+ async def dead_or_menu(self) -> bool:
+ val = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (self.players[0] - 1)), 1)
+ return (val & 0xF) == 0x8 or (val & 0xF) == 0x1
+
+ async def get_seed_name(self) -> str:
+ seed_name = await self._read_ram(0x3FC7F0, 0x10)
+ return seed_name.decode("utf-8").strip()
+
+ async def scout_locations(self, ctx: "GauntletLegendsContext") -> None:
+ try:
+ self.location_scouts = []
+ self.obelisk_locations = []
+ self.item_locations = []
+ self.chest_locations = []
+ self.spawner_locations = []
+ self.useful = []
+ self.obelisks = []
+ self.vanilla_spawner_count = 0
+ self.item_ram_indices = {}
+ self.chest_ram_indices = {}
+
+ raw_locations = [location for location in level_locations.get(self.level_id, []) if
+ "Mirror" not in location.name and "Skorne" not in location.name]
+ scoutable_location_ids = [location.id for location in raw_locations if
+ location.id in ctx.checked_locations or location.id in self.missing_locations]
+
+ # Scout locations if any exist
+ if raw_locations:
+ await ctx.send_msgs([{
+ "cmd": "LocationScouts",
+ "locations": scoutable_location_ids,
+ "create_as_hint": 0,
+ }])
+ while not self.location_scouts:
+ await asyncio.sleep(0.1)
+
+ # Build lookup for scouted items by location
+ scouted_by_location = {item.location: item for item in self.location_scouts}
+
+ item_slots: list[int] = []
+ chest_slots: list[int] = []
+ appended_items: list[int] = []
+
+ for loc in raw_locations:
+ is_obelisk_loc = "Obelisk" in loc.name
+ is_chest_loc = (not is_obelisk_loc) and (
+ "Chest" in loc.name
+ or ("Barrel" in loc.name and "Barrel of Gold" not in loc.name)
+ )
+
+ scouted = scouted_by_location.get(loc.id)
+
+ if not scouted:
+ if is_obelisk_loc:
+ pass
+ elif is_chest_loc:
+ chest_slots.append(loc.id)
+ else:
+ item_slots.append(loc.id)
+ continue
+
+ item_id = scouted.item
+ item_player = scouted.player
+ item_data = items_by_id.get(item_id, ItemData())
+
+ if is_obelisk_loc:
+ if "Obelisk" in item_data.item_name and item_player == self.slot:
+
+ pass
+ else:
+
+ appended_items.append(loc.id)
+ continue
+
+ if item_player == self.slot and item_id in spawner_trap_ids:
+ continue
+
+ if item_player != self.slot:
+
+ if is_chest_loc:
+ chest_slots.append(loc.id)
+ else:
+ item_slots.append(loc.id)
+ continue
+
+ if "Obelisk" in item_data.item_name:
+ continue
+
+ if (item_data.progression in (ItemClassification.useful, ItemClassification.progression)
+ and is_chest_loc):
+ appended_items.append(loc.id)
+ continue
+
+ if is_chest_loc:
+ chest_slots.append(loc.id)
+ else:
+ item_slots.append(loc.id)
+
+ final_item_order = item_slots + appended_items
+
+ self.item_ram_indices = {
+ loc_id: idx for idx, loc_id in enumerate(final_item_order)
+ }
+ self.chest_ram_indices = {
+ loc_id: idx for idx, loc_id in enumerate(chest_slots)
+ }
+
+ # Categorize scouted locations - mirror ROM's patch_items logic exactly
+ for loc in raw_locations:
+ scouted_item = scouted_by_location.get(loc.id)
+ if not scouted_item:
+ continue
+
+ item_id = scouted_item.item
+ item_player = scouted_item.player
+ item_data = items_by_id.get(item_id, ItemData())
+ is_chest = "Chest" in loc.name or ("Barrel" in loc.name and "Barrel of Gold" not in loc.name)
+
+ # Check obelisk locations
+ if "Obelisk" in loc.name:
+ if "Obelisk" in item_data.item_name and item_player == self.slot:
+ self.obelisk_locations.append(loc.id)
+ self.obelisks.append(scouted_item)
+ else:
+ self.item_locations.append(loc.id)
+ continue
+
+ # Check spawner (ROM: item[1] == player and item[0] in SPAWNER_TRAP_IDS)
+ if item_player == self.slot and item_id in spawner_trap_ids:
+ self.spawner_locations.append(loc.id)
+ continue
+
+ # Check non-local player (ROM: item[1] != player) - stays as item/chest
+ if item_player != self.slot:
+ if is_chest:
+ self.chest_locations.append(loc.id)
+ else:
+ self.item_locations.append(loc.id)
+ continue
+
+ # Check obelisk item at non-obelisk location (ROM: "Obelisk" in item_name)
+ if "Obelisk" in item_data.item_name:
+ self.obelisk_locations.append(loc.id)
+ self.obelisks.append(scouted_item)
+ continue
+
+ # Check useful/progression chest -> item conversion
+ if item_data.progression in (ItemClassification.useful, ItemClassification.progression) and is_chest:
+ self.item_locations.append(loc.id)
+ self.useful.append(scouted_item)
+ continue
+
+ # Regular item/chest
+ if is_chest:
+ self.chest_locations.append(loc.id)
+ else:
+ self.item_locations.append(loc.id)
+
+ self.scouted = True
+ except Exception:
+ logger.error(traceback.format_exc())
+
+ async def location_loop(self) -> list[int]:
+ if self.current_zone == -1:
+ self.current_zone = self.zone
+ self.current_level = self.level
+ self.level_id = (self.current_zone << 4) + self.current_level
+
+ zone_or_level_changed = self.zone != self.current_zone or self.level != self.current_level
+ if zone_or_level_changed:
+ if self.current_level & 0x8 == 0x8 and self.level_id != 0x58 and not await self.dead_or_menu():
+ await self.check_locations([loc.id for loc in level_locations[self.level_id]
+ if "Mirror Shard" in loc.name or "Skorne" in loc.name])
+ self.current_zone = self.zone
+ self.current_level = self.level
+ self.level_id = (self.current_zone << 4) + self.current_level
+ self.scouted = False
+ self.spawned_traps = 0
+ self.goal_streak = 0
+ await asyncio.sleep(2)
+
+ if self.current_zone in (0x8, 0xE):
+ return []
+
+ if not self.scouted:
+ await self.scout_locations(self)
+
+ active = await self._read_ram_int(PLAYER_KILL + (0x1F0 * (self.players[0] - 1)), 1)
+ if active != 0x4:
+ return []
+
+ if len(self.queued_traps) > 0 and self.spawned_traps == 0:
+ for i, trap in enumerate(self.queued_traps):
+ trap_name, index, given = trap
+ if not given:
+ self.spawned_traps += 1
+ await self.give_item(trap_name, self.players[0])
+ self.queued_traps[i] = (trap_name, index, True)
+ if self.spawned_traps >= 5:
+ break
+
+ locations_address = await self._read_ram_int(LOCATIONS_BASE_ADDRESS, 4) & 0xFFFFFF
+ self.item_address = await self._read_ram_int(locations_address + 0x14, 4)
+ self.spawner_address = await self._read_ram_int(locations_address + 0x1C, 4)
+ self.chest_address = await self._read_ram_int(locations_address + 0x30, 4)
+
+ # Read vanilla spawner count from level header (2 bytes at offset 0x28)
+ spawner_count_data = await self._read_ram(locations_address + 0x6, 2)
+ if spawner_count_data and not self.vanilla_spawner_count:
+ total_spawner_count = int.from_bytes(spawner_count_data, "little")
+ # Vanilla count is total minus the ones we added
+ self.vanilla_spawner_count = total_spawner_count - len(self.spawner_locations)
+
+ if 0x7FFF0BAD in (self.item_address, self.chest_address, self.spawner_address):
+ return []
+
+ self.item_address &= 0xFFFFFF
+ self.spawner_address &= 0xFFFFFF
+ self.chest_address &= 0xFFFFFF
+
+ acquired = []
+
+ max_item_idx = max((self.item_ram_indices[loc_id] for loc_id in self.item_locations), default=-1)
+ item_section = await self._read_ram(self.item_address, (max_item_idx + 1) * 0x18) if max_item_idx >= 0 else b""
+ for loc_id in self.item_locations:
+ offset = self.item_ram_indices[loc_id] * 0x18
+ active_byte, state = item_section[offset + 0x2], item_section[offset + 0x3]
+ if state < 0x7F and active_byte == 1 and state == 0:
+ acquired.append(loc_id)
+
+ obelisk = await self._read_ram_int(MOD_OBELISK_QUANTITY, 1)
+ for j, loc_id in enumerate(self.obelisk_locations):
+ bit = base_count[items_by_id[self.obelisks[j].item].item_name] - 1
+ if obelisk & (1 << bit):
+ acquired.append(loc_id)
+
+ max_chest_idx = max((self.chest_ram_indices[loc_id] for loc_id in self.chest_locations), default=-1)
+ chest_section = await self._read_ram(self.chest_address, (max_chest_idx + 1) * 0x18) if max_chest_idx >= 0 else b""
+ for loc_id in self.chest_locations:
+ offset = self.chest_ram_indices[loc_id] * 0x18
+ active_byte, state = chest_section[offset + 0x2], chest_section[offset + 0x3]
+ if state < 0x7F and active_byte == 1 and state != 1:
+ acquired.append(loc_id)
+
+ # Check spawner locations - these are added after vanilla spawners
+ # Read spawners starting after vanilla_spawner_count
+ if self.spawner_locations:
+ spawner_start = self.spawner_address + (self.vanilla_spawner_count * 0x1C)
+ spawner_section = await self._read_ram(spawner_start, len(self.spawner_locations) * 0x1C)
+ for i, loc_id in enumerate(self.spawner_locations):
+ offset = i * 0x1C
+ active_byte, hit = spawner_section[offset + 0x2], spawner_section[offset + 0x1A]
+ if active_byte == 1 and hit == 1:
+ acquired.append(loc_id)
+
+ await self.update_stage()
+ if self.zone != self.current_zone or self.level != self.current_level:
+ return []
+ return [loc_id for loc_id in acquired if loc_id not in self.checked_locations]
+
+ async def die(self):
+ """Trigger deathlink death with character-specific death sound."""
+ self.deathlink_triggered = True
+ for player in self.players:
+ char = await self._read_ram_int(PLAYER_CLASS + (0x1F0 * (player - 1)), 1)
+ color = await self._read_ram_int(PLAYER_COLOR + (0x1F0 * (player - 1)), 1)
+
+ # Play death sound
+ sound_data = (int.to_bytes(colors[color], 4, "little") +
+ int.to_bytes(sounds[char], 4, "little") +
+ int.to_bytes(0xBB, 4, "little"))
+ await self._write_ram(SOUND_ADDRESS, sound_data)
+ await self._write_ram(SOUND_START, int.to_bytes(0xE00AE718, 4, "little"))
+ await asyncio.sleep(2)
+
+ # Stop sound and kill player
+ await self._write_ram(SOUND_START, int.to_bytes(0x0, 4, "little"))
+ await self._write_ram(PLAYER_KILL + (0x1F0 * (player - 1)), int.to_bytes(0x7, 1, "little"))
+
+ def make_gui(self):
+ ui = super().make_gui()
+ ui.base_title = "Archipelago Gauntlet Legends Client"
+ return ui
+
+
+async def gl_sync_task(ctx: GauntletLegendsContext):
+ logger.info("Starting N64 connector...")
+ while not ctx.exit_event.is_set():
+ try:
+ if not ctx.retro_connected:
+ logger.info("Attempting to connect to Retroarch...")
+ status = await ctx.socket.status()
+ ctx.retro_connected = True
+ ctx.rom_loaded = "CONTENTLESS" not in status
+ logger.info("Connected to Retroarch")
+ continue
+
+ if not ctx.rom_loaded:
+ status = await ctx.socket.status()
+ if "CONTENTLESS" in status:
+ logger.info("No ROM loaded, waiting...")
+ await asyncio.sleep(3)
+ continue
+ logger.info("ROM Loaded")
+ ctx.rom_loaded = True
+
+ if not ctx.auth:
+ await asyncio.sleep(1)
+ continue
+
+ seed_name = await ctx.get_seed_name()
+ if seed_name != ctx.seed_name[0:16]:
+ logger.info(f"ROM seed does not match room seed ({seed_name} != {ctx.seed_name}), "
+ f"please load the correct ROM.")
+ await ctx.disconnect()
+ continue
+
+ await ctx.update_stage()
+ if ctx.zone == 0x10:
+ continue
+
+ await ctx.handle_items()
+ checking = await ctx.location_loop()
+
+ menu = await ctx._read_ram_int(PLAYER_MENU, 4)
+ alive = False
+ if ctx.deathlink_pending and ctx.deathlink_enabled and menu == 1:
+ ctx.deathlink_pending = False
+ ctx.deathlink_triggered = True
+ await ctx.die()
+ elif len(ctx.players) > 0:
+ player_state = await ctx._read_ram_int(PLAYER_KILL + (0x1F0 * (ctx.players[0] - 1)), 1)
+ dead = (player_state & 0xF) == 0x8
+ alive = (player_state & 0xF) == 0x4
+ if dead and ctx.deathlink_enabled and not ctx.deathlink_triggered:
+ await ctx.send_death(f"{ctx.player_names[ctx.slot]} ran out of food.")
+ ctx.deathlink_triggered = True
+
+ if alive:
+ ctx.deathlink_triggered = False
+
+ if checking:
+ ctx.locations_checked += checking
+ await ctx.check_locations(checking)
+
+ # Only trust the goal check when the player is alive
+ if alive:
+ goal = await ctx.check_goal()
+ if not ctx.finished_game and goal:
+ await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
+ ctx.finished_game = True
+ else:
+ ctx.goal_streak = 0
+
+ except Exception as e:
+ logger.error(f"Error: {e}\n{traceback.format_exc()}")
+ ctx.socket = RetroSocket()
+ ctx.retro_connected = False
+ await asyncio.sleep(2)
+
+
+_original_opt_content: dict[str, str | None] = {}
+
+
+async def _patch_opt():
+ """Create RetroArch core options override for CountPerOp=1."""
+ retroarch_path = settings.get_settings().gl_options.retroarch_path
+ override_dir = os.path.join(retroarch_path, "config", "Mupen64Plus-Next")
+ os.makedirs(override_dir, exist_ok=True)
+ override_path = os.path.join(override_dir, "Mupen64Plus-Next.opt")
+ target_setting = 'mupen64plus-CountPerOp = "1"'
+
+ if override_path not in _original_opt_content:
+ _original_opt_content[override_path] = open(override_path).read() if os.path.exists(override_path) else None
+
+ content = _original_opt_content[override_path] or ""
+ if target_setting in content:
+ return
+
+ if "mupen64plus-CountPerOp" in content:
+ content = re.sub(r'mupen64plus-CountPerOp\s*=\s*"[^"]*"', target_setting, content)
+ else:
+ content = content.rstrip("\n") + f"\n{target_setting}\n" if content else f"{target_setting}\n"
+
+ with open(override_path, "w") as f:
+ f.write(content)
+
+
+def _restore_opt_files():
+ for path, original in _original_opt_content.items():
+ try:
+ if original is None and os.path.exists(path):
+ os.remove(path)
+ elif original is not None:
+ with open(path, "w") as f:
+ f.write(original)
+ except Exception as e:
+ logger.error(f"Failed to restore {path}: {e}")
+ _original_opt_content.clear()
+
+
+async def _launch_retroarch(rom_path: str):
+ retroarch_path = settings.get_settings().gl_options.retroarch_path
+ retroarch_exe = os.path.join(retroarch_path, "retroarch.exe")
+ core_path = os.path.join(retroarch_path, "cores", "mupen64plus_next_libretro.dll")
+
+ if not os.path.exists(retroarch_exe):
+ logger.error(f"RetroArch not found at: {retroarch_exe}")
+ return
+
+ if not os.path.exists(core_path):
+ logger.error(f"Mupen64Plus core not found at: {core_path}")
+ return
+
+ subprocess.Popen([retroarch_exe, "-L", core_path, rom_path])
+ logger.info(f"Launched RetroArch with ROM: {rom_path}")
+
+ # Wait for RetroArch to start up
+ await asyncio.sleep(2)
+
+
+async def _patch_and_launch_game(patch_file: str):
+ metadata, output_file = Patch.create_rom_file(patch_file)
+ await _patch_opt()
+ await _launch_retroarch(output_file)
+
+
+def launch(*args):
+ async def main(args):
+ if args.patch_file:
+ await asyncio.create_task(_patch_and_launch_game(args.patch_file))
+ ctx = GauntletLegendsContext(args.connect, args.password)
+ ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
+ if gui_enabled:
+ ctx.run_gui()
+ ctx.run_cli()
+ ctx.gl_sync_task = asyncio.create_task(gl_sync_task(ctx), name="Gauntlet Legends Sync Task")
+
+ await ctx.exit_event.wait()
+ ctx.server_address = None
+
+ await ctx.shutdown()
+
+ _restore_opt_files()
+
+ parser = get_base_parser()
+ parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an APGL file")
+ args = parser.parse_args(args)
+
+ import colorama
+
+ colorama.just_fix_windows_console()
+ asyncio.run(main(args))
+ colorama.deinit()
diff --git a/worlds/gl/Items.py b/worlds/gl/Items.py
new file mode 100644
index 00000000000..0bfa14e8157
--- /dev/null
+++ b/worlds/gl/Items.py
@@ -0,0 +1,43 @@
+from collections.abc import Mapping
+from types import MappingProxyType
+from typing import Final
+
+from BaseClasses import Item, ItemClassification
+from .Data import portals, obelisks, mirror_shards
+
+
+class ItemData:
+ id: int
+ item_name: str
+ progression: ItemClassification
+ rom_id: int
+ frequency: int
+
+ def __init__(self, id: int | None = 0, item_name: str = "", progression: int = 0, rom_id: str = "0x0", frequency: int = 1):
+ self.id = id
+ self.item_name = item_name
+ self.progression = ItemClassification(progression)
+ self.rom_id = int(rom_id, 16)
+ self.frequency = frequency
+
+
+
+class GLItem(Item):
+ game: str = "Gauntlet Legends"
+
+def import_items() -> tuple[ItemData, ...]:
+ import orjson
+ import pkgutil
+
+ return tuple(ItemData(**item) for item in orjson.loads(pkgutil.get_data(__name__, "json/items.json").decode("utf-8")))
+
+item_list: Final[tuple[ItemData, ...]] = import_items()
+item_table: Final[Mapping[str, ItemData]] = MappingProxyType({item.item_name: item for item in item_list})
+items_by_id: Final[Mapping[int, ItemData]] = MappingProxyType({item.id: item for item in item_list})
+
+gauntlet_item_name_groups = {
+ "Runestone": [f"Runestone {i}" for i in range(1, 14)],
+ "Portal": portals.keys(),
+ "Obelisk": obelisks,
+ "Mirror Shard": mirror_shards
+}
diff --git a/worlds/gl/Locations.py b/worlds/gl/Locations.py
new file mode 100644
index 00000000000..900f452b1ae
--- /dev/null
+++ b/worlds/gl/Locations.py
@@ -0,0 +1,43 @@
+from typing import Final, List, Dict
+
+from BaseClasses import Location
+
+
+class LocationData:
+ name: str = ""
+ id: int = 0
+ difficulty: int = 0
+ tags: Final[tuple[str, ...]]
+
+ def __init__(self, name, id, difficulty, tags=None):
+ if tags is None:
+ tags = []
+ self.name = name
+ self.id = id
+ self.difficulty = difficulty
+ self.tags = tags
+
+
+class GLLocation(Location):
+ game: str = "Gauntlet Legends"
+
+def import_locations() -> List[LocationData]:
+ import orjson
+ import pkgutil
+
+ return [LocationData(**loc) for loc in orjson.loads(pkgutil.get_data(__name__, "json/locations.json").decode("utf-8"))]
+
+
+def get_locations_by_tags(tags: str | List[str]) -> List[LocationData]:
+ if isinstance(tags, str):
+ tags = [tags]
+ return [loc for loc in all_locations if any(tag in loc.tags for tag in tags)]
+
+
+all_locations: List[LocationData] = import_locations()
+
+location_table: Dict[str, int] = {locData.name: locData.id for locData in all_locations}
+
+location_id_to_name: Dict[int, str] = {locData.id: locData.name for locData in all_locations if locData.id is not None}
+
+locationName_to_data: Dict[str, LocationData] = {locData.name: locData for locData in all_locations}
diff --git a/worlds/gl/Options.py b/worlds/gl/Options.py
new file mode 100644
index 00000000000..a9f36b54f5d
--- /dev/null
+++ b/worlds/gl/Options.py
@@ -0,0 +1,247 @@
+from dataclasses import dataclass
+
+from Options import Choice, PerGameCommonOptions, StartInventoryPool, Toggle, Range, DefaultOnToggle, \
+ OptionSet, DeathLink
+
+
+class IncludedAreas(OptionSet):
+ """
+ Select which areas will have their locations included in the randomization pool.
+ Each selected area's stages and checks will be accessible in your world.
+ Unselected areas will not be included in the location pool.
+ Mountain is always available and cannot be excluded.
+ """
+ display_name = "Included Areas"
+ valid_keys = ["Castle", "Town", "Ice", "Battlefield"]
+ default = ["Castle", "Town", "Ice", "Battlefield"]
+
+
+class ChestBarrels(Choice):
+ """
+ Choose how you want Chests and Barrels to be randomized.
+ None: Neither Chests nor Barrels will be added as locations.
+ All Chests: Chests will be added as locations, Barrels will not.
+ All Barrels: Barrels will be added as locations, Chests will not.
+ All Both: Both Chests and Barrels will be added as locations.
+ """
+ display_name = "Chests and Barrels"
+ option_none = 0
+ option_all_chests = 1
+ option_all_barrels = 2
+ option_all_both = 3
+ default = 3
+
+
+class Obelisks(DefaultOnToggle):
+ """
+ Obelisks will be added to the random pool as items.
+ Activating all the obelisks in an area will unlock access to the next.
+ Disabling this will lock obelisks in their original locations.
+ """
+
+ display_name = "Obelisks"
+
+
+class MirrorShards(DefaultOnToggle):
+ """
+ Mirror Shards will be added to the random pool as items.
+ Collecting all four mirror shards will unlock access to the Desecrated Temple.
+ Disabling this will lock mirror shards in their original locations.
+ """
+
+ display_name = "Mirror Shards"
+
+
+class Portals(DefaultOnToggle):
+ """
+ Level portals will be added to the random pool as items.
+ Getting a portal item will unlock access to that level.
+ Levels will no longer unlock in succession when clearing the previous level.
+ """
+ display_name = "Portals"
+
+
+class Goal(Choice):
+ """
+ Choose your objective required to goal.
+ Defeat Skorne: Collect all 13 stones to unlock and defeat Skorne in the Underworld.
+ Defeat X Bosses: Defeat X number of bosses to goal.
+ """
+
+ display_name = "Goal"
+ option_defeat_skorne = 1
+ option_defeat_x_bosses = 2
+ default = 1
+
+
+class BossGoalCount(Range):
+ """
+ Choose how many bosses you must defeat to goal if 'Defeat X Bosses' is selected as your goal.
+ This includes both Temple and Underworld Skorne.
+ """
+
+ display_name = "Boss Goal Count"
+ range_start = 1
+ range_end = 6
+ default = 4
+
+
+class MaxDifficulty(Range):
+ """
+ Select the difficulty value you want to be the maximum.
+ This will affect the amount of checks in each level as well as enemy strength and number of spawners.
+ """
+
+ display_name = "Max Difficulty Value"
+ range_start = 1
+ range_end = 4
+ default = 4
+
+
+class InstantMaxDifficulty(DefaultOnToggle):
+ """
+ All stages will load with their max difficulty on the first run through.
+ By default, stages increase in difficulty by 1 at a set interval that changes per zone.
+ The starting level for each zone increases gradually as you would progress in vanilla.
+ """
+
+ display_name = "Instant Max Difficulty"
+
+
+class PermaSpeed(Toggle):
+ """
+ You will be given speed boots with a permanent duration.
+ """
+
+ display_name = "Permanent Speed Boots"
+
+
+class InfiniteKeys(Toggle):
+ """
+ You will be given more keys than you could ever use.
+ """
+
+ display_name = "Infinite Keys"
+
+
+class IncludedTraps(OptionSet):
+ """
+ Choose what traps will be put in the item pool.
+ Valid Keys: Death, Poison Fruit, Crossbow Shooter, Bomb Thrower, Bomb Runner, Golem
+ """
+
+ display_name = "Included Traps"
+ valid_keys = ["Death", "Poison Fruit", "Crossbow Shooter", "Bomb Thrower", "Bomb Runner", "Golem"]
+ default = ["Death", "Poison Fruit", "Crossbow Shooter", "Bomb Thrower", "Bomb Runner", "Golem"]
+
+
+class TrapsFrequency(Range):
+ """
+ Choose the frequency of traps added into the item pool
+ This range is a percentage of all items in the pool that will be traps.
+ """
+
+ display_name = "Trap Frequency"
+ range_start = 0
+ range_end = 90
+ default = 10
+
+
+class LocalFillerFrequency(Range):
+ """
+ Choose the frequency of filler items that will be placed locally in your world.
+ This range is a percentage of how many filler items will be locally placed before generation occurs.
+ """
+
+ display_name = "Local Filler Frequency"
+ range_start = 0
+ range_end = 90
+ default = 50
+
+
+class UnlockCharacterOne(Choice):
+ """
+ Unlock a secret character for Player 1 from the start.
+ None: No secret characters will be unlocked.
+ Chosen Character: The selected character will be available from a new save.
+ """
+
+ option_none = 0
+ option_minotaur = 1
+ option_falconess = 2
+ option_jackal = 3
+ option_tigress = 4
+ option_sumner = 5
+ default = 0
+
+
+class UnlockCharacterTwo(Choice):
+ """
+ Unlock a secret character for Player 2 from the start.
+ None: No secret characters will be unlocked.
+ Chosen Character: The selected character will be available from a new save.
+ """
+
+ option_none = 0
+ option_minotaur = 1
+ option_falconess = 2
+ option_jackal = 3
+ option_tigress = 4
+ option_sumner = 5
+ default = 0
+
+
+class UnlockCharacterThree(Choice):
+ """
+ Unlock a secret character for Player 3 from the start.
+ None: No secret characters will be unlocked.
+ Chosen Character: The selected character will be available from a new save.
+ """
+
+ option_none = 0
+ option_minotaur = 1
+ option_falconess = 2
+ option_jackal = 3
+ option_tigress = 4
+ option_sumner = 5
+ default = 0
+
+
+class UnlockCharacterFour(Choice):
+ """
+ Unlock a secret character for Player 4 from the start.
+ None: No secret characters will be unlocked.
+ Chosen Character: The selected character will be available from a new save.
+ """
+
+ option_none = 0
+ option_minotaur = 1
+ option_falconess = 2
+ option_jackal = 3
+ option_tigress = 4
+ option_sumner = 5
+ default = 0
+
+
+@dataclass
+class GLOptions(PerGameCommonOptions):
+ death_link: DeathLink
+ start_inventory_from_pool: StartInventoryPool
+ included_areas: IncludedAreas
+ chests_barrels: ChestBarrels
+ obelisks: Obelisks
+ mirror_shards: MirrorShards
+ portals: Portals
+ goal: Goal
+ boss_goal_count: BossGoalCount
+ max_difficulty: MaxDifficulty
+ instant_max: InstantMaxDifficulty
+ infinite_keys: InfiniteKeys
+ permanent_speed: PermaSpeed
+ included_traps: IncludedTraps
+ traps_frequency: TrapsFrequency
+ local_filler_frequency: LocalFillerFrequency
+ unlock_character_one: UnlockCharacterOne
+ unlock_character_two: UnlockCharacterTwo
+ unlock_character_three: UnlockCharacterThree
+ unlock_character_four: UnlockCharacterFour
diff --git a/worlds/gl/Regions.py b/worlds/gl/Regions.py
new file mode 100644
index 00000000000..37adec11685
--- /dev/null
+++ b/worlds/gl/Regions.py
@@ -0,0 +1,137 @@
+import typing
+from typing import Optional, Callable
+
+from BaseClasses import Region
+from .Data import excluded_levels
+
+from .Locations import GLLocation, LocationData, get_locations_by_tags
+
+if typing.TYPE_CHECKING:
+ from . import GauntletLegendsWorld
+
+
+def create_regions(world: "GauntletLegendsWorld"):
+ world.multiworld.regions.append(Region("Menu", world.player, world.multiworld))
+
+ regions_dict = get_regions_dict()
+ for name, locations in regions_dict.items():
+ if name not in [level for region, levels in excluded_levels.items() if region in world.excluded_regions for level in levels]:
+ create_region(world, name, locations)
+ else:
+ world.disabled_locations.update([loc.name for loc in locations if loc.name not in world.disabled_locations])
+
+def get_regions_dict() -> dict[str, list[LocationData]]:
+ """
+ Returns a dictionary mapping region names to their corresponding location data lists.
+ """
+ return {
+ "Valley of Fire": get_locations_by_tags("valley_of_fire"),
+ "Dagger Peak": get_locations_by_tags("dagger_peak"),
+ "Cliffs of Desolation": get_locations_by_tags("cliffs_of_desolation"),
+ "Lost Cave": get_locations_by_tags("lost_cave"),
+ "Volcanic Caverns": get_locations_by_tags("volcanic_cavern"),
+ "Dragon's Lair": get_locations_by_tags("dragons_lair"),
+ "Castle Courtyard": get_locations_by_tags("castle_courtyard"),
+ "Dungeon of Torment": get_locations_by_tags("dungeon_of_torment"),
+ "Tower Armory": get_locations_by_tags("tower_armory"),
+ "Castle Treasury": get_locations_by_tags("castle_treasury"),
+ "Chimera's Keep": get_locations_by_tags("chimeras_keep"),
+ "Poisoned Fields": get_locations_by_tags("poisoned_fields"),
+ "Haunted Cemetery": get_locations_by_tags("haunted_cemetery"),
+ "Venomous Spire": get_locations_by_tags("venomous_spire"),
+ "Toxic Air Ship": get_locations_by_tags("toxic_air_ship"),
+ "Vat of the Plague Fiend": get_locations_by_tags("plague_fiend"),
+ "Arctic Docks": get_locations_by_tags("arctic_docks"),
+ "Frozen Camp": get_locations_by_tags("frozen_camp"),
+ "Crystal Mine": get_locations_by_tags("crystal_mine"),
+ "Erupting Fissure": get_locations_by_tags("erupting_fissure"),
+ "Yeti's Cavern": get_locations_by_tags("yeti"),
+ "Desecrated Temple": get_locations_by_tags("desecrated_temple"),
+ "Altar of Skorne": get_locations_by_tags("altar_of_skorne"),
+ "Battle Trenches": get_locations_by_tags("battle_trenches"),
+ "Fortified Towers": get_locations_by_tags("fortified_towers"),
+ "Infernal Fortress": get_locations_by_tags("infernal_fortress"),
+ "Gates of the Underworld": get_locations_by_tags("gates_of_the_underworld")
+ }
+
+
+def connect_regions(world: "GauntletLegendsWorld"):
+ names: dict[str, int] = {}
+
+ connect(world, names, "Menu", "Valley of Fire")
+ connect(world, names, "Menu", "Dagger Peak", _portal_helper("Portal to Dagger Peak", world))
+ connect(world, names, "Menu", "Cliffs of Desolation", _portal_helper("Portal to Cliffs of Desolation", world))
+ connect(world, names, "Menu", "Lost Cave", _portal_helper("Portal to Lost Cave", world))
+ connect(world, names, "Menu", "Volcanic Caverns", _portal_helper("Portal to Volcanic Caverns", world))
+ connect(world, names, "Menu", "Dragon's Lair", _portal_helper("Portal to Dragon's Lair", world))
+ connect(world, names, "Menu", "Castle Courtyard",
+ lambda state: state.has("Valley of Fire Obelisk", world.player)
+ and state.has("Dagger Peak Obelisk", world.player)
+ and state.has("Cliffs of Desolation Obelisk", world.player)
+ )
+ connect(world, names, "Castle Courtyard", "Dungeon of Torment", _portal_helper("Portal to Dungeon of Torment", world))
+ connect(world, names, "Castle Courtyard", "Tower Armory", _portal_helper("Portal to Tower Armory", world))
+ connect(world, names, "Castle Courtyard", "Castle Treasury", _portal_helper("Portal to Castle Treasury", world))
+ connect(world, names, "Castle Courtyard", "Chimera's Keep", _portal_helper("Portal to Chimera's Keep", world))
+ connect(world, names, "Menu", "Poisoned Fields",
+ lambda state: state.has("Castle Courtyard Obelisk", world.player)
+ and state.has("Dungeon of Torment Obelisk", world.player)
+ )
+ connect(world, names, "Poisoned Fields", "Haunted Cemetery", _portal_helper("Portal to Haunted Cemetery", world))
+ connect(world, names, "Poisoned Fields", "Venomous Spire", _portal_helper("Portal to Venomous Spire", world))
+ connect(world, names, "Poisoned Fields", "Toxic Air Ship", _portal_helper("Portal to Toxic Air Ship", world))
+ connect(world, names, "Toxic Air Ship", "Vat of the Plague Fiend", _portal_helper("Portal to Vat of the Plague Fiend", world))
+ connect(world, names, "Menu", "Arctic Docks",
+ lambda state: state.has("Poisoned Fields Obelisk", world.player)
+ and state.has("Haunted Cemetery Obelisk", world.player)
+ )
+ connect(world, names, "Arctic Docks", "Frozen Camp", _portal_helper("Portal to Frozen Camp", world))
+ connect(world, names, "Arctic Docks", "Crystal Mine", _portal_helper("Portal to Crystal Mine", world))
+ connect(world, names, "Arctic Docks", "Erupting Fissure", _portal_helper("Portal to Erupting Fissure", world))
+ connect(world, names, "Erupting Fissure", "Yeti's Cavern", _portal_helper("Portal to Yeti's Cavern", world))
+ connect(world, names, "Menu", "Desecrated Temple",
+ lambda state: state.has("Dragon Mirror Shard", world.player)
+ and state.has("Chimera Mirror Shard", world.player)
+ and state.has("Plague Fiend Mirror Shard", world.player)
+ and state.has("Yeti Mirror Shard", world.player)
+ )
+ connect(world, names, "Desecrated Temple", "Altar of Skorne")
+ connect(world, names, "Desecrated Temple", "Battle Trenches")
+ connect(world, names, "Desecrated Temple", "Fortified Towers", _portal_helper("Portal to Fortified Towers", world))
+ connect(world, names, "Desecrated Temple", "Infernal Fortress", _portal_helper("Portal to Infernal Fortress", world))
+ connect(world, names, "Menu", "Gates of the Underworld",
+ lambda state: state.has("stones", world.player, 13)
+ )
+
+def _portal_helper(item: str, world: "GauntletLegendsWorld") -> Callable | None:
+ if world.options.portals.value:
+ return lambda state: state.has(item, world.player)
+ return None
+
+
+def create_region(world: "GauntletLegendsWorld", name: str, locations: list[LocationData]):
+ reg = Region(name, world.player, world.multiworld)
+ reg.add_locations({loc.name: loc.id for loc in locations if loc.name not in world.disabled_locations}, GLLocation)
+ world.multiworld.regions.append(reg)
+
+
+def connect(world: "GauntletLegendsWorld",
+ used_names: dict[str, int],
+ source: str,
+ target: str,
+ rule: Optional[Callable] = None,
+):
+ excluded_levels_ = [level for region, levels in excluded_levels.items() if region in world.excluded_regions for level in levels]
+ if source in excluded_levels_ or target in excluded_levels_:
+ return
+ source_region = world.multiworld.get_region(source, world.player)
+ target_region = world.multiworld.get_region(target, world.player)
+
+ if target not in used_names:
+ used_names[target] = 1
+ name = target
+ else:
+ used_names[target] += 1
+ name = target + (" " * used_names[target])
+
+ source_region.connect(target_region, name, rule)
diff --git a/worlds/gl/Rom.py b/worlds/gl/Rom.py
new file mode 100644
index 00000000000..2c997e1c2ae
--- /dev/null
+++ b/worlds/gl/Rom.py
@@ -0,0 +1,613 @@
+import io
+import json
+import os
+import pkgutil
+import traceback
+import typing
+import zlib
+from random import Random
+
+import bsdiff4
+
+import Utils
+from BaseClasses import Location, ItemClassification
+
+from worlds.Files import APPatchExtension, APProcedurePatch, APTokenMixin
+
+from .Data import level_address, level_header, level_locations, level_size, boss_location_offsets, spawner_trap_ids
+from .Items import ItemData, items_by_id
+from .Locations import locationName_to_data, GLLocation
+
+if typing.TYPE_CHECKING:
+ from . import GauntletLegendsWorld
+
+TABLE_START_OFFSET = 0x12E0
+EXPANDED_GAME_ROM_OFFSET = 0x1000000 # 16MB mark
+
+
+def create_spawner_from_item(item_data: bytearray, spawner_rom_id: int, difficulty: int) -> bytearray:
+ spawner = bytearray(16)
+ spawner[0:6] = item_data[0:6]
+ spawner[6:8] = (0).to_bytes(2, "big")
+ spawner[8:12] = spawner_rom_id.to_bytes(4, "big")
+ spawner[12:14] = (0x0003).to_bytes(2, "big")
+ spawner[14] = difficulty
+ spawner[15] = 0x00
+ return spawner
+
+
+def create_spawner_from_chest(chest_data: bytearray, spawner_rom_id: int, difficulty: int) -> bytearray:
+ spawner = bytearray(16)
+ spawner[0:6] = chest_data[0:6]
+ spawner[6:8] = chest_data[6:8]
+ spawner[8:12] = spawner_rom_id.to_bytes(4, "big")
+ spawner[12:14] = (0x0003).to_bytes(2, "big")
+ spawner[14] = difficulty
+ spawner[15] = 0x00
+ return spawner
+
+
+def be32(b: bytes) -> int:
+ return int.from_bytes(b, "big")
+
+
+def write_be32(buf: bytearray, off: int, val: int):
+ buf[off:off + 4] = (val & 0xFFFFFFFF).to_bytes(4, "big")
+
+
+def ensure_len(buf: bytearray, size: int, fill: int = 0):
+ if len(buf) < size:
+ buf.extend(bytes([fill]) * (size - len(buf)))
+
+
+def n64_crc(rom: bytes, cic: int = 6102) -> tuple[int, int]:
+ """Calculate N64 CRC checksums."""
+ if cic in (6101, 6102):
+ seed = 0xF8CA4DDC
+ elif cic == 6103:
+ seed = 0xA3886759
+ elif cic == 6105:
+ seed = 0xDF26F436
+ elif cic == 6106:
+ seed = 0x1FEA617A
+ else:
+ raise ValueError("Unsupported CIC")
+
+ t1 = t2 = t3 = t4 = t5 = t6 = seed
+
+ for i in range(0x1000, 0x101000, 4):
+ d = int.from_bytes(rom[i:i + 4], "big")
+
+ if (t6 + d) & 0xFFFFFFFF < t6:
+ t4 = (t4 + 1) & 0xFFFFFFFF
+
+ t6 = (t6 + d) & 0xFFFFFFFF
+ t3 ^= d
+
+ r = ((d << (d & 31)) | (d >> (32 - (d & 31)))) & 0xFFFFFFFF
+ t5 = (t5 + r) & 0xFFFFFFFF
+
+ if t2 > d:
+ t2 ^= r
+ else:
+ t2 ^= t6 ^ d
+
+ if cic == 6105:
+ extra = int.from_bytes(rom[0x0750 + (i & 0xFF):0x0754 + (i & 0xFF)], "big")
+ t1 = (t1 + (extra ^ d)) & 0xFFFFFFFF
+ else:
+ t1 = (t1 + (t5 ^ d)) & 0xFFFFFFFF
+
+ if cic == 6103:
+ crc1 = (t6 ^ t4) & 0xFFFFFFFF
+ crc2 = (t5 ^ t3) & 0xFFFFFFFF
+ elif cic == 6106:
+ crc1 = ((t6 * t4) + t3) & 0xFFFFFFFF
+ crc2 = ((t5 * t2) + t1) & 0xFFFFFFFF
+ else:
+ crc1 = (t6 ^ t4 ^ t3) & 0xFFFFFFFF
+ crc2 = (t5 ^ t2 ^ t1) & 0xFFFFFFFF
+
+ return crc1, crc2
+
+
+def get_base_rom_as_bytes() -> bytes:
+ """
+ Read the base ROM file and verify its MD5 hash.
+ Raises an exception if the ROM doesn't match the expected hash.
+ """
+ try:
+ from . import GauntletLegendsWorld
+ GauntletLegendsWorld.settings.rom_file.validate(GauntletLegendsWorld.settings.rom_file)
+ with open(GauntletLegendsWorld.settings.rom_file, "rb") as infile:
+ base_rom_bytes = bytes(infile.read())
+ except Exception:
+ traceback.print_exc()
+ raise Exception('Failed to read ROM file. Check file path and permissions.')
+ return base_rom_bytes
+
+
+def get_base_rom_path(file_name: str = "") -> str:
+ if not os.path.exists(file_name):
+ file_name = Utils.user_path(file_name)
+ return file_name
+
+
+# Contains header info and raw data for level item positions and rotations.
+class LevelData:
+ stream: io.BytesIO
+ header: bytearray
+ item_addr: int
+ spawner_addr: int
+ obj_addr: int
+ chest_addr: int
+ end_addr: int
+ end_addr2: int
+ end_addr3: int
+ portal_addr: int
+ items: list[bytearray]
+ spawners: list[bytearray]
+ objects: list[bytearray]
+ chests: list[bytearray]
+ end: bytes
+ items_replaced_by_obelisks: int = 0
+ chests_replaced_by_obelisks: int = 0
+ chests_replaced_by_items: int = 0
+ obelisks_replaced_by_items: int = 0
+ obelisks_kept: int = 0
+ items_replaced_by_spawners: int = 0
+ chests_replaced_by_spawners: int = 0
+
+ def __init__(self):
+ self.items = []
+ self.spawners = []
+ self.objects = []
+ self.chests = []
+ self.end = b""
+
+
+class GLPatchExtension(APPatchExtension):
+ game = "Gauntlet Legends"
+
+ # Patched ROM requires updated CRC values.
+ @staticmethod
+ def finalize_crc(caller: APProcedurePatch, rom: bytes) -> bytes:
+ options = json.loads(caller.get_file("options.json").decode("utf-8"))
+ rom = bytearray(rom)
+
+ SEED_ROM_OFFSET = 0xFFFFF0
+ SEED_LEN = 16
+
+ seed = options["seed_name"].encode("utf-8")[:SEED_LEN]
+ rom[SEED_ROM_OFFSET:SEED_ROM_OFFSET + SEED_LEN] = seed.ljust(SEED_LEN, b"\x00")
+
+ rom = bytearray(rom)
+ crc1, crc2 = n64_crc(rom, cic=6102)
+ rom[0x10:0x14] = crc1.to_bytes(4, "big")
+ rom[0x14:0x18] = crc2.to_bytes(4, "big")
+ return bytes(rom)
+
+
+ @staticmethod
+ def apply_bsdiff4(caller: APProcedurePatch, rom: bytes, patch: str) -> bytes:
+ return bsdiff4.patch(rom, pkgutil.get_data(__name__, f"data/basepatch.bsdiff4"))
+
+ # Decompress all levels, place all items in the levels.
+ @staticmethod
+ def patch_items(caller: APProcedurePatch, rom: bytes):
+ stream = io.BytesIO(rom)
+ options = json.loads(caller.get_file("options.json").decode("UTF-8"))
+ local_random = Random(options["seed"])
+ player = options["player"]
+
+ for i in range(len(level_locations)):
+ level: dict[str, tuple] = json.loads(caller.get_file(f"level_{i}.json").decode("utf-8"))
+ level_address_ = level_address[i]
+ if level_address_ == 0:
+ continue
+ stream.seek(level_address_, 0)
+ stream, data = get_level_data(stream, level_size[i], i)
+
+ items_seen = 0
+ items_deleted = 0
+ chests_deleted = 0
+ chests_seen = 0
+
+ for location_name, item in level.items():
+ is_chest = chest_barrel(location_name)
+ if is_chest:
+ chest_index = chests_seen - chests_deleted
+ chests_seen += 1
+ else:
+ item_index = items_seen - items_deleted
+ items_seen += 1
+
+ if item[0] == 0:
+ continue
+ if "Mirror" in location_name:
+ continue
+
+ item_data = items_by_id.get(item[0], ItemData())
+ rom_id = item_data.rom_id
+ if rom_id == 0x0302:
+ rom_id = local_random.choices(
+ [0x0300, 0x0301, 0x0302, 0x0303, 0x0304],
+ weights=[10, 20, 40, 20, 10]
+ )[0]
+
+ # Handle obelisk locations
+ if "Obelisk" in location_name:
+ if "Obelisk" not in item_data.item_name:
+ # Non-obelisk item at obelisk location - convert obelisk to item
+ try:
+ index = next(idx for idx in range(len(data.objects)) if data.objects[idx][8] == 0x26)
+ data.items.append(
+ bytearray(data.objects[index][0:6])
+ + (rom_id.to_bytes(2) if item[1] == player else bytes([0x27, 0x1C]))
+ + bytes([0x0, 0x0, 0x0, 0x0])
+ )
+ del data.objects[index]
+ data.obelisks_replaced_by_items += 1
+ except (StopIteration, Exception):
+ pass
+ else:
+ # Obelisk item at obelisk location
+ try:
+ index = next(idx for idx in range(len(data.objects)) if data.objects[idx][8] == 0x26)
+ if item[1] == player:
+ data.objects[index][15] = item[0] - 77780054
+ data.obelisks_kept += 1
+ else:
+ data.items.append(
+ bytearray(data.objects[index][0:6])
+ + bytes([0x27, 0x1C, 0x0, 0x0, 0x0, 0x0])
+ )
+ del data.objects[index]
+ data.obelisks_replaced_by_items += 1
+ except (StopIteration, Exception):
+ pass
+ continue
+
+ # Handle spawner trap items (local player only)
+ if item[1] == player and item[0] in spawner_trap_ids:
+ difficulty = locationName_to_data[location_name].difficulty
+ spawner_rom_id = items_by_id[item[0]].rom_id
+ if is_chest:
+ data.spawners.append(create_spawner_from_chest(data.chests[chest_index], spawner_rom_id, difficulty))
+ del data.chests[chest_index]
+ chests_deleted += 1
+ data.chests_replaced_by_spawners += 1
+ else:
+ data.spawners.append(create_spawner_from_item(data.items[item_index], spawner_rom_id, difficulty))
+ del data.items[item_index]
+ items_deleted += 1
+ data.items_replaced_by_spawners += 1
+ continue
+
+ # Handle non-local player items
+ if item[1] != player:
+ if is_chest:
+ data.chests[chest_index][12:14] = [0x27, 0x1C]
+ if "Chest" in location_name:
+ data.chests[chest_index][9] = 0x1
+ else:
+ data.items[item_index][6:8] = [0x27, 0x1C]
+ continue
+
+ # Handle local player items
+ if "Obelisk" in items_by_id[item[0]].item_name:
+ # Convert item/chest to obelisk
+ if is_chest:
+ slice_ = bytearray(data.chests[chest_index][0:6])
+ del data.chests[chest_index]
+ chests_deleted += 1
+ data.chests_replaced_by_obelisks += 1
+ else:
+ slice_ = bytearray(data.items[item_index][0:6])
+ del data.items[item_index]
+ items_deleted += 1
+ data.items_replaced_by_obelisks += 1
+ data.objects.append(
+ slice_ + bytearray([
+ 0x0, 0x0, 0x26, 0x1, 0x0,
+ locationName_to_data[location_name].difficulty,
+ 0x0, 0x0, 0x0, item[0] - 77780054,
+ 0x3F, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
+ ])
+ )
+ elif item_data.progression in (ItemClassification.useful, ItemClassification.progression) and is_chest:
+ # Convert chest to item for useful/progression items
+ chest = data.chests[chest_index]
+ data.items.append(
+ bytearray(chest[0:6])
+ + rom_id.to_bytes(2)
+ + bytes([chest[11], 0x0, 0x0, 0x0])
+ )
+ del data.chests[chest_index]
+ chests_deleted += 1
+ data.chests_replaced_by_items += 1
+ else:
+ # Regular item placement
+ if is_chest:
+ data.chests[chest_index][12:14] = rom_id.to_bytes(2)
+ if "Chest" in location_name:
+ data.chests[chest_index][9] = 0x2
+ else:
+ data.items[item_index][6:8] = item_data.rom_id.to_bytes(2)
+
+ # Write level data
+ uncompressed = level_data_reformat(data)
+ compressed = zenc(uncompressed)
+ stream.seek(level_header[i] + 4, 0)
+ stream.write(len(compressed).to_bytes(4, byteorder="big"))
+ stream.write(len(uncompressed).to_bytes(4, byteorder="big"))
+ write_pos = 0xFA1000 + (0x1500 * i)
+ stream.write((write_pos - 0x636E0).to_bytes(4, byteorder="big"))
+ stream.seek(write_pos, 0)
+ stream.write(compressed)
+
+ return stream.getvalue()
+
+ @staticmethod
+ def patch_bins(caller: APProcedurePatch, rom: bytes) -> bytes:
+ boss_items_data = json.loads(caller.get_file("boss_items.json").decode("UTF-8"))
+ options = json.loads(caller.get_file("options.json").decode("UTF-8"))
+ local_random = Random(options["seed"])
+
+ boss_entry_offset = TABLE_START_OFFSET + (2 * 0x30)
+
+ rom = bytearray(rom)
+
+ boss_rom_offset = be32(rom[boss_entry_offset + 0x10:boss_entry_offset + 0x14])
+ boss_comp_size = be32(rom[boss_entry_offset + 0x14:boss_entry_offset + 0x18])
+
+ boss_compressed = bytes(rom[boss_rom_offset:boss_rom_offset + boss_comp_size])
+ boss_decompressed = bytearray(zdec(boss_compressed))
+
+ FILLER_ROM_ID = 0x271C
+
+ for location_name, item_data in boss_items_data.items():
+ if location_name not in boss_location_offsets:
+ continue
+
+ item_code, item_player = item_data
+ if not item_code:
+ continue
+
+ if item_player != options["player"]:
+ rom_id = FILLER_ROM_ID
+ else:
+ rom_id = items_by_id[item_code].rom_id
+
+ if rom_id == 0x0302:
+ rom_id = local_random.choices([0x0300, 0x0301, 0x0302, 0x0303, 0x0304], weights=[10, 20, 40, 20, 10])[0]
+
+ hi_byte = (rom_id >> 8) & 0xFF
+ lo_byte = rom_id & 0xFF
+
+ offset = boss_location_offsets[location_name]
+ boss_decompressed[offset:offset + 2] = hi_byte.to_bytes(2, "big")
+ boss_decompressed[offset + 2:offset + 4] = lo_byte.to_bytes(2, "big")
+
+ boss_recompressed = zenc(boss_decompressed)
+ new_boss_comp_size = len(boss_recompressed)
+
+ if new_boss_comp_size > boss_comp_size:
+ new_boss_rom_offset = EXPANDED_GAME_ROM_OFFSET + 0x100000
+ new_boss_end = new_boss_rom_offset + new_boss_comp_size
+ ensure_len(rom, new_boss_end, fill=0xFF)
+ rom[new_boss_rom_offset:new_boss_end] = boss_recompressed
+ write_be32(rom, boss_entry_offset + 0x10, new_boss_rom_offset)
+ write_be32(rom, boss_entry_offset + 0x14, new_boss_comp_size)
+ else:
+ rom[boss_rom_offset:boss_rom_offset + new_boss_comp_size] = boss_recompressed
+ write_be32(rom, boss_entry_offset + 0x14, new_boss_comp_size)
+ if new_boss_comp_size < boss_comp_size:
+ leftover_start = boss_rom_offset + new_boss_comp_size
+ leftover_end = boss_rom_offset + boss_comp_size
+ rom[leftover_start:leftover_end] = bytes(leftover_end - leftover_start)
+
+ # Write portals option
+ rom[0xFFFFE0] = options["portals"]
+ rom[0xFFFFE1] = options["instant_max"]
+ rom[0xFFFFE2] = options["max"]
+ rom[0xFFFFE4] = options["keys"]
+ rom[0xFFFFE5] = options["speed"]
+ rom[0xFFFFE8:0xFFFFEC] = options["characters"]
+ return bytes(rom)
+
+
+class GLProcedurePatch(APProcedurePatch, APTokenMixin):
+ game = "Gauntlet Legends"
+ hash = "9cb963e8b71f18568f78ec1af120362e"
+ patch_file_ending = ".apgl"
+ result_file_ending = ".z64"
+
+ procedure = [
+ ("apply_bsdiff4", ["basepatch.bsdiff4"]),
+ ("patch_bins", []),
+ ("patch_items", []),
+ ("finalize_crc", [])
+ ]
+
+ @classmethod
+ def get_source_data(cls) -> bytes:
+ return get_base_rom_as_bytes()
+
+
+# Write data on all placed items into json files.
+# Also save options
+def write_files(world: "GauntletLegendsWorld", patch: GLProcedurePatch) -> None:
+ options_dict = {
+ "seed": world.multiworld.seed,
+ "seed_name": world.multiworld.seed_name,
+ "player": world.player,
+ "portals": world.options.portals.value,
+ "instant_max": world.options.instant_max.value,
+ "max": world.options.max_difficulty.value,
+ "keys": world.options.infinite_keys.value,
+ "speed": world.options.permanent_speed.value,
+ "characters": [world.options.unlock_character_one.value, world.options.unlock_character_two.value,
+ world.options.unlock_character_three.value, world.options.unlock_character_four.value]
+ }
+ patch.write_file("options.json", json.dumps(options_dict).encode("UTF-8"))
+
+ # Write level files
+ for i, level in enumerate(level_locations.values()):
+ locations: list[Location] = []
+ for location in level:
+ if location.name in world.disabled_locations:
+ locations += [GLLocation(world.player, location.name, location.id)]
+ else:
+ locations += [world.get_location(location.name)]
+ patch.write_file(f"level_{i}.json", json.dumps(locations_to_dict(locations)).encode("UTF-8"))
+
+ # Write boss items file
+ boss_location_names = [
+ "Dragon's Lair - Dragon Mirror Shard",
+ "Yeti's Cavern - Yeti Mirror Shard",
+ "Chimera's Keep - Chimera Mirror Shard",
+ "Vat of the Plague Fiend - Plague Fiend Mirror Shard",
+ "Altar of Skorne - Skorne's Mask",
+ "Altar of Skorne - Skorne's Horns",
+ "Altar of Skorne - Skorne's Left Gauntlet",
+ "Altar of Skorne - Skorne's Right Gauntlet"
+ ]
+
+ boss_locations: list[Location] = []
+ for location_name in boss_location_names:
+ if location_name in world.disabled_locations:
+ # Get location ID from locationName_to_data
+ location_data = locationName_to_data.get(location_name)
+ if location_data:
+ boss_locations += [GLLocation(world.player, location_name, location_data.id)]
+ else:
+ boss_locations += [world.get_location(location_name)]
+
+ patch.write_file("boss_items.json", json.dumps(locations_to_dict(boss_locations)).encode("UTF-8"))
+
+
+def locations_to_dict(locations: list[Location]) -> dict[str, tuple]:
+ return {location.name: (location.item.code, location.item.player) if location.item is not None else (0, 0) for
+ location in locations}
+
+
+def patch_docks(data: LevelData) -> LevelData:
+ data.stream.seek(0x19AC, 0)
+ data.stream.write(bytes([0x3, 0x20, 0x0, 0x18, 0x1, 0x7C]))
+ data.stream.seek(0x74, 0)
+ data.stream.write(bytes([0x3, 0x0, 0x0, 0x1A, 0x1, 0x8]))
+ return data
+
+
+def patch_camp(data: LevelData) -> LevelData:
+ data.stream.seek(0x1B74, 0)
+ data.stream.write(bytes([0xFE, 0xE9, 0x0, 0x3B, 0xFF, 0xDC, 0x0, 0x10]))
+ data.stream.seek(0x1B64, 0)
+ data.stream.write(bytes([0xFE, 0x84, 0x0, 0x3D, 0xFF, 0xE0, 0xFF, 0xF0]))
+ return data
+
+
+def patch_trenches(data: LevelData) -> LevelData:
+ data.stream.seek(0xD4, 0)
+ data.stream.write(bytes([0xFB, 0x68, 0x0, 0x82]))
+ return data
+
+
+# Zlib decompression with wbits set to -15
+def zdec(data):
+ decomp = zlib.decompressobj(-zlib.MAX_WBITS)
+ output = bytearray()
+ for i in range(0, len(data), 256):
+ output.extend(decomp.decompress(data[i: i + 256]))
+ output.extend(decomp.flush())
+ return output
+
+
+# Zlib compression with compression set to max and wbits set to -15
+def zenc(data):
+ compress = zlib.compressobj(zlib.Z_BEST_COMPRESSION, zlib.DEFLATED, -zlib.MAX_WBITS)
+ output = bytearray()
+ for i in range(0, len(data), 256):
+ output.extend(compress.compress(data[i: i + 256]))
+ output.extend(compress.flush())
+ return output
+
+
+# Create a LevelData object from raw decompressed bytes of a level
+def get_level_data(stream: io.BytesIO, size: int, level: int = 0) -> tuple[io.BytesIO, LevelData]:
+ data = LevelData()
+ data.stream = io.BytesIO(zdec(stream.read(size)))
+ if level == 17:
+ data = patch_docks(data)
+ if level == 18:
+ data = patch_camp(data)
+ if level == 24:
+ data = patch_trenches(data)
+ data.header = bytearray(data.stream.read(0x5C))
+ data.stream.seek(0)
+ data.item_addr = int.from_bytes(data.stream.read(4), "big")
+ data.spawner_addr = int.from_bytes(data.stream.read(4), "big")
+ data.stream.seek(4, 1)
+ data.obj_addr = int.from_bytes(data.stream.read(4), "big")
+ data.end_addr = int.from_bytes(data.stream.read(4), "big")
+ data.portal_addr = int.from_bytes(data.stream.read(4), "big")
+ data.chest_addr = int.from_bytes(data.stream.read(4), "big")
+ data.end_addr2 = int.from_bytes(data.stream.read(4), "big")
+ data.end_addr3 = int.from_bytes(data.stream.read(4), "big")
+ data.stream.seek(data.item_addr)
+ for i in range(data.stream.tell(), data.spawner_addr, 12):
+ data.stream.seek(i)
+ data.items += [bytearray(data.stream.read(12))]
+ for i in range(data.stream.tell(), data.obj_addr, 16):
+ data.stream.seek(i)
+ data.spawners += [bytearray(data.stream.read(16))]
+ for i in range(data.stream.tell(), data.chest_addr, 24):
+ data.stream.seek(i)
+ data.objects += [bytearray(data.stream.read(24))]
+ for i in range(data.stream.tell(), data.end_addr, 16):
+ data.stream.seek(i)
+ data.chests += [bytearray(data.stream.read(16))]
+ data.end = data.stream.read()
+ return stream, data
+
+
+# Format a LevelData object back into a bytes object
+# Format is header, items, spawners, objects, barrels/chests, then traps.
+def level_data_reformat(data: LevelData) -> bytes:
+ stream = io.BytesIO()
+ obelisk_offset = 24 * (
+ data.items_replaced_by_obelisks + data.chests_replaced_by_obelisks - data.obelisks_replaced_by_items)
+ item_offset = 12 * (
+ data.chests_replaced_by_items + data.obelisks_replaced_by_items - data.items_replaced_by_obelisks - data.items_replaced_by_spawners)
+ chest_offset = 16 * (
+ data.chests_replaced_by_obelisks + data.chests_replaced_by_items + data.chests_replaced_by_spawners)
+ spawner_offset = 16 * (data.items_replaced_by_spawners + data.chests_replaced_by_spawners)
+
+ stream.write(int.to_bytes(0x5C, 4, "big"))
+ stream.write(int.to_bytes(data.spawner_addr + item_offset, 4, "big"))
+ stream.write(int.to_bytes(data.spawner_addr + item_offset, 4, "big"))
+ stream.write(int.to_bytes(data.obj_addr + item_offset + spawner_offset, 4, "big"))
+ stream.write(int.to_bytes(data.end_addr + item_offset + spawner_offset + obelisk_offset - chest_offset, 4, "big"))
+ stream.write(
+ int.to_bytes(data.portal_addr + item_offset + spawner_offset + obelisk_offset - chest_offset, 4, "big"))
+ stream.write(int.to_bytes(data.chest_addr + item_offset + spawner_offset + obelisk_offset, 4, "big"))
+ stream.write(int.to_bytes(data.end_addr2 + item_offset + spawner_offset + obelisk_offset - chest_offset, 4, "big"))
+ stream.write(int.to_bytes(data.end_addr3 + item_offset + spawner_offset + obelisk_offset - chest_offset, 4, "big"))
+ # Counts are 2 bytes each, big-endian
+ stream.write(len(data.items).to_bytes(2, "big"))
+ stream.write(bytes([0x0, 0x0]))
+ stream.write(len(data.spawners).to_bytes(2, "big"))
+ stream.write(len(data.objects).to_bytes(2, "big"))
+ data.stream.seek(stream.tell())
+ temp = bytearray(data.stream.read(48))
+ temp[7] = len(data.chests)
+ stream.write(temp)
+ for item in data.items + data.spawners + data.objects + data.chests:
+ stream.write(bytes(item))
+ stream.write(data.end)
+ return stream.getvalue()
+
+
+def chest_barrel(name: str):
+ return "Chest" in name or ("Barrel" in name and "Barrel of Gold" not in name)
diff --git a/worlds/gl/Rules.py b/worlds/gl/Rules.py
new file mode 100644
index 00000000000..7c86dc7d303
--- /dev/null
+++ b/worlds/gl/Rules.py
@@ -0,0 +1,62 @@
+import typing
+
+from BaseClasses import CollectionRule
+from worlds.generic.Rules import add_rule, forbid_item
+
+from .Data import difficulty_lambda, level_locations, obelisks, boss_regions, excluded_levels, spawner_trap_ids, \
+ difficulty_lambda_no_portal
+from .Items import items_by_id
+from .Locations import get_locations_by_tags
+from .Options import Goal
+
+if typing.TYPE_CHECKING:
+ from . import GauntletLegendsWorld
+
+
+def set_rules(world: "GauntletLegendsWorld"):
+ for location in get_locations_by_tags("no_obelisks") + (get_locations_by_tags("obelisk") if world.options.obelisks else []):
+ for item in obelisks:
+ if location.name not in world.disabled_locations:
+ forbid_item(world.get_location(location.name), item, world.player)
+
+ for location in get_locations_by_tags("no_spawner"):
+ for item in spawner_trap_ids:
+ if location.name not in world.disabled_locations:
+ forbid_item(world.get_location(location.name), items_by_id[item].item_name, world.player)
+
+ if not world.options.instant_max:
+ for level_id, locations in level_locations.items():
+ for location in locations:
+ if location.difficulty > 1:
+ if location.name not in world.disabled_locations:
+ if location.name not in world.disabled_locations:
+ level_id_ = level_id >> 4
+ difficulty = location.difficulty - 1
+ if world.options.portals:
+ expected_count = difficulty_lambda[level_id_][difficulty] - (
+ len(world.excluded_regions) * 4)
+ else:
+ expected_count = difficulty_lambda_no_portal[level_id_][difficulty] - (
+ len(world.excluded_regions) * 4)
+ expected_count = max(expected_count, 0)
+ add_rule(
+ world.get_location(location.name),
+ lambda state, expected_count_=expected_count: state.has("progression", world.player,
+ expected_count)
+ )
+
+
+def goal_conditions(world: "GauntletLegendsWorld") -> CollectionRule:
+ if world.options.goal == Goal.option_defeat_skorne:
+ return lambda state: state.can_reach("Gates of the Underworld", "Region", world.player)
+
+ eligible_boss_regions = [
+ boss for boss in boss_regions
+ if boss not in [
+ level for region, levels in excluded_levels.items()
+ if region in world.excluded_regions for level in levels
+ ]
+ ]
+ needed_boss_count = world.options.boss_goal_count.value
+
+ return lambda state: sum(state.can_reach_region(boss, world.player) for boss in eligible_boss_regions) >= needed_boss_count
diff --git a/worlds/gl/__init__.py b/worlds/gl/__init__.py
new file mode 100644
index 00000000000..951aac57e6f
--- /dev/null
+++ b/worlds/gl/__init__.py
@@ -0,0 +1,346 @@
+import os
+
+import settings
+
+from BaseClasses import ItemClassification, Tutorial, Item, CollectionState
+from Fill import fast_fill
+from typing import ClassVar
+
+from worlds.AutoWorld import WebWorld, World
+
+from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch
+from .Data import obelisks, mirror_shards, portals, excluded_portals, \
+ excluded_obelisks, level_locations
+from .Items import GLItem, item_table, item_list, gauntlet_item_name_groups
+from .Locations import LocationData, all_locations, location_table, get_locations_by_tags, locationName_to_data
+from .Options import GLOptions, IncludedAreas, IncludedTraps
+from .Regions import connect_regions, create_regions
+from .Rom import GLProcedurePatch, write_files
+from .Rules import set_rules, goal_conditions
+
+
+def launch_client(*args):
+ from .GauntletLegendsClient import launch as launch_glclient
+ launch(launch_glclient, name="GauntletLegendsClient", args=args)
+
+
+components.append(
+ Component(
+ "Gauntlet Legends Client",
+ func=launch_client,
+ component_type=Type.CLIENT,
+ file_identifier=SuffixIdentifier(".apgl"),
+ ),
+)
+
+
+class GauntletLegendsWebWorld(WebWorld):
+ theme = "partyTime"
+ tutorials = [
+ Tutorial(
+ tutorial_name="Setup Guide",
+ description="A guide to playing Gauntlet Legends",
+ language="English",
+ file_name="setup_en.md",
+ link="setup/en",
+ authors=["jamesbrq"],
+ ),
+ ]
+
+
+class GLSettings(settings.Group):
+ class RetorarchPath(settings.UserFolderPath):
+ """The location of your Retroarch folder"""
+ description = "Retroarch Folder"
+
+ class RomFile(settings.UserFilePath):
+ """File name of the GL US rom"""
+ copy_to = "Gauntlet Legends (U) [!].z64"
+ description = "Gauntlet Legends ROM File"
+ md5s = ["9cb963e8b71f18568f78ec1af120362e"]
+
+ retroarch_path: RetorarchPath = RetorarchPath(None)
+ rom_file: RomFile = RomFile(RomFile.copy_to)
+ rom_start: bool = True
+
+
+class GauntletLegendsWorld(World):
+ """
+ Adventure through the 5 realms to collect 13 runestones
+ and defeat the evil skorne. Treasure, enemies, and death
+ awaits.
+ """
+
+ game = "Gauntlet Legends"
+ web = GauntletLegendsWebWorld()
+ options_dataclass = GLOptions
+ options: GLOptions
+ settings: ClassVar[GLSettings]
+ item_name_groups = gauntlet_item_name_groups
+ item_name_to_id = {name: data.id for name, data in item_table.items()}
+ location_name_to_id = {loc_data.name: loc_data.id for loc_data in all_locations}
+ excluded_regions: set
+ items: list[Item]
+ unlockable: set
+
+ disabled_locations: set[str]
+
+ def generate_early(self) -> None:
+ self.disabled_locations = set()
+ self.excluded_regions = set()
+ self.unlockable = set()
+ self.items = []
+
+ def create_regions(self) -> None:
+ if self.options.chests_barrels == "none":
+ self.disabled_locations.update([
+ location.name
+ for location in all_locations
+ if "Chest" in location.name or ("Barrel" in location.name and "Barrel of Gold" not in location.name)
+ ])
+ elif self.options.chests_barrels == "all_chests":
+ self.disabled_locations.update([
+ location.name
+ for location in all_locations
+ if "Barrel" in location.name and "Barrel of Gold" not in location.name
+ ])
+ elif self.options.chests_barrels == "all_barrels":
+ self.disabled_locations.update([location.name for location in all_locations if "Chest" in location.name])
+
+ self.disabled_locations.update([location.name for location in all_locations
+ if location.difficulty > self.options.max_difficulty])
+ self.excluded_regions.update([region for region in IncludedAreas.valid_keys if region not in self.options.included_areas.value])
+ self.options.boss_goal_count.value = min(self.options.boss_goal_count.value,
+ 6 - len([region for region in self.excluded_regions if region != "Battlefield"]))
+
+ create_regions(self)
+ connect_regions(self)
+ if not self.options.infinite_keys:
+ self.lock_item("Valley of Fire - Key 1", "Key")
+ self.lock_item("Valley of Fire - Key 5", "Key")
+
+ if not self.options.obelisks:
+ self.lock_item("Valley of Fire - Obelisk", "Valley of Fire Obelisk")
+ self.lock_item("Dagger Peak - Obelisk", "Dagger Peak Obelisk")
+ self.lock_item("Cliffs of Desolation - Obelisk", "Cliffs of Desolation Obelisk")
+ self.lock_item("Castle Courtyard - Obelisk", "Castle Courtyard Obelisk")
+ self.lock_item("Dungeon of Torment - Obelisk", "Dungeon of Torment Obelisk")
+ self.lock_item("Poisoned Fields - Obelisk", "Poisoned Fields Obelisk")
+ self.lock_item("Haunted Cemetery - Obelisk", "Haunted Cemetery Obelisk")
+
+ if not self.options.mirror_shards:
+ self.lock_item("Dragon's Lair - Dragon Mirror Shard", "Dragon Mirror Shard")
+ self.lock_item("Chimera's Keep - Chimera Mirror Shard", "Chimera Mirror Shard")
+ self.lock_item("Vat of the Plague Fiend - Plague Fiend Mirror Shard", "Plague Fiend Mirror Shard")
+ self.lock_item("Yeti's Cavern - Yeti Mirror Shard", "Yeti Mirror Shard")
+
+ def fill_slot_data(self) -> dict:
+ characters = [
+ self.options.unlock_character_one.value,
+ self.options.unlock_character_two.value,
+ self.options.unlock_character_three.value,
+ self.options.unlock_character_four.value,
+ ]
+ chests_barrels = self.options.chests_barrels.value
+ return {
+ "chests": int(chests_barrels == 3 or chests_barrels == 1),
+ "barrels": int(chests_barrels == 3 or chests_barrels == 2),
+ "speed": self.options.permanent_speed.value,
+ "keys": self.options.infinite_keys.value,
+ "characters": characters,
+ "max": self.options.max_difficulty.value,
+ "instant_max": self.options.instant_max.value,
+ "death_link": self.options.death_link.value,
+ "portals": self.options.portals.value,
+ "included_areas": [area for area in IncludedAreas.valid_keys if area in self.options.included_areas.value],
+ "mirror_shards": self.options.mirror_shards.value,
+ "obelisks": self.options.obelisks.value,
+ "goal": self.options.goal.value,
+ "boss_goal_count": self.options.boss_goal_count.value,
+ }
+
+ def create_items(self) -> None:
+ # First add in all progression and useful items
+ required_items = []
+ precollected = [item for item in item_list if item.item_name in [item.name for item in self.multiworld.precollected_items[self.player]]]
+ skipped_items = set()
+ items_required_count = len(self.multiworld.get_unfilled_locations(self.player))
+ if self.options.infinite_keys:
+ skipped_items.add("Key")
+ if self.options.permanent_speed:
+ skipped_items.add("Speed Boots")
+ skipped_items.update([item for item in IncludedTraps.valid_keys if item not in self.options.included_traps.value])
+ if not self.options.obelisks:
+ skipped_items.update([obelisk for obelisk in obelisks if obelisk not in self.unlockable])
+ if not self.options.mirror_shards:
+ skipped_items.update([shard for shard in mirror_shards if shard not in self.unlockable])
+ if not self.options.portals:
+ skipped_items.update([item for item in portals.keys()])
+ if len(self.excluded_regions) > 0:
+ for region in self.excluded_regions:
+ skipped_items.update(excluded_portals.get(region, []))
+ skipped_items.update(excluded_obelisks.get(region, []))
+ for item in [item_ for item_ in item_list
+ if (ItemClassification.progression in item_.progression
+ or ItemClassification.useful in item_.progression)
+ and item_.item_name not in precollected
+ and item_.item_name not in skipped_items]:
+ freq = item.frequency
+ required_items += [item.item_name for _ in range(freq)]
+ items_required_count -= freq
+
+ self.multiworld.itempool += [self.create_item(item_name) for item_name in required_items]
+
+ # Then, get a random amount of fillers until we have as many items as we have locations
+ filler_items = []
+ for item in [item_ for item_ in item_list
+ if ItemClassification.progression not in item_.progression
+ and ItemClassification.useful not in item_.progression
+ and item_.item_name not in skipped_items]:
+
+ freq = item.frequency
+ if item.item_name == "Anti-Death Halo" and "Death" not in skipped_items and self.options.traps_frequency.value >= 30:
+ freq *= 2
+
+ filler_items += [item.item_name for _ in range(freq)]
+ self.random.shuffle(filler_items)
+
+ if len(self.options.included_traps.value) != 0:
+ traps_cap = items_required_count - len(
+ [location for location in self.multiworld.get_unfilled_locations(self.player) if
+ "no_spawner" in locationName_to_data[location.name].tags])
+ traps_frequency = int(len(self.get_locations()) * (self.options.traps_frequency / 100)) // len(self.options.included_traps.value)
+ traps_frequency = min(traps_frequency, traps_cap // len(self.options.included_traps.value))
+ for item in self.options.included_traps.value:
+ if items_required_count == 0:
+ break
+ self.multiworld.itempool += [self.create_item(item) for _ in range(min(traps_frequency, items_required_count))]
+ items_required_count = max(items_required_count - traps_frequency, 0)
+
+ for i in range(items_required_count):
+ if i < int(items_required_count * (self.options.local_filler_frequency / 100)):
+ if self.multiworld.players > 1:
+ self.items.append(self.create_item(filler_items.pop()))
+ else:
+ self.multiworld.itempool.append(self.create_item(filler_items.pop()))
+ else:
+ self.multiworld.itempool.append(self.create_item(filler_items.pop()))
+
+
+ def set_rules(self) -> None:
+ set_rules(self)
+ self.multiworld.completion_condition[self.player] = goal_conditions(self)
+
+ def pre_fill(self) -> None:
+ local_item_count = len(self.items)
+ unfilled_locations = self.multiworld.get_unfilled_locations(self.player)
+ unfilled_names = {loc.name for loc in unfilled_locations}
+ skipped = get_locations_by_tags("skipped_local")
+ no_obelisk_locs = get_locations_by_tags("no_obelisks")
+
+ # Group available locations by level
+ level_locs = {}
+ for level_id, level in level_locations.items():
+ if level_id & 0x8 == 0x8:
+ continue
+ available = [loc for loc in level if loc.name in unfilled_names and loc.name not in skipped]
+ if available:
+ level_locs[level_id] = sorted(available, key=lambda l: l.difficulty, reverse=True)
+
+ all_available = [loc for locs in level_locs.values() for loc in locs]
+
+ # Calculate target per level, ensuring we don't fill entire regions
+ target_per_level = (local_item_count // len(level_locs)) + 2 if level_locs else 0
+ local_locations = []
+
+ for level_id, locs in level_locs.items():
+ # Leave at least 1 location unfilled per region if possible
+ max_from_level = max(1, len(locs) - 1) if len(locs) > 1 else len(locs)
+ count = min(target_per_level, max_from_level)
+
+ # Split into harder/easier halves
+ mid = len(locs) // 2
+ harder, easier = locs[:mid], locs[mid:]
+ self.random.shuffle(harder)
+ self.random.shuffle(easier)
+
+ # Take 60% from harder, 40% from easier
+ harder_count = int(count * 0.6)
+ easier_count = count - harder_count
+ local_locations.extend(self.get_location(l.name) for l in harder[:harder_count])
+ local_locations.extend(self.get_location(l.name) for l in easier[:easier_count])
+
+ # If still need more, pull from levels with most remaining availability
+ if len(local_locations) < local_item_count:
+ used_names = {loc.name for loc in local_locations}
+ remaining = []
+ for level_id, locs in level_locs.items():
+ available = [l for l in locs if l.name not in used_names]
+ for loc in available:
+ remaining.append((loc, len(available)))
+
+ remaining.sort(key=lambda x: x[1], reverse=True)
+ needed = local_item_count - len(local_locations)
+ local_locations.extend(self.get_location(loc.name) for loc, _ in remaining[:needed])
+
+ # Ensure obelisk accessibility
+ used_names = {loc.name for loc in local_locations}
+ remaining_unfilled = [loc.name for loc in all_available if loc.name not in used_names]
+
+ if remaining_unfilled and all(name in no_obelisk_locs for name in remaining_unfilled):
+ chosen_no_obelisk = [loc for loc in local_locations if loc.name in no_obelisk_locs]
+ unchosen_obelisk_ok = [loc for loc in all_available
+ if loc.name not in used_names and loc.name not in no_obelisk_locs]
+
+ swap_count = min(len(chosen_no_obelisk) // 2, len(unchosen_obelisk_ok))
+ if swap_count > 0:
+ self.random.shuffle(chosen_no_obelisk)
+ self.random.shuffle(unchosen_obelisk_ok)
+
+ for i in range(swap_count):
+ local_locations.remove(chosen_no_obelisk[i])
+ local_locations.append(self.get_location(unchosen_obelisk_ok[i].name))
+
+ # Final shuffle and fill
+ local_locations = local_locations[:local_item_count]
+ self.random.shuffle(self.items)
+ self.random.shuffle(local_locations)
+ fast_fill(self.multiworld, self.items, local_locations)
+
+ def create_item(self, name: str) -> GLItem:
+ item = item_table[name]
+ return GLItem(item.item_name, item.progression, item.id, self.player)
+
+ def lock_item(self, location: str, item_name: str) -> None:
+ item = self.create_item(item_name)
+ if location in self.disabled_locations:
+ self.unlockable.update({item_name})
+ return
+ self.get_location(location).place_locked_item(item)
+
+ def collect(self, state: "CollectionState", item: "Item") -> bool:
+ change = super().collect(state, item)
+ if change and "Runestone" in item.name:
+ state.prog_items[item.player]["stones"] += 1
+ if change and (ItemClassification.progression in item.classification):
+ state.prog_items[item.player]["progression"] += 1
+ return change
+
+ def remove(self, state: "CollectionState", item: "Item") -> bool:
+ change = super().remove(state, item)
+ if change and "Runestone" in item.name:
+ state.prog_items[item.player]["stones"] -= 1
+ if change and (ItemClassification.progression in item.classification):
+ state.prog_items[item.player]["progression"] -= 1
+ return change
+
+ def get_filler_item_name(self) -> str:
+ return self.random.choice(list(filter(lambda item: item.progression == ItemClassification.filler, item_list))).item_name
+
+ def generate_output(self, output_directory: str) -> None:
+ patch = GLProcedurePatch(player=self.player, player_name=self.player_name)
+ write_files(self, patch)
+ rom_path = os.path.join(
+ output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}{patch.patch_file_ending}",
+ )
+ patch.write(rom_path)
diff --git a/worlds/gl/archipelago.json b/worlds/gl/archipelago.json
new file mode 100644
index 00000000000..abee7fd1620
--- /dev/null
+++ b/worlds/gl/archipelago.json
@@ -0,0 +1,6 @@
+{
+ "game": "Gauntlet Legends",
+ "authors": [ "jamesbrq" ],
+ "minimum_ap_version": "0.6.6",
+ "world_version": "2.1.7"
+}
diff --git a/worlds/gl/data/basepatch.bsdiff4 b/worlds/gl/data/basepatch.bsdiff4
new file mode 100644
index 00000000000..de0caba0dd1
Binary files /dev/null and b/worlds/gl/data/basepatch.bsdiff4 differ
diff --git a/worlds/gl/docs/en_Gauntlet Legends.md b/worlds/gl/docs/en_Gauntlet Legends.md
new file mode 100644
index 00000000000..da8dd8c1f6d
--- /dev/null
+++ b/worlds/gl/docs/en_Gauntlet Legends.md
@@ -0,0 +1,55 @@
+# Gauntlet Legends
+
+## Where is the options page?
+
+The [player options page for this game](../player-options) contains all the options you need to configure and
+export a config file.
+
+## What does randomization do to this game?
+
+Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game is
+always able to be completed, but because of the item shuffle, the player may need to access certain areas before they
+would in the vanilla game.
+
+## What is the goal of Gauntlet Legends when randomized?
+
+Defeat Skorne in the Underworld. This requires gathering all 13 Runestones to gain access to the Underworld, and being strong enough to beat Skorne.
+
+Along the way, players will need to activate Obelisks to unlock new areas for them to explore.
+They may also need to collect Mirror Shards, which will unlock a 5th area to explore as well as the Cathedral level.
+
+An alternate goal is to defeat a certain number of unique bosses at the end of each area, which can be configured in the options.
+
+## What items and locations can get shuffled?
+
+Locations in which items can be found:
+- All in-stage items
+- All Chests
+- All Barrels
+- All Runestones
+- All Obelisks
+- All Mirror Shards
+
+Items that can be placed into locations:
+- Keys
+- Potions
+- All consumable items
+- Runestones
+- Obelisks
+- Mirror Shards
+- Level Portals
+- Death
+- Golems
+- Goblin Shooters, Bomb Throwers, and Bomb Runners
+
+The randomizer also includes new portal items, which can be used as an alternate way to unlock new levels,
+and add an extra layer of progression to the game.
+
+## What does another world's item look like in Gauntlet Legends?
+
+Items will show up as the Archipelago Logo, both as items and in chests or barrels.
+
+## When the player receives an item, what happens?
+
+Items will be placed directly into the players inventory.
+If the player either leaves a stage midway or dies, the items they received mid-level will be given back to them in the lobby.
diff --git a/worlds/gl/docs/setup_en.md b/worlds/gl/docs/setup_en.md
new file mode 100644
index 00000000000..f8b420c7b16
--- /dev/null
+++ b/worlds/gl/docs/setup_en.md
@@ -0,0 +1,58 @@
+# Setup Guide for Gauntlet Legends in Archipelago
+
+## Required Software
+
+- Retroarch: [Standalone](https://www.retroarch.com/?page=platforms), [Steam](https://store.steampowered.com/app/1118310/RetroArch/)
+- The built-in Gauntlet Legends Client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases)
+- A US copy of Gauntlet Legends for the N64
+
+## Configuring Retroarch
+
+### Enabling Network Commands
+
+You must go to Settings -> User Interface and turn the Show Advanced Settings to On.
+Then in Settings -> Network, you must also turn On Network Commands.
+Leave the port as the default.
+
+### Selecting a Core
+
+When selecting a core, make sure to select Mupen64-plus-next core.
+
+## Configuring your YAML file
+
+### What is a YAML file and why do I need one?
+
+Your YAML file contains a set of configuration options which provide the generator with information about how it should
+generate your game. Each player of a multiworld will provide their own YAML file. This setup allows each player to enjoy
+an experience customized for their taste, and different players in the same multiworld can all have different options.
+
+### Where do I get a YAML file?
+
+You can customize your options by visiting the
+[Gauntlet Legends Options Page](/games/Gauntlet%20Legends/player-options)
+
+## Joining a MultiWorld Game
+
+### Obtain your N64 patch file
+
+When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting.
+The host generating does not need a copy of Gauntlet Legends to be able to generate with a Gauntlet Legends yaml.
+
+Once that is done, the host will provide you with either a link to download your data file,
+or with a zip file containing everyone's data files. Your data file should have a `.apgl` extension.
+
+
+Double-click on your `.apgl` file to start the ROM patch process. Once the process is finished, the client will be started automatically.
+
+### Connect to the Multiserver
+
+This game uses its own custom client, named Gauntlet Legends Client.
+Retroarch is only emulator this client will accept.
+
+Once both the client and the emulator are started, you must connect them. Once your ROM is open in Retroarch,
+as long as the client is open they will be connected to each other.
+
+To connect the client to the multiserver simply put `:` on the textfield on top and press enter (if the
+server uses password, type in the bottom textfield `/connect : [password]`).
+
+The client will prompt you for your slot name, once you enter and submit it, you will be connected to the lobby.
diff --git a/worlds/gl/json/items.json b/worlds/gl/json/items.json
new file mode 100644
index 00000000000..003cd410265
--- /dev/null
+++ b/worlds/gl/json/items.json
@@ -0,0 +1,574 @@
+[
+ {
+ "id": 77780000,
+ "item_name": "Key",
+ "progression": 0,
+ "rom_id": "0x0000",
+ "frequency": 700
+ },
+ {
+ "id": 77780001,
+ "item_name": "Lightning Potion",
+ "progression": 0,
+ "rom_id": "0x0101",
+ "frequency": 30
+ },
+ {
+ "id": 77780002,
+ "item_name": "Light Potion",
+ "progression": 0,
+ "rom_id": "0x0102",
+ "frequency": 30
+ },
+ {
+ "id": 77780003,
+ "item_name": "Acid Potion",
+ "progression": 0,
+ "rom_id": "0x0103",
+ "frequency": 30
+ },
+ {
+ "id": 77780004,
+ "item_name": "Fire Potion",
+ "progression": 0,
+ "rom_id": "0x0104",
+ "frequency": 30
+ },
+ {
+ "id": 77780005,
+ "item_name": "Acid Breath",
+ "progression": 0,
+ "rom_id": "0x0212",
+ "frequency": 20
+ },
+ {
+ "id": 77780006,
+ "item_name": "Lightning Breath",
+ "progression": 0,
+ "rom_id": "0x0211",
+ "frequency": 20
+ },
+ {
+ "id": 77780007,
+ "item_name": "Fire Breath",
+ "progression": 0,
+ "rom_id": "0x0210",
+ "frequency": 20
+ },
+ {
+ "id": 77780008,
+ "item_name": "Light Amulet",
+ "progression": 0,
+ "rom_id": "0x0209",
+ "frequency": 20
+ },
+ {
+ "id": 77780009,
+ "item_name": "Acid Amulet",
+ "progression": 0,
+ "rom_id": "0x020A",
+ "frequency": 20
+ },
+ {
+ "id": 77780010,
+ "item_name": "Lightning Amulet",
+ "progression": 0,
+ "rom_id": "0x0208",
+ "frequency": 20
+ },
+ {
+ "id": 77780011,
+ "item_name": "Fire Amulet",
+ "progression": 0,
+ "rom_id": "0x0207",
+ "frequency": 20
+ },
+ {
+ "id": 77780012,
+ "item_name": "Lightning Shield",
+ "progression": 0,
+ "rom_id": "0x0218",
+ "frequency": 15
+ },
+ {
+ "id": 77780013,
+ "item_name": "Fire Shield",
+ "progression": 0,
+ "rom_id": "0x0217",
+ "frequency": 15
+ },
+ {
+ "id": 77780014,
+ "item_name": "Invisibility",
+ "progression": 0,
+ "rom_id": "0x0205",
+ "frequency": 15
+ },
+ {
+ "id": 77780015,
+ "item_name": "Levitate",
+ "progression": 0,
+ "rom_id": "0x021C",
+ "frequency": 10
+ },
+ {
+ "id": 77780016,
+ "item_name": "Speed Boots",
+ "progression": 0,
+ "rom_id": "0x0200",
+ "frequency": 30
+ },
+ {
+ "id": 77780017,
+ "item_name": "3-Way Shot",
+ "progression": 0,
+ "rom_id": "0x0204",
+ "frequency": 15
+ },
+ {
+ "id": 77780018,
+ "item_name": "5-Way Shot",
+ "progression": 0,
+ "rom_id": "0x020E",
+ "frequency": 15
+ },
+ {
+ "id": 77780019,
+ "item_name": "Rapid Fire",
+ "progression": 0,
+ "rom_id": "0x021A",
+ "frequency": 15
+ },
+ {
+ "id": 77780020,
+ "item_name": "Reflective Shot",
+ "progression": 0,
+ "rom_id": "0x0202",
+ "frequency": 10
+ },
+ {
+ "id": 77780021,
+ "item_name": "Reflective Shield",
+ "progression": 0,
+ "rom_id": "0x0216",
+ "frequency": 10
+ },
+ {
+ "id": 77780022,
+ "item_name": "Super Shot",
+ "progression": 0,
+ "rom_id": "0x0203",
+ "frequency": 15
+ },
+ {
+ "id": 77780023,
+ "item_name": "Timestop",
+ "progression": 0,
+ "rom_id": "0x020C",
+ "frequency": 15
+ },
+ {
+ "id": 77780024,
+ "item_name": "Phoenix Familiar",
+ "progression": 0,
+ "rom_id": "0x0213",
+ "frequency": 20
+ },
+ {
+ "id": 77780025,
+ "item_name": "Growth",
+ "progression": 0,
+ "rom_id": "0x0214",
+ "frequency": 20
+ },
+ {
+ "id": 77780026,
+ "item_name": "Shrink",
+ "progression": 0,
+ "rom_id": "0x0215",
+ "frequency": 15
+ },
+ {
+ "id": 77780027,
+ "item_name": "Thunder Hammer",
+ "progression": 0,
+ "rom_id": "0x0219",
+ "frequency": 15
+ },
+ {
+ "id": 77780028,
+ "item_name": "Anti-Death Halo",
+ "progression": 0,
+ "rom_id": "0x020D",
+ "frequency": 15
+ },
+ {
+ "id": 77780029,
+ "item_name": "Invulnerability",
+ "progression": 0,
+ "rom_id": "0x0206",
+ "frequency": 15
+ },
+ {
+ "id": 77780030,
+ "item_name": "Fruit",
+ "progression": 0,
+ "rom_id": "0x0401",
+ "frequency": 200
+ },
+ {
+ "id": 77780031,
+ "item_name": "Meat",
+ "progression": 0,
+ "rom_id": "0x0403",
+ "frequency": 150
+ },
+ {
+ "id": 77780032,
+ "item_name": "Runestone 1",
+ "progression": 1,
+ "rom_id": "0x1501"
+ },
+ {
+ "id": 77780033,
+ "item_name": "Runestone 2",
+ "progression": 1,
+ "rom_id": "0x1502"
+ },
+ {
+ "id": 77780034,
+ "item_name": "Runestone 3",
+ "progression": 1,
+ "rom_id": "0x1503"
+ },
+ {
+ "id": 77780035,
+ "item_name": "Runestone 4",
+ "progression": 1,
+ "rom_id": "0x1504"
+ },
+ {
+ "id": 77780036,
+ "item_name": "Runestone 5",
+ "progression": 1,
+ "rom_id": "0x1505"
+ },
+ {
+ "id": 77780037,
+ "item_name": "Runestone 6",
+ "progression": 1,
+ "rom_id": "0x1506"
+ },
+ {
+ "id": 77780038,
+ "item_name": "Runestone 7",
+ "progression": 1,
+ "rom_id": "0x1507"
+ },
+ {
+ "id": 77780039,
+ "item_name": "Runestone 8",
+ "progression": 1,
+ "rom_id": "0x1508"
+ },
+ {
+ "id": 77780040,
+ "item_name": "Runestone 9",
+ "progression": 1,
+ "rom_id": "0x1509"
+ },
+ {
+ "id": 77780041,
+ "item_name": "Runestone 10",
+ "progression": 1,
+ "rom_id": "0x150A"
+ },
+ {
+ "id": 77780042,
+ "item_name": "Runestone 11",
+ "progression": 1,
+ "rom_id": "0x150B"
+ },
+ {
+ "id": 77780043,
+ "item_name": "Runestone 12",
+ "progression": 1,
+ "rom_id": "0x150C"
+ },
+ {
+ "id": 77780044,
+ "item_name": "Runestone 13",
+ "progression": 1,
+ "rom_id": "0x150D"
+ },
+ {
+ "id": 77780045,
+ "item_name": "Dragon Mirror Shard",
+ "progression": 1,
+ "rom_id": "0x2B01"
+ },
+ {
+ "id": 77780046,
+ "item_name": "Yeti Mirror Shard",
+ "progression": 1,
+ "rom_id": "0x2B02"
+ },
+ {
+ "id": 77780047,
+ "item_name": "Chimera Mirror Shard",
+ "progression": 1,
+ "rom_id": "0x2B03"
+ },
+ {
+ "id": 77780048,
+ "item_name": "Plague Fiend Mirror Shard",
+ "progression": 1,
+ "rom_id": "0x2B04"
+ },
+ {
+ "id": 77780049,
+ "item_name": "Ice Axe of Untar",
+ "progression": 2,
+ "rom_id": "0x2901"
+ },
+ {
+ "id": 77780050,
+ "item_name": "Flame of Tarkana",
+ "progression": 2,
+ "rom_id": "0x2902"
+ },
+ {
+ "id": 77780051,
+ "item_name": "Scimitar of Decapitation",
+ "progression": 2,
+ "rom_id": "0x2903"
+ },
+ {
+ "id": 77780052,
+ "item_name": "Marker's Javelin",
+ "progression": 2,
+ "rom_id": "0x2904"
+ },
+ {
+ "id": 77780053,
+ "item_name": "Soul Savior",
+ "progression": 2,
+ "rom_id": "0x2905"
+ },
+ {
+ "id": 77780054,
+ "item_name": "Gold",
+ "progression": 0,
+ "rom_id": "0x0302",
+ "frequency": 850
+ },
+ {
+ "id": 77780055,
+ "item_name": "Valley of Fire Obelisk",
+ "progression": 1
+ },
+ {
+ "id": 77780056,
+ "item_name": "Dagger Peak Obelisk",
+ "progression": 1
+ },
+ {
+ "id": 77780057,
+ "item_name": "Cliffs of Desolation Obelisk",
+ "progression": 1
+ },
+ {
+ "id": 77780058,
+ "item_name": "Poisoned Fields Obelisk",
+ "progression": 1
+ },
+ {
+ "id": 77780059,
+ "item_name": "Haunted Cemetery Obelisk",
+ "progression": 1
+ },
+ {
+ "id": 77780060,
+ "item_name": "Castle Courtyard Obelisk",
+ "progression": 1
+ },
+ {
+ "id": 77780061,
+ "item_name": "Dungeon of Torment Obelisk",
+ "progression": 1
+ },
+ {
+ "id": 77780062,
+ "item_name": "Death",
+ "progression": 4,
+ "rom_id": "0x2101"
+ },
+ {
+ "id": 77780063,
+ "item_name": "Poison Fruit",
+ "progression": 4,
+ "rom_id": "0x0402"
+ },
+ {
+ "id": 77780064,
+ "item_name": "Skorne's Mask",
+ "progression": 2,
+ "rom_id": "0x2A01"
+ },
+ {
+ "id": 77780065,
+ "item_name": "Skorne's Horns",
+ "progression": 2,
+ "rom_id": "0x2A02"
+ },
+ {
+ "id": 77780066,
+ "item_name": "Skorne's Right Gauntlet",
+ "progression": 2,
+ "rom_id": "0x2A03"
+ },
+ {
+ "id": 77780067,
+ "item_name": "Skorne's Left Gauntlet",
+ "progression": 2,
+ "rom_id": "0x2A04"
+ },
+ {
+ "id": 77780068,
+ "item_name": "Portal to Dagger Peak",
+ "progression": 1,
+ "rom_id": "0x2709"
+ },
+ {
+ "id": 77780069,
+ "item_name": "Portal to Cliffs of Desolation",
+ "progression": 1,
+ "rom_id": "0x270A"
+ },
+ {
+ "id": 77780070,
+ "item_name": "Portal to Lost Cave",
+ "progression": 1,
+ "rom_id": "0x270B"
+ },
+ {
+ "id": 77780071,
+ "item_name": "Portal to Volcanic Caverns",
+ "progression": 1,
+ "rom_id": "0x270C"
+ },
+ {
+ "id": 77780072,
+ "item_name": "Portal to Dragon's Lair",
+ "progression": 1,
+ "rom_id": "0x270D"
+ },
+ {
+ "id": 77780073,
+ "item_name": "Portal to Dungeon of Torment",
+ "progression": 1,
+ "rom_id": "0x270E"
+ },
+ {
+ "id": 77780074,
+ "item_name": "Portal to Tower Armory",
+ "progression": 1,
+ "rom_id": "0x270F"
+ },
+ {
+ "id": 77780075,
+ "item_name": "Portal to Castle Treasury",
+ "progression": 1,
+ "rom_id": "0x2710"
+ },
+ {
+ "id": 77780076,
+ "item_name": "Portal to Chimera's Keep",
+ "progression": 1,
+ "rom_id": "0x2711"
+ },
+ {
+ "id": 77780077,
+ "item_name": "Portal to Haunted Cemetery",
+ "progression": 1,
+ "rom_id": "0x2712"
+ },
+ {
+ "id": 77780078,
+ "item_name": "Portal to Venomous Spire",
+ "progression": 1,
+ "rom_id": "0x2713"
+ },
+ {
+ "id": 77780079,
+ "item_name": "Portal to Toxic Air Ship",
+ "progression": 1,
+ "rom_id": "0x2714"
+ },
+ {
+ "id": 77780080,
+ "item_name": "Portal to Vat of the Plague Fiend",
+ "progression": 1,
+ "rom_id": "0x2715"
+ },
+ {
+ "id": 77780081,
+ "item_name": "Portal to Frozen Camp",
+ "progression": 1,
+ "rom_id": "0x2716"
+ },
+ {
+ "id": 77780082,
+ "item_name": "Portal to Crystal Mine",
+ "progression": 1,
+ "rom_id": "0x2717"
+ },
+ {
+ "id": 77780083,
+ "item_name": "Portal to Erupting Fissure",
+ "progression": 1,
+ "rom_id": "0x2718"
+ },
+ {
+ "id": 77780084,
+ "item_name": "Portal to Yeti's Cavern",
+ "progression": 1,
+ "rom_id": "0x2719"
+ },
+ {
+ "id": 77780085,
+ "item_name": "Portal to Fortified Towers",
+ "progression": 1,
+ "rom_id": "0x271A"
+ },
+ {
+ "id": 77780086,
+ "item_name": "Portal to Infernal Fortress",
+ "progression": 1,
+ "rom_id": "0x271B"
+ },
+ {
+ "id": 77780087,
+ "item_name": "Crossbow Shooter",
+ "progression": 4,
+ "rom_id": "0x05030411"
+ },
+ {
+ "id": 77780088,
+ "item_name": "Bomb Thrower",
+ "progression": 4,
+ "rom_id": "0x05030511"
+ },
+ {
+ "id": 77780089,
+ "item_name": "Bomb Runner",
+ "progression": 4,
+ "rom_id": "0x05030612"
+ },
+ {
+ "id": 77780090,
+ "item_name": "Golem",
+ "progression": 4,
+ "rom_id": "0x05080113"
+ }
+]
\ No newline at end of file
diff --git a/worlds/gl/json/locations.json b/worlds/gl/json/locations.json
new file mode 100644
index 00000000000..0d72983d3fa
--- /dev/null
+++ b/worlds/gl/json/locations.json
@@ -0,0 +1,13745 @@
+[
+ {
+ "name": "Valley of Fire - Scroll",
+ "id": 88870001,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 1",
+ "id": 88870002,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire",
+ "skipped_local"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 2",
+ "id": 88870003,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 3 (Dif. 3)",
+ "id": 88870004,
+ "difficulty": 3,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 4",
+ "id": 88870005,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Fire Amulet",
+ "id": 88870006,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 5",
+ "id": 88870007,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire",
+ "skipped_local"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 6 (Dif. 2)",
+ "id": 88870008,
+ "difficulty": 2,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 7",
+ "id": 88870009,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Fruit 1 (Dif. 2)",
+ "id": 88870010,
+ "difficulty": 2,
+ "tags": [
+ "valley_of_fire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 8 (Dif. 3)",
+ "id": 88870011,
+ "difficulty": 3,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 9 (Dif. 2)",
+ "id": 88870012,
+ "difficulty": 2,
+ "tags": [
+ "valley_of_fire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Fire Potion 1 (Dif. 3)",
+ "id": 88870013,
+ "difficulty": 3,
+ "tags": [
+ "valley_of_fire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Thunder Hammer (Dif. 2)",
+ "id": 88870014,
+ "difficulty": 2,
+ "tags": [
+ "valley_of_fire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Fire Potion 2 (Dif. 4)",
+ "id": 88870015,
+ "difficulty": 4,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Large Gold Pile (Dif. 3)",
+ "id": 88870016,
+ "difficulty": 3,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Small Gold Pile 1 (Dif. 4)",
+ "id": 88870017,
+ "difficulty": 4,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Meat Slab (Dif. 4)",
+ "id": 88870018,
+ "difficulty": 4,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Drumstick (Dif. 3)",
+ "id": 88870019,
+ "difficulty": 3,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Fruit 2 (Dif. 4)",
+ "id": 88870020,
+ "difficulty": 4,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Key 10",
+ "id": 88870021,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Small Gold Pile 2 (Dif. 2)",
+ "id": 88870022,
+ "difficulty": 2,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire - Obelisk",
+ "id": 88870611,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire",
+ "obelisk",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Nothing 1",
+ "id": 88873500,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - Meat 1 (Dif. 2)",
+ "id": 88873501,
+ "difficulty": 2,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - Potion 1",
+ "id": 88873502,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - Potion 2 (Dif. 2)",
+ "id": 88873503,
+ "difficulty": 2,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - Invisibility 1 (Dif. 3)",
+ "id": 88873504,
+ "difficulty": 3,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - Gold 1",
+ "id": 88873505,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - Random Chest 1 (Dif. 3)",
+ "id": 88873506,
+ "difficulty": 3,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - Gold 2",
+ "id": 88873507,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - Fruit 1",
+ "id": 88873508,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Nothing 2",
+ "id": 88873509,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Gold 1",
+ "id": 88873510,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Fruit 1 (Dif. 3)",
+ "id": 88873511,
+ "difficulty": 3,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Chest - 3-Way Shot 1 (Dif. 4)",
+ "id": 88873512,
+ "difficulty": 4,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Nothing 3",
+ "id": 88873513,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Scroll 1",
+ "id": 88873514,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Nothing 4",
+ "id": 88873515,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Key 1 (Dif. 4)",
+ "id": 88873516,
+ "difficulty": 4,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Speed Boots 1",
+ "id": 88873517,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Fire Breath 1",
+ "id": 88873518,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Key 2",
+ "id": 88873519,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Valley of Fire Barrel - Potion 1",
+ "id": 88873520,
+ "difficulty": 1,
+ "tags": [
+ "valley_of_fire"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Growth",
+ "id": 88870023,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Runestone",
+ "id": 88870024,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 1",
+ "id": 88870025,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Speed Boots (Dif. 2)",
+ "id": 88870026,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 2",
+ "id": 88870027,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 3",
+ "id": 88870028,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 4 (Dif. 4)",
+ "id": 88870029,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 5 (Dif. 4)",
+ "id": 88870030,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 6 (Dif. 3)",
+ "id": 88870031,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Large Pile of Gold 1 (Dif. 2)",
+ "id": 88870032,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Large Pile of Gold 2 (Dif. 3)",
+ "id": 88870033,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Full Barrel of Gold 1 (Dif. 4)",
+ "id": 88870034,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Small Pile of Gold 1 (Dif. 2)",
+ "id": 88870035,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Small Pile of Gold 2 (Dif. 3)",
+ "id": 88870036,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Small Pile of Gold 3",
+ "id": 88870037,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Fire Potion 1",
+ "id": 88870038,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Fruit",
+ "id": 88870039,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 7 (Dif. 3)",
+ "id": 88870040,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 8 (Dif. 4)",
+ "id": 88870041,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Half Barrel of Gold 1 (Dif. 3)",
+ "id": 88870042,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 9 (Dif. 2)",
+ "id": 88870043,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Fire Potion 2 (Dif. 3)",
+ "id": 88870044,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Poison Fruit (Dif. 3)",
+ "id": 88870045,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 10",
+ "id": 88870046,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 11 (Dif. 3)",
+ "id": 88870047,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 12",
+ "id": 88870048,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 13",
+ "id": 88870049,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 14 (Dif. 2)",
+ "id": 88870050,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Fire Potion 3 (Dif. 2)",
+ "id": 88870051,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Meat 1 (Dif. 3)",
+ "id": 88870052,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 15",
+ "id": 88870053,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 16 (Dif. 2)",
+ "id": 88870054,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 17",
+ "id": 88870055,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Meat 2",
+ "id": 88870056,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Key 18 (Dif. 4)",
+ "id": 88870057,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dagger Peak - Obelisk",
+ "id": 88870612,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak",
+ "obelisk",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Rapid Fire 1",
+ "id": 88873600,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Key 1",
+ "id": 88873601,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Levitate 1",
+ "id": 88873602,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Fire Amulet 1 (Dif. 2)",
+ "id": 88873603,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Death 1",
+ "id": 88873604,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Fire Shield 1 (Dif. 2)",
+ "id": 88873605,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Scroll 1",
+ "id": 88873606,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Invulnerability 1 (Dif. 4)",
+ "id": 88873607,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Gold 1",
+ "id": 88873608,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Potion 1 (Dif. 4)",
+ "id": 88873609,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Gold 2",
+ "id": 88873610,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Fruit 1 (Dif. 4)",
+ "id": 88873611,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Fruit 2",
+ "id": 88873612,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Fruit 3 (Dif. 2)",
+ "id": 88873613,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Barrel - Poison Fruit 1",
+ "id": 88873614,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Scroll 1",
+ "id": 88873615,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Meat 1 (Dif. 2)",
+ "id": 88873616,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Gold 3 (Dif. 2)",
+ "id": 88873617,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - 3-Way Shot 1",
+ "id": 88873618,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Gold 4",
+ "id": 88873619,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Gold 5 (Dif. 3)",
+ "id": 88873620,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Random 1 (Dif. 2)",
+ "id": 88873621,
+ "difficulty": 2,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Gold 6 (Dif. 4)",
+ "id": 88873622,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Potion 2",
+ "id": 88873623,
+ "difficulty": 1,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Death 1 (Dif. 3)",
+ "id": 88873624,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Meat 2 (Dif. 4)",
+ "id": 88873625,
+ "difficulty": 4,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Dagger Peak Chest - Timestop 1 (Dif. 3)",
+ "id": 88873626,
+ "difficulty": 3,
+ "tags": [
+ "dagger_peak"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Scroll 1",
+ "id": 88870058,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Reflective Shot (Dif. 4)",
+ "id": 88870059,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 1 (Dif. 2)",
+ "id": 88870060,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 2 (Dif. 3)",
+ "id": 88870061,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Invisibility",
+ "id": 88870062,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Fire Potion 1 (Dif. 3)",
+ "id": 88870063,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Half Barrel of Gold 1 (Dif. 4)",
+ "id": 88870064,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Invulnerability (Dif. 4)",
+ "id": 88870065,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Rapid Fire (Dif. 3)",
+ "id": 88870066,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Half Barrel of Gold 2 (Dif. 3)",
+ "id": 88870067,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Large Pile of Gold 1",
+ "id": 88870068,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Meat Slab 1",
+ "id": 88870069,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Poison Fruit 1 (Dif. 4)",
+ "id": 88870070,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 3 (Dif. 4)",
+ "id": 88870071,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 4 (Dif. 3)",
+ "id": 88870072,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 5 (Dif. 4)",
+ "id": 88870073,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 6 (Dif. 2)",
+ "id": 88870074,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 7",
+ "id": 88870075,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Small Pile of Gold 1",
+ "id": 88870076,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Small Pile of Gold 2 (Dif. 2)",
+ "id": 88870077,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 8",
+ "id": 88870078,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 9",
+ "id": 88870079,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 10",
+ "id": 88870080,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 11 (Dif. 2)",
+ "id": 88870081,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 12 (Dif. 3)",
+ "id": 88870082,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 13 (Dif. 3)",
+ "id": 88870083,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 14",
+ "id": 88870084,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 15",
+ "id": 88870085,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 16",
+ "id": 88870086,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 17",
+ "id": 88870087,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 18 (Dif. 4)",
+ "id": 88870088,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 19",
+ "id": 88870089,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 20 (Dif. 2)",
+ "id": 88870090,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Full Barrel of Gold 1 (Dif. 4)",
+ "id": 88870091,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 21",
+ "id": 88870092,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 22 (Dif. 4)",
+ "id": 88870093,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 23",
+ "id": 88870094,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 24",
+ "id": 88870095,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 25",
+ "id": 88870096,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 26",
+ "id": 88870097,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Key 27",
+ "id": 88870098,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation - Obelisk",
+ "id": 88870613,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation",
+ "obelisk",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 1 (Dif. 3)",
+ "id": 8887370,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Death 1 (Dif. 3)",
+ "id": 8887371,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Random 1 (Dif. 2)",
+ "id": 8887372,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Shrink 1 (Dif. 2)",
+ "id": 8887373,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - 5-Way Shot 1 (Dif. 3)",
+ "id": 8887374,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Nothing 1",
+ "id": 8887375,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Nothing 2",
+ "id": 8887376,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Scroll 1",
+ "id": 8887377,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Nothing 3 (Dif. 2)",
+ "id": 8887378,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Potion 1 (Dif. 4)",
+ "id": 8887379,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Poison Fruit 1",
+ "id": 88873710,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Nothing 4 (Dif. 2)",
+ "id": 88873711,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Fruit 1",
+ "id": 88873712,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 2 (Dif. 2)",
+ "id": 88873713,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 3 (Dif. 2)",
+ "id": 88873714,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Fruit 2 (Dif. 2)",
+ "id": 88873715,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Poison Fruit 2 (Dif. 2)",
+ "id": 88873716,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 4 (Dif. 2)",
+ "id": 88873717,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 5",
+ "id": 88873718,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 6",
+ "id": 88873719,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Meat 1",
+ "id": 88873720,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Potion 2",
+ "id": 88873721,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Levitate 1 (Dif. 2)",
+ "id": 88873722,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Gold 1 (Dif. 2)",
+ "id": 88873723,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Fruit 1 (Dif. 3)",
+ "id": 88873724,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Death 1 (Dif. 4)",
+ "id": 88873725,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Gold 2 (Dif. 2)",
+ "id": 88873726,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Potion 3",
+ "id": 88873727,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Gold 3",
+ "id": 88873728,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Death 2 (Dif. 2)",
+ "id": 88873729,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Gold 4 (Dif. 4)",
+ "id": 88873730,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 7",
+ "id": 88873731,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Gold 5 (Dif. 3)",
+ "id": 88873732,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 8",
+ "id": 88873733,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Potion 4",
+ "id": 88873734,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Meat 2 (Dif. 3)",
+ "id": 88873735,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Gold 6",
+ "id": 88873736,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Death 3",
+ "id": 88873737,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Meat 3 (Dif. 4)",
+ "id": 88873738,
+ "difficulty": 4,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Potion 5 (Dif. 2)",
+ "id": 88873739,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Dark Chest - Random 1 (Dif. 3)",
+ "id": 88873740,
+ "difficulty": 3,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Speed Boots 1",
+ "id": 88873741,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Potion 6",
+ "id": 88873742,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Key 9",
+ "id": 88873743,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Meat 4 (Dif. 2)",
+ "id": 88873744,
+ "difficulty": 2,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Fire Amulet 1",
+ "id": 88873745,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Fruit 2",
+ "id": 88873746,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Barrel - Invulnerability 1",
+ "id": 88873747,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation"
+ ]
+ },
+ {
+ "name": "Cliffs of Desolation Chest - Fruit 3",
+ "id": 88873748,
+ "difficulty": 1,
+ "tags": [
+ "cliffs_of_desolation",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Scroll 1",
+ "id": 88870099,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Runestone",
+ "id": 88870100,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Very Small Pile of Gold 1 (Dif. 2)",
+ "id": 88870101,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Large Pile of Gold 1 (Dif. 4)",
+ "id": 88870102,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 1",
+ "id": 88870103,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Very Small Pile of Gold 2",
+ "id": 88870104,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Very Small Pile of Gold 3 (Dif. 3)",
+ "id": 88870105,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 2",
+ "id": 88870106,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Small Pile of Gold 1 (Dif. 2)",
+ "id": 88870107,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Half Barrel of Gold 1 (Dif. 3)",
+ "id": 88870108,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Large Pile of Gold 2",
+ "id": 88870109,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 3",
+ "id": 88870110,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 4 (Dif. 2)",
+ "id": 88870111,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Fire Potion 1",
+ "id": 88870112,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 5",
+ "id": 88870113,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 6 (Dif. 3)",
+ "id": 88870114,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Meat Slab 1 (Dif. 2)",
+ "id": 88870115,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 7 (Dif. 2)",
+ "id": 88870116,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Fruit 1 (Dif. 2)",
+ "id": 88870117,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Fire Potion 2 (Dif. 2)",
+ "id": 88870118,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Fruit 2",
+ "id": 88870119,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 8",
+ "id": 88870120,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 9",
+ "id": 88870121,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 10",
+ "id": 88870122,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 11 (Dif. 2)",
+ "id": 88870123,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Small Pile of Gold 2 (Dif. 2)",
+ "id": 88870124,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Large Pile of Gold 3 (Dif. 2)",
+ "id": 88870125,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Full Barrel of Gold 1 (Dif. 4)",
+ "id": 88870126,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Drumstick 1 (Dif. 2)",
+ "id": 88870127,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Drumstick 2 (Dif. 4)",
+ "id": 88870128,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 12 (Dif. 4)",
+ "id": 88870129,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 13 (Dif. 3)",
+ "id": 88870130,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 14 (Dif. 4)",
+ "id": 88870131,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 15 (Dif. 3)",
+ "id": 88870132,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 16 (Dif. 3)",
+ "id": 88870133,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 17",
+ "id": 88870134,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 18 (Dif. 2)",
+ "id": 88870135,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 19 (Dif. 3)",
+ "id": 88870136,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 20 (Dif. 4)",
+ "id": 88870137,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave - Fruit 3",
+ "id": 88870138,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Fruit 4 (Dif. 2)",
+ "id": 88870139,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Reflective Shot",
+ "id": 88870140,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Fire Breath",
+ "id": 88870141,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave - Key 21",
+ "id": 88870142,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Scroll 1",
+ "id": 8887380,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Invulnerability 1 (Dif. 3)",
+ "id": 8887381,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Nothing 1 (Dif. 2)",
+ "id": 8887382,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Gold 1",
+ "id": 8887383,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Gold 2 (Dif. 3)",
+ "id": 8887384,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Rapid Fire 1",
+ "id": 8887385,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Nothing 2",
+ "id": 8887386,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Speed Boots 1 (Dif. 4)",
+ "id": 8887387,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Fruit 1 (Dif. 2)",
+ "id": 8887388,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Dark Chest - Random 1",
+ "id": 8887389,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Gold 3",
+ "id": 88873810,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Fruit 1",
+ "id": 88873811,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Death 1",
+ "id": 88873812,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Potion 1",
+ "id": 88873813,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Meat 1 (Dif. 3)",
+ "id": 88873814,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Potion 1 (Dif. 3)",
+ "id": 88873815,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Potion 2 (Dif. 4)",
+ "id": 88873816,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Meat 2",
+ "id": 88873817,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Fire Amulet 1",
+ "id": 88873818,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Gold 4 (Dif. 2)",
+ "id": 88873819,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Potion 2",
+ "id": 88873820,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Fruit 2 (Dif. 3)",
+ "id": 88873821,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Meat 1",
+ "id": 88873822,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Potion 3 (Dif. 2)",
+ "id": 88873823,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Potion 4 (Dif. 4)",
+ "id": 88873824,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Potion 5 (Dif. 2)",
+ "id": 88873825,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Acid Breath 1 (Dif. 2)",
+ "id": 88873826,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Fruit 2 (Dif. 4)",
+ "id": 88873827,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Nothing 3",
+ "id": 88873828,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Poison Fruit 1 (Dif. 3)",
+ "id": 88873829,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Gold 5 (Dif. 4)",
+ "id": 88873830,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Poison Fruit 2 (Dif. 4)",
+ "id": 88873831,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Light Amulet 1 (Dif. 2)",
+ "id": 88873832,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - 3-Way Shot 1 (Dif. 2)",
+ "id": 88873833,
+ "difficulty": 2,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Reflective Shield 1 (Dif. 3)",
+ "id": 88873834,
+ "difficulty": 3,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Barrel - Super Shot 1 (Dif. 4)",
+ "id": 88873835,
+ "difficulty": 4,
+ "tags": [
+ "lost_cave"
+ ]
+ },
+ {
+ "name": "Lost Cave Chest - Gold 6",
+ "id": 88873836,
+ "difficulty": 1,
+ "tags": [
+ "lost_cave",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 1",
+ "id": 88870143,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Scimitar of Decapitation",
+ "id": 88870144,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Runestone",
+ "id": 88870145,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 2",
+ "id": 88870146,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 3 (Dif. 3)",
+ "id": 88870147,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Fire Potion 1",
+ "id": 88870148,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 4",
+ "id": 88870149,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 5",
+ "id": 88870150,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 6",
+ "id": 88870151,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 7 (Dif. 2)",
+ "id": 88870152,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 8 (Dif. 2)",
+ "id": 88870153,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 9",
+ "id": 88870154,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Large Pile of Gold 1 (Dif. 2)",
+ "id": 88870155,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Small Pile of Gold 1",
+ "id": 88870156,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Half Barrel of Gold 1 (Dif. 3)",
+ "id": 88870157,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 10",
+ "id": 88870158,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 11 (Dif. 2)",
+ "id": 88870159,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 12 (Dif. 3)",
+ "id": 88870160,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 13 (Dif. 2)",
+ "id": 88870161,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Very Small Pile of Gold 1",
+ "id": 88870162,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 14 (Dif. 2)",
+ "id": 88870163,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 15 (Dif. 3)",
+ "id": 88870164,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Full Barrel of Gold 2 (Dif. 4)",
+ "id": 88870165,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Small Pile of Gold 3",
+ "id": 88870166,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Death (Dif. 2)",
+ "id": 88870167,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 16 (Dif. 3)",
+ "id": 88870168,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 17 (Dif. 4)",
+ "id": 88870169,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 18 (Dif. 3)",
+ "id": 88870170,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 19 (Dif. 4)",
+ "id": 88870171,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Key 20 (Dif. 2)",
+ "id": 88870172,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Fire Potion 2 (Dif. 4)",
+ "id": 88870173,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - 5-Way Shot (Dif. 4)",
+ "id": 88870174,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern - Invisibility (Dif. 3)",
+ "id": 88870175,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Key 1 (Dif. 4)",
+ "id": 8887390,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Key 2 (Dif. 3)",
+ "id": 8887391,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Key 3",
+ "id": 8887392,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Lightning Amulet 1",
+ "id": 8887393,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Lightning Shield 1",
+ "id": 8887394,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Fruit 1 (Dif. 4)",
+ "id": 8887395,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Meat 1",
+ "id": 8887396,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 1 (Dif. 4)",
+ "id": 8887397,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Scroll 1",
+ "id": 8887398,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Dark Chest - Random 1 (Dif. 3)",
+ "id": 8887399,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Death 1",
+ "id": 88873910,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Fruit 1",
+ "id": 88873911,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Potion 1 (Dif. 3)",
+ "id": 88873912,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Poison Fruit 1 (Dif. 3)",
+ "id": 88873913,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Super Shot 1",
+ "id": 88873914,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 2",
+ "id": 88873915,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Poison Fruit 1",
+ "id": 88873916,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Anti-Death Halo 1 (Dif. 2)",
+ "id": 88873917,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Potion 1 (Dif. 3)",
+ "id": 88873918,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - 3-Way Shot 1",
+ "id": 88873919,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Fruit 2 (Dif. 2)",
+ "id": 88873920,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Timestop 1 (Dif. 3)",
+ "id": 88873921,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Meat 2",
+ "id": 88873922,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Potion 2 (Dif. 2)",
+ "id": 88873923,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Meat 1",
+ "id": 88873924,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Potion 2 (Dif. 4)",
+ "id": 88873925,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Fruit 3 (Dif. 2)",
+ "id": 88873926,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Fruit 4 (Dif. 3)",
+ "id": 88873927,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Scroll 1",
+ "id": 88873928,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 3",
+ "id": 88873929,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Gold 1 (Dif. 3)",
+ "id": 88873930,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Gold 1 (Dif. 4)",
+ "id": 88873931,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Gold 2",
+ "id": 88873932,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Meat 3 (Dif. 2)",
+ "id": 88873933,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Fruit 2 (Dif. 4)",
+ "id": 88873934,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Meat 2 (Dif. 3)",
+ "id": 88873935,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Potion 3",
+ "id": 88873936,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Gold 3 (Dif. 2)",
+ "id": 88873937,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Lightning Breath 1 (Dif. 2)",
+ "id": 88873938,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Fruit 5 (Dif. 3)",
+ "id": 88873939,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Speed Boots 1 (Dif. 4)",
+ "id": 88873940,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Potion 3 (Dif. 2)",
+ "id": 88873941,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 4 (Dif. 3)",
+ "id": 88873942,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Potion 4",
+ "id": 88873943,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Death 1 (Dif. 3)",
+ "id": 88873944,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Meat 3 (Dif. 4)",
+ "id": 88873945,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Chest - Phoenix Familiar 1 (Dif. 2)",
+ "id": 88873946,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 5 (Dif. 2)",
+ "id": 88873947,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 6 (Dif. 2)",
+ "id": 88873948,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 7 (Dif. 3)",
+ "id": 88873949,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 8",
+ "id": 88873950,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 9 (Dif. 4)",
+ "id": 88873951,
+ "difficulty": 4,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 10 (Dif. 2)",
+ "id": 88873952,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 11 (Dif. 3)",
+ "id": 88873953,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Scroll 2",
+ "id": 88873954,
+ "difficulty": 1,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 12 (Dif. 3)",
+ "id": 88873955,
+ "difficulty": 3,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Volcanic Cavern Barrel - Nothing 13 (Dif. 2)",
+ "id": 88873956,
+ "difficulty": 2,
+ "tags": [
+ "volcanic_cavern"
+ ]
+ },
+ {
+ "name": "Dragon's Lair - Slab of Meat (Dif. 2)",
+ "id": 88870176,
+ "difficulty": 2,
+ "tags": [
+ "dragons_lair",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Dragon's Lair - Rapid Fire (Dif. 2)",
+ "id": 88870177,
+ "difficulty": 2,
+ "tags": [
+ "dragons_lair",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Dragon's Lair - Growth (Dif. 4)",
+ "id": 88870178,
+ "difficulty": 4,
+ "tags": [
+ "dragons_lair",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Dragon's Lair - Speed Boots (Dif. 3)",
+ "id": 88870179,
+ "difficulty": 3,
+ "tags": [
+ "dragons_lair",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Dragon's Lair - 3-Way Shot",
+ "id": 88870180,
+ "difficulty": 1,
+ "tags": [
+ "dragons_lair",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Dragon's Lair - Drumstick",
+ "id": 88870181,
+ "difficulty": 1,
+ "tags": [
+ "dragons_lair",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Dragon's Lair - Dragon Mirror Shard",
+ "id": 88870607,
+ "difficulty": 1,
+ "tags": [
+ "dragons_lair",
+ "no_obelisks",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Runestone",
+ "id": 88870182,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 1",
+ "id": 88870183,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 2",
+ "id": 88870184,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 3",
+ "id": 88870185,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 4",
+ "id": 88870186,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 5",
+ "id": 88870187,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 6",
+ "id": 88870188,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 7",
+ "id": 88870189,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 8",
+ "id": 88870190,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 9",
+ "id": 88870191,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Scroll 1",
+ "id": 88870192,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Full Barrel of Gold 1 (Dif. 4)",
+ "id": 88870193,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Lightning Potion 1",
+ "id": 88870194,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 10",
+ "id": 88870195,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Lightning Potion 2 (Dif. 4)",
+ "id": 88870196,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Scroll 2",
+ "id": 88870197,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Poison Fruit 1",
+ "id": 88870198,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 11 (Dif. 2)",
+ "id": 88870199,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 12",
+ "id": 88870200,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 13 (Dif. 4)",
+ "id": 88870201,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 14 (Dif. 4)",
+ "id": 88870202,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Large Pile of Gold 1 (Dif. 2)",
+ "id": 88870203,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 15",
+ "id": 88870204,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 16 (Dif. 2)",
+ "id": 88870205,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Death 1",
+ "id": 88870206,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Death 2 (Dif. 3)",
+ "id": 88870207,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Invulnerability",
+ "id": 88870208,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Half Barrel of Gold 1 (Dif. 3)",
+ "id": 88870209,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Lightning Potion 3 (Dif. 2)",
+ "id": 88870210,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Timestop (Dif. 4)",
+ "id": 88870211,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Super Shot (Dif. 2)",
+ "id": 88870212,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Phoenix Familiar",
+ "id": 88870213,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Fire Breath (Dif. 2)",
+ "id": 88870214,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 17 (Dif. 2)",
+ "id": 88870215,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 18 (Dif. 3)",
+ "id": 88870216,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Key 19 (Dif. 4)",
+ "id": 88870217,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Lightning Shield",
+ "id": 88870218,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard - Obelisk",
+ "id": 88870616,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard",
+ "obelisk",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 1",
+ "id": 8887300,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Shrink 1 (Dif. 4)",
+ "id": 8887301,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 1 (Dif. 3)",
+ "id": 8887302,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 2",
+ "id": 8887303,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 3",
+ "id": 8887304,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Fruit 1",
+ "id": 8887305,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Potion 1",
+ "id": 8887306,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 4",
+ "id": 8887307,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 2 (Dif. 2)",
+ "id": 8887308,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Poison Fruit 1 (Dif. 2)",
+ "id": 8887309,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 5 (Dif. 2)",
+ "id": 88873010,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Gold 1 (Dif. 3)",
+ "id": 88873011,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 3 (Dif. 2)",
+ "id": 88873012,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Scroll 1",
+ "id": 88873013,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Dark Chest - Random 1 (Dif. 4)",
+ "id": 88873014,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Gold 1 (Dif. 2)",
+ "id": 88873015,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Nothing 1",
+ "id": 88873016,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Reflective Shot 1",
+ "id": 88873017,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Gold 2",
+ "id": 88873018,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Death 1 (Dif. 2)",
+ "id": 88873019,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Speed Boots 1 (Dif. 3)",
+ "id": 88873020,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Invisibility 1 (Dif. 4)",
+ "id": 88873021,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 6",
+ "id": 88873022,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Gold 2",
+ "id": 88873023,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 4 (Dif. 3)",
+ "id": 88873024,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Fruit 2 (Dif. 3)",
+ "id": 88873025,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Scroll 2",
+ "id": 88873026,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Potion 1 (Dif. 3)",
+ "id": 88873027,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Gold 3 (Dif. 2)",
+ "id": 88873028,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 5 (Dif. 3)",
+ "id": 88873029,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 6",
+ "id": 88873030,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Meat 1 (Dif. 4)",
+ "id": 88873031,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Meat 2 (Dif. 3)",
+ "id": 88873032,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Gold 4 (Dif. 4)",
+ "id": 88873033,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Potion 2 (Dif. 3)",
+ "id": 88873034,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Meat 3 (Dif. 2)",
+ "id": 88873035,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Lightning Amulet 1 (Dif. 3)",
+ "id": 88873036,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Fruit 1 (Dif. 4)",
+ "id": 88873037,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 7 (Dif. 3)",
+ "id": 88873038,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Potion 3 (Dif. 4)",
+ "id": 88873039,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Gold 5",
+ "id": 88873040,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 8 (Dif. 3)",
+ "id": 88873041,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 9",
+ "id": 88873042,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Fruit 2 (Dif. 2)",
+ "id": 88873043,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Gold 3",
+ "id": 88873044,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Chest - Meat 4",
+ "id": 88873045,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 10 (Dif. 4)",
+ "id": 88873046,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - 3-Way Shot 1 (Dif. 2)",
+ "id": 88873047,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 7 (Dif. 4)",
+ "id": 88873048,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Lightning Breath 1 (Dif. 3)",
+ "id": 88873049,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Poison Fruit 2 (Dif. 4)",
+ "id": 88873050,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 8 (Dif. 2)",
+ "id": 88873051,
+ "difficulty": 2,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 9 (Dif. 4)",
+ "id": 88873052,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 11 (Dif. 4)",
+ "id": 88873053,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 10 (Dif. 3)",
+ "id": 88873054,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 11 (Dif. 3)",
+ "id": 88873055,
+ "difficulty": 3,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Nothing 12",
+ "id": 88873056,
+ "difficulty": 1,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Castle Courtyard Barrel - Key 12 (Dif. 4)",
+ "id": 88873057,
+ "difficulty": 4,
+ "tags": [
+ "castle_courtyard"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Lightning Potion 1 (Dif. 4)",
+ "id": 88870219,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Key 1",
+ "id": 88870220,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Runestone",
+ "id": 88870221,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Key 2 (Dif. 3)",
+ "id": 88870222,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Key 3",
+ "id": 88870223,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Key 4 (Dif. 2)",
+ "id": 88870224,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Key 5 (Dif. 3)",
+ "id": 88870225,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Key 6 (Dif. 4)",
+ "id": 88870226,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Very Small Pile of Gold 1",
+ "id": 88870227,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Small Pile of Gold 1 (Dif. 2)",
+ "id": 88870228,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Full Barrel of Gold 1 (Dif. 4)",
+ "id": 88870229,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Half Barrel of Gold 1 (Dif. 3)",
+ "id": 88870230,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Small Pile of Gold 2",
+ "id": 88870231,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Large Pile fo Gold 1 (Dif. 3)",
+ "id": 88870232,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Half Barrel of Gold 2 (Dif. 4)",
+ "id": 88870233,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Lightning Potion 2",
+ "id": 88870234,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Acid Amulet",
+ "id": 88870235,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Poison Fruit",
+ "id": 88870236,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Key 7 (Dif. 2)",
+ "id": 88870237,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Key 8",
+ "id": 88870238,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment - Obelisk",
+ "id": 88870617,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment",
+ "obelisk",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Nothing 1",
+ "id": 8887310,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Meat 1 (Dif. 4)",
+ "id": 8887311,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Meat 2 (Dif. 3)",
+ "id": 8887312,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Acid Amulet 1",
+ "id": 8887313,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Death 1",
+ "id": 8887314,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Invulnerability 1 (Dif. 4)",
+ "id": 8887315,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Key 1 (Dif. 3)",
+ "id": 8887316,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Key 2 (Dif. 2)",
+ "id": 8887317,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Nothing 2 (Dif. 4)",
+ "id": 8887318,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Nothing 3 (Dif. 3)",
+ "id": 8887319,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Nothing 4 (Dif. 4)",
+ "id": 88873110,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Fruit 1 (Dif. 2)",
+ "id": 88873111,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Super Shot 1 (Dif. 3)",
+ "id": 88873112,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Potion 1 (Dif. 2)",
+ "id": 88873113,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Gold 1 (Dif. 2)",
+ "id": 88873114,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Gold 2",
+ "id": 88873115,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Key 3 (Dif. 4)",
+ "id": 88873116,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Meat 3 (Dif. 2)",
+ "id": 88873117,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Meat 4",
+ "id": 88873118,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Potion 1 (Dif. 3)",
+ "id": 88873119,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Fruit 1 (Dif. 3)",
+ "id": 88873120,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Dark Chest - Random 1 (Dif. 2)",
+ "id": 88873121,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Potion 2",
+ "id": 88873122,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Chest - Gold 1 (Dif. 2)",
+ "id": 88873123,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Fruit 2 (Dif. 4)",
+ "id": 88873124,
+ "difficulty": 4,
+ "tags": [
+ "dungeon_of_torment",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Reflective Shot 1 (Dif. 2)",
+ "id": 88873125,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Scroll 1",
+ "id": 88873126,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Nothing 5 (Dif. 3)",
+ "id": 88873127,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Nothing 6 (Dif. 2)",
+ "id": 88873128,
+ "difficulty": 2,
+ "tags": [
+ "dungeon_of_torment",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Nothing 7 (Dif. 3)",
+ "id": 88873129,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Nothing 8 (Dif. 3)",
+ "id": 88873130,
+ "difficulty": 3,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Dungeon of Torment Barrel - Scroll 2",
+ "id": 88873131,
+ "difficulty": 1,
+ "tags": [
+ "dungeon_of_torment"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 1 (Dif. 4)",
+ "id": 88870239,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 2",
+ "id": 88870240,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 3",
+ "id": 88870241,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 4",
+ "id": 88870242,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 5",
+ "id": 88870243,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 6",
+ "id": 88870244,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 7 (Dif. 4)",
+ "id": 88870245,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Slab of Meat 1",
+ "id": 88870246,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Lightning Potion 1 (Dif. 3)",
+ "id": 88870247,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 8 (Dif. 2)",
+ "id": 88870248,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 9",
+ "id": 88870249,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 10 (Dif. 2)",
+ "id": 88870250,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 11",
+ "id": 88870251,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 12 (Dif. 3)",
+ "id": 88870252,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 13",
+ "id": 88870253,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 14 (Dif. 2)",
+ "id": 88870254,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 15 (Dif. 3)",
+ "id": 88870255,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 16 (Dif. 3)",
+ "id": 88870256,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 17",
+ "id": 88870257,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 18 (Dif. 2)",
+ "id": 88870258,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 19 (Dif. 3)",
+ "id": 88870259,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory - Fruit 1",
+ "id": 88870260,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory - Potion Pile (Dif. 2)",
+ "id": 88870261,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Lightning Potion 2",
+ "id": 88870262,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Poison Fruit 1 (Dif. 3)",
+ "id": 88870263,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 20 (Dif. 2)",
+ "id": 88870264,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Slab of Meat 2 (Dif. 4)",
+ "id": 88870265,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Lightning Potion 3 (Dif. 4)",
+ "id": 88870266,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 21 (Dif. 2)",
+ "id": 88870267,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Fruit 2 (Dif. 2)",
+ "id": 88870268,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Fruit 3",
+ "id": 88870269,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Reflective Shot 1 (Dif. 2)",
+ "id": 88870270,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Death 1 (Dif. 3)",
+ "id": 88870271,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Very Small Pile of Gold 1 (Dif. 4)",
+ "id": 88870272,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Very Small Pile of Gold 2",
+ "id": 88870273,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Half Barrel of Gold 1 (Dif. 4)",
+ "id": 88870274,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Small Pile of Gold 1 (Dif. 2)",
+ "id": 88870275,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Large Pile of Gold 1 (Dif. 3)",
+ "id": 88870276,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory - Very Small Pile of Gold 3",
+ "id": 88870277,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 22 (Dif. 3)",
+ "id": 88870278,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory - Acid Breath 1",
+ "id": 88870279,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Runestone",
+ "id": 88870280,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 23",
+ "id": 88870281,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 24 (Dif. 2)",
+ "id": 88870282,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory - Key 25",
+ "id": 88870283,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Key 1 (Dif. 4)",
+ "id": 8887320,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Scroll 1",
+ "id": 8887321,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Light Amulet 1 (Dif. 4)",
+ "id": 8887322,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Poison Fruit 1 (Dif. 4)",
+ "id": 8887323,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Fire Breath 1 (Dif. 4)",
+ "id": 8887324,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Gold 1",
+ "id": 8887325,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Invisibility 1 (Dif. 2)",
+ "id": 8887326,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Potion 1",
+ "id": 8887327,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Lightning Amulet 1",
+ "id": 8887328,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Poison Fruit 1",
+ "id": 8887329,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Meat 1 (Dif. 3)",
+ "id": 88873210,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Death 1 (Dif. 2)",
+ "id": 88873211,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Super Shot 1 (Dif. 3)",
+ "id": 88873212,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Growth 1 (Dif. 3)",
+ "id": 88873213,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Potion 2 (Dif. 2)",
+ "id": 88873214,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Death 2",
+ "id": 88873215,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Gold 2 (Dif. 3)",
+ "id": 88873216,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Gold 3 (Dif. 2)",
+ "id": 88873217,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Fruit 1 (Dif. 4)",
+ "id": 88873218,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Potion 3",
+ "id": 88873219,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Fruit 2 (Dif. 2)",
+ "id": 88873220,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Dark Chest - Random 1 (Dif. 3)",
+ "id": 88873221,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Meat 2",
+ "id": 88873222,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Scroll 2",
+ "id": 88873223,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - 3-Way Shot 1",
+ "id": 88873224,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Meat 3 (Dif. 2)",
+ "id": 88873225,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Invulnerability 1 (Dif. 4)",
+ "id": 88873226,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Gold 1",
+ "id": 88873227,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Key 2",
+ "id": 88873228,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Nothing 1 (Dif. 3)",
+ "id": 88873229,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Key 3 (Dif. 2)",
+ "id": 88873230,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Dark Chest - Random 2 (Dif. 2)",
+ "id": 88873231,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Scroll 3",
+ "id": 88873232,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Speed Boots 1",
+ "id": 88873233,
+ "difficulty": 1,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Levitate 1 (Dif. 3)",
+ "id": 88873234,
+ "difficulty": 3,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Barrel - Nothing 2 (Dif. 4)",
+ "id": 88873235,
+ "difficulty": 4,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Tower Armory Chest - Reflective Shield 1 (Dif. 2)",
+ "id": 88873236,
+ "difficulty": 2,
+ "tags": [
+ "tower_armory"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Lightning Potion 1",
+ "id": 88870284,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 1",
+ "id": 88870285,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 2",
+ "id": 88870286,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 3",
+ "id": 88870287,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 4",
+ "id": 88870288,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 5",
+ "id": 88870289,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 6 (Dif. 3)",
+ "id": 88870290,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 7",
+ "id": 88870291,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 8",
+ "id": 88870292,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Half Barrel of Gold 1 (Dif. 4)",
+ "id": 88870293,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Very Small Pile of Gold 1 (Dif. 4)",
+ "id": 88870294,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Small Pile of Gold 1 (Dif. 4)",
+ "id": 88870295,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Full Barrel of Gold 1 (Dif. 4)",
+ "id": 88870296,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Large Pile of Gold 1 (Dif. 2)",
+ "id": 88870297,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Small Pile of Gold 2 (Dif. 3)",
+ "id": 88870298,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Scroll 1",
+ "id": 88870299,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Meat Slab 1 (Dif. 2)",
+ "id": 88870300,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Poison Fruit 1",
+ "id": 88870301,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Lightning Potion 2 (Dif. 4)",
+ "id": 88870302,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 9",
+ "id": 88870303,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Lightning Potion 3 (Dif. 2)",
+ "id": 88870304,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Bananas",
+ "id": 88870305,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Drumstick 1",
+ "id": 88870306,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Lightning Potion 4 (Dif. 3)",
+ "id": 88870307,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Lightning Potion 5",
+ "id": 88870308,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Pineapple (Dif. 2)",
+ "id": 88870309,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 10 (Dif. 2)",
+ "id": 88870310,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Poison Fruit 2 (Dif. 3)",
+ "id": 88870311,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Death 1 (Dif. 2)",
+ "id": 88870312,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Invulnerability 1 (Dif. 2)",
+ "id": 88870313,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Large Pile of Gold 2 (Dif. 4)",
+ "id": 88870314,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Very Small Pile of Gold 2",
+ "id": 88870315,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Half Barrel of Gold 2 (Dif. 3)",
+ "id": 88870316,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Very Small Pile of Gold 3 (Dif. 3)",
+ "id": 88870317,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Small Pile of Gold 3 (Dif. 2)",
+ "id": 88870318,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Large Pile of Gold 3 (Dif. 3)",
+ "id": 88870319,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Small Pile of Gold 4",
+ "id": 88870320,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Speed Boots 1 (Dif. 3)",
+ "id": 88870321,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Fruit 1 (Dif. 4)",
+ "id": 88870322,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 11 (Dif. 2)",
+ "id": 88870323,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 12 (Dif. 3)",
+ "id": 88870324,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 13 (Dif. 4)",
+ "id": 88870325,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Death 2 (Dif. 3)",
+ "id": 88870326,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Ice Axe of Untar",
+ "id": 88870327,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury - Key 14 (Dif. 4)",
+ "id": 88870328,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Scroll 1",
+ "id": 8887330,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Scroll 2",
+ "id": 8887331,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Key 1 (Dif. 2)",
+ "id": 8887332,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Key 2",
+ "id": 8887333,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Key 3",
+ "id": 8887334,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Key 4",
+ "id": 8887335,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Key 5 (Dif. 2)",
+ "id": 8887336,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Gold 1",
+ "id": 8887337,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Key 6 (Dif. 3)",
+ "id": 8887338,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Poison Fruit 1 (Dif. 3)",
+ "id": 8887339,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Key 7 (Dif. 4)",
+ "id": 88873310,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Gold 2",
+ "id": 88873311,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Gold 3",
+ "id": 88873312,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Potion 1 (Dif. 4)",
+ "id": 88873313,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Potion 2 (Dif. 3)",
+ "id": 88873314,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Lightning Amulet 1",
+ "id": 88873315,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - 3-Way Shot 1 (Dif. 2)",
+ "id": 88873316,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Potion 3",
+ "id": 88873317,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Dark Chest - Random 1 (Dif. 3)",
+ "id": 88873318,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Gold 4 (Dif. 2)",
+ "id": 88873319,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Potion 4 (Dif. 2)",
+ "id": 88873320,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Scroll 1",
+ "id": 88873321,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Meat 1 (Dif. 3)",
+ "id": 88873322,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Fruit 1 (Dif. 2)",
+ "id": 88873323,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Meat 2 (Dif. 4)",
+ "id": 88873324,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Thunder Hammer 1 (Dif. 4)",
+ "id": 88873325,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Timestop 1 (Dif. 4)",
+ "id": 88873326,
+ "difficulty": 4,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Levitate 1 (Dif. 3)",
+ "id": 88873327,
+ "difficulty": 3,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Lightning Shield 1",
+ "id": 88873328,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Death 1",
+ "id": 88873329,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Fire Shield 1",
+ "id": 88873330,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Barrel - Levitate 2 (Dif. 2)",
+ "id": 88873331,
+ "difficulty": 2,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Castle Treasury Chest - Random 1",
+ "id": 88873332,
+ "difficulty": 1,
+ "tags": [
+ "castle_treasury"
+ ]
+ },
+ {
+ "name": "Chimera's Keep - Speed Boots",
+ "id": 88870329,
+ "difficulty": 1,
+ "tags": [
+ "chimeras_keep",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Chimera's Keep - Growth (Dif. 4)",
+ "id": 88870330,
+ "difficulty": 4,
+ "tags": [
+ "chimeras_keep",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Chimera's Keep - 3-Way Shot (Dif. 2)",
+ "id": 88870331,
+ "difficulty": 2,
+ "tags": [
+ "chimeras_keep",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Chimera's Keep - (Dif. 3)",
+ "id": 88870332,
+ "difficulty": 3,
+ "tags": [
+ "chimeras_keep",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Chimera's Keep - Ham (Dif. 2)",
+ "id": 88870333,
+ "difficulty": 2,
+ "tags": [
+ "chimeras_keep",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Chimera's Keep - Drumstick",
+ "id": 88870334,
+ "difficulty": 1,
+ "tags": [
+ "chimeras_keep",
+ "no_obelisks",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Chimera's Keep - Chimera Mirror Shard",
+ "id": 88870608,
+ "difficulty": 1,
+ "tags": [
+ "chimeras_keep",
+ "no_obelisks",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit Pile 1",
+ "id": 88870335,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit Pile 2 (Dif. 4)",
+ "id": 88870336,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit Pile 3 (Dif. 3)",
+ "id": 88870337,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit Pile 4",
+ "id": 88870338,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Death 1",
+ "id": 88870339,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 1",
+ "id": 88870340,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 2 (Dif. 2)",
+ "id": 88870341,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 3 (Dif. 3)",
+ "id": 88870342,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 4",
+ "id": 88870343,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 5 (Dif. 3)",
+ "id": 88870344,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 6",
+ "id": 88870345,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 7 (Dif. 2)",
+ "id": 88870346,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 8",
+ "id": 88870347,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 9 (Dif. 3)",
+ "id": 88870348,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit Pile 5 (Dif. 3)",
+ "id": 88870349,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit Pile 6 (Dif. 2)",
+ "id": 88870350,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit Pile 7 (Dif. 2)",
+ "id": 88870351,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit Pile 8 (Dif. 4)",
+ "id": 88870352,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Acid Potion 1 (Dif. 4)",
+ "id": 88870353,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 10 (Dif. 2)",
+ "id": 88870354,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Shrink",
+ "id": 88870355,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Reflective Shield (Dif. 2)",
+ "id": 88870356,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Ham 1 (Dif. 3)",
+ "id": 88870357,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 11",
+ "id": 88870358,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 12",
+ "id": 88870359,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 13 (Dif. 3)",
+ "id": 88870360,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 14",
+ "id": 88870361,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 15 (Dif. 2)",
+ "id": 88870362,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 16",
+ "id": 88870363,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 17 (Dif. 4)",
+ "id": 88870364,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 18",
+ "id": 88870365,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Small Pile of Gold 1 (Dif. 3)",
+ "id": 88870366,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Bananas",
+ "id": 88870367,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Acid Potion 2 (Dif. 3)",
+ "id": 88870368,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Drumstick 1 (Dif. 3)",
+ "id": 88870369,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Potion Pile 1 (Dif. 2)",
+ "id": 88870370,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Small Pile of Gold 2 (Dif. 2)",
+ "id": 88870371,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Very Small Pile of Gold 1 (Dif. 4)",
+ "id": 88870372,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Half Barrel of Gold 1 (Dif. 3)",
+ "id": 88870373,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Key 19",
+ "id": 88870374,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Death 2 (Dif. 3)",
+ "id": 88870375,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Fruit 1",
+ "id": 88870376,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Speed Boots 1 (Dif. 4)",
+ "id": 88870377,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Death 3",
+ "id": 88870378,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Small Pile of Gold 3",
+ "id": 88870379,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Full Barrel of Gold 1 (Dif. 4)",
+ "id": 88870380,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Phoenix Familiar 1 (Dif. 2)",
+ "id": 88870381,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields - Obelisk",
+ "id": 88870614,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields",
+ "obelisk",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Scroll 1",
+ "id": 8887110,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Potion 1",
+ "id": 8887111,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 1",
+ "id": 8887112,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 2",
+ "id": 8887113,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 1",
+ "id": 8887114,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 3",
+ "id": 8887115,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 4",
+ "id": 8887116,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 5",
+ "id": 8887117,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Scroll 2",
+ "id": 8887118,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 2",
+ "id": 8887119,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Fruit 1 (Dif. 4)",
+ "id": 88871110,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Scroll 3",
+ "id": 88871111,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Meat 1 (Dif. 2)",
+ "id": 88871112,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Fruit 2 (Dif. 4)",
+ "id": 88871113,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Fruit 3 (Dif. 3)",
+ "id": 88871114,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 1 (Dif. 3)",
+ "id": 88871115,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 3",
+ "id": 88871116,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 6",
+ "id": 88871117,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 7",
+ "id": 88871118,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 8",
+ "id": 88871119,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 4 (Dif. 4)",
+ "id": 88871120,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 5 (Dif. 3)",
+ "id": 88871121,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 6",
+ "id": 88871122,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 7 (Dif. 2)",
+ "id": 88871123,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 8",
+ "id": 88871124,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 9",
+ "id": 88871125,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 10",
+ "id": 88871126,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 11",
+ "id": 88871127,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 9",
+ "id": 88871128,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Thunder Hammer 1 (Dif. 2)",
+ "id": 88871129,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Death 1",
+ "id": 88871130,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 10",
+ "id": 88871131,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Anti-Death Halo 1",
+ "id": 88871132,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Potion 1 (Dif. 2)",
+ "id": 88871133,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 2 (Dif. 2)",
+ "id": 88871134,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 3 (Dif. 2)",
+ "id": 88871135,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 4 (Dif. 2)",
+ "id": 88871136,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 5",
+ "id": 88871137,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 6 (Dif. 4)",
+ "id": 88871138,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 12",
+ "id": 88871139,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Gold 1 (Dif. 4)",
+ "id": 88871140,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Potion 2",
+ "id": 88871141,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 7",
+ "id": 88871142,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 8 (Dif. 3)",
+ "id": 88871143,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 9 (Dif. 3)",
+ "id": 88871144,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Fruit 4 (Dif. 2)",
+ "id": 88871145,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 11",
+ "id": 88871146,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 13",
+ "id": 88871147,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 14",
+ "id": 88871148,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - 3-Way Shot 1",
+ "id": 88871149,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Meat 2 (Dif. 2)",
+ "id": 88871150,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 15",
+ "id": 88871151,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 16",
+ "id": 88871152,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 12",
+ "id": 88871153,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Death 2 (Dif. 4)",
+ "id": 88871154,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 17",
+ "id": 88871155,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Lightning Shield 1 (Dif. 3)",
+ "id": 88871156,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Fire Amulet 1 (Dif. 3)",
+ "id": 88871157,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 10",
+ "id": 88871158,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Potion 2 (Dif. 4)",
+ "id": 88871159,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 11 (Dif. 3)",
+ "id": 88871160,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Meat 1",
+ "id": 88871161,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Speed Boots 1",
+ "id": 88871162,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 18",
+ "id": 88871163,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 19",
+ "id": 88871164,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 20",
+ "id": 88871165,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 21",
+ "id": 88871166,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Meat 3 (Dif. 4)",
+ "id": 88871167,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Rapid Fire 1 (Dif. 2)",
+ "id": 88871168,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Meat 2",
+ "id": 88871169,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Potion 3",
+ "id": 88871170,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 13",
+ "id": 88871171,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Gold 2",
+ "id": 88871172,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - 3-Way Shot 1 (Dif. 4)",
+ "id": 88871173,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Scroll 4",
+ "id": 88871174,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Nothing 22",
+ "id": 88871175,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 12 (Dif. 3)",
+ "id": 88871176,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Fruit 5 (Dif. 3)",
+ "id": 88871177,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 14 (Dif. 3)",
+ "id": 88871178,
+ "difficulty": 3,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Key 15",
+ "id": 88871179,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Potion 3 (Dif. 2)",
+ "id": 88871180,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Barrel - Death 3 (Dif. 2)",
+ "id": 88871181,
+ "difficulty": 2,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 13 (Dif. 4)",
+ "id": 88871182,
+ "difficulty": 4,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Poisoned Fields Chest - Gold 14",
+ "id": 88871183,
+ "difficulty": 1,
+ "tags": [
+ "poisoned_fields"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Acid Potion 1",
+ "id": 88870382,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Runestone",
+ "id": 88870383,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Small Pile of Gold",
+ "id": 88870384,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Key 1 (Dif. 3)",
+ "id": 88870385,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Acid Potion 2",
+ "id": 88870386,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - 3-Way Shot",
+ "id": 88870387,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Key 2 (Dif. 2)",
+ "id": 88870388,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Key 3 (Dif. 3)",
+ "id": 88870389,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Key 4",
+ "id": 88870390,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Key 5 (Dif. 2)",
+ "id": 88870391,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Key 6",
+ "id": 88870392,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Key 7 (Dif. 2)",
+ "id": 88870393,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Speed Boots (Dif. 3)",
+ "id": 88870394,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Key 8 (Dif. 4)",
+ "id": 88870395,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Death 1",
+ "id": 88870396,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Phoenix Familiar",
+ "id": 88870397,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Timestop",
+ "id": 88870398,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Fire Amulet (Dif. 2)",
+ "id": 88870399,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Death 2 (Dif. 2)",
+ "id": 88870400,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery - Obelisk",
+ "id": 88870615,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery",
+ "obelisk",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Invulnerability 1",
+ "id": 8887120,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Fire Shield 1 (Dif. 4)",
+ "id": 8887121,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Fruit 1 (Dif. 4)",
+ "id": 8887122,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Meat 1",
+ "id": 8887123,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Fruit 2 (Dif. 2)",
+ "id": 8887124,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Potion 1 (Dif. 4)",
+ "id": 8887125,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Key 1 (Dif. 2)",
+ "id": 8887126,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Potion 1 (Dif. 2)",
+ "id": 8887127,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Poison Fruit 1 (Dif. 4)",
+ "id": 8887128,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Key 2 (Dif. 2)",
+ "id": 8887129,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Dark Chest - Random 1 (Dif. 2)",
+ "id": 88871210,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Key 3 (Dif. 3)",
+ "id": 88871211,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Scroll 1",
+ "id": 88871212,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Poison Fruit 2 (Dif. 2)",
+ "id": 88871213,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Poison Fruit 3 (Dif. 3)",
+ "id": 88871214,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Rapid Fire 1 (Dif. 2)",
+ "id": 88871215,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Gold 1 (Dif. 4)",
+ "id": 88871216,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Shrink 1 (Dif. 3)",
+ "id": 88871217,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Potion 2 (Dif. 3)",
+ "id": 88871218,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Scroll 1",
+ "id": 88871219,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Scroll 2",
+ "id": 88871220,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Meat 2 (Dif. 3)",
+ "id": 88871221,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Meat 1 (Dif. 2)",
+ "id": 88871222,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Key 4 (Dif. 2)",
+ "id": 88871223,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Potion 2",
+ "id": 88871224,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Death 1 (Dif. 4)",
+ "id": 88871225,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Gold 1",
+ "id": 88871226,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Levitate 1",
+ "id": 88871227,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Fruit 3 (Dif. 3)",
+ "id": 88871228,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Gold 2 (Dif. 2)",
+ "id": 88871229,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Fruit 1",
+ "id": 88871230,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Nothing 1",
+ "id": 88871231,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Key 5",
+ "id": 88871232,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Key 6",
+ "id": 88871233,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Gold 2 (Dif. 3)",
+ "id": 88871234,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Key 7 (Dif. 4)",
+ "id": 88871235,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Key 8",
+ "id": 88871236,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Death 2",
+ "id": 88871237,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Barrel - Gold 3 (Dif. 2)",
+ "id": 88871238,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Death 1 (Dif. 3)",
+ "id": 88871239,
+ "difficulty": 3,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - 3-Way Shot 1 (Dif. 2)",
+ "id": 88871240,
+ "difficulty": 2,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Gold 3",
+ "id": 88871241,
+ "difficulty": 1,
+ "tags": [
+ "haunted_cemetery",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Haunted Cemetery Chest - Meat 2 (Dif. 4)",
+ "id": 88871242,
+ "difficulty": 4,
+ "tags": [
+ "haunted_cemetery"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Acid Potion 1",
+ "id": 88870401,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Invisibility",
+ "id": 88870402,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Flame of Tarkana",
+ "id": 88870403,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Runestone",
+ "id": 88870404,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Super Shot",
+ "id": 88870405,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Death 1",
+ "id": 88870406,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Key 1 (Dif. 4)",
+ "id": 88870407,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Key 2 (Dif. 3)",
+ "id": 88870408,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Lightning Amulet (Dif. 2)",
+ "id": 88870409,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Pineapple (Dif. 3)",
+ "id": 88870410,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Half Barrel of Gold 1 (Dif. 3)",
+ "id": 88870411,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Death 2 (Dif. 2)",
+ "id": 88870412,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Key 3 (Dif. 3)",
+ "id": 88870413,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Key 4 (Dif. 2)",
+ "id": 88870414,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Phoenix Familiar (Dif. 4)",
+ "id": 88870415,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire - Key 5",
+ "id": 88870416,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Speed Boots 1",
+ "id": 8887130,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Potion 1",
+ "id": 8887131,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Poison Fruit 1 (Dif. 2)",
+ "id": 8887132,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Poison Fruit 2",
+ "id": 8887133,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 1 (Dif. 3)",
+ "id": 8887134,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Meat 1 (Dif. 3)",
+ "id": 8887135,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 2",
+ "id": 8887136,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Scroll 1",
+ "id": 8887137,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Scroll 1",
+ "id": 8887138,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Scroll 2",
+ "id": 8887139,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Scroll 3",
+ "id": 88871310,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Scroll 4",
+ "id": 88871311,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 3",
+ "id": 88871312,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 4 (Dif. 3)",
+ "id": 88871313,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 5",
+ "id": 88871314,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Scroll 5",
+ "id": 88871315,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 6 (Dif. 4)",
+ "id": 88871316,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 7",
+ "id": 88871317,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 8 (Dif. 4)",
+ "id": 88871318,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 9",
+ "id": 88871319,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 10",
+ "id": 88871320,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Acid Breath 1 (Dif. 2)",
+ "id": 88871321,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 11 (Dif. 2)",
+ "id": 88871322,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Speed Boots 1",
+ "id": 88871323,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 12",
+ "id": 88871324,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Gold 1 (Dif. 2)",
+ "id": 88871325,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Gold 2",
+ "id": 88871326,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Meat 1 (Dif. 2)",
+ "id": 88871327,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Potion 1",
+ "id": 88871328,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Dark Chest - Random 1",
+ "id": 88871329,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Fruit 1",
+ "id": 88871330,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Meat 2",
+ "id": 88871331,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Death 1",
+ "id": 88871332,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 13",
+ "id": 88871333,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Fruit 2 (Dif. 4)",
+ "id": 88871334,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Gold 3 (Dif. 3)",
+ "id": 88871335,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 14",
+ "id": 88871336,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Death 1 (Dif. 3)",
+ "id": 88871337,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Potion 2 (Dif. 3)",
+ "id": 88871338,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Death 2",
+ "id": 88871339,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Fruit 3 (Dif. 2)",
+ "id": 88871340,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Gold 1 (Dif. 3)",
+ "id": 88871341,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Speed Boots 2 (Dif. 3)",
+ "id": 88871342,
+ "difficulty": 3,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Gold 2 (Dif. 4)",
+ "id": 88871343,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Gold 3 (Dif. 4)",
+ "id": 88871344,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Gold 4 (Dif. 2)",
+ "id": 88871345,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Reflective Shield 1",
+ "id": 88871346,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Death 2 (Dif. 2)",
+ "id": 88871347,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Nothing 1",
+ "id": 88871348,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Nothing 2",
+ "id": 88871349,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Nothing 3",
+ "id": 88871350,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 15",
+ "id": 88871351,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Gold 5",
+ "id": 88871352,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Fire Shield 1",
+ "id": 88871353,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 16",
+ "id": 88871354,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Nothing 4",
+ "id": 88871355,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Meat 2 (Dif. 4)",
+ "id": 88871356,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 17 (Dif. 4)",
+ "id": 88871357,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Death 3 (Dif. 4)",
+ "id": 88871358,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Potion 3 (Dif. 4)",
+ "id": 88871359,
+ "difficulty": 4,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Gold 4",
+ "id": 88871360,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 18",
+ "id": 88871361,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Chest - Gold 5 (Dif. 2)",
+ "id": 88871362,
+ "difficulty": 2,
+ "tags": [
+ "venomous_spire"
+ ]
+ },
+ {
+ "name": "Venemous Spire Barrel - Key 19",
+ "id": 88871363,
+ "difficulty": 1,
+ "tags": [
+ "venomous_spire",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Acid Breath",
+ "id": 88870417,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Scroll",
+ "id": 88870418,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 1",
+ "id": 88870419,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Runestone",
+ "id": 88870420,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Phoenix Familiar",
+ "id": 88870421,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 2",
+ "id": 88870422,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 3 (Dif. 2)",
+ "id": 88870423,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 4 (Dif. 4)",
+ "id": 88870424,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 5 (Dif. 2)",
+ "id": 88870425,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 6",
+ "id": 88870426,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Poison Fruit 1 (Dif. 2)",
+ "id": 88870427,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Death (Dif. 2)",
+ "id": 88870428,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Bananas (Dif. 4)",
+ "id": 88870429,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 7",
+ "id": 88870430,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 8 (Dif. 2)",
+ "id": 88870431,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 9 (Dif. 3)",
+ "id": 88870432,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Poison Fruit 2 (Dif. 3)",
+ "id": 88870433,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Key 10",
+ "id": 88870434,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship - Fire Shield (Dif. 2)",
+ "id": 88870435,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 1 (Dif. 4)",
+ "id": 8887140,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 2 (Dif. 3)",
+ "id": 8887141,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Meat 1 (Dif. 4)",
+ "id": 8887142,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Potion 1",
+ "id": 8887143,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Meat 2 (Dif. 2)",
+ "id": 8887144,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Scroll 1",
+ "id": 8887145,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Poison Fruit 1",
+ "id": 8887146,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Fruit 1",
+ "id": 8887147,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 3 (Dif. 2)",
+ "id": 8887148,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Growth 1 (Dif. 4)",
+ "id": 8887149,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Dark Chest - Random 1 (Dif. 2)",
+ "id": 88871410,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Scroll 1",
+ "id": 88871411,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Acid Amulet 1 (Dif. 2)",
+ "id": 88871412,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Gold 1 (Dif. 3)",
+ "id": 88871413,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Meat 3",
+ "id": 88871414,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Potion 1 (Dif. 3)",
+ "id": 88871415,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 4",
+ "id": 88871416,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Death 1 (Dif. 3)",
+ "id": 88871417,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Gold 2 (Dif. 2)",
+ "id": 88871418,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - 3-Way Shot 1",
+ "id": 88871419,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Death 2 (Dif. 4)",
+ "id": 88871420,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 5",
+ "id": 88871421,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Gold 1 (Dif. 4)",
+ "id": 88871422,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Nothing 1",
+ "id": 88871423,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Potion 2 (Dif. 2)",
+ "id": 88871424,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 6",
+ "id": 88871425,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 7",
+ "id": 88871426,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Death 3",
+ "id": 88871427,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Nothing 2",
+ "id": 88871428,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 8",
+ "id": 88871429,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Gold 2 (Dif. 2)",
+ "id": 88871430,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 9 (Dif. 3)",
+ "id": 88871431,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Acid Breath 1 (Dif. 3)",
+ "id": 88871432,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Gold 3 (Dif. 4)",
+ "id": 88871433,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Gold 4 (Dif. 3)",
+ "id": 88871434,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Death 1",
+ "id": 88871435,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Gold 3",
+ "id": 88871436,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 10 (Dif. 2)",
+ "id": 88871437,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Key 11 (Dif. 4)",
+ "id": 88871438,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Poison Fruit 2 (Dif. 2)",
+ "id": 88871439,
+ "difficulty": 2,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Chest - Gold 5",
+ "id": 88871440,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Scroll 2",
+ "id": 88871441,
+ "difficulty": 1,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Meat 1 (Dif. 3)",
+ "id": 88871442,
+ "difficulty": 3,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Toxic Air Ship Barrel - Potion 3 (Dif. 4)",
+ "id": 88871443,
+ "difficulty": 4,
+ "tags": [
+ "toxic_air_ship"
+ ]
+ },
+ {
+ "name": "Vat of the Plague Fiend - Plague Fiend Mirror Shard",
+ "id": 88870609,
+ "difficulty": 1,
+ "tags": [
+ "plague_fiend",
+ "no_obelisks",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Light Potion 1",
+ "id": 88870436,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Scroll",
+ "id": 88870437,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Light Potion 2 (Dif. 2)",
+ "id": 88870438,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Small Pile of Gold (Dif. 2)",
+ "id": 88870439,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Fire Shield",
+ "id": 88870440,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 1",
+ "id": 88870441,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Meat",
+ "id": 88870442,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Light Potion 3 (Dif. 2)",
+ "id": 88870443,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Runestone",
+ "id": 88870444,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 2",
+ "id": 88870445,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 3 (Dif. 3)",
+ "id": 88870446,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 4",
+ "id": 88870447,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 5 (Dif. 2)",
+ "id": 88870448,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 6",
+ "id": 88870449,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 7",
+ "id": 88870450,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 8",
+ "id": 88870451,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 9",
+ "id": 88870452,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 10",
+ "id": 88870453,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 11",
+ "id": 88870454,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 12",
+ "id": 88870455,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 13 (Dif. 2)",
+ "id": 88870456,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 14 (Dif. 4)",
+ "id": 88870457,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 15",
+ "id": 88870458,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 16",
+ "id": 88870459,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Key 17",
+ "id": 88870460,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks - Speed Boots",
+ "id": 88870461,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 1",
+ "id": 8887160,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Phoenix Familiar 1",
+ "id": 8887161,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 1 (Dif. 2)",
+ "id": 8887162,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Potion 1 (Dif. 3)",
+ "id": 8887163,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Meat 1 (Dif. 3)",
+ "id": 8887164,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 2 (Dif. 2)",
+ "id": 8887165,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 3",
+ "id": 8887166,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 4 (Dif. 3)",
+ "id": 8887167,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Lightning Breath 1 (Dif. 2)",
+ "id": 8887168,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 5 (Dif. 4)",
+ "id": 8887169,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Meat 2 (Dif. 3)",
+ "id": 88871610,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 6 (Dif. 2)",
+ "id": 88871611,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 7",
+ "id": 88871612,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Meat 3",
+ "id": 88871613,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 2",
+ "id": 88871614,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 3",
+ "id": 88871615,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 4 (Dif. 2)",
+ "id": 88871616,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 5",
+ "id": 88871617,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Scroll 1",
+ "id": 88871618,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 6",
+ "id": 88871619,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Nothing 1",
+ "id": 88871620,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Rapid Fire 1 (Dif. 4)",
+ "id": 88871621,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Meat 1",
+ "id": 88871622,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Gold 1 (Dif. 2)",
+ "id": 88871623,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Acid Amulet 1 (Dif. 4)",
+ "id": 88871624,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 8",
+ "id": 88871625,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Death 1 (Dif. 2)",
+ "id": 88871626,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 7 (Dif. 4)",
+ "id": 88871627,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 8 (Dif. 2)",
+ "id": 88871628,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Potion 2 (Dif. 4)",
+ "id": 88871629,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Scroll 2",
+ "id": 88871630,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Meat 4 (Dif. 4)",
+ "id": 88871631,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Nothing 2",
+ "id": 88871632,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Gold 1 (Dif. 4)",
+ "id": 88871633,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Phoenix Familiar 1 (Dif. 3)",
+ "id": 88871634,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 9 (Dif. 3)",
+ "id": 88871635,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 10",
+ "id": 88871636,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Death 2 (Dif. 3)",
+ "id": 88871637,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Nothing 3",
+ "id": 88871638,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Nothing 4",
+ "id": 88871639,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Nothing 5",
+ "id": 88871640,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Nothing 6",
+ "id": 88871641,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 11 (Dif. 2)",
+ "id": 88871642,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Reflective Shield 1 (Dif. 2)",
+ "id": 88871643,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 9 (Dif. 4)",
+ "id": 88871644,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Gold 2",
+ "id": 88871645,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Scroll 3",
+ "id": 88871646,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Fruit 10 (Dif. 3)",
+ "id": 88871647,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Gold 2 (Dif. 3)",
+ "id": 88871648,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Invisibility 1",
+ "id": 88871649,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Potion 1",
+ "id": 88871650,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Light Amulet 1 (Dif. 2)",
+ "id": 88871651,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Speed Boots 1 (Dif. 3)",
+ "id": 88871652,
+ "difficulty": 3,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Thunder Hammer 1 (Dif. 2)",
+ "id": 88871653,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - 3-Way Shot 1",
+ "id": 88871654,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Rapid Fire 1",
+ "id": 88871655,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Gold 3",
+ "id": 88871656,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 12",
+ "id": 88871657,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Death 1",
+ "id": 88871658,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Nothing 7",
+ "id": 88871659,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Nothing 8",
+ "id": 88871660,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Scroll 4",
+ "id": 88871661,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Gold 4 (Dif. 2)",
+ "id": 88871662,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Barrel - Key 13",
+ "id": 88871663,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Death 2",
+ "id": 88871664,
+ "difficulty": 1,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Meat 2 (Dif. 2)",
+ "id": 88871665,
+ "difficulty": 2,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Arctic Docks Chest - Meat 3 (Dif. 4)",
+ "id": 88871666,
+ "difficulty": 4,
+ "tags": [
+ "arctic_docks"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Light Potion 1",
+ "id": 88870462,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Full Barrel of Gold 1 (Dif. 4)",
+ "id": 88870463,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Half Barrel of Gold 1",
+ "id": 88870464,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Scroll",
+ "id": 88870465,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Light Potion 2 (Dif. 2)",
+ "id": 88870466,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Light Potion 3",
+ "id": 88870467,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Large Pile of Gold 1 (Dif. 2)",
+ "id": 88870468,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Half Barrel of Gold 2 (Dif. 3)",
+ "id": 88870469,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Small Pile of Gold 1",
+ "id": 88870470,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Key 1 (Dif. 4)",
+ "id": 88870471,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Lightning Breath (Dif. 3)",
+ "id": 88870472,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Levitate (Dif. 2)",
+ "id": 88870473,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Key 2",
+ "id": 88870474,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Key 3 (Dif. 3)",
+ "id": 88870475,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Speed Boots",
+ "id": 88870476,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Key 4 (Dif. 3)",
+ "id": 88870477,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Key 5",
+ "id": 88870478,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Bananas (Dif. 4)",
+ "id": 88870479,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Pineapple",
+ "id": 88870480,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Light Potion 4 (Dif. 4)",
+ "id": 88870481,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Key 6 (Dif. 2)",
+ "id": 88870482,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Fruit 1 (Dif. 2)",
+ "id": 88870483,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Very Small Pile of Gold 1 (Dif. 2)",
+ "id": 88870484,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Very Small Pile of Gold 2",
+ "id": 88870485,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Drumstick (Dif. 4)",
+ "id": 88870486,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Very Small Pile of Gold 3 (Dif. 2)",
+ "id": 88870487,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Small Pile of Gold 2 (Dif. 2)",
+ "id": 88870488,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Very Small Pile of Gold 4 (Dif. 2)",
+ "id": 88870489,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Large Pile of Gold 2 (Dif. 3)",
+ "id": 88870490,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Growth",
+ "id": 88870491,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Runestone",
+ "id": 88870492,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Fruit 2",
+ "id": 88870493,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Phoenix Familiar",
+ "id": 88870494,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Death",
+ "id": 88870495,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Key 7",
+ "id": 88870496,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp - Key 8 (Dif. 4)",
+ "id": 88870497,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Scroll 1",
+ "id": 8887170,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Meat 1",
+ "id": 8887171,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 1",
+ "id": 8887172,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 2 (Dif. 2)",
+ "id": 8887173,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 3",
+ "id": 8887174,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 4",
+ "id": 8887175,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 5 (Dif. 2)",
+ "id": 8887176,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 1",
+ "id": 8887177,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 6",
+ "id": 8887178,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Meat 1 (Dif. 2)",
+ "id": 8887179,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Scroll 2",
+ "id": 88871710,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Nothing 1",
+ "id": 88871711,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Nothing 2",
+ "id": 88871712,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 7 (Dif. 2)",
+ "id": 88871713,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 8",
+ "id": 88871714,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Dark Chest - Random 1 (Dif. 2)",
+ "id": 88871715,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Death 1",
+ "id": 88871716,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 9 (Dif. 2)",
+ "id": 88871717,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 10",
+ "id": 88871718,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Rapid Fire 1 (Dif. 2)",
+ "id": 88871719,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 2",
+ "id": 88871720,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 11 (Dif. 2)",
+ "id": 88871721,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 12",
+ "id": 88871722,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 13",
+ "id": 88871723,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 14",
+ "id": 88871724,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 15 (Dif. 3)",
+ "id": 88871725,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 16 (Dif. 2)",
+ "id": 88871726,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 3 (Dif. 3)",
+ "id": 88871727,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 4 (Dif. 2)",
+ "id": 88871728,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 5 (Dif. 4)",
+ "id": 88871729,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 6",
+ "id": 88871730,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 7",
+ "id": 88871731,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 17",
+ "id": 88871732,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Super Shot 1",
+ "id": 88871733,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Invisibility 1 (Dif. 2)",
+ "id": 88871734,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Nothing 3",
+ "id": 88871735,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 8",
+ "id": 88871736,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Meat 2 (Dif. 3)",
+ "id": 88871737,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Gold 1 (Dif. 3)",
+ "id": 88871738,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Scroll 1",
+ "id": 88871739,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - 3-Way Shot 1 (Dif. 3)",
+ "id": 88871740,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Fruit 1 (Dif. 3)",
+ "id": 88871741,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Shrink 1 (Dif. 2)",
+ "id": 88871742,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 18",
+ "id": 88871743,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Speed Boots 1 (Dif. 4)",
+ "id": 88871744,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - 5-Way Shot 1 (Dif. 4)",
+ "id": 88871745,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Fruit 2 (Dif. 4)",
+ "id": 88871746,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 19 (Dif. 4)",
+ "id": 88871747,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Nothing 4",
+ "id": 88871748,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Nothing 5",
+ "id": 88871749,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Growth 1 (Dif. 4)",
+ "id": 88871750,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Potion 1",
+ "id": 88871751,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Fruit 1 (Dif. 2)",
+ "id": 88871752,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Scroll 3",
+ "id": 88871753,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 20",
+ "id": 88871754,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Potion 2 (Dif. 3)",
+ "id": 88871755,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Gold 2 (Dif. 4)",
+ "id": 88871756,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 21 (Dif. 3)",
+ "id": 88871757,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Reflective Shield 1",
+ "id": 88871758,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Thunder Hammer 1 (Dif. 2)",
+ "id": 88871759,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Lightning Amulet 1",
+ "id": 88871760,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Meat 3 (Dif. 2)",
+ "id": 88871761,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Potion 3 (Dif. 2)",
+ "id": 88871762,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Scroll 4",
+ "id": 88871763,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 22 (Dif. 2)",
+ "id": 88871764,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 9 (Dif. 4)",
+ "id": 88871765,
+ "difficulty": 4,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Nothing 6",
+ "id": 88871766,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Fruit 3 (Dif. 3)",
+ "id": 88871767,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Acid Amulet 1",
+ "id": 88871768,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Gold 3 (Dif. 3)",
+ "id": 88871769,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Key 23",
+ "id": 88871770,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 10 (Dif. 2)",
+ "id": 88871771,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Meat 4",
+ "id": 88871772,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Potion 1 (Dif. 3)",
+ "id": 88871773,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Gold 11",
+ "id": 88871774,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Speed Boots 1 (Dif. 3)",
+ "id": 88871775,
+ "difficulty": 3,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Chest - Invulnerability 1",
+ "id": 88871776,
+ "difficulty": 1,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Frozen Camp Barrel - Death 1 (Dif. 2)",
+ "id": 88871777,
+ "difficulty": 2,
+ "tags": [
+ "frozen_camp"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Rapid Fire",
+ "id": 88870498,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Speed Boots",
+ "id": 88870499,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Key 1 (Dif. 2)",
+ "id": 88870500,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Key 2 (Dif. 4)",
+ "id": 88870501,
+ "difficulty": 4,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Key 3 (Dif. 3)",
+ "id": 88870502,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Lightning Breath",
+ "id": 88870503,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Key 4 (Dif. 2)",
+ "id": 88870504,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Key 5",
+ "id": 88870505,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Lightning Shield",
+ "id": 88870506,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Bananas",
+ "id": 88870507,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Key 6",
+ "id": 88870508,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Key 7",
+ "id": 88870509,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Runestone",
+ "id": 88870510,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine - Shrink (Dif. 3)",
+ "id": 88870511,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Reflective Shot 1 (Dif. 3)",
+ "id": 8887180,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Speed Boots 1 (Dif. 4)",
+ "id": 8887181,
+ "difficulty": 4,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Meat 1 (Dif. 2)",
+ "id": 8887182,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Meat 2",
+ "id": 8887183,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Meat 3 (Dif. 3)",
+ "id": 8887184,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Meat 4 (Dif. 2)",
+ "id": 8887185,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Meat 5",
+ "id": 8887186,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Scroll 1",
+ "id": 8887187,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Growth 1 (Dif. 2)",
+ "id": 8887188,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 1",
+ "id": 8887189,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 2",
+ "id": 88871810,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 3 (Dif. 2)",
+ "id": 88871811,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 4 (Dif. 2)",
+ "id": 88871812,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 5 (Dif. 4)",
+ "id": 88871813,
+ "difficulty": 4,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Fruit 1",
+ "id": 88871814,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Gold 1 (Dif. 3)",
+ "id": 88871815,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Random 1 (Dif. 2)",
+ "id": 88871816,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Potion 1",
+ "id": 88871817,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Meat 1 (Dif. 4)",
+ "id": 88871818,
+ "difficulty": 4,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Gold 1 (Dif. 2)",
+ "id": 88871819,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Gold 2",
+ "id": 88871820,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 6",
+ "id": 88871821,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Gold 3 (Dif. 2)",
+ "id": 88871822,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Potion 1 (Dif. 4)",
+ "id": 88871823,
+ "difficulty": 4,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Gold 4",
+ "id": 88871824,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Gold 5",
+ "id": 88871825,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Fruit 1 (Dif. 2)",
+ "id": 88871826,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 7",
+ "id": 88871827,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Meat 2",
+ "id": 88871828,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Gold 2 (Dif. 2)",
+ "id": 88871829,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 8",
+ "id": 88871830,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Death 1 (Dif. 3)",
+ "id": 88871831,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 9 (Dif. 2)",
+ "id": 88871832,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Scroll 1",
+ "id": 88871833,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Nothing 1",
+ "id": 88871834,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Nothing 2",
+ "id": 88871835,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Potion 2 (Dif. 2)",
+ "id": 88871836,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 10",
+ "id": 88871837,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Gold 3 (Dif. 4)",
+ "id": 88871838,
+ "difficulty": 4,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Rapid Fire 1 (Dif. 2)",
+ "id": 88871839,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 11 (Dif. 3)",
+ "id": 88871840,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 12",
+ "id": 88871841,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Nothing 3",
+ "id": 88871842,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Nothing 4",
+ "id": 88871843,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Gold 6",
+ "id": 88871844,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Fruit 2 (Dif. 3)",
+ "id": 88871845,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 13 (Dif. 2)",
+ "id": 88871846,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Fruit 2",
+ "id": 88871847,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Gold 4 (Dif. 3)",
+ "id": 88871848,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Scroll 2",
+ "id": 88871849,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Gold 7 (Dif. 2)",
+ "id": 88871850,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Potion 3 (Dif. 3)",
+ "id": 88871851,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Fruit 3 (Dif. 2)",
+ "id": 88871852,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Gold 5 (Dif. 4)",
+ "id": 88871853,
+ "difficulty": 4,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 14",
+ "id": 88871854,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Potion 4",
+ "id": 88871855,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 15",
+ "id": 88871856,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - 3-Way Shot 1",
+ "id": 88871857,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Scroll 2",
+ "id": 88871858,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Gold 8",
+ "id": 88871859,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Fire Amulet 1",
+ "id": 88871860,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 16",
+ "id": 88871861,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Chest - Scroll 3",
+ "id": 88871862,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Gold 6 (Dif. 3)",
+ "id": 88871863,
+ "difficulty": 3,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Death 2 (Dif. 2)",
+ "id": 88871864,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Key 17",
+ "id": 88871865,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Potion 5",
+ "id": 88871866,
+ "difficulty": 1,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Fruit 3 (Dif. 4)",
+ "id": 88871867,
+ "difficulty": 4,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Crystal Mine Barrel - Gold 7 (Dif. 2)",
+ "id": 88871868,
+ "difficulty": 2,
+ "tags": [
+ "crystal_mine"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Light Potion",
+ "id": 88870512,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Growth",
+ "id": 88870513,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Reflective Shield",
+ "id": 88870514,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 1",
+ "id": 88870515,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 2",
+ "id": 88870516,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - 3-Way Shot",
+ "id": 88870517,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 3",
+ "id": 88870518,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 4",
+ "id": 88870519,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 5",
+ "id": 88870520,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Death",
+ "id": 88870521,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 6",
+ "id": 88870522,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 7 (Dif. 2)",
+ "id": 88870523,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 8",
+ "id": 88870524,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Marker's Javelin",
+ "id": 88870525,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Rapid Fire",
+ "id": 88870526,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Speed Boots (Dif. 4)",
+ "id": 88870527,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 9",
+ "id": 88870528,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure - Key 10 (Dif. 2)",
+ "id": 88870529,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Meat 1",
+ "id": 8887190,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Potion 1 (Dif. 4)",
+ "id": 8887191,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Thunder Hammer 1 (Dif. 2)",
+ "id": 8887192,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Fruit 1 (Dif. 4)",
+ "id": 8887193,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Scroll 1",
+ "id": 8887194,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Meat 2 (Dif. 4)",
+ "id": 8887195,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Fruit 2",
+ "id": 8887196,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Meat 3 (Dif. 2)",
+ "id": 8887197,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Scroll 2",
+ "id": 8887198,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Scroll 3",
+ "id": 8887199,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Fruit 3",
+ "id": 88871910,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Meat 4 (Dif. 3)",
+ "id": 88871911,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Fruit 4 (Dif. 2)",
+ "id": 88871912,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Meat 5",
+ "id": 88871913,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Scroll 4",
+ "id": 88871914,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 1",
+ "id": 88871915,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 2 (Dif. 2)",
+ "id": 88871916,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 3 (Dif. 2)",
+ "id": 88871917,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Dark Chest - Random 1 (Dif. 2)",
+ "id": 88871918,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Nothing 1",
+ "id": 88871919,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Nothing 2",
+ "id": 88871920,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Nothing 3",
+ "id": 88871921,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Nothing 4",
+ "id": 88871922,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Fruit 5 (Dif. 3)",
+ "id": 88871923,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Acid Amulet 1 (Dif. 4)",
+ "id": 88871924,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Nothing 5",
+ "id": 88871925,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Nothing 6",
+ "id": 88871926,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Gold 1 (Dif. 2)",
+ "id": 88871927,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 4 (Dif. 3)",
+ "id": 88871928,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 5",
+ "id": 88871929,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Fire Breath 1 (Dif. 3)",
+ "id": 88871930,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 6",
+ "id": 88871931,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Potion 1 (Dif. 3)",
+ "id": 88871932,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 7 (Dif. 3)",
+ "id": 88871933,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 8 (Dif. 4)",
+ "id": 88871934,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 9 (Dif. 3)",
+ "id": 88871935,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Fruit 6 (Dif. 2)",
+ "id": 88871936,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Death 1",
+ "id": 88871937,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Gold 2 (Dif. 3)",
+ "id": 88871938,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Meat 1 (Dif. 2)",
+ "id": 88871939,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 10 (Dif. 3)",
+ "id": 88871940,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 11 (Dif. 2)",
+ "id": 88871941,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 12",
+ "id": 88871942,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Gold 3",
+ "id": 88871943,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 13",
+ "id": 88871944,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 14",
+ "id": 88871945,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 15",
+ "id": 88871946,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 16",
+ "id": 88871947,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Death 2 (Dif. 3)",
+ "id": 88871948,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 17 (Dif. 3)",
+ "id": 88871949,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 18 (Dif. 2)",
+ "id": 88871950,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 19",
+ "id": 88871951,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 20 (Dif. 2)",
+ "id": 88871952,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Gold 4 (Dif. 2)",
+ "id": 88871953,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Scroll 5",
+ "id": 88871954,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Potion 2",
+ "id": 88871955,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Light Amulet 1 (Dif. 2)",
+ "id": 88871956,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Gold 1 (Dif. 4)",
+ "id": 88871957,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 21 (Dif. 2)",
+ "id": 88871958,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Fruit 7 (Dif. 4)",
+ "id": 88871959,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Fruit 8 (Dif. 3)",
+ "id": 88871960,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Gold 2",
+ "id": 88871961,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Key 22",
+ "id": 88871962,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Gold 3 (Dif. 3)",
+ "id": 88871963,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Gold 5",
+ "id": 88871964,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Potion 3 (Dif. 2)",
+ "id": 88871965,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Dark Chest - Random 2 (Dif. 3)",
+ "id": 88871966,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Invulnerability 1 (Dif. 2)",
+ "id": 88871967,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Invisibility 1",
+ "id": 88871968,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Fruit 1",
+ "id": 88871969,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Gold 6 (Dif. 4)",
+ "id": 88871970,
+ "difficulty": 4,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Gold 7",
+ "id": 88871971,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Super Shot 1 (Dif. 3)",
+ "id": 88871972,
+ "difficulty": 3,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Timestop 1",
+ "id": 88871973,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Potion 2",
+ "id": 88871974,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Barrel - Death 3 (Dif. 2)",
+ "id": 88871975,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Levitate 1",
+ "id": 88871976,
+ "difficulty": 1,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Erupting Fissure Chest - Gold 8 (Dif. 2)",
+ "id": 88871977,
+ "difficulty": 2,
+ "tags": [
+ "erupting_fissure"
+ ]
+ },
+ {
+ "name": "Yeti's Cavern - Yeti Mirror Shard",
+ "id": 88870610,
+ "difficulty": 1,
+ "tags": [
+ "yeti",
+ "no_obelisks",
+ "skipped_local",
+ "no_spawner"
+ ]
+ },
+ {
+ "name": "Desecrated Temple - Death 1",
+ "id": 88870530,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple - Death 2 (Dif. 2)",
+ "id": 88870531,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple - Speed Boots",
+ "id": 88870532,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Desecrated Temple - Reflective Shot",
+ "id": 88870533,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Desecrated Temple - Fire Amulet",
+ "id": 88870534,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Desecrated Temple - 3-Way Shot",
+ "id": 88870535,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 1 (Dif. 2)",
+ "id": 8887200,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 2 (Dif. 2)",
+ "id": 8887201,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 3 (Dif. 2)",
+ "id": 8887202,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 4 (Dif. 3)",
+ "id": 8887203,
+ "difficulty": 3,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 5 (Dif. 4)",
+ "id": 8887204,
+ "difficulty": 4,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 6",
+ "id": 8887205,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 7 (Dif. 2)",
+ "id": 8887206,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 8 (Dif. 2)",
+ "id": 8887207,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 9",
+ "id": 8887208,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 10",
+ "id": 8887209,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 11 (Dif. 3)",
+ "id": 88872010,
+ "difficulty": 3,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 12 (Dif. 2)",
+ "id": 88872011,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 13 (Dif. 4)",
+ "id": 88872012,
+ "difficulty": 4,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 14",
+ "id": 88872013,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 15 (Dif. 4)",
+ "id": 88872014,
+ "difficulty": 4,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Dark Chest - Random 1 (Dif. 3)",
+ "id": 88872015,
+ "difficulty": 3,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Dark Chest - Random 2 (Dif. 2)",
+ "id": 88872016,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 16 (Dif. 4)",
+ "id": 88872017,
+ "difficulty": 4,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 17",
+ "id": 88872018,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 18 (Dif. 2)",
+ "id": 88872019,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 19 (Dif. 3)",
+ "id": 88872020,
+ "difficulty": 3,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 20 (Dif. 3)",
+ "id": 88872021,
+ "difficulty": 3,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 21",
+ "id": 88872022,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 22",
+ "id": 88872023,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 23 (Dif. 4)",
+ "id": 88872024,
+ "difficulty": 4,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 24",
+ "id": 88872025,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 25",
+ "id": 88872026,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 26",
+ "id": 88872027,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 27",
+ "id": 88872028,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 28",
+ "id": 88872029,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Gold 29",
+ "id": 88872030,
+ "difficulty": 1,
+ "tags": [
+ "desecrated_temple"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Levitate 1 (Dif. 2)",
+ "id": 88872031,
+ "difficulty": 2,
+ "tags": [
+ "desecrated_temple",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Desecrated Temple Chest - Phoenix Familiar 1 (Dif. 3)",
+ "id": 88872032,
+ "difficulty": 3,
+ "tags": [
+ "desecrated_temple",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Altar of Skorne - Skorne's Mask",
+ "id": 88872033,
+ "difficulty": 1,
+ "tags": [
+ "altar_of_skorne",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Altar of Skorne - Skorne's Horns",
+ "id": 88872034,
+ "difficulty": 1,
+ "tags": [
+ "altar_of_skorne",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Altar of Skorne - Skorne's Left Gauntlet",
+ "id": 88872035,
+ "difficulty": 1,
+ "tags": [
+ "altar_of_skorne",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Altar of Skorne - Skorne's Right Gauntlet",
+ "id": 88872036,
+ "difficulty": 1,
+ "tags": [
+ "altar_of_skorne",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Fire Potion 1",
+ "id": 88870536,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Light Potion",
+ "id": 88870537,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Scroll 1",
+ "id": 88870538,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Fruit 1 (Dif. 2)",
+ "id": 88870539,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Key 1 (Dif. 3)",
+ "id": 88870540,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Invisibility (Dif. 2)",
+ "id": 88870541,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Speed Boots (Dif. 3)",
+ "id": 88870542,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Key 2",
+ "id": 88870543,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Key 3",
+ "id": 88870544,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Fruit 2",
+ "id": 88870545,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Anti-Death Halo",
+ "id": 88870546,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Scroll 2",
+ "id": 88870547,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Key 4",
+ "id": 88870548,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Death (Dif. 2)",
+ "id": 88870549,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Bananas (Dif. 4)",
+ "id": 88870550,
+ "difficulty": 4,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Fruit 3",
+ "id": 88870551,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Key 5 (Dif. 3)",
+ "id": 88870552,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Key 6 (Dif. 2)",
+ "id": 88870553,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Fire Potion 2",
+ "id": 88870554,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Shrink",
+ "id": 88870555,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Fire Amulet",
+ "id": 88870556,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - 3-Way Shot (Dif. 2)",
+ "id": 88870557,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Rapid Fire (Dif. 2)",
+ "id": 88870558,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches - Reflective Shot",
+ "id": 88870559,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - 3-Way Shot 1",
+ "id": 8887210,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Potion 1 (Dif. 4)",
+ "id": 8887211,
+ "difficulty": 4,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Reflective Shot 1 (Dif. 4)",
+ "id": 8887212,
+ "difficulty": 4,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Levitate 1",
+ "id": 8887213,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Scroll 1",
+ "id": 8887214,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Gold 1 (Dif. 4)",
+ "id": 8887215,
+ "difficulty": 4,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Scroll 2",
+ "id": 8887216,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Scroll 3",
+ "id": 8887217,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 1 (Dif. 3)",
+ "id": 8887218,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 2",
+ "id": 8887219,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 3",
+ "id": 88872110,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 4",
+ "id": 88872111,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 5",
+ "id": 88872112,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Death 1",
+ "id": 88872113,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Gold 2",
+ "id": 88872114,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Fruit 1 (Dif. 3)",
+ "id": 88872115,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 6",
+ "id": 88872116,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Gold 3",
+ "id": 88872117,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Death 2 (Dif. 4)",
+ "id": 88872118,
+ "difficulty": 4,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 7 (Dif. 3)",
+ "id": 88872119,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 8 (Dif. 2)",
+ "id": 88872120,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Death 3 (Dif. 2)",
+ "id": 88872121,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Potion 1 (Dif. 2)",
+ "id": 88872122,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Gold 1 (Dif. 3)",
+ "id": 88872123,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Meat 1",
+ "id": 88872124,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Fire Breath 1 (Dif. 2)",
+ "id": 88872125,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 9 (Dif. 2)",
+ "id": 88872126,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Gold 4 (Dif. 2)",
+ "id": 88872127,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 10 (Dif. 2)",
+ "id": 88872128,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 11",
+ "id": 88872129,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 12 (Dif. 2)",
+ "id": 88872130,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Potion 2",
+ "id": 88872131,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Gold 5 (Dif. 3)",
+ "id": 88872132,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 13",
+ "id": 88872133,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Meat 2 (Dif. 2)",
+ "id": 88872134,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Thunder Hammer 1",
+ "id": 88872135,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Gold 2",
+ "id": 88872136,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 14",
+ "id": 88872137,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 15 (Dif. 2)",
+ "id": 88872138,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Meat 3",
+ "id": 88872139,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 16 (Dif. 3)",
+ "id": 88872140,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Death 4 (Dif. 3)",
+ "id": 88872141,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Meat 1 (Dif. 4)",
+ "id": 88872142,
+ "difficulty": 4,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 17",
+ "id": 88872143,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Gold 6 (Dif. 2)",
+ "id": 88872144,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Phoenix Familiar 1",
+ "id": 88872145,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 18",
+ "id": 88872146,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Speed Boots 1 (Dif. 4)",
+ "id": 88872147,
+ "difficulty": 4,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Gold 7 (Dif. 2)",
+ "id": 88872148,
+ "difficulty": 2,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 19 (Dif. 4)",
+ "id": 88872149,
+ "difficulty": 4,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Potion 3 (Dif. 3)",
+ "id": 88872150,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Gold 3",
+ "id": 88872151,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 20",
+ "id": 88872152,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Barrel - Key 21",
+ "id": 88872153,
+ "difficulty": 1,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Fire Amulet 1 (Dif. 3)",
+ "id": 88872154,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Battle Trenches Chest - Meat 4 (Dif. 3)",
+ "id": 88872155,
+ "difficulty": 3,
+ "tags": [
+ "battle_trenches"
+ ]
+ },
+ {
+ "name": "Fortified Towers - Meat (Dif. 3)",
+ "id": 88870560,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Fortified Towers - Key 1",
+ "id": 88870561,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Fortified Towers - Large Pile of Gold (Dif. 3)",
+ "id": 88870562,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers - Small Pile of Gold (Dif. 2)",
+ "id": 88870563,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers - Reflective Shot (Dif. 2)",
+ "id": 88870564,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers - Key 2",
+ "id": 88870565,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers - Soul Savior",
+ "id": 88870566,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Fire Breath 1 (Dif. 4)",
+ "id": 8887220,
+ "difficulty": 4,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Scroll 1",
+ "id": 8887221,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Invulnerability 1",
+ "id": 8887222,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Thunder Hammer 1 (Dif. 2)",
+ "id": 8887223,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Reflective Shield 1",
+ "id": 8887224,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Potion 1 (Dif. 4)",
+ "id": 8887225,
+ "difficulty": 4,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 1 (Dif. 2)",
+ "id": 8887226,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 2 (Dif. 4)",
+ "id": 8887227,
+ "difficulty": 4,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Potion 2 (Dif. 2)",
+ "id": 8887228,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Fortified Towers Dark Chest - Random 1 (Dif. 3)",
+ "id": 8887229,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Fruit 1",
+ "id": 88872210,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Scroll 2",
+ "id": 88872211,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Gold 1 (Dif. 4)",
+ "id": 88872212,
+ "difficulty": 4,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Scroll 3",
+ "id": 88872213,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 3",
+ "id": 88872214,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 4",
+ "id": 88872215,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Scroll 4",
+ "id": 88872216,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 5",
+ "id": 88872217,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Fruit 2 (Dif. 3)",
+ "id": 88872218,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Gold 2",
+ "id": 88872219,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Potion 3 (Dif. 3)",
+ "id": 88872220,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 6 (Dif. 3)",
+ "id": 88872221,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Meat 1 (Dif. 4)",
+ "id": 88872222,
+ "difficulty": 4,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Speed Boots 1 (Dif. 3)",
+ "id": 88872223,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Phoenix Familiar 1 (Dif. 2)",
+ "id": 88872224,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Fruit 1 (Dif. 4)",
+ "id": 88872225,
+ "difficulty": 4,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Gold 1",
+ "id": 88872226,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Meat 2",
+ "id": 88872227,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - 3-Way Shot 1 (Dif. 3)",
+ "id": 88872228,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Death 1 (Dif. 2)",
+ "id": 88872229,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Gold 3",
+ "id": 88872230,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Fruit 3 (Dif. 2)",
+ "id": 88872231,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 7 (Dif. 3)",
+ "id": 88872232,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Gold 4 (Dif. 3)",
+ "id": 88872233,
+ "difficulty": 3,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 8",
+ "id": 88872234,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Nothing 1",
+ "id": 88872235,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Potion 1",
+ "id": 88872236,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Key 9 (Dif. 2)",
+ "id": 88872237,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Barrel - Rapid Fire 1",
+ "id": 88872238,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Fruit 2",
+ "id": 88872239,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Meat 1 (Dif. 2)",
+ "id": 88872240,
+ "difficulty": 2,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Meat 2",
+ "id": 88872241,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Fortified Towers Chest - Fire Breath 1",
+ "id": 88872242,
+ "difficulty": 1,
+ "tags": [
+ "fortified_towers"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Thunder Hammer",
+ "id": 88870567,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Key 1 (Dif. 2)",
+ "id": 88870568,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Runestone",
+ "id": 88870569,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Drumstick (Dif. 4)",
+ "id": 88870570,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Large Pile of Gold (Dif. 3)",
+ "id": 88870571,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Very Small Pile of Gold 1",
+ "id": 88870572,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Half Barrel of Gold 1 (Dif. 4)",
+ "id": 88870573,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Light Potion",
+ "id": 88870574,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Key 2 (Dif. 2)",
+ "id": 88870575,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Death",
+ "id": 88870576,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Fruit",
+ "id": 88870577,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Small Pile of Gold (Dif. 2)",
+ "id": 88870578,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Key 3",
+ "id": 88870579,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Key 4",
+ "id": 88870580,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Very Small Pile of Gold 2 (Dif. 2)",
+ "id": 88870581,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Potion Pile (Dif. 2)",
+ "id": 88870582,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress - Key 5",
+ "id": 88870583,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 1 (Dif. 4)",
+ "id": 8887230,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Potion 1 (Dif. 4)",
+ "id": 8887231,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Fruit 1 (Dif. 3)",
+ "id": 8887232,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Timestop 1",
+ "id": 8887233,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Poison Fruit 1",
+ "id": 8887234,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Meat 1 (Dif. 2)",
+ "id": 8887235,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 2",
+ "id": 8887236,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Fire Shield 1 (Dif. 3)",
+ "id": 8887237,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Scroll 1",
+ "id": 8887238,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Levitate 1",
+ "id": 8887239,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Scroll 2",
+ "id": 88872310,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Scroll 3",
+ "id": 88872311,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Meat 2",
+ "id": 88872312,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Invisibility 1 (Dif. 2)",
+ "id": 88872313,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Dark Chest - Random 1",
+ "id": 88872314,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 3",
+ "id": 88872315,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 4",
+ "id": 88872316,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 5",
+ "id": 88872317,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 6 (Dif. 4)",
+ "id": 88872318,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 7 (Dif. 3)",
+ "id": 88872319,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 8 (Dif. 3)",
+ "id": 88872320,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 9",
+ "id": 88872321,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 10",
+ "id": 88872322,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 11",
+ "id": 88872323,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 12",
+ "id": 88872324,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 13",
+ "id": 88872325,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 14 (Dif. 3)",
+ "id": 88872326,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Speed Boots 1 (Dif. 2)",
+ "id": 88872327,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Gold 1 (Dif. 3)",
+ "id": 88872328,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Fruit 2 (Dif. 4)",
+ "id": 88872329,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 15 (Dif. 2)",
+ "id": 88872330,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Gold 1 (Dif. 2)",
+ "id": 88872331,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Gold 2",
+ "id": 88872332,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Fruit 3 (Dif. 2)",
+ "id": 88872333,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Light Amulet 1",
+ "id": 88872334,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Gold 3 (Dif. 4)",
+ "id": 88872335,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Gold 4 (Dif. 3)",
+ "id": 88872336,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Gold 5",
+ "id": 88872337,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Gold 6 (Dif. 2)",
+ "id": 88872338,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Gold 7 (Dif. 4)",
+ "id": 88872339,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Gold 8",
+ "id": 88872340,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Scroll 1",
+ "id": 88872341,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 16 (Dif. 4)",
+ "id": 88872342,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Death 1 (Dif. 3)",
+ "id": 88872343,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Gold 2",
+ "id": 88872344,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Reflective Shot 1 (Dif. 4)",
+ "id": 88872345,
+ "difficulty": 4,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Gold 3 (Dif. 2)",
+ "id": 88872346,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Fruit 4",
+ "id": 88872347,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Potion 1 (Dif. 3)",
+ "id": 88872348,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Key 17",
+ "id": 88872349,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Death 2 (Dif. 2)",
+ "id": 88872350,
+ "difficulty": 2,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Barrel - Gold 4 (Dif. 3)",
+ "id": 88872351,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - 3-Way Shot 1",
+ "id": 88872352,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Potion 2",
+ "id": 88872353,
+ "difficulty": 1,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Infernal Fortress Chest - Meat 1 (Dif. 3)",
+ "id": 88872354,
+ "difficulty": 3,
+ "tags": [
+ "infernal_fortress"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Potion Pile",
+ "id": 88870584,
+ "difficulty": 1,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Rapid Fire (Dif. 3)",
+ "id": 88870585,
+ "difficulty": 3,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Reflective Shield (Dif. 2)",
+ "id": 88870586,
+ "difficulty": 2,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Reflective Shot (Dif. 2)",
+ "id": 88870587,
+ "difficulty": 2,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Lightning Shield (Dif. 2)",
+ "id": 88870588,
+ "difficulty": 2,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Fire Shield (Dif. 3)",
+ "id": 88870589,
+ "difficulty": 3,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Acid Amulet (Dif. 4)",
+ "id": 88870590,
+ "difficulty": 4,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Light Amulet (Dif. 3)",
+ "id": 88870591,
+ "difficulty": 3,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Lightning Amulet (Dif. 2)",
+ "id": 88870592,
+ "difficulty": 2,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Fire Amulet",
+ "id": 88870593,
+ "difficulty": 1,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Growth",
+ "id": 88870594,
+ "difficulty": 1,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Fire Breath",
+ "id": 88870595,
+ "difficulty": 1,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Phoenix Familiar (Dif. 2)",
+ "id": 88870596,
+ "difficulty": 2,
+ "tags": [
+ "gates_of_the_underworld",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Invulnerability (Dif. 4)",
+ "id": 88870597,
+ "difficulty": 4,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Acid Breath (Dif. 3)",
+ "id": 88870598,
+ "difficulty": 3,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - 5-Way Shot",
+ "id": 88870599,
+ "difficulty": 1,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Thunder Hammer (Dif. 4)",
+ "id": 88870600,
+ "difficulty": 4,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Lightning Breath (Dif. 2)",
+ "id": 88870601,
+ "difficulty": 2,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Timestop",
+ "id": 88870602,
+ "difficulty": 1,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - 3-Way Shot (Dif. 4)",
+ "id": 88870603,
+ "difficulty": 4,
+ "tags": [
+ "gates_of_the_underworld",
+ "no_obelisks"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Super Shot",
+ "id": 88870604,
+ "difficulty": 1,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Speed Boots (Dif. 3)",
+ "id": 88870605,
+ "difficulty": 3,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ },
+ {
+ "name": "Gates of the Underworld - Shrink (Dif. 4)",
+ "id": 88870606,
+ "difficulty": 4,
+ "tags": [
+ "gates_of_the_underworld"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py
index fbfdbfa871e..50c19e12c38 100644
--- a/worlds/hk/Options.py
+++ b/worlds/hk/Options.py
@@ -556,7 +556,7 @@ class CostSanityHybridChance(Range):
),
"default": cost.weight
}
- if cost == 'GEO':
+ if term == 'GEO':
extra_data["__doc__"] += " Geo costs will never be chosen for Grubfather, Seer, or Egg Shop."
option = type(option_name, (Range,), extra_data)
diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py
index 718bf3c41cf..755f35babe2 100644
--- a/worlds/hk/__init__.py
+++ b/worlds/hk/__init__.py
@@ -696,7 +696,7 @@ def stage_write_spoiler(cls, multiworld: MultiWorld, spoiler_handle):
def get_multi_location_name(self, base: str, i: typing.Optional[int]) -> str:
if i is None:
i = len(self.created_multi_locations[base]) + 1
- assert 1 <= 16, "limited number of multi location IDs reserved."
+ assert i <= 16, "limited number of multi location IDs reserved."
return f"{base}_{i}"
def get_filler_item_name(self) -> str:
diff --git a/worlds/jakanddaxter/agents/memory_reader.py b/worlds/jakanddaxter/agents/memory_reader.py
index 493fbde39e4..9b39f18dcb9 100644
--- a/worlds/jakanddaxter/agents/memory_reader.py
+++ b/worlds/jakanddaxter/agents/memory_reader.py
@@ -4,7 +4,8 @@
import sys
from typing import ByteString, Callable
import json
-from PyMemoryEditor import OpenProcess, ProcessNotFoundError, ProcessIDNotExistsError, ClosedProcess
+from PyMemoryEditor import OpenProcess, PyMemoryEditorError
+
from dataclasses import dataclass
import Utils
@@ -228,10 +229,8 @@ async def main_tick(self):
if self.connected:
try:
- # TODO - When PyMemoryEditor issue #15 is resolved, swap out this line for the commented one.
- # self.gk_process.read_process_memory(0, bytes, 1) # Ping to see if it's alive.
- OpenProcess(process_name=jak1_gk)
- except (ProcessNotFoundError, ProcessIDNotExistsError, ClosedProcess):
+ OpenProcess(name=jak1_gk) # Ping to see if it's alive.
+ except PyMemoryEditorError as e:
msg = (f"Error reading game memory! (Did the game crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
@@ -241,6 +240,7 @@ async def main_tick(self):
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
+ logger.error(e)
self.connected = False
else:
return
@@ -275,10 +275,11 @@ async def main_tick(self):
async def connect(self):
try:
- self.gk_process = OpenProcess(process_name=jak1_gk) # The GOAL Kernel
+ self.gk_process = OpenProcess(name=jak1_gk) # The GOAL Kernel
logger.debug(f"Found the gk process: {self.gk_process.pid}")
- except ProcessNotFoundError:
+ except PyMemoryEditorError as e:
self.log_error(logger, "Could not find the game process.")
+ logger.error(e)
self.connected = False
return
@@ -320,7 +321,7 @@ async def verify_memory_version(self):
self.connected = True
else:
raise Exception(memory_version_offset, sizeof_uint32)
- except (ProcessNotFoundError, ProcessIDNotExistsError, ClosedProcess, Exception):
+ except Exception as e:
if memory_version is None:
msg = (f"Could not find a version number in the OpenGOAL memory structure!\n"
f" Expected Version: {str(expected_memory_version)}\n"
@@ -343,6 +344,7 @@ async def verify_memory_version(self):
f" Click Versions and verify the latest version is marked 'Active'.\n"
f" Close all launchers, games, clients, and console windows, then restart Archipelago.")
self.log_error(logger, msg)
+ logger.error(e)
self.connected = False
async def print_status(self):
@@ -461,7 +463,7 @@ def read_memory(self) -> list[int]:
self.finished_game = True
self.log_success(logger, "Congratulations! You finished the game!")
- except (ProcessNotFoundError, ProcessIDNotExistsError, ClosedProcess):
+ except (PyMemoryEditorError, OSError) as e:
msg = (f"Error reading game memory! (Did the game crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
@@ -471,6 +473,7 @@ def read_memory(self) -> list[int]:
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
+ logger.error(e)
self.connected = False
return self.location_outbox
diff --git a/worlds/jakanddaxter/agents/repl_client.py b/worlds/jakanddaxter/agents/repl_client.py
index 2a624d7f742..73f7a10bfbd 100644
--- a/worlds/jakanddaxter/agents/repl_client.py
+++ b/worlds/jakanddaxter/agents/repl_client.py
@@ -8,7 +8,7 @@
from queue import Queue
from typing import Callable
-from PyMemoryEditor import OpenProcess, ProcessNotFoundError, ProcessIDNotExistsError, ClosedProcess
+from PyMemoryEditor import OpenProcess, PyMemoryEditorError
import asyncio
from asyncio import StreamReader, StreamWriter, Lock
@@ -65,8 +65,8 @@ class JakAndDaxterReplClient:
# The REPL client needs the REPL/compiler process running, but that process
# also needs the game running. Therefore, the REPL client needs both running.
- gk_process: OpenProcess = None
- goalc_process: OpenProcess = None
+ gk_process: OpenProcess | None = None
+ goalc_process: OpenProcess | None = None
item_inbox: dict[int, NetworkItem] = {}
inbox_index = 0
@@ -101,10 +101,8 @@ async def main_tick(self):
if self.connected:
try:
- # TODO - When PyMemoryEditor issue #15 is resolved, swap out this line for the commented one.
- # self.gk_process.read_process_memory(0, bytes, 1) # Ping to see if it's alive.
- OpenProcess(process_name=jak1_gk)
- except (ProcessNotFoundError, ProcessIDNotExistsError, ClosedProcess):
+ OpenProcess(name=jak1_gk)
+ except PyMemoryEditorError as e:
msg = (f"Error reading game memory! (Did the game crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
@@ -114,12 +112,11 @@ async def main_tick(self):
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
+ logger.error(e)
self.connected = False
try:
- # TODO - When PyMemoryEditor issue #15 is resolved, swap out this line for the commented one.
- # self.goalc_process.read_process_memory(0, bytes, 1) # Ping to see if it's alive.
- OpenProcess(process_name=jak1_goalc)
- except (ProcessNotFoundError, ProcessIDNotExistsError, ClosedProcess):
+ OpenProcess(name=jak1_goalc)
+ except PyMemoryEditorError as e:
msg = (f"Error sending data to compiler! (Did the compiler crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
@@ -129,6 +126,7 @@ async def main_tick(self):
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
+ logger.error(e)
self.connected = False
else:
return
@@ -180,17 +178,19 @@ async def send_form(self, form: str, print_ok: bool = True) -> bool:
async def connect(self):
try:
- self.gk_process = OpenProcess(process_name=jak1_gk) # The GOAL Kernel
+ self.gk_process = OpenProcess(name=jak1_gk) # The GOAL Kernel
logger.debug("Found the gk process: " + str(self.gk_process.pid))
- except ProcessNotFoundError:
+ except PyMemoryEditorError as e:
self.log_error(logger, "Could not find the game process.")
+ logger.error(e)
return
try:
- self.goalc_process = OpenProcess(process_name=jak1_goalc) # The GOAL Compiler and REPL
+ self.goalc_process = OpenProcess(name=jak1_goalc) # The GOAL Compiler and REPL
logger.debug("Found the goalc process: " + str(self.goalc_process.pid))
- except ProcessNotFoundError:
+ except PyMemoryEditorError as e:
self.log_error(logger, "Could not find the compiler process.")
+ logger.error(e)
return
try:
diff --git a/worlds/jakanddaxter/client.py b/worlds/jakanddaxter/client.py
index 710e0ef89d9..b76e6e70f79 100644
--- a/worlds/jakanddaxter/client.py
+++ b/worlds/jakanddaxter/client.py
@@ -22,7 +22,7 @@
import Utils
from CommonClient import ClientCommandProcessor, CommonContext, server_loop, gui_enabled
from NetUtils import ClientStatus
-from PyMemoryEditor import OpenProcess, ProcessNotFoundError
+from PyMemoryEditor import OpenProcess, ProcessNotFoundError, AmbiguousProcessNameError
# Jak imports
from .game_id import jak1_name, jak1_gk, jak1_goalc
@@ -471,17 +471,25 @@ async def run_game(ctx: JakAndDaxterContext):
# These may already be running. If they are not running, try to start them.
gk_running = False
try:
- OpenProcess(process_name=jak1_gk) # The GOAL Kernel
+ OpenProcess(name=jak1_gk) # The GOAL Kernel
gk_running = True
except ProcessNotFoundError:
ctx.on_log_warn(logger, "Game not running, attempting to start.")
+ except AmbiguousProcessNameError:
+ ctx.on_log_error(logger, "Two or more instances of the game were found. "
+ "Please close one and restart this client.")
+ return
goalc_running = False
try:
- OpenProcess(process_name=jak1_goalc) # The GOAL Compiler and REPL
+ OpenProcess(name=jak1_goalc) # The GOAL Compiler and REPL
goalc_running = True
except ProcessNotFoundError:
ctx.on_log_warn(logger, "Compiler not running, attempting to start.")
+ except AmbiguousProcessNameError:
+ ctx.on_log_error(logger, "Two or more instances of the compiler were found. "
+ "Please close one and restart this client.")
+ return
try:
auto_detect_root_directory = JakAndDaxterWorld.settings.auto_detect_root_directory
diff --git a/worlds/jakanddaxter/requirements.txt b/worlds/jakanddaxter/requirements.txt
index 9ec60e8d107..5a5f9919318 100644
--- a/worlds/jakanddaxter/requirements.txt
+++ b/worlds/jakanddaxter/requirements.txt
@@ -1 +1 @@
-PyMemoryEditor>=1.6.0
+PyMemoryEditor>=2.0.1
diff --git a/worlds/kh2/ClientStuff/Client.py b/worlds/kh2/ClientStuff/Client.py
index 4b4afd8c169..9c62aed268f 100644
--- a/worlds/kh2/ClientStuff/Client.py
+++ b/worlds/kh2/ClientStuff/Client.py
@@ -694,8 +694,7 @@ async def kh2_watcher(ctx: KH2Context):
ctx.kh2_finished_game = True
if ctx.sending:
- message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
- await ctx.send_msgs(message)
+ await ctx.check_locations(ctx.sending)
if ctx.queued_puzzle_popup:
await asyncio.create_task(ctx.displayPuzzlePieceTextinGame(ctx.queued_puzzle_popup[0])) # send the num 1 index of whats in the queue
diff --git a/worlds/kh2/ClientStuff/RecieveItems.py b/worlds/kh2/ClientStuff/RecieveItems.py
index e232f262e83..e957b7add1a 100644
--- a/worlds/kh2/ClientStuff/RecieveItems.py
+++ b/worlds/kh2/ClientStuff/RecieveItems.py
@@ -314,7 +314,7 @@ async def verifyItems(self):
amount_found_in_slots += 1
if item_name in self.kh2_seed_save["SoldEquipment"]:
amount_found_in_slots += self.kh2_seed_save["SoldEquipment"][item_name]
- inInventory = self.kh2_seed_save_cache["AmountInvo"]["Equipment"][item_name] - amount_found_in_slots
+ inInventory = max(self.kh2_seed_save_cache["AmountInvo"]["Equipment"][item_name] - amount_found_in_slots, 0)
if inInventory != self.kh2_read_byte(self.Save + item_data.memaddr):
self.kh2_write_byte(self.Save + item_data.memaddr, inInventory)
diff --git a/worlds/kh2/__init__.py b/worlds/kh2/__init__.py
index c7ed1522566..a65d406ea09 100644
--- a/worlds/kh2/__init__.py
+++ b/worlds/kh2/__init__.py
@@ -475,9 +475,10 @@ def emblem_verify(self):
logging.info(
f"Lucky Emblem Amount {self.options.LuckyEmblemsAmount.value} is less than required "
f"{self.options.LuckyEmblemsRequired.value} for player {self.multiworld.get_file_safe_player_name(self.player)}."
- f" Setting amount to {self.options.LuckyEmblemsRequired.value}")
- luckyemblemamount = max(self.lucky_emblem_amount, self.lucky_emblem_required)
- self.options.LuckyEmblemsAmount.value = luckyemblemamount
+ f" Swapping {self.options.LuckyEmblemsRequired.value} and {self.options.LuckyEmblemsAmount.value}")
+ temp = self.options.LuckyEmblemsAmount.value
+ self.options.LuckyEmblemsAmount.value = self.options.LuckyEmblemsRequired.value
+ self.options.LuckyEmblemsRequired.value = temp
self.item_quantity_dict[ItemName.LuckyEmblem] = self.options.LuckyEmblemsAmount.value
# give this proof to unlock the final door once the player has the amount of lucky emblem required
diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py
index d1b672db7d8..a05ff0aa88d 100644
--- a/worlds/messenger/__init__.py
+++ b/worlds/messenger/__init__.py
@@ -19,7 +19,7 @@
from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices
from .subclasses import MessengerItem, MessengerRegion, MessengerShopLocation
from .transitions import disconnect_entrances, shuffle_transitions
-from .universal_tracker import reverse_portal_exits_into_portal_plando, reverse_transitions_into_plando_connections
+from .universal_tracker import reverse_portal_exits_into_portal_plando, reverse_shop_prices, reverse_transitions_into_plando_connections
components.append(
Component(
@@ -169,7 +169,11 @@ def generate_early(self) -> None:
if self.options.early_meditation:
self.multiworld.early_items[self.player]["Meditation"] = 1
- self.shop_prices, self.figurine_prices = shuffle_shop_prices(self)
+ if not hasattr(self.multiworld, "re_gen_passthrough"):
+ self.shop_prices, self.figurine_prices = shuffle_shop_prices(self)
+ else:
+ if slot_data := self.multiworld.re_gen_passthrough.get(self.game):
+ self.shop_prices, self.figurine_prices = reverse_shop_prices(slot_data["shop"], slot_data["figures"])
starting_portals = ["Autumn Hills", "Howling Grotto", "Glacial Peak", "Riviere Turquoise", "Sunken Shrine",
"Searing Crags"]
@@ -276,6 +280,10 @@ def create_items(self) -> None:
self.multiworld.itempool += filler
+ if hasattr(self.multiworld, "re_gen_passthrough"):
+ if slot_data := self.multiworld.re_gen_passthrough.get(self.game):
+ self.total_shards = slot_data["max_price"]
+
def set_rules(self) -> None:
logic = self.options.logic_level
if logic == Logic.option_normal:
diff --git a/worlds/messenger/connections.py b/worlds/messenger/connections.py
index 490803e9596..b6f778bb720 100644
--- a/worlds/messenger/connections.py
+++ b/worlds/messenger/connections.py
@@ -37,7 +37,7 @@
"Autumn Hills - Lakeside Checkpoint",
],
"Dimension Climb Shop": [
- "Autumn Hills - Lakeside Checkpoint",
+ "Autumn Hills - Lakeside Checkpoint", # Only possible in 16 bits
"Autumn Hills - Portal",
"Autumn Hills - Double Swing Checkpoint",
],
@@ -60,7 +60,7 @@
"Double Swing Checkpoint": [
"Autumn Hills - Dimension Climb Shop",
"Autumn Hills - Spike Ball Swing Checkpoint",
- "Autumn Hills - Bottom",
+ "Autumn Hills - Bottom", # Only possible in 16 bits
],
"Spike Ball Swing Checkpoint": [
"Autumn Hills - Double Swing Checkpoint",
@@ -90,7 +90,6 @@
"Forlorn Temple - Sunny Day Checkpoint",
],
"Climb Shop": [
- "Forlorn Temple - Rocket Maze Checkpoint",
"Forlorn Temple - Rocket Sunset Shop",
],
"Rocket Sunset Shop": [
@@ -135,6 +134,7 @@
],
"Triple Spike Crushers Shop": [
"Catacombs - Bottom Left",
+ "Catacombs - Top Left",
"Catacombs - Death Trap Checkpoint",
],
"Ruxxtin Shop": [
@@ -153,7 +153,7 @@
"Catacombs - Bottom",
"Catacombs - Death Trap Checkpoint",
"Catacombs - Crusher Gauntlet Checkpoint",
- "Catacombs - Ruxxtin Shop",
+ "Catacombs - Ruxxtin Shop", # In 16 bits, requires rope dart (to reach the Dirty Pond Seal as well)
],
},
"Bamboo Creek": {
@@ -486,8 +486,7 @@
"Barm'athaziel Shop": [
"Underworld - Hot Tub Checkpoint",
],
- "Key of Chaos Shop": [
- ],
+ "Key of Chaos Shop": [],
"Hot Dip Checkpoint": [
"Underworld - Left Shop",
"Underworld - Fireball Wave Shop",
@@ -536,8 +535,7 @@
"Restock Shop": [
"Riviere Turquoise - Butterfly Matriarch Shop",
],
- "Butterfly Matriarch Shop": [
- ],
+ "Butterfly Matriarch Shop": [],
"Flower Flight Checkpoint": [
"Riviere Turquoise - Waterfall Shop",
"Riviere Turquoise - Launch of Faith Shop",
@@ -556,6 +554,7 @@
"Elemental Skylands - Air Generator Shop",
],
"Air Generator Shop": [
+ "Elemental Skylands - Air Seal Checkpoint",
"Elemental Skylands - Earth Shmup",
],
"Earth Shmup": [
@@ -574,6 +573,7 @@
"Elemental Skylands - Water Generator Shop",
],
"Water Generator Shop": [
+ "Elemental Skylands - Water Intro Shop",
"Elemental Skylands - Fire Shmup",
],
"Fire Shmup": [
@@ -639,6 +639,7 @@
},
}
+# fmt: off
RANDOMIZED_CONNECTIONS: dict[str, str] = {
"Ninja Village - Right": "Autumn Hills - Left",
"Autumn Hills - Left": "Ninja Village - Right",
@@ -678,6 +679,7 @@
"Dark Cave - Left": "Riviere Turquoise - Right",
"Sunken Shrine - Left": "Howling Grotto - Bottom",
}
+# fmt: on
TRANSITIONS: list[str] = [
"Ninja Village - Right",
diff --git a/worlds/messenger/portals.py b/worlds/messenger/portals.py
index 8e46eee17d6..9ea1b612901 100644
--- a/worlds/messenger/portals.py
+++ b/worlds/messenger/portals.py
@@ -96,10 +96,10 @@
"Air Generator",
"Earth Intro",
"Earth Generator",
- "Fire Intro",
- "Fire Generator",
"Water Intro",
"Water Generator",
+ "Fire Intro",
+ "Fire Generator",
],
"Sunken Shrine": [
"Above Portal",
@@ -107,7 +107,7 @@
"Sun Path",
"Tabi Gauntlet",
"Moon Path",
- ]
+ ],
}
CHECKPOINTS: dict[str, list[str]] = {
diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py
index 23f2bf32c0a..babc4cce6cb 100644
--- a/worlds/messenger/rules.py
+++ b/worlds/messenger/rules.py
@@ -1,7 +1,8 @@
from typing import TYPE_CHECKING
-from BaseClasses import CollectionState, CollectionRule, Region
+from BaseClasses import CollectionRule, CollectionState, Region
from worlds.generic.Rules import add_rule, allow_self_locking_items
+
from .constants import NOTES, PHOBEKINS
from .options import MessengerAccessibility
@@ -30,6 +31,7 @@ def __init__(self, world: "MessengerWorld") -> None:
self.required_seals = world.required_seals
# dict of connection names and requirements to traverse the exit
+ # fmt: off
self.connection_rules = {
# from ToTHQ
"Artificer's Portal":
@@ -131,7 +133,7 @@ def __init__(self, world: "MessengerWorld") -> None:
lambda state: state.has("Magic Firefly", self.player)
and state.multiworld.get_location("Quillshroom Marsh - Queen of Quills", self.player)
.can_reach(state),
- "Glacial Peak - Tower Entrance Shop -> Glacial Peak - Top":
+ "Glacial Peak - Top -> Cloud Ruins - Left":
lambda state: state.has("Ruxxtin's Amulet", self.player),
"Glacial Peak - Projectile Spike Pit Checkpoint -> Glacial Peak - Left":
lambda state: self.has_dart(state) or (self.can_dboost(state) and self.has_wingsuit(state)),
@@ -173,7 +175,7 @@ def __init__(self, world: "MessengerWorld") -> None:
lambda state: self.has_wingsuit(state) and self.can_dboost(state),
# Underworld
"Underworld - Left -> Underworld - Left Shop":
- self.has_tabi,
+ lambda state: self.has_tabi(state) or self.has_vertical(state),
"Underworld - Left Shop -> Underworld - Left":
self.has_tabi,
"Underworld - Hot Dip Checkpoint -> Underworld - Lava Run Checkpoint":
@@ -208,6 +210,10 @@ def __init__(self, world: "MessengerWorld") -> None:
self.has_wingsuit,
"Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Generator Shop":
self.has_wingsuit,
+ "Elemental Skylands - Earth Intro Shop -> Elemental Skylands - Earth Generator Shop":
+ self.has_dart,
+ "Elemental Skylands - Water Generator Shop -> Elemental Skylands - Water Intro Shop":
+ self.can_destroy_projectiles,
# Sunken Shrine
"Sunken Shrine - Portal -> Sunken Shrine - Sun Path Shop":
self.has_tabi,
@@ -220,6 +226,7 @@ def __init__(self, world: "MessengerWorld") -> None:
"Sunken Shrine - Tabi Gauntlet Shop -> Sunken Shrine - Sun Path Shop":
lambda state: self.can_dboost(state) or self.has_dart(state),
}
+ # fmt: on
# dict of connection names and the regions checked in the requirements to traverse the exit
self.indirect_conditions = {
@@ -231,6 +238,7 @@ def __init__(self, world: "MessengerWorld") -> None:
],
}
+ # fmt: off
self.location_rules = {
# hq
"Money Wrench": self.can_shop,
@@ -261,7 +269,7 @@ def __init__(self, world: "MessengerWorld") -> None:
"Howling Grotto Seal - Windy Saws and Balls":
self.has_wingsuit,
"Howling Grotto Seal - Crushing Pits":
- lambda state: self.has_wingsuit(state) and self.has_dart(state),
+ self.has_dart,
"Howling Grotto - Emerald Golem":
self.has_wingsuit,
# searing crags
@@ -280,7 +288,8 @@ def __init__(self, world: "MessengerWorld") -> None:
"Tower of Time Seal - Time Waster":
self.has_dart,
# corrupted future
- "Corrupted Future - Key of Courage": lambda state: state.has("Magic Firefly", self.player),
+ "Corrupted Future - Key of Courage":
+ lambda state: state.has("Magic Firefly", self.player),
# cloud ruins
"Time Warp Mega Shard":
lambda state: self.has_vertical(state) or self.can_dboost(state),
@@ -312,21 +321,18 @@ def __init__(self, world: "MessengerWorld") -> None:
"Riviere Turquoise Seal - Bounces and Balls":
self.can_dboost,
"Riviere Turquoise Seal - Launch of Faith":
- lambda state: self.has_vertical(state),
+ self.has_vertical,
# elemental skylands
- "Elemental Skylands - Key of Symbiosis":
- self.has_dart,
"Elemental Skylands Seal - Air":
self.has_wingsuit,
"Elemental Skylands Seal - Water":
- lambda state: self.has_dart(state) and state.has("Currents Master", self.player),
+ lambda state: state.has("Currents Master", self.player),
"Elemental Skylands Seal - Fire":
- lambda state: self.has_dart(state) and self.can_destroy_projectiles(state) and self.is_aerobatic(state),
+ lambda state: self.can_destroy_projectiles(state) and self.is_aerobatic(state),
"Earth Mega Shard":
self.has_dart,
- "Water Mega Shard":
- self.has_dart,
}
+ # fmt: on
if self.required_seals:
self.connection_rules["Shrink Down"] = self.has_enough_seals
@@ -393,6 +399,7 @@ class MessengerHardRules(MessengerRules):
def __init__(self, world: "MessengerWorld") -> None:
super().__init__(world)
+ # fmt: off
self.connection_rules.update(
{
# Autumn Hills
@@ -439,6 +446,8 @@ def __init__(self, world: "MessengerWorld") -> None:
# Elemental Skylands
"Elemental Skylands - Air Intro Shop -> Elemental Skylands - Air Generator Shop":
self.true,
+ "Elemental Skylands - Earth Intro Shop -> Elemental Skylands - Earth Generator Shop":
+ self.true,
# Riviere Turquoise
"Riviere Turquoise - Waterfall Shop -> Riviere Turquoise - Flower Flight Checkpoint":
self.true,
@@ -448,29 +457,31 @@ def __init__(self, world: "MessengerWorld") -> None:
self.can_double_dboost,
}
)
+ # fmt: on
+ # fmt: off
self.location_rules.update(
{
"Autumn Hills Seal - Spike Ball Darts":
- lambda state: self.has_vertical(state) and self.has_windmill(state) or self.is_aerobatic(state),
+ lambda state: (self.has_vertical(state) and self.has_windmill(state)) or self.is_aerobatic(state),
"Autumn Hills Seal - Double Swing Saws":
lambda state: self.has_vertical(state) or self.can_destroy_projectiles(state),
"Bamboo Creek - Claustro":
self.has_wingsuit,
"Bamboo Creek Seal - Spike Ball Pits":
self.true,
+ "Above Entrance Mega Shard": # Just reset to the menu and you can get it with full health
+ self.true,
"Howling Grotto Seal - Windy Saws and Balls":
self.true,
+ "Howling Grotto Seal - Crushing Pits":
+ self.has_vertical,
"Searing Crags Seal - Triple Ball Spinner":
self.true,
"Glacial Peak Seal - Ice Climbers":
lambda state: self.has_vertical(state) or self.can_dboost(state),
"Glacial Peak Seal - Projectile Spike Pit":
lambda state: self.can_dboost(state) or self.can_destroy_projectiles(state),
- "Glacial Peak Seal - Glacial Air Swag":
- lambda state: self.has_windmill(state) or self.has_vertical(state),
- "Glacial Peak Mega Shard":
- lambda state: self.has_windmill(state) or self.has_vertical(state),
"Cloud Ruins Seal - Ghost Pit":
self.true,
"Cloud Ruins Seal - Toothbrush Alley":
@@ -483,26 +494,22 @@ def __init__(self, world: "MessengerWorld") -> None:
self.true,
"Riviere Turquoise Seal - Launch of Faith":
lambda state: self.can_dboost(state) or self.has_vertical(state),
- "Elemental Skylands - Key of Symbiosis":
- lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
"Elemental Skylands Seal - Water":
- lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
+ self.true,
"Elemental Skylands Seal - Fire":
- lambda state: (self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state))
- and self.can_destroy_projectiles(state),
+ self.can_destroy_projectiles,
"Earth Mega Shard":
lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
- "Water Mega Shard":
- lambda state: self.has_dart(state) or self.can_dboost(state) or self.has_windmill(state),
}
)
+ # fmt: on
def has_windmill(self, state: CollectionState) -> bool:
return state.has("Windmill Shuriken", self.player)
def can_dboost(self, state: CollectionState) -> bool:
return state.has("Second Wind", self.player) # who really needs meditation
-
+
def can_destroy_projectiles(self, state: CollectionState) -> bool:
return super().can_destroy_projectiles(state) or self.has_windmill(state)
diff --git a/worlds/messenger/test/test_access.py b/worlds/messenger/test/test_access.py
index ad2265ffa0a..d2fa944560a 100644
--- a/worlds/messenger/test/test_access.py
+++ b/worlds/messenger/test/test_access.py
@@ -1,7 +1,7 @@
import typing
-from . import MessengerTestBase
from ..constants import NOTES, PHOBEKINS
+from . import MessengerTestBase
class AccessTest(MessengerTestBase):
@@ -52,25 +52,58 @@ def test_dart(self) -> None:
def test_wingsuit(self) -> None:
"""locations that hard require the Wingsuit"""
locations = [
- "Ninja Village - Candle", "Ninja Village Seal - Tree House", "Autumn Hills - Climbing Claws",
- "Autumn Hills - Key of Hope", "Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws",
- "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", "Catacombs - Necro",
- "Catacombs - Ruxxtin's Amulet", "Catacombs Seal - Triple Spike Crushers",
- "Catacombs Seal - Crusher Gauntlet", "Catacombs Seal - Dirty Pond", "Bamboo Creek - Claustro",
- "Cloud Ruins - Acro", "Bamboo Creek Seal - Spike Crushers and Doors", "Bamboo Creek Seal - Spike Ball Pits",
- "Bamboo Creek Seal - Spike Crushers and Doors v2", "Howling Grotto Seal - Crushing Pits",
- "Howling Grotto Seal - Windy Saws and Balls", "Tower of Time Seal - Lantern Climb",
- "Forlorn Temple - Demon King", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley",
- "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", "Tower of Time Seal - Lantern Climb",
- "Tower of Time Seal - Arcane Orbs", "Underworld Seal - Sharp and Windy Climb",
- "Underworld Seal - Fireball Wave", "Elemental Skylands Seal - Air", "Elemental Skylands Seal - Water",
- "Elemental Skylands Seal - Fire", "Elemental Skylands - Key of Symbiosis",
- "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", "Ninja Village - Astral Seed",
- "Searing Crags - Astral Tea Leaves", "Autumn Hills Mega Shard", "Hidden Entrance Mega Shard",
- "Sunny Day Mega Shard", "Down Under Mega Shard", "Catacombs Mega Shard", "Above Entrance Mega Shard",
- "Abandoned Mega Shard", "Time Loop Mega Shard", "Earth Mega Shard", "Water Mega Shard",
- "Money Farm Room Mega Shard 1", "Money Farm Room Mega Shard 2",
- "Autumn Hills - Leaf Golem", "Catacombs - Ruxxtin", "Howling Grotto - Emerald Golem"
+ "Ninja Village - Candle",
+ "Ninja Village Seal - Tree House",
+ "Autumn Hills - Climbing Claws",
+ "Autumn Hills - Key of Hope",
+ "Autumn Hills Seal - Trip Saws",
+ "Autumn Hills Seal - Double Swing Saws",
+ "Autumn Hills Seal - Spike Ball Swing",
+ "Autumn Hills Seal - Spike Ball Darts",
+ "Catacombs - Necro",
+ "Catacombs - Ruxxtin's Amulet",
+ "Catacombs Seal - Triple Spike Crushers",
+ "Catacombs Seal - Crusher Gauntlet",
+ "Catacombs Seal - Dirty Pond",
+ "Bamboo Creek - Claustro",
+ "Cloud Ruins - Acro",
+ "Bamboo Creek Seal - Spike Crushers and Doors",
+ "Bamboo Creek Seal - Spike Ball Pits",
+ "Bamboo Creek Seal - Spike Crushers and Doors v2",
+ "Howling Grotto Seal - Windy Saws and Balls",
+ "Tower of Time Seal - Lantern Climb",
+ "Forlorn Temple - Demon King",
+ "Cloud Ruins Seal - Ghost Pit",
+ "Cloud Ruins Seal - Toothbrush Alley",
+ "Cloud Ruins Seal - Saw Pit",
+ "Cloud Ruins Seal - Money Farm Room",
+ "Tower of Time Seal - Lantern Climb",
+ "Tower of Time Seal - Arcane Orbs",
+ "Underworld Seal - Sharp and Windy Climb",
+ "Underworld Seal - Fireball Wave",
+ "Elemental Skylands Seal - Air",
+ "Elemental Skylands Seal - Water",
+ "Elemental Skylands Seal - Fire",
+ "Elemental Skylands - Key of Symbiosis",
+ "Forlorn Temple Seal - Rocket Maze",
+ "Forlorn Temple Seal - Rocket Sunset",
+ "Ninja Village - Astral Seed",
+ "Searing Crags - Astral Tea Leaves",
+ "Autumn Hills Mega Shard",
+ "Hidden Entrance Mega Shard",
+ "Sunny Day Mega Shard",
+ "Down Under Mega Shard",
+ "Catacombs Mega Shard",
+ "Above Entrance Mega Shard",
+ "Abandoned Mega Shard",
+ "Time Loop Mega Shard",
+ "Earth Mega Shard",
+ "Water Mega Shard",
+ "Money Farm Room Mega Shard 1",
+ "Money Farm Room Mega Shard 2",
+ "Autumn Hills - Leaf Golem",
+ "Catacombs - Ruxxtin",
+ "Howling Grotto - Emerald Golem",
]
items = [["Wingsuit"]]
self.assertAccessDependency(locations, items)
diff --git a/worlds/messenger/universal_tracker.py b/worlds/messenger/universal_tracker.py
index 9d752031bf6..9da7af5f8ec 100644
--- a/worlds/messenger/universal_tracker.py
+++ b/worlds/messenger/universal_tracker.py
@@ -1,9 +1,11 @@
from Options import PlandoConnection
from .connections import RANDOMIZED_CONNECTIONS
from .portals import REGION_ORDER, SHOP_POINTS, CHECKPOINTS
+from .shop import FIGURINES, SHOP_ITEMS
from .transitions import TRANSITIONS
REVERSED_RANDOMIZED_CONNECTIONS = {v: k for k, v in RANDOMIZED_CONNECTIONS.items()}
+REVERSED_SHOP_ITEMS = {v.internal_name: k for k, v in (SHOP_ITEMS | FIGURINES).items()}
def find_spot(portal_key: int) -> str:
@@ -26,6 +28,14 @@ def reverse_portal_exits_into_portal_plando(portal_exits: list[int]) -> list[Pla
PlandoConnection("Glacial Peak", find_spot(portal_exits[5]), "both"),
]
+def reverse_shop_prices(
+ shop_prices: dict[str, int], figures_prices: dict[str, int]
+) -> tuple[dict[str, int], dict[str, int]]:
+ return (
+ {REVERSED_SHOP_ITEMS[item_internal_name]: price for item_internal_name, price in shop_prices.items()},
+ {REVERSED_SHOP_ITEMS[item_internal_name]: price for item_internal_name, price in figures_prices.items()},
+ )
+
def reverse_transitions_into_plando_connections(transitions: list[list[int]]) -> list[PlandoConnection]:
plando_connections = []
diff --git a/worlds/musedash/MuseDashData.py b/worlds/musedash/MuseDashData.py
index 70dae6b9a4d..6b7bbbfe7b6 100644
--- a/worlds/musedash/MuseDashData.py
+++ b/worlds/musedash/MuseDashData.py
@@ -719,4 +719,11 @@
"YURUSHITE": SongData(2900843, "97-3", "DASH AND SHOOT!", False, 6, 8, 11),
"girls.exe": SongData(2900844, "97-4", "DASH AND SHOOT!", False, 6, 8, 11),
"Baqeela": SongData(2900845, "97-5", "DASH AND SHOOT!", False, 6, 8, 10),
+ "Help me, ERINNNNNN!!": SongData(2900846, "43-74", "MD Plus Project", False, 4, 8, 10),
+ "Utakata, Ai no Mahoroba": SongData(2900847, "98-0", "Touhou Mugakudan -V-", False, 3, 5, 7),
+ "dynamite": SongData(2900848, "98-1", "Touhou Mugakudan -V-", False, 5, 7, 9),
+ "Matsuyoi Nightbug": SongData(2900849, "98-2", "Touhou Mugakudan -V-", False, 5, 7, 10),
+ "Coooonsultant!": SongData(2900850, "98-3", "Touhou Mugakudan -V-", False, 6, 8, 10),
+ "Stop at the affected part and melt quickly - Madness Udine Quarter": SongData(2900851, "98-4", "Touhou Mugakudan -V-", False, 4, 6, 10),
+ "Ultimate taste": SongData(2900852, "98-5", "Touhou Mugakudan -V-", False, 6, 8, 11),
}
\ No newline at end of file
diff --git a/worlds/musedash/archipelago.json b/worlds/musedash/archipelago.json
index 49114bcf9e8..4f32398442e 100644
--- a/worlds/musedash/archipelago.json
+++ b/worlds/musedash/archipelago.json
@@ -1,6 +1,6 @@
{
"game": "Muse Dash",
"authors": ["DeamonHunter"],
- "world_version": "1.5.32",
+ "world_version": "1.5.33",
"minimum_ap_version": "0.6.3"
}
\ No newline at end of file
diff --git a/worlds/osrs/__init__.py b/worlds/osrs/__init__.py
index 6722e4fac23..060eb8c558c 100644
--- a/worlds/osrs/__init__.py
+++ b/worlds/osrs/__init__.py
@@ -153,10 +153,6 @@ def fill_slot_data(self):
data[f"max_{task_type}_level"] = getattr(self.options,f"max_{task_type}_level").value
return data
- @staticmethod
- def interpret_slot_data(slot_data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
- return slot_data
-
def create_regions(self) -> None:
"""
called to place player's regions into the MultiWorld's regions list. If it's hard to separate, this can be done
@@ -323,7 +319,10 @@ def roll_locations(self):
max_amount_for_task_type = getattr(self.options, f"max_{task_type}_tasks")
tasks_for_this_type = [task for task in self.locations_by_category[task_type]
if self.task_within_skill_levels(task.skills)]
- max_amount_for_task_type = min(max_amount_for_task_type, len(tasks_for_this_type))
+ if generation_is_fake:
+ max_amount_for_task_type = len(tasks_for_this_type)
+ else:
+ max_amount_for_task_type = min(max_amount_for_task_type, len(tasks_for_this_type))
if not self.options.progressive_tasks:
rnd.shuffle(tasks_for_this_type)
else:
@@ -337,8 +336,8 @@ def roll_locations(self):
weights_per_task_type[task_type] = weight_for_this_type
# Build a list of collections and weights in a matching order for rnd.choices later
- all_tasks = []
- all_weights = []
+ all_tasks:list[list[LocationRow]] = []
+ all_weights:list[int] = []
for task_type in task_types:
if task_type in tasks_per_task_type:
all_tasks.append(tasks_per_task_type[task_type])
@@ -431,14 +430,14 @@ def explain_rule(self, dest_name:str, state:CollectionState ):
from NetUtils import JSONMessagePart
ret:list[JSONMessagePart] = []
max_index = self.options.bingo_size.value
- if dest_name.lower() in ["/","forward","forward diagonal", "bingo: forward diagonal"]:
- ret.append({"type":"text","text":"Bingo : Forward Diagonal : \n"})
+ if dest_name.lower() in ["\\","reverse","reverse diagonal", "bingo: reverse diagonal","backwards","backwards diagonal", "bingo: backwards diagonal"]:
+ ret.append({"type":"text","text":"Bingo : Reverse Diagonal : \n"})
for i in range(max_index):
temp_str = self.bingo_board[i][i]
temp_status = state.can_reach_location(temp_str,self.player)
ret.extend([{"type":"text","text":f"{temp_str}"},{"type":"color","text":f" ({str(temp_status)}) \n","color":"green" if temp_status else "red"}])
- elif dest_name.lower() in ["\\","reverse","reverse diagonal", "bingo: reverse diagonal","backwards","backwards diagonal", "bingo: backwards diagonal"]:
- ret.append({"type":"text","text":"Bingo : Reverse Diagonal : \n"})
+ elif dest_name.lower() in ["/","forward","forward diagonal", "bingo: forward diagonal"]:
+ ret.append({"type":"text","text":"Bingo : Forward Diagonal : \n"})
for i in range(max_index):
temp_str = self.bingo_board[i][(max_index-1)-i]
temp_status = state.can_reach_location(temp_str,self.player)
@@ -536,7 +535,7 @@ def set_rules(self) -> None:
"Imp_Catcher", "Prince_Ali_Rescue", "Dorics_Quest", "Black_Knights_Fortress",
"Witchs_Potion", "Knights_Sword", "Goblin_Diplomacy", "Pirates_Treasure",
"Rune_Mysteries", "Misthalin_Mystery", "Corsair_Curse", "X_Marks_the_Spot",
- "Below_Ice_Mountain"]
+ "Below_Ice_Mountain", "Ides_of_Milk"]
for quest_attr_name in quest_attr_names:
qp_loc_name = getattr(LocationNames, f"QP_{quest_attr_name}")
@@ -582,10 +581,10 @@ def set_rules(self) -> None:
for index in range(self.options.bingo_size.value):
temp_loc = self.get_location(self.bingo_board[index][index])
assert temp_loc.parent_region
- for_rules.append(CanReachLocation(temp_loc.name))
+ bak_rules.append(CanReachLocation(temp_loc.name))
temp_loc = self.get_location(self.bingo_board[index][max_index-index])
assert temp_loc.parent_region
- bak_rules.append(CanReachLocation(temp_loc.name))
+ for_rules.append(CanReachLocation(temp_loc.name))
row_rules:list[Rule] = []
col_rules:list[Rule] = []
for j_index in range(self.options.bingo_size.value):
diff --git a/worlds/satisfactory/docs/en_Satisfactory.md b/worlds/satisfactory/docs/en_Satisfactory.md
index 299bdacd58d..554ccaf65b7 100644
--- a/worlds/satisfactory/docs/en_Satisfactory.md
+++ b/worlds/satisfactory/docs/en_Satisfactory.md
@@ -2,9 +2,9 @@
-## Where is the settings page?
+## Where is the options page?
-The [player settings page for this game](../player-options)
+The [player options page for this game](../player-options)
contains all the options you need to configure and export a config file.
## What does randomization do to this game?
@@ -18,7 +18,7 @@ The materials required for constructing Assemblers and Foundries is altered to i
## What is the goal of Satisfactory?
-The player can choose from a number of goals using their YAML settings:
+The player can choose from a number of goals using their YAML options:
- Complete the selected number of **[Space Elevator](https://satisfactory.wiki.gg/wiki/Space_Elevator) Phases**.
- The goal completes upon submitting your selected Space Elevator Phase. Any other progression you may have access to (HUB, MAM, AWESOME Shop) is not required for goal completion.
@@ -62,7 +62,7 @@ In Satisfactory multiplayer, each Satisfactory player gets a copy of the sample.
Certain recipes and items, like Somersloops, are always excluded from samples.
You can separately configure how many samples to receive for buildings, equipment, and crafting components
-in your player settings.
+in your player options.
## What is a Resource Bundle?
@@ -78,7 +78,7 @@ unless they can't fit, in which case they can be collected by building an Archip
Traps are items intended to disrupt the player that replace non-progression filler items.
Satisfactory's traps currently include spawning disruptive creatures or sending inconvenient items to your Archipelago Portal.
-The player settings page gives full control over which traps are enabled,
+The player options page gives full control over which traps are enabled,
how many traps replace filler items,
as well as some pre-selected groups of themed traps.
@@ -108,7 +108,7 @@ Bundles will instantly be added to the Archipelago Portal network and can be col
## What is EnergyLink?
EnergyLink is an energy storage supported by certain games that is shared across all worlds in a multiworld.
-In Satisfactory, if enabled in the player settings, all base-game Power Storage buildings will act as Energy Link interfaces.
+In Satisfactory, if enabled in the player options, all base-game Power Storage buildings will act as Energy Link interfaces.
They will deposit surplus produced energy and draw energy from the shared storage when needed.
Just like the base game, there is no limit to the discharge/draw rate of one building,
@@ -198,10 +198,10 @@ It is possible to use other Satisfactory mods in tandem with the Archipelago Sat
However, no guarantee is made that they will work correctly,
especially if they affect game progression, recipes, or add unlocks to base-game technologies.
-Content added by unaffiliated mods may end up inaccessible based on your chosen slot settings,
+Content added by unaffiliated mods may end up inaccessible based on your chosen slot options,
for example, its milestones could be in a tier that is after your goal.
You may be able to write patches using [ContentLib](https://ficsit.app/mod/ContentLib)
-to adjust other mods to work with your slot settings,
+to adjust other mods to work with your slot options,
but doing so is out of the scope of this guide.
[The Satisfactory Archipelago mod GitHub](https://github.com/Jarno458/SatisfactoryArchipelagoMod/blob/main/Docs/AdditionalMods.md)
diff --git a/worlds/satisfactory/docs/setup_en.md b/worlds/satisfactory/docs/setup_en.md
index 4486f1bb490..63b5db7cc5f 100644
--- a/worlds/satisfactory/docs/setup_en.md
+++ b/worlds/satisfactory/docs/setup_en.md
@@ -119,9 +119,9 @@ and different players in the same multiworld can all have different options.
#### Where do I get a config file?
-The Player Settings page on the website
-allows you to configure your personal settings and export a config file from them.
-Satisfactory player settings page: [Satisfactory Settings Page](/games/Satisfactory/player-settings)
+The Player Options page on the website
+allows you to configure your personal options and export a config file from them.
+Satisfactory player options page: [Satisfactory Options Page](/games/Satisfactory/player-options)
#### Verifying Your Config File
@@ -131,21 +131,21 @@ YAML Validator page: [Yaml Validation Page](/mysterycheck)
#### Starting Inventory
-The Player Settings page provides a few options for controlling what materials you start with
+The Player Options page provides a few options for controlling what materials you start with
and when certain key technologies are unlocked.
Any Resource Bundle type items added to your starting inventory will be delivered to your player inventory when you initally spawn,
unless they can't fit, in which case they can be collected by building an Archipelago Portal.
Advanced users can use Plando, Weighted Options, and manual yaml editing to further configure the starting inventory.
If you don't wish to use these techniques, consider using Satisfactory's
-[Advanced Game Settings (Satisfactory Wiki)](https://satisfactory.wiki.gg/wiki/Advanced_Game_Settings)
+[Creative Mode (Satisfactory Wiki)](https://satisfactory.wiki.gg/wiki/Creative_Mode)
to spawn the items you desire.
#### Advanced Configuration
Advanced users can utilize the
-[Weighted Options Page](/weighted-options)
-and [Plando](/tutorial/Archipelago/plando)
+[Weighted Options Page](/games/Satisfactory/weighted-options)
+and [Plando](/tutorial/Archipelago/plando_en)
to futher customize their experience.
### Generating and Hosting the Multiworld
diff --git a/worlds/sc2/__init__.py b/worlds/sc2/__init__.py
index c57f54aa70a..be27b5aa900 100644
--- a/worlds/sc2/__init__.py
+++ b/worlds/sc2/__init__.py
@@ -91,6 +91,7 @@ class SC2World(World):
game = "Starcraft 2"
web = Starcraft2WebWorld()
settings: ClassVar[settings.Starcraft2Settings]
+ disable_ut = True
item_name_to_id = {name: data.code for name, data in get_full_item_list().items()}
location_name_to_id = {location.name: location.code for location in DEFAULT_LOCATION_LIST}
@@ -433,7 +434,7 @@ def resolve_count(count: int, max_count: int, negative_value: int | None = None)
if max_count and count > max_count:
return max_count
return count
-
+
auto_excludes = Counter({item_name: 1 for item_name in item_groups.legacy_items})
if world.options.exclude_overpowered_items.value == ExcludeOverpoweredItems.option_true:
for item_name in item_groups.overpowered_items:
@@ -1066,7 +1067,7 @@ def fill_pool_with_kerrigan_levels(world: SC2World, item_pool: List[StarcraftIte
or (world.options.grant_story_levels and not kerrigan_build_missions)
):
return
-
+
def add_kerrigan_level_items(level_amount: int, item_amount: int):
name = f"{level_amount} Kerrigan Level"
if level_amount > 1:
diff --git a/worlds/sc2/pool_filter.py b/worlds/sc2/pool_filter.py
index abecf8264cc..1087364e50b 100644
--- a/worlds/sc2/pool_filter.py
+++ b/worlds/sc2/pool_filter.py
@@ -1,5 +1,6 @@
import logging
from typing import Callable, Dict, List, Set, Tuple, TYPE_CHECKING, Iterable
+from collections import Counter
from BaseClasses import Location, ItemClassification
from .item import StarcraftItem, ItemFilterFlags, item_names, item_parents, item_groups
@@ -110,7 +111,7 @@ def __init__(self, world: 'SC2World', item_pool: List[StarcraftItem]) -> None:
self.player = world.player
self.world: 'SC2World' = world
# Track all Progression items and those with complex rules for filtering
- self.logical_inventory: Dict[str, int] = {}
+ self.logical_inventory: Counter[str] = Counter()
for item in item_pool:
if not item_table[item.name].is_important_for_filtering():
continue
@@ -125,13 +126,13 @@ def __init__(self, world: 'SC2World', item_pool: List[StarcraftItem]) -> None:
self.item_name_to_child_items.setdefault(parent_item, []).append(item)
def has(self, item: str, player: int, count: int = 1) -> bool:
- return self.logical_inventory.get(item, 0) >= count
+ return self.logical_inventory[item] >= count
def has_any(self, items: Set[str], player: int) -> bool:
- return any(self.logical_inventory.get(item) for item in items)
+ return any(self.logical_inventory[item] for item in items)
def has_all(self, items: Set[str], player: int) -> bool:
- return all(self.logical_inventory.get(item) for item in items)
+ return all(self.logical_inventory[item] for item in items)
def has_group(self, item_group: str, player: int, count: int = 1) -> bool:
return False # Deliberately fails here, as item pooling is not aware about mission layout
@@ -140,13 +141,17 @@ def count_group(self, item_name_group: str, player: int) -> int:
return 0 # For item filtering assume no missions are beaten
def count(self, item: str, player: int) -> int:
- return self.logical_inventory.get(item, 0)
+ return self.logical_inventory[item]
def count_from_list(self, items: Iterable[str], player: int) -> int:
- return sum(self.logical_inventory.get(item, 0) for item in items)
+ return sum(self.logical_inventory[item] for item in items)
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
- return sum(item in self.logical_inventory for item in items)
+ result = 0
+ for item in items:
+ if self.logical_inventory[item] > 0:
+ result += 1
+ return result
def generate_reduced_inventory(self, inventory_size: int, filler_amount: int, mission_requirements: List[Tuple[str, Callable]]) -> List[StarcraftItem]:
"""Attempts to generate a reduced inventory that can fulfill the mission requirements."""
diff --git a/worlds/sm64ex/test/test_options.py b/worlds/sm64ex/test/test_options.py
index 2a55b31723b..6391e9e1861 100644
--- a/worlds/sm64ex/test/test_options.py
+++ b/worlds/sm64ex/test/test_options.py
@@ -187,6 +187,10 @@ def test_stars_vs_filler(self):
# Entrance + Move Randos
class CourseEntrancesMoveTestBase(SM64TestBase):
+ @property
+ def run_default_tests(self) -> bool:
+ return False
+
options = {
"enable_move_rando": Options.EnableMoveRandomizer.option_true,
"area_rando": Options.AreaRandomizer.option_Courses_Only
@@ -212,6 +216,10 @@ def test_WF_entrance(self):
class SeparateEntrancesMoveTestBase(SM64TestBase):
+ @property
+ def run_default_tests(self) -> bool:
+ return False
+
options = {
"enable_move_rando": Options.EnableMoveRandomizer.option_true,
"area_rando": Options.AreaRandomizer.option_Courses_and_Secrets_Separate
@@ -237,6 +245,10 @@ def test_WF_entrance(self):
class AllEntrancesMoveTestBase(SM64TestBase):
+ @property
+ def run_default_tests(self) -> bool:
+ return False
+
options = {
"enable_move_rando": Options.EnableMoveRandomizer.option_true,
"area_rando": Options.AreaRandomizer.option_Courses_and_Secrets
diff --git a/worlds/stardew_valley/__init__.py b/worlds/stardew_valley/__init__.py
index 558df4feb10..2d6af65adfc 100644
--- a/worlds/stardew_valley/__init__.py
+++ b/worlds/stardew_valley/__init__.py
@@ -8,6 +8,7 @@
import entrance_rando
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
+from NetUtils import JSONMessagePart
from Options import PerGameCommonOptions
from worlds.AutoWorld import World, WebWorld
from worlds.LauncherComponents import components, Component, icon_paths, Type
@@ -34,6 +35,7 @@
from .regions import create_regions, prepare_mod_data
from .rules import set_rules
from .stardew_rule import True_, StardewRule, HasProgressionPercent
+from .stardew_rule.rule_explain import RuleExplanation
from .strings.ap_names.ap_option_names import StartWithoutOptionName
from .strings.ap_names.ap_weapon_names import APWeapon
from .strings.ap_names.event_names import Event
@@ -89,28 +91,6 @@ class StardewWebWorld(WebWorld):
tutorials = [setup_en, setup_fr]
-if TRACKER_ENABLED:
- from .. import user_folder
- import os
-
- # Best effort to detect if universal tracker is installed
- if any("tracker.apworld" in f.name for f in os.scandir(user_folder)):
- def launch_client(*args):
- from worlds.LauncherComponents import launch
- from .client import launch as client_main
- launch(client_main, name="Stardew Valley Tracker", args=args)
-
-
- components.append(Component(
- "Stardew Valley Tracker",
- func=launch_client,
- component_type=Type.CLIENT,
- icon='stardew'
- ))
-
- icon_paths['stardew'] = f"ap:{__name__}/stardew.png"
-
-
class StardewValleyWorld(World):
"""
Stardew Valley is an open-ended country-life RPG. You can farm, fish, mine, fight, complete quests,
@@ -270,7 +250,6 @@ def precollect_start_inventory_items_if_needed(self):
self.multiworld.push_precollected(self.create_item(item_name))
precollected_count += 1
-
def precollect_start_without_items(self):
if StartWithoutOptionName.landslide not in self.options.start_without:
self.multiworld.push_precollected(self.create_item("Landslide Removed"))
@@ -561,3 +540,13 @@ def update_received_progression_percent(self, player_state: Counter[str]) -> Non
# Total progression items is not set until all items are created, but collect will be called during the item creation when an item is precollected.
# We can't update the percentage if we don't know the total progression items, can't divide by 0.
player_state[Event.received_progression_percent] = received_progression_count * 100 // self.total_progression_items
+
+ previous_explanation: RuleExplanation | None = None
+
+ def explain_rule(self, target_name: str, state: CollectionState) -> list[JSONMessagePart]:
+ from .client import cmd_explain
+ return cmd_explain(self, target_name, state)
+
+ def explain_more(self, target_name: str, state: CollectionState) -> list[JSONMessagePart]:
+ from .client import cmd_more
+ return cmd_more(self, target_name, state)
diff --git a/worlds/stardew_valley/archipelago.json b/worlds/stardew_valley/archipelago.json
index 7f7e3a86085..03e565c67d3 100644
--- a/worlds/stardew_valley/archipelago.json
+++ b/worlds/stardew_valley/archipelago.json
@@ -2,5 +2,5 @@
"game": "Stardew Valley",
"authors": ["Kaito Kid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"],
"minimum_ap_version": "0.6.4",
- "world_version": "7.4.0"
+ "world_version": "7.4.25"
}
diff --git a/worlds/stardew_valley/client.py b/worlds/stardew_valley/client.py
index f4a581fd4d1..1181cdcd442 100644
--- a/worlds/stardew_valley/client.py
+++ b/worlds/stardew_valley/client.py
@@ -1,218 +1,76 @@
from __future__ import annotations
-import asyncio
import re
-# webserver imports
-import urllib.parse
-from collections.abc import Iterable
import Utils
from BaseClasses import CollectionState, Location
-from CommonClient import logger, get_base_parser, gui_enabled, server_loop
-from MultiServer import mark_raw
from NetUtils import JSONMessagePart
-from kvui import CommandPromptTextInput
from . import StardewValleyWorld
from .logic.logic import StardewLogic
from .stardew_rule.rule_explain import explain, ExplainMode, RuleExplanation
-try:
- from worlds.tracker.TrackerClient import TrackerGameContext, TrackerCommandProcessor as ClientCommandProcessor, UT_VERSION # noqa
- from worlds.tracker.TrackerCore import TrackerCore
- tracker_loaded = True
-except ImportError as e:
- logger.error(e)
- from CommonClient import CommonContext, ClientCommandProcessor
+def cmd_explain(world: StardewValleyWorld, target_name: str, state: CollectionState) -> list[JSONMessagePart]:
+ logic = world.logic
- TrackerCore = object
-
-
- class TrackerGameContextMixin:
- """Expecting the TrackerGameContext to have these methods."""
- tracker_core: TrackerCore
-
- def make_gui(self, manager):
- ...
-
- def run_generator(self):
- ...
-
-
- class TrackerGameContext(CommonContext, TrackerGameContextMixin):
- pass
-
-
- tracker_loaded = False
- UT_VERSION = "Not found"
-
-
-class StardewCommandProcessor(ClientCommandProcessor):
- ctx: StardewClientContext
-
- @mark_raw
- def _cmd_explain(self, location: str = ""):
- """Explain the logic behind a location."""
- logic = self.ctx.logic
- if logic is None:
- return
-
- try:
- rule = logic.region.can_reach_location(location)
- expl = explain(rule, self.ctx.current_state, expected=None, mode=ExplainMode.CLIENT)
- except KeyError:
-
- result, usable, response = Utils.get_intended_text(location, [loc.name for loc in self.ctx.all_locations])
- if usable:
- rule = logic.region.can_reach_location(result)
- expl = explain(rule, self.ctx.current_state, expected=None, mode=ExplainMode.CLIENT)
- else:
- self.ctx.ui.last_autofillable_command = "/explain"
- self.output(response)
- return
-
- self.ctx.previous_explanation = expl
- self.ctx.ui.print_json(parse_explanation(expl))
-
- @mark_raw
- def _cmd_explain_item(self, item: str = ""):
- """Explain the logic behind a game item."""
- logic = self.ctx.logic
- if logic is None:
- return
+ if target_name.startswith("item "):
+ is_item_explain = True
+ target_name = target_name[len("item "):]
+ else:
+ is_item_explain = False
+
+ if target_name.startswith("missing "):
+ expected = True
+ target_name = target_name[len("missing "):]
+ elif target_name.startswith("how "):
+ expected = False
+ target_name = target_name[len("how "):]
+ else:
+ expected = None
- result, usable, response = Utils.get_intended_text(item, logic.registry.item_rules.keys())
- if usable:
+ possible_answers = logic.registry.item_rules.keys() if is_item_explain else world.get_all_location_names()
+ result, usable, response = Utils.get_intended_text(target_name, possible_answers)
+ if usable:
+ if is_item_explain:
rule = logic.has(result)
- expl = explain(rule, self.ctx.current_state, expected=None, mode=ExplainMode.CLIENT)
else:
- self.ctx.ui.last_autofillable_command = "/explain_item"
- self.output(response)
- return
-
- self.ctx.previous_explanation = expl
- self.ctx.ui.print_json(parse_explanation(expl))
-
- @mark_raw
- def _cmd_explain_missing(self, location: str = ""):
- """Explain what is missing for a location to be in logic. It explains the logic behind a location, while skipping the rules that are already satisfied."""
- self.__explain("/explain_missing", location, expected=True)
-
- @mark_raw
- def _cmd_explain_how(self, location: str = ""):
- """Explain how a location is in logic. It explains the logic behind the location, while skipping the rules that are not satisfied."""
- self.__explain("/explain_how", location, expected=False)
-
- def __explain(self, command: str, location: str, expected: bool | None = None):
- logic = self.ctx.logic
- if logic is None:
- return
-
- try:
- rule = logic.region.can_reach_location(location)
- expl = explain(rule, self.ctx.current_state, expected=expected, mode=ExplainMode.CLIENT)
- except KeyError:
-
- result, usable, response = Utils.get_intended_text(location, [loc.name for loc in self.ctx.all_locations])
- if usable:
- rule = logic.region.can_reach_location(result)
- expl = explain(rule, self.ctx.current_state, expected=expected, mode=ExplainMode.CLIENT)
- else:
- self.ctx.ui.last_autofillable_command = command
- self.output(response)
- return
-
- self.ctx.previous_explanation = expl
- self.ctx.ui.print_json(parse_explanation(expl))
-
- @mark_raw
- def _cmd_more(self, index: str = ""):
- """Will tell you what's missing to consider a location in logic."""
- if self.ctx.previous_explanation is None:
- self.output("No previous explanation found.")
- return
-
- try:
- expl = self.ctx.previous_explanation.more(int(index))
- except (ValueError, IndexError):
- self.output("Which previous rule do you want to explained?")
- self.ctx.ui.last_autofillable_command = "/more"
- for i, rule in enumerate(self.ctx.previous_explanation.more_explanations):
- # TODO handle autofillable commands
- self.output(f"/more {i} -> {str(rule)})")
- return
-
- self.ctx.previous_explanation = expl
- self.ctx.ui.print_json(parse_explanation(expl))
-
- if not tracker_loaded:
- del _cmd_explain
- del _cmd_explain_missing
-
-
-class StardewClientContext(TrackerGameContext):
- game = "Stardew Valley"
- command_processor = StardewCommandProcessor
- previous_explanation: RuleExplanation | None = None
-
- def make_gui(self):
- ui = super().make_gui() # before the kivy imports so kvui gets loaded first
-
- class StardewManager(ui):
- base_title = f"Stardew Valley Tracker with UT {UT_VERSION} for AP version" # core appends ap version so this works
- ctx: StardewClientContext
-
- def build(self):
- container = super().build()
- if not tracker_loaded:
- logger.info("To enable the tracker page, install Universal Tracker.")
-
- # Until self.ctx.ui.last_autofillable_command allows for / commands, this is needed to remove the "!" before the /commands when using intended text autofill.
- def on_text_remove_hardcoded_exclamation_mark_garbage(textinput: CommandPromptTextInput, text: str) -> None:
- if text.startswith("!/"):
- textinput.text = text[1:]
-
- self.textinput.bind(text=on_text_remove_hardcoded_exclamation_mark_garbage)
-
- return container
-
- return StardewManager
-
- @property
- def logic(self) -> StardewLogic | None:
- if self.tracker_core.get_current_world() is None:
- logger.warning("Internal logic was not able to load, check your yamls and relaunch.")
- return None
+ rule = logic.region.can_reach_location(result)
+ expl = explain(rule, state, expected=expected, mode=ExplainMode.CLIENT)
+ world.previous_explanation = expl
+ return parse_explanation(expl)
+ else:
+ return [{"type": "color", "color": "salmon", "text": response}]
- if self.game != "Stardew Valley":
- logger.warning(f"Please connect to a slot with explainable logic (not {self.game}).")
- return None
- return self.tracker_core.get_current_world().logic
+def cmd_more(world: StardewValleyWorld, index: str, state: CollectionState) -> list[JSONMessagePart]:
+ logic = world.logic
- @property
- def current_state(self) -> CollectionState:
- return self.tracker_core.updateTracker().state
+ if world.previous_explanation is None:
+ return [{"type": "color", "color": "salmon", "text": "No previous explanation found"}]
- @property
- def world(self) -> StardewValleyWorld:
- return self.tracker_core.get_current_world()
+ try:
+ expl = world.previous_explanation.more(int(index))
+ except (ValueError, IndexError):
+ return [{"type": "text", "text": "Which previous rule do you want to be explained?"}]
- @property
- def all_locations(self) -> Iterable[Location]:
- return self.tracker_core.multiworld.get_locations(self.tracker_core.player_id)
+ world.previous_explanation = expl
+ return parse_explanation(expl)
-def parse_explanation(explanation: RuleExplanation) -> list[JSONMessagePart]:
+def parse_explanation(explanation: RuleExplanation) -> list[list[JSONMessagePart]]:
# Split the explanation in parts, by isolating all the delimiters, being \(, \), & , -> , | , \d+x , \[ , \] , \(\w+\), \n\s*
- result_regex = r"(\(|\)| & | -> | \| |\d+x | \[|\](?: ->)?\s*| \(\w+\)|\n\s*)"
+ result_regex = r"\s*(\(|\)| & | -> | \| |\d+x | \[|\](?: ->)?\s*| \(\w+\)|\n)"
splits = re.split(result_regex, str(explanation).strip())
messages = []
+ contents = []
+ content_length = 0
for s in splits:
if len(s) == 0:
continue
+ content_length += len(s)
+
if s == "True":
messages.append({"type": "color", "color": "green", "text": s})
elif s == "False":
@@ -245,40 +103,12 @@ def parse_explanation(explanation: RuleExplanation) -> list[JSONMessagePart]:
else:
messages.append({"text": s, "type": "text"})
- return messages
-
+ if content_length > 5000 and s.endswith("\n"):
+ contents[-1]["text"] = contents[-1]["text"][:-1]
+ messages.append(contents)
+ contents = []
+ content_length = 0
-async def main(args):
- ctx = StardewClientContext(args.connect, args.password)
-
- ctx.auth = args.name
- ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
-
- if tracker_loaded:
- ctx.run_generator()
- else:
- logger.warning("Could not find Universal Tracker.")
+ messages.append(contents)
- if gui_enabled:
- ctx.run_gui()
- ctx.run_cli()
-
- await ctx.exit_event.wait()
- await ctx.shutdown()
-
-
-def launch(*args):
- parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
- parser.add_argument('--name', default=None, help="Slot Name to connect as.")
- parser.add_argument("url", nargs="?", help="Archipelago connection url")
- args = parser.parse_args(args)
-
- if args.url:
- url = urllib.parse.urlparse(args.url)
- args.connect = url.netloc
- if url.username:
- args.name = urllib.parse.unquote(url.username)
- if url.password:
- args.password = urllib.parse.unquote(url.password)
-
- asyncio.run(main(args))
+ return messages
diff --git a/worlds/stardew_valley/data/bundles_data/meme_bundles.py b/worlds/stardew_valley/data/bundles_data/meme_bundles.py
index a9b7f8c7daf..e0f4f36717c 100644
--- a/worlds/stardew_valley/data/bundles_data/meme_bundles.py
+++ b/worlds/stardew_valley/data/bundles_data/meme_bundles.py
@@ -46,9 +46,9 @@
amons_fall_bundle = FixedPriceBundleTemplate(CCRoom.boiler_room, MemeBundleName.amons_fall, amons_fall_items, 7, 7)
screw_you_items = [tea_set, ostrich_egg.as_quality(AnimalProductQuality.iridium), snake_vertebrae.as_amount(5), mummified_bat.as_amount(5)]
-screw_you_bundle = BundleTemplate(CCRoom.boiler_room, MemeBundleName.screw_you, screw_you_items, 4, 4)
+screw_you_bundle = IslandBundleTemplate(CCRoom.boiler_room, MemeBundleName.screw_you, screw_you_items, 4, 4)
-sunmaid_items = [raisins.as_amount(28)]
+sunmaid_items = [raisins.as_amount(12)]
sunmaid_bundle = BundleTemplate(CCRoom.pantry, MemeBundleName.sunmaid, sunmaid_items, 1, 1)
rick_items = [pickles]
@@ -86,8 +86,8 @@
moss_soup.as_amount(3), radioactive_ore.as_amount(5), mystic_syrup.as_amount(10), truffle, aged_crimsonfish_roe]
burger_king_revenge_bundle = BundleTemplate(CCRoom.bulletin_board, MemeBundleName.burger_king_revenge, burger_king_revenge_items, 8, 8)
-trout_items = [golden_tag.as_amount(10), golden_tag.as_amount(20), golden_tag.as_amount(30)]
-trout_bundle = BundleTemplate(CCRoom.fish_tank, MemeBundleName.trout, trout_items, 1, 1)
+trout_items = [golden_tag.as_amount(1), golden_tag.as_amount(2), golden_tag.as_amount(4), golden_tag.as_amount(6)]
+trout_bundle = BundleTemplate(CCRoom.fish_tank, MemeBundleName.trout, trout_items, 3, 3)
eg_items = [egg, brown_egg, large_egg, large_brown_egg, duck_egg, void_egg, golden_egg, dinosaur_egg, fried_egg, ostrich_egg,
thunder_egg, calico_egg, green_slime_egg, blue_slime_egg, purple_slime_egg, tiger_slime_egg, roe, aged_roe]
@@ -113,8 +113,8 @@
honorable_items = [stone.as_amount(1), prismatic_shard.as_amount(1)]
honorable_bundle = FixedPriceBundleTemplate(CCRoom.boiler_room, MemeBundleName.honorable, honorable_items, 2, 1)
-caffeinated_items = [coffee_bean.as_amount(500)]
-caffeinated_bundle = BundleTemplate(CCRoom.crafts_room, MemeBundleName.caffeinated, caffeinated_items, 1, 1)
+caffeinated_items = [coffee_bean.as_amount(200)]
+caffeinated_bundle = BundleTemplate(CCRoom.crafts_room, MemeBundleName.caffeinated, caffeinated_items, 2, 2)
hats_off_to_you_items = [living_hat, garbage_hat, golden_helmet, laurel_wreath_crown, joja_cap,
deluxe_pirate_hat, dark_cowboy_hat, tiger_hat, mystery_hat, dark_ballcap]
@@ -231,7 +231,7 @@
pomnut_bundle = BundleTemplate(CCRoom.bulletin_board, MemeBundleName.pomnut, pomnut_items, 3, 3)
blossom_garden_items = [banana.as_amount(18), pizza.as_amount(32), spaghetti, single_bed, pink_cake, wood_floor, triple_shot_espresso, maple_bar, bug_steak, void_essence.as_amount(10), crystal_ball, solar_essence.as_amount(10)]
-blossom_garden_bundle = FixedPriceBundleTemplate(CCRoom.bulletin_board, MemeBundleName.blossom_garden, blossom_garden_items, 12, 6)
+blossom_garden_bundle = FixedPriceBundleTemplate(CCRoom.bulletin_board, MemeBundleName.blossom_garden, blossom_garden_items, 12, 4)
cooperation_items = [*all_simple_items]
cooperation_bundle = BundleTemplate(CCRoom.bulletin_board, MemeBundleName.cooperation, cooperation_items, 4, 4)
@@ -275,7 +275,7 @@
tick_tock_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.tick_tock, BundleItem(MemeCurrency.time, 1440))
archipela_go_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.archipela_go, BundleItem(MemeCurrency.steps, 20000))
clique_bundle = FixedPriceCurrencyBundleTemplate(CCRoom.vault, MemeBundleName.clique, BundleItem(MemeCurrency.clic, 1))
-cookie_clicker_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.cookie_clicker, BundleItem(MemeCurrency.cookies, 200000))
+cookie_clicker_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.cookie_clicker, BundleItem(MemeCurrency.cookies, 400000))
communism_bundle = FixedPriceCurrencyBundleTemplate(CCRoom.vault, MemeBundleName.communism, BundleItem.money_bundle(1))
death_bundle = FixedPriceCurrencyBundleTemplate(CCRoom.vault, MemeBundleName.death, death)
flashbang_bundle = FixedPriceCurrencyBundleTemplate(CCRoom.vault, MemeBundleName.flashbang, BundleItem.money_bundle(0))
@@ -286,11 +286,11 @@
restraint_bundle = FixedPriceCurrencyBundleTemplate(CCRoom.vault, MemeBundleName.restraint, BundleItem.money_bundle(0))
fast_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.fast, BundleItem(MemeCurrency.time_elapsed, 1000))
floor_is_lava_bundle = FixedPriceCurrencyBundleTemplate(CCRoom.vault, MemeBundleName.floor_is_lava, BundleItem.money_bundle(0))
-joetg_bundle = CurrencyBundleTemplate(CCRoom.bulletin_board, MemeBundleName.joetg, BundleItem(MemeCurrency.dead_pumpkins, 750))
-bad_farmer_bundle = CurrencyBundleTemplate(CCRoom.pantry, MemeBundleName.bad_farmer, BundleItem(MemeCurrency.dead_crops, 400))
+joetg_bundle = CurrencyBundleTemplate(CCRoom.bulletin_board, MemeBundleName.joetg, BundleItem(MemeCurrency.dead_pumpkins, 250))
+bad_farmer_bundle = CurrencyBundleTemplate(CCRoom.pantry, MemeBundleName.bad_farmer, BundleItem(MemeCurrency.dead_crops, 200))
bad_fisherman_bundle = CurrencyBundleTemplate(CCRoom.fish_tank, MemeBundleName.bad_fisherman, BundleItem(MemeCurrency.missed_fish, 20))
honeywell_bundle = CurrencyBundleTemplate(CCRoom.bulletin_board, MemeBundleName.honeywell, BundleItem(MemeCurrency.honeywell, 1))
-gacha_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.gacha, BundleItem.money_bundle(10000))
+gacha_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.gacha, BundleItem.money_bundle(20000))
hibernation_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.hibernation, BundleItem(MemeCurrency.sleep_days, 60))
crowdfunding_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.crowdfunding, BundleItem(MemeCurrency.bank_money, 10000))
clickbait_bundle = CurrencyBundleTemplate(CCRoom.vault, MemeBundleName.clickbait, BundleItem.money_bundle(100))
diff --git a/worlds/stardew_valley/items/filters.py b/worlds/stardew_valley/items/filters.py
index cab7f6ff8dd..0794c8b5b6e 100644
--- a/worlds/stardew_valley/items/filters.py
+++ b/worlds/stardew_valley/items/filters.py
@@ -27,5 +27,5 @@ def remove_already_included(items: Iterable[ItemData], already_added_items: set[
return [
item
for item in items
- if item.name not in already_added_items or (item.has_any_group(*FILLER_GROUPS, Group.TRAP) and Group.MAXIMUM_ONE not in item.groups)
+ if item.name not in already_added_items or (item.has_any_group(*FILLER_GROUPS, Group.TRAP, Group.PLAYER_BUFF) and Group.MAXIMUM_ONE not in item.groups)
]
diff --git a/worlds/stardew_valley/logic/money_logic.py b/worlds/stardew_valley/logic/money_logic.py
index 77aa3f5e906..f1796e7c1bb 100644
--- a/worlds/stardew_valley/logic/money_logic.py
+++ b/worlds/stardew_valley/logic/money_logic.py
@@ -14,6 +14,7 @@
from ..strings.food_names import Beverage
from ..strings.region_names import Region, LogicRegion
from ..strings.season_names import Season
+from ..strings.tool_names import Tool, ToolMaterial
qi_gem_rewards = ("100 Qi Gems", "50 Qi Gems", "40 Qi Gems", "35 Qi Gems", "25 Qi Gems",
"20 Qi Gems", "15 Qi Gems", "10 Qi Gems")
@@ -45,8 +46,8 @@ def can_have_earned_total(self, amount: int) -> StardewRule:
shipping_rule = self.logic.shipping.can_use_shipping_bin
pierre_rule = self.logic.region.can_reach_all(Region.pierre_store, Region.forest)
willy_rule = self.logic.region.can_reach_all(Region.fish_shop, LogicRegion.fishing)
- clint_rule = self.logic.region.can_reach_all(Region.blacksmith, Region.mines_floor_5)
- robin_rule = self.logic.region.can_reach_all(Region.carpenter, Region.secret_woods)
+ clint_rule = self.logic.region.can_reach_all(Region.blacksmith, Region.mines_floor_5) & self.logic.tool.has_tool(Tool.pickaxe)
+ robin_rule = self.logic.region.can_reach_all(Region.carpenter, Region.secret_woods) & self.logic.tool.has_tool(Tool.axe, ToolMaterial.copper)
farming_rule = self.logic.farming.can_plant_and_grow_item(Season.not_winter)
if amount <= 2000:
diff --git a/worlds/stardew_valley/logic/museum_logic.py b/worlds/stardew_valley/logic/museum_logic.py
index 230e7a85a16..ed9380d666c 100644
--- a/worlds/stardew_valley/logic/museum_logic.py
+++ b/worlds/stardew_valley/logic/museum_logic.py
@@ -31,7 +31,9 @@ def can_find_museum_item(self, item: MuseumItem) -> StardewRule:
if item.artifact_spot_locations:
number_locations = len(item.artifact_spot_locations)
number_required_locations = math.ceil(number_locations / 2)
- artifact_spot_rule = self.logic.count(number_required_locations, *[self.logic.tool.can_use_tool_at(Tool.hoe, ToolMaterial.basic, spot) for spot in item.artifact_spot_locations])
+ artifact_spot_rule = self.logic.tool.has_tool(Tool.hoe) & self.logic.count(number_required_locations, *[self.logic.region.can_reach(spot) for spot in item.artifact_spot_locations])
+ if Region.dig_site in item.artifact_spot_locations:
+ artifact_spot_rule = artifact_spot_rule & self.logic.tool.has_tool(Tool.pickaxe)
else:
artifact_spot_rule = False_()
if item.geodes:
diff --git a/worlds/stardew_valley/options/option_groups.py b/worlds/stardew_valley/options/option_groups.py
index ddb54f7c5de..3520c610830 100644
--- a/worlds/stardew_valley/options/option_groups.py
+++ b/worlds/stardew_valley/options/option_groups.py
@@ -62,19 +62,21 @@
options.BackpackSize,
options.NumberOfMovementBuffs,
options.EnabledFillerBuffs,
- options.AllowedFillerItems,
options.TrapDifficulty,
- options.TrapDistribution,
- options.MultipleDaySleepEnabled,
- options.MultipleDaySleepCost,
options.QuickStart,
]),
OptionGroup("Advanced Options", [
options.Gifting,
ap_options.DeathLink,
- options.Mods,
+ options.AllowedFillerItems,
+ options.TrapDistribution,
options.BundleWhitelist,
options.BundleBlacklist,
+ options.MultipleDaySleepEnabled,
+ options.MultipleDaySleepCost,
+ ]),
+ OptionGroup("Very Advanced Options", [
+ options.Mods,
options.CustomLogic,
ap_options.ProgressionBalancing,
ap_options.Accessibility,
diff --git a/worlds/stardew_valley/options/options.py b/worlds/stardew_valley/options/options.py
index 76d8e4cb954..b9ca0f721f9 100644
--- a/worlds/stardew_valley/options/options.py
+++ b/worlds/stardew_valley/options/options.py
@@ -904,7 +904,6 @@ class TrapDistribution(OptionCounter):
internal_name = "trap_distribution"
display_name = "Trap Distribution"
default_weight = trap_default_weight
- visibility = Visibility.all ^ Visibility.simple_ui
min = 0
max = 1000
valid_keys = frozenset(all_traps)
@@ -1088,7 +1087,6 @@ class Gifting(Toggle):
class Mods(OptionSet):
"""List of mods that will be included in the shuffling."""
- visibility = Visibility.all & ~Visibility.simple_ui
internal_name = "mods"
display_name = "Mods"
valid_keys = enabled_mods
@@ -1117,7 +1115,6 @@ class BundleWhitelist(OptionSet):
If more bundles are specified than what fits in their parent room, that room will randomly pick from only the whitelist ones"""
internal_name = "bundle_whitelist"
display_name = "Bundle Whitelist"
- visibility = Visibility.template | Visibility.spoiler
valid_keys = set(all_cc_bundle_names)
def prioritizes(self, bundle_name):
@@ -1136,7 +1133,6 @@ class BundleBlacklist(OptionSet):
If too many bundles are blacklisted for a given room, that room will pick all the non-blacklist bundles, then random blacklisted ones."""
internal_name = "bundle_blacklist"
display_name = "Bundle Blacklist"
- visibility = Visibility.template | Visibility.spoiler
valid_keys = set(all_cc_bundle_names)
def allows(self, bundle_name):
@@ -1170,7 +1166,6 @@ class AllowedFillerItems(OptionSet):
"""
internal_name = "allowed_filler_items"
display_name = "Allowed Filler Items"
- visibility = Visibility.template | Visibility.spoiler
valid_keys = frozenset({AllowedFillerOptionName.farming, AllowedFillerOptionName.fishing, AllowedFillerOptionName.fruit_trees,
AllowedFillerOptionName.food, AllowedFillerOptionName.buff_food, AllowedFillerOptionName.consumables,
AllowedFillerOptionName.machines, AllowedFillerOptionName.storage, AllowedFillerOptionName.quality_of_life,
diff --git a/worlds/stardew_valley/rules.py b/worlds/stardew_valley/rules.py
index 3c78d665df6..73a2732017b 100644
--- a/worlds/stardew_valley/rules.py
+++ b/worlds/stardew_valley/rules.py
@@ -323,7 +323,10 @@ def set_bookseller_rules(logic, rule_collector):
def set_raccoon_rules(logic: StardewLogic, rule_collector: StardewRuleCollector, bundle_rooms: List[BundleRoom], world_options: StardewValleyOptions):
- rule_collector.set_entrance_rule(LogicEntrance.has_giant_stump, logic.received(CommunityUpgrade.raccoon))
+ if world_options.quest_locations.has_story_quests():
+ rule_collector.set_entrance_rule(LogicEntrance.has_giant_stump, logic.received(CommunityUpgrade.raccoon, 2))
+ else:
+ rule_collector.set_entrance_rule(LogicEntrance.has_giant_stump, logic.quest.can_complete_quest(Quest.giant_stump))
rule_collector.set_entrance_rule(LogicEntrance.buy_from_raccoon_1, logic.quest.has_raccoon_shop())
rule_collector.set_entrance_rule(LogicEntrance.buy_from_raccoon_2, logic.quest.has_raccoon_shop(2))
rule_collector.set_entrance_rule(LogicEntrance.buy_from_raccoon_3, logic.quest.has_raccoon_shop(3))
diff --git a/worlds/stardew_valley/stardew_rule/rule_explain.py b/worlds/stardew_valley/stardew_rule/rule_explain.py
index 8361c3c1148..7528fb36ad1 100644
--- a/worlds/stardew_valley/stardew_rule/rule_explain.py
+++ b/worlds/stardew_valley/stardew_rule/rule_explain.py
@@ -34,7 +34,7 @@ def summary(self, depth=0) -> str:
depth *= 2
line = " " * depth + f"{str(self.rule)} -> {self.result}"
- line += f" [use `/more {self.more_index}` to explain]"
+ line += f" [use `/explain_more {self.more_index}` to explain]"
return line
diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py
index bfedf0c90a6..4a013df2b05 100644
--- a/worlds/subnautica/__init__.py
+++ b/worlds/subnautica/__init__.py
@@ -4,6 +4,7 @@
from typing import List, Dict, Any, cast
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
+from Options import OptionError
from worlds.AutoWorld import World, WebWorld
from . import items
from . import locations
@@ -46,8 +47,9 @@ class SubnauticaWorld(World):
creatures_to_scan: List[str]
def generate_early(self) -> None:
- if not self.options.filler_items_distribution.weights_pair[1][-1]:
- raise Exception("Filler Items Distribution needs at least one positive weight.")
+ weights_list = self.options.filler_items_distribution.weights_pair[1]
+ if not weights_list or not weights_list[-1]:
+ raise OptionError("Filler Items Distribution needs at least one positive weight.")
if self.options.early_seaglide:
self.multiworld.local_early_items[self.player]["Seaglide Fragment"] = 2
diff --git a/worlds/tww/TWWClient.py b/worlds/tww/TWWClient.py
index b71f43d5697..4838a0ee815 100644
--- a/worlds/tww/TWWClient.py
+++ b/worlds/tww/TWWClient.py
@@ -545,9 +545,7 @@ async def check_locations(ctx: TWWContext) -> None:
ctx.locations_checked.add(TWWLocation.get_apid(data.code))
# Send the list of newly-checked locations to the server.
- locations_checked = ctx.locations_checked.difference(ctx.checked_locations)
- if locations_checked:
- await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations_checked}])
+ await ctx.check_locations(ctx.locations_checked)
async def check_current_stage_changed(ctx: TWWContext) -> None:
diff --git a/worlds/wargroove/Items.py b/worlds/wargroove/Items.py
index acb31a84b12..f9569621774 100644
--- a/worlds/wargroove/Items.py
+++ b/worlds/wargroove/Items.py
@@ -1,7 +1,7 @@
import typing
from BaseClasses import Item, ItemClassification
-from typing import Dict, List
+from typing import Dict, List, Set
PROGRESSION = ItemClassification.progression
PROGRESSION_SKIP_BALANCING = ItemClassification.progression_skip_balancing
@@ -101,4 +101,19 @@ class CommanderData(typing.NamedTuple):
CommanderData('Twins', 'commander_twins', 'Errol & Orla'),
CommanderData('Vesper', 'commander_vesper')
]
-}
\ No newline at end of file
+}
+
+item_name_groups: Dict[str, Set[str]] = {}
+for name, item in item_table.items():
+ group = f'{item.type}s'
+ if not group or group == 'Goals':
+ continue
+ if group == "Triggers":
+ # Split triggers since they serve very different purposes.
+ if "Final" in name:
+ group = "Final Items"
+ else:
+ group = "Events"
+ if group not in item_name_groups:
+ item_name_groups[group] = set()
+ item_name_groups[group].add(name)
diff --git a/worlds/wargroove/__init__.py b/worlds/wargroove/__init__.py
index f6218225e16..8c7369394bc 100644
--- a/worlds/wargroove/__init__.py
+++ b/worlds/wargroove/__init__.py
@@ -3,6 +3,7 @@
import typing
from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification
+from . import Items
from .Items import item_table, faction_table
from .Locations import location_table
from .Regions import create_regions
@@ -73,6 +74,7 @@ class WargrooveWorld(World):
item_name_to_id = {name: data.code for name, data in item_table.items()}
location_name_to_id = location_table
+ item_name_groups = Items.item_name_groups
def _get_slot_data(self):
return {
diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py
index 19b1b800ec6..581bd6ba1d7 100644
--- a/worlds/witness/__init__.py
+++ b/worlds/witness/__init__.py
@@ -3,7 +3,7 @@
"""
import dataclasses
from logging import error, info, warning
-from typing import Any, Dict, List, Optional, cast
+from typing import Any, Dict, List, Optional, cast, ClassVar
from BaseClasses import CollectionState, Entrance, Item, Location, LocationProgressType, Region, Tutorial
@@ -17,13 +17,14 @@
from .data.utils import cast_not_none, get_audio_logs
from .hints import CompactHintData, create_all_hints, make_compact_hint_data, make_laser_hints
from .locations import WitnessPlayerLocations
-from .options import TheWitnessOptions, witness_option_groups
+from .options import RelevanceMixin, TheWitnessOptions, witness_option_groups
from .place_early_item import place_early_items
from .player_items import WitnessItem, WitnessPlayerItems
from .player_logic import WitnessPlayerLogic
from .presets import witness_option_presets
from .regions import WitnessPlayerRegions
from .rules import set_rules
+from .universal_tracker import set_options_from_ut
class WitnessWebWorld(WebWorld):
@@ -84,6 +85,8 @@ class WitnessWorld(World):
required_client_version = (0, 6, 8)
+ ut_can_gen_without_yaml: ClassVar[bool] = True
+
player_logic: WitnessPlayerLogic
player_locations: WitnessPlayerLocations
player_items: WitnessPlayerItems
@@ -157,6 +160,16 @@ def determine_sufficient_progression(self) -> None:
f" Shuffle, Door Shuffle, or Obelisk Keys.")
def generate_early(self) -> None:
+ # Do this first, as set_options_from_ut might re-set it to something different
+ if self.options.victory_condition == "panel_hunt":
+ total_panels = self.options.panel_hunt_total
+ required_percentage = self.options.panel_hunt_required_percentage
+ self.panel_hunt_required_count = round(total_panels * required_percentage / 100)
+ else:
+ self.panel_hunt_required_count = 0
+
+ set_options_from_ut(self)
+
disabled_locations = self.options.exclude_locations.value
self.player_logic = WitnessPlayerLogic(
@@ -178,13 +191,6 @@ def generate_early(self) -> None:
if item_data.local_only:
self.options.local_items.value.add(item_name)
- if self.options.victory_condition == "panel_hunt":
- total_panels = self.options.panel_hunt_total
- required_percentage = self.options.panel_hunt_required_percentage
- self.panel_hunt_required_count = round(total_panels * required_percentage / 100)
- else:
- self.panel_hunt_required_count = 0
-
def create_regions(self) -> None:
self.player_regions.create_regions(self, self.player_logic)
@@ -405,11 +411,11 @@ def fill_slot_data(self) -> Dict[str, Any]:
slot_data = self._get_slot_data()
- for option_name in (attr.name for attr in dataclasses.fields(TheWitnessOptions)
- if attr not in dataclasses.fields(PerGameCommonOptions)):
- option = getattr(self.options, option_name)
- slot_data[option_name] = bool(option.value) if isinstance(option, Toggle) else option.value
-
+ slot_data_options = [
+ option_name for option_name, option_type in TheWitnessOptions.type_hints.items()
+ if issubclass(option_type, RelevanceMixin) and option_type.needs_to_be_in_slot_data
+ ]
+ slot_data |= self.options.as_dict(*slot_data_options, toggles_as_bools=True)
return slot_data
def create_item(self, item_name: str) -> WitnessItem:
diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py
index 2de2e5c9191..b53a4ac02bb 100644
--- a/worlds/witness/data/utils.py
+++ b/worlds/witness/data/utils.py
@@ -270,3 +270,7 @@ def is_easter_time() -> bool:
last_easter_day = date(today.year, 4, 26) # Latest possible is 4/25 + 1 day buffer for Easter Monday
return earliest_easter_day <= today <= last_easter_day
+
+
+def entity_id_int_to_canonical_entity_id_string(entity_id_int: int) -> str:
+ return f"0x{entity_id_int:0{5}X}"
diff --git a/worlds/witness/options.py b/worlds/witness/options.py
index 2fcd35b64c5..3776bd6a2c9 100644
--- a/worlds/witness/options.py
+++ b/worlds/witness/options.py
@@ -1,10 +1,12 @@
from dataclasses import dataclass
+from enum import IntFlag
from typing import Tuple
from Options import (
Choice,
DefaultOnToggle,
LocationSet,
+ Option,
OptionCounter,
OptionError,
OptionGroup,
@@ -22,7 +24,26 @@
from .entity_hunt import ALL_HUNTABLE_PANELS
-class DisableNonRandomizedPuzzles(Toggle):
+class OptionRelevance(IntFlag):
+ client = 0b001
+ universal_tracker_regeneration = 0b010
+ external_tracker = 0b100
+
+ none = 0b000
+ all = 0b111
+
+ all_trackers = 0b110
+
+
+class RelevanceMixin:
+ relevance: OptionRelevance = OptionRelevance.none
+
+ @property
+ def needs_to_be_in_slot_data(self):
+ return self.relevance != OptionRelevance.none
+
+
+class DisableNonRandomizedPuzzles(RelevanceMixin, Toggle):
"""
Disables puzzles that cannot be randomized.
This includes many puzzles that heavily involve the environment, such as Shadows, Monastery or Orchard.
@@ -30,16 +51,20 @@ class DisableNonRandomizedPuzzles(Toggle):
The lasers for those areas will activate as you solve optional puzzles, such as Discarded Panels.
Additionally, the panel activating the Jungle Popup Wall will be on from the start.
"""
+ relevance = OptionRelevance.all
+
display_name = "Disable non randomized puzzles"
-class EarlyCaves(Choice):
+class EarlyCaves(RelevanceMixin, Choice):
"""
Adds an item that opens the Caves Shortcuts to Swamp and Mountain, allowing early access to the Caves even if you are not playing a remote Door Shuffle mode.
You can either add this item to the pool to be found in the multiworld, or you can outright start with it and have immediate access to the Caves.
If you choose "Add To Pool" and you are already playing a remote Door Shuffle mode, this option will do nothing.
"""
+ relevance = OptionRelevance.all_trackers
+
display_name = "Early Caves"
option_off = 0
alias_false = 0
@@ -49,7 +74,7 @@ class EarlyCaves(Choice):
alias_on = 2
-class EarlyGoodItems(OptionSet):
+class EarlyGoodItems(RelevanceMixin, OptionSet):
"""
Put one random helpful item of each of the chosen types on an early check, specifically a sphere 1 Tutorial location.
If a type is chosen, but no items of that type exist in the itempool, it is skipped.
@@ -59,22 +84,25 @@ class EarlyGoodItems(OptionSet):
If there still aren't enough sphere 1 Tutorial locations, a random local sphere 1 location is picked.
If no local sphere 1 locations are available, there are no further attempts to place the item.
"""
+ relevance = OptionRelevance.none
valid_keys = {"Symbol", "Door / Door Panel", "Obelisk Key"}
default = frozenset({"Symbol"})
-class ShuffleSymbols(DefaultOnToggle):
+class ShuffleSymbols(RelevanceMixin, DefaultOnToggle):
"""
If on, you will need to unlock puzzle symbols as items to be able to solve the panels that contain those symbols.
Please note that there is no minimum set of progression items in this randomizer.
If you turn this option off and don't turn on door shuffle or obelisk keys, there will be no progression items, which will disallow you from adding your yaml to a multiworld generation.
"""
+ relevance = OptionRelevance.all
+
display_name = "Shuffle Symbols"
-class ProgressiveSymbols(OptionSet):
+class ProgressiveSymbols(RelevanceMixin, OptionSet):
"""
Make some symbols progressive, if they exist.
@@ -87,6 +115,8 @@ class ProgressiveSymbols(OptionSet):
Progressive Shapers: Shapers -> Rotated Shapers -> Negative Shapers
Progressive Discard Symbols: Triangles -> Arrows
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through chains in slot_data
+
display_name = "Progressive Symbols"
valid_keys = {
@@ -101,7 +131,7 @@ class ProgressiveSymbols(OptionSet):
default = frozenset({"Progressive Dots", "Progressive Symmetry", "Progressive Stars"})
-class SecondStageSymbolsActIndependently(OptionSet):
+class SecondStageSymbolsActIndependently(RelevanceMixin, OptionSet):
"""
Makes certain second stage symbols act independently of first stage symbols if they are not progressive.
@@ -111,6 +141,7 @@ class SecondStageSymbolsActIndependently(OptionSet):
Rotated Shapers always act independently from Shapers. The ability to make them dependent on Shapers by omitting them in this option may be added in the future.
"""
+ relevance = OptionRelevance.all
valid_keys = {
"Full Dots",
@@ -123,27 +154,31 @@ class SecondStageSymbolsActIndependently(OptionSet):
visibility = Visibility.template | Visibility.complex_ui
-class ColoredDotsAreProgressiveDots(Toggle):
+class ColoredDotsAreProgressiveDots(RelevanceMixin, Toggle):
"""
Put Colored Dots into the "Progressive Dots" group, after Dots.
This removes Progressive Symmetry.
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through chains in slot_data
visibility = Visibility.template | Visibility.complex_ui
-class SoundDotsAreProgressiveDots(Toggle):
+class SoundDotsAreProgressiveDots(RelevanceMixin, Toggle):
"""
Put Sound Dots into the "Progressive Dots" group, before Full Dots.
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through chains in slot_data
visibility = Visibility.template | Visibility.complex_ui
-class ShuffleLasers(Choice):
+class ShuffleLasers(RelevanceMixin, Choice):
"""
If on, the 11 lasers are turned into items and will activate on their own upon receiving them.
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data
+
display_name = "Shuffle Lasers"
option_off = 0
alias_false = 0
@@ -153,13 +188,15 @@ class ShuffleLasers(Choice):
alias_on = 2
-class ShuffleDoors(Choice):
+class ShuffleDoors(RelevanceMixin, Choice):
"""
If on, opening doors, moving bridges etc. will require a "key".
- Panels: The panel on the door will be locked until receiving its corresponding key.
- Doors: The door will open immediately upon receiving its key. Door panels are added as location checks.
- Mixed: Includes all doors from "doors", and all control panels (bridges, elevators etc.) from "panels".
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data
+
display_name = "Shuffle Doors"
option_off = 0
option_panels = 1
@@ -167,42 +204,50 @@ class ShuffleDoors(Choice):
option_mixed = 3
-class DoorGroupings(Choice):
+class DoorGroupings(RelevanceMixin, Choice):
"""
Controls how door items are grouped.
- Off: There will be one key for each door, potentially resulting in upwards of 120 keys being added to the item pool.
- Regional: All doors in the same general region will open at once with a single key, reducing the amount of door items and complexity.
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data
+
display_name = "Door Groupings"
option_off = 0
option_regional = 1
-class ShuffleBoat(DefaultOnToggle):
+class ShuffleBoat(RelevanceMixin, DefaultOnToggle):
"""
If on, adds a "Boat" item to the item pool. Before receiving this item, you will not be able to use the boat.
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data
+
display_name = "Shuffle Boat"
-class ShuffleDiscardedPanels(Toggle):
+class ShuffleDiscardedPanels(RelevanceMixin, Toggle):
"""
Adds Discarded Panels into the location pool.
Even if this is off, solving certain Discarded Panels may still be necessary to beat the game - The main example of this being the alternate activation triggers in "Disable non randomized puzzles".
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through existing locations
+
display_name = "Shuffle Discarded Panels"
-class ShuffleVaultBoxes(Toggle):
+class ShuffleVaultBoxes(RelevanceMixin, Toggle):
"""
Adds Vault Boxes to the location pool.
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through existing locations
+
display_name = "Shuffle Vault Boxes"
-class ShuffleEnvironmentalPuzzles(Choice):
+class ShuffleEnvironmentalPuzzles(RelevanceMixin, Choice):
"""
Adds Environmental/Obelisk Puzzles into the location pool.
- Individual: Every Environmental Puzzle sends an item.
@@ -210,13 +255,15 @@ class ShuffleEnvironmentalPuzzles(Choice):
Note: In Obelisk Sides, any EPs excluded through another option will be pre-completed on their Obelisk.
"""
+ relevance = OptionRelevance.all # Client currently uses this info, but could probably infer from existing locations
+
display_name = "Shuffle Environmental Puzzles"
option_off = 0
option_individual = 1
option_obelisk_sides = 2
-class EasterEggHunt(Choice):
+class EasterEggHunt(RelevanceMixin, Choice):
"""
Adds up to 120 Easter Eggs to the game, placed by NewSoupVi, Exempt-Medic, hatkirby, Scipio, and Rever.
These can be collected by simply clicking on them.
@@ -238,6 +285,7 @@ class EasterEggHunt(Choice):
It is recommended that you play this mode together with Door Shuffle. Without it, more than half of the Easter Eggs will be in sphere 1.
"""
+ relevance = OptionRelevance.all
visibility = Visibility.all if is_easter_time() else Visibility.none
@@ -263,11 +311,13 @@ def get_step_and_logical_step(self) -> Tuple[int, int]:
return 4, 4
-class ShuffleDog(Choice):
+class ShuffleDog(RelevanceMixin, Choice):
"""
Adds petting the dog statue in Town into the location pool.
Alternatively, you can force it to be a Puzzle Skip.
"""
+ relevance = OptionRelevance.all_trackers # Client infers this from existing locations
+
display_name = "Pet the Dog"
option_off = 0
@@ -276,47 +326,55 @@ class ShuffleDog(Choice):
default = 1
-class EnvironmentalPuzzlesDifficulty(Choice):
+class EnvironmentalPuzzlesDifficulty(RelevanceMixin, Choice):
"""
When "Shuffle Environmental Puzzles" is on, this setting governs which EPs are eligible for the location pool.
- Eclipse: Every EP in the game is eligible, including the 1-hour-long "Theater Eclipse EP".
- Tedious: Theater Eclipse EP is excluded from the location pool.
- Normal: several other difficult or long EPs are excluded as well.
"""
+ relevance = OptionRelevance.all_trackers # Client just sees "disabled EPs"
+
display_name = "Environmental Puzzles Difficulty"
option_normal = 0
option_tedious = 1
option_eclipse = 2
-class ObeliskKeys(DefaultOnToggle):
+class ObeliskKeys(RelevanceMixin, DefaultOnToggle):
"""
Add one Obelisk Key item per Obelisk, locking you out of solving any of the associated Environmental Puzzles.
Does nothing if "Shuffle Environmental Puzzles" is set to "off".
"""
+ relevance = OptionRelevance.all_trackers # Client gets this info through door info in slot_data
+
display_name = "Obelisk Keys"
-class UnlockableWarps(Toggle):
+class UnlockableWarps(RelevanceMixin, Toggle):
"""
Adds unlockable fast travel points to the game.
These warp points are represented by spheres in game. You walk up to one, you unlock it for warping.
The warp points are: Entry, Symmetry Island, Desert, Quarry, Keep, Shipwreck, Town, Jungle, Bunker, Treehouse, Mountaintop, Caves.
"""
+ relevance = OptionRelevance.client | OptionRelevance.external_tracker # The PopTracker pack has a warp-based trick
+
display_name = "Unlockable Fast Travel Points"
-class ShufflePostgame(Toggle):
+class ShufflePostgame(RelevanceMixin, Toggle):
"""
Adds locations into the pool that are guaranteed to become accessible after or at the same time as your goal.
Use this if you don't play with release on victory.
"""
+ relevance = OptionRelevance.all
+
display_name = "Shuffle Postgame"
-class VictoryCondition(Choice):
+class VictoryCondition(RelevanceMixin, Choice):
"""
Set the victory condition for this world.
- Elevator: Start the elevator at the bottom of the mountain (requires Mountain Lasers).
@@ -328,6 +386,8 @@ class VictoryCondition(Choice):
It is important to note that while the Mountain Box requires Desert Laser to be redirected in Town for that laser
to count, the laser locks on the Elevator and Challenge Timer panels do not.
"""
+ relevance = OptionRelevance.all_trackers # Client gets a "victory location" through slot_data instead
+
display_name = "Victory Condition"
option_elevator = 0
option_challenge = 1
@@ -336,31 +396,35 @@ class VictoryCondition(Choice):
option_panel_hunt = 4
-class PanelHuntTotal(Range):
+class PanelHuntTotal(RelevanceMixin, Range):
"""
Only relevant if the Victory Condition is "Panel Hunt".
Sets the number of random panels that will get marked as "Panel Hunt" panels in the "Panel Hunt" game mode.
"""
+ relevance = OptionRelevance.none # slot_data contains a list of hunt panels, which client just checks the size of
+
display_name = "Total Panel Hunt panels"
range_start = 5
range_end = 100
default = 40
-class PanelHuntRequiredPercentage(Range):
+class PanelHuntRequiredPercentage(RelevanceMixin, Range):
"""
Only relevant if the Victory Condition is "Panel Hunt".
Determines the percentage of "Panel Hunt" panels that need to be solved to win.
"""
+ relevance = OptionRelevance.none # Tracked by a different value called WitnessWorld.panel_hunt_required_count
+
display_name = "Percentage of required Panel Hunt panels"
range_start = 20
range_end = 100
default = 63
-class PanelHuntPostgame(Choice):
+class PanelHuntPostgame(RelevanceMixin, Choice):
"""
Only relevant if the Victory Condition is "Panel Hunt".
@@ -372,6 +436,7 @@ class PanelHuntPostgame(Choice):
Note: The "Mountain Lasers" option may also affect locations locked by challenge lasers if the only path to those locations leads through the Mountain Entry.
"""
+ relevance = OptionRelevance.all
display_name = "Force postgame in Panel Hunt"
@@ -382,7 +447,7 @@ class PanelHuntPostgame(Choice):
default = 3
-class PanelHuntDiscourageSameAreaFactor(Range):
+class PanelHuntDiscourageSameAreaFactor(RelevanceMixin, Range):
"""
Only relevant if the Victory Condition is "Panel Hunt".
@@ -391,6 +456,8 @@ class PanelHuntDiscourageSameAreaFactor(Range):
At 0, Hunt Panels will be selected randomly.
At 100, Hunt Panels will be almost completely evenly distributed between areas.
"""
+ relevance = OptionRelevance.none # Universal tracker & client just consume the list of chosen hunt panels
+
display_name = "Panel Hunt Discourage Same Area Factor"
range_start = 0
@@ -398,19 +465,20 @@ class PanelHuntDiscourageSameAreaFactor(Range):
default = 40
-class PanelHuntPlando(LocationSet):
+class PanelHuntPlando(RelevanceMixin, LocationSet):
"""
Only relevant if the Victory Condition is "Panel Hunt".
Specify specific hunt panels you want for your panel hunt game.
"""
+ relevance = OptionRelevance.none # Universal tracker & client just consume the list of chosen hunt panels
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):
+class PuzzleRandomization(RelevanceMixin, Choice):
"""
Puzzles in this randomizer are randomly generated. This option changes the difficulty/types of puzzles.
"Sigma Normal" randomizes puzzles close to their original mechanics and difficulty.
@@ -418,6 +486,8 @@ class PuzzleRandomization(Choice):
"Umbra Variety" focuses on unique symbol combinations not featured in the original game. It is harder than Sigma Normal, but easier than Sigma Expert.
"None" means that the puzzles are unchanged from the original game.
"""
+ relevance = OptionRelevance.all
+
display_name = "Puzzle Randomization"
option_sigma_normal = 0
option_sigma_expert = 1
@@ -425,29 +495,33 @@ class PuzzleRandomization(Choice):
option_none = 2
-class MountainLasers(Range):
+class MountainLasers(RelevanceMixin, Range):
"""
Sets the number of lasers required to enter the Mountain.
If set to a higher number than 7, the mountaintop box will be slightly rotated to make it possible to solve without the hatch being opened.
This change will also be applied logically to the long solution ("Challenge Lasers" option).
"""
+ relevance = OptionRelevance.all
+
display_name = "Required Lasers for Mountain Entry"
range_start = 1
range_end = 11
default = 7
-class ChallengeLasers(Range):
+class ChallengeLasers(RelevanceMixin, Range):
"""
Sets the number of lasers required to enter the Caves through the Mountain Bottom Floor Discard and to unlock the Challenge Timer Panel.
"""
+ relevance = OptionRelevance.all
+
display_name = "Required Lasers for Challenge"
range_start = 1
range_end = 11
default = 11
-class ElevatorsComeToYou(OptionSet):
+class ElevatorsComeToYou(RelevanceMixin, OptionSet):
"""
In vanilla, some bridges/elevators come to you if you walk up to them when they are not currently there.
However, there are some that don't. Notably, this prevents Quarry Elevator from being a logical access method into Quarry, because you can send it away without riding ot and then permanently be locked out of using it.
@@ -459,6 +533,8 @@ class ElevatorsComeToYou(OptionSet):
- Bunker Elevator: Makes the Bunker Elevator come to any floor that you approach it from, meaning it can be accessed from the roof immediately
"""
+ relevance = OptionRelevance.all
+
# Used to be a toggle
@classmethod
def from_text(cls, text: str):
@@ -476,10 +552,12 @@ def from_text(cls, text: str):
default = frozenset({"Quarry Elevator"})
-class TrapPercentage(Range):
+class TrapPercentage(RelevanceMixin, Range):
"""
Replaces junk items with traps, at the specified rate.
"""
+ relevance = OptionRelevance.none
+
display_name = "Trap Percentage"
range_start = 0
range_end = 100
@@ -493,12 +571,14 @@ class TrapPercentage(Range):
}
-class TrapWeights(OptionCounter):
+class TrapWeights(RelevanceMixin, OptionCounter):
"""
Specify the weights determining how many copies of each trap item will be in your itempool.
If you don't want a specific type of trap, you can set the weight for it to 0.
If you set all trap weights to 0, you will get no traps, bypassing the "Trap Percentage" option.
"""
+ relevance = OptionRelevance.none
+
display_name = "Trap Weights"
valid_keys = _default_trap_weights.keys()
@@ -507,28 +587,32 @@ class TrapWeights(OptionCounter):
default = _default_trap_weights
-class PuzzleSkipAmount(Range):
+class PuzzleSkipAmount(RelevanceMixin, Range):
"""
Adds this many Puzzle Skips into the pool, if there is room. Puzzle Skips let you skip one panel.
"""
+ relevance = OptionRelevance.none
+
display_name = "Puzzle Skips"
range_start = 0
range_end = 30
default = 10
-class HintAmount(Range):
+class HintAmount(RelevanceMixin, Range):
"""
Adds hints to Audio Logs. If set to a low amount, up to 2 additional duplicates of each hint will be added.
Remaining Audio Logs will have junk hints.
"""
+ relevance = OptionRelevance.none
+
display_name = "Hints on Audio Logs"
range_start = 0
range_end = 49
default = 12
-class VagueHints(Choice):
+class VagueHints(RelevanceMixin, Choice):
"""Make Location Hints a bit more vague, where they only tell you about the general area the item is in.
Area Hints will be generated as normal.
@@ -539,6 +623,10 @@ class VagueHints(Choice):
Also, please don't pester any devs about implementing location groups. Bring it up nicely, accept their response even if it is "No".
"""
+ # This is to provide backcompat for a bug.
+ # Remove this when backcompat is broken for another reason
+ relevance = OptionRelevance.client
+
display_name = "Vague Hints"
option_off = 0
@@ -546,51 +634,61 @@ class VagueHints(Choice):
option_experimental = 2
-class AreaHintPercentage(Range):
+class AreaHintPercentage(RelevanceMixin, Range):
"""
There are two types of hints for The Witness.
"Location hints" hint one location in your world or one location containing an item for your world.
"Area hints" tell you some general info about the items you can find in one of the main geographic areas on the island.
Use this option to specify how many of your hints you want to be area hints. The rest will be location hints.
"""
+ relevance = OptionRelevance.none
+
display_name = "Area Hint Percentage"
range_start = 0
range_end = 100
default = 33
-class LaserHints(Toggle):
+class LaserHints(RelevanceMixin, Toggle):
"""
If on, lasers will tell you where their items are if you walk close to them in-game.
Only applies if Laser Shuffle is enabled.
"""
+ relevance = OptionRelevance.none
+
display_name = "Laser Hints"
-class DeathLink(Toggle):
+class DeathLink(RelevanceMixin, Toggle):
"""
If on, whenever you fail a puzzle (with some exceptions), you and everyone who is also on Death Link dies.
The effect of a "death" in The Witness is a Bonk Trap.
"""
+ relevance = OptionRelevance.all # Trackers use this for trick logic (some tricks are unreasonable on death link)
+
display_name = "Death Link"
-class DeathLinkAmnesty(Range):
+class DeathLinkAmnesty(RelevanceMixin, Range):
"""
The number of panel fails to allow before sending a death through Death Link.
0 means every panel fail will send a death, 1 means every other panel fail will send a death, etc.
"""
+ relevance = OptionRelevance.client
+
display_name = "Death Link Amnesty"
range_start = 0
range_end = 5
default = 1
-class PuzzleRandomizationSeed(Range):
+class PuzzleRandomizationSeed(RelevanceMixin, Range):
"""
Sigma Rando, which is the basis for all puzzle randomization in this randomizer, uses a seed from 1 to 9999999 for the puzzle randomization.
This option lets you set this seed yourself.
"""
+ relevance = OptionRelevance.client
+
display_name = "Puzzle Randomization Seed"
range_start = 1
range_end = 9999999
@@ -644,6 +742,16 @@ class TheWitnessOptions(PerGameCommonOptions):
early_symbol_item: Removed
+assert all(
+ (
+ issubclass(option_type, RelevanceMixin)
+ or option_name in PerGameCommonOptions.type_hints
+ or option_type is Removed
+ )
+ for option_name, option_type in TheWitnessOptions.type_hints.items()
+)
+
+
witness_option_groups = [
OptionGroup("Puzzles & Goal", [
PuzzleRandomization,
@@ -714,3 +822,4 @@ class TheWitnessOptions(PerGameCommonOptions):
else:
silly_options_group = next(group for group in witness_option_groups if group.name == "Silly Options")
silly_options_group.options.append(EasterEggHunt)
+
diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py
index c369ec623aa..bce11144df2 100644
--- a/worlds/witness/player_logic.py
+++ b/worlds/witness/player_logic.py
@@ -25,6 +25,7 @@
from .data.settings.progressive_items import PROGRESSIVE_SYMBOLS
from .data.static_logic import StaticWitnessLogicObj
from .data.utils import (
+ entity_id_int_to_canonical_entity_id_string,
get_boat,
get_caves_except_path_to_challenge_exclusion_list,
get_complex_additional_panels,
@@ -61,7 +62,12 @@ class WitnessPlayerLogic:
VICTORY_LOCATION: str
- def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_inv: Dict[str, int]) -> None:
+ def __init__(
+ self,
+ world: "WitnessWorld",
+ disabled_locations: Set[str],
+ start_inv: Dict[str, int],
+ ) -> None:
self.YAML_DISABLED_LOCATIONS: Set[str] = disabled_locations
self.YAML_ADDED_ITEMS: Dict[str, int] = start_inv
@@ -152,6 +158,8 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in
# This will make the access conditions way faster, instead of recursively checking dependent entities each time.
self.make_dependency_reduced_checklist()
+ self.make_universal_tracker_adjustments(world)
+
if world.options.victory_condition == "panel_hunt":
picker = EntityHuntPicker(self, world, self.PRE_PICKED_HUNT_ENTITIES)
self.HUNT_ENTITIES = picker.pick_panel_hunt_panels(world.options.panel_hunt_total.value)
@@ -1177,3 +1185,28 @@ def make_event_panel_lists(self) -> None:
self.EVENT_ITEM_PAIRS[f"{region_name} Easter Egg{plural}"] = (event_name, region_name)
return
+
+ def make_universal_tracker_adjustments(self, world: "WitnessWorld") -> None:
+ re_gen_passthrough = getattr(world.multiworld, "re_gen_passthrough", None)
+ if re_gen_passthrough is None:
+ return
+
+ witness_slot_data = re_gen_passthrough["The Witness"]
+
+ if "hunt_entities" not in witness_slot_data:
+ raise ValueError(
+ "The Witness apworld you have is not compatible with this multiworld, "
+ "as it is missing 'hunt_entities' in slot_data."
+ )
+
+ self.PRE_PICKED_HUNT_ENTITIES = {
+ entity_id_int_to_canonical_entity_id_string(entity_int) for entity_int in witness_slot_data["hunt_entities"]
+ }
+
+ not_real_entities = self.PRE_PICKED_HUNT_ENTITIES - set(self.REFERENCE_LOGIC.ENTITIES_BY_HEX)
+ if not_real_entities:
+ raise ValueError(
+ "UT slot_data had panel hunt entities in it that the apworld does not recognize. "
+ "Please report this to NewSoupVi.\n"
+ f"{not_real_entities}"
+ )
diff --git a/worlds/witness/universal_tracker.py b/worlds/witness/universal_tracker.py
new file mode 100644
index 00000000000..229271335df
--- /dev/null
+++ b/worlds/witness/universal_tracker.py
@@ -0,0 +1,48 @@
+from typing import TYPE_CHECKING, cast
+
+from Options import Option
+from .options import OptionRelevance
+
+from . import RelevanceMixin, TheWitnessOptions
+
+if TYPE_CHECKING:
+ from . import WitnessWorld
+
+
+def set_options_from_ut(world: "WitnessWorld"):
+ re_gen_passthrough = getattr(world.multiworld, "re_gen_passthrough", None)
+ if re_gen_passthrough is None:
+ return
+
+ if not "The Witness" in re_gen_passthrough:
+ raise ValueError(
+ "Found Universal Tracker re_gen_passthrough, but \"The Witness\" key was not in it. "
+ "Something went terribly wrong here."
+ )
+
+ witness_slot_data = re_gen_passthrough["The Witness"]
+
+ for option_name, option_type in TheWitnessOptions.type_hints.items():
+ if not issubclass(option_type, RelevanceMixin):
+ continue
+
+ option_class = cast(type[Option], option_type)
+
+ if not option_type.relevance & OptionRelevance.universal_tracker_regeneration:
+ continue
+
+ if option_name not in witness_slot_data:
+ raise ValueError(
+ "The Witness apworld you have is incompatible with this multiworld, "
+ f"as {option_name} is missing from slot data."
+ )
+
+ setattr(world.options, option_name, option_class.from_any(witness_slot_data[option_name]))
+
+ if "panel_hunt_required_absolute" not in witness_slot_data:
+ raise ValueError(
+ "The Witness apworld you have is incompatible with this multiworld, "
+ "as panel_hunt_required_absolute is missing from slot data."
+ )
+
+ world.panel_hunt_required_count = witness_slot_data["panel_hunt_required_absolute"]
\ No newline at end of file