Skip to content

Commit 63bac45

Browse files
Zach Parkswu4
Zach Parks
authored andcommitted
Core: Remove Universally Unique ID Requirements (Per-Game Data Packages) (ArchipelagoMW#1933)
1 parent 3a130a0 commit 63bac45

Some content is hidden

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

77 files changed

+319
-392
lines changed

AdventureClient.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def on_package(self, cmd: str, args: dict):
112112
if ': !' not in msg:
113113
self._set_message(msg, SYSTEM_MESSAGE_ID)
114114
elif cmd == "ReceivedItems":
115-
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
115+
msg = f"Received {', '.join([self.item_names.lookup_in_slot(item.item) for item in args['items']])}"
116116
self._set_message(msg, SYSTEM_MESSAGE_ID)
117117
elif cmd == "Retrieved":
118118
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:

CommonClient.py

+79-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import collections
34
import copy
45
import logging
56
import asyncio
@@ -8,6 +9,7 @@
89
import typing
910
import time
1011
import functools
12+
import warnings
1113

1214
import ModuleUpdate
1315
ModuleUpdate.update()
@@ -173,10 +175,74 @@ class CommonContext:
173175
items_handling: typing.Optional[int] = None
174176
want_slot_data: bool = True # should slot_data be retrieved via Connect
175177

176-
# data package
177-
# Contents in flux until connection to server is made, to download correct data for this multiworld.
178-
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
179-
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
178+
class NameLookupDict:
179+
"""A specialized dict, with helper methods, for id -> name item/location data package lookups by game."""
180+
def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]):
181+
self.ctx: CommonContext = ctx
182+
self.lookup_type: typing.Literal["item", "location"] = lookup_type
183+
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
184+
self._archipelago_lookup: typing.Dict[int, str] = {}
185+
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
186+
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
187+
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
188+
self.warned: bool = False
189+
190+
# noinspection PyTypeChecker
191+
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
192+
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
193+
if isinstance(key, int):
194+
if not self.warned:
195+
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
196+
self.warned = True
197+
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
198+
f"backwards compatibility for now. If multiple games share the same id for a "
199+
f"{self.lookup_type}, name could be incorrect. Please use "
200+
f"`{self.lookup_type}_names.lookup_in_game()` or "
201+
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
202+
return self._flat_store[key] # type: ignore
203+
204+
return self._game_store[key]
205+
206+
def __len__(self) -> int:
207+
return len(self._game_store)
208+
209+
def __iter__(self) -> typing.Iterator[str]:
210+
return iter(self._game_store)
211+
212+
def __repr__(self) -> str:
213+
return self._game_store.__repr__()
214+
215+
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
216+
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
217+
omitted.
218+
"""
219+
if game_name is None:
220+
game_name = self.ctx.game
221+
assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
222+
223+
return self._game_store[game_name][code]
224+
225+
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
226+
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
227+
omitted.
228+
"""
229+
if slot is None:
230+
slot = self.ctx.slot
231+
assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
232+
233+
return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
234+
235+
def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
236+
"""Overrides existing lookup tables for a particular game."""
237+
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
238+
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
239+
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
240+
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
241+
if game == "Archipelago":
242+
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
243+
# it updates in all chain maps automatically.
244+
self._archipelago_lookup.clear()
245+
self._archipelago_lookup.update(id_to_name_lookup_table)
180246

181247
# defaults
182248
starting_reconnect_delay: int = 5
@@ -231,7 +297,7 @@ class CommonContext:
231297
# message box reporting a loss of connection
232298
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
233299

234-
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
300+
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
235301
# server state
236302
self.server_address = server_address
237303
self.username = None
@@ -271,6 +337,9 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option
271337
self.exit_event = asyncio.Event()
272338
self.watcher_event = asyncio.Event()
273339

