Skip to content

Commit 1b50e43

Browse files
authored
Merge branch 'ArchipelagoMW:main' into earthbound
2 parents 2afa2af + 5390561 commit 1b50e43

File tree

108 files changed

+2515
-681
lines changed

Some content is hidden

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

108 files changed

+2515
-681
lines changed

.run/Build APWorld.run.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<component name="ProjectRunConfigurationManager">
2+
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
3+
<module name="Archipelago" />
4+
<option name="ENV_FILES" value="" />
5+
<option name="INTERPRETER_OPTIONS" value="" />
6+
<option name="PARENT_ENVS" value="true" />
7+
<envs>
8+
<env name="PYTHONUNBUFFERED" value="1" />
9+
</envs>
10+
<option name="SDK_HOME" value="" />
11+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
12+
<option name="IS_MODULE_SDK" value="true" />
13+
<option name="ADD_CONTENT_ROOTS" value="true" />
14+
<option name="ADD_SOURCE_ROOTS" value="true" />
15+
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
16+
<option name="PARAMETERS" value="\&quot;Build APWorlds\&quot;" />
17+
<option name="SHOW_COMMAND_LINE" value="false" />
18+
<option name="EMULATE_TERMINAL" value="false" />
19+
<option name="MODULE_MODE" value="false" />
20+
<option name="REDIRECT_INPUT" value="false" />
21+
<option name="INPUT_FILE" value="" />
22+
<method v="2" />
23+
</configuration>
24+
</component>

BaseClasses.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ def set_item_links(self):
261261
"local_items": set(item_link.get("local_items", [])),
262262
"non_local_items": set(item_link.get("non_local_items", [])),
263263
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
264+
"skip_if_solo": item_link.get("skip_if_solo", False),
264265
}
265266

266267
for _name, item_link in item_links.items():
@@ -284,6 +285,8 @@ def set_item_links(self):
284285

285286
for group_name, item_link in item_links.items():
286287
game = item_link["game"]
288+
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
289+
continue
287290
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
288291

289292
group["item_pool"] = item_link["item_pool"]
@@ -1343,8 +1346,7 @@ def get_connecting_entrance(self, is_main_entrance: Callable[[Entrance], bool])
13431346
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
13441347
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
13451348

