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",
+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 { useGameStore } from "@/lib/game-store";
import { useSoundStore } from "@/lib/sound-store";
import { Avatar } from "@/components/online/Avatar";
import { legalMoves } from "@/lib/hokm/engine";
import { sortHand } from "@/lib/hokm/deck";
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}
>
{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 && (
<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 { teamOf, type Seat } from "@/lib/hokm/types";
import { titleById } from "@/lib/online/gamification";
import { Avatar } from "./Avatar";
import { cn } from "@/lib/cn";
// 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)" }}
>
{sp ? (
sp.avatar
sp.avatarId || sp.avatarImage
? <Avatar id={sp.avatarId ?? "a-fox"} image={sp.avatarImage} size={48} />
: sp.avatar
) : (
<motion.span
className="text-cream/30 text-xl"
+7 -2
View File
@@ -2,6 +2,7 @@
import { Check, UserPlus } from "lucide-react";
import { useState } from "react";
import { Avatar } from "./Avatar";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
@@ -47,8 +48,12 @@ export function MatchPlayersList() {
(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">
{p.avatar}
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-navy-900 text-lg">
{p.avatarId || p.avatarImage ? (
<Avatar id={p.avatarId ?? "a-fox"} image={p.avatarImage} size={30} />
) : (
p.avatar
)}
</span>
<span className="w-full truncate text-[11px] font-semibold text-cream">{p.name}</span>
<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 { pushNotification } from "@/lib/notification-store";
import { ownedReactions } from "@/lib/online/gamification";
import { avatarEmoji } from "@/lib/online/types";
import { Avatar } from "@/components/online/Avatar";
import { cn } from "@/lib/cn";
export function ChatScreen() {
@@ -126,7 +126,7 @@ export function ChatScreen() {
onClick={() => viewProfile(friend.id)}
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="text-sm font-bold text-cream truncate">{friend.displayName}</div>
<div className={cn("text-[11px]", peerTyping ? "text-gold-300" : "text-teal-300")}>
+4 -5
View File
@@ -26,7 +26,6 @@ import {
Friend,
PlayerSummary,
PresenceStatus,
avatarEmoji,
} from "@/lib/online/types";
import { GENDER_META } from "@/lib/social";
import { titleById } from "@/lib/online/gamification";
@@ -130,8 +129,8 @@ function FriendsTab() {
<div className="grid gap-2 lg:grid-cols-2">
{requests.map((r) => (
<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">
{avatarEmoji(r.from.avatar)}
<button onClick={() => viewProfile(r.from.id)} className="active:scale-95 transition">
<Avatar id={r.from.avatar} image={r.from.avatarImage} size={34} />
</button>
<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">
@@ -156,7 +155,7 @@ function FriendsTab() {
<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">
<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])} />
</div>
<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">
<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])} />
</div>
<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 { useI18n } from "@/lib/i18n";
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";
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"
>
<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>
<span className="flex-1 min-w-0">
@@ -263,7 +264,7 @@ function SeatCard({
<span className="text-[10px] text-cream/50">{label}</span>
{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">
{seat.player?.displayName}
{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 {
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;
id?: string; // real player's user id (for add-friend); absent for bots/you
isBot?: boolean;
@@ -54,7 +56,7 @@ export interface GameSettings {
}
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;
stake: number;
ranked: boolean;
@@ -338,13 +340,18 @@ export const useGameStore = create<GameStore>((set, get) => {
turnDeadline: null,
disconnectedSeat: null,
reconnectDeadline: null,
seatPlayers: settings.names.map((name, i) => ({
name,
avatar: AI_AVATARS[i],
level: 0,
isBot: i > 0, // seat 0 is you
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
})),
seatPlayers: settings.names.map((name, i) => {
const prof = i === 0 ? useSessionStore.getState().profile : null;
return {
name,
avatar: AI_AVATARS[i],
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();
},
@@ -368,12 +375,17 @@ export const useGameStore = create<GameStore>((set, get) => {
turnDeadline: null,
disconnectedSeat: null,
reconnectDeadline: null,
seatPlayers: cfg.players.map((p, i) => ({
name: p.displayName,
avatar: avatarEmoji(p.avatar),
level: p.level,
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
})),
seatPlayers: cfg.players.map((p, i) => {
const prof = i === 0 ? useSessionStore.getState().profile : null;
return {
name: p.displayName,
avatar: avatarEmoji(p.avatar),
avatarId: p.avatar,
avatarImage: p.avatarImage ?? prof?.avatarImage ?? null,
level: p.level,
title: i === 0 ? prof?.title ?? null : null,
};
}),
});
scheduleAuto();
},
@@ -416,6 +428,8 @@ export const useGameStore = create<GameStore>((set, get) => {
.map((sp) => ({
name: sp.name,
avatar: avatarEmoji(sp.avatar),
avatarId: sp.avatar,
avatarImage: sp.avatarImage ?? null,
level: sp.level,
id: sp.userId,
isBot: sp.isBot,
+3 -3
View File
@@ -45,7 +45,7 @@ interface ServerRoom {
targetScore: number;
stake: number;
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 = {
@@ -318,7 +318,7 @@ export class SignalrService implements OnlineService {
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
this.emitMM("searching");
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();
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
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();
}
async setPartner(_roomId: string, friendId: string | null) {
+4
View File
@@ -315,6 +315,7 @@ export interface Friend {
username: string;
displayName: string;
avatar: string;
avatarImage?: string; // uploaded photo (overrides the emoji avatar)
level: number;
rating: number;
status: PresenceStatus;
@@ -340,6 +341,7 @@ export interface RoomSeat {
id: string;
displayName: string;
avatar: string;
avatarImage?: string;
level: number;
};
}
@@ -381,6 +383,7 @@ export interface MatchmakingState {
id: string;
displayName: string;
avatar: string;
avatarImage?: string;
level: number;
rating: number;
}[];
@@ -565,6 +568,7 @@ export interface ServerSeatPlayer {
seat: number;
name: string;
avatar: string;
avatarImage?: string | null;
level: number;
connected: boolean;
isBot: boolean;