340+
self.item_names = self.NameLookupDict(self, "item")
341+
self.location_names = self.NameLookupDict(self, "location")
342+
274343
self.jsontotextparser = JSONtoTextParser(self)
275344
self.rawjsontotextparser = RawJSONtoTextParser(self)
276345
self.update_data_package(network_data_package)
@@ -486,19 +555,17 @@ async def prepare_data_package(self, relevant_games: typing.Set[str],
486555
or remote_checksum != cache_checksum:
487556
needed_updates.add(game)
488557
else:
489-
self.update_game(cached_game)
558+
self.update_game(cached_game, game)
490559
if needed_updates:
491560
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
492561

493-
def update_game(self, game_package: dict):
494-
for item_name, item_id in game_package["item_name_to_id"].items():
495-
self.item_names[item_id] = item_name
496-
for location_name, location_id in game_package["location_name_to_id"].items():
497-
self.location_names[location_id] = location_name
562+
def update_game(self, game_package: dict, game: str):
563+
self.item_names.update_game(game, game_package["item_name_to_id"])
564+
self.location_names.update_game(game, game_package["location_name_to_id"])
498565

499566
def update_data_package(self, data_package: dict):
500567
for game, game_data in data_package["games"].items():
501-
self.update_game(game_data)
568+
self.update_game(game_data, game)
502569

503570
def consume_network_data_package(self, data_package: dict):
504571
self.update_data_package(data_package)

MultiServer.py

+28-22
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,11 @@ class Context:
168168
slot_info: typing.Dict[int, NetworkSlot]
169169
generator_version = Version(0, 0, 0)
170170
checksums: typing.Dict[str, str]
171-
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
171+
item_names: typing.Dict[str, typing.Dict[int, str]] = (
172+
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
172173
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
173-
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
174+
location_names: typing.Dict[str, typing.Dict[int, str]] = (
175+
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
174176
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
175177
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
176178
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
@@ -271,14 +273,21 @@ def _init_game_data(self):
271273
if "checksum" in game_package:
272274
self.checksums[game_name] = game_package["checksum"]
273275
for item_name, item_id in game_package["item_name_to_id"].items():
274-
self.item_names[item_id] = item_name
276+
self.item_names[game_name][item_id] = item_name
275277
for location_name, location_id in game_package["location_name_to_id"].items():
276-
self.location_names[location_id] = location_name
278+
self.location_names[game_name][location_id] = location_name
277279
self.all_item_and_group_names[game_name] = \
278280
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
279281
self.all_location_and_group_names[game_name] = \
280282
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
281283

284+
archipelago_item_names = self.item_names["Archipelago"]
285+
archipelago_location_names = self.location_names["Archipelago"]
286+
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
287+
# Add Archipelago items and locations to each data package.
288+
self.item_names[game].update(archipelago_item_names)
289+
self.location_names[game].update(archipelago_location_names)
290+
282291
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
283292
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
284293

@@ -783,10 +792,7 @@ async def on_client_connected(ctx: Context, client: Client):
783792
for slot, connected_clients in clients.items():
784793
if connected_clients:
785794
name = ctx.player_names[team, slot]
786-
players.append(
787-
NetworkPlayer(team, slot,
788-
ctx.name_aliases.get((team, slot), name), name)
789-
)
795+
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
790796
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
791797
games.add("Archipelago")
792798
await ctx.send_msgs(client, [{
@@ -801,8 +807,6 @@ async def on_client_connected(ctx: Context, client: Client):
801807
'permissions': get_permissions(ctx),
802808
'hint_cost': ctx.hint_cost,
803809
'location_check_points': ctx.location_check_points,
804-
'datapackage_versions': {game: game_data["version"] for game, game_data
805-
in ctx.gamespackage.items() if game in games},
806810
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
807811
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
808812
'seed_name': ctx.seed_name,
@@ -1006,8 +1010,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
10061010
send_items_to(ctx, team, target_player, new_item)
10071011

10081012
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
1009-
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
1010-
ctx.player_names[(team, target_player)], ctx.location_names[location]))
1013+
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
1014+
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
10111015
info_text = json_format_send_event(new_item, target_player)
10121016
ctx.broadcast_team(team, [info_text])
10131017

@@ -1061,8 +1065,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
10611065

10621066
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
10631067
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
1064-
f"{ctx.item_names[hint.item]} is " \
1065-
f"at {ctx.location_names[hint.location]} " \
1068+
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
1069+
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
10661070
f"in {ctx.player_names[team, hint.finding_player]}'s World"
10671071

10681072
if hint.entrance:
@@ -1364,7 +1368,7 @@ def _cmd_remaining(self) -> bool:
13641368
if self.ctx.remaining_mode == "enabled":
13651369
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
13661370
if remaining_item_ids:
1367-
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
1371+
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
13681372
for item_id in remaining_item_ids))
13691373
else:
13701374
self.output("No remaining items found.")
@@ -1377,7 +1381,7 @@ def _cmd_remaining(self) -> bool:
13771381
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
13781382
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
13791383
if remaining_item_ids:
1380-
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
1384+
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
13811385
for item_id in remaining_item_ids))
13821386
else:
13831387
self.output("No remaining items found.")
@@ -1395,7 +1399,8 @@ def _cmd_missing(self, filter_text="") -> bool:
13951399
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
13961400

