Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/main' into ladx/enable-upstrea…
Browse files Browse the repository at this point in the history
…m-settings
  • Loading branch information
threeandthreee committed Oct 5, 2024
2 parents 5ef69ae + 97f2c25 commit ac11401
Show file tree
Hide file tree
Showing 56 changed files with 736 additions and 386 deletions.
9 changes: 6 additions & 3 deletions BaseClasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset(
self.player_types[new_id] = NetUtils.SlotType.group
world_type = AutoWorld.AutoWorldRegister.world_types[game]
self.worlds[new_id] = world_type.create_group(self, new_id, players)
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id])
self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id])
self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id])
self.player_name[new_id] = name

new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
Expand Down Expand Up @@ -720,7 +722,7 @@ def _update_reachable_regions_explicit_indirect_conditions(self, player: int, qu
if new_region in reachable_regions:
blocked_connections.remove(connection)
elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
reachable_regions.add(new_region)
blocked_connections.remove(connection)
blocked_connections.update(new_region.exits)
Expand Down Expand Up @@ -946,6 +948,7 @@ def __init__(self, player: int, name: str = "", parent: Optional[Region] = None)
self.player = player

def can_reach(self, state: CollectionState) -> bool:
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
if self.parent_region.can_reach(state) and self.access_rule(state):
if not self.hide_path and not self in state.path:
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
Expand Down Expand Up @@ -1166,7 +1169,7 @@ def can_fill(self, state: CollectionState, item: Item, check_access: bool = True

def can_reach(self, state: CollectionState) -> bool:
# Region.can_reach is just a cache lookup, so placing it first for faster abort on average
assert self.parent_region, "Can't reach location without region"
assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
return self.parent_region.can_reach(state) and self.access_rule(state)

def place_locked_item(self, item: Item):
Expand Down
32 changes: 28 additions & 4 deletions CommonClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,21 @@ def get_ssl_context():


class ClientCommandProcessor(CommandProcessor):
"""
The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
and method("one", "two", "three") without.
In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
"""
def __init__(self, ctx: CommonContext):
self.ctx = ctx

def output(self, text: str):
"""Helper function to abstract logging to the CommonClient UI"""
logger.info(text)

def _cmd_exit(self) -> bool:
Expand Down Expand Up @@ -164,13 +175,14 @@ def _cmd_ready(self):
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")

def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
raw = self.ctx.on_user_say(raw)
if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")


class CommonContext:
# Should be adjusted as needed in subclasses
# The following attributes are used to Connect and should be adjusted as needed in subclasses
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
Expand Down Expand Up @@ -429,7 +441,10 @@ async def get_username(self):
self.auth = await self.console_input()

async def send_connect(self, **kwargs: typing.Any) -> None:
""" send `Connect` packet to log in to server """
"""
Send a `Connect` packet to log in to the server,
additional keyword args can override any value in the connection packet
"""
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
Expand All @@ -439,6 +454,7 @@ async def send_connect(self, **kwargs: typing.Any) -> None:
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])

async def console_input(self) -> str:
if self.ui:
Expand All @@ -459,13 +475,15 @@ def cancel_autoreconnect(self) -> bool:
return False

def slot_concerns_self(self, slot) -> bool:
"""Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
if slot == self.slot:
return True
if slot in self.slot_info:
return self.slot in self.slot_info[slot].group_members
return False

def is_echoed_chat(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out messages sent by self."""
return print_json_packet.get("type", "") == "Chat" \
and print_json_packet.get("team", None) == self.team \
and print_json_packet.get("slot", None) == self.slot
Expand Down Expand Up @@ -497,13 +515,14 @@ def on_user_say(self, text: str) -> typing.Optional[str]:
"""Gets called before sending a Say to the server from the user.
Returned text is sent, or sending is aborted if None is returned."""
return text

