Network: implement read_only datastore keys: hints and slot_data (#1286)

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
This commit is contained in:
Fabian Dill 2022-12-03 23:29:33 +01:00 committed by GitHub
parent 64e2d55e92
commit 65995cd586
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 139 additions and 86 deletions

View File

@ -134,6 +134,7 @@ class CommonContext:
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect
# datapackage
# Contents in flux until connection to server is made, to download correct data for this multiworld.
@ -309,7 +310,7 @@ class CommonContext:
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags, 'items_handling': self.items_handling,
'uuid': Utils.get_unique_identifier(), 'game': self.game
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data,
}
if kwargs:
payload.update(kwargs)
@ -801,6 +802,7 @@ if __name__ == '__main__':
tags = {"AP", "IgnoreGame", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received
want_slot_data = False # Can't use game specific slot_data
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:

View File

@ -120,6 +120,7 @@ class Context:
groups: typing.Dict[int, typing.Set[int]]
save_version = 2
stored_data: typing.Dict[str, object]
read_data: typing.Dict[str, object]
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
@ -191,6 +192,7 @@ class Context:
self.random = random.Random()
self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
self.read_data = {}
# init empty to satisfy linter, I suppose
self.gamespackage = {}
@ -342,7 +344,7 @@ class Context:
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
self.read_data = {}
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils.version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
@ -359,6 +361,8 @@ class Context:
self.clients[team][player] = []
self.player_names[team, player] = name
self.player_name_lookup[name] = team, player
self.read_data[f"hints_{team}_{player}"] = lambda local_team=team, local_player=player: \
list(self.get_rechecked_hints(local_team, local_player))
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
self.connect_names = decoded_obj['connect_names']
@ -366,6 +370,8 @@ class Context:
self.remote_start_inventory = decoded_obj.get('remote_start_inventory', decoded_obj['remote_items'])
self.locations = decoded_obj['locations']
self.slot_data = decoded_obj['slot_data']
for slot, data in self.slot_data.items():
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()}
@ -544,12 +550,17 @@ class Context:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def recheck_hints(self):
for team, slot in self.hints:
self.hints[team, slot] = {
hint.re_check(self, team) for hint in
self.hints[team, slot]
}
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
for hint_team, hint_slot in self.hints:
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
self.hints[hint_team, hint_slot] = {
hint.re_check(self, hint_team) for hint in
self.hints[hint_team, hint_slot]
}
def get_rechecked_hints(self, team: int, slot: int):
self.recheck_hints(team, slot)
return self.hints[team, slot]
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
@ -584,6 +595,44 @@ class Context:
else:
return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
if not hints:
return
new_hint_events: typing.Set[int] = set()
concerns = collections.defaultdict(list)
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
data = (hint, hint.as_network_message())
for player in self.slot_set(hint.receiving_player):
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
if not hint.found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
self.hints[team, hint.finding_player].add(hint)
new_hint_events.add(hint.finding_player)
for player in self.slot_set(hint.receiving_player):
self.hints[team, player].add(hint)
new_hint_events.add(player)
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
clients = self.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
async_start(self.send_msgs(client, client_hints))
# "events"
def on_goal_achieved(self, client: Client):
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
f' has completed their goal.'
@ -596,38 +645,11 @@ class Context:
forfeit_player(self, client.team, client.slot)
self.save() # save goal completion flag
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in ctx.hints[team, hint.finding_player]]
if not hints:
return
concerns = collections.defaultdict(list)
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
data = (hint, hint.as_network_message())
for player in ctx.slot_set(hint.receiving_player):
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
if not hint.found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in ctx.hints[team, hint.finding_player]:
ctx.hints[team, hint.finding_player].add(hint)
for player in ctx.slot_set(hint.receiving_player):
ctx.hints[team, player].add(hint)
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(ctx, team, hint)))
for slot, hint_data in concerns.items():
clients = ctx.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
async_start(ctx.send_msgs(client, client_hints))
def on_new_hint(self, team: int, slot: int):
key: str = f"_read_hints_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
def update_aliases(ctx: Context, team: int):
@ -1133,13 +1155,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
output = f"!admin {command}"
if output.lower().startswith(
"!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
"!admin login"): # disallow others from seeing the supplied password, whether it is correct.
output = f"!admin login {('*' * random.randint(4, 16))}"
elif output.lower().startswith(
"!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
# disallow others from knowing what the new remote administration password is.
"!admin /option server_password"):
output = f"!admin /option server_password {('*' * random.randint(4, 16))}"
# Otherwise notify the others what is happening.
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team,
self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
self.client.slot) + ': ' + output)
if not self.ctx.server_password:
self.output("Sorry, Remote administration is disabled")
@ -1147,8 +1171,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not command:
if self.is_authenticated():
self.output(
"Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
self.output("Usage: !admin [Server command].\nUse !admin /help for help.\n"
"Use !admin logout to log out of the current session.")
else:
self.output("Usage: !admin login [password]")
return True
@ -1338,7 +1362,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
self.ctx.hints[self.client.team, self.client.slot] = hints
notify_hints(self.ctx, self.client.team, list(hints))
self.ctx.notify_hints(self.client.team, list(hints))
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
return True
@ -1391,7 +1415,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
if old_hints:
notify_hints(self.ctx, self.client.team, list(old_hints))
self.ctx.notify_hints(self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
@ -1432,7 +1456,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.notify_hints(self.client.team, hints)
self.ctx.save()
return True
@ -1554,15 +1578,15 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.version = args['version']
client.tags = args['tags']
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
reply = [{
connected_packet = {
"cmd": "Connected",
"team": client.team, "slot": client.slot,
"players": ctx.get_players_package(),
"missing_locations": get_missing_checks(ctx, team, slot),
"checked_locations": get_checked_checks(ctx, team, slot),
"slot_data": ctx.slot_data[client.slot],
"slot_info": ctx.slot_info
}]
}
reply = [connected_packet]
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
if (start_inventory or items) and not client.no_items:
@ -1571,7 +1595,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if not client.auth: # if this was a Re-Connect, don't print to console
client.auth = True
await on_client_joined(ctx, client)
if args.get("slot_data", True):
connected_packet["slot_data"] = ctx.slot_data[client.slot]
await ctx.send_msgs(client, reply)
elif cmd == "GetDataPackage":
@ -1659,7 +1684,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
locs.append(NetworkItem(target_item, location, target_player, flags))
notify_hints(ctx, client.team, hints, only_new=create_as_hint == 2)
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'StatusUpdate':
@ -1693,11 +1718,15 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
return
args["cmd"] = "Retrieved"
keys = args["keys"]
args["keys"] = {key: ctx.stored_data.get(key, None) for key in keys}
args["keys"] = {
key: ctx.read_data.get(key[6:], lambda: None)() if key.startswith("_read_") else
ctx.stored_data.get(key, None)
for key in keys
}
await ctx.send_msgs(client, [args])
elif cmd == "Set":
if "key" not in args or \
if "key" not in args or args["key"].startswith("_read_") or \
"operations" not in args or not type(args["operations"]) == list:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'Set', "original_cmd": cmd}])
@ -1962,7 +1991,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = collect_hints(self.ctx, team, slot, item)
if hints:
notify_hints(self.ctx, team, hints)
self.ctx.notify_hints(team, hints)
else:
self.output("No hints found.")
@ -1997,7 +2026,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
if hints:
notify_hints(self.ctx, team, hints)
self.ctx.notify_hints(team, hints)
else:
self.output("No hints found.")
return True