13971401
if locations:
1398-
names = [self.ctx.location_names[location] for location in locations]
1402+
game = self.ctx.slot_info[self.client.slot].game
1403+
names = [self.ctx.location_names[game][location] for location in locations]
13991404
if filter_text:
14001405
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
14011406
if filter_text in location_groups: # location group name
@@ -1420,7 +1425,8 @@ def _cmd_checked(self, filter_text="") -> bool:
14201425
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
14211426

14221427
if locations:
1423-
names = [self.ctx.location_names[location] for location in locations]
1428+
game = self.ctx.slot_info[self.client.slot].game
1429+
names = [self.ctx.location_names[game][location] for location in locations]
14241430
if filter_text:
14251431
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
14261432
if filter_text in location_groups: # location group name
@@ -1501,10 +1507,10 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool:
15011507
elif input_text.isnumeric():
15021508
game = self.ctx.games[self.client.slot]
15031509
hint_id = int(input_text)
1504-
hint_name = self.ctx.item_names[hint_id] \
1505-
if not for_location and hint_id in self.ctx.item_names \
1506-
else self.ctx.location_names[hint_id] \
1507-
if for_location and hint_id in self.ctx.location_names \
1510+
hint_name = self.ctx.item_names[game][hint_id] \
1511+
if not for_location and hint_id in self.ctx.item_names[game] \
1512+
else self.ctx.location_names[game][hint_id] \
1513+
if for_location and hint_id in self.ctx.location_names[game] \
15081514
else None
15091515
if hint_name in self.ctx.non_hintable_names[game]:
15101516
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")

NetUtils.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -247,16 +247,16 @@ def _handle_item_name(self, node: JSONMessagePart):
247247

248248
def _handle_item_id(self, node: JSONMessagePart):
249249
item_id = int(node["text"])
250-
node["text"] = self.ctx.item_names[item_id]
250+
node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"])
251251
return self._handle_item_name(node)
252252

253253
def _handle_location_name(self, node: JSONMessagePart):
254254
node["color"] = 'green'
255255
return self._handle_color(node)
256256

257257
def _handle_location_id(self, node: JSONMessagePart):
258-
item_id = int(node["text"])
259-
node["text"] = self.ctx.location_names[item_id]
258+
location_id = int(node["text"])
259+
node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"])
260260
return self._handle_location_name(node)
261261

262262
def _handle_entrance_name(self, node: JSONMessagePart):

UndertaleClient.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
247247
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
248248
toDraw = ""
249249
for i in range(20):
250-
if i < len(str(ctx.item_names[l.item])):
251-
toDraw += str(ctx.item_names[l.item])[i]
250+
if i < len(str(ctx.item_names.lookup_in_slot(l.item))):
251+
toDraw += str(ctx.item_names.lookup_in_slot(l.item))[i]
252252
else:
253253
break
254254
f.write(toDraw)

Utils.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def as_simple_string(self) -> str:
4646
return ".".join(str(item) for item in self)
4747

4848

49-
__version__ = "0.4.6"
49+
__version__ = "0.5.0"
5050
version_tuple = tuplize_version(__version__)
5151

5252
is_linux = sys.platform.startswith("linux")
@@ -458,6 +458,9 @@ class KeyedDefaultDict(collections.defaultdict):
458458
"""defaultdict variant that uses the missing key as argument to default_factory"""
459459
default_factory: typing.Callable[[typing.Any], typing.Any]
460460

461+
def __init__(self, default_factory: typing.Callable[[Any], Any] = None, **kwargs):
462+
super().__init__(default_factory, **kwargs)
463+
461464
def __missing__(self, key):
462465
self[key] = value = self.default_factory(key)
463466
return value

0 commit comments

Comments
 (0)