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 CardDto(string Suit, int Rank, string Id);
public record PlayedCardDto(int Seat, CardDto Card); 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 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 RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points);
public record GameStateDto( public record GameStateDto(
@@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
namespace Hokm.Server.Game; namespace Hokm.Server.Game;
// Wire DTOs for private rooms (camelCase JSON → TS client types). // 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 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 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); 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? UserId;
public string Name = ""; public string Name = "";
public string Avatar = "a-fox"; public string Avatar = "a-fox";
public string? AvatarImage;
public int Level; 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 }; 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 }; 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; _privateRooms[room.Id] = room;
_userPrivate[host.UserId] = room.Id; _userPrivate[host.UserId] = room.Id;
PushRoom(room); PushRoom(room);
@@ -62,14 +63,14 @@ public sealed partial class GameManager
{ {
if (seat is < 1 or > 3 || string.IsNullOrEmpty(friendId) || friendId == hostId) return; if (seat is < 1 or > 3 || string.IsNullOrEmpty(friendId) || friendId == hostId) return;
// Authoritative friend identity (so the pending seat shows their real name/avatar). // 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) lock (_proomLock)
{ {
if (!HostRoom(hostId, out var room)) return; if (!HostRoom(hostId, out var room)) return;
if (room!.Seats[seat].Kind is not ("empty" or "invited")) 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 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 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; _pendingInvite[friendId] = room.Id;
_ = _hub.Clients.User(friendId).SendAsync("roomInvite", new RoomInviteDto(room.Id, room.Code, room.Seats[0].Name, room.Stake)); _ = _hub.Clients.User(friendId).SendAsync("roomInvite", new RoomInviteDto(room.Id, room.Code, room.Seats[0].Name, room.Stake));
PushRoom(room); 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; 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); var seat = room.Seats.FirstOrDefault(x => x.Kind == "invited" && x.UserId == userId);
if (seat == null) return; if (seat == null) return;
var (name, avatar, level) = ResolveProfile(userId, seat.Name, seat.Avatar, seat.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, Level = 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; _userPrivate[userId] = room.Id;
PushRoom(room); PushRoom(room);
} }
@@ -139,7 +140,7 @@ public sealed partial class GameManager
{ {
var s = room.Seats[i]; var s = room.Seats[i];
slots[i] = s.Kind == "human" 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, : new SeatSlot { Seat = i, IsBot = true,
Name = s.Kind == "bot" && s.Name.Length > 0 ? s.Name : BotNames[_rng.Next(BotNames.Length)], 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)], 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 try
{ {
using var scope = _scopes.CreateScope(); using var scope = _scopes.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>(); var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
var p = svc.GetOrCreate(userId, null).GetAwaiter().GetResult(); 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) private void PushRoom(PRoom room)
@@ -225,7 +226,7 @@ public sealed partial class GameManager
private static RoomSeatDto SeatDto(PSeat s) => private static RoomSeatDto SeatDto(PSeat s) =>
s.Kind == "empty" s.Kind == "empty"
? new RoomSeatDto(s.Seat, "empty", null) ? 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> /// <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) 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 required string UserId { get; init; }
public string Name { get; init; } = ""; public string Name { get; init; } = "";
public string Avatar { get; init; } = "a-fox"; public string Avatar { get; init; } = "a-fox";
public string? AvatarImage { get; init; }
public int Level { get; init; } public int Level { get; init; }
public string Plan { get; init; } = "free"; public string Plan { get; init; } = "free";
} }
@@ -142,7 +143,7 @@ public sealed partial class GameManager
if (i < humans.Count) if (i < humans.Count)
{ {
var h = humans[i]; 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 else
{ {
+2 -1
View File
@@ -12,6 +12,7 @@ public sealed class SeatSlot
public string? UserId { get; set; } public string? UserId { get; set; }
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string Avatar { get; set; } = "a-fox"; public string Avatar { get; set; } = "a-fox";
public string? AvatarImage { get; set; }
public int Level { get; set; } public int Level { get; set; }
public bool IsBot { get; set; } public bool IsBot { get; set; }
public bool Connected { get; set; } = true; 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(); p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList();
var seatPlayers = Seats.OrderBy(s => s.Seat) 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 RoundResultDto? rr = State.LastRoundResult is null ? null
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks, : new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
+3 -1
View File
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.SignalR;
namespace Hokm.Server.Hubs; 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] [Authorize]
public sealed class GameHub : Hub public sealed class GameHub : Hub
@@ -32,6 +32,7 @@ public sealed class GameHub : Hub
UserId = Uid, UserId = Uid,
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name, Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
Avatar = req.Avatar, Avatar = req.Avatar,
AvatarImage = req.AvatarImage,
Level = req.Level, Level = req.Level,
Plan = req.Plan, Plan = req.Plan,
}); });
@@ -65,6 +66,7 @@ public sealed class GameHub : Hub
UserId = Uid, UserId = Uid,
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name, Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
Avatar = req.Avatar, Avatar = req.Avatar,
AvatarImage = req.AvatarImage,
Level = req.Level, Level = req.Level,
Plan = req.Plan, Plan = req.Plan,
}; };
@@ -6,6 +6,7 @@ public class FriendDto
public string Username { get; set; } = ""; public string Username { get; set; } = "";
public string DisplayName { get; set; } = ""; public string DisplayName { get; set; } = "";
public string Avatar { get; set; } = "a-fox"; public string Avatar { get; set; } = "a-fox";
public string? AvatarImage { get; set; }
public int Level { get; set; } public int Level { get; set; }
public int Rating { get; set; } public int Rating { get; set; }
public string Status { get; set; } = "offline"; // online | offline public string Status { get; set; } = "offline"; // online | offline
@@ -60,6 +60,7 @@ public class SocialService
Username = p?.Username ?? userId, Username = p?.Username ?? userId,
DisplayName = p?.DisplayName ?? userId, DisplayName = p?.DisplayName ?? userId,
Avatar = p?.Avatar ?? "a-fox", Avatar = p?.Avatar ?? "a-fox",
AvatarImage = p?.AvatarImage,
Level = p?.Level ?? 1, Level = p?.Level ?? 1,
Rating = p?.Rating ?? 1000, Rating = p?.Rating ?? 1000,
Status = _mgr.IsOnline(userId) ? "online" : "offline", Status = _mgr.IsOnline(userId) ? "online" : "offline",
+6 -1
View File
@@ -5,6 +5,7 @@ import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-re
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useGameStore } from "@/lib/game-store"; import { useGameStore } from "@/lib/game-store";
import { useSoundStore } from "@/lib/sound-store"; import { useSoundStore } from "@/lib/sound-store";
import { Avatar } from "@/components/online/Avatar";
import { legalMoves } from "@/lib/hokm/engine"; import { legalMoves } from "@/lib/hokm/engine";
import { sortHand } from "@/lib/hokm/deck"; import { sortHand } from "@/lib/hokm/deck";
import { import {
@@ -341,7 +342,11 @@ function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
)} )}
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined} style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
> >
{sp?.avatar ?? name.charAt(0)} {sp?.avatarId || sp?.avatarImage ? (
<Avatar id={sp.avatarId ?? "a-fox"} image={sp.avatarImage} size={44} />
) : (
sp?.avatar ?? name.charAt(0)
)}
{isHakem && ( {isHakem && (
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" /> <Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
)} )}
+4 -1
View File
@@ -6,6 +6,7 @@ import { useGameStore } from "@/lib/game-store";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { teamOf, type Seat } from "@/lib/hokm/types"; import { teamOf, type Seat } from "@/lib/hokm/types";
import { titleById } from "@/lib/online/gamification"; import { titleById } from "@/lib/online/gamification";
import { Avatar } from "./Avatar";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
// Where each seat sits around the table + which edge it slides in from. // Where each seat sits around the table + which edge it slides in from.
@@ -39,7 +40,9 @@ function Seat({ seat }: { seat: Seat }) {
style={{ boxShadow: team === 0 ? "0 0 18px rgba(45,212,191,0.4)" : "0 0 18px rgba(251,113,133,0.4)" }} style={{ boxShadow: team === 0 ? "0 0 18px rgba(45,212,191,0.4)" : "0 0 18px rgba(251,113,133,0.4)" }}
> >
{sp ? ( {sp ? (
sp.avatar sp.avatarId || sp.avatarImage
? <Avatar id={sp.avatarId ?? "a-fox"} image={sp.avatarImage} size={48} />
: sp.avatar
) : ( ) : (
<motion.span <motion.span
className="text-cream/30 text-xl" className="text-cream/30 text-xl"
+7 -2
View File
@@ -2,6 +2,7 @@
import { Check, UserPlus } from "lucide-react"; import { Check, UserPlus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Avatar } from "./Avatar";
import { useGameStore } from "@/lib/game-store"; import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store"; import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store"; import { useUIStore } from "@/lib/ui-store";
@@ -47,8 +48,12 @@ export function MatchPlayersList() {
(canAdd ? "active:scale-[0.97] transition" : "cursor-default") (canAdd ? "active:scale-[0.97] transition" : "cursor-default")
} }
> >
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg"> <span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-navy-900 text-lg">
{p.avatar} {p.avatarId || p.avatarImage ? (
<Avatar id={p.avatarId ?? "a-fox"} image={p.avatarImage} size={30} />
) : (
p.avatar
)}
</span> </span>
<span className="w-full truncate text-[11px] font-semibold text-cream">{p.name}</span> <span className="w-full truncate text-[11px] font-semibold text-cream">{p.name}</span>
<span className="text-[9px] text-cream/45 leading-tight"> <span className="text-[9px] text-cream/45 leading-tight">
+2 -2
View File
@@ -10,7 +10,7 @@ import { sound } from "@/lib/sound";
import { getService } from "@/lib/online/service"; import { getService } from "@/lib/online/service";
import { pushNotification } from "@/lib/notification-store"; import { pushNotification } from "@/lib/notification-store";
import { ownedReactions } from "@/lib/online/gamification"; import { ownedReactions } from "@/lib/online/gamification";
import { avatarEmoji } from "@/lib/online/types"; import { Avatar } from "@/components/online/Avatar";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
export function ChatScreen() { export function ChatScreen() {
@@ -126,7 +126,7 @@ export function ChatScreen() {
onClick={() => viewProfile(friend.id)} onClick={() => viewProfile(friend.id)}
className="flex items-center gap-3 min-w-0 flex-1 text-start active:scale-[0.99] transition" className="flex items-center gap-3 min-w-0 flex-1 text-start active:scale-[0.99] transition"
> >
<span className="text-2xl shrink-0">{avatarEmoji(friend.avatar)}</span> <span className="shrink-0"><Avatar id={friend.avatar} image={friend.avatarImage} size={34} /></span>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div> <div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
<div className={cn("text-[11px]", peerTyping ? "text-gold-300" : "text-teal-300")}> <div className={cn("text-[11px]", peerTyping ? "text-gold-300" : "text-teal-300")}>
+4 -5
View File
@@ -26,7 +26,6 @@ import {
Friend, Friend,
PlayerSummary, PlayerSummary,
PresenceStatus, PresenceStatus,
avatarEmoji,
} from "@/lib/online/types"; } from "@/lib/online/types";
import { GENDER_META } from "@/lib/social"; import { GENDER_META } from "@/lib/social";
import { titleById } from "@/lib/online/gamification"; import { titleById } from "@/lib/online/gamification";
@@ -130,8 +129,8 @@ function FriendsTab() {
<div className="grid gap-2 lg:grid-cols-2"> <div className="grid gap-2 lg:grid-cols-2">
{requests.map((r) => ( {requests.map((r) => (
<div key={r.id} className="glass rounded-xl p-2.5 flex items-center gap-3"> <div key={r.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<button onClick={() => viewProfile(r.from.id)} className="text-2xl active:scale-95 transition"> <button onClick={() => viewProfile(r.from.id)} className="active:scale-95 transition">
{avatarEmoji(r.from.avatar)} <Avatar id={r.from.avatar} image={r.from.avatarImage} size={34} />
</button> </button>
<span className="flex-1 text-sm font-semibold text-cream">{r.from.displayName}</span> <span className="flex-1 text-sm font-semibold text-cream">{r.from.displayName}</span>
<button onClick={() => accept(r.id)} className="size-8 rounded-lg bg-teal-600/80 grid place-items-center hover:bg-teal-600"> <button onClick={() => accept(r.id)} className="size-8 rounded-lg bg-teal-600/80 grid place-items-center hover:bg-teal-600">
@@ -156,7 +155,7 @@ function FriendsTab() {
<div key={f.id} className="glass rounded-xl p-2.5 flex items-center gap-3"> <div key={f.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<button onClick={() => viewProfile(f.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition"> <button onClick={() => viewProfile(f.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
<div className="relative shrink-0"> <div className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span> <Avatar id={f.avatar} image={f.avatarImage} size={34} />
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} /> <span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -418,7 +417,7 @@ function MessagesTab() {
> >
<button onClick={() => open(c)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition"> <button onClick={() => open(c)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
<div className="relative shrink-0"> <div className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(c.friend.avatar)}</span> <Avatar id={c.friend.avatar} image={c.friend.avatarImage} size={34} />
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[c.friend.status])} /> <span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[c.friend.status])} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
+4 -3
View File
@@ -9,7 +9,8 @@ import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store"; import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service"; import { getService } from "@/lib/online/service";
import { Friend, PresenceStatus, RoomSeat, avatarEmoji } from "@/lib/online/types"; import { Friend, PresenceStatus, RoomSeat } from "@/lib/online/types";
import { Avatar } from "@/components/online/Avatar";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = { const STATUS_COLOR: Record<PresenceStatus, string> = {
@@ -210,7 +211,7 @@ export function RoomScreen() {
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start" className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
> >
<span className="relative shrink-0"> <span className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span> <Avatar id={f.avatar} image={f.avatarImage} size={34} />
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} /> <span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
</span> </span>
<span className="flex-1 min-w-0"> <span className="flex-1 min-w-0">
@@ -263,7 +264,7 @@ function SeatCard({
<span className="text-[10px] text-cream/50">{label}</span> <span className="text-[10px] text-cream/50">{label}</span>
{filled ? ( {filled ? (
<> <>
<span className="text-3xl">{avatarEmoji(seat.player?.avatar ?? "a-fox")}</span> <Avatar id={seat.player?.avatar ?? "a-fox"} image={seat.player?.avatarImage} size={48} />
<span className="text-sm font-bold text-cream text-center max-w-full truncate"> <span className="text-sm font-bold text-cream text-center max-w-full truncate">
{seat.player?.displayName} {seat.player?.displayName}
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>} {seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
+29 -15
View File
@@ -39,7 +39,9 @@ export type GameMode = "ai" | "online";
export interface SeatPlayer { export interface SeatPlayer {
name: string; name: string;
avatar: string; // emoji avatar: string; // emoji (legacy/fallback)
avatarId?: string; // avatar id (for the shared <Avatar> component)
avatarImage?: string | null; // uploaded profile photo — shown everywhere when present
level: number; level: number;
id?: string; // real player's user id (for add-friend); absent for bots/you id?: string; // real player's user id (for add-friend); absent for bots/you
isBot?: boolean; isBot?: boolean;
@@ -54,7 +56,7 @@ export interface GameSettings {
} }
export interface OnlineMatchConfig { export interface OnlineMatchConfig {
players: { displayName: string; avatar: string; level: number }[]; // index = seat players: { displayName: string; avatar: string; level: number; avatarImage?: string | null }[]; // index = seat
targetScore: number; targetScore: number;
stake: number; stake: number;
ranked: boolean; ranked: boolean;
@@ -338,13 +340,18 @@ export const useGameStore = create<GameStore>((set, get) => {
turnDeadline: null, turnDeadline: null,
disconnectedSeat: null, disconnectedSeat: null,
reconnectDeadline: null, reconnectDeadline: null,
seatPlayers: settings.names.map((name, i) => ({ seatPlayers: settings.names.map((name, i) => {
name, const prof = i === 0 ? useSessionStore.getState().profile : null;
avatar: AI_AVATARS[i], return {
level: 0, name,
isBot: i > 0, // seat 0 is you avatar: AI_AVATARS[i],
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null, avatarId: prof?.avatar, // you → your avatar; bots fall back to the emoji
})), avatarImage: prof?.avatarImage ?? null,
level: 0,
isBot: i > 0, // seat 0 is you
title: i === 0 ? prof?.title ?? null : null,
};
}),
}); });
scheduleAuto(); scheduleAuto();
}, },
@@ -368,12 +375,17 @@ export const useGameStore = create<GameStore>((set, get) => {
turnDeadline: null, turnDeadline: null,
disconnectedSeat: null, disconnectedSeat: null,
reconnectDeadline: null, reconnectDeadline: null,
seatPlayers: cfg.players.map((p, i) => ({ seatPlayers: cfg.players.map((p, i) => {
name: p.displayName, const prof = i === 0 ? useSessionStore.getState().profile : null;
avatar: avatarEmoji(p.avatar), return {
level: p.level, name: p.displayName,
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null, avatar: avatarEmoji(p.avatar),
})), avatarId: p.avatar,
avatarImage: p.avatarImage ?? prof?.avatarImage ?? null,
level: p.level,
title: i === 0 ? prof?.title ?? null : null,
};
}),
}); });
scheduleAuto(); scheduleAuto();
}, },
@@ -416,6 +428,8 @@ export const useGameStore = create<GameStore>((set, get) => {
.map((sp) => ({ .map((sp) => ({
name: sp.name, name: sp.name,
avatar: avatarEmoji(sp.avatar), avatar: avatarEmoji(sp.avatar),
avatarId: sp.avatar,
avatarImage: sp.avatarImage ?? null,
level: sp.level, level: sp.level,
id: sp.userId, id: sp.userId,
isBot: sp.isBot, isBot: sp.isBot,
+3 -3
View File
@@ -45,7 +45,7 @@ interface ServerRoom {
targetScore: number; targetScore: number;
stake: number; stake: number;
ranked: boolean; ranked: boolean;
seats: { seat: number; kind: string; player?: { id: string; displayName: string; avatar: string; level: number } }[]; seats: { seat: number; kind: string; player?: { id: string; displayName: string; avatar: string; avatarImage?: string; level: number } }[];
} }
const EMPTY_ROOM: Room = { const EMPTY_ROOM: Room = {
@@ -318,7 +318,7 @@ export class SignalrService implements OnlineService {
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile())); const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
this.emitMM("searching"); this.emitMM("searching");
await this.conn?.invoke("StartMatchmaking", { await this.conn?.invoke("StartMatchmaking", {
name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, avatarImage: p.avatarImage,
}); });
} }
@@ -497,7 +497,7 @@ export class SignalrService implements OnlineService {
await this.connect(); await this.connect();
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile())); const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
await this.conn?.invoke("CreatePrivateRoom", await this.conn?.invoke("CreatePrivateRoom",
{ name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan }, o.stake, o.targetScore); { name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, avatarImage: p.avatarImage }, o.stake, o.targetScore);
return this.waitRoom(); return this.waitRoom();
} }
async setPartner(_roomId: string, friendId: string | null) { async setPartner(_roomId: string, friendId: string | null) {
+4
View File
@@ -315,6 +315,7 @@ export interface Friend {
username: string; username: string;
displayName: string; displayName: string;
avatar: string; avatar: string;
avatarImage?: string; // uploaded photo (overrides the emoji avatar)
level: number; level: number;
rating: number; rating: number;
status: PresenceStatus; status: PresenceStatus;
@@ -340,6 +341,7 @@ export interface RoomSeat {
id: string; id: string;
displayName: string; displayName: string;
avatar: string; avatar: string;
avatarImage?: string;
level: number; level: number;
}; };
} }
@@ -381,6 +383,7 @@ export interface MatchmakingState {
id: string; id: string;
displayName: string; displayName: string;
avatar: string; avatar: string;
avatarImage?: string;
level: number; level: number;
rating: number; rating: number;
}[]; }[];
@@ -565,6 +568,7 @@ export interface ServerSeatPlayer {
seat: number; seat: number;
name: string; name: string;
avatar: string; avatar: string;
avatarImage?: string | null;
level: number; level: number;
connected: boolean; connected: boolean;
isBot: boolean; isBot: boolean;