Skip to content

Commit 998bbb1

Browse files
committed
Merge remote-tracking branch 'upstream/main' into local-filler
2 parents ca31b97 + 5a42c70 commit 998bbb1

File tree

126 files changed

+9457
-868
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

126 files changed

+9457
-868
lines changed

.github/pyright-config.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
{
22
"include": [
3-
"type_check.py",
3+
"../BizHawkClient.py",
4+
"../Patch.py",
5+
"../test/general/test_groups.py",
6+
"../test/general/test_helpers.py",
7+
"../test/general/test_memory.py",
8+
"../test/general/test_names.py",
9+
"../test/multiworld/__init__.py",
10+
"../test/multiworld/test_multiworlds.py",
11+
"../test/netutils/__init__.py",
12+
"../test/programs/__init__.py",
13+
"../test/programs/test_multi_server.py",
14+
"../test/utils/__init__.py",
15+
"../test/webhost/test_descriptions.py",
416
"../worlds/AutoSNIClient.py",
5-
"../Patch.py"
17+
"type_check.py"
618
],
719

820
"exclude": [

.github/workflows/strict-type-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626

2727
- name: "Install dependencies"
2828
run: |
29-
python -m pip install --upgrade pip pyright==1.1.358
29+
python -m pip install --upgrade pip pyright==1.1.392.post0
3030
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
3131
3232
- name: "pyright: strict check on specific files"

CommonClient.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
if typing.TYPE_CHECKING:
3333
import kvui
34+
import argparse
3435

3536
logger = logging.getLogger("Client")
3637

@@ -459,6 +460,13 @@ async def send_connect(self, **kwargs: typing.Any) -> None:
459460
await self.send_msgs([payload])
460461
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
461462

463+
async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
464+
"""Send new location checks to the server. Returns the set of actually new locations that were sent."""
465+
locations = set(locations) & self.missing_locations
466+
if locations:
467+
await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
468+
return locations
469+
462470
async def console_input(self) -> str:
463471
if self.ui:
464472
self.ui.focus_textinput()
@@ -1041,6 +1049,32 @@ def get_base_parser(description: typing.Optional[str] = None):
10411049
return parser
10421050

10431051

1052+
def handle_url_arg(args: "argparse.Namespace",
1053+
parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
1054+
"""
1055+
Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
1056+
If alternate data is required the urlparse response is saved back to args.url if valid
1057+
"""
1058+
if not args.url:
1059+
return args
1060+
1061+
url = urllib.parse.urlparse(args.url)
1062+
if url.scheme != "archipelago":
1063+
if not parser:
1064+
parser = get_base_parser()
1065+
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
1066+
return args
1067+
1068+
args.url = url
1069+
args.connect = url.netloc
1070+
if url.username:
1071+
args.name = urllib.parse.unquote(url.username)
1072+
if url.password:
1073+
args.password = urllib.parse.unquote(url.password)
1074+
1075+
return args
1076+
1077+
10441078
def run_as_textclient(*args):
10451079
class TextContext(CommonContext):
10461080
# Text Mode to use !hint and such with games that have no text entry
@@ -1082,17 +1116,7 @@ async def main(args):
10821116
parser.add_argument("url", nargs="?", help="Archipelago connection url")
10831117
args = parser.parse_args(args)
10841118

1085-
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
1086-
if args.url:
1087-
url = urllib.parse.urlparse(args.url)
1088-
if url.scheme == "archipelago":
1089-
args.connect = url.netloc
1090-
if url.username:
1091-
args.name = urllib.parse.unquote(url.username)
1092-
if url.password:
1093-
args.password = urllib.parse.unquote(url.password)
1094-
else:
1095-
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
1119+
args = handle_url_arg(args, parser=parser)
10961120

10971121
# use colorama to display colored text highlighting on windows
10981122
colorama.init()

Fill.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,13 @@ def mark_for_locking(location: Location):
528528
# "priority fill"
529529
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
530530
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
531-
name="Priority", one_item_per_player=False)
531+
name="Priority", one_item_per_player=True, allow_partial=True)
532+
533+
if prioritylocations:
534+
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
535+
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
536+
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
537+
name="Priority Retry", one_item_per_player=False)
532538
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
533539
defaultlocations = prioritylocations + defaultlocations
534540

@@ -601,6 +607,26 @@ def mark_for_locking(location: Location):
601607
print_data = {"items": items_counter, "locations": locations_counter}
602608
logging.info(f"Per-Player counts: {print_data})")
603609

