feat(avatars): show the uploaded profile photo everywhere
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m35s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m17s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m12s

Previously the uploaded profile photo only appeared in a few places (profile,
top bar, leaderboard, public profile); chat, friends, game table, match intro,
post-match roster and private rooms showed the emoji avatar only.

- carry avatarImage end-to-end:
  - server DTOs: FriendDto, SeatPlayerDto, RoomPlayerDto, MatchmakeRequest +
    Player/SeatSlot/PSeat; ResolveProfile now returns avatarImage; FriendDtoFor
    fills it from the profile.
  - client types: Friend, RoomSeat.player, MatchmakingState.players,
    ServerSeatPlayer, SeatPlayer (adds avatarId + avatarImage).
  - signalr-service: send my avatarImage on StartMatchmaking/CreatePrivateRoom;
    carry it through mapRoom.
  - game-store: applyServerState + newOnlineMatch + offline match now populate
    avatarId/avatarImage (seat 0 uses your own profile photo).
- render every avatar through the shared <Avatar> component (image → emoji
  fallback): ChatScreen, FriendsScreen (requests/friends/chats), GameTable
  seats, MatchIntroOverlay, MatchPlayersList, RoomScreen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 08:17:27 +03:30
parent e5b48ecb26
commit 4739018488
16 changed files with 85 additions and 47 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ namespace Hokm.Server.Game;
public record CardDto(string Suit, int Rank, string Id);
public record PlayedCardDto(int Seat, CardDto Card);
public record PlayerDto(int Seat, string Name, int Team, bool IsHuman, int HandCount, List<CardDto>? Hand);
public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot, string? UserId);
public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot, string? UserId, string? AvatarImage = null);
public record RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points);
public record GameStateDto(
@@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace Hokm.Server.Game;
// Wire DTOs for private rooms (camelCase JSON → TS client types).
public record RoomPlayerDto(string Id, string DisplayName, string Avatar, int Level);
public record RoomPlayerDto(string Id, string DisplayName, string Avatar, int Level, string? AvatarImage = null);
public record RoomSeatDto(int Seat, string Kind, RoomPlayerDto? Player); // kind: empty|invited|bot|human
public record RoomDto(string Id, string Code, string HostId, string Status, List<RoomSeatDto> Seats, int TargetScore, int Stake, bool Ranked);
public record RoomInviteDto(string RoomId, string Code, string HostName, int Stake);
@@ -26,6 +26,7 @@ public sealed partial class GameManager
public string? UserId;
public string Name = "";
public string Avatar = "a-fox";
public string? AvatarImage;
public int Level;
}
@@ -51,7 +52,7 @@ public sealed partial class GameManager
{
var room = new PRoom { HostId = host.UserId, Stake = stake, TargetScore = target <= 0 ? 7 : target };
for (int i = 0; i < 4; i++) room.Seats[i] = new PSeat { Seat = i };
room.Seats[0] = new PSeat { Seat = 0, Kind = "human", UserId = host.UserId, Name = host.Name, Avatar = host.Avatar, Level = host.Level };
room.Seats[0] = new PSeat { Seat = 0, Kind = "human", UserId = host.UserId, Name = host.Name, Avatar = host.Avatar, AvatarImage = host.AvatarImage, Level = host.Level };
_privateRooms[room.Id] = room;
_userPrivate[host.UserId] = room.Id;
PushRoom(room);
@@ -62,14 +63,14 @@ public sealed partial class GameManager
{
if (seat is < 1 or > 3 || string.IsNullOrEmpty(friendId) || friendId == hostId) return;
// Authoritative friend identity (so the pending seat shows their real name/avatar).
var (name, avatar, level) = ResolveProfile(friendId, "", "a-fox", 1);
var (name, avatar, level, avatarImage) = ResolveProfile(friendId, "", "a-fox", 1);
lock (_proomLock)
{
if (!HostRoom(hostId, out var room)) return;
if (room!.Seats[seat].Kind is not ("empty" or "invited")) return;
if (room.Seats.Any(s => s.Seat != seat && s.UserId == friendId)) return; // already in this room
FreeSeatInvite(room.Seats[seat]); // if re-inviting over a prior invite
room.Seats[seat] = new PSeat { Seat = seat, Kind = "invited", UserId = friendId, Name = name, Avatar = avatar, Level = level };
room.Seats[seat] = new PSeat { Seat = seat, Kind = "invited", UserId = friendId, Name = name, Avatar = avatar, AvatarImage = avatarImage, Level = level };
_pendingInvite[friendId] = room.Id;
_ = _hub.Clients.User(friendId).SendAsync("roomInvite", new RoomInviteDto(room.Id, room.Code, room.Seats[0].Name, room.Stake));
PushRoom(room);
@@ -83,8 +84,8 @@ public sealed partial class GameManager
if (!_pendingInvite.TryRemove(userId, out var roomId) || !_privateRooms.TryGetValue(roomId, out var room)) return;
var seat = room.Seats.FirstOrDefault(x => x.Kind == "invited" && x.UserId == userId);
if (seat == null) return;
var (name, avatar, level) = ResolveProfile(userId, seat.Name, seat.Avatar, seat.Level);
room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat, Kind = "human", UserId = userId, Name = name, Avatar = avatar, Level = level };
var (name, avatar, level, avatarImage) = ResolveProfile(userId, seat.Name, seat.Avatar, seat.Level);
room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat, Kind = "human", UserId = userId, Name = name, Avatar = avatar, AvatarImage = avatarImage, Level = level };
_userPrivate[userId] = room.Id;
PushRoom(room);
}
@@ -139,7 +140,7 @@ public sealed partial class GameManager
{
var s = room.Seats[i];
slots[i] = s.Kind == "human"
? new SeatSlot { Seat = i, UserId = s.UserId, Name = s.Name, Avatar = s.Avatar, Level = s.Level }
? new SeatSlot { Seat = i, UserId = s.UserId, Name = s.Name, Avatar = s.Avatar, AvatarImage = s.AvatarImage, Level = s.Level }
: new SeatSlot { Seat = i, IsBot = true,
Name = s.Kind == "bot" && s.Name.Length > 0 ? s.Name : BotNames[_rng.Next(BotNames.Length)],
Avatar = s.Kind == "bot" ? s.Avatar : Avatars[_rng.Next(Avatars.Length)],
@@ -198,16 +199,16 @@ public sealed partial class GameManager
}
}
private (string name, string avatar, int level) ResolveProfile(string userId, string fbName, string fbAvatar, int fbLevel)
private (string name, string avatar, int level, string? avatarImage) ResolveProfile(string userId, string fbName, string fbAvatar, int fbLevel)
{
try
{
using var scope = _scopes.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
var p = svc.GetOrCreate(userId, null).GetAwaiter().GetResult();
return (p.DisplayName, p.Avatar, p.Level);
return (p.DisplayName, p.Avatar, p.Level, p.AvatarImage);
}
catch { return (fbName, fbAvatar, fbLevel); }
catch { return (fbName, fbAvatar, fbLevel, null); }
}
private void PushRoom(PRoom room)
@@ -225,7 +226,7 @@ public sealed partial class GameManager
private static RoomSeatDto SeatDto(PSeat s) =>
s.Kind == "empty"
? new RoomSeatDto(s.Seat, "empty", null)
: new RoomSeatDto(s.Seat, s.Kind, new RoomPlayerDto(s.UserId ?? $"bot-{s.Seat}", s.Name, s.Avatar, s.Level));
: new RoomSeatDto(s.Seat, s.Kind, new RoomPlayerDto(s.UserId ?? $"bot-{s.Seat}", s.Name, s.Avatar, s.Level, s.Kind == "bot" ? null : s.AvatarImage));
/// <summary>Turn a fixed seat arrangement into a live match (used by private-room start).</summary>
private void StartMatchSeats(SeatSlot[] seats, int stake, int targetScore)
+2 -1
View File
@@ -10,6 +10,7 @@ public sealed class Player
public required string UserId { get; init; }
public string Name { get; init; } = "";
public string Avatar { get; init; } = "a-fox";
public string? AvatarImage { get; init; }
public int Level { get; init; }
public string Plan { get; init; } = "free";
}
@@ -142,7 +143,7 @@ public sealed partial class GameManager
if (i < humans.Count)
{
var h = humans[i];
seats[i] = new SeatSlot { Seat = i, UserId = h.UserId, Name = h.Name, Avatar = h.Avatar, Level = h.Level };
seats[i] = new SeatSlot { Seat = i, UserId = h.UserId, Name = h.Name, Avatar = h.Avatar, AvatarImage = h.AvatarImage, Level = h.Level };
}
else
{
+2 -1
View File
@@ -12,6 +12,7 @@ public sealed class SeatSlot
public string? UserId { get; set; }
public string Name { get; set; } = "";
public string Avatar { get; set; } = "a-fox";
public string? AvatarImage { get; set; }
public int Level { get; set; }
public bool IsBot { get; set; }
public bool Connected { get; set; } = true;
@@ -413,7 +414,7 @@ public sealed class GameRoom : IDisposable
p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList();
var seatPlayers = Seats.OrderBy(s => s.Seat)
.Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot, s.IsBot ? null : s.UserId)).ToList();
.Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot, s.IsBot ? null : s.UserId, s.IsBot ? null : s.AvatarImage)).ToList();
RoundResultDto? rr = State.LastRoundResult is null ? null
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
+3 -1
View File
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.SignalR;
namespace Hokm.Server.Hubs;
public record MatchmakeRequest(string Name, string Avatar, int Level, string Plan);
public record MatchmakeRequest(string Name, string Avatar, int Level, string Plan, string? AvatarImage = null);
[Authorize]
public sealed class GameHub : Hub
@@ -32,6 +32,7 @@ public sealed class GameHub : Hub
UserId = Uid,
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
Avatar = req.Avatar,
AvatarImage = req.AvatarImage,
Level = req.Level,
Plan = req.Plan,
});
@@ -65,6 +66,7 @@ public sealed class GameHub : Hub
UserId = Uid,
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
Avatar = req.Avatar,
AvatarImage = req.AvatarImage,
Level = req.Level,
Plan = req.Plan,
};
@@ -6,6 +6,7 @@ public class FriendDto
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Avatar { get; set; } = "a-fox";
public string? AvatarImage { get; set; }
public int Level { get; set; }
public int Rating { get; set; }
public string Status { get; set; } = "offline"; // online | offline
@@ -60,6 +60,7 @@ public class SocialService
Username = p?.Username ?? userId,
DisplayName = p?.DisplayName ?? userId,
Avatar = p?.Avatar ?? "a-fox",
AvatarImage = p?.AvatarImage,
Level = p?.Level ?? 1,
Rating = p?.Rating ?? 1000,
Status = _mgr.IsOnline(userId) ? "online" : "offline",