mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-18 21:38:13 -07:00
Compare commits
7 Commits
core_orjso
...
0.6.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecb22642af | ||
|
|
17ccfdc266 | ||
|
|
4633f12972 | ||
|
|
1f6c99635e | ||
|
|
4e92cac171 | ||
|
|
3b88630b0d | ||
|
|
e6d2d8f455 |
@@ -1571,7 +1571,7 @@ class ItemClassification(IntFlag):
|
||||
|
||||
def as_flag(self) -> int:
|
||||
"""As Network API flag int."""
|
||||
return int(self & 0b0111)
|
||||
return int(self & 0b00111)
|
||||
|
||||
|
||||
class Item:
|
||||
|
||||
@@ -9,6 +9,7 @@ import sys
|
||||
import typing
|
||||
import time
|
||||
import functools
|
||||
import warnings
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
@@ -21,11 +22,10 @@ if __name__ == "__main__":
|
||||
Utils.init_logging("TextClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor, mark_raw
|
||||
from NetUtils import (Endpoint, decode, NetworkItem, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus,
|
||||
SlotType, NetworkPlayer, encode_to_bytes)
|
||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||
from Utils import Version, stream_input, async_start
|
||||
from worlds import network_data_package
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
import ssl
|
||||
|
||||
@@ -502,11 +502,10 @@ class CommonContext:
|
||||
""" `msgs` JSON serializable """
|
||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||
return
|
||||
await self.server.socket.send(encode_to_bytes(msgs))
|
||||
await self.server.socket.send(encode(msgs))
|
||||
|
||||
def consume_players_package(self, package: typing.List[NetworkPlayer]):
|
||||
self.player_names = {network_player.slot: network_player.name for network_player in package
|
||||
if self.team == network_player.team}
|
||||
def consume_players_package(self, package: typing.List[tuple]):
|
||||
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
|
||||
self.player_names[0] = "Archipelago"
|
||||
|
||||
def event_invalid_slot(self):
|
||||
@@ -515,12 +514,11 @@ class CommonContext:
|
||||
def event_invalid_game(self):
|
||||
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
|
||||
|
||||
async def server_auth(self, password_requested: bool = False) -> typing.Optional[str]:
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
logger.info('Enter the password required to join this game:')
|
||||
self.password = await self.console_input()
|
||||
return self.password
|
||||
return None
|
||||
|
||||
async def get_username(self):
|
||||
if not self.auth:
|
||||
@@ -944,10 +942,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
logger.info('--------------------------------')
|
||||
logger.info('Room Information:')
|
||||
logger.info('--------------------------------')
|
||||
ctx.server_version = Version.from_network_dict(args["version"])
|
||||
version = args["version"]
|
||||
ctx.server_version = Version(*version)
|
||||
|
||||
if "generator_version" in args:
|
||||
ctx.generator_version = Version.from_network_dict(args["generator_version"])
|
||||
ctx.generator_version = Version(*args["generator_version"])
|
||||
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
|
||||
f'generator version: {ctx.generator_version.as_simple_string()}, '
|
||||
f'tags: {", ".join(args["tags"])}')
|
||||
@@ -1017,9 +1016,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.slot = args["slot"]
|
||||
# int keys get lost in JSON transfer
|
||||
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
|
||||
ctx.slot_info.update({int(pid): NetworkSlot.from_network_dict(data) for pid, data in args["slot_info"].items()})
|
||||
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
|
||||
ctx.hint_points = args.get("hint_points", 0)
|
||||
ctx.consume_players_package([NetworkPlayer.from_network_dict(player) for player in args["players"]])
|
||||
ctx.consume_players_package(args["players"])
|
||||
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
||||
if ctx.game:
|
||||
game = ctx.game
|
||||
@@ -1068,17 +1067,17 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
await ctx.send_msgs(sync_msg)
|
||||
if start_index == len(ctx.items_received):
|
||||
for item in args['items']:
|
||||
ctx.items_received.append(NetworkItem.from_network_dict(item))
|
||||
ctx.items_received.append(NetworkItem(*item))
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == 'LocationInfo':
|
||||
for item in [NetworkItem.from_network_dict(item) for item in args['locations']]:
|
||||
for item in [NetworkItem(*item) for item in args['locations']]:
|
||||
ctx.locations_info[item.location] = item
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
if "players" in args:
|
||||
ctx.consume_players_package([NetworkPlayer.from_network_dict(player) for player in args["players"]])
|
||||
ctx.consume_players_package(args["players"])
|
||||
if "hint_points" in args:
|
||||
ctx.hint_points = args['hint_points']
|
||||
if "checked_locations" in args:
|
||||
|
||||
13
Main.py
13
Main.py
@@ -342,18 +342,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
# TODO: change to `"version": version_tuple` after getting better serialization
|
||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||
|
||||
base_types_keys = ["er_hint_data"]
|
||||
|
||||
# starting with 0.7.0 pre-encode slot data, until then multiserver does it on load
|
||||
if version_tuple < (0, 7, 0):
|
||||
base_types_keys.append("slot_data")
|
||||
else:
|
||||
for slot, data in multidata["slot_data"].items():
|
||||
multidata[slot] = NetUtils.encode_to_bytes(data)
|
||||
assert type(multidata[slot]) is bytes
|
||||
multidata["minimum_versions"]["server"] = max((0, 7, 0), multidata["minimum_versions"]["server"])
|
||||
|
||||
for key in base_types_keys:
|
||||
for key in ("slot_data", "er_hint_data"):
|
||||
multidata[key] = convert_to_base_types(multidata[key])
|
||||
|
||||
multidata = zlib.compress(restricted_dumps(multidata), 9)
|
||||
|
||||
@@ -31,7 +31,6 @@ if typing.TYPE_CHECKING:
|
||||
from NetUtils import ServerConnection
|
||||
|
||||
import colorama
|
||||
import orjson
|
||||
import websockets
|
||||
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||
try:
|
||||
@@ -42,9 +41,9 @@ except ImportError:
|
||||
|
||||
import NetUtils
|
||||
import Utils
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text, __version__
|
||||
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, encode_to_bytes
|
||||
SlotType, LocationStore, MultiData, Hint, HintStatus
|
||||
from BaseClasses import ItemClassification
|
||||
|
||||
|
||||
@@ -170,7 +169,7 @@ team_slot = typing.Tuple[int, int]
|
||||
|
||||
|
||||
class Context:
|
||||
dumper = staticmethod(encode_to_bytes)
|
||||
dumper = staticmethod(encode)
|
||||
loader = staticmethod(decode)
|
||||
|
||||
simple_options = {"hint_cost": int,
|
||||
@@ -454,7 +453,7 @@ class Context:
|
||||
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||
if mdata_ver > version_tuple:
|
||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
|
||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||
f"however this server is of version {version_tuple}")
|
||||
self.generator_version = Version(*decoded_obj["version"])
|
||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||
@@ -491,10 +490,6 @@ class Context:
|
||||
self.locations = LocationStore(decoded_obj.pop("locations")) # pre-emptively free memory
|
||||
self.slot_data = decoded_obj['slot_data']
|
||||
for slot, data in self.slot_data.items():
|
||||
if not isinstance(data, bytes):
|
||||
data = encode_to_bytes(data)
|
||||
data = orjson.Fragment(data)
|
||||
self.slot_data[slot] = data
|
||||
self.read_data[f"slot_data_{slot}"] = lambda data=data: data
|
||||
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
|
||||
for player, loc_data in decoded_obj["er_hint_data"].items()}
|
||||
@@ -1791,11 +1786,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
|
||||
if cmd == 'Connect':
|
||||
if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \
|
||||
'game' not in args or "version" not in args:
|
||||
'game' not in args:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", 'text': 'Connect',
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
args["version"] = Version.from_network_dict(args["version"])
|
||||
|
||||
errors = set()
|
||||
if ctx.password and args['password'] != ctx.password:
|
||||
errors.add('InvalidPassword')
|
||||
@@ -1811,7 +1806,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
if not ignore_game and args['game'] != game:
|
||||
errors.add('InvalidGame')
|
||||
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
|
||||
if minver > args["version"]:
|
||||
if minver > args['version']:
|
||||
errors.add('IncompatibleVersion')
|
||||
try:
|
||||
client.items_handling = args['items_handling']
|
||||
@@ -1819,7 +1814,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
errors.add('InvalidItemsHandling')
|
||||
|
||||
# only exact version match allowed
|
||||
if ctx.compatibility == 0 and args['version'] != Version(__version__):
|
||||
if ctx.compatibility == 0 and args['version'] != version_tuple:
|
||||
errors.add('IncompatibleVersion')
|
||||
if errors:
|
||||
ctx.logger.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
|
||||
|
||||
57
NetUtils.py
57
NetUtils.py
@@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence
|
||||
import typing
|
||||
import enum
|
||||
import warnings
|
||||
import orjson
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from websockets import WebSocketServerProtocol as ServerConnection
|
||||
@@ -78,11 +78,6 @@ class NetworkPlayer(typing.NamedTuple):
|
||||
alias: str
|
||||
name: str
|
||||
|
||||
@classmethod
|
||||
def from_network_dict(cls, source: dict):
|
||||
source.pop("class", None)
|
||||
return cls(**source)
|
||||
|
||||
|
||||
class NetworkSlot(typing.NamedTuple):
|
||||
"""Represents a particular slot across teams."""
|
||||
@@ -91,11 +86,6 @@ class NetworkSlot(typing.NamedTuple):
|
||||
type: SlotType
|
||||
group_members: Sequence[int] = () # only populated if type == group
|
||||
|
||||
@classmethod
|
||||
def from_network_dict(cls, source: dict):
|
||||
source.pop("class", None)
|
||||
return cls(**source)
|
||||
|
||||
|
||||
class NetworkItem(typing.NamedTuple):
|
||||
item: int
|
||||
@@ -104,11 +94,6 @@ class NetworkItem(typing.NamedTuple):
|
||||
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
|
||||
flags: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_network_dict(cls, source: dict):
|
||||
source.pop("class", None)
|
||||
return cls(**source)
|
||||
|
||||
|
||||
def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
if isinstance(obj, tuple) and hasattr(obj, "_fields"): # NamedTuple is not actually a parent class
|
||||
@@ -143,12 +128,15 @@ def convert_to_base_types(obj: typing.Any) -> _base_types:
|
||||
raise Exception(f"Cannot handle {type(obj)}")
|
||||
|
||||
|
||||
def encode_to_bytes(obj: typing.Any) -> bytes:
|
||||
return orjson.dumps(_scan_for_TypedTuples(obj), option=orjson.OPT_NON_STR_KEYS)
|
||||
_encode = JSONEncoder(
|
||||
ensure_ascii=False,
|
||||
check_circular=False,
|
||||
separators=(',', ':'),
|
||||
).encode
|
||||
|
||||
|
||||
def encode(obj: typing.Any) -> str:
|
||||
return encode_to_bytes(obj).decode()
|
||||
return _encode(_scan_for_TypedTuples(obj))
|
||||
|
||||
|
||||
def get_any_version(data: dict) -> Version:
|
||||
@@ -156,10 +144,33 @@ def get_any_version(data: dict) -> Version:
|
||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||
|
||||
|
||||
def decode(data: str | bytes) -> typing.Any:
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
return orjson.loads(data)
|
||||
allowlist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
}
|
||||
|
||||
custom_hooks = {
|
||||
"Version": get_any_version
|
||||
}
|
||||
|
||||
|
||||
def _object_hook(o: typing.Any) -> typing.Any:
|
||||
if isinstance(o, dict):
|
||||
hook = custom_hooks.get(o.get("class", None), None)
|
||||
if hook:
|
||||
return hook(o)
|
||||
cls = allowlist.get(o.get("class", None), None)
|
||||
if cls:
|
||||
for key in tuple(o):
|
||||
if key not in cls._fields:
|
||||
del (o[key])
|
||||
return cls(**o)
|
||||
|
||||
return o
|
||||
|
||||
|
||||
decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
|
||||
5
Utils.py
5
Utils.py
@@ -46,11 +46,6 @@ class Version(typing.NamedTuple):
|
||||
def as_simple_string(self) -> str:
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
@classmethod
|
||||
def from_network_dict(cls, source: dict):
|
||||
source.pop("class", None)
|
||||
return cls(**source)
|
||||
|
||||
|
||||
__version__ = "0.6.3"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
@@ -344,7 +344,7 @@ names, and `def can_place_boss`, which passes a boss and location, allowing you
|
||||
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
|
||||
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
|
||||
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
|
||||
`worlds.alttp.options.py`
|
||||
`worlds/alttp/Options.py`
|
||||
|
||||
### OptionDict
|
||||
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the
|
||||
|
||||
@@ -48,13 +48,14 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
original_get_all_state = multiworld.get_all_state
|
||||
|
||||
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
|
||||
def patched_get_all_state(use_cache: bool | None = None, allow_partial_entrances: bool = False,
|
||||
**kwargs):
|
||||
self.assertTrue(allow_partial_entrances, (
|
||||
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
|
||||
"As such, any call to get_all_state must use allow_partial_entrances = True."
|
||||
))
|
||||
|
||||
return original_get_all_state(use_cache, allow_partial_entrances)
|
||||
return original_get_all_state(use_cache, allow_partial_entrances, **kwargs)
|
||||
|
||||
multiworld.get_all_state = patched_get_all_state
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import orjson
|
||||
import unittest
|
||||
from NetUtils import encode, decode
|
||||
|
||||
|
||||
class TestSerialize(unittest.TestCase):
|
||||
def test_unbounded_int(self) -> None:
|
||||
big_number = 2**200
|
||||
round_tripped_big_number = decode(encode(orjson.Fragment(str(big_number).encode())))
|
||||
self.assertEqual(big_number, round_tripped_big_number)
|
||||
self.assertEqual(type(big_number), type(round_tripped_big_number))
|
||||
@@ -267,6 +267,10 @@ class DarkSouls3World(World):
|
||||
# Don't allow missable duplicates of progression items to be expected progression.
|
||||
if location.name in self.missable_dupe_prog_locs: continue
|
||||
|
||||
# Don't create DLC and NGP locations if those are disabled
|
||||
if location.dlc and not self.options.enable_dlc: continue
|
||||
if location.ngp and not self.options.enable_ngp: continue
|
||||
|
||||
# Replace non-randomized items with events that give the default item
|
||||
event_item = (
|
||||
self.create_item(location.default_item_name) if location.default_item_name
|
||||
|
||||
@@ -89,11 +89,15 @@ class FF1Client(BizHawkClient):
|
||||
|
||||
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
|
||||
try:
|
||||
if (await bizhawk.get_memory_size(ctx.bizhawk_ctx, self.rom)) < rom_name_location + 0x0D:
|
||||
return False # ROM is not large enough to be a Final Fantasy 1 ROM
|
||||
# Check ROM name/patch version
|
||||
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(rom_name_location, 0x0D, self.rom)]))[0])
|
||||
rom_name = rom_name.decode("ascii")
|
||||
if rom_name != "FINAL FANTASY":
|
||||
return False # Not a Final Fantasy 1 ROM
|
||||
except UnicodeDecodeError:
|
||||
return False # rom_name returned invalid text
|
||||
except bizhawk.RequestFailedError:
|
||||
return False # Not able to get a response, say no for now
|
||||
|
||||
|
||||
@@ -34,62 +34,75 @@ business!
|
||||
|
||||
## I don't know what to do!
|
||||
|
||||
That's not a question - but I'd suggest clicking the crow icon on your client, which will load an AP compatible autotracker for LADXR.
|
||||
That's not a question - but I'd suggest clicking the **Open Tracker** button in your client, which will load an AP compatible autotracker for LADXR.
|
||||
|
||||
## What is this randomizer based on?
|
||||
|
||||
This randomizer is based on (forked from) the wonderful work daid did on LADXR - https://github.com/daid/LADXR
|
||||
This randomizer is based on (forked from) the wonderful work daid did on [LADXR](https://github.com/daid/LADXR)
|
||||
|
||||
The autotracker code for communication with magpie tracker is directly copied from kbranch's repo - https://github.com/kbranch/Magpie/tree/master/autotracking
|
||||
The autotracker code for communication with magpie tracker is directly copied from [kbranch's repo](https://github.com/kbranch/Magpie)
|
||||
|
||||
### Graphics
|
||||
|
||||
The following sprite sheets have been included with permission of their respective authors:
|
||||
|
||||
* by Madam Materia (https://www.twitch.tv/isabelle_zephyr)
|
||||
* by [Madam Materia](https://www.twitch.tv/isabelle_zephyr)
|
||||
* Matty_LA
|
||||
* by Linker (https://twitter.com/BenjaminMaksym)
|
||||
* Bowwow
|
||||
* Bunny
|
||||
* Luigi
|
||||
* Mario
|
||||
* Richard
|
||||
* Tarin
|
||||
|
||||
Title screen graphics by toomanyteeth✨ (https://instagram.com/toomanyyyteeth)
|
||||
Title screen graphics by [toomanyteeth✨](https://instagram.com/toomanyyyteeth)
|
||||
|
||||
## Some tips from LADXR...
|
||||
|
||||
<h3>Locations</h3>
|
||||
<p>All chests and dungeon keys are always randomized. Also, the 3 songs (Marin, Mambo, and Manu) give a you an item if you present them the Ocarina. The seashell mansion 20 shells reward is also shuffled, but the 5 and 10 shell reward is not, as those can be missed.</p>
|
||||
<p>The moblin cave with Bowwow contains a chest instead. The color dungeon gives 2 items at the end instead of a choice of tunic. Other item locations are: The toadstool, the reward for delivering the toadstool, hidden seashells, heart pieces, heart containers, golden leaves, the Mad Batters (capacity upgrades), the shovel/bow in the shop, the rooster's grave, and all of the keys' (tail,slime,angler,face,bird) locations.</p>
|
||||
<p>Finally, new players often forget the following locations: the heart piece hidden in the water at the castle, the heart piece hidden in the bomb cave (screen before the honey), bonk seashells (run with pegasus boots against the tree in at the Tail Cave, and the tree right of Mabe Village, next to the phone booth), and the hookshop drop from Master Stalfos in D5.</p>
|
||||
### Locations
|
||||
|
||||
<h3>Color Dungeon</h3>
|
||||
<p>The Color Dungeon is part of the item shuffle, and the red/blue tunics are shuffled in the item pool. Which means the fairy at the end of the color dungeon gives out two random items.</p>
|
||||
<p>To access the color dungeon, you need the power bracelet, and you need to push the gravestones in the right order: "down, left, up, right, up", going from the lower right gravestone, to the one left of it, above it, and then to the right.</p>
|
||||
All chests and dungeon keys are always randomized. Also, the 3 songs (Marin, Mambo, and Manu) give a you an item if you present them the Ocarina. The seashell mansion 20 shells reward is also shuffled, but the 5 and 10 shell reward is not, as those can be missed.
|
||||
|
||||
<h3>Bowwow</h3>
|
||||
<p>Bowwow is in a chest, somewhere. After you find him, he will always be in the swamp with you, but not anywhere else.</p>
|
||||
The moblin cave with Bowwow contains a chest instead. The color dungeon gives 2 items at the end instead of a choice of tunic. Other item locations are: The toadstool, the reward for delivering the toadstool, hidden seashells, heart pieces, heart containers, golden leaves, the Mad Batters (capacity upgrades), the shovel/bow in the shop, the rooster's grave, and all of the keys' (tail,slime,angler,face,bird) locations.
|
||||
|
||||
<h3>Added things</h3>
|
||||
<p>In your save and quit menu, there is a 3rd option to return to your home. This has two main uses: it speeds up the game, and prevents softlocks (common in entrance rando).</p>
|
||||
<p>If you have weapons that require ammunition (bombs, powder, arrows), a ghost will show up inside Marin's house. He will refill you up to 10 ammunition, so you do not run out.</p>
|
||||
<p>The flying rooster is (optionally) available as an item.</p>
|
||||
<p>You can access the Bird Key cave item with the L2 Power Bracelet.</p>
|
||||
<p>Boomerang cave is now a random item gift by default (available post-bombs), and boomerang is in the item pool.</p>
|
||||
<p>Your inventory has been increased by four, to accommodate these items now coexisting with eachother.</p>
|
||||
Finally, new players often forget the following locations: the heart piece hidden in the water at the castle, the heart piece hidden in the bomb cave (screen before the honey), bonk seashells (run with pegasus boots against the tree in at the Tail Cave, and the tree right of Mabe Village, next to the phone booth), and the hookshop drop from Master Stalfos in D5.
|
||||
|
||||
<h3>Removed things</h3>
|
||||
<p>The ghost mini-quest after D4 never shows up, his seashell reward is always available.</p>
|
||||
<p>The walrus is moved a bit, so that you can access the desert without taking Marin on a date.</p>
|
||||
### Color Dungeon
|
||||
|
||||
<h3>Logic</h3>
|
||||
<p>Depending on your options, you can only steal after you find the sword, always, or never.</p>
|
||||
<p>Do not forget that there are two items in the rafting ride. You can access this with just Hookshot or Flippers.</p>
|
||||
<p>Killing enemies with bombs is in normal logic. You can switch to casual logic if you do not want this.</p>
|
||||
<p>D7 confuses some people, but by dropping down pits on the 2nd floor you can access almost all of this dungeon, even without feather and power bracelet.</p>
|
||||
The Color Dungeon is part of the item shuffle, and the red/blue tunics are shuffled in the item pool. Which means the fairy at the end of the color dungeon gives out two random items.
|
||||
|
||||
<h3>Tech</h3>
|
||||
<p>The toadstool and magic powder used to be the same type of item. LADXR turns this into two items that you can have a the same time. 4 extra item slots in your inventory were added to support this extra item, and have the ability to own the boomerang.</p>
|
||||
<p>The glitch where the slime key is effectively a 6th golden leaf is fixed, and golden leaves can be collected fine next to the slime key.</p>
|
||||
To access the color dungeon, you need the power bracelet, and you need to push the gravestones in the right order: "down, left, up, right, up", going from the lower right gravestone, to the one left of it, above it, and then to the right.
|
||||
|
||||
### Bowwow
|
||||
|
||||
Bowwow is in a chest, somewhere. After you find him, he will always be in the swamp with you, but not anywhere else.
|
||||
|
||||
### Added things
|
||||
|
||||
In your save and quit menu, there is a 3rd option to return to your home. This has two main uses: it speeds up the game, and prevents softlocks (common in entrance rando).
|
||||
|
||||
If you have weapons that require ammunition (bombs, powder, arrows), a ghost will show up inside Marin's house. He will refill you up to 10 ammunition, so you do not run out.
|
||||
|
||||
The flying rooster is (optionally) available as an item.
|
||||
|
||||
If the rooster is disabled, you can access the Bird Key cave item with the L2 Power Bracelet.
|
||||
|
||||
Boomerang cave is now a random item gift by default (available post-bombs), and boomerang is in the item pool.
|
||||
|
||||
Your inventory has been increased by four, to accommodate these items now coexisting with eachother.
|
||||
|
||||
### Removed things
|
||||
|
||||
The ghost mini-quest after D4 never shows up, his seashell reward is always available.
|
||||
|
||||
The walrus is moved a bit, so that you can access the desert without taking Marin on a date.
|
||||
|
||||
### Logic
|
||||
|
||||
You can only steal after you find the sword.
|
||||
|
||||
Do not forget that there are two items in the rafting ride. You can access this with just Hookshot or Flippers.
|
||||
|
||||
Killing enemies with bombs is in logic.
|
||||
|
||||
D7 confuses some people, but by dropping down pits on the 2nd floor you can access almost all of this dungeon, even without feather and power bracelet.
|
||||
|
||||
### Tech
|
||||
|
||||
The toadstool and magic powder used to be the same type of item. LADXR turns this into two items that you can have a the same time. 4 extra item slots in your inventory were added to support this extra item, and have the ability to own the boomerang.
|
||||
|
||||
The glitch where the slime key is effectively a 6th golden leaf is fixed, and golden leaves can be collected fine next to the slime key.
|
||||
|
||||
@@ -255,8 +255,10 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
|
||||
else:
|
||||
dead_ends.append(portal)
|
||||
dead_end_direction_tracker[portal.direction] += 1
|
||||
if portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop:
|
||||
if (portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop
|
||||
and not decoupled):
|
||||
# direction isn't meaningful here since zig skip cannot be in direction pairs mode
|
||||
# don't add it in decoupled
|
||||
two_plus.append(portal)
|
||||
|
||||
# now we generate the shops and add them to the dead ends list
|
||||
|
||||
Reference in New Issue
Block a user