Skip to content

Commit 9dc8ca0

Browse files
authored
Reconcile legacy avatars.selected on avatar update events (#1098)
1 parent ac6d11d commit 9dc8ca0

2 files changed

Lines changed: 70 additions & 6 deletions

File tree

server/player_service.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import TYPE_CHECKING, ClassVar, Optional, ValuesView
99

1010
import aiocron
11-
from sqlalchemy import and_, or_, select
11+
from sqlalchemy import and_, func, or_, select
1212

1313
import server.metrics as metrics
1414
from server.config import config
@@ -178,6 +178,28 @@ async def _fetch_player_avatar(
178178
player.avatar = None
179179
return None
180180

181+
async def _sync_legacy_avatar_selection(self, player_id: int, conn) -> None:
182+
"""
183+
Reconcile the deprecated `avatars.selected` flag with the authoritative
184+
`login.avatar_id`: mark only the granted row matching `avatar_id` as
185+
selected (all of them deselected when `avatar_id` is null). This keeps the
186+
legacy fallback consistent so an explicit clear is not resurrected.
187+
"""
188+
authoritative_avatar_id = (
189+
select(login.c.avatar_id)
190+
.where(login.c.id == player_id)
191+
.scalar_subquery()
192+
)
193+
await conn.execute(
194+
avatars.update()
195+
.where(avatars.c.idUser == player_id)
196+
.values(
197+
selected=func.coalesce(
198+
avatars.c.idAvatar == authoritative_avatar_id, False
199+
)
200+
)
201+
)
202+
181203
async def refresh_player_avatar(self, player_id: int) -> bool:
182204
"""
183205
Re-read avatar for one player and mark them dirty.
@@ -189,6 +211,12 @@ async def refresh_player_avatar(self, player_id: int) -> bool:
189211
if player is None:
190212
return False
191213
async with self._db.acquire() as conn:
214+
# An avatar-update event means the API (the authoritative writer of
215+
# `login.avatar_id`) changed this player's selection, so reconcile the
216+
# deprecated `avatars.selected` flag before reading. Otherwise a cleared
217+
# avatar (`avatar_id` set to null) would be undone by the legacy
218+
# fallback in `_avatar_grant_join_onclause`.
219+
await self._sync_legacy_avatar_selection(player_id, conn)
192220
avatar_id = await self._fetch_player_avatar(player, conn)
193221
avatar_tooltip = player.avatar["tooltip"] if player.avatar else None
194222
self._logger.info(

tests/unit_tests/test_player_service.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from unittest import mock
22

3+
from sqlalchemy import select
4+
5+
from server.db.models import avatars, login
36
from server.rating import RatingType
47

58

@@ -67,18 +70,51 @@ async def test_fetch_player_data_non_existent(player_factory, player_service):
6770
async def test_refresh_player_avatar_connected(
6871
player_factory, player_service
6972
):
70-
player = player_factory(player_id=50)
73+
# Player 51 owns avatars 1 (QAI) and 2 (UEF); make 1 the authoritative
74+
# selection via login.avatar_id while the legacy flag still points at 2.
75+
player = player_factory(player_id=51)
7176
player.avatar = None # simulate stale (e.g. just connected)
72-
player_service[50] = player
77+
player_service[51] = player
78+
async with player_service._db.acquire() as conn:
79+
await conn.execute(login.update().where(login.c.id == 51).values(avatar_id=1))
7380

74-
refreshed = await player_service.refresh_player_avatar(50)
81+
refreshed = await player_service.refresh_player_avatar(51)
7582

7683
assert refreshed is True
7784
assert player.avatar == {
78-
"url": "https://content.faforever.com/faf/avatars/UEF.png",
79-
"tooltip": "UEF",
85+
"url": "https://content.faforever.com/faf/avatars/qai2.png",
86+
"tooltip": "QAI",
8087
}
8188
assert player in player_service._dirty_players
89+
# the legacy `selected` flag is reconciled to the authoritative avatar
90+
async with player_service._db.acquire() as conn:
91+
result = await conn.execute(
92+
select(avatars.c.idAvatar, avatars.c.selected).where(avatars.c.idUser == 51)
93+
)
94+
selected = {row.idAvatar: bool(row.selected) for row in result}
95+
assert selected == {1: True, 2: False}
96+
97+
98+
async def test_refresh_player_avatar_clears_legacy_fallback(
99+
player_factory, player_service
100+
):
101+
# Player 50 has a legacy selected avatar but no authoritative login.avatar_id,
102+
# which represents an explicit clear via the API. The refresh must not let the
103+
# legacy fallback resurrect it, and must clean the flag up.
104+
player = player_factory(player_id=50)
105+
player_service[50] = player
106+
107+
refreshed = await player_service.refresh_player_avatar(50)
108+
109+
assert refreshed is True
110+
assert player.avatar is None
111+
async with player_service._db.acquire() as conn:
112+
result = await conn.execute(
113+
select(avatars.c.selected).where(avatars.c.idUser == 50)
114+
)
115+
selected = [bool(row.selected) for row in result]
116+
assert selected
117+
assert all(not is_selected for is_selected in selected)
82118

83119

84120
async def test_refresh_player_avatar_not_connected(player_service):

0 commit comments

Comments
 (0)