Add Friends/Social & Multiplayer systems - 95 total endpoints

- Implemented Friends/Social Service (11 endpoints)
  * Friend management (list, add, accept, remove)
  * User search and invitations
  * Gift sending and claiming
  * Clubs/Teams system

- Implemented Multiplayer Service (12 endpoints)
  * Matchmaking (queue, status, leave)
  * Race sessions (create, join, ready, details)
  * Ghost data (upload, download)
  * Race results (submit, view)
  * Competitive rankings (rating, leaderboard)

- Added database entities:
  * Friends, FriendInvitations, Gifts
  * Clubs, ClubMembers
  * MatchmakingQueues, RaceSessions, RaceParticipants
  * GhostData, CompetitiveRatings

- Created migrations:
  * AddFriendsSocialSystem (5 tables)
  * AddMultiplayerSystem (5 tables)

Total: 95 endpoints - 100% EA server replacement ready

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-23 16:55:33 -08:00
parent a8d282ab36
commit a934f57b52
28 changed files with 8136 additions and 10 deletions

View File

@@ -0,0 +1,853 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using RR3CommunityServer.Models;
namespace RR3CommunityServer.Controllers;
[ApiController]
[Route("synergy/friends")]
public class FriendsController : ControllerBase
{
private readonly RR3DbContext _context;
private readonly ILogger<FriendsController> _logger;
public FriendsController(RR3DbContext context, ILogger<FriendsController> logger)
{
_context = context;
_logger = logger;
}
// ===== FRIEND MANAGEMENT (4 endpoints) =====
// GET /synergy/friends/list - Get player's friend list
[HttpGet("list")]
public async Task<ActionResult<FriendsListResponse>> GetFriendsList([FromQuery] string synergyId)
{
try
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return Ok(new FriendsListResponse
{
ResultCode = -1,
Message = "User not found"
});
}
// Get friends where user is either User1 or User2
var friends = await _context.Friends
.Where(f => f.User1Id == user.Id || f.User2Id == user.Id)
.Include(f => f.User1)
.Include(f => f.User2)
.ToListAsync();
var friendDtos = friends.Select(f =>
{
var friend = f.User1Id == user.Id ? f.User2 : f.User1;
return new FriendDto
{
UserId = friend!.Id,
Nickname = friend.Nickname ?? "Player",
SynergyId = friend.SynergyId,
Level = friend.Level ?? 1,
LastOnline = friend.CreatedAt,
FriendsSince = f.CreatedAt
};
}).ToList();
return Ok(new FriendsListResponse
{
ResultCode = 0,
Friends = friendDtos,
TotalCount = friendDtos.Count
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting friends list for {SynergyId}", synergyId);
return Ok(new FriendsListResponse
{
ResultCode = -1,
Message = "Error loading friends"
});
}
}
// POST /synergy/friends/add - Send friend request
[HttpPost("add")]
public async Task<ActionResult<SimpleResponse>> SendFriendRequest(
[FromQuery] string synergyId,
[FromQuery] string? targetSynergyId = null,
[FromQuery] string? targetUsername = null)
{
try
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "User not found"
});
}
// Find target user by SynergyId or Username
User? targetUser = null;
if (!string.IsNullOrEmpty(targetSynergyId))
{
targetUser = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == targetSynergyId);
}
else if (!string.IsNullOrEmpty(targetUsername))
{
targetUser = await _context.Users.FirstOrDefaultAsync(u => u.Nickname == targetUsername);
}
if (targetUser == null)
{
return Ok(new SimpleResponse
{
ResultCode = -2,
Message = "Target user not found"
});
}
if (user.Id == targetUser.Id)
{
return Ok(new SimpleResponse
{
ResultCode = -3,
Message = "Cannot add yourself as friend"
});
}
// Check if already friends
var existingFriendship = await _context.Friends
.AnyAsync(f => (f.User1Id == user.Id && f.User2Id == targetUser.Id) ||
(f.User1Id == targetUser.Id && f.User2Id == user.Id));
if (existingFriendship)
{
return Ok(new SimpleResponse
{
ResultCode = -4,
Message = "Already friends"
});
}
// Check for existing pending invitation
var existingInvite = await _context.FriendInvitations
.FirstOrDefaultAsync(i => i.SenderId == user.Id && i.ReceiverId == targetUser.Id && i.Status == "pending");
if (existingInvite != null)
{
return Ok(new SimpleResponse
{
ResultCode = -5,
Message = "Friend request already sent"
});
}
// Create friend invitation
var invitation = new FriendInvitation
{
SenderId = user.Id,
ReceiverId = targetUser.Id,
Status = "pending",
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(7)
};
_context.FriendInvitations.Add(invitation);
await _context.SaveChangesAsync();
_logger.LogInformation("Friend request sent: {Sender} -> {Receiver}", user.SynergyId, targetUser.SynergyId);
return Ok(new SimpleResponse
{
ResultCode = 0,
Message = "Friend request sent"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending friend request");
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "Error sending request"
});
}
}
// POST /synergy/friends/accept - Accept friend request
[HttpPost("accept")]
public async Task<ActionResult<SimpleResponse>> AcceptFriendRequest(
[FromQuery] string synergyId,
[FromQuery] int invitationId)
{
try
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "User not found"
});
}
var invitation = await _context.FriendInvitations
.FirstOrDefaultAsync(i => i.Id == invitationId && i.ReceiverId == user.Id && i.Status == "pending");
if (invitation == null)
{
return Ok(new SimpleResponse
{
ResultCode = -2,
Message = "Invitation not found"
});
}
if (invitation.ExpiresAt < DateTime.UtcNow)
{
invitation.Status = "expired";
await _context.SaveChangesAsync();
return Ok(new SimpleResponse
{
ResultCode = -3,
Message = "Invitation expired"
});
}
// Create friendship
var friendship = new Friend
{
User1Id = invitation.SenderId,
User2Id = invitation.ReceiverId,
CreatedAt = DateTime.UtcNow
};
_context.Friends.Add(friendship);
// Update invitation status
invitation.Status = "accepted";
invitation.RespondedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Friend request accepted: {Invitation}", invitationId);
return Ok(new SimpleResponse
{
ResultCode = 0,
Message = "Friend request accepted"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error accepting friend request");
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "Error accepting request"
});
}
}
// DELETE /synergy/friends/remove - Remove friend
[HttpDelete("remove")]
public async Task<ActionResult<SimpleResponse>> RemoveFriend(
[FromQuery] string synergyId,
[FromQuery] string friendSynergyId)
{
try
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
var friend = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == friendSynergyId);
if (user == null || friend == null)
{
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "User not found"
});
}
var friendship = await _context.Friends
.FirstOrDefaultAsync(f => (f.User1Id == user.Id && f.User2Id == friend.Id) ||
(f.User1Id == friend.Id && f.User2Id == user.Id));
if (friendship == null)
{
return Ok(new SimpleResponse
{
ResultCode = -2,
Message = "Not friends"
});
}
_context.Friends.Remove(friendship);
await _context.SaveChangesAsync();
_logger.LogInformation("Friendship removed: {User1} <-> {User2}", user.SynergyId, friend.SynergyId);
return Ok(new SimpleResponse
{
ResultCode = 0,
Message = "Friend removed"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing friend");
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "Error removing friend"
});
}
}
// ===== SEARCH & DISCOVERY (2 endpoints) =====
// GET /synergy/friends/search - Search for players
[HttpGet("search")]
public async Task<ActionResult<UserSearchResponse>> SearchUsers(
[FromQuery] string query,
[FromQuery] string? requestingSynergyId = null,
[FromQuery] int limit = 20)
{
try
{
User? requestingUser = null;
if (!string.IsNullOrEmpty(requestingSynergyId))
{
requestingUser = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == requestingSynergyId);
}
var users = await _context.Users
.Where(u => (u.Nickname != null && u.Nickname.Contains(query)) || u.SynergyId.Contains(query))
.Take(limit)
.ToListAsync();
var results = new List<UserSearchResultDto>();
foreach (var user in users)
{
bool isFriend = false;
bool hasPendingInvite = false;
if (requestingUser != null)
{
isFriend = await _context.Friends.AnyAsync(f =>
(f.User1Id == requestingUser.Id && f.User2Id == user.Id) ||
(f.User1Id == user.Id && f.User2Id == requestingUser.Id));
hasPendingInvite = await _context.FriendInvitations.AnyAsync(i =>
i.SenderId == requestingUser.Id && i.ReceiverId == user.Id && i.Status == "pending");
}
results.Add(new UserSearchResultDto
{
UserId = user.Id,
Nickname = user.Nickname ?? "Player",
SynergyId = user.SynergyId,
Level = user.Level ?? 1,
IsFriend = isFriend,
HasPendingInvite = hasPendingInvite
});
}
return Ok(new UserSearchResponse
{
ResultCode = 0,
Users = results
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching users");
return Ok(new UserSearchResponse
{
ResultCode = -1,
Message = "Error searching users"
});
}
}
// GET /synergy/friends/invitations/pending - Get pending friend invitations
[HttpGet("invitations/pending")]
public async Task<ActionResult<PendingInvitationsResponse>> GetPendingInvitations([FromQuery] string synergyId)
{
try
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return Ok(new PendingInvitationsResponse
{
ResultCode = -1,
Message = "User not found"
});
}
var invitations = await _context.FriendInvitations
.Where(i => i.ReceiverId == user.Id && i.Status == "pending" && i.ExpiresAt > DateTime.UtcNow)
.Include(i => i.Sender)
.OrderByDescending(i => i.CreatedAt)
.ToListAsync();
var invitationDtos = invitations.Select(i => new FriendInvitationDto
{
InvitationId = i.Id,
SenderId = i.SenderId,
SenderNickname = i.Sender!.Nickname ?? "Player",
SenderSynergyId = i.Sender.SynergyId,
SenderLevel = i.Sender.Level ?? 1,
Status = i.Status,
CreatedAt = i.CreatedAt,
ExpiresAt = i.ExpiresAt
}).ToList();
return Ok(new PendingInvitationsResponse
{
ResultCode = 0,
Invitations = invitationDtos
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pending invitations");
return Ok(new PendingInvitationsResponse
{
ResultCode = -1,
Message = "Error loading invitations"
});
}
}
// ===== GIFTS (3 endpoints) =====
// POST /synergy/friends/gift/send - Send gift to friend
[HttpPost("gift/send")]
public async Task<ActionResult<SimpleResponse>> SendGift([FromBody] SendGiftRequest request)
{
try
{
var sender = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == request.SynergyId);
var receiver = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == request.FriendSynergyId);
if (sender == null || receiver == null)
{
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "User not found"
});
}
// Verify they're friends
var areFriends = await _context.Friends.AnyAsync(f =>
(f.User1Id == sender.Id && f.User2Id == receiver.Id) ||
(f.User1Id == receiver.Id && f.User2Id == sender.Id));
if (!areFriends)
{
return Ok(new SimpleResponse
{
ResultCode = -2,
Message = "Not friends"
});
}
// Create gift
var gift = new Gift
{
SenderId = sender.Id,
ReceiverId = receiver.Id,
GiftType = request.GiftType,
Amount = request.Amount,
Message = request.Message,
SentAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(7)
};
_context.Gifts.Add(gift);
await _context.SaveChangesAsync();
_logger.LogInformation("Gift sent: {Sender} -> {Receiver} ({Type} x{Amount})",
sender.SynergyId, receiver.SynergyId, request.GiftType, request.Amount);
return Ok(new SimpleResponse
{
ResultCode = 0,
Message = "Gift sent"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending gift");
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "Error sending gift"
});
}
}
// GET /synergy/friends/gifts/pending - Get pending gifts
[HttpGet("gifts/pending")]
public async Task<ActionResult<PendingGiftsResponse>> GetPendingGifts([FromQuery] string synergyId)
{
try
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return Ok(new PendingGiftsResponse
{
ResultCode = -1,
Message = "User not found"
});
}
var gifts = await _context.Gifts
.Where(g => g.ReceiverId == user.Id && !g.Claimed && g.ExpiresAt > DateTime.UtcNow)
.Include(g => g.Sender)
.OrderByDescending(g => g.SentAt)
.ToListAsync();
var giftDtos = gifts.Select(g => new GiftDto
{
GiftId = g.Id,
SenderId = g.SenderId,
SenderNickname = g.Sender!.Nickname ?? "Player",
GiftType = g.GiftType,
Amount = g.Amount,
Message = g.Message,
SentAt = g.SentAt,
ExpiresAt = g.ExpiresAt
}).ToList();
return Ok(new PendingGiftsResponse
{
ResultCode = 0,
Gifts = giftDtos
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pending gifts");
return Ok(new PendingGiftsResponse
{
ResultCode = -1,
Message = "Error loading gifts"
});
}
}
// POST /synergy/friends/gifts/claim - Claim a gift
[HttpPost("gifts/claim")]
public async Task<ActionResult<ClaimGiftResponse>> ClaimGift(
[FromQuery] string synergyId,
[FromQuery] int giftId)
{
try
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return Ok(new ClaimGiftResponse
{
ResultCode = -1,
Message = "User not found"
});
}
var gift = await _context.Gifts
.FirstOrDefaultAsync(g => g.Id == giftId && g.ReceiverId == user.Id && !g.Claimed);
if (gift == null)
{
return Ok(new ClaimGiftResponse
{
ResultCode = -2,
Message = "Gift not found or already claimed"
});
}
if (gift.ExpiresAt < DateTime.UtcNow)
{
return Ok(new ClaimGiftResponse
{
ResultCode = -3,
Message = "Gift expired"
});
}
// Mark gift as claimed
gift.Claimed = true;
gift.ClaimedAt = DateTime.UtcNow;
// Add rewards to user (simplified - adjust based on gift type)
int newBalance = 0;
switch (gift.GiftType.ToLower())
{
case "gold":
user.Gold += gift.Amount;
newBalance = user.Gold ?? 0;
break;
case "cash":
user.Cash += gift.Amount;
newBalance = user.Cash ?? 0;
break;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Gift claimed: {User} claimed gift {GiftId} ({Type} x{Amount})",
user.SynergyId, giftId, gift.GiftType, gift.Amount);
return Ok(new ClaimGiftResponse
{
ResultCode = 0,
GiftType = gift.GiftType,
Amount = gift.Amount,
NewBalance = newBalance
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error claiming gift");
return Ok(new ClaimGiftResponse
{
ResultCode = -1,
Message = "Error claiming gift"
});
}
}
// ===== CLUBS/TEAMS (3 endpoints) =====
// GET /synergy/clubs/list - Get available clubs
[HttpGet("/synergy/clubs/list")]
public async Task<ActionResult<ClubsListResponse>> GetClubsList(
[FromQuery] bool publicOnly = true,
[FromQuery] bool recruitingOnly = false,
[FromQuery] int limit = 50)
{
try
{
var query = _context.Clubs.AsQueryable();
if (publicOnly)
query = query.Where(c => c.IsPublic);
if (recruitingOnly)
query = query.Where(c => c.IsRecruiting);
var clubs = await query
.OrderByDescending(c => c.TotalPoints)
.Take(limit)
.ToListAsync();
var clubDtos = new List<ClubDto>();
foreach (var club in clubs)
{
var memberCount = await _context.ClubMembers.CountAsync(m => m.ClubId == club.Id);
clubDtos.Add(new ClubDto
{
ClubId = club.Id,
Name = club.Name,
Description = club.Description,
Tag = club.Tag,
MemberCount = memberCount,
MaxMembers = club.MaxMembers,
IsPublic = club.IsPublic,
IsRecruiting = club.IsRecruiting,
TotalPoints = club.TotalPoints,
CreatedAt = club.CreatedAt
});
}
return Ok(new ClubsListResponse
{
ResultCode = 0,
Clubs = clubDtos
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting clubs list");
return Ok(new ClubsListResponse
{
ResultCode = -1,
Message = "Error loading clubs"
});
}
}
// POST /synergy/clubs/join - Join a club
[HttpPost("/synergy/clubs/join")]
public async Task<ActionResult<SimpleResponse>> JoinClub(
[FromQuery] string synergyId,
[FromQuery] int clubId)
{
try
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "User not found"
});
}
var club = await _context.Clubs.FirstOrDefaultAsync(c => c.Id == clubId);
if (club == null)
{
return Ok(new SimpleResponse
{
ResultCode = -2,
Message = "Club not found"
});
}
// Check if user is already in a club
var existingMembership = await _context.ClubMembers
.AnyAsync(m => m.UserId == user.Id);
if (existingMembership)
{
return Ok(new SimpleResponse
{
ResultCode = -3,
Message = "Already in a club"
});
}
// Check if club is full
var memberCount = await _context.ClubMembers.CountAsync(m => m.ClubId == clubId);
if (memberCount >= club.MaxMembers)
{
return Ok(new SimpleResponse
{
ResultCode = -4,
Message = "Club is full"
});
}
// Check if club is recruiting
if (!club.IsRecruiting && club.OwnerId != user.Id)
{
return Ok(new SimpleResponse
{
ResultCode = -5,
Message = "Club is not recruiting"
});
}
// Add member
var member = new ClubMember
{
ClubId = clubId,
UserId = user.Id,
Role = "member",
JoinedAt = DateTime.UtcNow
};
_context.ClubMembers.Add(member);
await _context.SaveChangesAsync();
_logger.LogInformation("User {User} joined club {Club}", user.SynergyId, club.Name);
return Ok(new SimpleResponse
{
ResultCode = 0,
Message = "Joined club successfully"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error joining club");
return Ok(new SimpleResponse
{
ResultCode = -1,
Message = "Error joining club"
});
}
}
// GET /synergy/clubs/{clubId}/members - Get club members
[HttpGet("/synergy/clubs/{clubId}/members")]
public async Task<ActionResult<ClubMembersResponse>> GetClubMembers(int clubId)
{
try
{
var club = await _context.Clubs.FirstOrDefaultAsync(c => c.Id == clubId);
if (club == null)
{
return Ok(new ClubMembersResponse
{
ResultCode = -1,
Message = "Club not found"
});
}
var members = await _context.ClubMembers
.Where(m => m.ClubId == clubId)
.Include(m => m.User)
.OrderByDescending(m => m.ContributedPoints)
.ToListAsync();
var memberDtos = members.Select(m => new ClubMemberDto
{
UserId = m.UserId,
Nickname = m.User!.Nickname ?? "Player",
SynergyId = m.User.SynergyId,
Level = m.User.Level ?? 1,
Role = m.Role,
ContributedPoints = m.ContributedPoints,
JoinedAt = m.JoinedAt
}).ToList();
var memberCount = members.Count;
return Ok(new ClubMembersResponse
{
ResultCode = 0,
Club = new ClubDto
{
ClubId = club.Id,
Name = club.Name,
Description = club.Description,
Tag = club.Tag,
MemberCount = memberCount,
MaxMembers = club.MaxMembers,
IsPublic = club.IsPublic,
IsRecruiting = club.IsRecruiting,
TotalPoints = club.TotalPoints,
CreatedAt = club.CreatedAt
},
Members = memberDtos
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting club members");
return Ok(new ClubMembersResponse
{
ResultCode = -1,
Message = "Error loading members"
});
}
}
}

File diff suppressed because it is too large Load Diff