Compare commits

..

7 Commits

Author SHA1 Message Date
Duck
ecb22642af Tests: Handle optional args for get_all_state patch (#5297)
* Make `use_cache` optional

* Pass all kwargs
2025-08-09 00:24:19 +02:00
Exempt-Medic
17ccfdc266 DS3: Don't Create Disabled Locations (#5292) 2025-08-08 15:07:36 -04:00
Scipio Wright
4633f12972 Docs: Use / instead of . for the reference to lttp's options.py (#5300)
* Update options api.md

* o -> O
2025-08-07 20:14:09 +02:00
Silvris
1f6c99635e FF1: fix client breaking other NES games (#5293) 2025-08-05 22:25:11 +02:00
threeandthreee
4e92cac171 LADX: Update Docs (#5290)
* convert ladxr section to markdown, other adjustments
make links clickable
crow icon -> open tracker
adjust for removed sprite sheets
some adjustments in ladxr section for differences in the ap version:
we don't have a casual logic
we don't have stealing options

* fix link, and another correction
2025-08-04 11:46:05 -04:00
Scipio Wright
3b88630b0d TUNIC: Fix zig skip showing up in decoupled + fixed shop #5289 2025-08-04 14:21:58 +02:00
Ishigh1
e6d2d8f455 Core: Added a leading 0 to classification.as_flag #5291 2025-08-04 14:19:51 +02:00
13 changed files with 127 additions and 125 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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