610+
more_locations = locations_counter - items_counter
611+
more_items = items_counter - locations_counter
612+
for player in multiworld.player_ids:
613+
if more_locations[player]:
614+
logging.error(
615+
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
616+
elif more_items[player]:
617+
logging.warning(
618+
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
619+
if unfilled:
620+
raise FillError(
621+
f"Unable to fill all locations.\n" +
622+
f"Unfilled locations({len(unfilled)}): {unfilled}"
623+
)
624+
else:
625+
logging.warning(
626+
f"Unable to place all items.\n" +
627+
f"Unplaced items({len(unplaced)}): {unplaced}"
628+
)
629+
604630

605631
def flood_items(multiworld: MultiWorld) -> None:
606632
# get items to distribute

Generate.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ def mystery_argparse():
4242
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
4343
parser.add_argument('--race', action='store_true', default=defaults.race)
4444
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
45-
parser.add_argument('--log_level', default='info', help='Sets log level')
45+
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
46+
parser.add_argument('--log_time', help="Add timestamps to STDOUT",
47+
default=defaults.logtime, action='store_true')
4648
parser.add_argument("--csv_output", action="store_true",
4749
help="Output rolled player options to csv (made for async multiworld).")
4850
parser.add_argument("--plando", default=defaults.plando_options,
@@ -75,7 +77,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
7577

7678
seed = get_seed(args.seed)
7779

78-
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
80+
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
7981
random.seed(seed)
8082
seed_name = get_seed_name(random)
8183

@@ -438,7 +440,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
438440
if "linked_options" in weights:
439441
weights = roll_linked_options(weights)
440442

441-
valid_keys = set()
443+
valid_keys = {"triggers"}
442444
if "triggers" in weights:
443445
weights = roll_triggers(weights, weights["triggers"], valid_keys)
444446

@@ -497,16 +499,23 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
497499
for option_key, option in world_type.options_dataclass.type_hints.items():
498500
handle_option(ret, game_weights, option_key, option, plando_options)
499501
valid_keys.add(option_key)
500-
for option_key in game_weights:
501-
if option_key in {"triggers", *valid_keys}:
502-
continue
503-
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
504-
f"for player {ret.name}.")
502+
503+
# TODO remove plando_items after moving it to the options system
504+
valid_keys.add("plando_items")
505505
if PlandoOptions.items in plando_options:
506506
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
507507
if ret.game == "A Link to the Past":
508+
# TODO there are still more LTTP options not on the options system
509+
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
508510
roll_alttp_settings(ret, game_weights)
509511

512+
# log a warning for options within a game section that aren't determined as valid
513+
for option_key in game_weights:
514+
if option_key in valid_keys:
515+
continue
516+
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
517+
f"for player {ret.name}.")
518+
510519
return ret
511520

512521

LinksAwakeningClient.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,10 @@ async def server_auth(self, password_requested: bool = False):
560560

561561
while self.client.auth == None:
562562
await asyncio.sleep(0.1)
563+
564+
# Just return if we're closing
565+
if self.exit_event.is_set():
566+
return
563567
self.auth = self.client.auth
564568
await self.send_connect()
565569

Main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
148148
else:
149149
multiworld.worlds[1].options.non_local_items.value = set()
150150
multiworld.worlds[1].options.local_items.value = set()
151-
151+
152+
AutoWorld.call_all(multiworld, "connect_entrances")
152153
AutoWorld.call_all(multiworld, "generate_basic")
153154

154155
# remove starting inventory from pool items.

MultiServer.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -743,16 +743,17 @@ def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = Fal
743743
concerns[player].append(data)
744744
if not hint.local and data not in concerns[hint.finding_player]:
745745
concerns[hint.finding_player].append(data)
746-
# remember hints in all cases
747746

748-
# since hints are bidirectional, finding player and receiving player,
749-
# we can check once if hint already exists
750-
if hint not in self.hints[team, hint.finding_player]:
751-
self.hints[team, hint.finding_player].add(hint)
752-
new_hint_events.add(hint.finding_player)
753-
for player in self.slot_set(hint.receiving_player):
754-
self.hints[team, player].add(hint)
755-
new_hint_events.add(player)
747+
# only remember hints that were not already found at the time of creation
748+
if not hint.found:
749+
# since hints are bidirectional, finding player and receiving player,
750+
# we can check once if hint already exists
751+
if hint not in self.hints[team, hint.finding_player]:
752+
self.hints[team, hint.finding_player].add(hint)
753+
new_hint_events.add(hint.finding_player)
754+
for player in self.slot_set(hint.receiving_player):
755+
self.hints[team, player].add(hint)
756+
new_hint_events.add(player)
756757

757758
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
758759
for slot in new_hint_events:
@@ -1887,7 +1888,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
18871888
for location in args["locations"]:
18881889
if type(location) is not int:
18891890
await ctx.send_msgs(client,
1890-
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
1891+
[{'cmd': 'InvalidPacket', "type": "arguments",
1892+
"text": 'Locations has to be a list of integers',
18911893
"original_cmd": cmd}])
18921894
return
18931895

@@ -1990,6 +1992,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
19901992
args["cmd"] = "SetReply"
19911993
value = ctx.stored_data.get(args["key"], args.get("default", 0))
19921994
args["original_value"] = copy.copy(value)
1995+
args["slot"] = client.slot
19931996
for operation in args["operations"]:
19941997
func = modify_functions[operation["operation"]]
19951998
value = func(value, operation["value"])

OoTAdjuster.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import tkinter as tk
22
import argparse
33
import logging
4-
import random
54
import os
65
import zipfile
76
from itertools import chain
@@ -197,7 +196,6 @@ def set_icon(window):
197196
def adjust(args):
198197
# Create a fake multiworld and OOTWorld to use as a base
199198
multiworld = MultiWorld(1)
200-
multiworld.per_slot_randoms = {1: random}
201199
ootworld = OOTWorld(multiworld, 1)
202200
# Set options in the fake OOTWorld
203201
for name, option in chain(cosmetic_options.items(), sfx_options.items()):

Options.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -689,9 +689,9 @@ def from_text(cls, text: str) -> Range:
689689
@classmethod
690690
def weighted_range(cls, text) -> Range:
691691
if text == "random-low":
692-
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
692+
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
693693
elif text == "random-high":
694-
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
694+
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
695695
elif text == "random-middle":
696696
return cls(cls.triangular(cls.range_start, cls.range_end))
697697
elif text.startswith("random-range-"):
@@ -717,11 +717,11 @@ def custom_range(cls, text) -> Range:
717717
f"{random_range[0]}-{random_range[1]} is outside allowed range "
718718
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
719719
if text.startswith("random-range-low"):
720-
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
720+
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
721721
elif text.startswith("random-range-middle"):
722722
return cls(cls.triangular(random_range[0], random_range[1]))
723723
elif text.startswith("random-range-high"):
724-
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
724+
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
725725
else:
726726
return cls(random.randint(random_range[0], random_range[1]))
727727

@@ -739,8 +739,16 @@ def __str__(self) -> str:
739739
return str(self.value)
740740

741741
@staticmethod
742-
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
743-
return int(round(random.triangular(lower, end, tri), 0))
742+
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
743+
"""
744+
Integer triangular distribution for `lower` inclusive to `end` inclusive.
745+
746+
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
747+
"""
748+
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
749+
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
750+
# when a != b, so ensure the result is never more than `end`.
751+
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
744752

745753

746754
class NamedRange(Range):

Utils.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -521,8 +521,8 @@ def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogReco
521521
def filter(self, record: logging.LogRecord) -> bool:
522522
return self.condition(record)
523523

524-
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
525-
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.msg))
524+
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
525+
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
526526
root_logger.addHandler(file_handler)
527527
if sys.stdout:
528528
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
@@ -940,7 +940,7 @@ def freeze_support() -> None:
940940