View File

@ -677,7 +677,7 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
_faf_tasks: "Set[asyncio.Task[None]]" = set()
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"

View File

@ -121,15 +121,15 @@ InvalidItemsHandling indicates a wrong value type or flag combination was sent.
### Connected
Sent to clients when the connection handshake is successfully completed.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| team | int | Your team number. See [NetworkPlayer](#NetworkPlayer) for more info on team number. |
| slot | int | Your slot number on your team. See [NetworkPlayer](#NetworkPlayer) for more info on the slot number. |
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | List denoting other players in the multiworld, whether connected or not. |
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
| slot_data | dict | Contains a json object for slot related data, differs per game. Empty if not required. |
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
| Name | Type | Notes |
|-------------------|------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| team | int | Your team number. See [NetworkPlayer](#NetworkPlayer) for more info on team number. |
| slot | int | Your slot number on your team. See [NetworkPlayer](#NetworkPlayer) for more info on the slot number. |
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | List denoting other players in the multiworld, whether connected or not. |
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
| slot_data | dict | Contains a json object for slot related data, differs per game. Empty if not required. Not present if slot_data in [Connect](#Connect) is false. |
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
### ReceivedItems
Sent to clients when they receive an item.
@ -242,11 +242,11 @@ Additional arguments added to the [Get](#Get) package that triggered this [Retri
### SetReply
Sent to clients in response to a [Set](#Set) package if want_reply was set to true, or if the client has registered to receive updates for a certain key using the [SetNotify](#SetNotify) package. SetReply packages are sent even if a [Set](#Set) package did not alter the value for the key.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| key | str | The key that was updated. |
| value | any | The new value for the key. |
| original_value | any | The value the key had before it was updated. |
| Name | Type | Notes |
|----------------|------|--------------------------------------------------------------------------------------------|
| key | str | The key that was updated. |
| value | any | The new value for the key. |
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
@ -269,15 +269,16 @@ These packets are sent purely from client to server. They are not accepted by cl
Sent by the client to initiate a connection to an Archipelago game session.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| password | str | If the game session requires a password, it should be passed here. |
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
| name | str | The player name for this client. |
| uuid | str | Unique identifier for player client. |
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
| Name | Type | Notes |
|----------------|-----------------------------------|----------------------------------------------------------------------------------------------|
| password | str | If the game session requires a password, it should be passed here. |
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
| name | str | The player name for this client. |
| uuid | str | Unique identifier for player client. |
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
| slot_data | bool | If true, the Connect answer will contain slot_data |
#### items_handling flags
| Value | Meaning |
@ -366,15 +367,22 @@ Used to request a single or multiple values from the server's data storage, see
| keys | list\[str\] | Keys to retrieve the values for. |
Additional arguments sent in this package will also be added to the [Retrieved](#Retrieved) package it triggers.
Some special keys exist with specific return data:
| Name | Type | Notes |
|----------------------------|-----------------------|----------------------------------------------|
| \_read_hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. |
| \_read_slot_data_{slot} | any | slot_data belonging to the requested slot. |
### 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.
#### Arguments
| Name | Type | Notes |
| ------ | ----- | ------ |
| key | str | The key to manipulate. |
| default | any | The default value to use in case the key has no value on the server. |
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
| Name | Type | Notes |
|------------|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|
| key | str | The key to manipulate. Can never start with "_read". |
| default | any | The default value to use in case the key has no value on the server. |
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
@ -591,6 +599,20 @@ class Permission(enum.IntEnum):
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
```
### Hint
An object representing a Hint.
```python
import typing
class Hint(typing.NamedTuple):
receiving_player: int
finding_player: int
location: int
item: int
found: bool
entrance: str = ""
item_flags: int = 0
```
### Data Package Contents
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago server most easily. Currently, this package is used to send ID to name mappings so that clients need not maintain their own mappings.