def on_ui_command(self, text: str) -> None:
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
The command processor is still called; this is just intended for command echoing."""
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])

def update_permissions(self, permissions: typing.Dict[str, int]):
"""Internal method to parse and save server permissions from RoomInfo"""
for permission_name, permission_flag in permissions.items():
try:
flag = Permission(permission_flag)
Expand Down Expand Up @@ -613,6 +632,7 @@ def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
logger.info(f"DeathLink: Received from {data['source']}")

async def send_death(self, death_text: str = ""):
"""Helper function to send a deathlink using death_text as the unique death cause string."""
if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time()
Expand All @@ -626,6 +646,7 @@ async def send_death(self, death_text: str = ""):
}])

async def update_death_link(self, death_link: bool):
"""Helper function to set Death Link connection tag on/off and update the connection if already connected."""
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
Expand All @@ -635,7 +656,7 @@ async def update_death_link(self, death_link: bool):
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])

def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
"""Displays an error messagebox"""
"""Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework"""
if not self.ui:
return None
title = title or "Error"
Expand Down Expand Up @@ -987,6 +1008,7 @@ async def console_loop(ctx: CommonContext):


def get_base_parser(description: typing.Optional[str] = None):
"""Base argument parser to be reused for components subclassing off of CommonClient"""
import argparse
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
Expand Down Expand Up @@ -1037,6 +1059,7 @@ async def main(args):
parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args(args)

# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
if args.url:
url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago":
Expand All @@ -1048,6 +1071,7 @@ async def main(args):
else:
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")

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

asyncio.run(main(args))
Expand Down
1 change: 1 addition & 0 deletions Main.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ def precollect_hint(location):
"seed_name": multiworld.seed_name,
"spheres": spheres,
"datapackage": data_package,
"race_mode": int(multiworld.is_race),
}
AutoWorld.call_all(multiworld, "modify_multidata", multidata)

Expand Down
5 changes: 4 additions & 1 deletion MultiServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import operator
import pickle
import random
import shlex
import threading
import time
import typing
Expand Down Expand Up @@ -427,6 +428,8 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A
use_embedded_server_options: bool):

self.read_data = {}
# there might be a better place to put this.
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},"
Expand Down Expand Up @@ -1150,7 +1153,7 @@ def __call__(self, raw: str) -> typing.Optional[bool]:
if not raw:
return
try:
command = raw.split()
command = shlex.split(raw, comments=False)
basecommand = command[0]
if basecommand[0] == self.marker:
method = self.commands.get(basecommand[1:].lower(), None)
Expand Down
4 changes: 1 addition & 3 deletions WargrooveClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,7 @@ class WargrooveManager(GameManager):

def build(self):
container = super().build()
panel = TabbedPanelItem(text="Wargroove")
panel.content = self.build_tracker()
self.tabs.add_widget(panel)
self.add_client_tab("Wargroove", self.build_tracker())
return container

def build_tracker(self) -> TrackerLayout:
Expand Down
8 changes: 6 additions & 2 deletions WebHostLib/templates/genericTracker.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,18 @@
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
{{ player_names_with_alias[(team, hint.finding_player)] }}
</a>
{% endif %}
</td>
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
{{ player_names_with_alias[(team, hint.receiving_player)] }}
</a>
{% endif %}
</td>
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
Expand Down
31 changes: 27 additions & 4 deletions WebHostLib/templates/userContent.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
{% extends 'tablepage.html' %}

{%- macro games(slots) -%}
{%- set gameList = [] -%}
{%- set maxGamesToShow = 10 -%}

{%- for slot in (slots|list|sort(attribute="player_id"))[:maxGamesToShow] -%}
{% set player = "#" + slot["player_id"]|string + " " + slot["player_name"] + " : " + slot["game"] -%}
{% set _ = gameList.append(player) -%}
{%- endfor -%}

{%- if slots|length > maxGamesToShow -%}
{% set _ = gameList.append("... and " + (slots|length - maxGamesToShow)|string + " more") -%}
{%- endif -%}

{{ gameList|join('\n') }}
{%- endmacro -%}

{% block head %}
{{ super() }}
<title>User Content</title>
Expand Down Expand Up @@ -33,10 +49,12 @@ <h2>Your Rooms</h2>
<tr>
<td><a href="{{ url_for("view_seed", seed=room.seed.id) }}">{{ room.seed.id|suuid }}</a></td>
<td><a href="{{ url_for("host_room", room=room.id) }}">{{ room.id|suuid }}</a></td>
<td>{{ room.seed.slots|length }}</td>
<td title="{{ games(room.seed.slots) }}">
{{ room.seed.slots|length }}
</td>
<td>{{ room.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td>{{ room.last_activity.strftime("%Y-%m-%d %H:%M") }}</td>
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</td>
<td><a href="{{ url_for("disown_room", room=room.id) }}">Delete next maintenance.</a></td>
</tr>
{% endfor %}
</tbody>
Expand All @@ -60,10 +78,15 @@ <h2>Your Seeds</h2>
{% for seed in seeds %}
<tr>
<td><a href="{{ url_for("view_seed", seed=seed.id) }}">{{ seed.id|suuid }}</a></td>
<td>{% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %}
<td title="{{ games(seed.slots) }}">
{% if seed.multidata %}
{{ seed.slots|length }}
{% else %}
1
{% endif %}
</td>
<td>{{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }}</td>
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</td>
<td><a href="{{ url_for("disown_seed", seed=seed.id) }}">Delete next maintenance.</a></td>
</tr>
{% endfor %}
</tbody>
Expand Down
1 change: 1 addition & 0 deletions docs/network protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ Some special keys exist with specific return data, all of them have the prefix `
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
| location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. |
| client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. |
| race_mode | int | 0 if race mode is disabled, and 1 if it's enabled. |

### Set
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.
Expand Down
16 changes: 13 additions & 3 deletions kvui.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ def get_text(self):
f"\nYou currently have {ctx.hint_points} points."
elif ctx.hint_cost == 0:
text += "\n!hint is free to use."
if ctx.stored_data and "_read_race_mode" in ctx.stored_data:
text += "\nRace mode is enabled." \
if ctx.stored_data["_read_race_mode"] else "\nRace mode is disabled."
else:
text += f"\nYou are not authenticated yet."

Expand Down Expand Up @@ -536,9 +539,8 @@ def connect_bar_validate(sender):
# show Archipelago tab if other logging is present
self.tabs.add_widget(panel)

hint_panel = TabbedPanelItem(text="Hints")
self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
self.tabs.add_widget(hint_panel)
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser))
self.log_panels["Hints"] = hint_panel.content

if len(self.logging_pairs) == 1:
self.tabs.default_tab_text = "Archipelago"
Expand Down Expand Up @@ -572,6 +574,14 @@ def connect_bar_validate(sender):

return self.container

def add_client_tab(self, title: str, content: Widget) -> Widget:
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
Returns the new tab widget, with the provided content being placed on the tab as content."""
new_tab = TabbedPanelItem(text=title)
new_tab.content = content
self.tabs.add_widget(new_tab)
return new_tab

def update_texts(self, dt):
if hasattr(self.tabs.content.children[0], "fix_heights"):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
Expand Down
8 changes: 4 additions & 4 deletions test/multiworld/test_multiworlds.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def test_two_player_single_game_fills(self) -> None:
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
2 changes: 1 addition & 1 deletion worlds/AutoWorld.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ def __getattr__(self, item: str) -> Any:

# overridable methods that get called by Main.py, sorted by execution order
# can also be implemented as a classmethod and called "stage_<original_name>",
# in that case the MultiWorld object is passed as an argument, and it gets called once for the entire multiworld.
# in that case the MultiWorld object is passed as the first argument, and it gets called once for the entire multiworld.
# An example of this can be found in alttp as stage_pre_fill

@classmethod
Expand Down
Loading

0 comments on commit ac11401

Please sign in to comment.