1346-
def add_locations(self, locations: Dict[str, Optional[int]],
1347-
location_type: Optional[type[Location]] = None) -> None:
1349+
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
13481350
"""
13491351
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
13501352
location names to address.
@@ -1432,16 +1434,16 @@ def create_er_target(self, name: str) -> Entrance:
14321434
entrance.connect(self)
14331435
return entrance
14341436

1435-
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
1436-
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
1437+
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
1438+
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
14371439
"""
14381440
Connects current region to regions in exit dictionary. Passed region names must exist first.
14391441
14401442
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
14411443
created entrances will be named "self.name -> connecting_region"
14421444
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
14431445
"""
1444-
if not isinstance(exits, Dict):
1446+
if not isinstance(exits, Mapping):
14451447
exits = dict.fromkeys(exits)
14461448
return [
14471449
self.connect(
@@ -1855,6 +1857,9 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
18551857
Utils.__version__, self.multiworld.seed))
18561858
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
18571859
outfile.write('Players: %d\n' % self.multiworld.players)
1860+
if self.multiworld.players > 1:
1861+
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
1862+
outfile.write('Total Location Count: %d\n' % loc_count)
18581863
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
18591864
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
18601865

@@ -1863,6 +1868,9 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
18631868
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
18641869
outfile.write('Game: %s\n' % self.multiworld.game[player])
18651870

1871+
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
1872+
outfile.write('Location Count: %d\n' % loc_count)
1873+
18661874
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
18671875
write_option(f_option, option)
18681876

Fill.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
129129
for i, location in enumerate(placements))
130130
for (i, location, unsafe) in swap_attempts:
131131
placed_item = location.item
132+
if item_to_place == placed_item:
133+
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
134+
# itself.
135+
continue
132136
# Unplaceable items can sometimes be swapped infinitely. Limit the
133137
# number of times we will swap an individual item to prevent this
134138
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]

Generate.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,22 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
486486
if required_plando_options:
487487
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
488488
f"which is not enabled.")
489-
489+
games = requirements.get("game", {})
490+
for game, version in games.items():
491+
if game not in AutoWorldRegister.world_types:
492+
continue
493+
if not version:
494+
raise Exception(f"Invalid version for game {game}: {version}.")
495+
if isinstance(version, str):
496+
version = {"min": version}
497+
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
498+
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
499+
f"however world is of version "
500+
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
501+
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
502+
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
503+
f"however world is of version "
504+
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
490505
ret = argparse.Namespace()
491506
for option_key in Options.PerGameCommonOptions.type_hints:
492507
if option_key in weights and option_key not in Options.CommonOptions.type_hints:

Main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,17 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
5454
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
5555
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
5656

57-
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
58-
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
57+
world_classes = AutoWorld.AutoWorldRegister.world_types.values()
58+
59+
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
60+
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
61+
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
5962

6063
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
6164
if not cls.hidden and len(cls.item_names) > 0:
62-
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
65+
logger.info(f" {name:{longest_name}}: "
66+
f"v{cls.world_version.as_simple_string():{version_count}} | "
67+
f"Items: {len(cls.item_names):{item_count}} | "
6368
f"Locations: {len(cls.location_names):{location_count}}")
6469

6570
del item_count, location_count

MultiServer.py

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
import colorama
3434
import websockets
35-
from websockets.extensions.permessage_deflate import PerMessageDeflate
35+
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
3636
try:
3737
# ponyorm is a requirement for webhost, not default server, so may not be importable
3838
from pony.orm.dbapiprovider import OperationalError
@@ -50,6 +50,15 @@
5050
min_client_version = Version(0, 5, 0)
5151
colorama.just_fix_windows_console()
5252

53+
no_version = Version(0, 0, 0)
54+
assert isinstance(no_version, tuple) # assert immutable
55+
56+
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
57+
server_max_window_bits=11,
58+
client_max_window_bits=11,
59+
compress_settings={"memLevel": 4},
60+
)
61+
5362

5463
def remove_from_list(container, value):
5564
try:
@@ -125,8 +134,31 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
125134

126135

127136
class Client(Endpoint):
128-
version = Version(0, 0, 0)
129-
tags: typing.List[str]
137+
__slots__ = (
138+
"__weakref__",
139+
"version",
140+
"auth",
141+
"team",
142+
"slot",
143+
"send_index",
144+
"tags",
145+
"messageprocessor",
146+
"ctx",
147+
"remote_items",
148+
"remote_start_inventory",
149+
"no_items",
150+
"no_locations",
151+
"no_text",
152+
)
153+
154+
version: Version
155+
auth: bool
156+
team: int | None
157+
slot: int | None
158+
send_index: int
159+
tags: list[str]
160+
messageprocessor: ClientMessageProcessor
161+
ctx: weakref.ref[Context]
130162
remote_items: bool
131163
remote_start_inventory: bool
132164
no_items: bool
@@ -135,13 +167,19 @@ class Client(Endpoint):
135167

136168
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
137169
super().__init__(socket)
170+
self.version = no_version
138171
self.auth = False
139172
self.team = None
140173
self.slot = None
141174
self.send_index = 0
142175
self.tags = []
143176
self.messageprocessor = client_message_processor(ctx, self)
144177
self.ctx = weakref.ref(ctx)
178+
self.remote_items = False
179+
self.remote_start_inventory = False
180+
self.no_items = False
181+
self.no_locations = False
182+
self.no_text = False
145183

146184
@property
147185
def items_handling(self):
@@ -179,6 +217,7 @@ class Context:
179217
"release_mode": str,
180218
"remaining_mode": str,
181219
"collect_mode": str,
220+
"countdown_mode": str,
182221
"item_cheat": bool,
183222
"compatibility": int}
184223
# team -> slot id -> list of clients authenticated to slot.
@@ -208,8 +247,8 @@ class Context:
208247

209248
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
210249
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
211-
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
212-
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
250+
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
251+
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
213252
self.logger = logger
214253
super(Context, self).__init__()
215254
self.slot_info = {}
@@ -242,6 +281,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
242281
self.release_mode: str = release_mode
243282
self.remaining_mode: str = remaining_mode
244283
self.collect_mode: str = collect_mode
284+
self.countdown_mode: str = countdown_mode
245285
self.item_cheat = item_cheat
246286
self.exit_event = asyncio.Event()
247287
self.client_activity_timers: typing.Dict[
@@ -627,6 +667,7 @@ def get_save(self) -> dict:
627667
"server_password": self.server_password, "password": self.password,
628668
"release_mode": self.release_mode,
629669
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
670+
"countdown_mode": self.countdown_mode,
630671
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
631672

632673
}
@@ -661,6 +702,7 @@ def set_save(self, savedata: dict):
661702
self.release_mode = savedata["game_options"]["release_mode"]
662703
self.remaining_mode = savedata["game_options"]["remaining_mode"]
663704
self.collect_mode = savedata["game_options"]["collect_mode"]
705+
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
664706
self.item_cheat = savedata["game_options"]["item_cheat"]
665707
self.compatibility = savedata["game_options"]["compatibility"]
666708

@@ -1492,6 +1534,23 @@ def _cmd_collect(self) -> bool:
14921534
" You can ask the server admin for a /collect")
14931535
return False
14941536

1537+
def _cmd_countdown(self, seconds: str = "10") -> bool:
1538+
"""Start a countdown in seconds"""
1539+
if self.ctx.countdown_mode == "disabled" or \
1540+
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
1541+
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
1542+
return False
1543+
try:
1544+
timer = int(seconds, 10)
1545+
except ValueError:
1546+
timer = 10
1547+
else:
1548+
if timer > 60 * 60:
1549+
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
1550+
1551+
async_start(countdown(self.ctx, timer))
1552+
return True
1553+
14951554
def _cmd_remaining(self) -> bool:
14961555
"""List remaining items in your game, but not their location or recipient"""
14971556
if self.ctx.remaining_mode == "enabled":
@@ -2452,6 +2511,11 @@ def value_type(input_text: str):
24522511
elif value_type == str and option_name.endswith("password"):
24532512
def value_type(input_text: str):
24542513
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
2514+
elif option_name == "countdown_mode":
2515+
valid_values = {"enabled", "disabled", "auto"}
2516+
if option_value.lower() not in valid_values:
2517+
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
2518+
return False
24552519
elif value_type == str and option_name.endswith("mode"):
24562520
valid_values = {"goal", "enabled", "disabled"}
24572521
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
@@ -2539,6 +2603,13 @@ def parse_args() -> argparse.Namespace:
25392603
goal: !collect can be used after goal completion
25402604
auto-enabled: !collect is available and automatically triggered on goal completion
25412605
''')
2606+
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
2607+
choices=['enabled', 'disabled', "auto"], help='''\
2608+
Select !countdown Accessibility. (default: %(default)s)
2609+
enabled: !countdown is always available
2610+
disabled: !countdown is never available
2611+
auto: !countdown is available for rooms with less than 30 players
2612+
''')
25422613
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
25432614
choices=['enabled', 'disabled', "goal"], help='''\
25442615
Select !remaining Accessibility. (default: %(default)s)
@@ -2604,7 +2675,7 @@ async def main(args: argparse.Namespace):
26042675

26052676
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
26062677
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
2607-
args.remaining_mode,
2678+
args.countdown_mode, args.remaining_mode,
26082679
args.auto_shutdown, args.compatibility, args.log_network)
26092680
data_filename = args.multidata
26102681

@@ -2639,7 +2710,13 @@ async def main(args: argparse.Namespace):
26392710

26402711
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
26412712

2642-
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
2713+
ctx.server = websockets.serve(
2714+
functools.partial(server, ctx=ctx),
2715+
host=ctx.host,
2716+
port=ctx.port,
2717+
ssl=ssl_context,
2718+
extensions=[server_per_message_deflate_factory],
2719+
)
26432720
ip = args.host if args.host else Utils.get_public_ipv4()
26442721
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
26452722
'No password' if not ctx.password else 'Password: %s' % ctx.password))

NetUtils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ def _object_hook(o: typing.Any) -> typing.Any:
174174

175175

176176
class Endpoint:
177+
__slots__ = ("socket",)
178+
177179
socket: "ServerConnection"
178180

179181
def __init__(self, socket):

Options.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,15 +1380,15 @@ class NonLocalItems(ItemSet):
13801380

13811381

13821382
class StartInventory(ItemDict):
1383-
"""Start with these items."""
1383+
"""Start with the specified amount of these items. Example: "Bomb: 1" """
13841384
verify_item_name = True
13851385
display_name = "Start Inventory"
13861386
rich_text_doc = True
13871387
max = 10000
13881388

13891389

13901390
class StartInventoryPool(StartInventory):
1391-
"""Start with these items and don't place them in the world.
1391+
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
13921392
13931393
The game decides what the replacement items will be.
13941394
"""
@@ -1446,6 +1446,7 @@ class ItemLinks(OptionList):
14461446
Optional("local_items"): [And(str, len)],
14471447
Optional("non_local_items"): [And(str, len)],
14481448
Optional("link_replacement"): Or(None, bool),
1449+
Optional("skip_if_solo"): Or(None, bool),
14491450
}
14501451
])
14511452

@@ -1752,7 +1753,10 @@ def yaml_dump_scalar(scalar) -> str:
17521753

17531754
res = template.render(
17541755
option_groups=option_groups,
1755-
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
1756+
__version__=__version__,
1757+
game=game_name,
1758+
world_version=world.world_version.as_simple_string(),
1759+
yaml_dump=yaml_dump_scalar,
17561760
dictify_range=dictify_range,
17571761
cleandoc=cleandoc,
17581762
)

0 commit comments

Comments
 (0)