941941
def visualize_regions(root_region: Region, file_name: str, *,
942942
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
943-
linetype_ortho: bool = True) -> None:
943+
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
944944
"""Visualize the layout of a world as a PlantUML diagram.
945945
946946
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -956,16 +956,22 @@ def visualize_regions(root_region: Region, file_name: str, *,
956956
Items without ID will be shown in italics.
957957
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
958958
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
959+
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
959960
960961
Example usage in World code:
961962
from Utils import visualize_regions
962-
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
963+
state = self.multiworld.get_all_state(False)
964+
state.update_reachable_regions(self.player)
965+
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
966+
regions_to_highlight=state.reachable_regions[self.player])
963967
964968
Example usage in Main code:
965969
from Utils import visualize_regions
966970
for player in multiworld.player_ids:
967971
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
968972
"""
973+
if regions_to_highlight is None:
974+
regions_to_highlight = set()
969975
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
970976
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
971977
from collections import deque
@@ -1018,7 +1024,7 @@ def visualize_locations(region: Region) -> None:
10181024
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
10191025

10201026
def visualize_region(region: Region) -> None:
1021-
uml.append(f"class \"{fmt(region)}\"")
1027+
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}")
10221028
if show_locations:
10231029
visualize_locations(region)
10241030
visualize_exits(region)

0 commit comments

Comments
 (0)