WebHost: revamp /api/*tracker/ (#5388)

This commit is contained in:
Fabian Dill
2025-09-22 00:25:12 +02:00
committed by GitHub
parent 68187ba25f
commit fb9011da63
4 changed files with 255 additions and 264 deletions

View File

@@ -11,6 +11,47 @@ from WebHostLib.models import Room
from WebHostLib.tracker import TrackerData from WebHostLib.tracker import TrackerData
class PlayerAlias(TypedDict):
team: int
player: int
alias: str | None
class PlayerItemsReceived(TypedDict):
team: int
player: int
items: list[NetworkItem]
class PlayerChecksDone(TypedDict):
team: int
player: int
locations: list[int]
class TeamTotalChecks(TypedDict):
team: int
checks_done: int
class PlayerHints(TypedDict):
team: int
player: int
hints: list[Hint]
class PlayerTimer(TypedDict):
team: int
player: int
time: datetime | None
class PlayerStatus(TypedDict):
team: int
player: int
status: ClientStatus
@api_endpoints.route("/tracker/<suuid:tracker>") @api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60) @cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]: def tracker_data(tracker: UUID) -> dict[str, Any]:
@@ -29,122 +70,77 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
all_players: dict[int, list[int]] = tracker_data.get_all_players() all_players: dict[int, list[int]] = tracker_data.get_all_players()
class PlayerAlias(TypedDict): player_aliases: list[PlayerAlias] = []
player: int
name: str | None
player_aliases: list[dict[str, int | list[PlayerAlias]]] = []
"""Slot aliases of all players.""" """Slot aliases of all players."""
for team, players in all_players.items(): for team, players in all_players.items():
team_player_aliases: list[PlayerAlias] = []
team_aliases = {"team": team, "players": team_player_aliases}
player_aliases.append(team_aliases)
for player in players: for player in players:
team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)}) player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
class PlayerItemsReceived(TypedDict): player_items_received: list[PlayerItemsReceived] = []
player: int
items: list[NetworkItem]
player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = []
"""Items received by each player.""" """Items received by each player."""
for team, players in all_players.items(): for team, players in all_players.items():
player_received_items: list[PlayerItemsReceived] = []
team_items_received = {"team": team, "players": player_received_items}
player_items_received.append(team_items_received)
for player in players: for player in players:
player_received_items.append( player_items_received.append(
{"player": player, "items": tracker_data.get_player_received_items(team, player)}) {"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
class PlayerChecksDone(TypedDict): player_checks_done: list[PlayerChecksDone] = []
player: int
locations: list[int]
player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = []
"""ID of all locations checked by each player.""" """ID of all locations checked by each player."""
for team, players in all_players.items(): for team, players in all_players.items():
per_player_checks: list[PlayerChecksDone] = []
team_checks_done = {"team": team, "players": per_player_checks}
player_checks_done.append(team_checks_done)
for player in players: for player in players:
per_player_checks.append( player_checks_done.append(
{"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))}) {"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[dict[str, int]] = [ total_checks_done: list[TeamTotalChecks] = [
{"team": team, "checks_done": checks_done} {"team": team, "checks_done": checks_done}
for team, checks_done in tracker_data.get_team_locations_checked_count().items() for team, checks_done in tracker_data.get_team_locations_checked_count().items()
] ]
"""Total number of locations checked for the entire multiworld per team.""" """Total number of locations checked for the entire multiworld per team."""
class PlayerHints(TypedDict): hints: list[PlayerHints] = []
player: int
hints: list[Hint]
hints: list[dict[str, int | list[PlayerHints]]] = []
"""Hints that all players have used or received.""" """Hints that all players have used or received."""
for team, players in tracker_data.get_all_slots().items(): for team, players in tracker_data.get_all_slots().items():
per_player_hints: list[PlayerHints] = []
team_hints = {"team": team, "players": per_player_hints}
hints.append(team_hints)
for player in players: for player in players:
player_hints = sorted(tracker_data.get_player_hints(team, player)) player_hints = sorted(tracker_data.get_player_hints(team, player))
per_player_hints.append({"player": player, "hints": player_hints}) hints.append({"team": team, "player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(team, player) slot_info = tracker_data.get_slot_info(player)
# this assumes groups are always after players # this assumes groups are always after players
if slot_info.type != SlotType.group: if slot_info.type != SlotType.group:
continue continue
for member in slot_info.group_members: for member in slot_info.group_members:
team_hints[member]["hints"] += player_hints hints[member - 1]["hints"] += player_hints
class PlayerTimer(TypedDict): activity_timers: list[PlayerTimer] = []
player: int
time: datetime | None
activity_timers: list[dict[str, int | list[PlayerTimer]]] = []
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made.""" """Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items(): for team, players in all_players.items():
player_timers: list[PlayerTimer] = []
team_timers = {"team": team, "players": player_timers}
activity_timers.append(team_timers)
for player in players: for player in players:
player_timers.append({"player": player, "time": None}) activity_timers.append({"team": team, "player": player, "time": None})
client_activity_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get("client_activity_timers", ()) for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
for (team, player), timestamp in client_activity_timers: for entry in activity_timers:
# use index since we can rely on order if entry["team"] == team and entry["player"] == player:
# FIX: key is "players" (not "player_timers") entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
activity_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc) break
connection_timers: list[PlayerTimer] = []
connection_timers: list[dict[str, int | list[PlayerTimer]]] = []
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made.""" """Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items(): for team, players in all_players.items():
player_timers: list[PlayerTimer] = []
team_connection_timers = {"team": team, "players": player_timers}
connection_timers.append(team_connection_timers)
for player in players: for player in players:
player_timers.append({"player": player, "time": None}) connection_timers.append({"team": team, "player": player, "time": None})
client_connection_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get( for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
"client_connection_timers", ()) # find the matching entry
for (team, player), timestamp in client_connection_timers: for entry in connection_timers:
connection_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc) if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
class PlayerStatus(TypedDict): player_status: list[PlayerStatus] = []
player: int
status: ClientStatus
player_status: list[dict[str, int | list[PlayerStatus]]] = []
"""The current client status for each player.""" """The current client status for each player."""
for team, players in all_players.items(): for team, players in all_players.items():
player_statuses: list[PlayerStatus] = []
team_status = {"team": team, "players": player_statuses}
player_status.append(team_status)
for player in players: for player in players:
player_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)}) player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
return { return {
**get_static_tracker_data(room),
"aliases": player_aliases, "aliases": player_aliases,
"player_items_received": player_items_received, "player_items_received": player_items_received,
"player_checks_done": player_checks_done, "player_checks_done": player_checks_done,
@@ -153,80 +149,80 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
"activity_timers": activity_timers, "activity_timers": activity_timers,
"connection_timers": connection_timers, "connection_timers": connection_timers,
"player_status": player_status, "player_status": player_status,
"datapackage": tracker_data._multidata["datapackage"],
} }
@cache.memoize()
def get_static_tracker_data(room: Room) -> dict[str, Any]:
"""
Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated.
"""
class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
@api_endpoints.route("/static_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room) tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players() all_players: dict[int, list[int]] = tracker_data.get_all_players()
class PlayerGroups(TypedDict): groups: list[PlayerGroups] = []
slot: int
name: str
members: list[int]
groups: list[dict[str, int | list[PlayerGroups]]] = []
"""The Slot ID of groups and the IDs of the group's members.""" """The Slot ID of groups and the IDs of the group's members."""
for team, players in tracker_data.get_all_slots().items(): for team, players in tracker_data.get_all_slots().items():
groups_in_team: list[PlayerGroups] = []
team_groups = {"team": team, "groups": groups_in_team}
groups.append(team_groups)
for player in players: for player in players:
slot_info = tracker_data.get_slot_info(team, player) slot_info = tracker_data.get_slot_info(player)
if slot_info.type != SlotType.group or not slot_info.group_members: if slot_info.type != SlotType.group or not slot_info.group_members:
continue continue
groups_in_team.append( groups.append(
{ {
"slot": player, "slot": player,
"name": slot_info.name, "name": slot_info.name,
"members": list(slot_info.group_members), "members": list(slot_info.group_members),
}) })
class PlayerName(TypedDict): break
player: int
name: str
player_names: list[dict[str, str | list[PlayerName]]] = []
"""Slot names of all players."""
for team, players in all_players.items():
per_team_player_names: list[PlayerName] = []
team_names = {"team": team, "players": per_team_player_names}
player_names.append(team_names)
for player in players:
per_team_player_names.append({"player": player, "name": tracker_data.get_player_name(team, player)})
class PlayerGame(TypedDict):
player: int
game: str
games: list[dict[str, int | list[PlayerGame]]] = []
"""The game each player is playing."""
for team, players in all_players.items():
player_games: list[PlayerGame] = []
team_games = {"team": team, "players": player_games}
games.append(team_games)
for player in players:
player_games.append({"player": player, "game": tracker_data.get_player_game(team, player)})
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
slot_data: list[dict[str, int | list[PlayerSlotData]]] = []
"""Slot data for each player."""
for team, players in all_players.items():
player_slot_data: list[PlayerSlotData] = []
team_slot_data = {"team": team, "players": player_slot_data}
slot_data.append(team_slot_data)
for player in players:
player_slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(team, player)})
return { return {
"groups": groups, "groups": groups,
"slot_data": slot_data, "datapackage": tracker_data._multidata["datapackage"],
} }
# It should be exceedingly rare that slot data is needed, so it's separated out.
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
"""
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Slot data for all players in the room. Typing completely arbitrary per game.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
slot_data: list[PlayerSlotData] = []
"""Slot data for each player."""
for team, players in all_players.items():
for player in players:
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
break
return slot_data

View File

@@ -17,7 +17,6 @@ from .models import GameDataPackage, Room
# Multisave is currently updated, at most, every minute. # Multisave is currently updated, at most, every minute.
TRACKER_CACHE_TIMEOUT_IN_SECONDS = 60 TRACKER_CACHE_TIMEOUT_IN_SECONDS = 60
_multidata_cache = {}
_multiworld_trackers: Dict[str, Callable] = {} _multiworld_trackers: Dict[str, Callable] = {}
_player_trackers: Dict[str, Callable] = {} _player_trackers: Dict[str, Callable] = {}

View File

@@ -18,6 +18,8 @@ Current endpoints:
- [`/room_status/<suuid:room_id>`](#roomstatus) - [`/room_status/<suuid:room_id>`](#roomstatus)
- Tracker API - Tracker API
- [`/tracker/<suuid:tracker>`](#tracker) - [`/tracker/<suuid:tracker>`](#tracker)
- [`/static_tracker/<suuid:tracker>`](#statictracker)
- [`/slot_data_tracker/<suuid:tracker>`](#slotdatatracker)
- User API - User API
- [`/get_rooms`](#getrooms) - [`/get_rooms`](#getrooms)
- [`/get_seeds`](#getseeds) - [`/get_seeds`](#getseeds)
@@ -254,8 +256,6 @@ can either be viewed while on a room tracker page, or from the [room's endpoint]
<a name=tracker></a> <a name=tracker></a>
Will provide a dict of tracker data with the following keys: Will provide a dict of tracker data with the following keys:
- item_link groups and their players (`groups`)
- Each player's slot_data (`slot_data`)
- Each player's current alias (`aliases`) - Each player's current alias (`aliases`)
- Will return the name if there is none - Will return the name if there is none
- A list of items each player has received as a NetworkItem (`player_items_received`) - A list of items each player has received as a NetworkItem (`player_items_received`)
@@ -265,111 +265,55 @@ Will provide a dict of tracker data with the following keys:
- The time of last activity of each player in RFC 1123 format (`activity_timers`) - The time of last activity of each player in RFC 1123 format (`activity_timers`)
- The time of last active connection of each player in RFC 1123 format (`connection_timers`) - The time of last active connection of each player in RFC 1123 format (`connection_timers`)
- The current client status of each player (`player_status`) - The current client status of each player (`player_status`)
- The datapackage hash for each player (`datapackage`)
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
Example: Example:
```json ```json
{ {
"groups": [
{
"team": 0,
"groups": [
{
"slot": 5,
"name": "testGroup",
"members": [
1,
2
]
},
{
"slot": 6,
"name": "myCoolLink",
"members": [
3,
4
]
}
]
}
],
"slot_data": [
{
"team": 0,
"players": [
{
"player": 1,
"slot_data": {
"example_option": 1,
"other_option": 3
}
},
{
"player": 2,
"slot_data": {
"example_option": 1,
"other_option": 2
}
}
]
}
],
"aliases": [ "aliases": [
{ {
"team": 0, "team": 0,
"players": [ "player": 1,
{ "alias": "Incompetence"
"player": 1, },
"alias": "Incompetence" {
}, "team": 0,
{ "player": 2,
"player": 2, "alias": "Slot_Name_2"
"alias": "Slot_Name_2"
}
]
} }
], ],
"player_items_received": [ "player_items_received": [
{ {
"team": 0, "team": 0,
"players": [ "player": 1,
{ "items": [
"player": 1, [1, 1, 1, 0],
"items": [ [2, 2, 2, 1]
[1, 1, 1, 0], ]
[2, 2, 2, 1] },
] {
}, "team": 0,
{ "player": 2,
"player": 2, "items": [
"items": [ [1, 1, 1, 2],
[1, 1, 1, 2], [2, 2, 2, 0]
[2, 2, 2, 0]
]
}
] ]
} }
], ],
"player_checks_done": [ "player_checks_done": [
{ {
"team": 0, "team": 0,
"players": [ "player": 1,
{ "locations": [
"player": 1, 1,
"locations": [ 2
1, ]
2 },
] {
}, "team": 0,
{ "player": 2,
"player": 2, "locations": [
"locations": [ 1,
1, 2
2
]
}
] ]
} }
], ],
@@ -382,78 +326,120 @@ Example:
"hints": [ "hints": [
{ {
"team": 0, "team": 0,
"players": [ "player": 1,
{ "hints": [
"player": 1, [1, 2, 4, 6, 0, "", 4, 0]
"hints": [
[1, 2, 4, 6, 0, "", 4, 0]
]
},
{
"player": 2,
"hints": []
}
] ]
},
{
"team": 0,
"player": 2,
"hints": []
} }
], ],
"activity_timers": [ "activity_timers": [
{ {
"team": 0, "team": 0,
"players": [ "player": 1,
{ "time": "Fri, 18 Apr 2025 20:35:45 GMT"
"player": 1, },
"time": "Fri, 18 Apr 2025 20:35:45 GMT" {
}, "team": 0,
{ "player": 2,
"player": 2, "time": "Fri, 18 Apr 2025 20:42:46 GMT"
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
}
]
} }
], ],
"connection_timers": [ "connection_timers": [
{ {
"team": 0, "team": 0,
"players": [ "player": 1,
{ "time": "Fri, 18 Apr 2025 20:38:25 GMT"
"player": 1, },
"time": "Fri, 18 Apr 2025 20:38:25 GMT" {
}, "team": 0,
{ "player": 2,
"player": 2, "time": "Fri, 18 Apr 2025 21:03:00 GMT"
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
}
]
} }
], ],
"player_status": [ "player_status": [
{ {
"team": 0, "team": 0,
"players": [ "player": 1,
{ "status": 0
"player": 1, },
"status": 0 {
}, "team": 0,
{ "player": 2,
"player": 2, "status": 0
"status": 0 }
} ]
}
```
### `/static_tracker/<suuid:tracker>`
<a name=statictracker></a>
Will provide a dict of static tracker data with the following keys:
- item_link groups and their players (`groups`)
- The datapackage hash for each game (`datapackage`)
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
Example:
```json
{
"groups": [
{
"slot": 5,
"name": "testGroup",
"members": [
1,
2
]
},
{
"slot": 6,
"name": "myCoolLink",
"members": [
3,
4
] ]
} }
], ],
"datapackage": { "datapackage": {
"Archipelago": { "Archipelago": {
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb", "checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
"version": 0
}, },
"The Messenger": { "The Messenger": {
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b", "checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
"version": 0
} }
} }
} }
``` ```
### `/slot_data_tracker/<suuid:tracker>`
<a name=slotdatatracker></a>
Will provide a list of each player's slot_data.
Example:
```json
[
{
"player": 1,
"slot_data": {
"example_option": 1,
"other_option": 3
}
},
{
"player": 2,
"slot_data": {
"example_option": 1,
"other_option": 2
}
}
]
```
## User Endpoints ## User Endpoints
User endpoints can get room and seed details from the current session tokens (cookies) User endpoints can get room and seed details from the current session tokens (cookies)
@@ -554,4 +540,4 @@ Example:
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb" "seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
} }
] ]
``` ```

View File

@@ -93,3 +93,13 @@ class TestTracker(TestBase):
headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_tracker_api(self) -> None:
"""Verify that tracker api gives a reply for the room."""
with self.app.test_request_context():
with self.client.open(url_for("api.tracker_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)
with self.client.open(url_for("api.static_tracker_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)
with self.client.open(url_for("api.tracker_slot_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)